@camstack/server 1.2.354 → 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.
@@ -205,25 +205,15 @@ function createCapabilitiesRouter(addonRegistry, configService, loggingService)
205
205
  const overrides = registry.getDeviceOverrides(input.deviceId);
206
206
  return Object.fromEntries(overrides);
207
207
  }),
208
- setDeviceCollectionFilter: trpc_middleware_1.adminProcedure
209
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string(), addonIds: zod_1.z.array(zod_1.z.string()) }))
210
- .mutation(({ input }) => {
211
- const registry = addonRegistry.getCapabilityRegistry();
212
- registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds);
213
- logger.info('Device collection filter set', {
214
- tags: { deviceId: Number(input.deviceId) },
215
- meta: { capability: input.capability, addonIds: input.addonIds },
216
- });
217
- }),
218
- clearDeviceCollectionFilter: trpc_middleware_1.adminProcedure
219
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string() }))
220
- .mutation(({ input }) => {
221
- const registry = addonRegistry.getCapabilityRegistry();
222
- registry.clearDeviceCollectionFilter(input.deviceId, input.capability);
223
- logger.info('Device collection filter cleared', {
224
- tags: { deviceId: Number(input.deviceId) },
225
- meta: { capability: input.capability },
226
- });
227
- }),
208
+ /*
209
+ * `setDeviceCollectionFilter` / `clearDeviceCollectionFilter` were REMOVED
210
+ * here (D556). They wrote an in-memory per-device allow list that nothing
211
+ * persisted, so an operator's pick survived until the next hub restart and
212
+ * then silently reverted — a control that looks like it works, which is the
213
+ * defect D62 exists for. Which sources a camera has is now asked of the ONE
214
+ * authority that already owned a per-camera function switch:
215
+ * `deviceManager.setWrapperActive`. The registry keeps only its projection,
216
+ * written by `device-source-activation.ts` and by nothing else.
217
+ */
228
218
  });
229
219
  }
@@ -114,25 +114,15 @@ function createCapabilitiesRouter(registry, config) {
114
114
  }
115
115
  return result;
116
116
  }),
117
- setDeviceCollectionFilter: trpc_middleware_js_1.adminProcedure
118
- .input(zod_1.z.object({
119
- deviceId: zod_1.z.string(),
120
- capability: zod_1.z.string(),
121
- addonIds: zod_1.z.array(zod_1.z.string()),
122
- }))
123
- .output(zod_1.z.void())
124
- .mutation(({ input }) => {
125
- if (!registry)
126
- throw new Error('Capability registry unavailable');
127
- registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds);
128
- }),
129
- clearDeviceCollectionFilter: trpc_middleware_js_1.adminProcedure
130
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string() }))
131
- .output(zod_1.z.void())
132
- .mutation(({ input }) => {
133
- if (!registry)
134
- throw new Error('Capability registry unavailable');
135
- registry.clearDeviceCollectionFilter(input.deviceId, input.capability);
136
- }),
117
+ /*
118
+ * `setDeviceCollectionFilter` / `clearDeviceCollectionFilter` were REMOVED
119
+ * here (D556). They wrote an in-memory per-device allow list that nothing
120
+ * persisted, so an operator's pick survived until the next hub restart and
121
+ * then silently reverted — a control that looks like it works, which is the
122
+ * defect D62 exists for. Which sources a camera has is now asked of the ONE
123
+ * authority that already owned a per-camera function switch:
124
+ * `deviceManager.setWrapperActive`. The registry keeps only its projection,
125
+ * written by `device-source-activation.ts` and by nothing else.
126
+ */
137
127
  });
138
128
  }
@@ -28,8 +28,10 @@ exports.createCapRouterPrimitives = createCapRouterPrimitives;
28
28
  exports.createCapRouterServices = createCapRouterServices;
29
29
  exports.buildRuntimeCapRouters = buildRuntimeCapRouters;
30
30
  const system_1 = require("@camstack/system");
31
+ const types_1 = require("@camstack/types");
31
32
  const server_1 = require("@trpc/server");
32
33
  const cap_mount_helpers_js_1 = require("./cap-mount-helpers.js");
34
+ const device_collection_dispatch_js_1 = require("./device-collection-dispatch.js");
33
35
  const trpc_middleware_js_1 = require("./trpc.middleware.js");
