@camstack/server 1.2.355 → 1.2.356

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.
@@ -20,10 +20,16 @@
20
20
  * source the DEVICE has (`CapabilityRegistry.resolveCollectionForDevice` —
21
21
  * the enabled providers, minus the ones the operator switched off for this
22
22
  * camera through `deviceManager.setWrapperActive`, D556), and the results
23
- * are concatenated. No dedup, no best-source
24
- * pick: a clip held in two storages appears twice, once per source, and
25
- * each row says which (D549 decision 11 — stated there as a consequence
26
- * precisely so it is not "fixed" later).
23
+ * are concatenated. No dedup ACROSS SOURCES, no best-source pick: a clip
24
+ * held in two storages appears twice, once per source, and each row says
25
+ * which (D549 decision 11 — stated there as a consequence precisely so it
26
+ * is not "fixed" later). But a source is not a REGISTRATION: one addon
27
+ * reaches this dispatcher as the device's native provider, as the
28
+ * hub-local registration and once per agent node, and those are one
29
+ * source answering three times. Each row is held once, keyed by the
30
+ * identity its provider minted ({@link holdEachRowOnce}); on 592 that was
31
+ * three `analytics` tick-boxes for one thing, D62's defect with the
32
+ * polarity reversed (D554 amended 2026-09-20).
27
33
  * 2. **One failing source never empties the others.** Every source is called
28
34
  * independently and a rejection removes only its own rows — and says so,
29
35
  * per source, with `tags: { deviceId }`. A fan-out where one throwing
@@ -35,7 +41,9 @@
35
41
  * ids are self-contained and source-prefixed (`analytics:…`,
36
42
  * `native:reolink:onboard:…`, `hksv:…`), so the dispatcher asks each
37
43
  * source which ids it owns ({@link SOURCE_LISTING_METHOD}) and hands the
38
- * call to the one that claims the longest matching prefix. An id nobody
44
+ * call to the one that claims the longest matching prefix. Several
45
+ * registrations of one source all claim it: the FIRST serving one wins,
46
+ * and it is the registration the collapsed listing row names. An id nobody
39
47
  * claims is REFUSED by name — never handed to the first provider, which is
40
48
  * how a fabricated `addonId` once came back with another addon's answer
41
49
  * (D552 consequences, `rejectUnresolvedAddonPin`).
@@ -49,7 +57,6 @@
49
57
  Object.defineProperty(exports, "__esModule", { value: true });
50
58
  exports.SOURCE_LISTING_METHOD = void 0;
51
59
  exports.requireDeviceCollection = requireDeviceCollection;
52
- const system_1 = require("@camstack/system");
53
60
  const types_1 = require("@camstack/types");
54
61
  const server_1 = require("@trpc/server");
55
62
  /**
@@ -64,50 +71,127 @@ exports.SOURCE_LISTING_METHOD = 'listSources';
64
71
  const SOURCE_ID_FIELD = 'source';
65
72
  /** The field on a source row that names the addon serving it (D555/D557). */
66
73
  const SOURCE_ADDON_FIELD = 'addonId';
