@camstack/server 1.2.79 → 1.2.81
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/oauth2/oauth2-routes.js +90 -6
- package/dist/api/trpc/cap-router-runtime.js +34 -0
- package/dist/api/trpc/generated-cap-routers.js +9 -0
- 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/dist/main.js +4 -0
- package/package.json +7 -7
|
@@ -7,6 +7,43 @@ exports.registerOauth2Routes = registerOauth2Routes;
|
|
|
7
7
|
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
8
8
|
const consent_page_js_1 = require("./consent-page.js");
|
|
9
9
|
const private_host_js_1 = require("./private-host.js");
|
|
10
|
+
/** Longest caller-supplied `integration` value echoed back in an error. */
|
|
11
|
+
const MAX_ECHOED_INTEGRATION_LEN = 100;
|
|
12
|
+
/** Render the received `integration` for a human, bounded. */
|
|
13
|
+
function describeReceivedIntegration(raw) {
|
|
14
|
+
if (raw === undefined)
|
|
15
|
+
return null;
|
|
16
|
+
const joined = typeof raw === 'string' ? raw : raw.join(',');
|
|
17
|
+
if (joined === '')
|
|
18
|
+
return null;
|
|
19
|
+
return joined.length > MAX_ECHOED_INTEGRATION_LEN
|
|
20
|
+
? `${joined.slice(0, MAX_ECHOED_INTEGRATION_LEN)}…`
|
|
21
|
+
: joined;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Resolve the `integration` parameter to a single id.
|
|
25
|
+
*
|
|
26
|
+
* A repeat carrying ONE distinct value is collapsed rather than refused:
|
|
27
|
+
* every layer that parses the query — proxy, framework, app — necessarily
|
|
28
|
+
* agrees on the value, so there is no parameter-pollution ambiguity to
|
|
29
|
+
* protect against, and refusing would strand a client whose request is
|
|
30
|
+
* merely redundant. A repeat whose values DISAGREE is exactly that
|
|
31
|
+
* ambiguity, and is refused.
|
|
32
|
+
*/
|
|
33
|
+
function resolveIntegrationParam(raw) {
|
|
34
|
+
if (raw === undefined)
|
|
35
|
+
return { ok: false, reason: 'missing' };
|
|
36
|
+
if (typeof raw === 'string') {
|
|
37
|
+
return raw === '' ? { ok: false, reason: 'missing' } : { ok: true, value: raw };
|
|
38
|
+
}
|
|
39
|
+
const distinct = [...new Set(raw.filter((v) => v !== ''))];
|
|
40
|
+
const [first] = distinct;
|
|
41
|
+
if (first === undefined)
|
|
42
|
+
return { ok: false, reason: 'missing' };
|
|
43
|
+
if (distinct.length > 1)
|
|
44
|
+
return { ok: false, reason: 'conflicting-repeat' };
|
|
45
|
+
return { ok: true, value: first };
|
|
46
|
+
}
|
|
10
47
|
/** Validate the inbound authorize query. `client_id` is intentionally
|
|
11
48
|
* NOT checked — that pair is verified only at the Lambda boundary, and a
|
|
12
49
|
* PUBLIC client (`requiresPkce`) has no secret to check it against at all;
|
|
@@ -14,9 +51,24 @@ const private_host_js_1 = require("./private-host.js");
|
|
|
14
51
|
function validateAuthorizeQuery(q, knownIntegrations) {
|
|
15
52
|
if (q.response_type !== 'code')
|
|
16
53
|
return { ok: false, status: 400, error: 'unsupported_response_type' };
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
54
|
+
// Three distinct client faults used to collapse into one opaque string.
|
|
55
|
+
// Alexa linking was down for a day because the message named neither what
|
|
56
|
+
// arrived nor what would have been accepted.
|
|
57
|
+
const detail = {
|
|
58
|
+
received: describeReceivedIntegration(q.integration),
|
|
59
|
+
known_integrations: [...knownIntegrations.keys()],
|
|
60
|
+
};
|
|
61
|
+
const param = resolveIntegrationParam(q.integration);
|
|
62
|
+
if (!param.ok) {
|
|
63
|
+
const error = param.reason === 'missing'
|
|
64
|
+
? 'invalid_request — integration parameter missing'
|
|
65
|
+
: 'invalid_request — integration parameter repeated with conflicting values';
|
|
66
|
+
return { ok: false, status: 400, error, detail };
|
|
67
|
+
}
|
|
68
|
+
const policy = knownIntegrations.get(param.value);
|
|
69
|
+
if (!policy) {
|
|
70
|
+
return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
|
|
71
|
+
}
|
|
20
72
|
if (!q.redirect_uri)
|
|
21
73
|
return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
|
|
22
74
|
if (!q.state)
|
|
@@ -32,7 +84,7 @@ function validateAuthorizeQuery(q, knownIntegrations) {
|
|
|
32
84
|
}
|
|
33
85
|
return {
|
|
34
86
|
ok: true,
|
|
35
|
-
integration:
|
|
87
|
+
integration: param.value,
|
|
36
88
|
redirectUri: q.redirect_uri,
|
|
37
89
|
state: q.state,
|
|
38
90
|
codeChallenge: challenge,
|
|
@@ -76,6 +128,20 @@ function summariseScopes(scopes) {
|
|
|
76
128
|
}
|
|
77
129
|
return scopes.map((s) => s.type).join(', ') || 'no permissions';
|
|
78
130
|
}
|
|
131
|
+
/** A refused `/authorize`. Logged at `warn` and echoed to the caller: both
|
|
132
|
+
* halves name the value that ARRIVED and the ids that would have worked, so
|
|
133
|
+
* neither the operator nor the log reader has to guess which of them is
|
|
134
|
+
* wrong. */
|
|
135
|
+
function refuseAuthorize(reply, logger, method, refusal) {
|
|
136
|
+
logger.warn(`oauth2 authorize refused: ${refusal.error}`, {
|
|
137
|
+
meta: {
|
|
138
|
+
method,
|
|
139
|
+
received: refusal.detail?.received ?? null,
|
|
140
|
+
knownIntegrations: refusal.detail?.known_integrations ?? [],
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
|
|
144
|
+
}
|
|
79
145
|
/** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
|
|
80
146
|
async function buildIntegrationMap(registry) {
|
|
81
147
|
const entries = registry.getCollectionEntries('oauth-integration');
|
|
@@ -137,10 +203,19 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
137
203
|
const query = request.query;
|
|
138
204
|
const v = validateAuthorizeQuery(query, descriptorMap);
|
|
139
205
|
if (!v.ok) {
|
|
140
|
-
return reply.
|
|
206
|
+
return refuseAuthorize(reply, deps.logger, 'GET', v);
|
|
141
207
|
}
|
|
142
208
|
const descriptor = descriptorMap.get(v.integration);
|
|
143
209
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
210
|
+
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
211
|
+
meta: {
|
|
212
|
+
method: 'GET',
|
|
213
|
+
integration: v.integration,
|
|
214
|
+
redirectUri: v.redirectUri,
|
|
215
|
+
allowedPrefixes: descriptor.allowedRedirectPrefixes,
|
|
216
|
+
allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
|
|
217
|
+
},
|
|
218
|
+
});
|
|
144
219
|
return reply
|
|
145
220
|
.status(400)
|
|
146
221
|
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
|
|
@@ -223,10 +298,19 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
223
298
|
// and every one of them is attacker-editable.
|
|
224
299
|
const v = validateAuthorizeQuery(formQuery, descriptorMap);
|
|
225
300
|
if (!v.ok) {
|
|
226
|
-
return reply.
|
|
301
|
+
return refuseAuthorize(reply, deps.logger, 'POST', v);
|
|
227
302
|
}
|
|
228
303
|
const descriptor = descriptorMap.get(v.integration);
|
|
229
304
|
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
305
|
+
deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
|
|
306
|
+
meta: {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
integration: v.integration,
|
|
309
|
+
redirectUri: v.redirectUri,
|
|
310
|
+
allowedPrefixes: descriptor.allowedRedirectPrefixes,
|
|
311
|
+
allowedPrivateHostPaths: descriptor.allowedPrivateHostPaths ?? [],
|
|
312
|
+
},
|
|
313
|
+
});
|
|
230
314
|
return reply
|
|
231
315
|
.status(400)
|
|
232
316
|
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
|
|
@@ -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({
|
|
@@ -7794,6 +7794,15 @@ function createCapRouter_snapshot(getProvider, createRemoteProxy) {
|
|
|
7794
7794
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7795
7795
|
return p.getSnapshotOverview(methodInput);
|
|
7796
7796
|
}),
|
|
7797
|
+
getSnapshotLinks: trpc_middleware_js_1.protectedProcedure
|
|
7798
|
+
.input(types_98.snapshotCapability.methods.getSnapshotLinks.input.loose())
|
|
7799
|
+
.output(types_98.snapshotCapability.methods.getSnapshotLinks.output)
|
|
7800
|
+
.query(async ({ input, ctx }) => {
|
|
7801
|
+
const { nodeId, ...methodInput } = input;
|
|
7802
|
+
const p = resolveProvider('snapshot', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
7803
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
7804
|
+
return p.getSnapshotLinks(methodInput);
|
|
7805
|
+
}),
|
|
7797
7806
|
});
|
|
7798
7807
|
}
|
|
7799
7808
|
function createCapRouter_ssoBridge(getProvider, createRemoteProxy) {
|
|
@@ -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/dist/main.js
CHANGED
|
@@ -958,6 +958,10 @@ async function bootstrap() {
|
|
|
958
958
|
getRegistry: () => capabilityRegistry,
|
|
959
959
|
verifyToken: (t) => authService.verifyToken(t),
|
|
960
960
|
publicHubUrl: () => process.env.CAMSTACK_PUBLIC_ORIGIN ?? `https://localhost:${port}`,
|
|
961
|
+
// Every /authorize refusal is a dropped account-linking attempt. Without
|
|
962
|
+
// this scope the drop is invisible in Loki and the only signal left is an
|
|
963
|
+
// operator noticing that linking stopped working.
|
|
964
|
+
logger: app.get(logging_service_1.LoggingService).createLogger('oauth2'),
|
|
961
965
|
});
|
|
962
966
|
console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*');
|
|
963
967
|
// Attach tRPC WebSocket handler using noServer mode to avoid
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.81",
|
|
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.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.52",
|
|
42
42
|
"@camstack/addon-pipeline-orchestrator": "1.2.33",
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.56",
|
|
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.68",
|
|
47
|
+
"@camstack/types": "1.2.52",
|
|
48
|
+
"@camstack/ui-library": "1.2.37",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|