@camstack/server 1.2.410 → 1.2.412

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.
@@ -80,7 +80,7 @@ const addon_package_service_js_1 = require("../../core/addon/addon-package.servi
80
80
  const lifecycle_runner_singleton_js_1 = require("../../core/lifecycle/lifecycle-runner.singleton.js");
81
81
  const agent_installed_packages_js_1 = require("../../core/updates/agent-installed-packages.js");
82
82
  const http_request_census_singleton_js_1 = require("../trpc/http-request-census.singleton.js");
83
- const collection_preference_js_1 = require("./collection-preference.js");
83
+ const capability_preference_writes_js_1 = require("./capability-preference-writes.js");
84
84
  const failure_contributions_js_1 = require("./failure-contributions.js");
85
85
  const load_contributions_js_1 = require("./load-contributions.js");
86
86
  const logging_settings_js_1 = require("./logging-settings.js");
@@ -1444,38 +1444,14 @@ function buildAddonsProvider(ar, ps, ls, moleculer, configService, ctx) {
1444
1444
  }));
1445
1445
  },
1446
1446
  setCapabilityProviderEnabled: async (input) => {
1447
- const registry = ar.getCapabilityRegistry();
1448
- const caps = registry.listCapabilities();
1449
- const found = caps.find((c) => c.name === input.capName);
1450
- if (!found) {
1451
- throw new server_1.TRPCError({
1452
- code: 'NOT_FOUND',
1453
- message: `Unknown capability: ${input.capName}`,
1454
- });
1455
- }
1456
- if (found.mode !== 'collection') {
1457
- throw new server_1.TRPCError({
1458
- code: 'BAD_REQUEST',
1459
- message: `Capability "${input.capName}" is not a collection`,
1460
- });
1461
- }
1462
- if (!found.providers.includes(input.addonId)) {
1463
- throw new server_1.TRPCError({
1464
- code: 'BAD_REQUEST',
1465
- message: `Provider "${input.addonId}" is not registered for "${input.capName}"`,
1466
- });
1467
- }
1468
- if (input.enabled) {
1469
- registry.enableCollectionProvider(input.capName, input.addonId);
1470
- }
1471
- else {
1472
- registry.disableCollectionProvider(input.capName, input.addonId);
1473
- }
1474
- // Persist the new disabled-set so the choice survives a hub reboot.
1475
- // Reuses the same `capabilities.collection.<cap>` key/format the
1476
- // `capabilities` core router writes — via the shared canonical writer.
1477
- const updated = registry.listCapabilities().find((c) => c.name === input.capName);
1478
- await (0, collection_preference_js_1.persistCollectionDisabled)(configService, input.capName, updated?.disabledProviders ?? []);
1447
+ // Refused (unknown cap, not a collection, unregistered provider) before
1448
+ // anything is written; otherwise the new disabled-set is PERSISTED first
1449
+ // and applied live only once the write landed (D675 amendment).
1450
+ await (0, capability_preference_writes_js_1.writeCollectionToggle)(configService, ar.getCapabilityRegistry(), {
1451
+ capability: input.capName,
1452
+ addonId: input.addonId,
1453
+ enabled: input.enabled,
1454
+ });
1479
1455
  return { success: true };
1480
1456
  },
1481
1457
  getVersions: async (input) => ps.getPackageVersions(input.name),
@@ -9,6 +9,7 @@ exports.createCapabilitiesRouter = createCapabilitiesRouter;
9
9
  */
10
10
  const zod_1 = require("zod");
