@rebasepro/server-mongo 0.17.3 → 0.18.1

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.
Files changed (50) hide show
  1. package/LICENSE +0 -1
  2. package/README.md +4 -0
  3. package/dist/MongoBootstrapper.d.ts +0 -1
  4. package/dist/auth/ensure-collections.d.ts +0 -1
  5. package/dist/auth/services.d.ts +0 -1
  6. package/dist/connection.d.ts +0 -1
  7. package/dist/db/MongoConditionBuilder.d.ts +0 -1
  8. package/dist/db/MongoDataService.d.ts +0 -1
  9. package/dist/db/securityRuleFilter.d.ts +0 -1
  10. package/dist/factory.d.ts +0 -1
  11. package/dist/history/ensure-history-collection.d.ts +0 -1
  12. package/dist/index.d.ts +0 -1
  13. package/dist/index.es.js +83 -79
  14. package/dist/index.es.js.map +1 -1
  15. package/dist/schema/plan-schema-change.d.ts +0 -1
  16. package/dist/services/MongoDriver.d.ts +0 -1
  17. package/dist/services/MongoHistoryService.d.ts +0 -1
  18. package/dist/services/MongoRealtimeService.d.ts +0 -1
  19. package/dist/websocket.d.ts +0 -1
  20. package/package.json +28 -24
  21. package/dist/MongoBootstrapper.d.ts.map +0 -1
  22. package/dist/auth/ensure-collections.d.ts.map +0 -1
  23. package/dist/auth/services.d.ts.map +0 -1
  24. package/dist/connection.d.ts.map +0 -1
  25. package/dist/db/MongoConditionBuilder.d.ts.map +0 -1
  26. package/dist/db/MongoDataService.d.ts.map +0 -1
  27. package/dist/db/securityRuleFilter.d.ts.map +0 -1
  28. package/dist/factory.d.ts.map +0 -1
  29. package/dist/history/ensure-history-collection.d.ts.map +0 -1
  30. package/dist/index.d.ts.map +0 -1
  31. package/dist/schema/plan-schema-change.d.ts.map +0 -1
  32. package/dist/services/MongoDriver.d.ts.map +0 -1
  33. package/dist/services/MongoHistoryService.d.ts.map +0 -1
  34. package/dist/services/MongoRealtimeService.d.ts.map +0 -1
  35. package/dist/websocket.d.ts.map +0 -1
  36. package/src/MongoBootstrapper.ts +0 -204
  37. package/src/auth/ensure-collections.ts +0 -153
  38. package/src/auth/services.ts +0 -866
  39. package/src/connection.ts +0 -60
  40. package/src/db/MongoConditionBuilder.ts +0 -348
  41. package/src/db/MongoDataService.ts +0 -412
  42. package/src/db/securityRuleFilter.ts +0 -398
  43. package/src/factory.ts +0 -331
  44. package/src/history/ensure-history-collection.ts +0 -22
  45. package/src/index.ts +0 -25
  46. package/src/schema/plan-schema-change.ts +0 -159
  47. package/src/services/MongoDriver.ts +0 -950
  48. package/src/services/MongoHistoryService.ts +0 -186
  49. package/src/services/MongoRealtimeService.ts +0 -592
  50. package/src/websocket.ts +0 -387
