@hitchy/plugin-odem-socket.io 0.2.4 → 0.4.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.
@@ -1,16 +1,20 @@
1
- "use strict";
1
+ import { promisify } from "node:util";
2
2
 
3
- module.exports = function() {
3
+ /** */
4
+ export default function() {
4
5
  const api = this;
5
6
 
6
- const logDebug = api.log( "hitchy:odem:socket.io:debug" );
7
- const logError = api.log( "hitchy:odem:socket.io:error" );
7
+ const logDebug = api.log( "hitchy:plugin:odem:socket.io:debug" );
8
+ const logInfo = api.log( "hitchy:plugin:odem:socket.io:info" );
9
+ const logError = api.log( "hitchy:plugin:odem:socket.io:error" );
8
10
 
9
11
  /**
10
12
  * Implements API for controlling and monitoring server-side
11
13
  * document-oriented database via websocket.
12
14
  */
13
15
  class OdemWebsocketProvider {
16
+ static #socketUsers = new Map();
17
+
14
18
  /**
15
19
  * Integrates this provider with server-side websocket.
16
20
  *
@@ -19,184 +23,347 @@ module.exports = function() {
19
23
  static start() {
20
24
  api.once( "websocket", io => {
21
25
  const { crud, notifications } = api.config.socket.odem;
22
-
23
26
  const namespace = io.of( "/hitchy/odem" );
24
27
 
28
+
29
+ // - transmit notifications to clients ------------------------
30
+
25
31
  const txPerModel = {};
26
32
 
27
33
  for ( const [ modelName, model ] of Object.entries( api.models ) ) {
28
- txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, modelName, model );
34
+ if ( api.service.OdemSchema.mayBeExposed( { user: undefined }, model ) ) {
35
+ txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, model );
36
+ }
29
37
  }
30
38
 
31
- namespace.on( "connection", socket => {
32
- const rxPerModel = {};
33
-
34
- for ( const [ modelName, model ] of Object.entries( api.models ) ) {
35
- rxPerModel[modelName] = this.handleModelRequests( crud, socket, modelName, model );
36
- }
39
+ api.once( "close", () => {
40
+ namespace.disconnectSockets( true );
37
41
 
38
- socket.once( "disconnect", () => {
39
- for ( const [ modelName, handlers ] of Object.entries( rxPerModel ) ) {
40
- for ( const [ eventName, handler ] of Object.entries( handlers ) ) {
41
- const prefix = api.utility.case.pascalToKebab( modelName );
42
+ for ( const [ modelName, handlers ] of Object.entries( txPerModel ) ) {
43
+ if ( handlers ) {
44
+ const model = api.models[modelName];
42
45
 
43
- socket.off( `${prefix}:${eventName}`, handler );
44
- }
46
+ model.notifications
47
+ .off( "created", handlers.created )
48
+ .off( "changed", handlers.changed )
49
+ .off( "removed", handlers.removed );
45
50
  }
46
- } );
51
+ }
47
52
  } );
48
53
 
49
- api.once( "close", () => {
50
- namespace.disconnectSockets( true );
51
54
 
52
- for ( const [ modelName, model ] of Object.entries( api.models ) ) {
53
- const handlers = txPerModel[modelName];
55
+ // - receive requests from clients ----------------------------
54
56
 
55
- model.notifications
56
- .off( "created", handlers.created )
57
- .off( "changed", handlers.changed )
58
- .off( "removed", handlers.removed );
59
- }
57
+ namespace.on( "connection", socket => {
58
+ this.getUserForSocket( socket );
59
+
60
+ const listener = ( query, ...args ) => this.handleControlRequest( crud, socket, query, args );
61
+
62
+ socket.onAny( listener );
63
+
64
+ socket.once( "disconnect", () => {
65
+ socket.offAny( listener );
66
+
67
+ this.#socketUsers.delete( socket.id );
68
+ } );
60
69
  } );
61
70
  } );
62
71
  }
63
72
 
64
73
  /**
65
- * Registers listeners for handling client-side events requesting
66
- * model-related actions.
74
+ * Monitors models of document-oriented database and sends notifications
75
+ * to connected clients in case items of either model have changed.
67
76
  *
68
77
  * @param {boolean} enabled true if command handling is enabled in configuration
69
- * @param {WebSocket} socket socket representing established incoming connection
70
- * @param {string} modelName name of model to manage
71
- * @param {class<Hitchy.Plugin.Odem.Model>} model model instance based on document-oriented database to manage
72
- * @returns {Object<string,function>} map of registered handler functions
78
+ * @param {Server} namespace namespace managing a list of server-side sockets with common prefix
79
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model model to monitor for change of items
80
+ * @returns {Object<string,function>|undefined} map of registered handler functions
73
81
  */
74
- static handleModelRequests( enabled, socket, modelName, model ) {
75
- const prefix = api.utility.case.pascalToKebab( modelName );
76
-
77
- if ( !enabled ) {
78
- logDebug( "disabling socket-based action requests for model %s (%s)", modelName, prefix );
79
-
82
+ static broadcastModelNotifications( enabled, namespace, Model ) {
83
+ if ( enabled ) {
80
84
  const handlers = {
81
- list: ( _, __, ___, response ) => reject( response ),
82
- find: ( _, __, ___, ____, response ) => reject( response ),
83
- create: ( _, __, response ) => reject( response ),
84
- read: ( _, __, response ) => reject( response ),
85
- update: ( _, __, ___, response ) => reject( response ),
86
- delete: ( _, __, response ) => reject( response ),
85
+ created: ( uuid, newRecord, asyncGeneratorFn ) => {
86
+ asyncGeneratorFn()
87
+ .then( item => this.namespaceEmit( namespace, Model, {
88
+ change: "created",
89
+ model: Model.name,
90
+ properties: this.serializeItem( item, false ),
91
+ }, true ) )
92
+ .catch( cause => {
93
+ logError( "broadcasting notification on having created item %s of %s has failed:", uuid, Model.name, cause.stack );
94
+ } );
95
+ },
96
+ changed: ( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
97
+ asyncGeneratorFn()
98
+ .then( item => this.namespaceEmit( namespace, Model, {
99
+ change: "updated",
100
+ model: Model.name,
101
+ properties: this.serializeItem( item, false ),
102
+ }, true ) )
103
+ .catch( cause => {
104
+ logError( "broadcasting notification on having updated item %s of %s has failed:", uuid, Model.name, cause.stack );
105
+ } );
106
+ },
107
+ removed: uuid => {
108
+ this.namespaceEmit( namespace, Model, {
109
+ change: "deleted",
110
+ model: Model.name,
111
+ properties: { uuid: String( uuid ) },
112
+ }, false )
113
+ .catch( cause => {
114
+ logError( "broadcasting notification on having removed item %s of %s has failed:", uuid, Model.name, cause.stack );
115
+ } );
116
+ },
87
117
  };
88
118
 
89
- socket
90
- .on( `${prefix}:list`, handlers.list )
91
- .on( `${prefix}:find`, handlers.find )
92
- .on( `${prefix}:create`, handlers.create )
93
- .on( `${prefix}:read`, handlers.read )
94
- .on( `${prefix}:update`, handlers.update )
95
- .on( `${prefix}:delete`, handlers.delete );
119
+ Model.notifications
120
+ .on( "created", handlers.created )
121
+ .on( "changed", handlers.changed )
122
+ .on( "removed", handlers.removed );
96
123
 
97
124
  return handlers;
98
125
  }
99
126
 
100
- logDebug( "enabling socket-based action requests for model %s (%s)", modelName, prefix );
127
+ return undefined;
128
+ }
101
129
 
102
- const handlers = {
103
- list: ( ...args ) => this.handleListRequest( modelName, model, ...args ),
104
- find: ( ...args ) => this.handleFindRequest( modelName, model, ...args ),
105
- create: ( ...args ) => this.handleCreateRequest( modelName, model, ...args ),
106
- read: ( ...args ) => this.handleReadRequest( modelName, model, ...args ),
107
- update: ( ...args ) => this.handleUpdateRequest( modelName, model, ...args ),
108
- delete: ( ...args ) => this.handleDeleteRequest( modelName, model, ...args ),
109
- };
130
+ /**
131
+ * Reads promise for given socket's requesting user from local cache.
132
+ *
133
+ * This method is mocking Hitchy's request dispatching to invoke
134
+ * policies of @hitchy/plugin-auth involved in setting up a requesting
135
+ * user to be returned here eventually. The mocked request is based on
136
+ * the provided socket's initial request.
137
+ *
138
+ * @param {WebSocket} socket socket to look up
139
+ * @returns {Promise<User|undefined>} promise for user of provided socket
140
+ */
141
+ static async getUserForSocket( socket ) {
142
+ if ( !this.#socketUsers.has( socket.id ) ) {
143
+ this.#socketUsers.set( socket.id, ( async _socket => {
144
+ if ( api.plugins.authentication ) {
145
+ const { SessionInjector, Cookies } = api.service;
146
+ const { Authentication } = api.policy;
147
+ const useSession = api.plugins.session && !api.config?.session?.disable;
148
+ const headers = _socket.request?.headers;
149
+
150
+ // mock context used by Hitchy for dispatching requests
151
+ const request = {
152
+ headers,
153
+ cookies: Cookies.parseFromString( headers?.cookie ),
154
+ };
155
+
156
+ const response = {
157
+ set: () => {}, // eslint-disable-line no-empty-function
158
+ };
110
159
 
111
- socket
112
- .on( `${prefix}:list`, handlers.list )
113
- .on( `${prefix}:find`, handlers.find )
114
- .on( `${prefix}:create`, handlers.create )
115
- .on( `${prefix}:read`, handlers.read )
116
- .on( `${prefix}:update`, handlers.update )
117
- .on( `${prefix}:delete`, handlers.delete );
160
+ const context = {
161
+ local: {},
162
+ updateSessionId: ( _, __, fn ) => fn(),
163
+ };
164
+
165
+ // invoke policies usually injecting a requesting user into a request
166
+ if ( useSession ) {
167
+ try {
168
+ await SessionInjector.injectIntoRequest( request, response );
169
+ await promisify( Authentication.injectPassport.bind( context ) )( request, response );
170
+ } catch ( cause ) {
171
+ logError( "mocking session-handling and passport injection has failed:", cause );
172
+ }
173
+ }
174
+
175
+ if ( !request.user ) {
176
+ try {
177
+ await promisify( Authentication.handleBasicAuth.bind( context ) )( request, response );
178
+ } catch ( cause ) {
179
+ logError( "mocking Basic authentication handling has failed:", cause );
180
+ }
181
+ }
182
+
183
+ // pick user eventually injected into mocked request
184
+ return request.user || undefined;
185
+ }
118
186
 
119
- return handlers;
187
+ return undefined;
188
+ } )( socket ) );
189
+ }
190
+
191
+ return await this.#socketUsers.get( socket.id );
120
192
  }
121
193
 
122
194
  /**
123
- * Sets up listeners for model-related notifications in
124
- * document-oriented database and forwards them as broadcast events to
125
- * connected clients.
195
+ * Broadcasts a change notification to every connected peer in a given
196
+ * namespace individually considering either peer's authorization for
197
+ * accessing the changed model and/or its properties.
126
198
  *
127
- * @param {boolean} enabled true if command handling is enabled in configuration
128
- * @param {Server} namespace namespace managing a list of server-side sockets with common prefix
129
- * @param {string} modelName name of model to manage
130
- * @param {class<Hitchy.Plugin.Odem.Model>} model model instance of document-oriented database to manage
131
- * @returns {Object<string,function>} map of registered handler functions
199
+ * @param {Server} namespace namespace managing a pool of sockets that have joined the namespace
200
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model model of changed item
201
+ * @param {{change: string, model: string, properties: Object}} payload raw payload of model-specific notification
202
+ * @param {boolean} filterProperties if true, properties need to be filtered per peer based on its authorization
203
+ * @returns {Promise<void>} promise resolved when all authorized peers have been notified
132
204
  */
133
- static broadcastModelNotifications( enabled, namespace, modelName, model ) {
134
- if ( enabled ) {
135
- const prefix = api.utility.case.pascalToKebab( modelName );
205
+ static async namespaceEmit( namespace, Model, payload, filterProperties ) {
206
+ const hasPluginAuth = Boolean( api.plugins.authentication );
207
+ const sockets = await namespace.fetchSockets();
136
208
 
137
- const handlers = {
138
- created: async( uuid, newRecord, asyncGeneratorFn ) => {
139
- const event = {
140
- change: "created",
141
- properties: ( await asyncGeneratorFn() ).toObject( { serialized: true } ),
142
- };
209
+ logDebug( "forwarding notification to %d socket(s)", sockets.length );
143
210
 
144
- namespace.emit( `${prefix}:changed`, event );
145
- },
146
- changed: async( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
147
- const event = {
148
- change: "updated",
149
- properties: ( await asyncGeneratorFn() ).toObject( { serialized: true } ),
150
- };
211
+ const notifyPeer = async socket => {
212
+ const { OdemSchema, Authorization } = api.service;
213
+ let user;
151
214
 
152
- namespace.emit( `${prefix}:changed`, event );
153
- },
154
- removed: uuid => {
155
- const event = {
156
- change: "deleted",
157
- properties: { uuid: model.formatUUID( uuid ) },
158
- };
215
+ try {
216
+ user = await this.#socketUsers.get( socket.id );
217
+ } catch ( cause ) {
218
+ logError( `discovering user of socket ${socket.id} has failed:`, cause );
219
+ return;
220
+ }
159
221
 
160
- namespace.emit( `${prefix}:changed`, event );
161
- },
162
- };
222
+ if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
223
+ logDebug( `omit notification to user ${user?.uuid} (${user.name}) at peer ${socket.id} with access on ${Model.name} forbidden by model` );
224
+ return;
225
+ }
163
226
 
164
- model.notifications
165
- .on( "created", handlers.created )
166
- .on( "changed", handlers.changed )
167
- .on( "removed", handlers.removed );
227
+ let mayRead = OdemSchema.isAdmin( user );
228
+ let scope = "read";
168
229
 
169
- return handlers;
230
+ if ( !mayRead && hasPluginAuth ) {
231
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
232
+
233
+ if ( !mayRead ) {
234
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
235
+ scope = "list";
236
+ }
237
+ }
238
+
239
+ const prefix = api.utility.case.pascalToKebab( Model.name );
240
+
241
+ if ( !mayRead ) {
242
+ // user must not read items of this model -> do not provide any identifier, but basically notify on some change to the model
243
+ socket.emit( `${prefix}:changed`, { ...payload, properties: {} } );
244
+ socket.emit( "*:changed", { ...payload, properties: {} } );
245
+ } else if ( filterProperties ) {
246
+ const copy = { ...payload };
247
+
248
+ copy.properties = OdemSchema.filterItem( copy.properties, { user }, Model, scope );
249
+
250
+ socket.emit( `${prefix}:changed`, copy );
251
+ socket.emit( "*:changed", copy );
252
+ } else {
253
+ socket.emit( `${prefix}:changed`, payload );
254
+ socket.emit( "*:changed", payload );
255
+ }
256
+ };
257
+
258
+ for ( const socket of sockets ) {
259
+ notifyPeer( socket ).catch( cause => {
260
+ logError(
261
+ `notifying peer ${socket.id} on having ${payload.change} item of ${Model.name} has failed:`,
262
+ cause.stack
263
+ );
264
+ } );
170
265
  }
266
+ }
171
267
 
172
- return {};
268
+ /**
269
+ * Commonly handles incoming messages to process those requesting
270
+ * supported control actions on existing models.
271
+ *
272
+ * @param {boolean} enabled if true, this plugin's control API has been enabled in configuration
273
+ * @param {WebSocket} socket request-receiving socket
274
+ * @param {string} query name of event that has been emitted by peer socket to request some action
275
+ * @param {any[]} args arguments of incoming request and a callback for sending a response in final argument
276
+ * @returns {void}
277
+ */
278
+ static handleControlRequest( enabled, socket, query, args ) {
279
+ const match = /^([a-zA-Z][a-zA-Z0-9]+):(list|find|create|read|write|update|remove|delete)$/.exec( query );
280
+
281
+ if ( !match ) {
282
+ logDebug( "ignoring socket-based request %s due to mismatching format", query );
283
+ return;
284
+ }
285
+
286
+ const [ , prefix, action ] = match;
287
+ const modelName = api.utility.case.kebabToPascal( prefix );
288
+ const Model = api.model[modelName];
289
+
290
+ if ( !Model ) {
291
+ logDebug( "rejecting socket-based request %s due to addressing unknown model %s", query, modelName );
292
+
293
+ args[args.length - 1]( {
294
+ error: "no such model " + modelName,
295
+ } );
296
+ return;
297
+ }
298
+
299
+ const handler = {
300
+ list: "handleListRequest",
301
+ find: "handleFindRequest",
302
+ create: "handleCreateRequest",
303
+ read: "handleReadRequest",
304
+ write: "handleUpdateRequest",
305
+ update: "handleUpdateRequest",
306
+ remove: "handleDeleteRequest",
307
+ delete: "handleDeleteRequest",
308
+ }[action];
309
+
310
+ if ( !handler ) {
311
+ logDebug( "ignoring socket-based request for actin %s on model %s due to lack of handler", action, Model.name );
312
+ return;
313
+ }
314
+
315
+ if ( enabled ) {
316
+ this[handler]( socket, Model, ...args );
317
+ } else {
318
+ logDebug( "rejecting socket-based request for action %s on model %s", action, Model.name );
319
+
320
+ reject( args[args.length - 1] );
321
+ }
173
322
  }
174
323
 
175
324
  /**
176
325
  * Handles incoming request for listing items of a model.
177
326
  *
178
- * @param {string} modelName name of model
179
- * @param {string} model named server-side model
180
- * @param {string} sessionId client-provided session ID
327
+ * @param {WebSocket} socket receiving socket of request
328
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
181
329
  * @param {Hitchy.Odem.ListOptions} queryOptions client-provided options for listing items
182
330
  * @param {boolean} loadRecords if true, whole records shall be fetched per item
183
331
  * @param {function} response callback to invoke with response
184
332
  * @returns {Promise<void>} promise settled on request handled
185
333
  */
186
- static async handleListRequest( modelName, model, sessionId, queryOptions, loadRecords, response ) {
187
- logDebug( "request for listing %s instances w/ queryOptions %j and loadRecords %j", modelName, queryOptions, Boolean( loadRecords ) );
334
+ static async handleListRequest( socket, Model, queryOptions, loadRecords, response ) {
335
+ logDebug( "request for listing %s instances w/ queryOptions %j and loadRecords %j", Model.name, queryOptions, Boolean( loadRecords ) );
188
336
 
189
337
  try {
338
+ const user = await this.getUserForSocket( socket );
339
+
340
+ if ( api.plugins.authentication ) {
341
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` ) ) {
342
+ throw new Error( "access forbidden" );
343
+ }
344
+ }
345
+
346
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
347
+ throw new Error( "access forbidden by model" );
348
+ }
349
+
190
350
  const meta = {};
191
- const items = await model.list( queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
351
+ const items = await Model.list( queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
352
+ let filter;
353
+
354
+ if ( loadRecords ) {
355
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), { user }, Model, "list" );
356
+ } else {
357
+ filter = item => ( { uuid: item.uuid } );
358
+ }
192
359
 
193
360
  response( {
194
361
  success: true,
195
362
  count: meta.count,
196
- items: items.map( item => ( loadRecords ? item.toObject( { serialized: true } ) : { uuid: item.uuid } ) ),
363
+ items: items.map( filter ),
197
364
  } );
198
365
  } catch ( error ) {
199
- logError( "listing %s instances failed: %s", modelName, error.stack );
366
+ logError( "listing %s instances failed: %s", Model.name, error.stack );
200
367
 
201
368
  response( {
202
369
  error: error.message,
@@ -208,29 +375,47 @@ module.exports = function() {
208
375
  * Handles incoming request for finding items of a model matching some
209
376
  * provided query.
210
377
  *
211
- * @param {string} modelName name of model
212
- * @param {string} model named server-side model
213
- * @param {string} sessionId client-provided session ID
378
+ * @param {WebSocket} socket receiving socket of request
379
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
214
380
  * @param {Hitchy.Odem.Query} query client-provided constraints to be met by returned items
215
381
  * @param {Hitchy.Odem.ListOptions} queryOptions client-provided options for listing items
216
382
  * @param {boolean} loadRecords if true, whole records shall be fetched per item
217
383
  * @param {function} response callback to invoke with response
218
384
  * @returns {Promise<void>} promise settled on request handled
219
385
  */
220
- static async handleFindRequest( modelName, model, sessionId, query, queryOptions, loadRecords, response ) {
221
- logDebug( "request for finding %s instances matching %j w/ queryOptions %j and uuidsOnly %j", modelName, query, queryOptions, Boolean( loadRecords ) );
386
+ static async handleFindRequest( socket, Model, query, queryOptions, loadRecords, response ) {
387
+ logDebug( "request for finding %s instances matching %j w/ queryOptions %j and uuidsOnly %j", Model.name, query, queryOptions, Boolean( loadRecords ) );
222
388
 
223
389
  try {
390
+ const user = await this.getUserForSocket( socket );
391
+
392
+ if ( api.plugins.authentication ) {
393
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` ) ) {
394
+ throw new Error( "access forbidden" );
395
+ }
396
+ }
397
+
398
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
399
+ throw new Error( "access forbidden by model" );
400
+ }
401
+
224
402
  const meta = {};
225
- const items = await model.find( query, queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
403
+ const items = await Model.find( query, queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
404
+ let filter;
405
+
406
+ if ( loadRecords ) {
407
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), { user }, Model, "list" );
408
+ } else {
409
+ filter = item => ( { uuid: item.uuid } );
410
+ }
226
411
 
227
412
  response( {
228
413
  success: true,
229
414
  count: meta.count,
230
- items: items.map( item => ( loadRecords ? item.toObject( { serialized: true } ) : { uuid: item.uuid } ) ),
415
+ items: items.map( filter ),
231
416
  } );
232
417
  } catch ( error ) {
233
- logError( "finding %s instances matching %j failed: %s", modelName, error.stack );
418
+ logError( "finding %s instances matching %j failed: %s", Model.name, query, error.stack );
234
419
 
235
420
  response( {
236
421
  error: error.message,
@@ -241,24 +426,35 @@ module.exports = function() {
241
426
  /**
242
427
  * Handles incoming request for creating item of a model.
243
428
  *
244
- * @param {string} modelName name of model
245
- * @param {string} model named server-side model
246
- * @param {string} sessionId client-provided session ID
429
+ * @param {WebSocket} socket receiving socket of request
430
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
247
431
  * @param {Hitchy.Odem.ListOptions} properties properties of item to create
248
432
  * @param {function} response callback to invoke with response
249
433
  * @returns {Promise<void>} promise settled on request handled
250
434
  */
251
- static async handleCreateRequest( modelName, model, sessionId, properties, response ) {
252
- logDebug( "request for creating %s instance with %j", modelName, properties );
435
+ static async handleCreateRequest( socket, Model, properties, response ) {
436
+ logDebug( "request for creating %s instance with %j", Model.name, reduce( properties ) );
437
+
438
+ const { Authorization, OdemSchema } = api.service;
253
439
 
254
440
  try {
255
- const schema = model.schema;
256
- const instance = new model; // eslint-disable-line new-cap
441
+ const user = await this.getUserForSocket( socket );
442
+
443
+ if ( api.plugins.authentication && !await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.create` ) ) {
444
+ throw new Error( "access forbidden" );
445
+ }
446
+
447
+ if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
448
+ throw new Error( "access forbidden by model" );
449
+ }
450
+
451
+ const schema = Model.schema;
452
+ const filtered = OdemSchema.filterItem( properties, { user }, Model, "create" );
453
+ const instance = new Model();
257
454
 
258
- for ( const propName of Object.keys( properties ) ) {
259
- if ( schema.props.hasOwnProperty( propName ) ||
260
- schema.computed.hasOwnProperty( propName ) ) {
261
- instance[propName] = properties[propName];
455
+ for ( const propName of Object.keys( filtered ) ) {
456
+ if ( schema.props.hasOwnProperty( propName ) || schema.computed.hasOwnProperty( propName ) ) {
457
+ instance[propName] = filtered[propName];
262
458
  }
263
459
  }
264
460
 
@@ -266,10 +462,10 @@ module.exports = function() {
266
462
 
267
463
  response( {
268
464
  success: true,
269
- properties: instance.toObject( { serialized: true } ),
465
+ properties: OdemSchema.filterItem( this.serializeItem( instance, false ), { user }, Model, "read" ),
270
466
  } );
271
467
  } catch ( error ) {
272
- logError( "creating %s instance failed: %s", modelName, error.stack );
468
+ logError( "creating %s instance failed: %s", Model.name, error.stack );
273
469
 
274
470
  response( {
275
471
  error: error.message,
@@ -280,26 +476,37 @@ module.exports = function() {
280
476
  /**
281
477
  * Handles incoming request for reading properties of a single item.
282
478
  *
283
- * @param {string} modelName name of item's model
284
- * @param {string} model named server-side model
285
- * @param {string} sessionId client-provided session ID
479
+ * @param {WebSocket} socket receiving socket of request
480
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
286
481
  * @param {string} uuid client-provided UUID of item to read
287
482
  * @param {function} response callback to invoke with response
288
483
  * @returns {Promise<void>} promise settled on request handled
289
484
  */
290
- static async handleReadRequest( modelName, model, sessionId, uuid, response ) {
291
- logDebug( "request for reading %s instance %s", modelName, uuid );
485
+ static async handleReadRequest( socket, Model, uuid, response ) {
486
+ logDebug( "request for reading %s instance %s", Model.name, uuid );
292
487
 
293
488
  try {
294
- const instance = new model( uuid ); // eslint-disable-line new-cap
489
+ const user = await this.getUserForSocket( socket );
490
+
491
+ if ( api.plugins.authentication ) {
492
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` ) ) {
493
+ throw new Error( "access forbidden" );
494
+ }
495
+ }
496
+
497
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
498
+ throw new Error( "access forbidden by model" );
499
+ }
500
+
501
+ const instance = new Model( uuid );
295
502
  await instance.load();
296
503
 
297
504
  response( {
298
505
  success: true,
299
- properties: instance.toObject( { serialized: true } ),
506
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, true ), { user }, Model, "read" ),
300
507
  } );
301
508
  } catch ( error ) {
302
- logError( "reading %s instance failed: %s", modelName, error.stack );
509
+ logError( "reading %s instance failed: %s", Model.name, error.stack );
303
510
 
304
511
  response( {
305
512
  error: error.message,
@@ -310,24 +517,37 @@ module.exports = function() {
310
517
  /**
311
518
  * Handles incoming request for adjusting properties of a single item.
312
519
  *
313
- * @param {string} modelName name of item's model
314
- * @param {string} model named server-side model
315
- * @param {string} sessionId client-provided session ID
520
+ * @param {WebSocket} socket receiving socket of request
521
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
316
522
  * @param {string} uuid client-provided UUID of item to modify
317
523
  * @param {Object} properties client-provided properties of item replacing its existing ones
318
524
  * @param {function} response callback to invoke with response
319
525
  * @returns {Promise<void>} promise settled on request handled
320
526
  */
321
- static async handleUpdateRequest( modelName, model, sessionId, uuid, properties, response ) {
322
- logDebug( "request for updating %s instance %s with %j", modelName, uuid, properties );
527
+ static async handleUpdateRequest( socket, Model, uuid, properties, response ) {
528
+ logDebug( "request for updating %s instance %s with %j", Model.name, uuid, reduce( properties ) );
323
529
 
324
530
  try {
325
- const instance = new model( uuid ); // eslint-disable-line new-cap
531
+ const user = await this.getUserForSocket( socket );
532
+
533
+ if ( api.plugins.authentication ) {
534
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.write` ) ) {
535
+ throw new Error( "access forbidden" );
536
+ }
537
+ }
538
+
539
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
540
+ throw new Error( "access forbidden by model" );
541
+ }
542
+
543
+ const schema = Model.schema;
544
+ const filtered = api.service.OdemSchema.filterItem( properties, { user }, Model, "write" );
545
+ const instance = new Model( uuid );
326
546
  await instance.load();
327
547
 
328
- for ( const propName of Object.keys( properties ) ) {
329
- if ( model.schema.props.hasOwnProperty( propName ) || model.schema.computed.hasOwnProperty( propName ) ) {
330
- instance[propName] = properties[propName];
548
+ for ( const [ propName, propValue ] of Object.entries( filtered ) ) {
549
+ if ( schema.props.hasOwnProperty( propName ) || schema.computed.hasOwnProperty( propName ) ) {
550
+ instance[propName] = propValue;
331
551
  }
332
552
  }
333
553
 
@@ -335,10 +555,10 @@ module.exports = function() {
335
555
 
336
556
  response( {
337
557
  success: true,
338
- properties: instance.toObject( { serialized: true } ),
558
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, false ), { user }, Model, "read" ),
339
559
  } );
340
560
  } catch ( error ) {
341
- logError( "updating %s instance failed: %s", modelName, error.stack );
561
+ logError( "updating %s instance failed: %s", Model.name, error.stack );
342
562
 
343
563
  response( {
344
564
  error: error.message,
@@ -349,18 +569,29 @@ module.exports = function() {
349
569
  /**
350
570
  * Handles incoming request for removing a model's item.
351
571
  *
352
- * @param {string} modelName name of item's model
353
- * @param {string} model named server-side model
354
- * @param {string} sessionId client-provided session ID
572
+ * @param {WebSocket} socket receiving socket of request
573
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
355
574
  * @param {string} uuid client-provided UUID of item to remove
356
575
  * @param {function} response callback to invoke with response
357
576
  * @returns {Promise<void>} promise settled on request handled
358
577
  */
359
- static async handleDeleteRequest( modelName, model, sessionId, uuid, response ) {
360
- logDebug( "request for deleting %s instance %s", modelName, uuid );
578
+ static async handleDeleteRequest( socket, Model, uuid, response ) {
579
+ logDebug( "request for deleting %s instance %s", Model.name, uuid );
361
580
 
362
581
  try {
363
- const instance = new model( uuid ); // eslint-disable-line new-cap
582
+ const user = await this.getUserForSocket( socket );
583
+
584
+ if ( api.plugins.authentication ) {
585
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.remove` ) ) {
586
+ throw new Error( "access forbidden" );
587
+ }
588
+ }
589
+
590
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
591
+ throw new Error( "access forbidden by model" );
592
+ }
593
+
594
+ const instance = new Model( uuid );
364
595
  await instance.remove();
365
596
 
366
597
  response( {
@@ -368,13 +599,54 @@ module.exports = function() {
368
599
  properties: { uuid },
369
600
  } );
370
601
  } catch ( error ) {
371
- logError( "deleting %s instance %s failed: %s", modelName, uuid, error.stack );
602
+ logError( "deleting %s instance %s failed: %s", Model.name, uuid, error.stack );
372
603
 
373
604
  response( {
374
605
  error: error.message,
375
606
  } );
376
607
  }
377
608
  }
609
+
610
+ /**
611
+ * Serializes provided instance of a model optionally including/excluding
612
+ * properties marked as _lazy_.
613
+ *
614
+ * This method is used to provide reduced sets of properties per item
615
+ * on listing/updating items while delivering all properties when
616
+ * reading individual items.
617
+ *
618
+ * @param {Hitchy.Plugin.Odem.Model} item instance of model
619
+ * @param {boolean} includeLazyProperties if true, properties marked as lazy in schema of model are included with resulting record
620
+ * @returns {object} record of provided item's serialized properties
621
+ */
622
+ static serializeItem( item, includeLazyProperties = false ) {
623
+ const record = item.toObject( { serialized: true } );
624
+
625
+ if ( includeLazyProperties ) {
626
+ return record;
627
+ }
628
+
629
+ const filtered = {};
630
+
631
+ for ( const name of Object.keys( record ) ) {
632
+ let definition = item.constructor.schema.props[name];
633
+
634
+ if ( definition == null ) {
635
+ definition = item.constructor.schema.computed[name];
636
+ }
637
+
638
+ const lazy = definition?.lazy;
639
+
640
+ if ( !lazy ||
641
+ ( typeof lazy === "string" && lazy !== "ws" && lazy !== "socket" && lazy !== "websocket" ) ||
642
+ ( typeof lazy === "object" && !lazy.ws && !lazy.socket && !lazy.websocket )
643
+ ) {
644
+ filtered[name] = record[name];
645
+ }
646
+ }
647
+
648
+ return filtered;
649
+ }
378
650
  }
379
651
 
380
652
  return OdemWebsocketProvider;
@@ -383,7 +655,7 @@ module.exports = function() {
383
655
  /**
384
656
  * Commonly responds to action requests in case of having disabled them.
385
657
  *
386
- * @param {function(response: any):void} respondFn callback to be invoked for responding to a peer event
658
+ * @param {function(any):void} respondFn callback to be invoked for responding to a peer event
387
659
  * @returns {void}
388
660
  */
389
661
  function reject( respondFn ) {
@@ -391,3 +663,42 @@ function reject( respondFn ) {
391
663
  error: "requested action is not available due to runtime configuration",
392
664
  } );
393
665
  }
666
+
667
+ /**
668
+ * Recursively trims values of provided data.
669
+ *
670
+ * @param {*} data data to be trimmed
671
+ * @param {number} limit number of characters to keep in strings when trimming
672
+ * @returns {*} provided data trimmed as necessary
673
+ */
674
+ function reduce( data, limit = 200 ) {
675
+ if ( Array.isArray( data ) ) {
676
+ return data.map( item => reduce( item, Math.max( 10, limit / 5 ) ) );
677
+ }
678
+
679
+ if ( data && typeof data === "object" ) {
680
+ const copy = {};
681
+
682
+ for ( const name of Object.keys( data ) ) {
683
+ copy[name] = reduce( data[name], Math.max( 10, limit / 5 ) );
684
+ }
685
+
686
+ return copy;
687
+ }
688
+
689
+ switch ( typeof data ) {
690
+ case "function" :
691
+ case "string" : {
692
+ const s = String( data );
693
+
694
+ if ( s.length > limit ) {
695
+ return s.substring( 0, limit ) + "…+" + String( limit - s.length );
696
+ }
697
+
698
+ return s;
699
+ }
700
+
701
+ default :
702
+ return data;
703
+ }
704
+ }
package/config/socket.js CHANGED
@@ -1,6 +1,4 @@
1
- "use strict";
2
-
3
- module.exports = {
1
+ export default {
4
2
  socket: {
5
3
  odem: {
6
4
  /**
package/index.js CHANGED
@@ -1,6 +1,5 @@
1
- "use strict";
2
-
3
- module.exports = function() {
1
+ /** */
2
+ export default function() {
4
3
  const api = this;
5
4
 
6
5
  return {
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@hitchy/plugin-odem-socket.io",
3
- "version": "0.2.4",
3
+ "version": "0.4.0",
4
4
  "description": "exposing Hitchy's document-oriented database via websocket",
5
5
  "main": "index.js",
6
+ "type": "module",
6
7
  "scripts": {
7
8
  "lint": "eslint .",
8
- "test": "hitchy-pm odem socket.io --exec mocha --ui=tdd 'test/**/*.spec.js*'",
9
- "coverage": "hitchy-pm odem socket.io --exec c8 mocha --ui=tdd 'test/**/*.spec.js*'"
9
+ "test": "hitchy-pm odem socket.io auth --exec mocha --ui=tdd test/**/*.spec.js",
10
+ "coverage": "hitchy-pm odem socket.io auth --exec c8 mocha --ui=tdd test/**/*.spec.js"
10
11
  },
11
12
  "repository": {
12
13
  "type": "git",
@@ -29,18 +30,18 @@
29
30
  "index.js"
30
31
  ],
31
32
  "peerDependencies": {
32
- "@hitchy/core": ">=0.8.1",
33
- "@hitchy/plugin-odem": ">=0.7.8",
34
- "@hitchy/plugin-socket.io": ">=0.1.1"
33
+ "@hitchy/core": "1.x",
34
+ "@hitchy/plugin-odem": "0.13.x",
35
+ "@hitchy/plugin-socket.io": "0.1.x"
35
36
  },
36
37
  "devDependencies": {
37
- "@hitchy/server-dev-tools": "^0.4.9",
38
- "c8": "^10.1.2",
39
- "eslint": "^8.57.0",
40
- "eslint-config-cepharum": "^1.0.14",
41
- "eslint-plugin-promise": "^7.1.0",
42
- "mocha": "^10.7.3",
38
+ "@hitchy/server-dev-tools": "^0.8.6",
39
+ "c8": "^10.1.3",
40
+ "eslint": "^9.23.0",
41
+ "eslint-config-cepharum": "^2.0.2",
42
+ "eslint-plugin-promise": "^7.2.1",
43
+ "mocha": "^11.1.0",
43
44
  "should": "^13.2.3",
44
- "socket.io-client": "^4.7.5"
45
+ "socket.io-client": "^4.8.1"
45
46
  }
46
47
  }
package/readme.md CHANGED
@@ -25,11 +25,17 @@ npm i @hitchy/plugin-odem @hitchy/plugin-socket.io
25
25
 
26
26
  * The parameter `uuidsOnly` of actions `<modelName>:list` and `<modelName>:find` has been replaced by `loadRecords` to match the semantics of server-side Odem API. You have to negate its value to adapt.
27
27
 
28
+ ### v0.3
29
+
30
+ * This version is recognizing an existing authentication plugin and checks a user's authorization for accessing models and their properties based on authorization rules set on server.
31
+ * Previously, actions of control API required the provision of a session ID to read a requesting user from as first argument. This approach has been abandoned. All actions are automatically associated with the user which has been authenticated when the websocket connection has been established.
32
+ * Clients should drop and re-connect over websocket whenever authentication of a requesting user has changed.
33
+
28
34
  ## Usage
29
35
 
30
36
  ### Basics
31
37
 
32
- This plugin is establishing an API via websocket for controlling and monitoring models of server-side document-oriented database. Relying on particular features of [socket.io](https://socket.io/), this API is exposed in [namespace](https://socket.io/docs/v4/namespaces/) `/hitchy/odem`.
38
+ This plugin is exposing an API via websocket for controlling and monitoring models of server-side document-oriented database. Relying on particular features of [socket.io](https://socket.io/), this API is exposed in [namespace](https://socket.io/docs/v4/namespaces/) `/hitchy/odem`.
33
39
 
34
40
  For every server-side model, request listeners and/or notification broadcasters are set up. Either model's name is converted into its kebab-case variant and used as colon-separated prefix in event names emitted either by the server or by a client.
35
41
 
@@ -47,30 +53,27 @@ All action events listed in this section are to be sent by a client. The server
47
53
  const socket = io( "/hitchy/odem" );
48
54
 
49
55
  socket.on( "connect", () => {
50
- socket.emit( actionName, sessionId, arg1, arg2, arg3, response => {
56
+ socket.emit( actionName, arg1, arg2, arg3, response => {
51
57
  // process the response here
52
58
  } );
53
59
  } );
54
60
  ```
55
61
 
56
- > socket.io requires all arguments to be provided no matter what. Thus, you can't omit any of the arguments given in examples below but have to provide values assumed to be default, at least.
62
+ > socket.io requires all arguments to be provided no matter what. Thus, you can not omit any of the arguments given in examples below but have to provide values assumed to be default, at least.
57
63
 
58
64
  The following code excerpts are focusing on the inner part of emitting an action event and processing the response.
59
65
 
60
- In all these examples, a _sessionId_ is given. This is the current user's session ID usually found in a cookie set by a preceding HTTP request to authenticate the user. It is required on all requests to re-authenticate the user and to apply authorization checks to either request. It is okay to provide `null` here, however this might cause requests to be rejected based on server-side configuration.
61
-
62
66
  #### &lt;modelName>:list
63
67
 
64
68
  This request is the websocket variant of [Model.list()](https://odem.hitchy.org/api/model.html#model-list). Supported arguments are
65
69
 
66
- * the session ID,
67
70
  * [query-related options](https://odem.hitchy.org/api/model.html#query-related-options) as supported by `Model.list()` and
68
71
  * a boolean controlling `loadRecords` of [result-related options](https://odem.hitchy.org/api/model.html#result-related-options) supported by `Model.list()`.
69
72
 
70
- > This boolean must be `true` to actually get all properties of retrieved items. Otherwise, listed items are represented by their UUIDs, only.
73
+ > This boolean must be `true` to actually get the properties of retrieved items. Otherwise, listed items are represented by their UUIDs, only.
71
74
 
72
75
  ```javascript
73
- socket.emit( "<modelName>:list", sessionId, { limit: 10 }, false, response => {
76
+ socket.emit( "<modelName>:list", { limit: 10 }, false, response => {
74
77
  // - check for `response.success` or `response.error`
75
78
  // - process total count of items in `response.count`
76
79
  // - process requested excerpt of items in `response.items`
@@ -83,15 +86,14 @@ Last argument is a callback to be invoked with the resulting response from serve
83
86
 
84
87
  This request is searching for instances of selected model matching some query. It is invoking [Model.find()](https://odem.hitchy.org/api/model.html#model-find). Arguments are
85
88
 
86
- * the session ID,
87
89
  * the [query](https://odem.hitchy.org/api/model.html#model-find) describing items to find,
88
90
  * [query-related options](https://odem.hitchy.org/api/model.html#query-related-options) as supported by `Model.find()` and
89
91
  * a boolean controlling `loadRecords` of [result-related options](https://odem.hitchy.org/api/model.html#result-related-options) supported by `Model.find()`.
90
92
 
91
- > This boolean must be `true` to actually get all properties of retrieved items. Otherwise, listed items are represented by their UUIDs, only.
93
+ > This boolean must be `true` to actually get the properties of retrieved items. Otherwise, listed items are represented by their UUIDs, only.
92
94
 
93
95
  ```javascript
94
- socket.emit( "<modelName>:find", sessionId, {}, { limit: 10 }, true, response => {
96
+ socket.emit( "<modelName>:find", {}, { limit: 10 }, true, response => {
95
97
  // - check for `response.success` or `response.error`
96
98
  // - process total count of items in `response.count`
97
99
  // - process requested excerpt of items in `response.items`
@@ -102,10 +104,10 @@ Last argument is a callback to be invoked with the resulting response from serve
102
104
 
103
105
  #### &lt;modelName>:create
104
106
 
105
- This request is creating another instance of selected model assigning provided properties as initial values. Properties of instance to create are provided as serializable object in argument following the session ID.
107
+ This request is creating another instance of selected model assigning provided properties as initial values. Those properties are provided as serializable object.
106
108
 
107
109
  ```javascript
108
- socket.emit( "<modelName>:create", sessionId, { title: "important things" }, response => {
110
+ socket.emit( "<modelName>:create", { title: "important things" }, response => {
109
111
  // - check for `response.success` or `response.error`
110
112
  // - process properties of eventually created instance in `response.properties`
111
113
  // - created instance's UUID is available as `response.properties.uuid`
@@ -119,7 +121,7 @@ Last argument is a callback to be invoked with the resulting response from serve
119
121
  This request is fetching a single instance's properties.
120
122
 
121
123
  ```javascript
122
- socket.emit( "<modelName>:read", sessionId, "12345678-1234-1234-1234-123456789012", response => {
124
+ socket.emit( "<modelName>:read", "12345678-1234-1234-1234-123456789012", response => {
123
125
  // - check for `response.success` or `response.error`
124
126
  // - process properties of fetched instance in `response.properties`
125
127
  } );
@@ -129,10 +131,10 @@ Last argument is a callback to be invoked with the resulting response from serve
129
131
 
130
132
  #### &lt;modelName>:update
131
133
 
132
- This request is updating an existing instance of model. Following the session ID, the UUID of item to update and the values per property to assign are provided in additional arguments.
134
+ This request is updating an existing instance of model. The UUID of item to update and the values per property to assign are provided as arguments.
133
135
 
134
136
  ```javascript
135
- socket.emit( "<modelName>:update", sessionId, "12345678-1234-1234-1234-123456789012", { prio: 1 }, response => {
137
+ socket.emit( "<modelName>:update", "12345678-1234-1234-1234-123456789012", { prio: 1 }, response => {
136
138
  // - check for `response.success` or `response.error`
137
139
  // - properties of updated instance are provided in `response.properties`
138
140
  } );
@@ -145,7 +147,7 @@ Last argument is a callback to be invoked with the resulting response from serve
145
147
  This request is eventually completing the CRUD-support of this API by deleting an existing instance of model selected by its UUID in first argument.
146
148
 
147
149
  ```javascript
148
- socket.emit( "<modelName>:delete", sessionId, "12345678-1234-1234-1234-123456789012", response => {
150
+ socket.emit( "<modelName>:delete", "12345678-1234-1234-1234-123456789012", response => {
149
151
  // - check for `response.success` or `response.error`
150
152
  // - deleted instance's UUID is available as `response.properties.uuid`
151
153
  } );