@hitchy/plugin-odem-socket.io 0.4.0 → 0.5.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.
@@ -31,7 +31,7 @@ export default function() {
31
31
  const txPerModel = {};
32
32
 
33
33
  for ( const [ modelName, model ] of Object.entries( api.models ) ) {
34
- if ( api.service.OdemSchema.mayBeExposed( { user: undefined }, model ) ) {
34
+ if ( api.service.OdemSchema.mayBeExposed( undefined, model ) ) {
35
35
  txPerModel[modelName] = this.broadcastModelNotifications( notifications, namespace, model );
36
36
  }
37
37
  }
@@ -84,7 +84,7 @@ export default function() {
84
84
  const handlers = {
85
85
  created: ( uuid, newRecord, asyncGeneratorFn ) => {
86
86
  asyncGeneratorFn()
87
- .then( item => this.namespaceEmit( namespace, Model, {
87
+ .then( item => this.namespaceEmit( namespace, item, {
88
88
  change: "created",
89
89
  model: Model.name,
90
90
  properties: this.serializeItem( item, false ),
@@ -95,8 +95,8 @@ export default function() {
95
95
  },
96
96
  changed: ( uuid, newRecord, oldRecord, asyncGeneratorFn ) => {
97
97
  asyncGeneratorFn()
98
- .then( item => this.namespaceEmit( namespace, Model, {
99
- change: "updated",
98
+ .then( item => this.namespaceEmit( namespace, item, {
99
+ change: "changed",
100
100
  model: Model.name,
101
101
  properties: this.serializeItem( item, false ),
102
102
  }, true ) )
@@ -104,12 +104,13 @@ export default function() {
104
104
  logError( "broadcasting notification on having updated item %s of %s has failed:", uuid, Model.name, cause.stack );
105
105
  } );
106
106
  },
107
- removed: uuid => {
108
- this.namespaceEmit( namespace, Model, {
109
- change: "deleted",
110
- model: Model.name,
111
- properties: { uuid: String( uuid ) },
112
- }, false )
107
+ removed: ( uuid, lastRecord, asyncGeneratorFn ) => {
108
+ asyncGeneratorFn()
109
+ .then( item => this.namespaceEmit( namespace, item, {
110
+ change: "removed",
111
+ model: Model.name,
112
+ properties: { uuid: String( uuid ) },
113
+ }, false ) )
113
114
  .catch( cause => {
114
115
  logError( "broadcasting notification on having removed item %s of %s has failed:", uuid, Model.name, cause.stack );
115
116
  } );
@@ -180,6 +181,8 @@ export default function() {
180
181
  }
181
182
  }
182
183
 
184
+ // TODO track the whole request instead of just its user to enable the plugin to assign $context to processed model instances
185
+
183
186
  // pick user eventually injected into mocked request
184
187
  return request.user || undefined;
185
188
  }
@@ -197,14 +200,16 @@ export default function() {
197
200
  * accessing the changed model and/or its properties.
198
201
  *
199
202
  * @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
203
+ * @param {Hitchy.Plugin.Odem.Model} item instance of model notification is related to
204
+ * @param {{change: "created"|"changed"|"removed", model: string, properties: Object}} payload raw payload of model-specific notification
202
205
  * @param {boolean} filterProperties if true, properties need to be filtered per peer based on its authorization
203
206
  * @returns {Promise<void>} promise resolved when all authorized peers have been notified
204
207
  */
205
- static async namespaceEmit( namespace, Model, payload, filterProperties ) {
208
+ static async namespaceEmit( namespace, item, payload, filterProperties ) {
206
209
  const hasPluginAuth = Boolean( api.plugins.authentication );
207
210
  const sockets = await namespace.fetchSockets();
211
+ const Model = item.constructor;
212
+ const userCache = {};
208
213
 
209
214
  logDebug( "forwarding notification to %d socket(s)", sockets.length );
210
215
 
@@ -219,46 +224,73 @@ export default function() {
219
224
  return;
220
225
  }
221
226
 
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
- }
227
+ const userId = user?.uuid ?? "(null)";
228
+ let cached = userCache[userId];
226
229
 
227
- let mayRead = OdemSchema.isAdmin( user );
228
- let scope = "read";
230
+ if ( cached == null ) {
231
+ cached = userCache[userId] = false;
229
232
 
230
- if ( !mayRead && hasPluginAuth ) {
231
- mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
233
+ // check connected user's common authorization to access the
234
+ // notification's model or the instance properties included with
235
+ // the notification
236
+ if ( !OdemSchema.mayBeExposed( user, Model ) ) {
237
+ logDebug( `omit notification to user ${userId} (${user?.name}) at peer ${socket.id} with access on ${Model.name} forbidden by model` );
238
+ return;
239
+ }
232
240
 
233
- if ( !mayRead ) {
234
- mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
235
- scope = "list";
241
+ let mayRead = OdemSchema.isAdmin( user );
242
+ let scope = "read";
243
+
244
+ if ( !mayRead && hasPluginAuth ) {
245
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.read` );
246
+
247
+ if ( !mayRead ) {
248
+ mayRead = await Authorization.mayAccess( user, `@hitchy.odem.model.${Model.name}.list` );
249
+ scope = "list";
250
+ }
236
251
  }
237
- }
238
252
 
239
- const prefix = api.utility.case.pascalToKebab( Model.name );
253
+ // check model's life cycle hook individually controlling
254
+ // if current socket's user may receive the notification
255
+ const mayBeNotified = await item.beforeNotify( user, payload.change );
240
256
 
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 };
257
+ if ( !mayBeNotified ) {
258
+ logDebug( `notification to user ${userId} (${user?.name}) at peer ${socket.id} prevented by beforeNotify() of model ${Model.name}` );
259
+ return;
260
+ }
247
261
 
248
- copy.properties = OdemSchema.filterItem( copy.properties, { user }, Model, scope );
262
+ // eventually send the notification ...
263
+ if ( !mayRead ) {
264
+ // user must not read items of this model -> do not
265
+ // provide any identifier, but basically notify on some
266
+ // (unspecified) change to the model
267
+ cached = userCache[userId] = { ...payload, properties: {} };
268
+ } else if ( filterProperties ) {
269
+ // user may read items of this model, but access to some
270
+ // properties may be restricted by the model's schema
271
+ cached = userCache[userId] = {
272
+ ...payload,
273
+ properties: OdemSchema.filterItem( payload.properties, user, Model, scope ),
274
+ };
275
+ } else {
276
+ // user gets notification with all properties of item
277
+ cached = userCache[userId] = payload;
278
+ }
279
+ }
249
280
 
250
- socket.emit( `${prefix}:changed`, copy );
251
- socket.emit( "*:changed", copy );
252
- } else {
253
- socket.emit( `${prefix}:changed`, payload );
254
- socket.emit( "*:changed", payload );
281
+ if ( cached ) {
282
+ // user may receive notification -> emit it eventually
283
+ const prefix = api.utility.case.pascalToKebab( Model.name );
284
+
285
+ socket.emit( `${prefix}:changed`, cached );
286
+ socket.emit( "*:changed", cached );
255
287
  }
256
288
  };
257
289
 
258
290
  for ( const socket of sockets ) {
259
291
  notifyPeer( socket ).catch( cause => {
260
292
  logError(
261
- `notifying peer ${socket.id} on having ${payload.change} item of ${Model.name} has failed:`,
293
+ `notifying peer ${socket.id} on having ${payload.change} item ${item.uuid} of ${Model.name} has failed:`,
262
294
  cause.stack
263
295
  );
264
296
  } );
@@ -308,7 +340,7 @@ export default function() {
308
340
  }[action];
309
341
 
310
342
  if ( !handler ) {
311
- logDebug( "ignoring socket-based request for actin %s on model %s due to lack of handler", action, Model.name );
343
+ logDebug( "ignoring socket-based request for action %s on model %s due to lack of handler", action, Model.name );
312
344
  return;
313
345
  }
314
346
 
@@ -343,7 +375,7 @@ export default function() {
343
375
  }
344
376
  }
345
377
 
346
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
378
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
347
379
  throw new Error( "access forbidden by model" );
348
380
  }
349
381
 
@@ -352,7 +384,7 @@ export default function() {
352
384
  let filter;
353
385
 
354
386
  if ( loadRecords ) {
355
- filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), { user }, Model, "list" );
387
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), user, Model, "list" );
356
388
  } else {
357
389
  filter = item => ( { uuid: item.uuid } );
358
390
  }
@@ -395,7 +427,7 @@ export default function() {
395
427
  }
396
428
  }
397
429
 
398
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
430
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
399
431
  throw new Error( "access forbidden by model" );
400
432
  }
401
433
 
@@ -404,7 +436,7 @@ export default function() {
404
436
  let filter;
405
437
 
406
438
  if ( loadRecords ) {
407
- filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), { user }, Model, "list" );
439
+ filter = item => api.service.OdemSchema.filterItem( this.serializeItem( item, false ), user, Model, "list" );
408
440
  } else {
409
441
  filter = item => ( { uuid: item.uuid } );
410
442
  }
@@ -444,12 +476,12 @@ export default function() {
444
476
  throw new Error( "access forbidden" );
445
477
  }
446
478
 
447
- if ( !OdemSchema.mayBeExposed( { user }, Model ) ) {
479
+ if ( !OdemSchema.mayBeExposed( user, Model ) ) {
448
480
  throw new Error( "access forbidden by model" );
449
481
  }
450
482
 
451
483
  const schema = Model.schema;
452
- const filtered = OdemSchema.filterItem( properties, { user }, Model, "create" );
484
+ const filtered = OdemSchema.filterItem( properties, user, Model, "create" );
453
485
  const instance = new Model();
454
486
 
455
487
  for ( const propName of Object.keys( filtered ) ) {
@@ -462,7 +494,7 @@ export default function() {
462
494
 
463
495
  response( {
464
496
  success: true,
465
- properties: OdemSchema.filterItem( this.serializeItem( instance, false ), { user }, Model, "read" ),
497
+ properties: OdemSchema.filterItem( this.serializeItem( instance, false ), user, Model, "read" ),
466
498
  } );
467
499
  } catch ( error ) {
468
500
  logError( "creating %s instance failed: %s", Model.name, error.stack );
@@ -494,7 +526,7 @@ export default function() {
494
526
  }
495
527
  }
496
528
 
497
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
529
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
498
530
  throw new Error( "access forbidden by model" );
499
531
  }
500
532
 
@@ -503,7 +535,7 @@ export default function() {
503
535
 
504
536
  response( {
505
537
  success: true,
506
- properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, true ), { user }, Model, "read" ),
538
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, true ), user, Model, "read" ),
507
539
  } );
508
540
  } catch ( error ) {
509
541
  logError( "reading %s instance failed: %s", Model.name, error.stack );
@@ -536,12 +568,12 @@ export default function() {
536
568
  }
537
569
  }
538
570
 
539
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
571
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
540
572
  throw new Error( "access forbidden by model" );
541
573
  }
542
574
 
543
575
  const schema = Model.schema;
544
- const filtered = api.service.OdemSchema.filterItem( properties, { user }, Model, "write" );
576
+ const filtered = api.service.OdemSchema.filterItem( properties, user, Model, "write" );
545
577
  const instance = new Model( uuid );
546
578
  await instance.load();
547
579
 
@@ -555,7 +587,7 @@ export default function() {
555
587
 
556
588
  response( {
557
589
  success: true,
558
- properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, false ), { user }, Model, "read" ),
590
+ properties: api.service.OdemSchema.filterItem( this.serializeItem( instance, false ), user, Model, "read" ),
559
591
  } );
560
592
  } catch ( error ) {
561
593
  logError( "updating %s instance failed: %s", Model.name, error.stack );
@@ -587,7 +619,7 @@ export default function() {
587
619
  }
588
620
  }
589
621
 
590
- if ( !api.service.OdemSchema.mayBeExposed( { user }, Model ) ) {
622
+ if ( !api.service.OdemSchema.mayBeExposed( user, Model ) ) {
591
623
  throw new Error( "access forbidden by model" );
592
624
  }
593
625
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hitchy/plugin-odem-socket.io",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "exposing Hitchy's document-oriented database via websocket",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -30,18 +30,24 @@
30
30
  "index.js"
31
31
  ],
32
32
  "peerDependencies": {
33
- "@hitchy/core": "1.x",
34
- "@hitchy/plugin-odem": "0.13.x",
35
- "@hitchy/plugin-socket.io": "0.1.x"
33
+ "@hitchy/core": "^1.5.5",
34
+ "@hitchy/plugin-odem": "^0.14.0",
35
+ "@hitchy/plugin-socket.io": "^0.1.3"
36
36
  },
37
37
  "devDependencies": {
38
- "@hitchy/server-dev-tools": "^0.8.6",
38
+ "@hitchy/server-dev-tools": "^0.9.6",
39
39
  "c8": "^10.1.3",
40
- "eslint": "^9.23.0",
40
+ "eslint": "^9.39.2",
41
41
  "eslint-config-cepharum": "^2.0.2",
42
- "eslint-plugin-promise": "^7.2.1",
43
- "mocha": "^11.1.0",
42
+ "mocha": "^11.7.5",
44
43
  "should": "^13.2.3",
45
- "socket.io-client": "^4.8.1"
44
+ "socket.io-client": "^4.8.3"
45
+ },
46
+ "c8": {
47
+ "reporter": [
48
+ "text",
49
+ "html"
50
+ ],
51
+ "all": true
46
52
  }
47
53
  }
package/readme.md CHANGED
@@ -166,8 +166,12 @@ Last argument is a callback to be invoked with the resulting response from serve
166
166
  The type of change is
167
167
 
168
168
  * `created` on notifications about having created another instance of model,
169
- * `updated` on notifications about having adjusted one or more properties of an existing instance of model and
170
- * `deleted` on notifications about having removed an existing instance of model.
169
+ * `changed` on notifications about having adjusted one or more properties of an existing instance of model and
170
+ * `removed` on notifications about having removed an existing instance of model.
171
+
172
+ :::danger Breaking change
173
+ Notification types have changed in v0.5.0 from `updated` to `changed` and from `deleted` to `removed` to match names used on server side.
174
+ :::
171
175
 
172
176
  In last case the provided set of properties is limited to the instance's UUID. The common pattern on client-side for listening to server-side notifications looks like this:
173
177
 
@@ -175,7 +179,7 @@ In last case the provided set of properties is limited to the instance's UUID. T
175
179
  const socket = io( "/hitchy/odem" );
176
180
 
177
181
  socket.on( "<modelName>:changed", info => {
178
- // type of change is in `info.change`, e.g. "updated"
182
+ // type of change is in `info.change`, e.g. "created", "changed" or "removed"
179
183
  // process properties of changed instance in `info.properties`
180
184
  } );
181
185
  ```