34
36
  /**
35
37
  * Pre-computed set of capName → array-output method names for collection
@@ -41,7 +43,10 @@ exports.COLLECTION_ARRAY_METHODS = computeArrayMethods();
41
43
  function computeArrayMethods() {
42
44
  const out = new Map();
43
45
  for (const def of system_1.ALL_CAPABILITY_DEFINITIONS) {
44
- if (def.mode !== 'collection')
46
+ // The effective MOUNT, never `mode`: a device-scoped wrapper collection
47
+ // (`videoclips`, D554) fans over ONE DEVICE's sources through the
48
+ // per-device dispatcher, and must never be fanned globally here.
49
+ if ((0, types_1.resolveCapMount)(def).kind !== 'collection')
45
50
  continue;
46
51
  const names = [];
47
52
  for (const [name, schema] of Object.entries(def.methods)) {
@@ -56,6 +61,22 @@ function computeArrayMethods() {
56
61
  }
57
62
  return out;
58
63
  }
64
+ /**
65
+ * Is this cap served by the GLOBAL collection mount — every registered
66
+ * provider, for every caller?
67
+ *
68
+ * `mode: 'collection'` alone does not answer it any more: a device-scoped
69
+ * wrapper collection keeps the per-device mount (D554,
70
+ * `device-collection-dispatch.ts`), and fanning it globally here would answer
71
+ * one camera with another camera's sources on the `ctx.api` path while the
72
+ * tRPC path resolved per device — two authorities disagreeing about the same
73
+ * call, which is exactly what {@link aggregateCollectionCall} exists to
74
+ * prevent.
75
+ */
76
+ function isGlobalCollection(reg, capName) {
77
+ const def = reg.getDefinition(capName);
78
+ return def !== undefined && (0, types_1.resolveCapMount)(def).kind === 'collection';
79
+ }
59
80
  /**
60
81
  * Resolve a collection cap's provider exactly as the codegen-emitted
61
82
  * mount did:
@@ -108,7 +129,7 @@ function resolveCollectionProvider(reg, capName, addonId) {
108
129
  async function aggregateCollectionCall(reg, capName, method, args) {
109
130
  if (!reg)
110
131
  return null;
111
- if (reg.getDefinition(capName)?.mode !== 'collection')
132
+ if (!isGlobalCollection(reg, capName))
112
133
  return null;
113
134
  if (!exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method))
114
135
  return null;
@@ -141,7 +162,7 @@ async function aggregateCollectionCall(reg, capName, method, args) {
141
162
  function addonPinOf(reg, capName, method, args) {
142
163
  if (!reg)
143
164
  return null;
144
- if (reg.getDefinition(capName)?.mode !== 'collection')
165
+ if (!isGlobalCollection(reg, capName))
145
166
  return null;
146
167
  if (exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method) === true)
147
168
  return null;
@@ -258,8 +279,26 @@ function createCapRouterServices(deps) {
258
279
  const factory = deps.serverProviders[capName];
259
280
  return factory ? factory(ctx) : null;
260
281
  }
282
+ // Both per-device mounts go through the SAME dispatcher. It resolves
283
+ // `getNativeProvider` first (the provider IS the device) and falls
284
+ // through to the D13 resolver `getProviderForDevice` — which is what a
285
+ // `device-scoped` WRAPPER needs: the per-device binding
286
+ // (`setWrapperActive` → `setDeviceOverride`) when there is one, the
287
+ // global active provider when there is not. No second resolver.
261
288
  case 'device-native':
289
+ case 'device-scoped': {
290
+ // A device-scoped cap whose mode is `collection` has SEVERAL providers
291
+ // per device — sources listed beside each other (D549 decision 2,
292
+ // D554). `requireDeviceScoped` resolves exactly one of them, and on a
293
+ // collection cap `getProviderForDevice` answers with whichever addon
294
+ // registered FIRST: every camera served by one source, silently. The
295
+ // collection twin fans the array methods over the device's sources and
296
+ // routes the scalar ones by the source inside the id.
297
+ if (reg?.getMode(capName) === 'collection') {
298
+ return (0, device_collection_dispatch_js_1.requireDeviceCollection)(reg, capName, deps.logger);
299
+ }
262
300
  return (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, capName);
301
+ }
263
302
  case 'collection':
264
303
  return resolveCollectionProvider(reg, capName, addonId);
265
304
  // singleton / per-node / hub-only resolve the active singleton.
@@ -0,0 +1,513 @@
1
+ "use strict";
2
+ /**
3
+ * The per-device COLLECTION dispatcher — a capability whose providers are
4
+ * SOURCES of one device, listed beside each other.
5
+ *
6
+ * `requireDeviceScoped` (`cap-mount-helpers.ts`) resolves ONE provider for a
7
+ * device: the native, else the D13 `getProviderForDevice`. That is right for a
8
+ * cap where a device has one answer. `videoclips` is not that cap any more
9
+ * (D549 decision 2, D554): a camera's clips live in several places at once —
10
+ * the analytics visit log, the camera's own SD card, a hub's storage, HomeKit
11
+ * — and the operator asked for all of them, side by side, never one hidden
12
+ * behind another. `getProviderForDevice` on a collection cap returns the FIRST
13
+ * registered provider, so leaving `videoclips` on it would have answered every
14
+ * camera from whichever addon booted first: the same silent single-answer
15
+ * defect D552 closed, wearing a different hat.
16
+ *
17
+ * The three rules this module implements, and nothing else:
18
+ *
19
+ * 1. **Fan out, per device.** An ARRAY-returning method is called on every
20
+ * source the DEVICE has (`CapabilityRegistry.resolveCollectionForDevice` —
21
+ * the enabled providers, minus the ones the operator switched off for this
22
+ * camera through `deviceManager.setWrapperActive`, D556), and the results
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).
33
+ * 2. **One failing source never empties the others.** Every source is called
34
+ * independently and a rejection removes only its own rows — and says so,
35
+ * per source, with `tags: { deviceId }`. A fan-out where one throwing
36
+ * provider empties the list is worse than no fan-out. For the SOURCE
37
+ * listing itself the failure becomes a row (`unreachable` + the reason):
38
+ * an empty list from a camera nobody could reach reads as "this camera has
39
+ * no recordings", which is the defect this whole line of work is about.
40
+ * 3. **A scalar method routes by the SOURCE inside the id it carries.** Clip
41
+ * ids are self-contained and source-prefixed (`analytics:…`,
42
+ * `native:reolink:onboard:…`, `hksv:…`), so the dispatcher asks each
43
+ * source which ids it owns ({@link SOURCE_LISTING_METHOD}) and hands the
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
47
+ * claims is REFUSED by name — never handed to the first provider, which is
48
+ * how a fabricated `addonId` once came back with another addon's answer
49
+ * (D552 consequences, `rejectUnresolvedAddonPin`).
50
+ *
51
+ * A device with exactly ONE source skips 1's merge and 3's probe entirely: its
52
+ * rows are returned verbatim in the provider's own order and its scalar calls
53
+ * go straight through. That is not an optimisation — it is the promise that a
54
+ * camera with one provider behaves exactly as it did before this mount
55
+ * existed, and it is pinned by spec.
56
+ */
57
+ Object.defineProperty(exports, "__esModule", { value: true });
58
+ exports.SOURCE_LISTING_METHOD = void 0;
59
+ exports.requireDeviceCollection = requireDeviceCollection;
60
+ const types_1 = require("@camstack/types");
61
+ const server_1 = require("@trpc/server");
62
+ /**
63
+ * The device-collection CONVENTION: a cap whose providers are sources declares
64
+ * this method, taking `{ deviceId }` and returning one row per source with a
65
+ * `source` id and an `availability` block. It is what makes rule 3 possible
66
+ * without a second registry of id prefixes — the provider that mints the ids
67
+ * is the one that names them.
68
+ */
69
+ exports.SOURCE_LISTING_METHOD = 'listSources';
70
+ /** The field on a source row that carries its id / clip-id namespace. */
71
+ const SOURCE_ID_FIELD = 'source';
72
+ /** The field on a source row that names the addon serving it (D555/D557). */
73
+ const SOURCE_ADDON_FIELD = 'addonId';
74
+ /**
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.
100
+ *
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.
107
+ *
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.
112
+ */
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)));
188
+ const sources = [];
189
+ const seen = new Set();
190
+ const native = registry.getNativeProvider(capName, deviceId);
191
+ if (native !== null) {
192
+ sources.push({
193
+ addonId: registry.getNativeAddonId(capName, deviceId) ?? 'native',
194
+ provider: native,
195
+ });
196
+ seen.add(native);
197
+ }
198
+ for (const [addonId, provider] of registry.getCollectionEntries(capName)) {
199
+ if (!admitted.has(provider) || seen.has(provider))
200
+ continue;
201
+ seen.add(provider);
202
+ sources.push({ addonId, provider });
203
+ }
204
+ return sources;
205
+ }
206
+ /** Is `method` one of the cap's array-returning (fan-out) methods? */
207
+ function isFanOutMethod(registry, capName, method) {
208
+ const def = registry.getDefinition(capName);
209
+ if (def === undefined)
210
+ return false;
211
+ const schema = (0, types_1.expandCapMethods)(def)[method];
212
+ if (schema === undefined || schema.kind === 'subscription')
213
+ return false;
214
+ return (0, types_1.isArrayOutputSchema)(schema.output);
215
+ }
216
+ function errMessage(err) {
217
+ return err instanceof Error ? err.message : String(err);
218
+ }
219
+ /** Call one source, naming the cap, the source and the method on any refusal. */
220
+ async function callSource(source, method, input, capName, deviceId) {
221
+ const fn = source.provider[method];
222
+ if (typeof fn !== 'function') {
223
+ throw new server_1.TRPCError({
224
+ code: 'NOT_IMPLEMENTED',
225
+ message: `Capability "${capName}" source "${source.addonId}" for device ${deviceId} does not implement "${method}"`,
226
+ });
227
+ }
228
+ return await fn.call(source.provider, input);
229
+ }
230
+ /**
231
+ * The `startMs` a row is ordered by, or null when the row does not declare a
232
+ * time range. A merge across sources is newest-first (D549 decision 2's
233
+ * `(startMs, endMs, id)`); rows that carry no window — a source listing, say —
234
+ * keep the order their providers produced them in.
235
+ */
236
+ function startMsOf(row) {
237
+ if (row === null || typeof row !== 'object')
238
+ return null;
239
+ const range = Reflect.get(row, 'timeRange');
240
+ if (range === null || typeof range !== 'object')
241
+ return null;
242
+ const startMs = Reflect.get(range, 'startMs');
243
+ return typeof startMs === 'number' ? startMs : null;
244
+ }
245
+ function endMsOf(row) {
246
+ if (row === null || typeof row !== 'object')
247
+ return 0;
248
+ const range = Reflect.get(row, 'timeRange');
249
+ if (range === null || typeof range !== 'object')
250
+ return 0;
251
+ const endMs = Reflect.get(range, 'endMs');
252
+ return typeof endMs === 'number' ? endMs : 0;
253
+ }
254
+ function idOf(row) {
255
+ if (row === null || typeof row !== 'object')
256
+ return '';
257
+ const id = Reflect.get(row, 'id');
258
+ return typeof id === 'string' ? id : '';
259
+ }
260
+ /** Newest-first by `(startMs, endMs, id)`; applied only when EVERY row has a window. */
261
+ function mergeRows(rows) {
262
+ if (rows.some((row) => startMsOf(row) === null))
263
+ return rows;
264
+ return [...rows].sort((a, b) => {
265
+ const byStart = (startMsOf(b) ?? 0) - (startMsOf(a) ?? 0);
266
+ if (byStart !== 0)
267
+ return byStart;
268
+ const byEnd = endMsOf(b) - endMsOf(a);
269
+ if (byEnd !== 0)
270
+ return byEnd;
271
+ return idOf(a).localeCompare(idOf(b));
272
+ });
273
+ }
274
+ /**
275
+ * The row a source listing gets when the source could not be reached AT ALL.
276
+ *
277
+ * The source id is the addon's, because a provider that threw could not name
278
+ * its own sources — saying "this provider, as a whole, did not answer" is the
279
+ * honest version, and the reason carries the refusal verbatim. Never composed
280
+ * with a provider's output: this row replaces rows that do not exist.
281
+ */
282
+ function unreachableSourceRow(addonId, reason) {
283
+ return {
284
+ [SOURCE_ID_FIELD]: addonId,
285
+ label: addonId,
286
+ availability: { state: 'unreachable', reason },
287
+ };
288
+ }
289
+ async function fanOut(sources, method, input, capName, deviceId, logger) {
290
+ const isSourceListing = method === exports.SOURCE_LISTING_METHOD;
291
+ const settled = await Promise.allSettled(sources.map(async (source) => await callSource(source, method, input, capName, deviceId)));
292
+ const rows = [];
293
+ for (const [index, result] of settled.entries()) {
294
+ const source = sources[index];
295
+ if (source === undefined)
296
+ continue;
297
+ if (result.status === 'rejected') {
298
+ const reason = errMessage(result.reason);
299
+ // A branch that drops work says so — a fan-out that silently loses a
300
+ // source is indistinguishable from a camera that has none.
301
+ logger?.warn(`${capName}.${method}: source failed — its rows are missing from this answer`, {
302
+ tags: { deviceId, addonId: source.addonId },
303
+ meta: { capability: capName, method, reason },
304
+ });
305
+ if (isSourceListing)
306
+ rows.push(unreachableSourceRow(source.addonId, reason));
307
+ continue;
308
+ }
309
+ if (!Array.isArray(result.value)) {
310
+ logger?.warn(`${capName}.${method}: source answered a non-array — contributing nothing`, {
311
+ tags: { deviceId, addonId: source.addonId },
312
+ meta: { capability: capName, method },
313
+ });
314
+ continue;
315
+ }
316
+ rows.push(...finishRows(source, result.value, isSourceListing));
317
+ }
318
+ return mergeRows(rows);
319
+ }
320
+ /**
321
+ * Stamp each source-listing row with the addon that really served it.
322
+ *
323
+ * The stamp is authoritative: a surface cannot otherwise resolve a source row
324
+ * to who answers it, and the alternative — a `native:reolink:* →
325
+ * provider-reolink` prefix table inside the widget — is a second authority on
326
+ * provider identity living in the one package with no business knowing it
327
+ * (D557). The registry's id is a fact; a provider's own value is a claim, so
328
+ * the fact wins.
329
+ *
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.
337
+ */
338
+ function finishRows(source, rows, isSourceListing) {
339
+ if (!isSourceListing)
340
+ return rows;
341
+ const out = [];
342
+ for (const row of rows) {
343
+ if (row === null || typeof row !== 'object') {
344
+ out.push(row);
345
+ continue;
346
+ }
347
+ out.push({ ...row, [SOURCE_ADDON_FIELD]: source.addonId });
348
+ }
349
+ return out;
350
+ }
351
+ async function claimsOf(sources, capName, deviceId, logger) {
352
+ const claimed = new Map();
353
+ const unknownClaims = [];
354
+ const settled = await Promise.allSettled(sources.map(async (source) => await callSource(source, exports.SOURCE_LISTING_METHOD, { deviceId }, capName, deviceId)));
355
+ for (const [index, result] of settled.entries()) {
356
+ const source = sources[index];
357
+ if (source === undefined)
358
+ continue;
359
+ if (result.status === 'rejected' || !Array.isArray(result.value)) {
360
+ // It could not name its sources, so it cannot be excluded on that basis
361
+ // either — a read that FAILS must not destroy work (D49).
362
+ logger?.warn(`${capName}.${exports.SOURCE_LISTING_METHOD}: source could not name its ids — routing falls back to it only if it is the only such source`, {
363
+ tags: { deviceId, addonId: source.addonId },
364
+ meta: {
365
+ capability: capName,
366
+ reason: result.status === 'rejected' ? errMessage(result.reason) : 'non-array answer',
367
+ },
368
+ });
369
+ unknownClaims.push(source);
370
+ continue;
371
+ }
372
+ for (const row of result.value) {
373
+ if (row === null || typeof row !== 'object')
374
+ continue;
375
+ const id = Reflect.get(row, SOURCE_ID_FIELD);
376
+ if (typeof id === 'string' && id.length > 0 && !claimed.has(id))
377
+ claimed.set(id, source);
378
+ }
379
+ }
380
+ return { claimed, unknownClaims };
381
+ }
382
+ /** Every top-level string value of an object input — the candidate routing keys. */
383
+ function stringFieldsOf(input) {
384
+ if (input === null || typeof input !== 'object' || Array.isArray(input))
385
+ return [];
386
+ const out = [];
387
+ for (const value of Object.values(input)) {
388
+ if (typeof value === 'string' && value.length > 0)
389
+ out.push(value);
390
+ }
391
+ return out;
392
+ }
393
+ /**
394
+ * The source that owns a source-prefixed id: `<sourceId>` itself, or anything
395
+ * under `<sourceId>:`. The LONGEST matching source wins, so
396
+ * `native:reolink:onboard` beats a hypothetical `native:reolink`.
397
+ */
398
+ function ownerOf(claims, input) {
399
+ let best = null;
400
+ for (const value of stringFieldsOf(input)) {
401
+ for (const [sourceId, source] of claims.claimed) {
402
+ if (value !== sourceId && !value.startsWith(`${sourceId}:`))
403
+ continue;
404
+ if (best === null || sourceId.length > best.sourceId.length)
405
+ best = { source, sourceId };
406
+ }
407
+ }
408
+ return best;
409
+ }
410
+ async function dispatchScalar(sources, method, input, capName, deviceId, logger) {
411
+ const claims = await claimsOf(sources, capName, deviceId, logger);
412
+ const owner = ownerOf(claims, input);
413
+ if (owner !== null)
414
+ return await callSource(owner.source, method, input, capName, deviceId);
415
+ const fallback = claims.unknownClaims.length === 1 ? claims.unknownClaims[0] : undefined;
416
+ if (fallback !== undefined) {
417
+ logger?.warn(`${capName}.${method}: no source claimed this id — using the one source that could not name its ids`, {
418
+ tags: { deviceId, addonId: fallback.addonId },
419
+ meta: { capability: capName, method },
420
+ });
421
+ return await callSource(fallback, method, input, capName, deviceId);
422
+ }
423
+ const known = [...claims.claimed.keys()];
424
+ logger?.warn(`${capName}.${method}: refused — no source of this device claims the id`, {
425
+ tags: { deviceId },
426
+ meta: { capability: capName, method, knownSources: known, input },
427
+ });
428
+ throw new server_1.TRPCError({
429
+ code: 'BAD_REQUEST',
430
+ message: `Capability "${capName}.${method}": no source of device ${deviceId} claims ` +
431
+ `${JSON.stringify(input)}. Sources: ${known.length > 0 ? known.join(', ') : '(none named)'}. ` +
432
+ `An unclaimed id is refused rather than answered by another source.`,
433
+ });
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
+ }
460
+ /**
461
+ * Build the per-device COLLECTION dispatcher for `capName` — the collection
462
+ * twin of `requireDeviceScoped`, and the provider object the cap router's
463
+ * `device-scoped` mount resolves when the cap's mode is `collection`.
464
+ *
465
+ * Returns null without a registry (the router then surfaces
466
+ * PRECONDITION_FAILED), exactly as the singleton dispatcher does.
467
+ */
468
+ function requireDeviceCollection(registry, capName, logger) {
469
+ if (!registry)
470
+ return null;
471
+ const dispatcher = new Proxy({}, {
472
+ get(_target, prop) {
473
+ if (typeof prop !== 'string')
474
+ return undefined;
475
+ return async (input) => {
476
+ const deviceId = input !== null && typeof input === 'object' ? Reflect.get(input, 'deviceId') : undefined;
477
+ if (typeof deviceId !== 'number') {
478
+ throw new server_1.TRPCError({
479
+ code: 'BAD_REQUEST',
480
+ message: `${capName}.${prop}: input must carry numeric "deviceId"`,
481
+ });
482
+ }
483
+ const bound = await sourcesForDevice(registry, capName, deviceId, logger);
484
+ const sources = pinnedProvider(bound, input, capName, deviceId);
485
+ if (sources.length === 0) {
486
+ throw new server_1.TRPCError({
487
+ code: 'PRECONDITION_FAILED',
488
+ message: `Capability "${capName}" not registered for device ${deviceId}`,
489
+ });
490
+ }
491
+ const first = sources[0];
492
+ if (isFanOutMethod(registry, capName, prop)) {
493
+ // One source is not a merge: its rows travel verbatim, in its own
494
+ // order, so a camera with one provider answers exactly as it did
495
+ // before this dispatcher existed (D554 decision 6). Only the
496
+ // listing rows are finished, and only to carry the addon stamp.
497
+ if (sources.length === 1 && first !== undefined) {
498
+ const answered = await callSource(first, prop, input, capName, deviceId);
499
+ if (!Array.isArray(answered))
500
+ return answered;
501
+ return finishRows(first, answered, prop === exports.SOURCE_LISTING_METHOD);
502
+ }
503
+ return await fanOut(sources, prop, input, capName, deviceId, logger);
504
+ }
505
+ if (sources.length === 1 && first !== undefined) {
506
+ return await callSource(first, prop, input, capName, deviceId);
507
+ }
508
+ return await dispatchScalar(sources, prop, input, capName, deviceId, logger);
509
+ };
510
+ },
511
+ });
512
+ return dispatcher;
513
+ }
@@ -78,7 +78,7 @@ function mountAllCaps(services) {
78
78
  alarmPanel: (0, generated_cap_routers_js_1.createCapRouter_alarmPanel)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'alarm-panel'), remoteCapProxy),
