@iobroker/socket-classes 2.7.0 → 2.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -526,6 +526,7 @@ Unsubscribe from file changes in ioBroker DB
526
526
  * [`getCompactInstalled`](#getcompactinstalled_a)
527
527
  * [`getCompactSystemRepositories`](#getcompactsystemrepositories_a)
528
528
  * [`getCompactRepository`](#getcompactrepository_a)
529
+ * [`getReducedRepository`](#getreducedrepository_a)
529
530
  * [`getCompactHosts`](#getcompacthosts_a)
530
531
  * [`delState`](#delstate_a)
531
532
  * [`getStates`](#getstates_a)
@@ -770,6 +771,17 @@ made every start of the GUI wait seconds for it.
770
771
  * `host` *string*: - Host name, e.g., `system.host.raspberrypi`
771
772
  * `callback` *(result: Record<string, {version: string; icon?: string}>) => void) => void*: - Callback function `(error: string | null, results?: Record<string, { version: string; icon?: string }>) => void`
772
773
 
774
+ #### <a name="getreducedrepository_a"></a>`getReducedRepository(host, options, callback)`
775
+ Get the repository with what the GUI shows of it.
776
+ The whole repository is several megabytes, most of it the `news` of every version in every
777
+ language. They are delivered only for the adapters installed on any host and for the
778
+ controller, as only an update shows them; with `lang` all translated texts keep only that
779
+ language and English. Answered from the objects admin holds in memory where possible.
780
+ Announced as the feature `REDUCED_REPOSITORY`.
781
+ * `host` *string*: - Host to ask if the objects in memory cannot answer, e.g., `system.host.raspberrypi`
782
+ * `options` *ReducedRepositoryOptions | null | undefined*: - `{ repo?: string | string[]; update?: boolean; lang?: ioBroker.Languages; adapters?: string[] }`
783
+ * `callback` *(error: string | null | Error | undefined, repository?: Record<string, RepoAdapterObject>) => void*: - Callback function `(error: string | null, repository?: Record<string, RepoAdapterObject>) => void`
784
+
773
785
  #### <a name="getcompacthosts_a"></a>`getCompactHosts(callback)`
774
786
  Get all hosts in a compact form to save bandwidth.
775
787
  * `callback` *(error: string | null | Error | undefined, hosts?: CompactHost[]) => void) => void*: - Callback function `(error: string | null, results?: Record<string, { common: { name: string; icon: string; color: string; installedVersion: string }; native: { hardware: { networkInterfaces: any[] } } }>) => void`
@@ -1043,8 +1055,19 @@ Unsubscribe from file changes in ioBroker DB
1043
1055
  Placeholder for the next version (at the beginning of the line):
1044
1056
  ### **WORK IN PROGRESS**
1045
1057
  -->
1058
+ ### 2.8.1 (2026-10-15)
1059
+ - (@GermanBluefox) Changed: `getCompactSystemRepositories` is answered from the objects in memory
1060
+ - (@GermanBluefox) Fixed: the outdated lock file that stopped the release of 2.8.0
1061
+
1062
+ ### 2.8.0 (2026-10-15)
1063
+ - (@GermanBluefox) Added: command `getReducedRepository` - the repository without unneeded news and languages
1064
+
1065
+ ### 2.7.1 (2026-10-07)
1066
+ - (@GermanBluefox) Added: an event only reaches a connection whose user may read what it is about. A subscription says which ids a client is interested in, never which ids it may see - `subscribe` asks whether the user may read states *at all*, so whoever subscribed to `*` was told about every state of the system, the ACL of the single objects notwithstanding, and the same went for objects and for files. The fan-out asks that question now, for state, object and file events alike. Members of the administrator group are not restricted, as everywhere else, and with authentication switched off every connection is the configured default user - so for most installations nothing changes at all. **Where restricted users exist, they will see less than before: that is the point, but it is the kind of change that comes back as "my user stopped getting updates".** The database decides, not this package: `mayRead` of js-controller 8.0 and newer is asked where it exists, which knows the ACL of a state rather than that of its object and the mode of a single file rather than that of the adapter; an older controller is asked for the object instead, which is coarser but refuses in the same direction. One question per user and thing, remembered until the object - or the rights themselves - change, and the objects are watched from the first decision on, so an installation whose connections are all administrators never asks anything
1067
+ - (@GermanBluefox) Changed: `publish` and `publishFile` answer `true` where the event is on its way to the client, which now includes the moment it waits for the decision described above; `false` means the client is not subscribed to it, or may not see it. Only the first event of a thing ever waits, and only the newest one of it, so nothing can overtake it
1068
+
1046
1069
  ### 2.7.0 (2026-10-05)
1047
- - (@GermanBluefox) Added: every command that sends a message now names the user of the connection - `sendTo`, `sendToHost`, `cmdExec`, `clientSubscribe`, `clientUnsubscribe` and the "nobody subscribed" notice. Objects, states and files have always been read and written with `{ user }` so the database applies the ACLs of the logged-in user; a message had no such channel, so the receiving instance saw `from` and nothing else and had to act with its own rights - a GUI user who may use `sendTo` could have an adapter do things their own ACL forbids. It travels as a send option, which a js-controller that reports `ADAPTER_MESSAGE_USER_CONTEXT` passes on to the message as `obj.user`; an older one ignores it, so nothing breaks, and a receiving adapter has to treat the field as optional
1070
+ - (@GermanBluefox) Added: every command that sends a message now names the user of the connection - `sendTo`, `sendToHost`, `cmdExec`, `clientSubscribe`, `clientUnsubscribe` and the "nobody subscribed" notice. Objects, states and files have always been read and written with `{ user }` so the database applies the ACLs of the logged-in user; a message had no such channel, so the receiving instance saw `from` and nothing else and had to act with its own rights - a GUI user who may use `sendTo` could have an adapter do things their own ACL forbids. It travels as a send option, which js-controller 7.2.5 and newer pass on to the message as `obj.user`; an older one ignores it, so nothing breaks, and a receiving adapter treats the field as optional
1048
1071
 
1049
1072
  ### 2.6.2 (2026-10-02)
1050
1073
  - (@GermanBluefox) Fixed: a websocket whose access token was not accepted stayed open without a single command handler. The client was asked to re-authenticate, fetched a new token within milliseconds and announced it with `updateTokenExpiration` - the one command that is exempt from the session check exactly for this - but nobody was listening. The client waited for an answer that could not come until its own three second timeout closed the connection, burnt its single-use refresh token for nothing and had to start over, which is why the GUI took seconds to come up with authentication enabled and sometimes did not come up at all. The handlers are installed now, with an empty ACL: every command that needs a permission is still refused, only the announcement of a token gets through
package/build/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import passport from 'passport';
2
2
  import cookieParser from 'cookie-parser';
3
3
  export type { SocketSubscribeTypes, InternalStorageToken, CommandFile } from './types';
4
- export { SocketCommandsAdmin, type ObjectsCount } from './lib/socketCommandsAdmin';
4
+ export { SocketCommandsAdmin, type ObjectsCount, type ReducedRepositoryOptions } from './lib/socketCommandsAdmin';
5
5
  export { SocketCommands, type SupportedFeature, COMMANDS_PERMISSIONS } from './lib/socketCommands';
6
6
  export { SocketCommon, type Server, type SocketSettings, type SocketIoOptions, type WhiteListSettings, } from './lib/socketCommon';
7
7
  export { SocketAdmin } from './lib/socketAdmin';
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,wDAAgC;AAoBvB,mBApBF,kBAAQ,CAoBE;AAnBjB,kEAAyC;AAmBtB,uBAnBZ,uBAAY,CAmBY;AAhB/B,iEAAmF;AAA1E,0HAAA,mBAAmB,OAAA;AAC5B,uDAAmG;AAA1F,gHAAA,cAAc,OAAA;AAAyB,sHAAA,oBAAoB,OAAA;AACpE,mDAM4B;AALxB,4GAAA,YAAY,OAAA;AAMhB,iDAAgD;AAAvC,0GAAA,WAAW,OAAA;AACpB,yDAK8B;AACG,+FALhB,0BAAc,OAKgB"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";;;;;;AAAA,wDAAgC;AAoBvB,mBApBF,kBAAQ,CAoBE;AAnBjB,kEAAyC;AAmBtB,uBAnBZ,uBAAY,CAmBY;AAhB/B,iEAAkH;AAAzG,0HAAA,mBAAmB,OAAA;AAC5B,uDAAmG;AAA1F,gHAAA,cAAc,OAAA;AAAyB,sHAAA,oBAAoB,OAAA;AACpE,mDAM4B;AALxB,4GAAA,YAAY,OAAA;AAMhB,iDAAgD;AAAvC,0GAAA,WAAW,OAAA;AACpB,yDAK8B;AACG,+FALhB,0BAAc,OAKgB"}
@@ -3,9 +3,9 @@ import { type PermissionCommands, type SocketSubscribeTypes, type SocketOperatio
3
3
  /**
4
4
  * The options of an outbound message, as far as this package needs them.
5
5
  *
6
- * `timeout` has always been there. `user` is read by the controllers that report
7
- * `ADAPTER_MESSAGE_USER_CONTEXT` and silently ignored by older ones, and it is not in the older
8
- * `@iobroker/types` this package builds against - hence the own declaration.
6
+ * `timeout` has always been there. `user` is read by js-controller 7.2.5 and newer and silently
7
+ * ignored by older ones, and it is not in the older `@iobroker/types` this package builds against -
8
+ * hence the own declaration.
9
9
  */
10
10
  interface MessageSendOptions {
11
11
  /** Reject/err-callback if no answer arrives in time (single targets only) */
@@ -34,7 +34,9 @@ export type Ratings = {
34
34
  };
35
35
  export type SupportedFeature = 'ALIAS' | 'ALIAS_SEPARATE_READ_WRITE_ID' | 'ADAPTER_GETPORT_BIND' | 'ADAPTER_DEL_OBJECT_RECURSIVE' | 'ADAPTER_SET_OBJECT_SETS_DEFAULT_VALUE' | 'ADAPTER_AUTO_DECRYPT_NATIVE' | 'PLUGINS' | 'CONTROLLER_NPM_AUTO_REBUILD' | 'CONTROLLER_READWRITE_BASE_SETTINGS' | 'CONTROLLER_MULTI_REPO' | 'CONTROLLER_LICENSE_MANAGER' | 'CONTROLLER_OS_PACKAGE_UPGRADE' | 'DEL_INSTANCE_CUSTOM' | 'CUSTOM_FULL_VIEW' | 'ADAPTER_GET_OBJECTS_BY_ARRAY' | 'CONTROLLER_UI_UPGRADE' | 'ADAPTER_WEBSERVER_UPGRADE' | 'INSTANCE_MESSAGES' | 'PARTIAL_OBJECT_TREE'
36
36
  /** The command `getObjectsCount` counts the objects instead of the client reading them all */
37
- | 'OBJECTS_COUNT';
37
+ | 'OBJECTS_COUNT'
38
+ /** The command `getReducedRepository` delivers the repository without what the GUI does not show */
39
+ | 'REDUCED_REPOSITORY';
38
40
  export interface SocketDataContext {
39
41
  language?: ioBroker.Languages;
40
42
  ratings: Ratings | null;
@@ -74,7 +76,36 @@ export declare class SocketCommands {
74
76
  */
75
77
  static _fixCallback(callback: SocketCallback | null | undefined, error: string | Error | null | undefined, ...args: any[]): void;
76
78
  _checkPermissions(socket: WebSocketClient, command: PermissionCommands, callback: ((error: string | null, ...args: any[]) => void) | undefined, ...args: any[]): boolean;
79
+ /**
80
+ * Give one event to one connection, if it is subscribed to it and may see it.
81
+ *
82
+ * Returns whether the event is on its way to this client: `true` where it was sent, and also
83
+ * where it is waiting for the database to decide whether this user may read the object - the
84
+ * answer to that is not worth holding up every other connection for. `false` means the client is
85
+ * not subscribed to it, or may not see it.
86
+ *
87
+ * @param socket the connection
88
+ * @param type what kind of event it is
89
+ * @param id the object it is about
90
+ * @param obj the state or object as it is sent to the client
91
+ */
77
92
  publish(socket: WebSocketClient, type: SocketSubscribeTypes, id: string, obj: ioBroker.Object | ioBroker.State | null | undefined): boolean;
93
+ /**
94
+ * Give one file event to one connection, if it is subscribed to it and may see it.
95
+ *
96
+ * The decision is taken per file and user. Where the controller has `mayRead`, it answers for the
97
+ * single file, with its own owner and mode as `chownFile` and `chmodFile` set them. An older
98
+ * controller cannot be asked about a file, so the meta object the files belong to - `vis.0` for
99
+ * every file of vis - stands in for it: still one question per file, but every one of them about
100
+ * the adapter. That also works where the file the event is about has just been deleted.
101
+ *
102
+ * The answer means the same as in {@link publish}: `true` where the event is on its way.
103
+ *
104
+ * @param socket the connection
105
+ * @param id the adapter the file belongs to, e.g. `vis.0`
106
+ * @param fileName the path of the file inside it
107
+ * @param size how big it is now, or null where it is gone
108
+ */
78
109
  publishFile(socket: WebSocketClient, id: string, fileName: string, size: number | null): boolean;
79
110
  /**
80
111
  * The send options for a message triggered by this socket: the user it is sent on behalf of.
@@ -82,9 +113,10 @@ export declare class SocketCommands {
82
113
  * Objects, states and files have always been read and written with `{ user }` so the database
83
114
  * applies the ACLs of the logged-in user. A message had no such channel: the receiving instance saw
84
115
  * `from` and nothing else, so every adapter reachable over `sendTo` had to act with its own rights,
85
- * and could not tell one caller from another. The user travels with the message now, for the
86
- * controller versions that support it - older ones ignore the option, so nothing breaks, but a
87
- * receiver must treat `obj.user` as optional (`adapter.supportsFeature('ADAPTER_MESSAGE_USER_CONTEXT')`).
116
+ * and could not tell one caller from another. The user travels with the message now: js-controller
117
+ * 7.2.5 and newer put it into the message as `obj.user`, older ones ignore the option, so nothing
118
+ * breaks. A receiver treats the field as optional - where nobody was named there is nothing to
119
+ * check against, which is how every message looked before.
88
120
  *
89
121
  * @param socket the socket the command came in on
90
122
  */
@@ -3,6 +3,16 @@ var _a;
3
3
  Object.defineProperty(exports, "__esModule", { value: true });
4
4
  exports.SocketCommands = exports.COMMANDS_PERMISSIONS = void 0;
5
5
  const adapter_core_1 = require("@iobroker/adapter-core"); // Get common adapter utils
6
+ /**
7
+ * The events that are waiting for the database to say whether their socket may see them.
8
+ *
9
+ * Kept per connection and per event, and only ever the **latest** one: while the first event of an id
10
+ * waits for its answer, a newer value of the same state would otherwise overtake it and the client
11
+ * would end up with the older one. What is kept is how to deliver it - the event goes through
12
+ * `publish`/`publishFile` again, so a subscription that was given up in the meantime still counts. It
13
+ * lives in a `WeakMap`, so a connection that goes away takes its waiting events with it.
14
+ */
15
+ const pendingEvents = new WeakMap();
6
16
  exports.COMMANDS_PERMISSIONS = {
7
17
  getObject: { type: 'object', operation: 'read' },
8
18
  getObjects: { type: 'object', operation: 'list' },
@@ -71,6 +81,21 @@ class SocketCommands {
71
81
  subscribes = {};
72
82
  #logEnabled = false;
73
83
  #clientSubscribes = {};
84
+ /**
85
+ * What each user may read, by object id. Filled while the events flow, emptied where an object -
86
+ * or the rights themselves - change. Per user, because every browser tab of one person would
87
+ * otherwise ask the same question again.
88
+ */
89
+ #readable = new Map();
90
+ /** Questions that are with the database right now, so the same id is asked only once at a time */
91
+ #deciding = new Map();
92
+ /**
93
+ * Counts how often the decisions were thrown away. An answer that was asked for before the last
94
+ * change arrives too late to be trusted and is dropped instead of being remembered.
95
+ */
96
+ #readableGeneration = 0;
97
+ /** Whether this instance watches the objects for the sake of its own decisions - see `#rememberDecision` */
98
+ #watchingObjects = false;
74
99
  #updateSession;
75
100
  adapterName;
76
101
  _sendToHost;
@@ -260,10 +285,250 @@ class SocketCommands {
260
285
  }
261
286
  return true;
262
287
  }
288
+ /**
289
+ * Whether this connection belongs to somebody who may see everything.
290
+ *
291
+ * The administrator group is the whole point of the administrator group, and with authentication
292
+ * switched off every connection is the configured default user - usually exactly that one. So this
293
+ * is the answer for almost every connection there is, and it costs a look at an ACL that the
294
+ * socket layer calculated when the connection was opened.
295
+ *
296
+ * @param socket the connection in question
297
+ */
298
+ static #seesEverything(socket) {
299
+ const acl = socket?._acl;
300
+ return acl?.user === 'system.user.admin' || !!acl?.groups?.includes('system.group.administrator');
301
+ }
302
+ /**
303
+ * Whether the user of this connection may read `id`, as far as it is already known.
304
+ *
305
+ * `undefined` means "not decided yet" - the caller has to let `#decideReadable` ask the database
306
+ * and come back. Decisions are kept per user, not per connection: three browser tabs of the same
307
+ * person ask once.
308
+ *
309
+ * @param socket the connection the event would go to
310
+ * @param question what the event is about
311
+ */
312
+ #mayRead(socket, question) {
313
+ const user = socket?._acl?.user;
314
+ if (!user) {
315
+ // a connection without a user has no rights at all, not even to be told that `id` exists
316
+ return false;
317
+ }
318
+ if (_a.#seesEverything(socket)) {
319
+ return true;
320
+ }
321
+ return this.#readable.get(user)?.get(_a.#questionKey(question));
322
+ }
323
+ /** What one question is remembered under: the kind, the id, and for a file its name */
324
+ static #questionKey(question) {
325
+ return question.fileName
326
+ ? `${question.type}####${question.id}####${question.fileName}`
327
+ : `${question.type}####${question.id}`;
328
+ }
329
+ /**
330
+ * Ask the database whether the user may read `id`, and remember the answer.
331
+ *
332
+ * The database owns this decision - the ACL of the object, its owner, the groups of the user and
333
+ * the default ACL of the system all go into it, and a second implementation here would drift away
334
+ * from it sooner or later. Everybody who is waiting for the same id is answered together.
335
+ *
336
+ * @param socket the connection the event would go to
337
+ * @param question what the event is about
338
+ * @param andThen what to do once it is decided, with the decision
339
+ */
340
+ #decideReadable(socket, question, andThen) {
341
+ // a connection without a user never gets this far: `#mayRead` refuses it outright
342
+ const user = socket._acl.user;
343
+ const questionKey = _a.#questionKey(question);
344
+ const key = `${user}####${questionKey}`;
345
+ const waiting = this.#deciding.get(key);
346
+ if (waiting) {
347
+ waiting.push(andThen);
348
+ return;
349
+ }
350
+ this.#deciding.set(key, [andThen]);
351
+ const generation = this.#readableGeneration;
352
+ const answer = (allowed) => {
353
+ if (generation === this.#readableGeneration) {
354
+ this.#rememberDecision(user, questionKey, allowed);
355
+ }
356
+ /*
357
+ * An answer that is too old to be remembered still answers the events that waited for it:
358
+ * a "no" drops them, a "yes" sends them back through `publish`, which - with nothing in
359
+ * memory - asks again under the rights as they are now.
360
+ */
361
+ const callbacks = this.#deciding.get(key) || [];
362
+ this.#deciding.delete(key);
363
+ for (const callback of callbacks) {
364
+ callback(allowed);
365
+ }
366
+ };
367
+ const mayRead = this.adapter.mayRead;
368
+ if (mayRead) {
369
+ /*
370
+ * The controller answers this itself from js-controller 8.0 on, and it is the only place
371
+ * that knows the whole truth: the ACL of a state rather than of its object, the mode of a
372
+ * single file rather than of the adapter it belongs to.
373
+ */
374
+ mayRead.call(this.adapter, { ...question, user }).then(answer, (e) => {
375
+ this.adapter.log.warn(`Cannot check what "${user}" may read of "${question.id}": ${e.message}`);
376
+ answer(false);
377
+ });
378
+ return;
379
+ }
380
+ /*
381
+ * An older controller cannot be asked, so the object has to stand in for all three. It is
382
+ * coarser - the right on the object instead of the one on the state, the adapter instead of
383
+ * the single file - but it errs the same way: only a refusal of the database counts as "no".
384
+ * That an object does not exist is not a refusal, and `mayRead` of a newer controller says the
385
+ * same, because such a state can be read by anybody.
386
+ */
387
+ // the callback form also returns a promise, which nobody here waits for
388
+ void this.adapter.getForeignObject(question.id, { user }, (error) => answer(!error));
389
+ }
390
+ /**
391
+ * Keep one decision, and make sure the object changes that would invalidate it arrive here.
392
+ *
393
+ * Nothing of this is needed as long as everybody who is connected sees everything anyway, which is
394
+ * the normal case - so the subscription is taken out where the first decision is made and not
395
+ * before. The events themselves reach `publish` the way every other object change does.
396
+ *
397
+ * @param user whose decision it is
398
+ * @param questionKey what was asked - the kind, the id and for a file its name
399
+ * @param allowed what was decided
400
+ */
401
+ #rememberDecision(user, questionKey, allowed) {
402
+ let decisions = this.#readable.get(user);
403
+ if (!decisions) {
404
+ decisions = new Map();
405
+ this.#readable.set(user, decisions);
406
+ }
407
+ decisions.set(questionKey, allowed);
408
+ if (!this.#watchingObjects) {
409
+ this.#watchingObjects = true;
410
+ /*
411
+ * The ACL of an object can change at any time, and without this the decision above would
412
+ * be kept until the connection goes away. The watch counts as one more subscriber of `*`:
413
+ * a client that subscribed to all objects and gives them up again would otherwise take
414
+ * the subscription of the database away from under it.
415
+ */
416
+ this.subscribes.objectChange ||= {};
417
+ if (this.subscribes.objectChange['*'] === undefined) {
418
+ this.subscribes.objectChange['*'] = 1;
419
+ this.adapter
420
+ .subscribeForeignObjectsAsync('*')
421
+ .catch(e => this.adapter.log.warn(`Cannot watch the objects for the permissions of the clients: ${e.message}`));
422
+ }
423
+ else {
424
+ this.subscribes.objectChange['*']++;
425
+ }
426
+ }
427
+ }
428
+ /** Forget what was decided about `id`, because the object - and with it its ACL - changed. */
429
+ #forgetReadable(id) {
430
+ this.#readableGeneration++;
431
+ if (id.startsWith('system.user.') || id.startsWith('system.group.') || id === 'system.config') {
432
+ // the rights themselves moved, so nothing that was decided with them still counts
433
+ this.#readable.clear();
434
+ return;
435
+ }
436
+ for (const decisions of this.#readable.values()) {
437
+ // everything that was decided about this id, whatever was asked about it
438
+ for (const questionKey of decisions.keys()) {
439
+ if (questionKey.endsWith(`####${id}`) || questionKey.includes(`####${id}####`)) {
440
+ decisions.delete(questionKey);
441
+ }
442
+ }
443
+ }
444
+ }
445
+ /**
446
+ * Hold one event until it is decided whether its connection may see it.
447
+ *
448
+ * Only the latest event of the same kind and id waits; a newer one replaces it, so nothing can
449
+ * overtake it. The question is asked once - every further event of that id joins the one that is
450
+ * already open.
451
+ *
452
+ * @param socket the connection the event would go to
453
+ * @param question what the decision is about
454
+ * @param key what identifies this event - its kind and what it is about
455
+ * @param deliver how to send it once it is decided
456
+ */
457
+ #holdUntilDecided(socket, question, key, deliver) {
458
+ let waiting = pendingEvents.get(socket);
459
+ if (!waiting) {
460
+ waiting = new Map();
461
+ pendingEvents.set(socket, waiting);
462
+ }
463
+ const first = !waiting.has(key);
464
+ waiting.set(key, deliver);
465
+ if (first) {
466
+ this.#decideReadable(socket, question, allowed => this.#flushPending(socket, key, allowed));
467
+ }
468
+ }
469
+ /**
470
+ * Send the event that waited for a decision, if it may go out after all.
471
+ *
472
+ * What is sent is whatever arrived last while the question was open - a client that subscribes to
473
+ * a state wants its value, not the one it had a moment ago. It goes through `publish`/`publishFile`
474
+ * again: the subscription may have been given up in the meantime. Usually the decision is in memory
475
+ * by then and the event goes out; only where the object changed while the question was open was
476
+ * the answer not remembered, and the event waits once more for a fresh one.
477
+ *
478
+ * @param socket the connection that waited
479
+ * @param key what identifies the event
480
+ * @param allowed what was decided
481
+ */
482
+ #flushPending(socket, key, allowed) {
483
+ const waiting = pendingEvents.get(socket);
484
+ const deliver = waiting?.get(key);
485
+ if (!deliver || !waiting) {
486
+ return;
487
+ }
488
+ waiting.delete(key);
489
+ if (allowed) {
490
+ deliver();
491
+ }
492
+ }
493
+ /**
494
+ * Give one event to one connection, if it is subscribed to it and may see it.
495
+ *
496
+ * Returns whether the event is on its way to this client: `true` where it was sent, and also
497
+ * where it is waiting for the database to decide whether this user may read the object - the
498
+ * answer to that is not worth holding up every other connection for. `false` means the client is
499
+ * not subscribed to it, or may not see it.
500
+ *
501
+ * @param socket the connection
502
+ * @param type what kind of event it is
503
+ * @param id the object it is about
504
+ * @param obj the state or object as it is sent to the client
505
+ */
263
506
  publish(socket, type, id, obj) {
507
+ if (type === 'objectChange') {
508
+ // the ACL of the object travelled with it, so what was decided about it is out of date
509
+ this.#forgetReadable(id);
510
+ }
264
511
  if (socket?.subscribe?.[type] && this.#updateSession(socket)) {
265
512
  return !!socket.subscribe[type].find(sub => {
266
513
  if (sub.regex.test(id)) {
514
+ /*
515
+ * Who may know that this exists? A subscription says which ids a client is
516
+ * interested in, never which ids it may see - `subscribe` only asks whether the
517
+ * user may read states at all, so whoever subscribed to `*` was told about every
518
+ * state of the system, the ACL of the single objects notwithstanding.
519
+ */
520
+ const question = { type: type === 'objectChange' ? 'object' : 'state', id };
521
+ const allowed = this.#mayRead(socket, question);
522
+ if (allowed === false) {
523
+ return false;
524
+ }
525
+ if (allowed === undefined) {
526
+ // the first event of an id for this user waits for the answer instead of
527
+ // being guessed at
528
+ this.#holdUntilDecided(socket, question, `${type}####${id}`, () => this.publish(socket, type, id, obj));
529
+ // on its way: either it goes out in a moment, or the user may not see it
530
+ return true;
531
+ }
267
532
  // replace language
268
533
  if (this.context.language &&
269
534
  id === 'system.config' &&
@@ -277,11 +542,36 @@ class SocketCommands {
277
542
  }
278
543
  return false;
279
544
  }
545
+ /**
546
+ * Give one file event to one connection, if it is subscribed to it and may see it.
547
+ *
548
+ * The decision is taken per file and user. Where the controller has `mayRead`, it answers for the
549
+ * single file, with its own owner and mode as `chownFile` and `chmodFile` set them. An older
550
+ * controller cannot be asked about a file, so the meta object the files belong to - `vis.0` for
551
+ * every file of vis - stands in for it: still one question per file, but every one of them about
552
+ * the adapter. That also works where the file the event is about has just been deleted.
553
+ *
554
+ * The answer means the same as in {@link publish}: `true` where the event is on its way.
555
+ *
556
+ * @param socket the connection
557
+ * @param id the adapter the file belongs to, e.g. `vis.0`
558
+ * @param fileName the path of the file inside it
559
+ * @param size how big it is now, or null where it is gone
560
+ */
280
561
  publishFile(socket, id, fileName, size) {
281
562
  if (socket?.subscribe?.fileChange && this.#updateSession(socket)) {
282
563
  const key = `${id}####${fileName}`;
283
564
  return !!socket.subscribe.fileChange.find(sub => {
284
565
  if (sub.regex.test(key)) {
566
+ const question = { type: 'file', id, fileName };
567
+ const allowed = this.#mayRead(socket, question);
568
+ if (allowed === false) {
569
+ return false;
570
+ }
571
+ if (allowed === undefined) {
572
+ this.#holdUntilDecided(socket, question, `fileChange####${key}`, () => this.publishFile(socket, id, fileName, size));
573
+ return true;
574
+ }
285
575
  socket.emit('fileChange', id, fileName, size);
286
576
  return true;
287
577
  }
@@ -295,9 +585,10 @@ class SocketCommands {
295
585
  * Objects, states and files have always been read and written with `{ user }` so the database
296
586
  * applies the ACLs of the logged-in user. A message had no such channel: the receiving instance saw
297
587
  * `from` and nothing else, so every adapter reachable over `sendTo` had to act with its own rights,
298
- * and could not tell one caller from another. The user travels with the message now, for the
299
- * controller versions that support it - older ones ignore the option, so nothing breaks, but a
300
- * receiver must treat `obj.user` as optional (`adapter.supportsFeature('ADAPTER_MESSAGE_USER_CONTEXT')`).
588
+ * and could not tell one caller from another. The user travels with the message now: js-controller
589
+ * 7.2.5 and newer put it into the message as `obj.user`, older ones ignore the option, so nothing
590
+ * breaks. A receiver treats the field as optional - where nobody was named there is nothing to
591
+ * check against, which is how every message looked before.
301
592
  *
302
593
  * @param socket the socket the command came in on
303
594
  */
@@ -882,6 +1173,9 @@ class SocketCommands {
882
1173
  // send a command that nobody answers - it would sit there until its timeout
883
1174
  _a._fixCallback(callback, null, !!this.commands.getObjectsCount);
884
1175
  }
1176
+ else if (feature === 'REDUCED_REPOSITORY') {
1177
+ _a._fixCallback(callback, null, !!this.commands.getReducedRepository);
1178
+ }
885
1179
  else {
886
1180
  _a._fixCallback(callback, null, this.adapter.supportsFeature(feature));
887
1181
  }
@@ -1015,8 +1309,8 @@ class SocketCommands {
1015
1309
  }
1016
1310
  else {
1017
1311
  try {
1018
- // the 5th parameter exists from the controller version that reports
1019
- // `ADAPTER_MESSAGE_USER_CONTEXT`; an older one ignores the extra argument
1312
+ // the 5th parameter exists from js-controller 7.2.5 on; an older one
1313
+ // ignores the extra argument
1020
1314
  const sendToHost = this.adapter.sendToHost;
1021
1315
  sendToHost.call(this.adapter, host, command, message, callback, _a.sendOptionsOf(socket));
1022
1316
  }