67
- /** The field a source row carries the operator's off switch on (D556 amended). */
68
- const SOURCE_SWITCHED_OFF_FIELD = 'switchedOff';
69
- /** The availability field a switched-off row must not carry a value in. */
70
- const CATALOG_AGE_FIELD = 'catalogAsOf';
71
74
  /**
72
- * EVERY source a DEVICE has, in registration order with the device's native
73
- * provider first when it has one — each one marked with whether the operator
74
- * switched it off.
75
+ * The input field naming WHICH of a device's providers to ask. One at a time,
76
+ * by `addonId`, never a list of source ids: the operator settled the surface
77
+ * on 2026-09-20 (*"l'utilizzatore è uno solo"*). Absent means the device's
78
+ * bound provider.
79
+ */
80
+ const PROVIDER_PIN_FIELD = 'provider';
81
+ /** The capability whose singleton answers what a device is bound to. */
82
+ const DEVICE_MANAGER_CAP = 'device-manager';
83
+ /**
84
+ * EVERY source a DEVICE has — read from the device's BINDINGS, which is the
85
+ * framework's answer to "which providers does this device have".
86
+ *
87
+ * It used to enumerate REGISTRATIONS, and that is what put `analytics` in the
88
+ * picker three times on the first live run (device 592, 2026-09-20): one addon
89
+ * reached here as the device's native provider, as the hub-local registration
90
+ * and again as `pipeline-analytics@little-unraid`, with a fourth on the Mac
91
+ * node that `resolveCollectionForDevice` had already dropped. Three tick-boxes
92
+ * for one thing, and `listClips` triplicating every row underneath them.
93
+ *
94
+ * A binding is one per `(device, cap, provider)` and `setWrapperActive` (D62)
95
+ * is the authority over it, so **three rows for one provider stop being
96
+ * representable** — there is nothing to de-duplicate after the fact. It also
97
+ * makes the list mean what the operator says it means: a source exists for a
98
+ * camera because a configuration elsewhere put it there, not because some
99
+ * addon somewhere registered the capability.
75
100
  *
76
- * `resolveCollectionForDevice` decides which of the registered providers this
77
- * device may use: it drops disabled ones and ones inactive for the system. The
78
- * ones the operator switched OFF for this camera are NOT dropped here — they
79
- * are marked, because a switched-off source must still produce a ROW to be
80
- * switched back on from, and only its own provider can name it (D554 decision
81
- * 4; D556 as amended). What that mark costs the source is everything else:
82
- * {@link requireDeviceCollection} calls it for the source listing and for
83
- * nothing else. The deny set is the registry's PROJECTION of the persisted
84
- * wrapper activation — the operator's verb is `deviceManager.setWrapperActive`
85
- * and the authority is its store, never a second one here (D556).
101
+ * The binding names an ADDON, never a shape: `DeviceBindingEntry.kind` is UI
102
+ * metadata and is not branched on here (D13). A named addon is looked up in the
103
+ * cap's collection map, and failing that against the device's native provider,
104
+ * which the registry cannot always NAME (a cross-process native arrives through
105
+ * `setNativeFallback` with no addon id of its own) — in that case the binding is
106
+ * the only thing that names it.
86
107
  *
87
- * `resolveCollectionForDevice` returns provider objects only, so the addon ids
88
- * — needed to NAME a source that failed, to stamp the rows, and to apply the
89
- * addon-grained half of the deny set — come from `getCollectionEntries` by
90
- * object identity: both come from the same registry map in the same call, so
91
- * the reference IS the key.
108
+ * **A bindings read that FAILS keeps the registration set** (D49). Answering an
109
+ * empty list would read as "this camera has no recordings", which is the defect
110
+ * this whole line of work exists to end, so a failure is logged and the old
111
+ * behaviour stands for that call.
92
112
  */