79
79
  alerts: (0, generated_cap_routers_js_1.createCapRouter_alerts)((_ctx) => reg?.getSingleton('alerts') ?? null, remoteCapProxy),
80
80
  ambientLightSensor: (0, generated_cap_routers_js_1.createCapRouter_ambientLightSensor)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'ambient-light-sensor'), remoteCapProxy),
81
- audioAnalysis: (0, generated_cap_routers_js_1.createCapRouter_audioAnalysis)((_ctx) => reg?.getSingleton('audio-analysis') ?? null, remoteCapProxy),
81
+ audioAnalysis: (0, generated_cap_routers_js_1.createCapRouter_audioAnalysis)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'audio-analysis'), remoteCapProxy),
82
82
  audioAnalyzer: (0, generated_cap_routers_js_1.createCapRouter_audioAnalyzer)((_ctx) => reg?.getSingleton('audio-analyzer') ?? null, remoteCapProxy),
83
83
  audioCodec: (0, generated_cap_routers_js_1.createCapRouter_audioCodec)((_ctx) => reg?.getSingleton('audio-codec') ?? null, remoteCapProxy),
84
84
  audioMetrics: (0, generated_cap_routers_js_1.createCapRouter_audioMetrics)((_ctx) => reg?.getSingleton('audio-metrics') ?? null, remoteCapProxy),