package/src/websocket.ts DELETED
@@ -1,387 +0,0 @@
1
- import { RealtimeProvider, DataDriver, FetchCollectionProps, FetchOneProps, SaveProps, DeleteProps, TableMetadata, DatabaseAdmin, isSchemaAdmin, isDocumentAdmin, User, AuthAdapter } from "@rebasepro/types";
2
- import { WebSocketServer, WebSocket } from "ws";
3
- import { Server } from "http";
4
- import { inspect } from "util";
5
- import { extractUserFromToken, resolveRequireAuth, assertWriteRequestValid, ApiError } from "@rebasepro/server";
6
- import type { RebaseAuthConfig } from "@rebasepro/server";
7
- import { MongoRealtimeService } from "./services/MongoRealtimeService";
8
- import { MongoDriver } from "./services/MongoDriver";
9
- import { logger } from "@rebasepro/server";
10
-
11
- interface DriverWithAuth extends DataDriver {
12
- withAuth(user: Record<string, unknown>): Promise<DataDriver>;
13
- }
14
-
15
- function isDriverWithAuth(driver: DataDriver): driver is DriverWithAuth {
16
- return "withAuth" in driver && typeof (driver as Record<string, unknown>).withAuth === "function";
17
- }
18
-
19
- /**
20
- * Normalized user identity for WebSocket sessions — the same shape the Postgres
21
- * socket keeps, because an `AuthAdapter` user is not an access-token payload.
22
- */
23
- interface WsUserIdentity {
24
- uid: string;
25
- email?: string;
26
- displayName?: string;
27
- photoURL?: string;
28
- roles: string[];
29
- isAdmin: boolean;
30
- }
31
-
32
- interface ClientSession {
33
- ws: WebSocket;
34
- user?: WsUserIdentity;
35
- authenticated: boolean;
36
- messageCount: number;
37
- messageWindowStart: number;
38
- }
39
-
40
- const WS_RATE_LIMIT = 2000;
41
- const WS_RATE_WINDOW_MS = 60_000;
42
-
43
- const ADMIN_ONLY_TYPES = new Set([
44
- "EXECUTE_SQL",
45
- "FETCH_DATABASES",
46
- "FETCH_ROLES",
47
- "FETCH_UNMAPPED_TABLES",
48
- "FETCH_TABLE_METADATA",
49
- "FETCH_CURRENT_DATABASE",
50
- "CREATE_BRANCH",
51
- "DELETE_BRANCH",
52
- "LIST_BRANCHES"
53
- ]);
54
-
55
- function isAdminSession(session: ClientSession | undefined): boolean {
56
- if (!session?.user) return false;
57
- // The adapter's own answer first; a role *named* `admin` is only the
58
- // fallback for the built-in JWT path.
59
- if (session.user.isAdmin) return true;
60
- return (session.user.roles ?? []).some((r) => r === "admin");
61
- }
62
-
63
- export function createMongoWebSocket(
64
- server: Server,
65
- realtimeService: MongoRealtimeService,
66
- driver: MongoDriver,
67
- authConfig?: RebaseAuthConfig,
68
- admin?: DatabaseAdmin,
69
- authAdapter?: AuthAdapter
70
- ) {
71
- // Scoped to this factory invocation rather than the module, so sessions do
72
- // not leak across hot reloads or a second server on the same process — the
73
- // Postgres socket keeps it here for the same reason.
74
- const clientSessions = new Map<string, ClientSession>();
75
-
76
- const isProduction = process.env.NODE_ENV === "production";
77
- const wsDebug = (...args: unknown[]) => { if (!isProduction) console.debug(...args); };
78
- const wss = new WebSocketServer({ server });
79
-
80
- wss.on("error", (err: NodeJS.ErrnoException) => {
81
- if (err.code === "EADDRINUSE") return;
82
- logger.error("❌ [WebSocket Server] Error", { error: err });
83
- });
84
-
85
- // The same predicate the HTTP data routes use, from the same function. See
86
- // `resolveRequireAuth` for what this socket's local copy got wrong — most
87
- // importantly that a `false` here does not skip a check, it marks every
88
- // session `authenticated` at connect time.
89
- const requireAuth = !!authAdapter || resolveRequireAuth(authConfig);
90
-
91
- if (requireAuth && !authAdapter && !authConfig?.jwtSecret) {
92
- logger.warn(
93
- "🔐 [WebSocket Server] Authentication is required but no adapter or jwtSecret is " +
94
- "configured — no client can complete AUTH, so every realtime message will be refused."
95
- );
96
- }
97
-
98
- wss.on("connection", (ws) => {
99
- const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
100
- wsDebug(`WebSocket client connected: ${clientId}`);
101
-
102
- clientSessions.set(clientId, { ws,
103
- authenticated: !requireAuth,
104
- messageCount: 0,
105
- messageWindowStart: Date.now() });
106
- realtimeService.addClient(clientId, ws);
107
-
108
- ws.on("close", () => {
109
- wsDebug(`WebSocket client disconnected: ${clientId}`);
110
- clientSessions.delete(clientId);
111
- });
112
-
113
- ws.on("message", async (message) => {
114
- let requestId: string | undefined;
115
- try {
116
- const { type, payload, requestId: reqId } = JSON.parse(message.toString());
117
- requestId = reqId;
118
-
119
- wsDebug(`[WS] ${clientId} → ${type}`, requestId ? `(${requestId})` : "");
120
-
121
- const sendError = (errType: "ERROR" | "AUTH_ERROR", code: string, msg: string) => {
122
- ws.send(JSON.stringify({ type: errType,
123
- requestId,
124
- payload: { error: { message: msg,
125
- code } } }));
126
- };
127
-
128
- if (type === "AUTHENTICATE") {
129
- const { token } = payload || {};
130
- if (!token) {
131
- sendError("AUTH_ERROR", "INVALID_INPUT", "Token is required");
132
- return;
133
- }
134
-
135
- // The adapter verifies when one is configured, exactly as the
136
- // HTTP routes do; the built-in JWT path is the fallback.
137
- let verifiedUser: WsUserIdentity | null = null;
138
-
139
- if (authAdapter) {
140
- try {
141
- const adapterUser = authAdapter.verifyToken
142
- ? await authAdapter.verifyToken(token)
143
- : await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
144
- headers: { Authorization: `Bearer ${token}` }
145
- }));
146
- if (adapterUser) {
147
- verifiedUser = {
148
- uid: adapterUser.uid,
149
- email: adapterUser.email,
150
- roles: adapterUser.roles ?? [],
151
- isAdmin: !!adapterUser.isAdmin
152
- };
153
- }
154
- } catch {
155
- // Adapter threw — treat as invalid token
156
- }
157
- } else {
158
- const jwtPayload = await extractUserFromToken(token);
159
- if (jwtPayload) {
160
- verifiedUser = {
161
- uid: jwtPayload.uid,
162
- email: jwtPayload.email,
163
- displayName: jwtPayload.displayName,
164
- photoURL: jwtPayload.photoURL,
165
- roles: jwtPayload.roles ?? [],
166
- isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin")
167
- };
168
- }
169
- }
170
-
171
- if (verifiedUser) {
172
- const session = clientSessions.get(clientId);
173
- if (session) {
174
- session.user = verifiedUser;
175
- session.authenticated = true;
176
- }
177
- ws.send(JSON.stringify({ type: "AUTH_SUCCESS",
178
- requestId,
179
- payload: { uid: verifiedUser.uid,
180
- roles: verifiedUser.roles } }));
181
- } else {
182
- sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
183
- }
184
- return;
185
- }
186
-
187
- if (requireAuth) {
188
- const session = clientSessions.get(clientId);
189
- if (!session?.authenticated) {
190
- sendError("ERROR", "UNAUTHORIZED", "Authentication required");
191
- return;
192
- }
193
- }
194
-
195
- {
196
- const session = clientSessions.get(clientId);
197
- if (session) {
198
- const now = Date.now();
199
- if (now - session.messageWindowStart > WS_RATE_WINDOW_MS) {
200
- session.messageCount = 0;
201
- session.messageWindowStart = now;
202
- }
203
- session.messageCount++;
204
- if (session.messageCount > WS_RATE_LIMIT) {
205
- sendError("ERROR", "RATE_LIMITED", "Too many requests. Please slow down.");
206
- return;
207
- }
208
- }
209
- }
210
-
211
- if (ADMIN_ONLY_TYPES.has(type)) {
212
- const session = clientSessions.get(clientId);
213
- if (!isAdminSession(session)) {
214
- sendError("ERROR", "FORBIDDEN", "Admin access required for this operation");
215
- return;
216
- }
217
- }
218
-
219
- /** @see the Postgres socket — same rule, same reason. */
220
- const assertWriteRequest = (path: string | undefined, values: unknown): void => {
221
- if (!path || !values || typeof values !== "object") return;
222
- const collection = driver.registry?.getCollectionByPath(path);
223
- if (!collection) return;
224
- assertWriteRequestValid(values as Record<string, unknown>, collection);
225
- };
226
-
227
- const getScopedDelegate = async (): Promise<DataDriver> => {
228
- const session = clientSessions.get(clientId);
229
- if (session?.user && isDriverWithAuth(driver)) {
230
- try {
231
- const userForAuth: User = {
232
- uid: session.user.uid,
233
- email: session.user.email ?? "",
234
- displayName: session.user.displayName ?? "",
235
- photoURL: session.user.photoURL ?? "",
236
- providerId: "jwt",
237
- isAnonymous: false,
238
- roles: session.user.roles ?? []
239
- };
240
- return await driver.withAuth(userForAuth);
241
- } catch (e) {
242
- logger.error("Failed to create authenticated delegate for WS request", { error: e });
243
- return driver;
244
- }
245
- }
246
- return driver;
247
- };
248
-
249
- switch (type) {
250
- case "FETCH_COLLECTION": {
251
- const request: FetchCollectionProps = payload;
252
- const delegate = await getScopedDelegate();
253
- const rows = await delegate.fetchCollection(request);
254
- ws.send(JSON.stringify({ type: "FETCH_COLLECTION_SUCCESS",
255
- payload: { rows },
256
- requestId }));
257
- break;
258
- }
259
- case "FETCH_ONE": {
260
- const request: FetchOneProps = payload;
261
- const delegate = await getScopedDelegate();
262
- const row = await delegate.fetchOne(request);
263
- ws.send(JSON.stringify({ type: "FETCH_ONE_SUCCESS",
264
- payload: { row },
265
- requestId }));
266
- break;
267
- }
268
- case "SAVE": {
269
- const request: SaveProps = payload;
270
- // The REST layer's write checks, at this boundary too —
271
- // the socket is a second way in, and it used to be the
272
- // unchecked one. Collection from the registry by path,
273
- // never from the client's `request.collection`.
274
- assertWriteRequest(request.path, request.values as Record<string, unknown>);
275
- const delegate = await getScopedDelegate();
276
- const row = await delegate.save(request);
277
- ws.send(JSON.stringify({ type: "SAVE_SUCCESS",
278
- payload: { row },
279
- requestId }));
280
- break;
281
- }
282
- case "DELETE": {
283
- const request: DeleteProps = payload;
284
- const delegate = await getScopedDelegate();
285
- await delegate.delete(request);
286
- ws.send(JSON.stringify({ type: "DELETE_SUCCESS",
287
- payload: { success: true },
288
- requestId }));
289
- break;
290
- }
291
- case "CHECK_UNIQUE_FIELD": {
292
- const { path, name, value, id, collection } = payload;
293
- const delegate = await getScopedDelegate();
294
- const isUnique = await delegate.checkUniqueField(path, name, value, id, collection);
295
- ws.send(JSON.stringify({ type: "CHECK_UNIQUE_FIELD_SUCCESS",
296
- payload: { isUnique },
297
- requestId }));
298
- break;
299
- }
300
- case "COUNT": {
301
- const request: FetchCollectionProps = payload;
302
- const delegate = await getScopedDelegate();
303
- const count = await delegate.count!(request);
304
- ws.send(JSON.stringify({ type: "COUNT_SUCCESS",
305
- payload: { count },
306
- requestId }));
307
- break;
308
- }
309
- case "EXECUTE_SQL": {
310
- const { sql, options } = payload;
311
- if (admin && isDocumentAdmin(admin) && admin.executeAggregate) {
312
- const result = await admin.executeAggregate(sql as Record<string, unknown>[]);
313
- ws.send(JSON.stringify({ type: "EXECUTE_SQL_SUCCESS",
314
- payload: { result },
315
- requestId }));
316
- } else {
317
- ws.send(JSON.stringify({ type: "ERROR",
318
- requestId,
319
- payload: { error: { message: "SQL execution not supported for this driver",
320
- code: "NOT_SUPPORTED" } } }));
321
- }
322
- break;
323
- }
324
- case "FETCH_UNMAPPED_TABLES": {
325
- if (admin && isSchemaAdmin(admin)) {
326
- const tables = await admin.fetchUnmappedTables?.(payload?.mappedPaths) || [];
327
- ws.send(JSON.stringify({ type: "FETCH_UNMAPPED_TABLES_SUCCESS",
328
- payload: { tables },
329
- requestId }));
330
- } else {
331
- ws.send(JSON.stringify({ type: "FETCH_UNMAPPED_TABLES_SUCCESS",
332
- payload: { tables: [] },
333
- requestId }));
334
- }
335
- break;
336
- }
337
- case "FETCH_TABLE_METADATA": {
338
- const { tableName } = payload;
339
- if (admin && isSchemaAdmin(admin)) {
340
- const metadata = await admin.fetchTableMetadata?.(tableName);
341
- ws.send(JSON.stringify({ type: "FETCH_TABLE_METADATA_SUCCESS",
342
- payload: { metadata },
343
- requestId }));
344
- } else {
345
- ws.send(JSON.stringify({ type: "FETCH_TABLE_METADATA_SUCCESS",
346
- payload: { metadata: null },
347
- requestId }));
348
- }
349
- break;
350
- }
351
- case "subscribe_collection":
352
- case "subscribe_one":
353
- case "unsubscribe": {
354
- const session = clientSessions.get(clientId);
355
- const authContext = session?.user ? { uid: session.user.uid,
356
- roles: session.user.roles ?? [] } : undefined;
357
- await realtimeService.handleClientMessage(clientId, {
358
- type,
359
- payload,
360
- subscriptionId: payload?.subscriptionId
361
- }, authContext);
362
- break;
363
- }
364
- default:
365
- logger.error("❌ [WebSocket Server] Unknown message type", { detail: type });
366
- }
367
- } catch (error: unknown) {
368
- // A refused write keeps its message: it is the only thing that
369
- // tells the caller what to send instead, and the generic branch
370
- // below drops it in production.
371
- if (error instanceof ApiError || (error as Error)?.name === "ApiError") {
372
- const apiError = error as ApiError;
373
- ws.send(JSON.stringify({ type: "ERROR",
374
- requestId,
375
- payload: { error: { message: apiError.message,
376
- code: apiError.code } } }));
377
- return;
378
- }
379
- const errorMessage = process.env.NODE_ENV === "production" ? "An unexpected error occurred" : (error instanceof Error ? error.message : "An unexpected error occurred");
380
- ws.send(JSON.stringify({ type: "ERROR",
381
- requestId,
382
- payload: { error: { message: errorMessage,
383
- code: "INTERNAL_ERROR" } } }));
384
- }
385
- });
386
- });
387
- }