@terreno/api 0.13.2 → 0.14.0
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/dist/__tests__/versionCheckPlugin.test.js +53 -3
- package/dist/api.arrayOperations.test.js +1 -0
- package/dist/api.asyncHandler.test.d.ts +1 -0
- package/dist/api.asyncHandler.test.js +236 -0
- package/dist/api.d.ts +15 -4
- package/dist/api.errors.test.js +1 -0
- package/dist/api.hooks.test.js +1 -0
- package/dist/api.js +153 -104
- package/dist/api.query.test.js +1 -0
- package/dist/api.test.js +174 -0
- package/dist/auth.d.ts +10 -5
- package/dist/auth.js +163 -90
- package/dist/auth.test.js +159 -0
- package/dist/betterAuthApp.test.js +1 -0
- package/dist/betterAuthSetup.d.ts +5 -6
- package/dist/betterAuthSetup.js +17 -14
- package/dist/betterAuthSetup.test.js +1 -0
- package/dist/config.d.ts +48 -0
- package/dist/config.js +248 -0
- package/dist/config.test.d.ts +1 -0
- package/dist/config.test.js +328 -0
- package/dist/configuration.test.js +1 -0
- package/dist/configurationApp.d.ts +1 -1
- package/dist/configurationApp.js +17 -13
- package/dist/configurationPlugin.test.js +1 -0
- package/dist/consentApp.test.js +1 -0
- package/dist/envConfigurationPlugin.d.ts +2 -0
- package/dist/envConfigurationPlugin.js +173 -0
- package/dist/envConfigurationPlugin.test.d.ts +1 -0
- package/dist/envConfigurationPlugin.test.js +322 -0
- package/dist/errors.d.ts +18 -7
- package/dist/errors.js +106 -10
- package/dist/errors.test.js +16 -1
- package/dist/example.js +16 -7
- package/dist/expressServer.d.ts +10 -9
- package/dist/expressServer.js +62 -53
- package/dist/expressServer.test.js +53 -2
- package/dist/githubAuth.d.ts +2 -1
- package/dist/githubAuth.js +41 -26
- package/dist/githubAuth.test.js +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- package/dist/logger.d.ts +1 -1
- package/dist/logger.js +42 -20
- package/dist/models/versionConfig.d.ts +2 -0
- package/dist/models/versionConfig.js +8 -0
- package/dist/notifiers/googleChatNotifier.js +14 -16
- package/dist/notifiers/googleChatNotifier.test.js +1 -0
- package/dist/notifiers/slackNotifier.js +16 -14
- package/dist/notifiers/slackNotifier.test.js +41 -3
- package/dist/notifiers/zoomNotifier.js +7 -10
- package/dist/notifiers/zoomNotifier.test.js +1 -0
- package/dist/openApi.d.ts +1 -1
- package/dist/openApi.test.js +1 -0
- package/dist/openApiBuilder.d.ts +39 -6
- package/dist/openApiBuilder.js +1 -31
- package/dist/openApiBuilder.test.js +1 -0
- package/dist/openApiValidator.js +1 -0
- package/dist/openApiValidator.test.js +65 -0
- package/dist/permissions.d.ts +4 -4
- package/dist/permissions.js +67 -65
- package/dist/permissions.middleware.test.js +1 -0
- package/dist/permissions.test.js +1 -0
- package/dist/plugins.d.ts +5 -5
- package/dist/plugins.js +18 -9
- package/dist/plugins.test.js +1 -1
- package/dist/populate.d.ts +15 -8
- package/dist/populate.js +23 -24
- package/dist/populate.test.js +1 -0
- package/dist/realtime/changeStreamWatcher.d.ts +73 -0
- package/dist/realtime/changeStreamWatcher.js +720 -0
- package/dist/realtime/index.d.ts +6 -0
- package/dist/realtime/index.js +27 -0
- package/dist/realtime/queryMatcher.d.ts +14 -0
- package/dist/realtime/queryMatcher.js +250 -0
- package/dist/realtime/queryStore.d.ts +37 -0
- package/dist/realtime/queryStore.js +195 -0
- package/dist/realtime/realtime.test.d.ts +10 -0
- package/dist/realtime/realtime.test.js +2158 -0
- package/dist/realtime/realtimeApp.d.ts +93 -0
- package/dist/realtime/realtimeApp.js +560 -0
- package/dist/realtime/registry.d.ts +40 -0
- package/dist/realtime/registry.js +38 -0
- package/dist/realtime/socketUser.d.ts +10 -0
- package/dist/realtime/socketUser.js +17 -0
- package/dist/realtime/types.d.ts +100 -0
- package/dist/realtime/types.js +2 -0
- package/dist/requestContext.d.ts +37 -0
- package/dist/requestContext.js +344 -0
- package/dist/requestContext.test.d.ts +1 -0
- package/dist/requestContext.test.js +241 -0
- package/dist/terrenoApp.d.ts +8 -0
- package/dist/terrenoApp.js +50 -13
- package/dist/terrenoApp.test.js +194 -21
- package/dist/terrenoPlugin.d.ts +11 -0
- package/dist/tests/bunSetup.js +1 -0
- package/dist/tests.js +1 -1
- package/dist/transformers.d.ts +2 -2
- package/dist/transformers.js +5 -3
- package/dist/transformers.test.js +90 -0
- package/dist/types/consentResponse.d.ts +6 -3
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +18 -12
- package/package.json +4 -2
- package/src/__tests__/versionCheckPlugin.test.ts +37 -3
- package/src/api.arrayOperations.test.ts +1 -0
- package/src/api.asyncHandler.test.ts +177 -0
- package/src/api.errors.test.ts +1 -0
- package/src/api.hooks.test.ts +1 -0
- package/src/api.query.test.ts +1 -0
- package/src/api.test.ts +132 -0
- package/src/api.ts +199 -84
- package/src/auth.test.ts +160 -0
- package/src/auth.ts +120 -50
- package/src/betterAuthApp.test.ts +1 -0
- package/src/betterAuthSetup.test.ts +1 -0
- package/src/betterAuthSetup.ts +46 -19
- package/src/config.test.ts +255 -0
- package/src/config.ts +206 -0
- package/src/configuration.test.ts +1 -0
- package/src/configurationApp.ts +59 -24
- package/src/configurationPlugin.test.ts +1 -0
- package/src/consentApp.test.ts +1 -0
- package/src/envConfigurationPlugin.test.ts +143 -0
- package/src/envConfigurationPlugin.ts +100 -0
- package/src/errors.test.ts +19 -1
- package/src/errors.ts +94 -20
- package/src/example.ts +46 -21
- package/src/express.d.ts +18 -1
- package/src/expressServer.test.ts +50 -2
- package/src/expressServer.ts +80 -50
- package/src/githubAuth.test.ts +1 -0
- package/src/githubAuth.ts +59 -38
- package/src/index.ts +4 -0
- package/src/logger.ts +47 -17
- package/src/models/versionConfig.ts +13 -2
- package/src/notifiers/googleChatNotifier.test.ts +1 -0
- package/src/notifiers/googleChatNotifier.ts +7 -9
- package/src/notifiers/slackNotifier.test.ts +29 -3
- package/src/notifiers/slackNotifier.ts +9 -7
- package/src/notifiers/zoomNotifier.test.ts +1 -0
- package/src/notifiers/zoomNotifier.ts +8 -11
- package/src/openApi.test.ts +1 -0
- package/src/openApi.ts +4 -4
- package/src/openApiBuilder.test.ts +1 -0
- package/src/openApiBuilder.ts +14 -11
- package/src/openApiValidator.test.ts +59 -0
- package/src/openApiValidator.ts +3 -2
- package/src/permissions.middleware.test.ts +1 -0
- package/src/permissions.test.ts +1 -0
- package/src/permissions.ts +30 -25
- package/src/plugins.test.ts +1 -1
- package/src/plugins.ts +21 -14
- package/src/populate.test.ts +1 -0
- package/src/populate.ts +44 -36
- package/src/realtime/changeStreamWatcher.ts +568 -0
- package/src/realtime/index.ts +34 -0
- package/src/realtime/queryMatcher.ts +179 -0
- package/src/realtime/queryStore.ts +132 -0
- package/src/realtime/realtime.test.ts +1755 -0
- package/src/realtime/realtimeApp.ts +478 -0
- package/src/realtime/registry.ts +64 -0
- package/src/realtime/socketUser.ts +25 -0
- package/src/realtime/types.ts +112 -0
- package/src/requestContext.test.ts +196 -0
- package/src/requestContext.ts +368 -0
- package/src/terrenoApp.test.ts +137 -11
- package/src/terrenoApp.ts +64 -17
- package/src/terrenoPlugin.ts +12 -0
- package/src/tests/bunSetup.ts +1 -0
- package/src/tests.ts +7 -2
- package/src/transformers.test.ts +70 -2
- package/src/transformers.ts +15 -7
- package/src/types/consentResponse.ts +8 -10
- package/src/versionCheckPlugin.ts +15 -7
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: Socket.io handler signatures require dynamic args
|
|
2
|
+
import type http from "node:http";
|
|
3
|
+
import * as Sentry from "@sentry/bun";
|
|
4
|
+
import {authorize} from "@thream/socketio-jwt";
|
|
5
|
+
import type express from "express";
|
|
6
|
+
import {Server, type Socket} from "socket.io";
|
|
7
|
+
|
|
8
|
+
import type {User} from "../auth";
|
|
9
|
+
import {logger} from "../logger";
|
|
10
|
+
import {checkPermissions} from "../permissions";
|
|
11
|
+
import type {TerrenoPlugin} from "../terrenoPlugin";
|
|
12
|
+
import {startChangeStreamWatcher, stopChangeStreamWatcher} from "./changeStreamWatcher";
|
|
13
|
+
import {
|
|
14
|
+
addQuerySubscription,
|
|
15
|
+
computeQueryId,
|
|
16
|
+
removeAllSocketQueries,
|
|
17
|
+
removeQuerySubscription,
|
|
18
|
+
} from "./queryStore";
|
|
19
|
+
import {findRegistryEntryByRoutePath, type RealtimeRegistryEntry} from "./registry";
|
|
20
|
+
import {getSocketUser, type SocketWithDecodedToken} from "./socketUser";
|
|
21
|
+
import type {DocumentSubscription, QuerySubscription, RealtimeAppOptions} from "./types";
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Caps on per-socket subscriptions. Prevents a malicious or buggy client from
|
|
25
|
+
* exhausting server memory by opening unbounded subscriptions.
|
|
26
|
+
*/
|
|
27
|
+
export const MAX_MODEL_SUBSCRIPTIONS = 50;
|
|
28
|
+
export const MAX_DOCUMENT_SUBSCRIPTIONS = 500;
|
|
29
|
+
export const MAX_QUERY_SUBSCRIPTIONS = 100;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Replace any userinfo (`user:password@`) component in a URL with `***@` so the
|
|
33
|
+
* credentials are not written to logs. Falls back to a regex when the string
|
|
34
|
+
* isn't a valid URL the standard `URL` parser can read.
|
|
35
|
+
*
|
|
36
|
+
* Exported for testing.
|
|
37
|
+
*/
|
|
38
|
+
export const redactCredentials = (url: string): string => {
|
|
39
|
+
try {
|
|
40
|
+
const parsed = new URL(url);
|
|
41
|
+
if (parsed.username || parsed.password) {
|
|
42
|
+
parsed.username = "";
|
|
43
|
+
parsed.password = "";
|
|
44
|
+
// URL serialization drops the empty userinfo; reinsert a sentinel so logs make it
|
|
45
|
+
// obvious credentials were stripped rather than silently absent from the source URL.
|
|
46
|
+
return parsed.toString().replace(`${parsed.protocol}//`, `${parsed.protocol}//***@`);
|
|
47
|
+
}
|
|
48
|
+
return parsed.toString();
|
|
49
|
+
} catch {
|
|
50
|
+
return url.replace(/^(\w+):\/\/[^@/]*@/, "$1://***@");
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Minimal shape this module requires from a Socket.io socket. Lets tests pass a
|
|
56
|
+
* mock without standing up a real server.
|
|
57
|
+
*/
|
|
58
|
+
export interface RealtimeSocketLike extends SocketWithDecodedToken {
|
|
59
|
+
id: string;
|
|
60
|
+
join: (room: string) => Promise<void> | void;
|
|
61
|
+
leave: (room: string) => Promise<void> | void;
|
|
62
|
+
emit: (event: string, payload: unknown) => void;
|
|
63
|
+
// biome-ignore lint/suspicious/noExplicitAny: Socket.io event handlers accept arbitrary argument shapes per event name
|
|
64
|
+
on: (event: string, handler: (...args: any[]) => any) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const canSubscribe = async (
|
|
68
|
+
entry: RealtimeRegistryEntry,
|
|
69
|
+
method: "list" | "read",
|
|
70
|
+
user?: User
|
|
71
|
+
): Promise<boolean> => {
|
|
72
|
+
const permissions = entry.options.permissions[method];
|
|
73
|
+
return checkPermissions(method, permissions, user);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const getAuthorizedQuery = async (
|
|
77
|
+
entry: RealtimeRegistryEntry,
|
|
78
|
+
query: Record<string, any>,
|
|
79
|
+
user?: User
|
|
80
|
+
): Promise<Record<string, any> | null> => {
|
|
81
|
+
if (!(await canSubscribe(entry, "list", user))) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!entry.options.queryFilter) {
|
|
86
|
+
return query;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let filteredQuery: Record<string, any> | null;
|
|
90
|
+
try {
|
|
91
|
+
filteredQuery = await entry.options.queryFilter(user, query);
|
|
92
|
+
} catch (error) {
|
|
93
|
+
logger.error(
|
|
94
|
+
`[realtime] queryFilter threw for ${entry.modelName}/list: ${error}. Denying query subscription.`
|
|
95
|
+
);
|
|
96
|
+
Sentry.captureException(error);
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (filteredQuery === null) {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {...query, ...filteredQuery};
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Install the realtime subscription handlers on a single socket. Extracted from the
|
|
109
|
+
* RealtimeApp connection handler so this logic can be unit-tested with a mock socket
|
|
110
|
+
* (no real Socket.io / HTTP server / JWT handshake required).
|
|
111
|
+
*
|
|
112
|
+
* Enforces:
|
|
113
|
+
* - per-socket subscription caps (DoS protection)
|
|
114
|
+
* - registry membership (only realtime-enabled collections can be subscribed)
|
|
115
|
+
* - owner-strategy isolation (non-admin users cannot subscribe to other users' rooms)
|
|
116
|
+
* - server-side queryId computation (clients can't hijack queries by colliding ids)
|
|
117
|
+
*/
|
|
118
|
+
export const installRealtimeSocketHandlers = (
|
|
119
|
+
socket: RealtimeSocketLike,
|
|
120
|
+
options: {logInfo?: (msg: string) => void} = {}
|
|
121
|
+
): void => {
|
|
122
|
+
const logInfo = options.logInfo ?? ((): void => {});
|
|
123
|
+
const userId = socket.decodedToken?.id;
|
|
124
|
+
const isAdmin = socket.decodedToken?.admin === true;
|
|
125
|
+
const user = getSocketUser(socket);
|
|
126
|
+
|
|
127
|
+
const counts = {document: 0, model: 0, query: 0};
|
|
128
|
+
|
|
129
|
+
const joinUserRooms = async (): Promise<void> => {
|
|
130
|
+
if (userId) {
|
|
131
|
+
await socket.join(`user:${userId}`);
|
|
132
|
+
await socket.join("authenticated");
|
|
133
|
+
logInfo(`[realtime] User ${userId} connected`);
|
|
134
|
+
}
|
|
135
|
+
if (isAdmin) {
|
|
136
|
+
await socket.join("admin");
|
|
137
|
+
logInfo(`[realtime] Admin user ${userId} joined admin room`);
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
// Fire-and-forget — there is nothing useful for the caller to await.
|
|
142
|
+
void joinUserRooms();
|
|
143
|
+
|
|
144
|
+
socket.on("subscribe:model", async (modelName: string): Promise<void> => {
|
|
145
|
+
if (typeof modelName !== "string" || modelName.length === 0) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
if (counts.model >= MAX_MODEL_SUBSCRIPTIONS) {
|
|
149
|
+
logInfo(`[realtime] User ${userId} hit model subscription limit`);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const entry = findRegistryEntryByRoutePath(modelName);
|
|
154
|
+
if (!entry) {
|
|
155
|
+
logInfo(
|
|
156
|
+
`[realtime] User ${userId} denied model subscription: collection "${modelName}" not registered`
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Owner-strategy models fan out via user:{ownerId} — there is no shared model room
|
|
162
|
+
// that should be open to all users. Owners receive events through their user room
|
|
163
|
+
// automatically; admins can use the admin room to see everything.
|
|
164
|
+
if (entry.config.roomStrategy === "owner" && !isAdmin) {
|
|
165
|
+
logInfo(
|
|
166
|
+
`[realtime] User ${userId} denied model subscription for ${modelName}: ` +
|
|
167
|
+
"owner strategy restricts model room to admins"
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (!(await canSubscribe(entry, "list", user))) {
|
|
173
|
+
logInfo(
|
|
174
|
+
`[realtime] User ${userId} denied model subscription for ${modelName}: list permission denied`
|
|
175
|
+
);
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
counts.model += 1;
|
|
180
|
+
await socket.join(`model:${modelName}`);
|
|
181
|
+
logInfo(`[realtime] User ${userId} subscribed to model:${modelName}`);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
socket.on("unsubscribe:model", async (modelName: string): Promise<void> => {
|
|
185
|
+
if (typeof modelName === "string" && modelName.length > 0) {
|
|
186
|
+
await socket.leave(`model:${modelName}`);
|
|
187
|
+
counts.model = Math.max(0, counts.model - 1);
|
|
188
|
+
logInfo(`[realtime] User ${userId} unsubscribed from model:${modelName}`);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
socket.on("subscribe:document", async (payload: DocumentSubscription): Promise<void> => {
|
|
193
|
+
if (
|
|
194
|
+
!payload?.collection ||
|
|
195
|
+
!payload?.id ||
|
|
196
|
+
typeof payload.collection !== "string" ||
|
|
197
|
+
typeof payload.id !== "string"
|
|
198
|
+
) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (counts.document >= MAX_DOCUMENT_SUBSCRIPTIONS) {
|
|
202
|
+
logInfo(`[realtime] User ${userId} hit document subscription limit`);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const entry = findRegistryEntryByRoutePath(payload.collection);
|
|
207
|
+
if (!entry) {
|
|
208
|
+
logInfo(
|
|
209
|
+
`[realtime] User ${userId} denied document subscription: ` +
|
|
210
|
+
`collection "${payload.collection}" not registered`
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (!(await canSubscribe(entry, "read", user))) {
|
|
216
|
+
logInfo(
|
|
217
|
+
`[realtime] User ${userId} denied document subscription for ` +
|
|
218
|
+
`${payload.collection}/${payload.id}: read permission denied`
|
|
219
|
+
);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
if (entry.config.roomStrategy === "owner" && !isAdmin) {
|
|
224
|
+
logInfo(
|
|
225
|
+
`[realtime] User ${userId} denied document subscription for ` +
|
|
226
|
+
`${payload.collection}/${payload.id}: owner strategy requires admin`
|
|
227
|
+
);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
counts.document += 1;
|
|
232
|
+
const room = `document:${payload.collection}:${payload.id}`;
|
|
233
|
+
await socket.join(room);
|
|
234
|
+
logInfo(`[realtime] User ${userId} subscribed to ${room}`);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
socket.on("unsubscribe:document", async (payload: DocumentSubscription): Promise<void> => {
|
|
238
|
+
if (payload?.collection && payload?.id) {
|
|
239
|
+
const room = `document:${payload.collection}:${payload.id}`;
|
|
240
|
+
await socket.leave(room);
|
|
241
|
+
counts.document = Math.max(0, counts.document - 1);
|
|
242
|
+
logInfo(`[realtime] User ${userId} unsubscribed from ${room}`);
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
socket.on("subscribe:query", async (payload: QuerySubscription): Promise<void> => {
|
|
247
|
+
if (
|
|
248
|
+
!payload?.collection ||
|
|
249
|
+
!payload?.query ||
|
|
250
|
+
typeof payload.collection !== "string" ||
|
|
251
|
+
typeof payload.query !== "object" ||
|
|
252
|
+
Array.isArray(payload.query)
|
|
253
|
+
) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (counts.query >= MAX_QUERY_SUBSCRIPTIONS) {
|
|
257
|
+
logInfo(`[realtime] User ${userId} hit query subscription limit`);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const entry = findRegistryEntryByRoutePath(payload.collection);
|
|
262
|
+
if (!entry) {
|
|
263
|
+
logInfo(
|
|
264
|
+
`[realtime] User ${userId} denied query subscription: ` +
|
|
265
|
+
`collection "${payload.collection}" not registered`
|
|
266
|
+
);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
let query = await getAuthorizedQuery(entry, {...payload.query}, user);
|
|
271
|
+
if (!query) {
|
|
272
|
+
logInfo(
|
|
273
|
+
`[realtime] User ${userId} denied query subscription for ${payload.collection}: ` +
|
|
274
|
+
"list permission or query filter denied"
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
if (entry.config.roomStrategy === "owner" && !isAdmin) {
|
|
280
|
+
if (!userId) {
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
query = {...query, ownerId: userId};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
const queryId = computeQueryId(payload.collection, query);
|
|
287
|
+
|
|
288
|
+
addQuerySubscription(socket.id, payload.collection, query, queryId);
|
|
289
|
+
counts.query += 1;
|
|
290
|
+
await socket.join(`query:${queryId}`);
|
|
291
|
+
socket.emit("query:subscribed", {
|
|
292
|
+
clientQueryId: payload.queryId,
|
|
293
|
+
collection: payload.collection,
|
|
294
|
+
queryId,
|
|
295
|
+
});
|
|
296
|
+
logInfo(`[realtime] User ${userId} subscribed to query:${queryId} on ${payload.collection}`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
socket.on("unsubscribe:query", async (payload: {queryId: string}): Promise<void> => {
|
|
300
|
+
if (payload?.queryId) {
|
|
301
|
+
removeQuerySubscription(socket.id, payload.queryId);
|
|
302
|
+
await socket.leave(`query:${payload.queryId}`);
|
|
303
|
+
counts.query = Math.max(0, counts.query - 1);
|
|
304
|
+
logInfo(`[realtime] User ${userId} unsubscribed from query:${payload.queryId}`);
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
socket.on("disconnect", () => {
|
|
309
|
+
removeAllSocketQueries(socket.id);
|
|
310
|
+
logInfo(`[realtime] User ${userId} disconnected`);
|
|
311
|
+
});
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* TerrenoPlugin that provides real-time sync via Socket.io and MongoDB change streams.
|
|
316
|
+
*
|
|
317
|
+
* Attaches a Socket.io server to the HTTP server created by TerrenoApp.start(),
|
|
318
|
+
* sets up JWT authentication for socket connections, manages room subscriptions
|
|
319
|
+
* (model, document, and query rooms), and starts a change stream watcher that
|
|
320
|
+
* emits events to connected clients.
|
|
321
|
+
*
|
|
322
|
+
* ## Subscription types
|
|
323
|
+
*
|
|
324
|
+
* - **Model rooms**: `subscribe:model` / `unsubscribe:model` — receive all events for a collection
|
|
325
|
+
* - **Document rooms**: `subscribe:document` / `unsubscribe:document` — receive events for a single document
|
|
326
|
+
* - **Query rooms**: `subscribe:query` / `unsubscribe:query` — receive events matching a MongoDB query
|
|
327
|
+
*
|
|
328
|
+
* @example
|
|
329
|
+
* ```typescript
|
|
330
|
+
* const app = new TerrenoApp({
|
|
331
|
+
* userModel: User,
|
|
332
|
+
* realtime: { debug: true },
|
|
333
|
+
* })
|
|
334
|
+
* .register(todoRouter) // todoRouter has realtime config
|
|
335
|
+
* .start();
|
|
336
|
+
* ```
|
|
337
|
+
*/
|
|
338
|
+
export class RealtimeApp implements TerrenoPlugin {
|
|
339
|
+
private io: Server | null = null;
|
|
340
|
+
private config: RealtimeAppOptions;
|
|
341
|
+
|
|
342
|
+
constructor(config: RealtimeAppOptions = {}) {
|
|
343
|
+
this.config = config;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Register routes and middleware. Adds a /realtime/health endpoint.
|
|
348
|
+
*/
|
|
349
|
+
register(app: express.Application): void {
|
|
350
|
+
app.get("/realtime/health", (_req, res) => {
|
|
351
|
+
const connected = this.io?.engine?.clientsCount ?? 0;
|
|
352
|
+
res.json({
|
|
353
|
+
clients: connected,
|
|
354
|
+
debug: this.config.debug ?? false,
|
|
355
|
+
status: this.io ? "running" : "not_started",
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Called after the HTTP server is created. Sets up Socket.io, auth, rooms,
|
|
362
|
+
* and starts the change stream watcher.
|
|
363
|
+
*/
|
|
364
|
+
onServerCreated(server: http.Server): void {
|
|
365
|
+
const debug = this.config.debug ?? false;
|
|
366
|
+
|
|
367
|
+
const logInfo = (message: string): void => {
|
|
368
|
+
if (debug) {
|
|
369
|
+
logger.info(message);
|
|
370
|
+
}
|
|
371
|
+
};
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
logInfo("[realtime] Setting up Socket.io server...");
|
|
375
|
+
|
|
376
|
+
this.io = new Server(server, {
|
|
377
|
+
cors: this.config.cors ?? {
|
|
378
|
+
methods: ["GET", "POST"],
|
|
379
|
+
origin: "*",
|
|
380
|
+
},
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// JWT authentication middleware
|
|
384
|
+
const tokenSecret = this.config.tokenSecret ?? process.env.TOKEN_SECRET;
|
|
385
|
+
if (!tokenSecret) {
|
|
386
|
+
throw new Error(
|
|
387
|
+
"[realtime] TOKEN_SECRET is required for socket authentication. " +
|
|
388
|
+
"Set process.env.TOKEN_SECRET or pass tokenSecret in RealtimeAppOptions."
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
this.io.use(
|
|
393
|
+
authorize({
|
|
394
|
+
secret: tokenSecret,
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
logInfo("[realtime] JWT authorization middleware added");
|
|
399
|
+
|
|
400
|
+
// Configure adapter for multi-instance deployments
|
|
401
|
+
this.setupAdapter(logInfo);
|
|
402
|
+
|
|
403
|
+
// Connection handling
|
|
404
|
+
this.io.on("connection", (socket: Socket): void => {
|
|
405
|
+
try {
|
|
406
|
+
// socketio-jwt's authorize middleware adds `decodedToken` at runtime; cast through
|
|
407
|
+
// RealtimeSocketLike (a structural subset) to keep socket handler logic testable.
|
|
408
|
+
installRealtimeSocketHandlers(socket as unknown as RealtimeSocketLike, {logInfo});
|
|
409
|
+
} catch (error) {
|
|
410
|
+
logger.error(`[realtime] Error handling connection: ${error}`);
|
|
411
|
+
Sentry.captureException(error);
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
this.io.on("connect_error", (error: Error) => {
|
|
416
|
+
logger.error(`[realtime] Connection error: ${error.message}`);
|
|
417
|
+
Sentry.captureException(error);
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Start the change stream watcher
|
|
421
|
+
startChangeStreamWatcher(this.io, this.config.changeStream, debug);
|
|
422
|
+
|
|
423
|
+
logInfo("[realtime] Socket.io server setup complete");
|
|
424
|
+
} catch (error) {
|
|
425
|
+
logger.error(`[realtime] Failed to set up Socket.io: ${error}`);
|
|
426
|
+
Sentry.captureException(error);
|
|
427
|
+
throw error;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get the Socket.io server instance.
|
|
433
|
+
*/
|
|
434
|
+
getIo(): Server | null {
|
|
435
|
+
return this.io;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Gracefully shut down the real-time server.
|
|
440
|
+
*/
|
|
441
|
+
async close(): Promise<void> {
|
|
442
|
+
try {
|
|
443
|
+
await stopChangeStreamWatcher();
|
|
444
|
+
if (this.io) {
|
|
445
|
+
await this.io.close();
|
|
446
|
+
this.io = null;
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
logger.error(`[realtime] Error closing: ${error}`);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
private setupAdapter(logInfo: (msg: string) => void): void {
|
|
454
|
+
if (!this.io) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const adapter = this.config.adapter ?? "none";
|
|
459
|
+
|
|
460
|
+
if (adapter === "redis") {
|
|
461
|
+
const redisUrl = this.config.redisUrl ?? process.env.VALKEY_URL ?? process.env.REDIS_URL;
|
|
462
|
+
if (redisUrl) {
|
|
463
|
+
logInfo(`[realtime] Redis adapter configured with URL: ${redactCredentials(redisUrl)}`);
|
|
464
|
+
// Redis adapter must be configured externally by the consuming app
|
|
465
|
+
// since @socket.io/redis-adapter and ioredis are optional peer dependencies.
|
|
466
|
+
// Use realtimeApp.getIo() to access the Socket.io instance and call
|
|
467
|
+
// io.adapter(createRedisAdapter(pubClient, subClient))
|
|
468
|
+
logger.info(
|
|
469
|
+
"[realtime] To enable Redis adapter, configure it after server creation via getIo(). " +
|
|
470
|
+
"See @socket.io/redis-adapter docs."
|
|
471
|
+
);
|
|
472
|
+
} else {
|
|
473
|
+
logger.warn("[realtime] Redis adapter requested but no VALKEY_URL or REDIS_URL found");
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
// 'none' — no adapter, single instance mode
|
|
477
|
+
}
|
|
478
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: model router options are generic across all models
|
|
2
|
+
import type {ModelRouterOptions} from "../api";
|
|
3
|
+
import type {RealtimeConfig} from "./types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A registered model with real-time sync configuration.
|
|
7
|
+
*/
|
|
8
|
+
export interface RealtimeRegistryEntry {
|
|
9
|
+
/** Mongoose model name (e.g. "Todo") */
|
|
10
|
+
modelName: string;
|
|
11
|
+
/** Route path (e.g. "/todos") */
|
|
12
|
+
routePath: string;
|
|
13
|
+
/** Collection name in MongoDB (e.g. "todos") */
|
|
14
|
+
collectionName: string;
|
|
15
|
+
/** Real-time configuration from modelRouter options */
|
|
16
|
+
config: RealtimeConfig;
|
|
17
|
+
/**
|
|
18
|
+
* Full modelRouter options (for responseHandler, permissions, etc.).
|
|
19
|
+
*/
|
|
20
|
+
// biome-ignore lint/suspicious/noExplicitAny: registry stores heterogeneous models — narrowing the generic is not useful at the registry level
|
|
21
|
+
options: ModelRouterOptions<any>;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const realtimeRegistry: RealtimeRegistryEntry[] = [];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Register a model for real-time sync. Called automatically by modelRouter
|
|
28
|
+
* when the `realtime` option is provided.
|
|
29
|
+
*/
|
|
30
|
+
export const registerRealtime = (entry: RealtimeRegistryEntry): void => {
|
|
31
|
+
realtimeRegistry.push(entry);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get all registered real-time models.
|
|
36
|
+
*/
|
|
37
|
+
export const getRealtimeRegistry = (): RealtimeRegistryEntry[] => realtimeRegistry;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Find a registry entry by MongoDB collection name.
|
|
41
|
+
*/
|
|
42
|
+
export const findRegistryEntryByCollection = (
|
|
43
|
+
collectionName: string
|
|
44
|
+
): RealtimeRegistryEntry | undefined => {
|
|
45
|
+
return realtimeRegistry.find((entry) => entry.collectionName === collectionName);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find a registry entry by route path / collection tag (e.g. "todos").
|
|
50
|
+
*/
|
|
51
|
+
export const findRegistryEntryByRoutePath = (
|
|
52
|
+
collection: string
|
|
53
|
+
): RealtimeRegistryEntry | undefined => {
|
|
54
|
+
return realtimeRegistry.find(
|
|
55
|
+
(entry) => entry.routePath === `/${collection}` || entry.routePath === collection
|
|
56
|
+
);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clear the registry (for testing).
|
|
61
|
+
*/
|
|
62
|
+
export const clearRealtimeRegistry = (): void => {
|
|
63
|
+
realtimeRegistry.length = 0;
|
|
64
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type {User} from "../auth";
|
|
2
|
+
|
|
3
|
+
export interface DecodedRealtimeToken {
|
|
4
|
+
admin?: boolean;
|
|
5
|
+
id?: string;
|
|
6
|
+
isAnonymous?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface SocketWithDecodedToken {
|
|
10
|
+
decodedToken?: DecodedRealtimeToken;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export const getSocketUser = (socket: SocketWithDecodedToken): User | undefined => {
|
|
14
|
+
const userId = socket.decodedToken?.id;
|
|
15
|
+
if (!userId) {
|
|
16
|
+
return undefined;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
return {
|
|
20
|
+
_id: userId,
|
|
21
|
+
admin: socket.decodedToken?.admin === true,
|
|
22
|
+
id: userId,
|
|
23
|
+
isAnonymous: socket.decodedToken?.isAnonymous,
|
|
24
|
+
};
|
|
25
|
+
};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// biome-ignore-all lint/suspicious/noExplicitAny: realtime config callbacks receive dynamic document shapes
|
|
2
|
+
import type express from "express";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Configuration for real-time sync on a modelRouter.
|
|
6
|
+
* Determines which CRUD methods emit WebSocket events and how they are routed.
|
|
7
|
+
*/
|
|
8
|
+
export interface RealtimeConfig {
|
|
9
|
+
/** Which CRUD methods should emit real-time sync events */
|
|
10
|
+
methods: Array<"create" | "update" | "delete">;
|
|
11
|
+
/**
|
|
12
|
+
* Strategy for determining which Socket.io rooms receive events.
|
|
13
|
+
* - 'owner': emit to `user:{doc.ownerId}` room
|
|
14
|
+
* - 'model': emit to `model:{modelName}` room (clients must subscribe)
|
|
15
|
+
* - 'broadcast': emit to all authenticated sockets
|
|
16
|
+
* - function: custom room resolver returning room name(s)
|
|
17
|
+
*/
|
|
18
|
+
roomStrategy:
|
|
19
|
+
| "owner"
|
|
20
|
+
| "model"
|
|
21
|
+
| "broadcast"
|
|
22
|
+
// biome-ignore lint/suspicious/noExplicitAny: doc is an arbitrary Mongoose document; consumers cast to their model type
|
|
23
|
+
| ((doc: any, method: string, req: express.Request) => string[]);
|
|
24
|
+
/** Custom serializer for real-time events. Falls back to the modelRouter responseHandler. */
|
|
25
|
+
// biome-ignore lint/suspicious/noExplicitAny: doc shape and return value are consumer-defined per model
|
|
26
|
+
realtimeResponseHandler?: (doc: any, method: string) => any;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A real-time sync event emitted to clients via WebSocket.
|
|
31
|
+
*/
|
|
32
|
+
export interface RealtimeEvent {
|
|
33
|
+
/** Mongoose model name (e.g. "Todo") */
|
|
34
|
+
model: string;
|
|
35
|
+
/** Route path used as tag type (e.g. "todos") */
|
|
36
|
+
collection: string;
|
|
37
|
+
/** The CRUD method that triggered this event */
|
|
38
|
+
method: "create" | "update" | "delete";
|
|
39
|
+
/** Document ID */
|
|
40
|
+
id: string;
|
|
41
|
+
/** Serialized document data (omitted for hard deletes) */
|
|
42
|
+
// biome-ignore lint/suspicious/noExplicitAny: emitted document shape varies by model and serializer
|
|
43
|
+
data?: any;
|
|
44
|
+
/** Fields that were updated (for update events from change streams) */
|
|
45
|
+
updatedFields?: string[];
|
|
46
|
+
/** Epoch milliseconds when the event was generated */
|
|
47
|
+
timestamp: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Configuration for the MongoDB change stream watcher.
|
|
52
|
+
*/
|
|
53
|
+
export interface ChangeStreamConfig {
|
|
54
|
+
/** Collections to never watch (e.g. "socketio", "sessions") */
|
|
55
|
+
ignoredCollections?: string[];
|
|
56
|
+
/** Operation types to ignore */
|
|
57
|
+
ignoredOperations?: string[];
|
|
58
|
+
/** Non-modelRouter collections to watch (emits raw events) */
|
|
59
|
+
additionalCollections?: string[];
|
|
60
|
+
/** Change stream batch size (default: 50) */
|
|
61
|
+
batchSize?: number;
|
|
62
|
+
/** Full document mode (default: "updateLookup") */
|
|
63
|
+
fullDocument?: "updateLookup" | "whenAvailable";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Options for the RealtimeApp plugin.
|
|
68
|
+
*/
|
|
69
|
+
export interface RealtimeAppOptions {
|
|
70
|
+
/** Change stream watcher configuration */
|
|
71
|
+
changeStream?: ChangeStreamConfig;
|
|
72
|
+
/** CORS configuration for Socket.io */
|
|
73
|
+
cors?: {origin: string | string[]; methods?: string[]};
|
|
74
|
+
/**
|
|
75
|
+
* Socket.io adapter for multi-instance deployments.
|
|
76
|
+
* - 'none': single-instance mode, no adapter (default)
|
|
77
|
+
* - 'redis': use Redis adapter (requires redisUrl or VALKEY_URL env var)
|
|
78
|
+
*
|
|
79
|
+
* For MongoDB adapter or custom adapters, configure the Socket.io instance
|
|
80
|
+
* directly via getIo() after server creation.
|
|
81
|
+
*/
|
|
82
|
+
adapter?: "redis" | "none";
|
|
83
|
+
/** Redis URL for the Redis adapter */
|
|
84
|
+
redisUrl?: string;
|
|
85
|
+
/** JWT secret for socket authentication (default: process.env.TOKEN_SECRET) */
|
|
86
|
+
tokenSecret?: string;
|
|
87
|
+
/** Enable debug logging */
|
|
88
|
+
debug?: boolean;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Payload sent by the client to subscribe to a single document's changes.
|
|
93
|
+
*/
|
|
94
|
+
export interface DocumentSubscription {
|
|
95
|
+
/** Collection tag (e.g. "todos") */
|
|
96
|
+
collection: string;
|
|
97
|
+
/** Document ID */
|
|
98
|
+
id: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Payload sent by the client to subscribe to a query-filtered list.
|
|
103
|
+
*/
|
|
104
|
+
export interface QuerySubscription {
|
|
105
|
+
/** Collection tag (e.g. "todos") */
|
|
106
|
+
collection: string;
|
|
107
|
+
/** MongoDB-style query filter (e.g. {completed: false}) */
|
|
108
|
+
// biome-ignore lint/suspicious/noExplicitAny: MongoDB query filter values are arbitrary user-supplied JSON
|
|
109
|
+
query: Record<string, any>;
|
|
110
|
+
/** Client-provided queryId (ignored — server computes a canonical ID) */
|
|
111
|
+
queryId?: string;
|
|
112
|
+
}
|