@@ -107,8 +107,8 @@ function mountAllCaps(services) {
107
107
  button: (0, generated_cap_routers_js_1.createCapRouter_button)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'button'), remoteCapProxy),
108
108
  cameraCredentials: (0, generated_cap_routers_js_1.createCapRouter_cameraCredentials)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-credentials'), remoteCapProxy),
109
109
  cameraGridLayout: (0, generated_cap_routers_js_1.createCapRouter_cameraGridLayout)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-grid-layout'), remoteCapProxy),
110
- cameraPipelineConfig: (0, generated_cap_routers_js_1.createCapRouter_cameraPipelineConfig)((_ctx) => reg?.getSingleton('camera-pipeline-config') ?? null, remoteCapProxy),
111
- cameraStreams: (0, generated_cap_routers_js_1.createCapRouter_cameraStreams)((_ctx) => reg?.getSingleton('camera-streams') ?? null, remoteCapProxy),
110
+ cameraPipelineConfig: (0, generated_cap_routers_js_1.createCapRouter_cameraPipelineConfig)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-pipeline-config'), remoteCapProxy),
111
+ cameraStreams: (0, generated_cap_routers_js_1.createCapRouter_cameraStreams)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'camera-streams'), remoteCapProxy),
112
112
  carbonMonoxide: (0, generated_cap_routers_js_1.createCapRouter_carbonMonoxide)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'carbon-monoxide'), remoteCapProxy),