93
- function sourcesForDevice(registry, capName, deviceId) {
94
- const admitted = new Set(registry.resolveCollectionForDevice(capName, String(deviceId), {
95
- includeSwitchedOff: true,
96
- }));
113
+ async function sourcesForDevice(registry, capName, deviceId, logger) {
114
+ const registered = new Map(registry.getCollectionEntries(capName));
115
+ const bound = await boundAddonIds(registry, capName, deviceId, logger);
116
+ if (bound === null)
117
+ return registrationsForDevice(registry, capName, deviceId);
118
+ const sources = [];
119
+ for (const addonId of bound) {
120
+ const provider = registered.get(addonId) ?? nativeNamedBy(registry, capName, deviceId, addonId);
121
+ if (provider === undefined) {
122
+ // A binding whose provider never booted is a DROPPED source, and a
123
+ // dropped source that says nothing is indistinguishable from a camera
124
+ // that has none.
125
+ logger?.warn(`${capName}: device is bound to "${addonId}", which has registered no provider here`, { tags: { deviceId, addonId }, meta: { capability: capName } });
126
+ continue;
127
+ }
128
+ sources.push({ addonId, provider });
129
+ }
130
+ return sources;
131
+ }
132
+ /**
133
+ * The addon ids the device is bound to for `capName`, or `null` when the
134
+ * bindings could not be read at all (never an empty list for that case — the
135
+ * two must not look alike).
136
+ */
137
+ async function boundAddonIds(registry, capName, deviceId, logger) {
138
+ const manager = registry.getSingleton(DEVICE_MANAGER_CAP);
139
+ if (manager === null || typeof manager.getBindings !== 'function') {
140
+ logger?.warn(`${capName}: no device manager to read this device's bindings from`, {
141
+ tags: { deviceId },
142
+ meta: { capability: capName },
143
+ });
144
+ return null;
145
+ }
146
+ try {
147
+ const answer = await manager.getBindings({ deviceId });
148
+ const ids = [];
149
+ for (const entry of answer.entries) {
150
+ if (entry.capName !== capName)
151
+ continue;
152
+ const addonId = entry.providerAddonId;
153
+ if (addonId.length > 0 && !ids.includes(addonId))
154
+ ids.push(addonId);
155
+ }
156
+ return ids;
157
+ }
158
+ catch (err) {
159
+ logger?.warn(`${capName}: could not read this device's bindings — keeping the registrations`, {
160
+ tags: { deviceId },
161
+ meta: { capability: capName, reason: errMessage(err) },
162
+ });
163
+ return null;
164
+ }
165
+ }
166
+ /**
167
+ * The device's native provider when the binding's addon id is the one that
168
+ * registered it — or when the registry cannot name it at all, which is how a
169
+ * cross-process native arrives (`setNativeFallback`). The native is not in
170
+ * `state.providers`, so it is asked about separately (D556 decision 5).
171
+ */
172
+ function nativeNamedBy(registry, capName, deviceId, addonId) {
173
+ const native = registry.getNativeProvider(capName, deviceId);
174
+ if (native === null)
175
+ return undefined;
176
+ const nativeAddonId = registry.getNativeAddonId(capName, deviceId);
177
+ if (nativeAddonId !== null && nativeAddonId !== addonId)
178
+ return undefined;
179
+ return native;
180
+ }
181
+ /**
182
+ * What this dispatcher did before the bindings became its authority, kept for
183
+ * exactly one caller: the failure path above. Every registered provider this
184
+ * device may use, native first.
185
+ */
186
+ function registrationsForDevice(registry, capName, deviceId) {
187
+ const admitted = new Set(registry.resolveCollectionForDevice(capName, String(deviceId)));
97
188
  const sources = [];
98
189
  const seen = new Set();
99
190
  const native = registry.getNativeProvider(capName, deviceId);
100
191
  if (native !== null) {
101
- // The native is NOT in `state.providers`, so `resolveCollectionForDevice`
102
- // never sees it — it is asked about separately, against the same authority
103
- // (D556 decision 5). Without this the picker would carry one row the
104
- // operator cannot switch off, and it is the camera's OWN recordings:
105
- // exactly the source the external-videoclips line is about.
106
- const nativeAddonId = registry.getNativeAddonId(capName, deviceId) ?? 'native';
107
192
  sources.push({
108
- addonId: nativeAddonId,
193
+ addonId: registry.getNativeAddonId(capName, deviceId) ?? 'native',
109
194
  provider: native,
110
- switchedOff: registry.isDeviceSourceDisabled(String(deviceId), capName, nativeAddonId),
111
195
  });
112
196
  seen.add(native);
113
197
  }
@@ -115,11 +199,7 @@ function sourcesForDevice(registry, capName, deviceId) {
115
199
  if (!admitted.has(provider) || seen.has(provider))
116
200
  continue;
117
201
  seen.add(provider);
118
- sources.push({
119
- addonId,
120
- provider,
121
- switchedOff: registry.isDeviceSourceDisabled(String(deviceId), capName, addonId),
122
- });
202
+ sources.push({ addonId, provider });
123
203
  }
124
204
  return sources;
125
205
  }
@@ -206,7 +286,7 @@ function unreachableSourceRow(addonId, reason) {
206
286
  availability: { state: 'unreachable', reason },
207
287
  };
208
288
  }
