@jskit-ai/realtime 0.1.176 → 0.1.178
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 +3 -3
- package/patterns/realtime-application/PATTERN.md +17 -0
- package/patterns/realtime-application/example/package.json +2 -2
- package/src/server/RealtimeProvider.js +25 -9
- package/src/server/realtimeAudience.js +86 -13
- package/src/server/realtimeDelivery.js +114 -18
- package/src/server/runtime.js +14 -0
- package/test/authorization.test.js +144 -0
- package/test/providerRuntime.test.js +82 -2
- package/test/serverRuntime.test.js +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/realtime",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.178",
|
|
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.
|
|
24
|
-
"@jskit-ai/shell-web": "0.1.
|
|
23
|
+
"@jskit-ai/kernel": "0.1.181",
|
|
24
|
+
"@jskit-ai/shell-web": "0.1.185",
|
|
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,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 {
|
|
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,22 +63,26 @@ const RealtimeProvider = defineProvider({
|
|
|
59
63
|
provides: {
|
|
60
64
|
realtime: "runtime.realtime"
|
|
61
65
|
},
|
|
62
|
-
setup({ config,
|
|
66
|
+
setup({ config, env, fastify, logger }) {
|
|
63
67
|
const io = createSocketIoServer({ fastify });
|
|
64
68
|
const providerLogger = createProviderLogger(logger, { debugEnabled: debugEnabled(config, env) });
|
|
65
|
-
const delivery = createRealtimeDelivery({ io, database, logger: providerLogger });
|
|
66
69
|
const realtime = createRealtimeCapability({ io });
|
|
67
|
-
stateByCapability.set(realtime, {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
stateByCapability.set(realtime, {
|
|
71
|
+
authenticationRequired: false,
|
|
72
|
+
delivery: null,
|
|
73
|
+
io,
|
|
74
|
+
providerLogger,
|
|
75
|
+
redisConnection: null
|
|
72
76
|
});
|
|
73
77
|
return { realtime };
|
|
74
78
|
},
|
|
75
|
-
async boot({ authService, env, workspaces }, { outputs }) {
|
|
79
|
+
async boot({ authService, database, env, events, workspaces }, { outputs }) {
|
|
76
80
|
const state = stateByCapability.get(outputs.realtime);
|
|
77
81
|
if (!state) throw new Error("Realtime runtime state is unavailable.");
|
|
82
|
+
state.delivery = createRealtimeDelivery({
|
|
83
|
+
io: state.io, database, logger: state.providerLogger, authService, workspaces
|
|
84
|
+
});
|
|
85
|
+
state.authenticationRequired = realtimeAuthenticationRequired(authService);
|
|
78
86
|
registerSocketAudienceBootstrap({
|
|
79
87
|
io: state.io,
|
|
80
88
|
logger: state.providerLogger,
|
|
@@ -82,13 +90,21 @@ const RealtimeProvider = defineProvider({
|
|
|
82
90
|
workspaces
|
|
83
91
|
});
|
|
84
92
|
state.redisConnection = await configureSocketIoRedisAdapter(state.io, {
|
|
93
|
+
logger: state.providerLogger,
|
|
85
94
|
redisUrl: resolveRealtimeRedisUrl(env),
|
|
86
95
|
redisNamespace: resolveRealtimeRedisNamespace(env)
|
|
87
96
|
});
|
|
97
|
+
state.delivery.start({ redisConfigured: state.redisConnection.enabled });
|
|
98
|
+
events.register({
|
|
99
|
+
id: "runtime.realtime.delivery",
|
|
100
|
+
matches: (event) => Boolean(normalizeText(event?.realtime?.event)),
|
|
101
|
+
handle: state.delivery.handle
|
|
102
|
+
});
|
|
88
103
|
},
|
|
89
104
|
async shutdown(_dependencies, { outputs }) {
|
|
90
105
|
const state = stateByCapability.get(outputs.realtime);
|
|
91
106
|
if (!state) return;
|
|
107
|
+
state.delivery?.stop();
|
|
92
108
|
await closeSocketIoServer(state.io);
|
|
93
109
|
await closeSocketIoRedisConnections(state.redisConnection || {});
|
|
94
110
|
stateByCapability.delete(outputs.realtime);
|
|
@@ -26,6 +26,17 @@ function roomForWorkspaceUser(workspaceId, userId) {
|
|
|
26
26
|
return `workspace:${String(workspaceId || "").trim()}:user:${String(userId || "").trim()}`;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function workspaceAudienceRooms(workspaceIds, actorId) {
|
|
30
|
+
const rooms = [];
|
|
31
|
+
for (const rawId of normalizeArray(workspaceIds)) {
|
|
32
|
+
const workspaceId = normalizeRecordId(rawId, { fallback: null });
|
|
33
|
+
if (workspaceId) {
|
|
34
|
+
rooms.push(roomForWorkspace(workspaceId), roomForWorkspaceUser(workspaceId, actorId));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return rooms;
|
|
38
|
+
}
|
|
39
|
+
|
|
29
40
|
function resolveScopeWorkspaceId(scope = {}) {
|
|
30
41
|
const kind = normalizeText(scope?.kind).toLowerCase();
|
|
31
42
|
if (kind === "workspace") return normalizeRecordId(scope.id, { fallback: null });
|
|
@@ -171,7 +182,7 @@ function parseCookieHeader(value = "") {
|
|
|
171
182
|
return cookies;
|
|
172
183
|
}
|
|
173
184
|
|
|
174
|
-
async function
|
|
185
|
+
async function resolveSocketActor(authService, socket) {
|
|
175
186
|
if (typeof authService?.authenticateRequest !== "function") return null;
|
|
176
187
|
const handshakeHeaders = socket?.handshake?.headers || {};
|
|
177
188
|
const requestHeaders = socket?.request?.headers || {};
|
|
@@ -185,31 +196,65 @@ async function resolveSocketActorId(authService, socket) {
|
|
|
185
196
|
if (host) request.headers = { host };
|
|
186
197
|
if (remoteAddress) request.socket = { remoteAddress };
|
|
187
198
|
const result = await authService.authenticateRequest(request);
|
|
188
|
-
|
|
189
|
-
? normalizeRecordId(result?.actor?.id, { fallback: null })
|
|
190
|
-
|
|
199
|
+
const id = result?.authenticated === true
|
|
200
|
+
? normalizeRecordId(result?.actor?.id, { fallback: null }) : null;
|
|
201
|
+
return id ? { ...result.actor, id } : null;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function realtimeAuthenticationRequired(authService = null) {
|
|
205
|
+
return authService?.realtime?.requireAuthentication === true;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function authenticationRequiredError() {
|
|
209
|
+
const error = new Error("Authentication required.");
|
|
210
|
+
error.data = Object.freeze({ code: "AUTHENTICATION_REQUIRED" });
|
|
211
|
+
return error;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function rememberSocketActorId(socket, actorId) {
|
|
215
|
+
socket.data = socket.data && typeof socket.data === "object" ? socket.data : {};
|
|
216
|
+
socket.data.actorId = actorId;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function registerRequiredSocketAuthentication({ io, logger, authService }) {
|
|
220
|
+
if (!realtimeAuthenticationRequired(authService)) return;
|
|
221
|
+
if (typeof io?.use !== "function") {
|
|
222
|
+
throw new TypeError("Realtime authenticated-client mode requires Socket.IO middleware support.");
|
|
223
|
+
}
|
|
224
|
+
io.use(async (socket, next) => {
|
|
225
|
+
try {
|
|
226
|
+
const actorId = (await resolveSocketActor(authService, socket))?.id;
|
|
227
|
+
if (!actorId) {
|
|
228
|
+
next(authenticationRequiredError());
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
rememberSocketActorId(socket, actorId);
|
|
232
|
+
next();
|
|
233
|
+
} catch (error) {
|
|
234
|
+
logger.warn({ error: String(error?.message || error) }, "Realtime socket authentication failed.");
|
|
235
|
+
next(authenticationRequiredError());
|
|
236
|
+
}
|
|
237
|
+
});
|
|
191
238
|
}
|
|
192
239
|
|
|
193
240
|
function registerSocketAudienceBootstrap({ io, logger, authService = null, workspaces = null }) {
|
|
194
241
|
if (typeof io?.on !== "function") return;
|
|
242
|
+
registerRequiredSocketAuthentication({ io, logger, authService });
|
|
195
243
|
io.on("connection", async (socket) => {
|
|
196
244
|
try {
|
|
197
245
|
socket.join(ALL_CLIENTS_ROOM);
|
|
198
|
-
const actorId =
|
|
246
|
+
const actorId = normalizeRecordId(socket?.data?.actorId, { fallback: null })
|
|
247
|
+
|| (await resolveSocketActor(authService, socket))?.id;
|
|
199
248
|
if (!actorId) return;
|
|
200
|
-
socket
|
|
201
|
-
socket.data.actorId = actorId;
|
|
249
|
+
rememberSocketActorId(socket, actorId);
|
|
202
250
|
socket.join(ALL_USERS_ROOM);
|
|
203
251
|
socket.join(roomForUser(actorId));
|
|
204
252
|
const repository = workspaces?.repositories?.workspaceMemberships;
|
|
205
253
|
const workspaceIds = typeof repository?.listActiveWorkspaceIdsByUserId === "function"
|
|
206
254
|
? await repository.listActiveWorkspaceIdsByUserId(actorId)
|
|
207
255
|
: [];
|
|
208
|
-
for (const
|
|
209
|
-
|
|
210
|
-
if (!workspaceId) continue;
|
|
211
|
-
socket.join(roomForWorkspace(workspaceId));
|
|
212
|
-
socket.join(roomForWorkspaceUser(workspaceId, actorId));
|
|
256
|
+
for (const room of workspaceAudienceRooms(workspaceIds, actorId)) {
|
|
257
|
+
socket.join(room);
|
|
213
258
|
}
|
|
214
259
|
} catch (error) {
|
|
215
260
|
logger.warn({ error: String(error?.message || error) }, "Realtime socket audience bootstrap failed.");
|
|
@@ -217,4 +262,32 @@ function registerSocketAudienceBootstrap({ io, logger, authService = null, works
|
|
|
217
262
|
});
|
|
218
263
|
}
|
|
219
264
|
|
|
220
|
-
|
|
265
|
+
async function revalidateSocket({ socket, authService, workspaces = null }) {
|
|
266
|
+
const actor = await resolveSocketActor(authService, socket);
|
|
267
|
+
const previousActorId = socket.data?.actorId;
|
|
268
|
+
if ((!actor && realtimeAuthenticationRequired(authService)) ||
|
|
269
|
+
(previousActorId && previousActorId !== actor?.id)) {
|
|
270
|
+
socket.disconnect(true);
|
|
271
|
+
return null;
|
|
272
|
+
}
|
|
273
|
+
if (!actor) return null;
|
|
274
|
+
|
|
275
|
+
rememberSocketActorId(socket, actor.id);
|
|
276
|
+
const repository = workspaces?.repositories?.workspaceMemberships;
|
|
277
|
+
if (typeof repository?.listActiveWorkspaceIdsByUserId !== "function") return actor;
|
|
278
|
+
|
|
279
|
+
const workspaceIds = await repository.listActiveWorkspaceIdsByUserId(actor.id);
|
|
280
|
+
const rooms = new Set(workspaceAudienceRooms(workspaceIds, actor.id));
|
|
281
|
+
for (const room of socket.rooms) {
|
|
282
|
+
if (room.startsWith("workspace:") && !rooms.has(room)) await socket.leave(room);
|
|
283
|
+
}
|
|
284
|
+
for (const room of rooms) await socket.join(room);
|
|
285
|
+
return actor;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export {
|
|
289
|
+
realtimeAuthenticationRequired,
|
|
290
|
+
revalidateSocket,
|
|
291
|
+
registerSocketAudienceBootstrap,
|
|
292
|
+
resolveAudienceTargets
|
|
293
|
+
};
|
|
@@ -1,16 +1,25 @@
|
|
|
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
|
|
7
|
+
const { realtime } = event && typeof event === "object" ? event : {};
|
|
8
|
+
const canonical = {};
|
|
9
|
+
for (const key of [
|
|
10
|
+
"type", "source", "entity", "operation", "entityId", "scope", "actorId",
|
|
11
|
+
"commandId", "sourceClientId", "occurredAt"
|
|
12
|
+
]) {
|
|
13
|
+
if (Object.hasOwn(event, key)) canonical[key] = event[key];
|
|
14
|
+
}
|
|
6
15
|
const payload = realtime?.payload;
|
|
7
|
-
if (payload != null && (
|
|
16
|
+
if (payload != null && (typeof payload !== "object" || Array.isArray(payload))) {
|
|
8
17
|
throw new TypeError("Realtime event payload must be an object when provided.");
|
|
9
18
|
}
|
|
10
19
|
return Object.freeze({ ...(payload || {}), ...canonical });
|
|
11
20
|
}
|
|
12
21
|
|
|
13
|
-
function createRealtimeDelivery({ io, database = null, logger }) {
|
|
22
|
+
function createRealtimeDelivery({ io, database = null, logger, authService = null, workspaces = null }) {
|
|
14
23
|
if (!io || typeof io.emit !== "function" || typeof io.to !== "function") {
|
|
15
24
|
throw new TypeError("Realtime delivery requires a Socket.IO server.");
|
|
16
25
|
}
|
|
@@ -18,22 +27,109 @@ function createRealtimeDelivery({ io, database = null, logger }) {
|
|
|
18
27
|
throw new TypeError("Realtime delivery requires a logger.");
|
|
19
28
|
}
|
|
20
29
|
|
|
30
|
+
const guarded = typeof authService?.authenticateRequest === "function";
|
|
31
|
+
if (authService?.realtime?.authorizeEvent != null && typeof authService.realtime.authorizeEvent !== "function") {
|
|
32
|
+
throw new TypeError("Realtime authorizeEvent must be a function.");
|
|
33
|
+
}
|
|
34
|
+
if ((authService?.realtime?.requireAuthentication === true || authService?.realtime?.authorizeEvent) && !guarded) {
|
|
35
|
+
throw new TypeError("Realtime authorization requires auth.service.authenticateRequest().");
|
|
36
|
+
}
|
|
37
|
+
let redisEnabled = false;
|
|
38
|
+
let revalidationTimer = null;
|
|
39
|
+
let stopped = false;
|
|
40
|
+
|
|
41
|
+
async function revalidate(socket) {
|
|
42
|
+
try {
|
|
43
|
+
return await revalidateSocket({ socket, authService, workspaces });
|
|
44
|
+
} catch (error) {
|
|
45
|
+
socket.disconnect(true);
|
|
46
|
+
logger.warn({ error: String(error?.message || error) }, "Realtime socket revalidation failed.");
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function deliverLocal({ eventName, payload, targets }) {
|
|
52
|
+
await Promise.all([...io.sockets.sockets.values()].map(async (socket) => {
|
|
53
|
+
try {
|
|
54
|
+
const actor = await revalidate(socket);
|
|
55
|
+
if (!socket.connected) return;
|
|
56
|
+
if (!targets.broadcastAllClients && !targets.rooms.some((room) => socket.rooms.has(room))) return;
|
|
57
|
+
if (typeof authService.realtime?.authorizeEvent === "function") {
|
|
58
|
+
const authorized = await authService.realtime.authorizeEvent({
|
|
59
|
+
actor,
|
|
60
|
+
event: { name: eventName, payload }
|
|
61
|
+
});
|
|
62
|
+
if (authorized !== true) return;
|
|
63
|
+
}
|
|
64
|
+
if (socket.connected) socket.emit(eventName, payload);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
logger.warn({ socketEvent: eventName, error: String(error?.message || error) }, "Realtime delivery denied after authorization failure.");
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function receive(envelope) {
|
|
72
|
+
try {
|
|
73
|
+
await deliverLocal(envelope);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
logger.warn({ error: String(error?.message || error) }, "Realtime peer delivery failed.");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scheduleRevalidation() {
|
|
80
|
+
if (stopped) return;
|
|
81
|
+
revalidationTimer = setTimeout(async () => {
|
|
82
|
+
await Promise.all([...io.sockets.sockets.values()].map(revalidate));
|
|
83
|
+
scheduleRevalidation();
|
|
84
|
+
}, 30_000);
|
|
85
|
+
revalidationTimer.unref();
|
|
86
|
+
}
|
|
87
|
+
|
|
21
88
|
return Object.freeze({
|
|
89
|
+
start({ redisConfigured = false } = {}) {
|
|
90
|
+
redisEnabled = redisConfigured;
|
|
91
|
+
if (guarded) {
|
|
92
|
+
io.on(DELIVERY_EVENT, receive);
|
|
93
|
+
scheduleRevalidation();
|
|
94
|
+
}
|
|
95
|
+
},
|
|
96
|
+
stop() {
|
|
97
|
+
stopped = true;
|
|
98
|
+
clearTimeout(revalidationTimer);
|
|
99
|
+
if (guarded) io.off(DELIVERY_EVENT, receive);
|
|
100
|
+
},
|
|
22
101
|
async handle(event = {}) {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
102
|
+
try {
|
|
103
|
+
const eventName = normalizeText(event?.realtime?.event);
|
|
104
|
+
if (!eventName) return;
|
|
105
|
+
const targets = await resolveAudienceTargets(event.realtime.audience, event, { database, logger });
|
|
106
|
+
const payload = publicRealtimePayload(event);
|
|
107
|
+
if (guarded) {
|
|
108
|
+
const envelope = { eventName, payload, targets };
|
|
109
|
+
if (redisEnabled) {
|
|
110
|
+
try {
|
|
111
|
+
io.serverSideEmit(DELIVERY_EVENT, envelope);
|
|
112
|
+
} catch (error) {
|
|
113
|
+
logger.warn({ socketEvent: eventName, error: String(error?.message || error) }, "Realtime peer publication failed.");
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
await deliverLocal(envelope);
|
|
117
|
+
} else if (targets.broadcastAllClients) {
|
|
118
|
+
io.emit(eventName, payload);
|
|
119
|
+
} else if (targets.rooms.length) {
|
|
120
|
+
io.to(targets.rooms).emit(eventName, payload);
|
|
121
|
+
}
|
|
122
|
+
logger.debug({
|
|
123
|
+
socketEvent: eventName,
|
|
124
|
+
rooms: targets.rooms,
|
|
125
|
+
broadcastAllClients: targets.broadcastAllClients,
|
|
126
|
+
connectedClients: Number.isInteger(Number(io?.engine?.clientsCount))
|
|
127
|
+
? Number(io.engine.clientsCount)
|
|
128
|
+
: null
|
|
129
|
+
}, "Realtime delivered an action event.");
|
|
130
|
+
} catch (error) {
|
|
131
|
+
logger.warn({ socketEvent: event?.realtime?.event, error: String(error?.message || error) }, "Realtime delivery failed; the domain mutation remains successful.");
|
|
132
|
+
}
|
|
37
133
|
}
|
|
38
134
|
});
|
|
39
135
|
}
|
package/src/server/runtime.js
CHANGED
|
@@ -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
|
+
});
|
|
@@ -8,9 +8,37 @@ import { RealtimeClientProvider } from "../src/client/RealtimeClientProvider.js"
|
|
|
8
8
|
import { RealtimeProvider } from "../src/server/RealtimeProvider.js";
|
|
9
9
|
import { registerSocketAudienceBootstrap } from "../src/server/realtimeAudience.js";
|
|
10
10
|
import { createRealtimeDelivery } from "../src/server/realtimeDelivery.js";
|
|
11
|
+
import { attachDeferredAuthService, createDeferredAuthService } from "../../auth-core/src/server/deferredAuthService.js";
|
|
11
12
|
|
|
12
13
|
const logger = Object.freeze({ debug() {}, info() {}, warn() {}, error() {} });
|
|
13
14
|
|
|
15
|
+
test("RealtimeProvider waits for authentication boot before accessing the auth service", async () => {
|
|
16
|
+
const authService = createDeferredAuthService();
|
|
17
|
+
const authProvider = defineProvider({
|
|
18
|
+
id: "test.deferred.auth",
|
|
19
|
+
provides: { authService: "auth.service" },
|
|
20
|
+
setup() { return { authService }; },
|
|
21
|
+
boot() {
|
|
22
|
+
attachDeferredAuthService(authService, {
|
|
23
|
+
realtime: { requireAuthentication: true },
|
|
24
|
+
async authenticateRequest() { return { authenticated: false, actor: null }; }
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
const runtime = createCapabilityRuntime({
|
|
29
|
+
inputs: {
|
|
30
|
+
"runtime.config": {}, "runtime.env": {},
|
|
31
|
+
"runtime.fastify": { server: createServer() }, "runtime.logger": logger
|
|
32
|
+
},
|
|
33
|
+
providers: [EventProvider, RealtimeProvider, authProvider]
|
|
34
|
+
});
|
|
35
|
+
try {
|
|
36
|
+
await runtime.start();
|
|
37
|
+
} finally {
|
|
38
|
+
await runtime.shutdown();
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
14
42
|
function createIoDouble() {
|
|
15
43
|
const emitted = [];
|
|
16
44
|
return {
|
|
@@ -72,7 +100,7 @@ test("realtime delivery sends explicit action events to their selected rooms", a
|
|
|
72
100
|
});
|
|
73
101
|
|
|
74
102
|
assert.deepEqual(io.emitted, [{
|
|
75
|
-
room: "workspace:11",
|
|
103
|
+
room: ["workspace:11"],
|
|
76
104
|
eventName: "workspace.settings.changed",
|
|
77
105
|
payload: {
|
|
78
106
|
workspaceSlug: "acme",
|
|
@@ -115,7 +143,7 @@ test("realtime delivery resolves an explicit database-backed audience without ex
|
|
|
115
143
|
}
|
|
116
144
|
});
|
|
117
145
|
assert.equal(io.emitted.length, 1);
|
|
118
|
-
assert.
|
|
146
|
+
assert.deepEqual(io.emitted[0].room, ["user:55"]);
|
|
119
147
|
assert.equal(Object.hasOwn(io.emitted[0].payload, "realtime"), false);
|
|
120
148
|
});
|
|
121
149
|
|
|
@@ -168,6 +196,58 @@ test("socket audience bootstrap authenticates explicitly and joins actor workspa
|
|
|
168
196
|
]);
|
|
169
197
|
});
|
|
170
198
|
|
|
199
|
+
test("socket audience bootstrap rejects unauthenticated handshakes when the auth service requires them", async () => {
|
|
200
|
+
let connectionHandler = null;
|
|
201
|
+
let authenticationMiddleware = null;
|
|
202
|
+
const io = {
|
|
203
|
+
on(eventName, handler) {
|
|
204
|
+
if (eventName === "connection") connectionHandler = handler;
|
|
205
|
+
},
|
|
206
|
+
use(handler) {
|
|
207
|
+
authenticationMiddleware = handler;
|
|
208
|
+
}
|
|
209
|
+
};
|
|
210
|
+
registerSocketAudienceBootstrap({
|
|
211
|
+
io,
|
|
212
|
+
logger,
|
|
213
|
+
authService: {
|
|
214
|
+
realtime: { requireAuthentication: true },
|
|
215
|
+
async authenticateRequest(request) {
|
|
216
|
+
return request.cookies.session === "valid"
|
|
217
|
+
? { authenticated: true, actor: { id: 17 } }
|
|
218
|
+
: { authenticated: false, actor: null };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
const rejected = [];
|
|
224
|
+
await authenticationMiddleware({
|
|
225
|
+
data: {},
|
|
226
|
+
handshake: { headers: { cookie: "session=invalid" } },
|
|
227
|
+
request: { headers: {} }
|
|
228
|
+
}, (error) => rejected.push(error));
|
|
229
|
+
assert.equal(rejected.length, 1);
|
|
230
|
+
assert.equal(rejected[0].data.code, "AUTHENTICATION_REQUIRED");
|
|
231
|
+
|
|
232
|
+
const socket = {
|
|
233
|
+
data: {},
|
|
234
|
+
handshake: { headers: { cookie: "session=valid" } },
|
|
235
|
+
request: { headers: {} },
|
|
236
|
+
join(room) {
|
|
237
|
+
this.joinedRooms ||= [];
|
|
238
|
+
this.joinedRooms.push(room);
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
let acceptedError = "not-called";
|
|
242
|
+
await authenticationMiddleware(socket, (error) => {
|
|
243
|
+
acceptedError = error;
|
|
244
|
+
});
|
|
245
|
+
assert.equal(acceptedError, undefined);
|
|
246
|
+
await connectionHandler(socket);
|
|
247
|
+
assert.equal(socket.data.actorId, "17");
|
|
248
|
+
assert.deepEqual(socket.joinedRooms, ["clients", "users", "user:17"]);
|
|
249
|
+
});
|
|
250
|
+
|
|
171
251
|
async function startRealtimeClient({ mobile = null } = {}) {
|
|
172
252
|
const registrations = new Map();
|
|
173
253
|
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
|
});
|