11
11
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
12
+ const capability_preference_writes_js_1 = require("./capability-preference-writes.js");
12
13
  function createCapabilitiesRouter(registry, config) {
13
14
  return (0, trpc_middleware_js_1.trpcRouter)({
14
15
  listCapabilities: trpc_middleware_js_1.adminProcedure
@@ -29,17 +30,14 @@ function createCapabilitiesRouter(registry, config) {
29
30
  .mutation(async ({ input }) => {
30
31
  if (!registry)
31
32
  throw new Error('Capability registry unavailable');
32
- await registry.setActiveSingleton(input.capability, input.addonId, input.nodeId);
33
- if (input.nodeId !== undefined) {
34
- // Per-node override: persist under the node-qualified key so the boot
35
- // restorer (`setNodeConfigReader`) replays it on restart.
36
- await config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, input.addonId);
37
- }
38
- else {
39
- // Cluster-global default: persist to the SAME key the boot restorer reads
40
- // (addon-registry `setConfigReader` → `capabilities.singleton.<cap>`).
41
- await config.set(`capabilities.singleton.${input.capability}`, input.addonId);
42
- }
33
+ // Persist FIRST, under the key the boot restorer reads
34
+ // (`capabilities.singleton.<cap>` / `capabilities.singletonNode.<cap>.<node>`),
35
+ // then switch live: a write that fails changes nothing (D675 amendment).
36
+ await (0, capability_preference_writes_js_1.writeSingletonPreference)(config, registry, {
37
+ capability: input.capability,
38
+ addonId: input.addonId,
39
+ ...(input.nodeId !== undefined ? { nodeId: input.nodeId } : {}),
40
+ });
43
41
  }),
44
42
  clearSingletonNodeOverride: trpc_middleware_js_1.adminProcedure
45
43
  .input(zod_1.z.object({ capability: zod_1.z.string(), nodeId: zod_1.z.string() }))
@@ -47,10 +45,10 @@ function createCapabilitiesRouter(registry, config) {
47
45
  .mutation(async ({ input }) => {
48
46
  if (!registry)
49
47
  throw new Error('Capability registry unavailable');
48
+ // Persist the cleared state as null FIRST so the boot restorer doesn't
49
+ // re-apply a stale override; only a write that landed clears it live.
50
+ await config.set((0, capability_preference_writes_js_1.singletonPreferenceKey)(input.capability, input.nodeId), null);
50
51
  registry.clearSingletonNodeOverride(input.capability, input.nodeId);
51
- // Persist the cleared state as null so the boot restorer doesn't re-apply
52
- // a stale override on restart.
53
- await config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, null);
54
52
  }),
55
53
  /**
56
54
  * Set the enabled set of providers for a collection capability in
@@ -65,25 +63,13 @@ function createCapabilitiesRouter(registry, config) {
65
63
  enabledAddonIds: zod_1.z.array(zod_1.z.string()),
66
64
  }))
67
65
  .output(zod_1.z.void())
68
- .mutation(({ input }) => {
66
+ .mutation(async ({ input }) => {
69
67
  if (!registry)
70
68
  throw new Error('Capability registry unavailable');
71
- const caps = registry.listCapabilities();
72
- const entry = caps.find((c) => c.name === input.capability);
73
- if (!entry)
74
- throw new Error(`Capability "${input.capability}" not declared`);
75
- if (entry.mode !== 'collection') {
76
- throw new Error(`Capability "${input.capability}" is not a collection`);
77
- }
78
- const wanted = new Set(input.enabledAddonIds);
79
- for (const providerId of entry.providers) {
80
- if (wanted.has(providerId)) {
81
- registry.enableCollectionProvider(input.capability, providerId);
82
- }
83
- else {
84
- registry.disableCollectionProvider(input.capability, providerId);
85
- }
86
- }
69
+ // Persisted under `capabilities.collection.<cap>` BEFORE the live
70
+ // switch. It used to switch live only, so the operator's pick
71
+ // reverted at the next boot (D62's defect, D675 amendment).
72
+ await (0, capability_preference_writes_js_1.writeCollectionEnabledSet)(config, registry, input.capability, input.enabledAddonIds);
87
73
  }),
88
74
  setDeviceCapability: trpc_middleware_js_1.adminProcedure
89
75
  .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string(), addonId: zod_1.z.string() }))
@@ -0,0 +1,169 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.singletonPreferenceKey = singletonPreferenceKey;
4
+ exports.singletonSwitchRefusal = singletonSwitchRefusal;
5
+ exports.writeSingletonPreference = writeSingletonPreference;
6
+ exports.writeCollectionToggle = writeCollectionToggle;
7
+ exports.writeCollectionEnabledSet = writeCollectionEnabledSet;
8
+ /**
9
+ * The ONE write order for an operator's capability preference: PERSIST, then
10
+ * switch the live registry (D675 amendment, 2026-09-29).
11
+ *
12
+ * Every caller used to switch the registry first and persist second. Since
13
+ * D675 `ConfigManager.set` awaits the settings door and REJECTS when the write
14
+ * does not land, so that order produced a mutation that reported an error
15
+ * while the live switch had already happened — and silently reverted at the
16
+ * next boot, because the boot restorer reads the store the write never
17
+ * reached. The operator saw an error for a change that was visibly in effect.
18
+ *
19
+ * Here a refused switch (unknown cap, wrong mode, provider not registered)
20
+ * persists NOTHING, a failed write changes NOTHING live, and only a write that
21
+ * landed is applied. Writes to one capability are serialised, because a
22
+ * collection write computes its next disabled-set from the live one: two
23
+ * toggles planned against the same base would each persist half of the
24
+ * answer.
25
+ *
26
+ * Callers: `capabilities` core router (`setActiveSingleton`,
27
+ * `clearSingletonNodeOverride` via `singletonPreferenceKey`,
28
+ * `setCollectionEnabledProviders`) and the `addons` cap's
29
+ * `setCapabilityProviderEnabled`.
30
+ */
31
+ const server_1 = require("@trpc/server");
32
+ const collection_preference_1 = require("./collection-preference");
33
+ /** ConfigService key holding a singleton preference (global or per-node). */
34
+ function singletonPreferenceKey(capability, nodeId) {
35
+ return nodeId === undefined
36
+ ? `capabilities.singleton.${capability}`
37
+ : `capabilities.singletonNode.${capability}.${nodeId}`;
38
+ }
39
+ /**
40
+ * One write chain per capability, MODULE-LEVEL on purpose: the routers that
41
+ * write preferences are built per request, so the chain cannot live on them.
42
+ * It serialises only within this process — correct because only hub-main
43
+ * mounts these routers; a second process writing preferences would need the
44
+ * store itself to arbitrate.
45
+ */
46
+ const writeChains = new Map();
47
+ /** Run `work` after every earlier write to the same capability has settled. */
48
+ async function serialised(capability, work) {
49
+ const previous = writeChains.get(capability) ?? Promise.resolve();
50
+ const next = previous.then(work, work);
51
+ const settled = next.then(() => undefined, () => undefined);
52
+ writeChains.set(capability, settled);
53
+ try {
54
+ await next;
55
+ }
56
+ finally {
57
+ if (writeChains.get(capability) === settled)
58
+ writeChains.delete(capability);
59
+ }
60
+ }
61
+ function findCapability(registry, capability) {
62
+ return registry.listCapabilities().find((c) => c.name === capability) ?? null;
63
+ }
64
+ /**
65
+ * The refusal `setActiveSingleton` would raise, asked BEFORE anything is
66
+ * persisted — so a switch the registry would refuse never reaches the store.
67
+ * Reads the registry's own projection (`providers`, `perNode`), the same sets
68
+ * `setActiveSingleton` checks.
69
+ */
70
+ function singletonSwitchRefusal(registry, write) {
71
+ const cap = findCapability(registry, write.capability);
72
+ if (cap === null) {
73
+ return { code: 'NOT_FOUND', message: `Unknown capability: ${write.capability}` };
74
+ }
75
+ if (cap.mode !== 'singleton') {
76
+ return { code: 'BAD_REQUEST', message: `"${write.capability}" is not a singleton` };
77
+ }
78
+ if (write.nodeId !== undefined) {
79
+ const onNode = cap.perNode?.find((n) => n.nodeId === write.nodeId);
80
+ if (onNode === undefined || !onNode.providers.includes(write.addonId)) {
81
+ return {
82
+ code: 'BAD_REQUEST',
83
+ message: `Provider "${write.addonId}" is not available on node "${write.nodeId}" for "${write.capability}"`,
84
+ };
85
+ }
86
+ return null;
87
+ }
88
+ if (!cap.providers.includes(write.addonId)) {
89
+ return {
90
+ code: 'BAD_REQUEST',
91
+ message: `Provider "${write.addonId}" is not registered for "${write.capability}"`,
92
+ };
93
+ }
94
+ return null;
95
+ }
96
+ function refuse(refusal) {
97
+ throw new server_1.TRPCError({ code: refusal.code, message: refusal.message });
98
+ }
99
+ /** Persist a singleton preference, then switch the registry. */
100
+ async function writeSingletonPreference(store, registry, write) {
101
+ await serialised(write.capability, async () => {
102
+ const refusal = singletonSwitchRefusal(registry, write);
103
+ if (refusal !== null)
104
+ refuse(refusal);
105
+ await store.set(singletonPreferenceKey(write.capability, write.nodeId), write.addonId);
106
+ await registry.setActiveSingleton(write.capability, write.addonId, write.nodeId);
107
+ });
108
+ }
109
+ function requireCollection(registry, capability) {
110
+ const cap = findCapability(registry, capability);
111
+ if (cap === null)
112
+ refuse({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` });
113
+ if (cap.mode !== 'collection') {
114
+ refuse({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` });
115
+ }
116
+ return cap;
117
+ }
118
+ /** Switch the live registry from `current` to `next` — only after the write landed. */
119
+ function applyDisabledSet(registry, capability, current, next) {
120
+ for (const addonId of next) {
121
+ if (!current.has(addonId))
122
+ registry.disableCollectionProvider(capability, addonId);
123
+ }
124
+ for (const addonId of current) {
125
+ if (!next.has(addonId))
126
+ registry.enableCollectionProvider(capability, addonId);
127
+ }
128
+ }
129
+ /** Persist one collection provider's enable/disable, then apply it live. */
130
+ async function writeCollectionToggle(store, registry, toggle) {
131
+ await serialised(toggle.capability, async () => {
132
+ const cap = requireCollection(registry, toggle.capability);
133
+ if (!cap.providers.includes(toggle.addonId)) {
134
+ refuse({
135
+ code: 'BAD_REQUEST',
136
+ message: `Provider "${toggle.addonId}" is not registered for "${toggle.capability}"`,
137
+ });
138
+ }
139
+ const current = new Set(cap.disabledProviders);
140
+ const next = new Set(current);
141
+ if (toggle.enabled)
142
+ next.delete(toggle.addonId);
143
+ else
144
+ next.add(toggle.addonId);
145
+ await (0, collection_preference_1.persistCollectionDisabled)(store, toggle.capability, [...next]);
146
+ applyDisabledSet(registry, toggle.capability, current, next);
147
+ });
148
+ }
149
+ /**
150
+ * Persist the enabled set of a collection in one shot, then apply it live.
151
+ * Registered providers outside `enabledAddonIds` are disabled; a disabled id
152
+ * whose provider is not registered right now keeps its disabled mark.
153
+ */
154
+ async function writeCollectionEnabledSet(store, registry, capability, enabledAddonIds) {
155
+ await serialised(capability, async () => {
156
+ const cap = requireCollection(registry, capability);
157
+ const wanted = new Set(enabledAddonIds);
158
+ const current = new Set(cap.disabledProviders);
159
+ const next = new Set(current);
160
+ for (const providerId of cap.providers) {
161
+ if (wanted.has(providerId))
162
+ next.delete(providerId);
163
+ else
164
+ next.add(providerId);
165
+ }
166
+ await (0, collection_preference_1.persistCollectionDisabled)(store, capability, [...next]);
167
+ applyDisabledSet(registry, capability, current, next);
168
+ });
169
+ }
@@ -7,10 +7,8 @@ function collectionPreferenceKey(capability) {
7
7
  return `capabilities.collection.${capability}`;
8
8
  }
9
9
  /**
10
- * Persist the disabled-addonId set for a collection capability. Pass the
11
- * authoritative set straight from `registry.listCapabilities()` after the
12
- * enable/disable mutation so the stored value always mirrors the live
13
- * registry state.
10
+ * Persist the disabled-addonId set for a collection capability: the set the
11
+ * registry WILL hold once the write has landed and is applied.
14
12
  */
15
13
  async function persistCollectionDisabled(configService, capability, disabled) {
16
14
  await configService.set(collectionPreferenceKey(capability), JSON.stringify({ disabled: [...disabled] }));
@@ -6393,33 +6393,6 @@ function createCapRouter_pipelineAnalytics(getProvider, createRemoteProxy) {
6393
6393
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6394
6394
  return p.listRecentTracks(methodInput);
6395
6395
  }),
6396
- listSummaries: trpc_middleware_js_1.protectedProcedure
6397
- .input(types_89.pipelineAnalyticsCapability.methods.listSummaries.input.loose())
6398
- .output(types_89.pipelineAnalyticsCapability.methods.listSummaries.output)
6399
- .query(async ({ input, ctx }) => {
6400
- const { nodeId, ...methodInput } = input;
6401
- const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
6402
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6403
- return p.listSummaries(methodInput);
6404
- }),
6405
- listRecentSummaries: trpc_middleware_js_1.protectedProcedure
6406
- .input(types_89.pipelineAnalyticsCapability.methods.listRecentSummaries.input.loose())
6407
- .output(types_89.pipelineAnalyticsCapability.methods.listRecentSummaries.output)
6408
- .query(async ({ input, ctx }) => {
6409
- const { nodeId, ...methodInput } = input;
6410
- const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
6411
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6412
- return p.listRecentSummaries(methodInput);
6413
- }),
6414
- getSummary: trpc_middleware_js_1.protectedProcedure
6415
- .input(types_89.pipelineAnalyticsCapability.methods.getSummary.input.loose())
6416
- .output(types_89.pipelineAnalyticsCapability.methods.getSummary.output)
6417
- .query(async ({ input, ctx }) => {
6418
- const { nodeId, ...methodInput } = input;
6419
- const p = resolveProvider('pipeline-analytics', nodeId, () => getProvider(ctx), createRemoteProxy);
6420
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
6421
- return p.getSummary(methodInput);
6422
- }),
6423
6396
  clearTracks: trpc_middleware_js_1.adminProcedure
6424
6397
  .input(types_89.pipelineAnalyticsCapability.methods.clearTracks.input.loose())
6425
6398
  .output(types_89.pipelineAnalyticsCapability.methods.clearTracks.output)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.410",
3
+ "version": "1.2.412",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "files": [
@@ -37,18 +37,18 @@
37
37
  ]
38
38
  },
39
39
  "dependencies": {
40
- "@camstack/addon-admin-ui": "1.2.286",
41
- "@camstack/addon-agent-ui": "1.2.155",
40
+ "@camstack/addon-admin-ui": "1.2.287",
41
+ "@camstack/addon-agent-ui": "1.2.156",
42
42
  "@camstack/addon-auth": "1.2.123",
43
43
  "@camstack/addon-decoder-nodeav": "1.2.115",
44
44
  "@camstack/addon-notifiers": "1.2.121",
45
- "@camstack/addon-pipeline": "1.2.305",
46
- "@camstack/addon-pipeline-orchestrator": "1.2.262",
47
- "@camstack/addon-post-analysis": "1.2.297",
45
+ "@camstack/addon-pipeline": "1.2.306",
46
+ "@camstack/addon-pipeline-orchestrator": "1.2.263",
47
+ "@camstack/addon-post-analysis": "1.2.298",
48
48
  "@camstack/sdk": "1.2.118",
49
- "@camstack/system": "1.2.318",
50
- "@camstack/types": "1.2.272",
51
- "@camstack/ui-library": "1.2.213",
49
+ "@camstack/system": "1.2.320",
50
+ "@camstack/types": "1.2.273",
51
+ "@camstack/ui-library": "1.2.214",
52
52
  "@fastify/compress": "^9.0.0",
53
53
  "@fastify/cookie": "^11.0.2",
54
54
  "@fastify/cors": "^11.2.0",
@@ -1,246 +0,0 @@
1
- "use strict";
2
- Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.createCapabilitiesRouter = createCapabilitiesRouter;
4
- const zod_1 = require("zod");
5
- const server_1 = require("@trpc/server");
6
- const trpc_middleware_1 = require("./trpc/trpc.middleware");
7
- const system_1 = require("@camstack/system");
8
- const collection_preference_1 = require("./core/collection-preference");
9
- // ─── Zod Schemas ───────────────────────────────────────────────────────────
10
- const setPreferenceInput = zod_1.z.discriminatedUnion('mode', [
11
- zod_1.z.object({
12
- mode: zod_1.z.literal('singleton'),
13
- capability: zod_1.z.string(),
14
- addonId: zod_1.z.string(),
15
- }),
16
- zod_1.z.object({
17
- mode: zod_1.z.literal('collection'),
18
- capability: zod_1.z.string(),
19
- addonId: zod_1.z.string(),
20
- enabled: zod_1.z.boolean(),
21
- }),
22
- ]);
23
- // ─── Router ────────────────────────────────────────────────────────────────
24
- function createCapabilitiesRouter(addonRegistry, configService, loggingService) {
25
- const logger = loggingService.createLogger('CapabilitiesRouter');
26
- /** Build enriched provider details from addon metadata */
27
- function getProviderDetails(addonIds) {
28
- const allAddons = addonRegistry.listAllAddons();
29
- return addonIds.map((addonId) => {
30
- const addon = allAddons.find((a) => a.manifest.id === addonId);
31
- return {
32
- addonId,
33
- displayName: addon?.manifest.name ?? addonId,
34
- packageName: addon?.manifest.packageName ?? addonId,
35
- };
36
- });
37
- }
38
- return (0, trpc_middleware_1.trpcRouter)({
39
- // ─── List all capabilities with enriched metadata ──────────────────
40
- list: trpc_middleware_1.adminProcedure.query(() => {
41
- const registry = addonRegistry.getCapabilityRegistry();
42
- const caps = registry.listCapabilities();
43
- return caps.map((cap) => ({
44
- ...cap,
45
- providerDetails: getProviderDetails(cap.providers),
46
- isInfra: (0, system_1.isInfraCapability)(cap.name),
47
- }));
48
- }),
49
- // ─── Get current preference for a capability ──────────────────────
50
- //
51
- // `state` separates "the operator set nothing" (`known`, `preference`
52
- // null / no disabled ids) from "the persisted preference cannot be read"
53
- // (`unknown` — the boot read of the settings store is pending or failed,
54
- // D675). The latter used to be reported as `null`: an unset preference.
55
- getPreference: trpc_middleware_1.adminProcedure.input(zod_1.z.object({ capability: zod_1.z.string() })).query(({ input }) => {
56
- const registry = addonRegistry.getCapabilityRegistry();
57
- const mode = registry.getMode(input.capability);
58
- if (!mode)
59
- return null;
60
- let raw;
61
- try {
62
- raw = configService.get(mode === 'singleton'
63
- ? `capabilities.singleton.${input.capability}`
64
- : (0, collection_preference_1.collectionPreferenceKey)(input.capability));
65
- }
66
- catch (err) {
67
- if (!(0, system_1.isSettingsReadUnavailable)(err))
68
- throw err;
69
- return {
70
- capability: input.capability,
71
- mode,
72
- state: 'unknown',
73
- reason: err.reason,
74
- preference: null,
75
- };
76
- }
77
- if (mode === 'singleton') {
78
- return {
79
- capability: input.capability,
80
- mode: 'singleton',
81
- state: 'known',
82
- preference: raw ? { addonId: raw } : null,
83
- };
84
- }
85
- // collection
86
- let disabled = [];
87
- if (raw) {
88
- try {
89
- const parsed = JSON.parse(raw);
90
- if (typeof parsed === 'object' && parsed !== null && 'disabled' in parsed) {
91
- const list = parsed.disabled;
92
- disabled = Array.isArray(list)
93
- ? list.filter((id) => typeof id === 'string')
94
- : [];
95
- }
96
- }
97
- catch {
98
- /* ignore malformed */
99
- }
100
- }
101
- return {
102
- capability: input.capability,
103
- mode: 'collection',
104
- state: 'known',
105
- preference: { disabled },
106
- };
107
- }),
108
- // ─── Set preference (singleton switch or collection toggle) ───────
109
- setPreference: trpc_middleware_1.adminProcedure.input(setPreferenceInput).mutation(async ({ input }) => {
110
- const registry = addonRegistry.getCapabilityRegistry();
111
- if (input.mode === 'singleton') {
112
- const { capability, addonId } = input;
113
- const caps = registry.listCapabilities();
114
- const cap = caps.find((c) => c.name === capability);
115
- if (!cap)
116
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` });
117
- if (cap.mode !== 'singleton')
118
- throw new server_1.TRPCError({
119
- code: 'BAD_REQUEST',
120
- message: `"${capability}" is not a singleton`,
121
- });
122
- if (!cap.providers.includes(addonId)) {
123
- throw new server_1.TRPCError({
124
- code: 'BAD_REQUEST',
125
- message: `Provider "${addonId}" is not registered for "${capability}"`,
126
- });
127
- }
128
- const requiresRestart = (0, system_1.isInfraCapability)(capability);
129
- if (!requiresRestart) {
130
- // Hot-swap at runtime
131
- await registry.setActiveSingleton(capability, addonId);
132
- }
133
- // Persist preference
134
- await configService.set(`capabilities.singleton.${capability}`, addonId);
135
- logger.info('Singleton preference set', {
136
- tags: { addonId },
137
- meta: { capability, requiresRestart },
138
- });
139
- return { success: true, requiresRestart };
140
- }
141
- // collection toggle
142
- const { capability, addonId, enabled } = input;
143
- const caps = registry.listCapabilities();
144
- const cap = caps.find((c) => c.name === capability);
145
- if (!cap)
146
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` });
147
- if (cap.mode !== 'collection')
148
- throw new server_1.TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` });
149
- if (!cap.providers.includes(addonId)) {
150
- throw new server_1.TRPCError({
151
- code: 'BAD_REQUEST',
152
- message: `Provider "${addonId}" is not registered for "${capability}"`,
153
- });
154
- }
155
- if (enabled) {
156
- registry.enableCollectionProvider(capability, addonId);
157
- }
158
- else {
159
- registry.disableCollectionProvider(capability, addonId);
160
- }
161
- // Persist disabled list via the shared canonical writer.
162
- const updatedCap = registry.listCapabilities().find((c) => c.name === capability);
163
- await (0, collection_preference_1.persistCollectionDisabled)(configService, capability, updatedCap?.disabledProviders ?? []);
164
- logger.info('Collection provider toggled', {
165
- tags: { addonId },
166
- meta: { capability, enabled },
167
- });
168
- return { success: true, requiresRestart: false };
169
- }),
170
- // ─── Reset preference to default ──────────────────────────────────
171
- resetPreference: trpc_middleware_1.adminProcedure
172
- .input(zod_1.z.object({ capability: zod_1.z.string() }))
173
- .mutation(async ({ input }) => {
174
- const registry = addonRegistry.getCapabilityRegistry();
175
- const mode = registry.getMode(input.capability);
176
- if (!mode)
177
- throw new server_1.TRPCError({
178
- code: 'NOT_FOUND',
179
- message: `Unknown capability: ${input.capability}`,
180
- });
181
- if (mode === 'singleton') {
182
- await configService.set(`capabilities.singleton.${input.capability}`, null);
183
- logger.info('Singleton preference reset (takes effect on restart)', {
184
- meta: { capability: input.capability },
185
- });
186
- return { success: true, requiresRestart: true };
187
- }
188
- // collection: re-enable all disabled providers
189
- const caps = registry.listCapabilities();
190
- const cap = caps.find((c) => c.name === input.capability);
191
- if (cap) {
192
- for (const addonId of cap.disabledProviders) {
193
- registry.enableCollectionProvider(input.capability, addonId);
194
- }
195
- }
196
- await configService.set(`capabilities.collection.${input.capability}`, null);
197
- logger.info('Collection preference reset (all providers re-enabled)', {
198
- meta: { capability: input.capability },
199
- });
200
- return { success: true, requiresRestart: false };
201
- }),
202
- // ─── Per-device overrides (existing, unchanged) ───────────────────
203
- listCapabilities: trpc_middleware_1.adminProcedure.query(() => {
204
- const registry = addonRegistry.getCapabilityRegistry();
205
- return registry.listCapabilities();
206
- }),
207
- setDeviceCapability: trpc_middleware_1.adminProcedure
208
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string(), addonId: zod_1.z.string() }))
209
- .mutation(({ input }) => {
210
- const registry = addonRegistry.getCapabilityRegistry();
211
- registry.setDeviceOverride(input.deviceId, input.capability, input.addonId);
212
- logger.info('Device capability override set', {
213
- tags: { deviceId: Number(input.deviceId), addonId: input.addonId },
214
- meta: { capability: input.capability },
215
- });
216
- }),
217
- clearDeviceCapability: trpc_middleware_1.adminProcedure
218
- .input(zod_1.z.object({ deviceId: zod_1.z.string(), capability: zod_1.z.string() }))
219
- .mutation(({ input }) => {
220
- const registry = addonRegistry.getCapabilityRegistry();
221
- registry.clearDeviceOverride(input.deviceId, input.capability);
222
- logger.info('Device capability override cleared', {
223
- tags: { deviceId: Number(input.deviceId) },
224
- meta: { capability: input.capability },
225
- });
226
- }),
227
- getDeviceCapabilities: trpc_middleware_1.adminProcedure
228
- .input(zod_1.z.object({ deviceId: zod_1.z.string() }))
229
- .output(zod_1.z.record(zod_1.z.string(), zod_1.z.string()))
230
- .query(({ input }) => {
231
- const registry = addonRegistry.getCapabilityRegistry();
232
- const overrides = registry.getDeviceOverrides(input.deviceId);
233
- return Object.fromEntries(overrides);
234
- }),
235
- /*
236
- * `setDeviceCollectionFilter` / `clearDeviceCollectionFilter` were REMOVED
237
- * here (D556). They wrote an in-memory per-device allow list that nothing
238
- * persisted, so an operator's pick survived until the next hub restart and
239
- * then silently reverted — a control that looks like it works, which is the
240
- * defect D62 exists for. Which sources a camera has is now asked of the ONE
241
- * authority that already owned a per-camera function switch:
242
- * `deviceManager.setWrapperActive`. The registry keeps only its projection,
243
- * written by `device-source-activation.ts` and by nothing else.
244
- */
245
- });
246
- }