209
- async function fanOut(registry, sources, method, input, capName, deviceId, logger) {
289
+ async function fanOut(sources, method, input, capName, deviceId, logger) {
210
290
  const isSourceListing = method === exports.SOURCE_LISTING_METHOD;
211
291
  const settled = await Promise.allSettled(sources.map(async (source) => await callSource(source, method, input, capName, deviceId)));
212
292
  const rows = [];
@@ -233,88 +313,38 @@ async function fanOut(registry, sources, method, input, capName, deviceId, logge
233
313
  });
234
314
  continue;
235
315
  }
236
- rows.push(...finishRows(registry, capName, deviceId, source, result.value, isSourceListing));
316
+ rows.push(...finishRows(source, result.value, isSourceListing));
237
317
  }
238
318
  return mergeRows(rows);
239
319
  }
240
320
  /**
241
- * The availability block without its catalog age.
321
+ * Stamp each source-listing row with the addon that really served it.
242
322
  *
243
- * `catalogAsOf` means "when this source's catalog was last CONFIRMED". A source
244
- * the operator switched off is never asked for a catalog, so it has no
245
- * confirmation to report and will never get a fresher one — and an age that can
246
- * only grow draws a STALLING source rather than an off switch (D62: disabled
247
- * must never look like broken). The rest of the block is left exactly as the
248
- * provider said it: what the source CAN do is a different question from whether
249
- * it may.
250
- */
251
- function withoutCatalogAge(availability) {
252
- if (availability === null || typeof availability !== 'object')
253
- return availability;
254
- return Object.fromEntries(Object.entries(availability).filter(([key]) => key !== CATALOG_AGE_FIELD));
255
- }
256
- /**
257
- * Stamp each row with the addon that really served it, mark a source listing
258
- * row the operator switched off, and drop the CLIP rows of such a source.
259
- *
260
- * The two halves are deliberately asymmetric, and that asymmetry is the whole
261
- * of D556's amendment: a switched-off source contributes no clips (the point of
262
- * the switch) and keeps its row (the control the switch is undone from). A
263
- * `listSources` that dropped the row left an unticked source with nothing to
264
- * un-tick — on a Reolink camera, which has exactly one source since D555's
265
- * collapse, that is the entire picker.
266
- *
267
- * **The stamp** is authoritative: a surface cannot otherwise resolve a source
268
- * row to who answers it, and the alternative — a `native:reolink:* →
323
+ * The stamp is authoritative: a surface cannot otherwise resolve a source row
324
+ * to who answers it, and the alternative — a `native:reolink:* →
269
325
  * provider-reolink` prefix table inside the widget — is a second authority on
270
326
  * provider identity living in the one package with no business knowing it
271
327
  * (D557). The registry's id is a fact; a provider's own value is a claim, so
272
328
  * the fact wins.
273
329
  *
274
- * **The drop** happens here rather than in `sourcesForDevice` because one
275
- * addon serves SEVERAL of a device's sources (`addon-provider-reolink` answers
276
- * a hub child with `onboard` AND `hub`), so dropping the PROVIDER would turn
277
- * off every source it has while the operator ticked one. Rows are where
278
- * sources are actually named, so this is the grain the per-source authority
279
- * applies at — same registry projection, same migration rule, one step finer.
280
- *
281
- * A camera with NO deny entry is untouched: the rows come back verbatim, in
282
- * the provider's own order, which is D554 decision 6's guarantee and today's
283
- * behaviour for every camera on the fleet.
330
+ * It used to also DROP the rows of a source the operator had switched off, and
331
+ * mark the listing row that let him switch it back on. That whole half went on
332
+ * 2026-09-20: every provider always works, and a source exists for a camera
333
+ * because a binding put it there — so "off" is an unbound provider, not a
334
+ * second store beside the binding (D556/D557 amended). Nothing here filters
335
+ * any more; the filtering happened when {@link sourcesForDevice} read the
336
+ * bindings.
284
337
  */