113
113
  climateControl: (0, generated_cap_routers_js_1.createCapRouter_climateControl)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'climate-control'), remoteCapProxy),
114
114
  color: (0, generated_cap_routers_js_1.createCapRouter_color)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'color'), remoteCapProxy),
@@ -162,7 +162,7 @@ function mountAllCaps(services) {
162
162
  }, remoteCapProxy),
163
163
  dayNight: (0, generated_cap_routers_js_1.createCapRouter_dayNight)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'day-night'), remoteCapProxy),
164
164
  decoder: (0, generated_cap_routers_js_1.createCapRouter_decoder)((_ctx) => reg?.getSingleton('decoder') ?? null, remoteCapProxy),
165
- detectionPipeline: (0, generated_cap_routers_js_1.createCapRouter_detectionPipeline)((_ctx) => reg?.getSingleton('detection-pipeline') ?? null, remoteCapProxy),
165
+ detectionPipeline: (0, generated_cap_routers_js_1.createCapRouter_detectionPipeline)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'detection-pipeline'), remoteCapProxy),
166
166
  deviceAdoption: (0, generated_cap_routers_js_1.createCapRouter_deviceAdoption)((_ctx) => reg?.getSingleton('device-adoption') ??
167
167
  null, remoteCapProxy),
168
168
  deviceDiscovery: (0, generated_cap_routers_js_1.createCapRouter_deviceDiscovery)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'device-discovery'), remoteCapProxy),
