@hitchy/plugin-odem-socket.io 0.2.3 → 0.3.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,21 @@
1
1
  "use strict";
2
2
 
3
+ const { promisify } = require( "node:util" );
4
+
3
5
  module.exports = function() {
4
6
  const api = this;
5
7
 
6
- const logDebug = api.log( "hitchy:odem:socket.io:debug" );
7
- const logError = api.log( "hitchy:odem:socket.io:error" );
8
+ const logDebug = api.log( "hitchy:plugin:odem:socket.io:debug" );
9
+ const logInfo = api.log( "hitchy:plugin:odem:socket.io:info" );
10
+ const logError = api.log( "hitchy:plugin:odem:socket.io:error" );
8
11
 
9
12
  /**
10
- * Implements API for controlling and monitoring server-side ODM via
11
- * websocket.
13
+ * Implements API for controlling and monitoring server-side
14
+ * document-oriented database via websocket.
12
15
  */
13
16
  class OdemWebsocketProvider {
17
+ static #socketUsers = new Map();
18
+
14
19
  /**
15
20
  * Integrates this provider with server-side websocket.
16
21
  *
@@ -19,183 +24,347 @@ module.exports = function() {
19
24
  static start() {
20
25
  api.once( "websocket", io => {
21
26
  const { crud, notifications } = api.config.socket.odem;
22
-
23
27
  const namespace = io.of( "/hitchy/odem" );
24
28
 
29
+
30
+ // - transmit notifications to clients ------------------------
31
+
25
32
  const txPerModel = {};
26
33
 
27
34
  for ( const [ modelName, model ] of Object.entries( api.models ) ) {
28
- txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, modelName, model );
35
+ if ( api.service.OdemSchema.mayBeExposed( { user: undefined }, model ) ) {
36
+ txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, model );
37
+ }
29
38
  }
30
39
 
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
- }
40
+ api.once( "close", () => {
41
+ namespace.disconnectSockets( true );
37
42
 
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 );
43
+ for ( const [ modelName, handlers ] of Object.entries( txPerModel ) ) {
44
+ if ( handlers ) {
45
+ const model = api.models[modelName];
42
46
 
43
- socket.off( `${prefix}:${eventName}`, handler );
44
- }
47
+ model.notifications
48
+ .off( "created", handlers.created )
49
+ .off( "changed", handlers.changed )
50
+ .off( "removed", handlers.removed );
45
51
  }
46
- } );
52
+ }
47
53
  } );
48
54
 
49
- api.once( "close", () => {
50
- namespace.disconnectSockets( true );
51
55
 
52
- for ( const [ modelName, model ] of Object.entries( api.models ) ) {
53
- const handlers = txPerModel[modelName];
56
+ // - receive requests from clients ----------------------------
54
57
 
55
- model.notifications
56
- .off( "created", handlers.created )
57
- .off( "changed", handlers.changed )
58
- .off( "removed", handlers.removed );
59
- }
58
+ namespace.on( "connection", socket => {
59
+ this.getUserForSocket( socket );
60
+
61
+ const listener = ( query, ...args ) => this.handleControlRequest( crud, socket, query, args );
62
+
63
+ socket.onAny( listener );
64
+
65
+ socket.once( "disconnect", () => {
66
+ socket.offAny( listener );
67
+
68
+ this.#socketUsers.delete( socket.id );
69
+ } );
60
70
  } );
61
71
  } );
62
72
  }
63
73
 
64
74
  /**
65
- * Registers listeners for handling client-side events requesting
66
- * model-related actions.
75
+ * Monitors models of document-oriented database and sends notifications
76
+ * to connected clients in case items of either model have changed.
67
77
  *
68
78
  * @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 ODM-based model instance to manage
72
- * @returns {Object<string,function>} map of registered handler functions
79
+ * @param {Server} namespace namespace managing a list of server-side sockets with common prefix
80
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model model to monitor for change of items
81
+ * @returns {Object<string,function>|undefined} map of registered handler functions
73
82
  */
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
-
83
+ static broadcastModelNotifications( enabled, namespace, Model ) {
84
+ if ( enabled ) {
80
85
  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 ),
86
+ created: ( uuid, newRecord, asyncGeneratorFn ) => {
87
+ asyncGeneratorFn()
88
+ .then( item => this.namespaceEmit( namespace, Model, {
89
+ change: "created",
90
+ model: Model.name,
91
+ properties: item.toObject( { serialized: true } ),
92
+ }, true ) )
93
+ .catch( cause => {
94
+ logError( "broadcasting notification on having created item %s of %s has failed:", uuid, Model.name, cause.stack );
95
+ } );
96
+ },
97
+ changed: ( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
98
+ asyncGeneratorFn()
99
+ .then( item => this.namespaceEmit( namespace, Model, {
100
+ change: "updated",
101
+ model: Model.name,
102
+ properties: item.toObject( { serialized: true } ),
103
+ }, true ) )
104
+ .catch( cause => {
105
+ logError( "broadcasting notification on having updated item %s of %s has failed:", uuid, Model.name, cause.stack );
106
+ } );
107
+ },
108
+ removed: uuid => {
109
+ this.namespaceEmit( namespace, Model, {
110
+ change: "deleted",
111
+ model: Model.name,
112
+ properties: { uuid: Model.formatUUID( uuid ) },
113
+ }, false )
114
+ .catch( cause => {
115
+ logError( "broadcasting notification on having removed item %s of %s has failed:", uuid, Model.name, cause.stack );
116
+ } );
117
+ },
87
118
  };
88
119
 
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 );
120
+ Model.notifications
121
+ .on( "created", handlers.created )
122
+ .on( "changed", handlers.changed )
123
+ .on( "removed", handlers.removed );
96
124
 
97
125
  return handlers;
98
126
  }