285
- function finishRows(registry, capName, deviceId, source, rows, isSourceListing) {
286
- const denied = registry.getDeviceDisabledSources(String(deviceId), capName);
338
+ function finishRows(source, rows, isSourceListing) {
339
+ if (!isSourceListing)
340
+ return rows;
287
341
  const out = [];
288
342
  for (const row of rows) {
289
343
  if (row === null || typeof row !== 'object') {
290
344
  out.push(row);
291
345
  continue;
292
346
  }
293
- const rowSourceId = Reflect.get(row, SOURCE_ID_FIELD);
294
- // ONE predicate for the whole deny set, migration rule included: an entry
295
- // is a source id, or (pre-re-key, D555) the id of the addon that serves it.
296
- const off = denied.length > 0 &&
297
- (0, system_1.isSourceDenied)(denied, {
298
- addonId: source.addonId,
299
- ...(typeof rowSourceId === 'string' ? { sourceId: rowSourceId } : {}),
300
- });
301
- if (isSourceListing) {
302
- const stamped = {
303
- ...row,
304
- [SOURCE_ADDON_FIELD]: source.addonId,
305
- };
306
- out.push(off
307
- ? {
308
- ...stamped,
309
- availability: withoutCatalogAge(Reflect.get(row, 'availability')),
310
- [SOURCE_SWITCHED_OFF_FIELD]: true,
311
- }
312
- : stamped);
313
- continue;
314
- }
315
- if (off)
316
- continue;
317
- out.push(row);
347
+ out.push({ ...row, [SOURCE_ADDON_FIELD]: source.addonId });
318
348
  }
319
349
  return out;
320
350
  }
@@ -402,6 +432,31 @@ async function dispatchScalar(sources, method, input, capName, deviceId, logger)
402
432
  `An unclaimed id is refused rather than answered by another source.`,
403
433
  });
404
434
  }