@@ -337,8 +337,7 @@ function mountAllCaps(services) {
337
337
  modelDistributor: (0, generated_cap_routers_js_1.createCapRouter_modelDistributor)((_ctx) => reg?.getSingleton('model-distributor') ??
338
338
  null, remoteCapProxy),
339
339
  motion: (0, generated_cap_routers_js_1.createCapRouter_motion)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion'), remoteCapProxy),
340
- motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => reg?.getSingleton('motion-detection') ??
341
- null, remoteCapProxy),
340
+ motionDetection: (0, generated_cap_routers_js_1.createCapRouter_motionDetection)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-detection'), remoteCapProxy),
342
341
  motionTrigger: (0, generated_cap_routers_js_1.createCapRouter_motionTrigger)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-trigger'), remoteCapProxy),
343
342
  motionZones: (0, generated_cap_routers_js_1.createCapRouter_motionZones)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'motion-zones'), remoteCapProxy),
344
343
  mqttBroker: (0, generated_cap_routers_js_1.createCapRouter_mqttBroker)((_ctx, addonId) => {
@@ -412,7 +411,7 @@ function mountAllCaps(services) {
412
411
  osd: (0, generated_cap_routers_js_1.createCapRouter_osd)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'osd'), remoteCapProxy),
413
412
  osdManager: (0, generated_cap_routers_js_1.createCapRouter_osdManager)((_ctx) => reg?.getSingleton('osd-manager') ?? null, remoteCapProxy),
414
413
  petFeeder: (0, generated_cap_routers_js_1.createCapRouter_petFeeder)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pet-feeder'), remoteCapProxy),
415
- pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => reg?.getSingleton('pipeline-analytics') ?? null, remoteCapProxy),
414
+ pipelineAnalytics: (0, generated_cap_routers_js_1.createCapRouter_pipelineAnalytics)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'pipeline-analytics'), remoteCapProxy),
416
415
  pipelineExecutor: (0, generated_cap_routers_js_1.createCapRouter_pipelineExecutor)((_ctx) => reg?.getSingleton('pipeline-executor') ??
417
416
  null, remoteCapProxy),
