@camstack/server 1.2.78 → 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.
- package/dist/api/trpc/cap-router-runtime.js +34 -0
- package/dist/api/trpc/device-config-secret-redaction.js +191 -0
- package/dist/api/trpc/trpc.middleware.js +30 -15
- package/dist/api/trpc/trpc.router.js +1 -0
- package/dist/core/addon/addon-registry.service.js +14 -66
- package/dist/core/addon/integration-visibility.js +142 -0
- package/package.json +8 -8
|
@@ -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({
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Device-config secret redaction for NON-ADMIN principals.
|
|
4
|
+
*
|
|
5
|
+
* A device's `config` blob is the driver's connection record — host, port,
|
|
6
|
+
* username and, for every IP camera in the deployment, the camera's
|
|
7
|
+
* `password` in plaintext. It rides the wire on every device-manager read:
|
|
8
|
+
* `getDevice`, `listAll`, `getChildren`, and the three settings surfaces that
|
|
9
|
+
* project the same values into form fields.
|
|
10
|
+
*
|
|
11
|
+
* Those methods are declared `auth: 'protected'` (the default in
|
|
12
|
+
* `capability-definition.ts`), which is NOT admin — it is "authenticated, and
|
|
13
|
+
* scope-matched". Measured against the live hub: a `cst_*` scoped token
|
|
14
|
+
* carrying nothing but `category:system` + `view` — a READ-ONLY grant, the one
|
|
15
|
+
* the deploy runbook mints for CI — read `deviceManager.getDevice` and received
|
|
16
|
+
* `config.password` verbatim, and `deviceManager.listAll` answered with a
|
|
17
|
+
* populated password for every camera at once. Read-only access to a device
|
|
18
|
+
* list is not consent to hand over the credentials that open those cameras.
|
|
19
|
+
*
|
|
20
|
+
* WHY HERE, and not at the provider. `toDeviceInfo()` builds one `config` for
|
|
21
|
+
* everybody, and its in-process consumers need the real thing: the pipeline
|
|
22
|
+
* orchestrator's per-camera `getDevice`, dispatch routing, the drivers that
|
|
23
|
+
* actually connect. Those callers arrive through `ctx.api` / UDS and never
|
|
24
|
+
* touch this middleware, so redacting here withholds the secret from the WIRE
|
|
25
|
+
* without blinding the system to its own credentials. The transport boundary
|
|
26
|
+
* is also the only layer that knows the principal.
|
|
27
|
+
*
|
|
28
|
+
* WHY ADMINS ARE UNTOUCHED. The settings form renders the current value into a
|
|
29
|
+
* password field, and `updateConfig` is `auth: 'admin'`, so a redacted value
|
|
30
|
+
* can never be written back by a caller who received one. Making the field
|
|
31
|
+
* write-only for admins too is a bigger, UI-side change; it is not needed to
|
|
32
|
+
* close this hole and would regress the form.
|
|
33
|
+
*
|
|
34
|
+
* Share-view (`csv_*`) principals never reach this code — they are gated
|
|
35
|
+
* earlier by the fail-closed allowlist in `share-view-access.ts`, which does
|
|
36
|
+
* not expose `getDevice` at all and projects `listAll` down to seven presentation
|
|
37
|
+
* fields. This module is the gate for the tier BETWEEN a share link and an admin.
|
|
38
|
+
*
|
|
39
|
+
* Pure module — no tRPC machinery — so the spec exercises it directly (same
|
|
40
|
+
* pattern as `scope-access.ts` and `share-view-access.ts`).
|
|
41
|
+
*/
|
|
42
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
43
|
+
exports.NON_ADMIN_CONFIG_REDACTED_METHODS = exports.REDACTED_SECRET = void 0;
|
|
44
|
+
exports.isSecretConfigKey = isSecretConfigKey;
|
|
45
|
+
exports.redactDeviceInfoSecrets = redactDeviceInfoSecrets;
|
|
46
|
+
exports.redactSettingsSections = redactSettingsSections;
|
|
47
|
+
exports.redactSettingsAggregate = redactSettingsAggregate;
|
|
48
|
+
exports.redactConfigEntries = redactConfigEntries;
|
|
49
|
+
/** What a non-admin sees instead of the value. Matches the `***` convention
|
|
50
|
+
* `maskUrlCredentials` already uses for credentials in log output, so one
|
|
51
|
+
* redaction marker means one thing across the system. */
|
|
52
|
+
exports.REDACTED_SECRET = '***';
|
|
53
|
+
/**
|
|
54
|
+
* Config keys whose VALUE is a credential.
|
|
55
|
+
*
|
|
56
|
+
* Deliberately the same rule the settings UI uses to decide that a field
|
|
57
|
+
* renders as a password input (`zodEntriesToConfigUI` in
|
|
58
|
+
* `packages/types/src/device/zod-to-config-ui.ts`). The two are pinned
|
|
59
|
+
* together by an assertion in `device-config-secret-redaction.spec.ts`: a
|
|
60
|
+
* keyword added to one and forgotten in the other fails the suite instead of
|
|
61
|
+
* leaking. It is duplicated rather than imported because `@camstack/types` is
|
|
62
|
+
* a framework package on the publish train, and a redaction fix must not have
|
|
63
|
+
* to wait for one.
|
|
64
|
+
*/
|
|
65
|
+
const SECRET_KEY_SUBSTRINGS = [
|
|
66
|
+
'password',
|
|
67
|
+
'secret',
|
|
68
|
+
'token',
|
|
69
|
+
'apikey',
|
|
70
|
+
'api_key',
|
|
71
|
+
];
|
|
72
|
+
/** True when a config key names a credential. Case-insensitive substring match. */
|
|
73
|
+
function isSecretConfigKey(key) {
|
|
74
|
+
const lower = key.toLowerCase();
|
|
75
|
+
return SECRET_KEY_SUBSTRINGS.some((needle) => lower.includes(needle));
|
|
76
|
+
}
|
|
77
|
+
/** Non-null, non-array plain object. */
|
|
78
|
+
function isRecord(x) {
|
|
79
|
+
return x !== null && typeof x === 'object' && !Array.isArray(x);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Replace the value of every credential-named key with {@link REDACTED_SECRET}.
|
|
83
|
+
*
|
|
84
|
+
* An EMPTY value stays empty: "not configured" and "configured, withheld" are
|
|
85
|
+
* different facts, and flattening them would make a broken camera look set up.
|
|
86
|
+
* A non-string value is left alone — a numeric `maxTokens` is not a secret, and
|
|
87
|
+
* the only credentials this repo persists are strings.
|
|
88
|
+
*/
|
|
89
|
+
function redactRecord(config) {
|
|
90
|
+
const out = {};
|
|
91
|
+
for (const [key, value] of Object.entries(config)) {
|
|
92
|
+
out[key] =
|
|
93
|
+
isSecretConfigKey(key) && typeof value === 'string' && value.length > 0
|
|
94
|
+
? exports.REDACTED_SECRET
|
|
95
|
+
: value;
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Project a `DeviceInfo` (or an array of them) with its `config` redacted.
|
|
101
|
+
* Anything that is not a device row passes through untouched — a route that
|
|
102
|
+
* errored upstream must not be rewritten into a plausible-looking shape.
|
|
103
|
+
*/
|
|
104
|
+
function redactDeviceInfoSecrets(data) {
|
|
105
|
+
if (Array.isArray(data))
|
|
106
|
+
return data.map((row) => redactDeviceInfoSecrets(row));
|
|
107
|
+
if (!isRecord(data))
|
|
108
|
+
return data;
|
|
109
|
+
const config = data['config'];
|
|
110
|
+
if (!isRecord(config))
|
|
111
|
+
return data;
|
|
112
|
+
return { ...data, config: redactRecord(config) };
|
|
113
|
+
}
|
|
114
|
+
/** One settings field as the aggregate emits it: `{ key, value, … }`. */
|
|
115
|
+
function redactField(field) {
|
|
116
|
+
if (!isRecord(field))
|
|
117
|
+
return field;
|
|
118
|
+
const key = field['key'];
|
|
119
|
+
if (typeof key !== 'string' || !isSecretConfigKey(key))
|
|
120
|
+
return field;
|
|
121
|
+
const value = field['value'];
|
|
122
|
+
if (typeof value !== 'string' || value.length === 0)
|
|
123
|
+
return field;
|
|
124
|
+
return { ...field, value: exports.REDACTED_SECRET };
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* Project a `SettingsSchemaWithValues` (`{ sections: [{ fields: [...] }] }`).
|
|
128
|
+
* Used by `getDeviceSettingsAggregate` and `getSettingsSchema`.
|
|
129
|
+
*/
|
|
130
|
+
function redactSettingsSections(data) {
|
|
131
|
+
if (!isRecord(data))
|
|
132
|
+
return data;
|
|
133
|
+
const sections = data['sections'];
|
|
134
|
+
if (!Array.isArray(sections))
|
|
135
|
+
return data;
|
|
136
|
+
return {
|
|
137
|
+
...data,
|
|
138
|
+
sections: sections.map((section) => {
|
|
139
|
+
if (!isRecord(section))
|
|
140
|
+
return section;
|
|
141
|
+
const fields = section['fields'];
|
|
142
|
+
if (!Array.isArray(fields))
|
|
143
|
+
return section;
|
|
144
|
+
return { ...section, fields: fields.map(redactField) };
|
|
145
|
+
}),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Project the combined aggregate, whose settings live one level down under
|
|
150
|
+
* `settings`. The live half carries no config blob.
|
|
151
|
+
*/
|
|
152
|
+
function redactSettingsAggregate(data) {
|
|
153
|
+
if (!isRecord(data))
|
|
154
|
+
return data;
|
|
155
|
+
const settings = data['settings'];
|
|
156
|
+
if (!isRecord(settings))
|
|
157
|
+
return data;
|
|
158
|
+
return { ...data, settings: redactSettingsSections(settings) };
|
|
159
|
+
}
|
|
160
|
+
/** Project a flat `ConfigEntry[]` — `getConfigSchema`'s `{ key, value }` rows. */
|
|
161
|
+
function redactConfigEntries(data) {
|
|
162
|
+
if (!Array.isArray(data))
|
|
163
|
+
return data;
|
|
164
|
+
return data.map(redactField);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Every device-manager read whose response carries a device `config` blob, and
|
|
168
|
+
* the projection that cuts it.
|
|
169
|
+
*
|
|
170
|
+
* ENUMERATED rather than applied blanket-wise on purpose: a structural
|
|
171
|
+
* "redact anything credential-named" pass over every non-admin response would
|
|
172
|
+
* also strip `turnProvider.getTurnServers().credential` (the browser needs it),
|
|
173
|
+
* `auth.listShareTokens().tokenPrefix`, and a notification rule's
|
|
174
|
+
* `conditions.eventTypeTokens` — breaking live surfaces to fix a leak that
|
|
175
|
+
* lives in one cap.
|
|
176
|
+
*
|
|
177
|
+
* `deviceManager.listPersistedByAddon` is absent by inspection, not omission:
|
|
178
|
+
* `SavedDeviceRowSchema` has no `config` field, confirmed against the live hub.
|
|
179
|
+
* A new device-manager method returning `DeviceInfoSchema` must be added here —
|
|
180
|
+
* `device-config-secret-redaction.spec.ts` pins the list so the addition is a
|
|
181
|
+
* failing test rather than a silent leak.
|
|
182
|
+
*/
|
|
183
|
+
exports.NON_ADMIN_CONFIG_REDACTED_METHODS = new Map([
|
|
184
|
+
['deviceManager.getDevice', redactDeviceInfoSecrets],
|
|
185
|
+
['deviceManager.listAll', redactDeviceInfoSecrets],
|
|
186
|
+
['deviceManager.getChildren', redactDeviceInfoSecrets],
|
|
187
|
+
['deviceManager.getDeviceSettingsAggregate', redactSettingsSections],
|
|
188
|
+
['deviceManager.getSettingsSchema', redactSettingsSections],
|
|
189
|
+
['deviceManager.getDeviceAggregate', redactSettingsAggregate],
|
|
190
|
+
['deviceManager.getConfigSchema', redactConfigEntries],
|
|
191
|
+
]);
|
|
@@ -10,6 +10,7 @@ const system_1 = require("@camstack/system");
|
|
|
10
10
|
const server_1 = require("@trpc/server");
|
|
11
11
|
const superjson_1 = __importDefault(require("superjson"));
|
|
12
12
|
const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
|
|
13
|
+
const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
|
|
13
14
|
const scope_access_js_1 = require("./scope-access.js");
|
|
14
15
|
const share_view_access_js_1 = require("./share-view-access.js");
|
|
15
16
|
const t = server_1.initTRPC.context().create({
|
|
@@ -136,23 +137,37 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
|
|
|
136
137
|
if (ctx.user.isAdmin) {
|
|
137
138
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
138
139
|
}
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
140
|
+
// Everything below this line is a NON-ADMIN principal.
|
|
141
|
+
//
|
|
142
|
+
// A hand-written core route has no cap entry. Authentication has
|
|
143
|
+
// already passed; defer further gating to any explicit
|
|
144
|
+
// `adminProcedure` chained on top of this one. A cap route is
|
|
145
|
+
// scope-matched first.
|
|
146
|
+
if (path in system_1.METHOD_ACCESS_MAP) {
|
|
147
|
+
// Device-scope caps may be gated by a `device:N` scope. Resolve the
|
|
148
|
+
// raw input once so the matcher can read `input.deviceId` without
|
|
149
|
+
// re-doing the Zod parse (tRPC caches the parsed input downstream).
|
|
150
|
+
// The `getDeviceAncestors` hook lets the matcher walk parent → child
|
|
151
|
+
// accessory inheritance (grant on Reolink also covers its siren / PIR).
|
|
152
|
+
const rawInput = await getRawInput();
|
|
153
|
+
const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.getDeviceAncestors);
|
|
154
|
+
if (!result.ok) {
|
|
155
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
|
156
|
+
}
|
|
144
157
|
}
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
//
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
158
|
+
// A non-admin who is allowed to READ a device is not thereby allowed to
|
|
159
|
+
// read the credential that opens it. `deviceManager.getDevice` &co. are
|
|
160
|
+
// `auth: 'protected'`, so a read-only `category:system` grant reaches
|
|
161
|
+
// them — and the raw row carries `config.password` in plaintext for
|
|
162
|
+
// every camera in the deployment. The value is withheld HERE, at the
|
|
163
|
+
// transport boundary, so in-process callers (`ctx.api`, the pipeline
|
|
164
|
+
// orchestrator, the drivers that must actually connect) are unaffected.
|
|
165
|
+
const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
|
|
166
|
+
const out = await next({ ctx: { ...ctx, user: ctx.user } });
|
|
167
|
+
if (redactConfigSecrets && out.ok) {
|
|
168
|
+
return { ...out, data: redactConfigSecrets(out.data) };
|
|
154
169
|
}
|
|
155
|
-
return
|
|
170
|
+
return out;
|
|
156
171
|
});
|
|
157
172
|
/**
|
|
158
173
|
* Destructive-ops gate. Adds an explicit admin check on top of
|
|
@@ -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
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
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.
|
|
3
|
+
"version": "1.2.80",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -33,19 +33,19 @@
|
|
|
33
33
|
]
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@camstack/addon-admin-ui": "1.2.
|
|
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.
|
|
42
|
-
"@camstack/addon-pipeline-orchestrator": "1.2.
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.52",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.33",
|
|
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.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.67",
|
|
47
|
+
"@camstack/types": "1.2.51",
|
|
48
|
+
"@camstack/ui-library": "1.2.36",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|