99
127
 
100
- logDebug( "enabling socket-based action requests for model %s (%s)", modelName, prefix );
128
+ return undefined;
129
+ }
101
130
 
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
- };
131
+ /**
132
+ * Reads promise for given socket's requesting user from local cache.
133
+ *
134
+ * This method is mocking Hitchy's request dispatching to invoke
135
+ * policies of @hitchy/plugin-auth involved in setting up a requesting
136
+ * user to be returned here eventually. The mocked request is based on
137
+ * the provided socket's initial request.
138
+ *
139
+ * @param {WebSocket} socket socket to look up
140
+ * @returns {Promise<User|undefined>} promise for user of provided socket
141
+ */
142
+ static async getUserForSocket( socket ) {
143
+ if ( !this.#socketUsers.has( socket.id ) ) {
144
+ this.#socketUsers.set( socket.id, ( async _socket => {
145
+ if ( api.plugins.authentication ) {
146
+ const { SessionInjector, Cookies } = api.service;
147
+ const { Authentication } = api.policy;
148
+ const useSession = api.plugins.session && !api.config?.session?.disable;
149
+ const headers = _socket.request?.headers;
150
+
151
+ // mock context used by Hitchy for dispatching requests
152
+ const request = {
153
+ headers,
154
+ cookies: Cookies.parseFromString( headers?.cookie ),
155
+ };
156
+
157
+ const response = {
158
+ set: () => {}, // eslint-disable-line no-empty-function
159
+ };
160
+
161
+ const context = {
162
+ local: {},
163
+ updateSessionId: ( _, __, fn ) => fn(),
164
+ };
165
+
166
+ // invoke policies usually injecting a requesting user into a request
167
+ if ( useSession ) {
168
+ try {
169
+ await SessionInjector.injectIntoRequest( request, response );
170
+ await promisify( Authentication.injectPassport.bind( context ) )( request, response );
171
+ } catch ( cause ) {
172
+ logError( "mocking session-handling and passport injection has failed:", cause );
173
+ }
174
+ }
175
+
176
+ if ( !request.user ) {
177
+ try {
178
+ await promisify( Authentication.handleBasicAuth.bind( context ) )( request, response );
179
+ } catch ( cause ) {
180
+ logError( "mocking Basic authentication handling has failed:", cause );
181
+ }
182
+ }
183
+
184
+ // pick user eventually injected into mocked request
185
+ return request.user || undefined;
186
+ }
110
187
 
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 );
188
+ return undefined;
189
+ } )( socket ) );
190
+ }
118
191
 