418
417
  pipelineOrchestrator: (0, generated_cap_routers_js_1.createCapRouter_pipelineOrchestrator)((_ctx) => reg?.getSingleton('pipeline-orchestrator') ?? null, remoteCapProxy),
@@ -431,7 +430,7 @@ function mountAllCaps(services) {
431
430
  recordingExport: (0, generated_cap_routers_js_1.createCapRouter_recordingExport)((_ctx) => reg?.getSingleton('recording-export') ??
432
431
  null, remoteCapProxy),
433
432
  recordingSignal: (0, generated_cap_routers_js_1.createCapRouter_recordingSignal)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'recording-signal'), remoteCapProxy),
434
- sceneMonitor: (0, generated_cap_routers_js_1.createCapRouter_sceneMonitor)((_ctx) => reg?.getSingleton('scene-monitor') ?? null, remoteCapProxy),
433
+ sceneMonitor: (0, generated_cap_routers_js_1.createCapRouter_sceneMonitor)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'scene-monitor'), remoteCapProxy),
435
434
  scriptRunner: (0, generated_cap_routers_js_1.createCapRouter_scriptRunner)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'script-runner'), remoteCapProxy),
436
435
  serverManagement: (0, generated_cap_routers_js_1.createCapRouter_serverManagement)((_ctx) => reg?.getSingleton('server-management') ??
437
436
  null, remoteCapProxy),
