@camstack/server 1.2.79 → 1.2.80

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.
@@ -181,9 +181,43 @@ function createCapRouterServices(deps) {
181
181
  return reg?.getSingleton(capName) ?? null;
182
182
  }
183
183
  };
184
+ /**
185
+ * An explicit `{ addonId }` collection pin that no registered provider owns
186
+ * must REFUSE — never degrade into another provider's answer.
187
+ *
188
+ * The hole this closes: `getProviderByAddonId` returns null for an unknown
189
+ * id, which the router could not tell apart from "this cap is resident in a
190
+ * forked hub worker", so it fell through to `remoteProxy(cap, 'hub')` and
191
+ * answered from the FIRST hub-side provider. Measured live: a fabricated
192
+ * `addonId` on `deviceExport.getStatus` returned Alexa's `linked / 3`. The
193
+ * Home Assistant component pins `homeassistant-export` precisely to avoid
194
+ * the merged aggregate — one missing/renamed/undeployed addon and it would
195
+ * have silently imported Alexa's devices while looking like success.
196
+ *
197
+ * Refuses ONLY when the cap has providers and none matches: with zero local
198
+ * providers the fallthrough is the legitimate forked-worker route and stays.
199
+ */
200
+ const rejectUnresolvedAddonPin = (capName, addonId) => {
201
+ if (!reg)
202
+ return;
203
+ const known = reg.getProviderAddonIds(capName);
204
+ if (known.length === 0)
205
+ return;
206
+ deps.logger?.warn('Refusing cap call: pinned addonId is not a provider of this capability', {
207
+ tags: { addonId },
208
+ meta: { capability: capName, validAddonIds: [...known] },
209
+ });
210
+ throw new server_1.TRPCError({
211
+ code: 'BAD_REQUEST',
212
+ message: `Capability "${capName}" has no provider with addonId "${addonId}". ` +
213
+ `Valid addonId(s): ${known.join(', ')}. ` +
214
+ `An unresolvable addonId is refused rather than answered by another provider.`,
215
+ });
216
+ };
184
217
  return {
185
218
  getLocalProvider,
186
219
  remoteProxy,
220
+ rejectUnresolvedAddonPin,
187
221
  noProvider: (capName, nodeId) => {
188
222
  if (nodeId !== undefined && nodeId !== 'hub') {
189
223
  throw new server_1.TRPCError({
@@ -243,6 +243,7 @@ function buildCapabilityRouters(services) {
243
243
  capabilityRegistry: services.capabilityRegistry,
244
244
  moleculer: services.moleculer,
245
245
  serverProviders: buildServerProviders(services),
246
+ logger: services.loggingService.createLogger('cap-router'),
246
247
  });
247
248
  return {
248
249
  // ── Runtime-built cap routers: EVERY cap whose mount is
@@ -55,6 +55,7 @@ const fs = __importStar(require("node:fs"));
55
55
  const node_url_1 = require("node:url");
56
56
  const require_cache_js_1 = require("./require-cache.js");
57
57
  const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
58
+ const integration_visibility_js_1 = require("./integration-visibility.js");
58
59
  const addon_call_gateway_js_1 = require("./addon-call-gateway.js");
59
60
  const system_6 = require("@camstack/system");
60
61
  const types_2 = require("@camstack/types");
@@ -1053,73 +1054,20 @@ class AddonRegistryService {
1053
1054
  getRawIntegrationRegistry() {
1054
1055
  return this.integrationRegistry;
1055
1056
  }
1057
+ /**
1058
+ * The installed-addon VIEW over the integration store. The rule it enforces
1059
+ * — a principal that cannot read an integration must not create one, and a
1060
+ * read that hides an existing row says so — lives in
1061
+ * `integration-visibility.ts`, on its own, with its own tests.
1062
+ */
1056
1063
  createFilteredRegistry(raw) {
1057
- const installedAddonIds = new Set(this.addonEntries.keys());
1058
- // Build set of integration IDs whose addon IS installed
1059
- // Cache per call lightweight, called infrequently
1060
- let activeIntegrationIds = null;
1061
- const ensureActiveIds = async () => {
1062
- if (activeIntegrationIds)
1063
- return activeIntegrationIds;
1064
- const all = await raw.listIntegrations();
1065
- activeIntegrationIds = new Set(all.filter((i) => installedAddonIds.has(i.addonId)).map((i) => i.id));
1066
- return activeIntegrationIds;
1067
- };
1068
- return {
1069
- // Integrations: filter out orphaned
1070
- createIntegration: (input) => raw.createIntegration(input),
1071
- getIntegration: async (id) => {
1072
- const i = await raw.getIntegration(id);
1073
- return i && installedAddonIds.has(i.addonId) ? i : null;
1074
- },
1075
- getIntegrationByAddonId: async (addonId) => {
1076
- if (!installedAddonIds.has(addonId))
1077
- return null;
1078
- return raw.getIntegrationByAddonId(addonId);
1079
- },
1080
- listIntegrations: async () => {
1081
- const all = await raw.listIntegrations();
1082
- return all.filter((i) => installedAddonIds.has(i.addonId));
1083
- },
1084
- updateIntegration: (id, updates) => raw.updateIntegration(id, updates),
1085
- deleteIntegration: (id) => raw.deleteIntegration(id),
1086
- // Integration settings: passthrough (already gated by getIntegration)
1087
- getIntegrationSettings: (id) => raw.getIntegrationSettings(id),
1088
- setIntegrationSetting: (id, key, value) => raw.setIntegrationSetting(id, key, value),
1089
- setIntegrationSettings: (id, settings) => raw.setIntegrationSettings(id, settings),
1090
- // Devices: filter out devices belonging to orphaned integrations
1091
- createDevice: (input) => raw.createDevice(input),
1092
- getDevice: async (id) => {
1093
- const d = await raw.getDevice(id);
1094
- if (!d)
1095
- return null;
1096
- const ids = await ensureActiveIds();
1097
- return ids.has(d.integrationId) ? d : null;
1098
- },
1099
- getDeviceByStableId: async (stableId) => {
1100
- const d = await raw.getDeviceByStableId(stableId);
1101
- if (!d)
1102
- return null;
1103
- const ids = await ensureActiveIds();
1104
- return ids.has(d.integrationId) ? d : null;
1105
- },
1106
- listDevices: async (integrationId) => {
1107
- const devices = await raw.listDevices(integrationId);
1108
- const ids = await ensureActiveIds();
1109
- return devices.filter((d) => ids.has(d.integrationId));
1110
- },
1111
- listCameras: async () => {
1112
- const cameras = await raw.listCameras();
1113
- const ids = await ensureActiveIds();
1114
- return cameras.filter((d) => ids.has(d.integrationId));
1115
- },
1116
- updateDevice: (id, updates) => raw.updateDevice(id, updates),
1117
- deleteDevice: (id) => raw.deleteDevice(id),
1118
- // Device settings: passthrough
1119
- getDeviceSettings: (id) => raw.getDeviceSettings(id),
1120
- setDeviceSetting: (id, key, value) => raw.setDeviceSetting(id, key, value),
1121
- setDeviceSettings: (id, settings) => raw.setDeviceSettings(id, settings),
1122
- };
1064
+ return (0, integration_visibility_js_1.createFilteredIntegrationRegistry)({
1065
+ raw,
1066
+ // The LIVE map, not a snapshot: an addon registered after this wrapper
1067
+ // was built is installed, and a stale copy would refuse its first write.
1068
+ isAddonInstalled: (addonId) => this.addonEntries.has(addonId),
1069
+ logger: this.logger,
1070
+ });
1123
1071
  }
1124
1072
  // InferenceCapabilitiesService removed — now lives in pipeline-executor addon.
1125
1073
  // Use capabilityRegistry.getSingleton('pipeline-executor') instead.
@@ -0,0 +1,142 @@
1
+ "use strict";
2
+ /**
3
+ * Who may see, and who may mint, an integration.
4
+ *
5
+ * The integration store is shared by every addon on the node, so the registry
6
+ * handed out to callers is a VIEW: rows whose declaring addon is not installed
7
+ * are hidden, and the data stays in the database so a reinstall reconnects to
8
+ * it rather than starting empty.
9
+ *
10
+ * That filter had a hole, and it was the silent, accumulating kind. Extracted
11
+ * here verbatim from `AddonRegistryService.createFilteredRegistry` so the rule
12
+ * is testable on its own.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ exports.createFilteredIntegrationRegistry = createFilteredIntegrationRegistry;
16
+ const types_1 = require("@camstack/types");
17
+ /**
18
+ * Wrap `raw` in the installed-addon view.
19
+ *
20
+ * Two rules, and they are the same rule read in both directions:
21
+ *
22
+ * 1. **A principal that cannot READ an integration may not CREATE one.**
23
+ * Until this guard existed, `createIntegration` was the only unfiltered
24
+ * entry point in the whole surface. A core block — addon id
25
+ * `core-block-<uuid>`, never in the installed set — therefore minted a fresh
26
+ * row on EVERY start: `getIntegrationByAddonId` answered null forever, so
27
+ * the get-or-create above it never found what it had just made.
28
+ * `listIntegrations` hid the row from the operator and `integrations.delete`
29
+ * answered *"not found"* for the same reason, so nothing could ever remove
30
+ * it. Measured on the live hub: `int_0021` and `int_0022`, two starts of one
31
+ * block, invisible and undeletable. A declarer that is not an installed
32
+ * addon hangs its devices off an integration somebody else owns —
33
+ * `DeclaredDevicesSpec.integrationId`.
34
+ *
35
+ * 2. **Hidden is not absent.** A read that suppresses a row that EXISTS logs
36
+ * it. Both branches returned a bare `null`, and that indistinguishability is
37
+ * precisely what kept rule 1's breach invisible for as long as it lasted.
38
+ */
39
+ function createFilteredIntegrationRegistry(deps) {
40
+ const { raw, logger } = deps;
41
+ // A co-located builtin's `ctx.id` is `addon:<manifest id>`; every registry
42
+ // key is bare (D72). Normalise on the way in, or the create guard rejects
43
+ // exactly the builtins that legitimately declare an integration.
44
+ const isInstalled = (addonId) => deps.isAddonInstalled((0, types_1.bareAddonId)(addonId));
45
+ // Integration ids whose addon IS installed. Cached per wrapper — these
46
+ // wrappers are built per `getIntegrationRegistry()` call, so the cache never
47
+ // outlives a request path.
48
+ let activeIntegrationIds = null;
49
+ const ensureActiveIds = async () => {
50
+ if (activeIntegrationIds)
51
+ return activeIntegrationIds;
52
+ const all = await raw.listIntegrations();
53
+ activeIntegrationIds = new Set(all.filter((i) => isInstalled(i.addonId)).map((i) => i.id));
54
+ return activeIntegrationIds;
55
+ };
56
+ /** The suppression log line. Only ever called when the row EXISTS. */
57
+ const logHidden = (integration, phase) => {
58
+ logger.warn('integration hidden — its addon is not installed, so this read answers null even though the row exists', {
59
+ tags: { integrationId: integration.id, addonId: integration.addonId },
60
+ meta: { phase, name: integration.name },
61
+ });
62
+ };
63
+ const createIntegration = async (input) => {
64
+ if (!isInstalled(input.addonId)) {
65
+ // Refuse, loudly. The alternative is not "it works": it is a row this
66
+ // same wrapper will hide from every later read, from the operator's list
67
+ // and from delete — the row can only accumulate.
68
+ logger.error('refused to mint an integration for a principal that cannot read one', {
69
+ tags: { addonId: input.addonId },
70
+ meta: { phase: 'create', name: input.name },
71
+ });
72
+ throw new Error(`addon "${input.addonId}" is not installed, so it cannot read an integration — ` +
73
+ 'and a principal that cannot read one must not create one (it would be invisible ' +
74
+ 'and undeletable). Hang the devices off an existing integration instead: ' +
75
+ 'DeclaredDevicesSpec.integrationId.');
76
+ }
77
+ return raw.createIntegration(input);
78
+ };
79
+ return {
80
+ createIntegration,
81
+ getIntegration: async (id) => {
82
+ const i = await raw.getIntegration(id);
83
+ if (i === null)
84
+ return null;
85
+ if (isInstalled(i.addonId))
86
+ return i;
87
+ logHidden(i, 'getIntegration');
88
+ return null;
89
+ },
90
+ getIntegrationByAddonId: async (addonId) => {
91
+ const i = await raw.getIntegrationByAddonId(addonId);
92
+ if (i === null)
93
+ return null;
94
+ if (isInstalled(i.addonId))
95
+ return i;
96
+ logHidden(i, 'getIntegrationByAddonId');
97
+ return null;
98
+ },
99
+ listIntegrations: async () => {
100
+ const all = await raw.listIntegrations();
101
+ return all.filter((i) => isInstalled(i.addonId));
102
+ },
103
+ updateIntegration: (id, updates) => raw.updateIntegration(id, updates),
104
+ deleteIntegration: (id) => raw.deleteIntegration(id),
105
+ // Integration settings: passthrough (already gated by getIntegration)
106
+ getIntegrationSettings: (id) => raw.getIntegrationSettings(id),
107
+ setIntegrationSetting: (id, key, value) => raw.setIntegrationSetting(id, key, value),
108
+ setIntegrationSettings: (id, settings) => raw.setIntegrationSettings(id, settings),
109
+ // Devices: filter out devices belonging to hidden integrations
110
+ createDevice: (input) => raw.createDevice(input),
111
+ getDevice: async (id) => {
112
+ const d = await raw.getDevice(id);
113
+ if (!d)
114
+ return null;
115
+ const ids = await ensureActiveIds();
116
+ return ids.has(d.integrationId) ? d : null;
117
+ },
118
+ getDeviceByStableId: async (stableId) => {
119
+ const d = await raw.getDeviceByStableId(stableId);
120
+ if (!d)
121
+ return null;
122
+ const ids = await ensureActiveIds();
123
+ return ids.has(d.integrationId) ? d : null;
124
+ },
125
+ listDevices: async (integrationId) => {
126
+ const devices = await raw.listDevices(integrationId);
127
+ const ids = await ensureActiveIds();
128
+ return devices.filter((d) => ids.has(d.integrationId));
129
+ },
130
+ listCameras: async () => {
131
+ const cameras = await raw.listCameras();
132
+ const ids = await ensureActiveIds();
133
+ return cameras.filter((d) => ids.has(d.integrationId));
134
+ },
135
+ updateDevice: (id, updates) => raw.updateDevice(id, updates),
136
+ deleteDevice: (id) => raw.deleteDevice(id),
137
+ // Device settings: passthrough
138
+ getDeviceSettings: (id) => raw.getDeviceSettings(id),
139
+ setDeviceSetting: (id, key, value) => raw.setDeviceSetting(id, key, value),
140
+ setDeviceSettings: (id, settings) => raw.setDeviceSettings(id, settings),
141
+ };
142
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.79",
3
+ "version": "1.2.80",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,18 +33,18 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.39",
36
+ "@camstack/addon-admin-ui": "1.2.40",
37
37
  "@camstack/addon-agent-ui": "1.2.10",
38
38
  "@camstack/addon-auth": "1.2.11",
39
39
  "@camstack/addon-decoder-nodeav": "1.2.9",
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
- "@camstack/addon-pipeline": "1.2.51",
41
+ "@camstack/addon-pipeline": "1.2.52",
42
42
  "@camstack/addon-pipeline-orchestrator": "1.2.33",
43
- "@camstack/addon-post-analysis": "1.2.54",
43
+ "@camstack/addon-post-analysis": "1.2.55",
44
44
  "@camstack/sdk": "1.2.10",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.66",
47
- "@camstack/types": "1.2.50",
46
+ "@camstack/system": "1.2.67",
47
+ "@camstack/types": "1.2.51",
48
48
  "@camstack/ui-library": "1.2.36",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",