435
+ /**
436
+ * The device's sources narrowed to the one the caller PINNED, if it pinned one.
437
+ *
438
+ * A pin the device is not bound to is REFUSED by name. Answering it from
439
+ * another provider is how a fabricated addon id once came back with somebody
440
+ * else's answer (D552, `rejectUnresolvedAddonPin`), and here it would hand a
441
+ * caller who asked for the camera's own SD card the analytics visit log with
442
+ * nothing saying so.
443
+ */
444
+ function pinnedProvider(sources, input, capName, deviceId) {
445
+ if (input === null || typeof input !== 'object')
446
+ return sources;
447
+ const pin = Reflect.get(input, PROVIDER_PIN_FIELD);
448
+ if (typeof pin !== 'string' || pin.length === 0)
449
+ return sources;
450
+ const picked = sources.filter((source) => source.addonId === pin);
451
+ if (picked.length > 0)
452
+ return picked;
453
+ throw new server_1.TRPCError({
454
+ code: 'BAD_REQUEST',
455
+ message: `Capability "${capName}": device ${deviceId} is not bound to provider "${pin}". ` +
456
+ `Bound providers: ${sources.map((source) => source.addonId).join(', ') || '(none)'}. ` +
457
+ `A pin nobody answers is refused rather than served by another provider.`,
458
+ });
459
+ }
405
460
  /**
406
461
  * Build the per-device COLLECTION dispatcher for `capName` — the collection
407
462
  * twin of `requireDeviceScoped`, and the provider object the cap router's
@@ -425,53 +480,27 @@ function requireDeviceCollection(registry, capName, logger) {
425
480
  message: `${capName}.${prop}: input must carry numeric "deviceId"`,
426
481
  });
427
482
  }
428
- const all = sourcesForDevice(registry, capName, deviceId);
429
- if (all.length === 0) {
483
+ const bound = await sourcesForDevice(registry, capName, deviceId, logger);
484
+ const sources = pinnedProvider(bound, input, capName, deviceId);
485
+ if (sources.length === 0) {
430
486
  throw new server_1.TRPCError({
431
487
  code: 'PRECONDITION_FAILED',
432
488
  message: `Capability "${capName}" not registered for device ${deviceId}`,
433
489
  });
434
490
  }
435
- // The LISTING sees every source the device has, switched off or not
436
- // — that row is the only control the operator has to switch one back
437
- // on. Everything else sees the SERVING set.
438
- const listing = prop === exports.SOURCE_LISTING_METHOD;
439
- const sources = listing ? all : all.filter((source) => !source.switchedOff);
440
- if (sources.length === 0) {
441
- // Every source is switched off. That is an operator's decision and
442
- // not a missing provider, so it must not wear PRECONDITION_FAILED's
443
- // "not registered" — the rows say which it is, and the answer to
444
- // "what clips does it have" is honestly none (D556 left this open).
445
- logger?.info(`${capName}.${prop}: every source of this device is switched off`, {
446
- tags: { deviceId },
447
- meta: { capability: capName, method: prop, sources: all.map((s) => s.addonId) },
448
- });
449
- if (isFanOutMethod(registry, capName, prop))
450
- return [];
451
- throw new server_1.TRPCError({
452
- code: 'BAD_REQUEST',
453
- message: `Capability "${capName}.${prop}": every source of device ${deviceId} is ` +
454
- `switched off for this camera. Switch one back on to serve it.`,
455
- });
456
- }
457
491
  const first = sources[0];
458
492
  if (isFanOutMethod(registry, capName, prop)) {
459
493
  // One source is not a merge: its rows travel verbatim, in its own
460
494
  // order, so a camera with one provider answers exactly as it did
461
- // before this dispatcher existed.
495
+ // before this dispatcher existed (D554 decision 6). Only the
496
+ // listing rows are finished, and only to carry the addon stamp.
462
497
  if (sources.length === 1 && first !== undefined) {
463
- // One PROVIDER is not a merge — but it may still serve several
464
- // SOURCES, and the operator may have switched one of them off.
465
- // So the rows are finished (stamped, and filtered by source)
466
- // even here; with no deny entry that is the identity function,
467
- // so an untouched camera answers exactly as it did before
468
- // (D554 decision 6).
469
498
  const answered = await callSource(first, prop, input, capName, deviceId);
470
499
  if (!Array.isArray(answered))
471
500
  return answered;
472
- return finishRows(registry, capName, deviceId, first, answered, prop === exports.SOURCE_LISTING_METHOD);
501
+ return finishRows(first, answered, prop === exports.SOURCE_LISTING_METHOD);
473
502
  }
474
- return await fanOut(registry, sources, prop, input, capName, deviceId, logger);
503
+ return await fanOut(sources, prop, input, capName, deviceId, logger);
475
504
  }
476
505
  if (sources.length === 1 && first !== undefined) {
477
506
  return await callSource(first, prop, input, capName, deviceId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.355",
3
+ "version": "1.2.356",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -35,8 +35,8 @@
35
35
  ]
36
36
  },
37
37
  "dependencies": {
38
- "@camstack/addon-admin-ui": "1.2.244",
39
- "@camstack/addon-agent-ui": "1.2.132",
38
+ "@camstack/addon-admin-ui": "1.2.245",
39
+ "@camstack/addon-agent-ui": "1.2.133",
40
40
  "@camstack/addon-auth": "1.2.123",
41
41
  "@camstack/addon-decoder-nodeav": "1.2.115",
42
42
  "@camstack/addon-notifiers": "1.2.119",
@@ -44,9 +44,9 @@
44
44
  "@camstack/addon-pipeline-orchestrator": "1.2.231",
45
45
  "@camstack/addon-post-analysis": "1.2.272",
46
46
  "@camstack/sdk": "1.2.118",
47
- "@camstack/system": "1.2.276",
48
- "@camstack/types": "1.2.234",
49
- "@camstack/ui-library": "1.2.190",
47
+ "@camstack/system": "1.2.277",
48
+ "@camstack/types": "1.2.235",
49
+ "@camstack/ui-library": "1.2.191",
50
50
  "@fastify/compress": "^9.0.0",
51
51
  "@fastify/cookie": "^11.0.2",
52
52
  "@fastify/cors": "^11.2.0",