119
- return handlers;
192
+ return await this.#socketUsers.get( socket.id );
120
193
  }
121
194
 
122
195
  /**
123
- * Sets up listeners for model-related notifications in ODM and forwards
124
- * them as broadcast events to connected clients.
196
+ * Broadcasts a change notification to every connected peer in a given
197
+ * namespace individually considering either peer's authorization for
198
+ * accessing the changed model and/or its properties.
125
199
  *
126
- * @param {boolean} enabled true if command handling is enabled in configuration
127
- * @param {Server} namespace namespace managing a list of server-side sockets with common prefix
128
- * @param {string} modelName name of model to manage
129
- * @param {class<Hitchy.Plugin.Odem.Model>} model ODM-based model instance to manage
130
- * @returns {Object<string,function>} map of registered handler functions
200
+ * @param {Server} namespace namespace managing a pool of sockets that have joined the namespace
201
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model model of changed item
202
+ * @param {{change: string, model: string, properties: Object}} payload raw payload of model-specific notification
203
+ * @param {boolean} filterProperties if true, properties need to be filtered per peer based on its authorization
204
+ * @returns {Promise<void>} promise resolved when all authorized peers have been notified
131
205
  */
132
- static broadcastModelNotifications( enabled, namespace, modelName, model ) {
133
- if ( enabled ) {
134
- const prefix = api.utility.case.pascalToKebab( modelName );
206
+ static async namespaceEmit( namespace, Model, payload, filterProperties ) {
207
+ const hasPluginAuth = Boolean( api.plugins.authentication );
208
+ const sockets = await namespace.fetchSockets();
135
209
 
136
- const handlers = {
137
- created: async( uuid, newRecord, asyncGeneratorFn ) => {
138
- const event = {
139
- change: "created",
140
- properties: ( await asyncGeneratorFn() ).toObject( { serialized: true } ),
141
- };
210
+ logDebug( "forwarding notification to %d socket(s)", sockets.length );
142
211
 
143
- namespace.emit( `${prefix}:changed`, event );
144
- },
145
- changed: async( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
146
- const event = {
147
- change: "updated",
148
- properties: ( await asyncGeneratorFn() ).toObject( { serialized: true } ),
149
- };
212
+ const notifyPeer = async socket => {
213
+ const { OdemSchema, Authorization } = api.service;
214
+ let user;
150
215
 
151
- namespace.emit( `${prefix}:changed`, event );
152
- },
153
- removed: uuid => {
154
- const event = {
155
- change: "deleted",
156
- properties: { uuid: model.formatUUID( uuid ) },
157
- };
216
+ try {
217
+ user = await this.#socketUsers.get( socket.id );
218
+ } catch ( cause ) {
219
+ logError( `discovering user of socket ${socket.id} has failed:`, cause );
220
+ return;
221
+ }
158
222
 
159
- namespace.emit( `${prefix}:changed`, event );
160
- },
161
- };
223
+ if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
224
+ logDebug( `omit notification to user ${user?.uuid} (${user.name}) at peer ${socket.id} with access on ${Model.name} forbidden by model` );
225
+ return;
226
+ }
162
227
 
163
- model.notifications
164
- .on( "created", handlers.created )
165
- .on( "changed", handlers.changed )
166
- .on( "removed", handlers.removed );
228
+ let mayRead = OdemSchema.isAdmin( user );
229
+ let scope = "read";
167
230
 
168
- return handlers;
231
+ if ( !mayRead && hasPluginAuth ) {
232
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
233
+
234
+ if ( !mayRead ) {
235
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
236
+ scope = "list";
237
+ }
238
+ }
239
+
240
+ const prefix = api.utility.case.pascalToKebab( Model.name );
241
+
242
+ if ( !mayRead ) {
243
+ // user must not read items of this model -> do not provide any identifier, but basically notify on some change to the model
244
+ socket.emit( `${prefix}:changed`, { ...payload, properties: {} } );
245
+ socket.emit( "*:changed", { ...payload, properties: {} } );
246
+ } else if ( filterProperties ) {
247
+ const copy = { ...payload };
248
+
249
+ copy.properties = OdemSchema.filterItem( copy.properties, { user }, Model, scope );
250
+
251
+ socket.emit( `${prefix}:changed`, copy );
252
+ socket.emit( "*:changed", copy );
253
+ } else {
254
+ socket.emit( `${prefix}:changed`, payload );
255
+ socket.emit( "*:changed", payload );
256
+ }
257
+ };
258
+
259
+ for ( const socket of sockets ) {
260
+ notifyPeer( socket ).catch( cause => {
261
+ logError(
262
+ `notifying peer ${socket.id} on having ${payload.change} item of ${Model.name} has failed:`,
263
+ cause.stack
264
+ );
265
+ } );
266
+ }
267
+ }
268
+
269
+ /**
270
+ * Commonly handles incoming messages to process those requesting
271
+ * supported control actions on existing models.
272
+ *
273
+ * @param {boolean} enabled if true, this plugin's control API has been enabled in configuration
274
+ * @param {WebSocket} socket request-receiving socket
275
+ * @param {string} query name of event that has been emitted by peer socket to request some action
276
+ * @param {any[]} args arguments of incoming request and a callback for sending a response in final argument
277
+ * @returns {void}
278
+ */
279
+ static handleControlRequest( enabled, socket, query, args ) {
280
+ const match = /^([a-zA-Z][a-zA-Z0-9]+):(list|find|create|read|write|update|remove|delete)$/.exec( query );
281
+
282
+ if ( !match ) {
283
+ logDebug( "ignoring socket-based request %s due to mismatching format", query );
284
+ return;
285
+ }
286
+
287
+ const [ , prefix, action ] = match;
288
+ const modelName = api.utility.case.kebabToPascal( prefix );
289
+ const Model = api.model[modelName];
290
+
291
+ if ( !Model ) {
292
+ logDebug( "rejecting socket-based request %s due to addressing unknown model %s", query, modelName );
293
+
294
+ args[args.length - 1]( {
295
+ error: "no such model " + modelName,
296
+ } );
297
+ return;
169
298
  }
170
299
 
171
- return {};
300
+ const handler = {
301
+ list: "handleListRequest",
302
+ find: "handleFindRequest",
303
+ create: "handleCreateRequest",
304
+ read: "handleReadRequest",
305
+ write: "handleUpdateRequest",
306
+ update: "handleUpdateRequest",
307
+ remove: "handleDeleteRequest",
308
+ delete: "handleDeleteRequest",
309
+ }[action];
310
+
311
+ if ( !handler ) {
312
+ logDebug( "ignoring socket-based request for actin %s on model %s due to lack of handler", action, Model.name );
313
+ return;
314
+ }
315
+
316
+ if ( enabled ) {
317
+ this[handler]( socket, Model, ...args );
318
+ } else {
319
+ logDebug( "rejecting socket-based request for action %s on model %s", action, Model.name );
320
+
321
+ reject( args[args.length - 1] );
322
+ }
172
323
  }
173
324
 
174
325
  /**
175
326
  * Handles incoming request for listing items of a model.
176
327
  *
177
- * @param {string} modelName name of model
178
- * @param {string} model named server-side model
179
- * @param {string} sessionId client-provided session ID
328
+ * @param {WebSocket} socket receiving socket of request
329
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
180
330
  * @param {Hitchy.Odem.ListOptions} queryOptions client-provided options for listing items
181
331
  * @param {boolean} loadRecords if true, whole records shall be fetched per item
182
332
  * @param {function} response callback to invoke with response
183
333
  * @returns {Promise<void>} promise settled on request handled
184
334
  */
185
- static async handleListRequest( modelName, model, sessionId, queryOptions, loadRecords, response ) {
186
- logDebug( "request for listing %s instances w/ queryOptions %j and loadRecords %j", modelName, queryOptions, Boolean( loadRecords ) );
335
+ static async handleListRequest( socket, Model, queryOptions, loadRecords, response ) {
336
+ logDebug( "request for listing %s instances w/ queryOptions %j and loadRecords %j", Model.name, queryOptions, Boolean( loadRecords ) );
187
337
 
188
338
  try {
339
+ const user = await this.getUserForSocket( socket );
340
+
341
+ if ( api.plugins.authentication ) {
342
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` ) ) {
343
+ throw new Error( "access forbidden" );
344
+ }
345
+ }
346
+
347
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
348
+ throw new Error( "access forbidden by model" );
349
+ }
350
+
189
351
  const meta = {};
190
- const items = await model.list( queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
352
+ const items = await Model.list( queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
353
+ let filter;
354
+
355
+ if ( loadRecords ) {
356
+ filter = item => api.service.OdemSchema.filterItem( item.toObject( { serialized: true } ), { user }, Model, "list" );
357
+ } else {
358
+ filter = item => ( { uuid: item.uuid } );
359
+ }
191
360
 
192
361
  response( {
193
362
  success: true,
194
363
  count: meta.count,
195
- items: items.map( item => ( loadRecords ? item.toObject( { serialized: true } ) : { uuid: item.uuid } ) ),
364
+ items: items.map( filter ),
196
365
  } );
197
366
  } catch ( error ) {
198
- logError( "listing %s instances failed: %s", modelName, error.stack );
367
+ logError( "listing %s instances failed: %s", Model.name, error.stack );
199
368
 
200
369
  response( {
201
370
  error: error.message,
@@ -207,29 +376,47 @@ module.exports = function() {
207
376
  * Handles incoming request for finding items of a model matching some
208
377
  * provided query.
209
378
  *
210
- * @param {string} modelName name of model
211
- * @param {string} model named server-side model
212
- * @param {string} sessionId client-provided session ID
379
+ * @param {WebSocket} socket receiving socket of request
380
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
213
381
  * @param {Hitchy.Odem.Query} query client-provided constraints to be met by returned items
214
382
  * @param {Hitchy.Odem.ListOptions} queryOptions client-provided options for listing items
215
383
  * @param {boolean} loadRecords if true, whole records shall be fetched per item
216
384
  * @param {function} response callback to invoke with response
217
385
  * @returns {Promise<void>} promise settled on request handled
218
386
  */
219
- static async handleFindRequest( modelName, model, sessionId, query, queryOptions, loadRecords, response ) {
220
- logDebug( "request for finding %s instances matching %j w/ queryOptions %j and uuidsOnly %j", modelName, query, queryOptions, Boolean( loadRecords ) );
387
+ static async handleFindRequest( socket, Model, query, queryOptions, loadRecords, response ) {
388
+ logDebug( "request for finding %s instances matching %j w/ queryOptions %j and uuidsOnly %j", Model.name, query, queryOptions, Boolean( loadRecords ) );
221
389
 
222
390
  try {
391
+ const user = await this.getUserForSocket( socket );
392
+
393
+ if ( api.plugins.authentication ) {
394
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` ) ) {
395
+ throw new Error( "access forbidden" );
396
+ }
397
+ }
398
+
399
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
400
+ throw new Error( "access forbidden by model" );
401
+ }
402
+
223
403
  const meta = {};
224
- const items = await model.find( query, queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
404
+ const items = await Model.find( query, queryOptions, { loadRecords: Boolean( loadRecords ), metaCollector: meta } );
405
+ let filter;
406
+
407
+ if ( loadRecords ) {
408
+ filter = item => api.service.OdemSchema.filterItem( item.toObject( { serialized: true } ), { user }, Model, "list" );
409
+ } else {
410
+ filter = item => ( { uuid: item.uuid } );
411
+ }
225
412
 
226
413
  response( {
227
414
  success: true,
228
415
  count: meta.count,
229
- items: items.map( item => ( loadRecords ? item.toObject( { serialized: true } ) : { uuid: item.uuid } ) ),
416
+ items: items.map( filter ),
230
417
  } );
231
418
  } catch ( error ) {
232
- logError( "finding %s instances matching %j failed: %s", modelName, error.stack );
419
+ logError( "finding %s instances matching %j failed: %s", Model.name, query, error.stack );
233
420
 
234
421
  response( {
235
422
  error: error.message,
@@ -240,24 +427,35 @@ module.exports = function() {
240
427
  /**
241
428
  * Handles incoming request for creating item of a model.
242
429
  *
243
- * @param {string} modelName name of model
244
- * @param {string} model named server-side model
245
- * @param {string} sessionId client-provided session ID
430
+ * @param {WebSocket} socket receiving socket of request
431
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
246
432
  * @param {Hitchy.Odem.ListOptions} properties properties of item to create
247
433
  * @param {function} response callback to invoke with response
248
434
  * @returns {Promise<void>} promise settled on request handled
249
435
  */
250
- static async handleCreateRequest( modelName, model, sessionId, properties, response ) {
251
- logDebug( "request for creating %s instance with %j", modelName, properties );
436
+ static async handleCreateRequest( socket, Model, properties, response ) {
437
+ logDebug( "request for creating %s instance with %j", Model.name, properties );
438
+
439
+ const { Authorization, OdemSchema } = api.service;
252
440
 
253
441
  try {
254
- const schema = model.schema;
255
- const instance = new model; // eslint-disable-line new-cap
442
+ const user = await this.getUserForSocket( socket );
443
+
444
+ if ( api.plugins.authentication && !await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.create` ) ) {
445
+ throw new Error( "access forbidden" );
446
+ }
447
+
448
+ if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
449
+ throw new Error( "access forbidden by model" );
450
+ }
451
+
452
+ const schema = Model.schema;
453
+ const filtered = OdemSchema.filterItem( properties, { user }, Model, "create" );
454
+ const instance = new Model();
256
455
 
257
- for ( const propName of Object.keys( properties ) ) {
258
- if ( schema.props.hasOwnProperty( propName ) ||
259
- schema.computed.hasOwnProperty( propName ) ) {
260
- instance[propName] = properties[propName];
456
+ for ( const [ propName, propValue ] of Object.entries( filtered ) ) {
457
+ if ( schema.props.hasOwnProperty( propName ) || schema.computed.hasOwnProperty( propName ) ) {
458
+ instance[propName] = propValue;
261
459
  }
262
460
  }
263
461
 
@@ -265,10 +463,10 @@ module.exports = function() {
265
463
 
266
464
  response( {
267
465
  success: true,
268
- properties: instance.toObject( { serialized: true } ),
466
+ properties: OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
269
467
  } );
270
468
  } catch ( error ) {
271
- logError( "creating %s instance failed: %s", modelName, error.stack );
469
+ logError( "creating %s instance failed: %s", Model.name, error.stack );
272
470
 
273
471
  response( {
274
472
  error: error.message,
@@ -279,26 +477,37 @@ module.exports = function() {
279
477
  /**
280
478
  * Handles incoming request for reading properties of a single item.
281
479
  *
282
- * @param {string} modelName name of item's model
283
- * @param {string} model named server-side model
284
- * @param {string} sessionId client-provided session ID
480
+ * @param {WebSocket} socket receiving socket of request
481
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
285
482
  * @param {string} uuid client-provided UUID of item to read
286
483
  * @param {function} response callback to invoke with response
287
484
  * @returns {Promise<void>} promise settled on request handled
288
485
  */
289
- static async handleReadRequest( modelName, model, sessionId, uuid, response ) {
290
- logDebug( "request for reading %s instance %s", modelName, uuid );
486
+ static async handleReadRequest( socket, Model, uuid, response ) {
487
+ logDebug( "request for reading %s instance %s", Model.name, uuid );
291
488
 
292
489
  try {
293
- const instance = new model( uuid ); // eslint-disable-line new-cap
490
+ const user = await this.getUserForSocket( socket );
491
+
492
+ if ( api.plugins.authentication ) {
493
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` ) ) {
494
+ throw new Error( "access forbidden" );
495
+ }
496
+ }
497
+
498
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
499
+ throw new Error( "access forbidden by model" );
500
+ }
501
+
502
+ const instance = new Model( uuid );
294
503
  await instance.load();
295
504
 
296
505
  response( {
297
506
  success: true,
298
- properties: instance.toObject( { serialized: true } ),
507
+ properties: api.service.OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
299
508
  } );
300
509
  } catch ( error ) {
301
- logError( "reading %s instance failed: %s", modelName, error.stack );
510
+ logError( "reading %s instance failed: %s", Model.name, error.stack );
302
511
 
303
512
  response( {
304
513
  error: error.message,
@@ -309,24 +518,37 @@ module.exports = function() {
309
518
  /**
310
519
  * Handles incoming request for adjusting properties of a single item.
311
520
  *
312
- * @param {string} modelName name of item's model
313
- * @param {string} model named server-side model
314
- * @param {string} sessionId client-provided session ID
521
+ * @param {WebSocket} socket receiving socket of request
522
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
315
523
  * @param {string} uuid client-provided UUID of item to modify
316
524
  * @param {Object} properties client-provided properties of item replacing its existing ones
317
525
  * @param {function} response callback to invoke with response
318
526
  * @returns {Promise<void>} promise settled on request handled
319
527
  */
320
- static async handleUpdateRequest( modelName, model, sessionId, uuid, properties, response ) {
321
- logDebug( "request for updating %s instance %s with %j", modelName, uuid, properties );
528
+ static async handleUpdateRequest( socket, Model, uuid, properties, response ) {
529
+ logDebug( "request for updating %s instance %s with %j", Model.name, uuid, properties );
322
530
 
323
531
  try {
324
- const instance = new model( uuid ); // eslint-disable-line new-cap
532
+ const user = await this.getUserForSocket( socket );
533
+
534
+ if ( api.plugins.authentication ) {
535
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.write` ) ) {
536
+ throw new Error( "access forbidden" );
537
+ }
538
+ }
539
+
540
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
541
+ throw new Error( "access forbidden by model" );
542
+ }
543
+
544
+ const schema = Model.schema;
545
+ const filtered = api.service.OdemSchema.filterItem( properties, { user }, Model, "write" );
546
+ const instance = new Model( uuid );
325
547
  await instance.load();
326
548
 
327
- for ( const propName of Object.keys( properties ) ) {
328
- if ( model.schema.props.hasOwnProperty( propName ) || model.schema.computed.hasOwnProperty( propName ) ) {
329
- instance[propName] = properties[propName];
549
+ for ( const [ propName, propValue ] of Object.entries( filtered ) ) {
550
+ if ( schema.props.hasOwnProperty( propName ) || schema.computed.hasOwnProperty( propName ) ) {
551
+ instance[propName] = propValue;
330
552
  }
331
553
  }
332
554
 
@@ -334,10 +556,10 @@ module.exports = function() {
334
556
 
335
557
  response( {
336
558
  success: true,
337
- properties: instance.toObject( { serialized: true } ),
559
+ properties: api.service.OdemSchema.filterItem( instance.toObject( { serialized: true } ), { user }, Model, "read" ),
338
560
  } );
339
561
  } catch ( error ) {
340
- logError( "updating %s instance failed: %s", modelName, error.stack );
562
+ logError( "updating %s instance failed: %s", Model.name, error.stack );
341
563
 
342
564
  response( {
343
565
  error: error.message,
@@ -348,18 +570,29 @@ module.exports = function() {
348
570
  /**
349
571
  * Handles incoming request for removing a model's item.
350
572
  *
351
- * @param {string} modelName name of item's model
352
- * @param {string} model named server-side model
353
- * @param {string} sessionId client-provided session ID
573
+ * @param {WebSocket} socket receiving socket of request
574
+ * @param {class<Hitchy.Plugin.Odem.Model>} Model named server-side model
354
575
  * @param {string} uuid client-provided UUID of item to remove
355
576
  * @param {function} response callback to invoke with response
356
577
  * @returns {Promise<void>} promise settled on request handled
357
578
  */
358
- static async handleDeleteRequest( modelName, model, sessionId, uuid, response ) {
359
- logDebug( "request for deleting %s instance %s", modelName, uuid );
579
+ static async handleDeleteRequest( socket, Model, uuid, response ) {
580
+ logDebug( "request for deleting %s instance %s", Model.name, uuid );
360
581
 
361
582
  try {
362
- const instance = new model( uuid ); // eslint-disable-line new-cap
583
+ const user = await this.getUserForSocket( socket );
584
+
585
+ if ( api.plugins.authentication ) {
586
+ if ( !await api.service.Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.remove` ) ) {
587
+ throw new Error( "access forbidden" );
588
+ }
589
+ }
590
+
591
+ if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
592
+ throw new Error( "access forbidden by model" );
593
+ }
594
+
595
+ const instance = new Model( uuid );
363
596
  await instance.remove();
364
597
 
365
598
  response( {
@@ -367,7 +600,7 @@ module.exports = function() {
367
600
  properties: { uuid },
368
601
  } );
369
602
  } catch ( error ) {
370
- logError( "deleting %s instance %s failed: %s", modelName, uuid, error.stack );
603
+ logError( "deleting %s instance %s failed: %s", Model.name, uuid, error.stack );
371
604
 
372
605
  response( {
373
606
  error: error.message,
package/package.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@hitchy/plugin-odem-socket.io",
3
- "version": "0.2.3",
4
- "description": "exposing Hitchy's ODM via websocket",
3
+ "version": "0.3.0",
4
+ "description": "exposing Hitchy's document-oriented database via websocket",
5
5
  "main": "index.js",
6
6
  "scripts": {
7
7
  "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*'"
8
+ "test": "hitchy-pm odem socket.io auth --exec mocha --ui=tdd 'test/**/*.spec.js'",
9
+ "coverage": "hitchy-pm odem socket.io auth --exec c8 mocha --ui=tdd 'test/**/*.spec.js'"
10
10
  },
11
11
  "repository": {
12
12
  "type": "git",
@@ -14,8 +14,7 @@
14
14
  },
15
15
  "keywords": [
16
16
  "Hitchy",
17
- "socket.io",
18
- "ODM"
17
+ "socket.io"
19
18
  ],
20
19
  "author": "cepharum GmbH",
21
20
  "license": "MIT",
@@ -31,17 +30,17 @@
31
30
  ],
32
31
  "peerDependencies": {
33
32
  "@hitchy/core": ">=0.8.1",
34
- "@hitchy/plugin-odem": ">=0.7.8",
33
+ "@hitchy/plugin-odem": ">=0.9.1",
35
34
  "@hitchy/plugin-socket.io": ">=0.1.1"
36
35
  },
37
36
  "devDependencies": {
38
- "@hitchy/server-dev-tools": "^0.4.6",
39
- "c8": "^8.0.1",
40
- "eslint": "^8.47.0",
41
- "eslint-config-cepharum": "^1.0.13",
42
- "eslint-plugin-promise": "^6.1.1",
43
- "mocha": "^10.2.0",
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",
44
43
  "should": "^13.2.3",
45
- "socket.io-client": "^4.7.2"
44
+ "socket.io-client": "^4.7.5"
46
45
  }
47
46
  }
package/readme.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @hitchy/plugin-odem-socket.io
2
2
 
3
- _exposing Hitchy's ODM via websocket_
3
+ _exposing Hitchy's document-oriented database via websocket_
4
4
 
5
5
  ## License
6
6
 
@@ -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 ODM. 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,7 +53,7 @@ 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
  } );
@@ -57,20 +63,17 @@ socket.on( "connect", () => {
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
  } );
@@ -156,7 +158,7 @@ Last argument is a callback to be invoked with the resulting response from serve
156
158
 
157
159
  ### Notifications
158
160
 
159
- [ODM notifications](https://odem.hitchy.org/api/model.html#model-notifications) are forwarded as broadcast events named `<modelName>:changed`. Either event consists of
161
+ [notifications of the document-oriented database](https://odem.hitchy.org/api/model.html#model-notifications) are forwarded as broadcast events named `<modelName>:changed`. Either event consists of
160
162
 
161
163
  - a type of change and
162
164
  - the changed item's properties (limited to its UUID on deletion).