@@ -542,7 +541,7 @@ function mountAllCaps(services) {
542
541
  valve: (0, generated_cap_routers_js_1.createCapRouter_valve)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'valve'), remoteCapProxy),
543
542
  vectorStore: (0, generated_cap_routers_js_1.createCapRouter_vectorStore)((_ctx) => reg?.getSingleton('vector-store') ?? null, remoteCapProxy),
544
543
  vibration: (0, generated_cap_routers_js_1.createCapRouter_vibration)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'vibration'), remoteCapProxy),
545
- videoclips: (0, generated_cap_routers_js_1.createCapRouter_videoclips)((_ctx) => reg?.getSingleton('videoclips') ?? null, remoteCapProxy),
544
+ videoclips: (0, generated_cap_routers_js_1.createCapRouter_videoclips)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'videoclips'), remoteCapProxy),
546
545
  viewerUi: (0, generated_cap_routers_js_1.createCapRouter_viewerUi)((_ctx) => reg?.getSingleton('viewer-ui') ?? null, remoteCapProxy),
547
546
  waterHeater: (0, generated_cap_routers_js_1.createCapRouter_waterHeater)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'water-heater'), remoteCapProxy),
548
547
  weather: (0, generated_cap_routers_js_1.createCapRouter_weather)((_ctx) => (0, cap_mount_helpers_js_1.requireDeviceScoped)(reg, 'weather'), remoteCapProxy),
@@ -10733,7 +10733,7 @@ function createCapRouter_vibration(getProvider, createRemoteProxy) {
10733
10733
  }
10734
10734
  function createCapRouter_videoclips(getProvider, createRemoteProxy) {
10735
10735
  return (0, trpc_middleware_js_1.trpcRouter)({
10736
- listClips: trpc_middleware_js_1.adminProcedure
10736
+ listClips: trpc_middleware_js_1.protectedProcedure
10737
10737
  .input(types_126.videoclipsCapability.methods.listClips.input.loose())
10738
10738
  .output(types_126.videoclipsCapability.methods.listClips.output)
10739
10739
  .query(async ({ input, ctx }) => {
@@ -10742,7 +10742,16 @@ function createCapRouter_videoclips(getProvider, createRemoteProxy) {
10742
10742
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10743
10743
  return p.listClips(methodInput);
10744
10744
  }),
10745
- getClipPlayback: trpc_middleware_js_1.adminProcedure
10745
+ listSources: trpc_middleware_js_1.protectedProcedure
10746
+ .input(types_126.videoclipsCapability.methods.listSources.input.loose())
10747
+ .output(types_126.videoclipsCapability.methods.listSources.output)
10748
+ .query(async ({ input, ctx }) => {
10749
+ const { nodeId, ...methodInput } = input;
10750
+ const p = resolveProvider('videoclips', nodeId, () => getProvider(ctx), createRemoteProxy);
10751
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
10752
+ return p.listSources(methodInput);
10753
+ }),
10754
+ getClipPlayback: trpc_middleware_js_1.protectedProcedure
10746
10755
  .input(types_126.videoclipsCapability.methods.getClipPlayback.input.loose())
10747
10756
  .output(types_126.videoclipsCapability.methods.getClipPlayback.output)
10748
10757
  .query(async ({ input, ctx }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.354",
3
+ "version": "1.2.356",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -35,18 +35,18 @@
35
35
  ]
36
36
  },
37
37
  "dependencies": {
38
- "@camstack/addon-admin-ui": "1.2.243",
39
- "@camstack/addon-agent-ui": "1.2.131",
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",
43
- "@camstack/addon-pipeline": "1.2.272",
43
+ "@camstack/addon-pipeline": "1.2.273",
44
44
  "@camstack/addon-pipeline-orchestrator": "1.2.231",
45
- "@camstack/addon-post-analysis": "1.2.271",
45
+ "@camstack/addon-post-analysis": "1.2.272",
46
46
  "@camstack/sdk": "1.2.118",
47
- "@camstack/system": "1.2.275",
48
- "@camstack/types": "1.2.233",
49
- "@camstack/ui-library": "1.2.189",
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",