@camstack/server 1.2.75 → 1.2.77
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/agent/builtins-seed.js +12 -7
- package/dist/api/core/cap-providers.js +36 -4
- package/dist/api/oauth2/consent-page.js +3 -1
- package/dist/api/oauth2/oauth2-routes.js +97 -15
- package/dist/api/oauth2/private-host.js +80 -0
- package/dist/api/trpc/cap-mount-helpers.js +10 -31
- package/dist/api/trpc/generated-cap-routers.js +27 -9
- package/dist/core/addon/addon-registry.service.js +22 -2
- package/dist/core/addon/require-cache.js +68 -0
- package/dist/launcher.js +24 -1
- package/package.json +8 -8
|
@@ -72,13 +72,18 @@ function seedBuiltinsFromClosure(addonsDir, log = console.log, resolveClosurePkg
|
|
|
72
72
|
log(`[Agent] builtins seed: closure copy at ${closureRoot} has no dist — cannot self-heal`);
|
|
73
73
|
return 'unavailable';
|
|
74
74
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
// The WHOLE package, node_modules included. A deps-free copy was tried
|
|
76
|
+
// first and failed live (2026-08-08): the builtins' dist requires native
|
|
77
|
+
// deps (better-sqlite3) that resolve relative to the COPY, so the addon
|
|
78
|
+
// scan logged "Failed to scan" and the guard aborted with "no addon
|
|
79
|
+
// under /data/addons" — while the log right above it said the seed had
|
|
80
|
+
// run. Nested `node_modules` are safe: the scan reads one level of
|
|
81
|
+
// `addonsDir/@scope/*`, never inside a package. (The double-registration
|
|
82
|
+
// the deps-free copy was guarding against was actually the per-capability
|
|
83
|
+
// init loop — fixed by `planInfraBoot` — not nested discovery.)
|
|
84
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
85
|
+
fs.cpSync(closureRoot, target, { recursive: true });
|
|
81
86
|
log(`[Agent] builtins seed: @camstack/system was missing under ${addonsDir} — ` +
|
|
82
|
-
`seeded
|
|
87
|
+
`seeded the full package from the running closure (${closureRoot})`);
|
|
83
88
|
return 'seeded';
|
|
84
89
|
}
|
|
@@ -1000,6 +1000,41 @@ function ensureCustomActionAuth(ctx, level) {
|
|
|
1000
1000
|
return;
|
|
1001
1001
|
}
|
|
1002
1002
|
}
|
|
1003
|
+
/** How many neighbouring names a NOT_FOUND message is willing to spell out. */
|
|
1004
|
+
const UNRESOLVED_HINT_LIMIT = 40;
|
|
1005
|
+
/**
|
|
1006
|
+
* Explain a `(addonId, action)` that did not resolve, by NAMING what does.
|
|
1007
|
+
*
|
|
1008
|
+
* `addons.custom` keys on the MANIFEST addon id (`camstack.addons[].id`), which
|
|
1009
|
+
* is routinely not the npm package name — `@camstack/addon-post-analysis` ships
|
|
1010
|
+
* `pipeline-analytics` and `embedding-encoder`, and neither is the package. A
|
|
1011
|
+
* bare "has no custom action X" is then indistinguishable from "the addon is
|
|
1012
|
+
* broken", "the deploy did not land" or "the catalog never registered", and the
|
|
1013
|
+
* caller has no way to tell which. That ambiguity cost a session on 2026-08-08:
|
|
1014
|
+
* a working require-cache fix (d0bf802f1) was investigated as the culprit while
|
|
1015
|
+
* the action was reachable the whole time under its real id.
|
|
1016
|
+
*
|
|
1017
|
+
* So the two failures are separated and each names its neighbourhood — the ids
|
|
1018
|
+
* that have actions, or the actions that addon has.
|
|
1019
|
+
*/
|
|
1020
|
+
function describeUnresolved(registry, input) {
|
|
1021
|
+
const actions = registry.listActions(input.addonId);
|
|
1022
|
+
if (actions.length > 0) {
|
|
1023
|
+
return `addon '${input.addonId}' has no custom action '${input.action}' — known actions: ${formatNames(actions)}`;
|
|
1024
|
+
}
|
|
1025
|
+
return (`no addon '${input.addonId}' registers custom actions ` +
|
|
1026
|
+
`(expected a manifest addon id, not the npm package name) — ` +
|
|
1027
|
+
`addons with custom actions: ${formatNames(registry.listAddons())}`);
|
|
1028
|
+
}
|
|
1029
|
+
/** Sorted, comma-joined, bounded — a hint must never become a dump. */
|
|
1030
|
+
function formatNames(names) {
|
|
1031
|
+
if (names.length === 0)
|
|
1032
|
+
return '(none)';
|
|
1033
|
+
const sorted = [...names].sort();
|
|
1034
|
+
const shown = sorted.slice(0, UNRESOLVED_HINT_LIMIT);
|
|
1035
|
+
const rest = sorted.length - shown.length;
|
|
1036
|
+
return rest > 0 ? `${shown.join(', ')} (+${rest} more)` : shown.join(', ');
|
|
1037
|
+
}
|
|
1003
1038
|
/**
|
|
1004
1039
|
* Resolve + dispatch a single addon custom action. This is the LIVE dispatch
|
|
1005
1040
|
* used by the `addons.custom` cap-provider handler below — factored out so it
|
|
@@ -1020,10 +1055,7 @@ function ensureCustomActionAuth(ctx, level) {
|
|
|
1020
1055
|
async function dispatchCustomAction(registry, ctx, input) {
|
|
1021
1056
|
const entry = registry.resolve(input.addonId, input.action);
|
|
1022
1057
|
if (!entry) {
|
|
1023
|
-
throw new server_1.TRPCError({
|
|
1024
|
-
code: 'NOT_FOUND',
|
|
1025
|
-
message: `addon '${input.addonId}' has no custom action '${input.action}'`,
|
|
1026
|
-
});
|
|
1058
|
+
throw new server_1.TRPCError({ code: 'NOT_FOUND', message: describeUnresolved(registry, input) });
|
|
1027
1059
|
}
|
|
1028
1060
|
ensureCustomActionAuth(ctx, entry.spec.auth);
|
|
1029
1061
|
const parsedInput = entry.spec.input.parse(input.input);
|
|
@@ -23,7 +23,9 @@ function renderConsentPage(input) {
|
|
|
23
23
|
<div class="card">
|
|
24
24
|
<h1>Authorize ${escapeHtml(input.displayName)}</h1>
|
|
25
25
|
<p class="meta">Signed in as <strong>${escapeHtml(input.username)}</strong></p>
|
|
26
|
-
<p class="meta">This will grant: ${escapeHtml(input.scopeSummary)}</p
|
|
26
|
+
<p class="meta">This will grant: ${escapeHtml(input.scopeSummary)}</p>${input.redirectUri
|
|
27
|
+
? `\n <p class="meta">Sending the authorization to <code>${escapeHtml(input.redirectUri)}</code></p>`
|
|
28
|
+
: ''}
|
|
27
29
|
<form method="POST">
|
|
28
30
|
${hidden}
|
|
29
31
|
<button class="allow" name="consent" value="allow" type="submit">Allow</button>
|
|
@@ -4,24 +4,70 @@ exports.validateAuthorizeQuery = validateAuthorizeQuery;
|
|
|
4
4
|
exports.isRedirectUriAllowed = isRedirectUriAllowed;
|
|
5
5
|
exports.summariseScopes = summariseScopes;
|
|
6
6
|
exports.registerOauth2Routes = registerOauth2Routes;
|
|
7
|
-
const consent_page_js_1 = require("./consent-page.js");
|
|
8
7
|
const session_cookie_js_1 = require("../../auth/session-cookie.js");
|
|
8
|
+
const consent_page_js_1 = require("./consent-page.js");
|
|
9
|
+
const private_host_js_1 = require("./private-host.js");
|
|
9
10
|
/** Validate the inbound authorize query. `client_id` is intentionally
|
|
10
|
-
* NOT checked — that pair is verified only at the Lambda boundary
|
|
11
|
+
* NOT checked — that pair is verified only at the Lambda boundary, and a
|
|
12
|
+
* PUBLIC client (`requiresPkce`) has no secret to check it against at all;
|
|
13
|
+
* the S256 challenge is what binds the code to its requester instead. */
|
|
11
14
|
function validateAuthorizeQuery(q, knownIntegrations) {
|
|
12
15
|
if (q.response_type !== 'code')
|
|
13
16
|
return { ok: false, status: 400, error: 'unsupported_response_type' };
|
|
14
|
-
|
|
17
|
+
const policy = q.integration ? knownIntegrations.get(q.integration) : undefined;
|
|
18
|
+
if (!q.integration || !policy)
|
|
15
19
|
return { ok: false, status: 400, error: 'invalid_request — unknown integration' };
|
|
16
20
|
if (!q.redirect_uri)
|
|
17
21
|
return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
|
|
18
22
|
if (!q.state)
|
|
19
23
|
return { ok: false, status: 400, error: 'invalid_request — state required' };
|
|
20
|
-
|
|
24
|
+
const challenge = q.code_challenge ?? '';
|
|
25
|
+
if (challenge !== '' && q.code_challenge_method !== 'S256') {
|
|
26
|
+
// `plain` is accepted by the RFC and is worth nothing: the challenge IS
|
|
27
|
+
// the verifier, so an intercepted authorize request yields both.
|
|
28
|
+
return { ok: false, status: 400, error: 'invalid_request — code_challenge_method must be S256' };
|
|
29
|
+
}
|
|
30
|
+
if (policy.requiresPkce === true && challenge === '') {
|
|
31
|
+
return { ok: false, status: 400, error: 'invalid_request — code_challenge required (PKCE)' };
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
ok: true,
|
|
35
|
+
integration: q.integration,
|
|
36
|
+
redirectUri: q.redirect_uri,
|
|
37
|
+
state: q.state,
|
|
38
|
+
codeChallenge: challenge,
|
|
39
|
+
};
|
|
21
40
|
}
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
41
|
+
/**
|
|
42
|
+
* True if `redirectUri` is one this integration is allowed to receive a code at.
|
|
43
|
+
*
|
|
44
|
+
* Two independent branches:
|
|
45
|
+
* - prefix match against `allowedRedirectPrefixes` (Alexa's three fixed
|
|
46
|
+
* Amazon origins), and
|
|
47
|
+
* - exact-path match against `allowedPrivateHostPaths` when the host is
|
|
48
|
+
* private (a self-hosted client whose LAN address the hub cannot know).
|
|
49
|
+
*
|
|
50
|
+
* An unparsable URI is refused rather than falling through to the prefix test —
|
|
51
|
+
* `startsWith` on a string the browser will parse differently is exactly how an
|
|
52
|
+
* allow-list gets walked around.
|
|
53
|
+
*/
|
|
54
|
+
function isRedirectUriAllowed(redirectUri, allowedPrefixes, allowedPrivateHostPaths = []) {
|
|
55
|
+
if (allowedPrefixes.some((p) => redirectUri.startsWith(p)))
|
|
56
|
+
return true;
|
|
57
|
+
if (allowedPrivateHostPaths.length === 0)
|
|
58
|
+
return false;
|
|
59
|
+
let url;
|
|
60
|
+
try {
|
|
61
|
+
url = new URL(redirectUri);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
67
|
+
return false;
|
|
68
|
+
if (!(0, private_host_js_1.isPrivateHost)(url.hostname))
|
|
69
|
+
return false;
|
|
70
|
+
return allowedPrivateHostPaths.includes(url.pathname);
|
|
25
71
|
}
|
|
26
72
|
/** One-line human summary of a scope list for the consent screen. */
|
|
27
73
|
function summariseScopes(scopes) {
|
|
@@ -38,8 +84,7 @@ async function buildIntegrationMap(registry) {
|
|
|
38
84
|
const descriptor = await provider.getDescriptor();
|
|
39
85
|
descriptorMap.set(descriptor.integrationId, descriptor);
|
|
40
86
|
}
|
|
41
|
-
|
|
42
|
-
return { descriptorMap, knownSet };
|
|
87
|
+
return descriptorMap;
|
|
43
88
|
}
|
|
44
89
|
/** Parse an application/x-www-form-urlencoded body string into a plain object. */
|
|
45
90
|
function parseFormBody(raw) {
|
|
@@ -88,14 +133,14 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
88
133
|
if (!registry) {
|
|
89
134
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
90
135
|
}
|
|
91
|
-
const
|
|
136
|
+
const descriptorMap = await buildIntegrationMap(registry);
|
|
92
137
|
const query = request.query;
|
|
93
|
-
const v = validateAuthorizeQuery(query,
|
|
138
|
+
const v = validateAuthorizeQuery(query, descriptorMap);
|
|
94
139
|
if (!v.ok) {
|
|
95
140
|
return reply.status(v.status).send({ error: v.error });
|
|
96
141
|
}
|
|
97
142
|
const descriptor = descriptorMap.get(v.integration);
|
|
98
|
-
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
|
|
143
|
+
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
99
144
|
return reply
|
|
100
145
|
.status(400)
|
|
101
146
|
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
|
|
@@ -104,15 +149,43 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
104
149
|
displayName: descriptor.displayName,
|
|
105
150
|
username: tokenInfo.username ?? '',
|
|
106
151
|
scopeSummary: summariseScopes(descriptor.requestedScopes),
|
|
152
|
+
// Shown, not merely validated. A `redirect_uri` on a private host is
|
|
153
|
+
// admitted by pattern rather than by registration, so the operator is
|
|
154
|
+
// the last check on WHERE the code goes — and a consent screen that
|
|
155
|
+
// hides the destination is theatre.
|
|
156
|
+
redirectUri: v.redirectUri,
|
|
107
157
|
hidden: {
|
|
108
158
|
integration: v.integration,
|
|
109
159
|
redirect_uri: v.redirectUri,
|
|
110
160
|
state: v.state,
|
|
111
161
|
response_type: 'code',
|
|
162
|
+
...(v.codeChallenge !== ''
|
|
163
|
+
? { code_challenge: v.codeChallenge, code_challenge_method: 'S256' }
|
|
164
|
+
: {}),
|
|
112
165
|
},
|
|
113
166
|
});
|
|
114
167
|
return reply.type('text/html').send(html);
|
|
115
168
|
});
|
|
169
|
+
// ─── GET /api/oauth2/integrations ─────────────────────────────────────────
|
|
170
|
+
// Discovery. A client that has no token yet needs to know whether THIS hub
|
|
171
|
+
// knows it at all — a hub predating an integration must be distinguishable
|
|
172
|
+
// from one that is refusing it, or the client dead-ends on a 400 with no way
|
|
173
|
+
// to fall back. Unauthenticated (so is /token) and it discloses only which
|
|
174
|
+
// integrations are installed.
|
|
175
|
+
fastify.get('/api/oauth2/integrations', async (_request, reply) => {
|
|
176
|
+
const registry = deps.getRegistry();
|
|
177
|
+
if (!registry) {
|
|
178
|
+
return reply.status(503).send({ error: 'service_unavailable' });
|
|
179
|
+
}
|
|
180
|
+
const descriptorMap = await buildIntegrationMap(registry);
|
|
181
|
+
return reply.send({
|
|
182
|
+
integrations: [...descriptorMap.values()].map((d) => ({
|
|
183
|
+
integrationId: d.integrationId,
|
|
184
|
+
displayName: d.displayName,
|
|
185
|
+
requiresPkce: d.requiresPkce === true,
|
|
186
|
+
})),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
116
189
|
// ─── POST /api/oauth2/authorize ───────────────────────────────────────────
|
|
117
190
|
fastify.post('/api/oauth2/authorize', async (request, reply) => {
|
|
118
191
|
const cookie = request.cookies[session_cookie_js_1.SESSION_COOKIE];
|
|
@@ -136,20 +209,24 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
136
209
|
if (!registry) {
|
|
137
210
|
return reply.status(503).send({ error: 'service_unavailable' });
|
|
138
211
|
}
|
|
139
|
-
const
|
|
212
|
+
const descriptorMap = await buildIntegrationMap(registry);
|
|
140
213
|
const body = request.body;
|
|
141
214
|
const formQuery = {
|
|
142
215
|
response_type: body.response_type,
|
|
143
216
|
integration: body.integration,
|
|
144
217
|
redirect_uri: body.redirect_uri,
|
|
145
218
|
state: body.state,
|
|
219
|
+
code_challenge: body.code_challenge,
|
|
220
|
+
code_challenge_method: body.code_challenge_method,
|
|
146
221
|
};
|
|
147
|
-
|
|
222
|
+
// Re-validated, not trusted: the hidden fields came back from a browser
|
|
223
|
+
// and every one of them is attacker-editable.
|
|
224
|
+
const v = validateAuthorizeQuery(formQuery, descriptorMap);
|
|
148
225
|
if (!v.ok) {
|
|
149
226
|
return reply.status(v.status).send({ error: v.error });
|
|
150
227
|
}
|
|
151
228
|
const descriptor = descriptorMap.get(v.integration);
|
|
152
|
-
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
|
|
229
|
+
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
|
|
153
230
|
return reply
|
|
154
231
|
.status(400)
|
|
155
232
|
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' });
|
|
@@ -172,6 +249,7 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
172
249
|
// claim the cloud Lambda routes back on is the reachable public URL, not
|
|
173
250
|
// the hub-global fallback (which defaults to localhost in dev).
|
|
174
251
|
hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
|
|
252
|
+
...(v.codeChallenge !== '' ? { codeChallenge: v.codeChallenge } : {}),
|
|
175
253
|
});
|
|
176
254
|
return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`);
|
|
177
255
|
});
|
|
@@ -195,6 +273,10 @@ function registerOauth2Routes(fastify, deps) {
|
|
|
195
273
|
tokenResult = await userMgmt.oauthExchangeCode({
|
|
196
274
|
code: body.code,
|
|
197
275
|
redirectUri: body.redirect_uri,
|
|
276
|
+
// Optional here and mandatory in the grant: the requirement is carried
|
|
277
|
+
// by the CODE (it embeds the challenge), not by this endpoint, so it
|
|
278
|
+
// cannot be dropped by a client that simply omits the parameter.
|
|
279
|
+
...(body.code_verifier ? { codeVerifier: body.code_verifier } : {}),
|
|
198
280
|
});
|
|
199
281
|
}
|
|
200
282
|
else if (body.grant_type === 'refresh_token') {
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Is this hostname unreachable from the public internet?
|
|
4
|
+
*
|
|
5
|
+
* Used by `/api/oauth2/authorize` to admit a `redirect_uri` whose host the hub
|
|
6
|
+
* cannot know in advance — a self-hosted Home Assistant at
|
|
7
|
+
* `http://192.168.1.40:8123/auth/external/callback`. Pairing "private host" with
|
|
8
|
+
* "exact path" is what keeps that from being a wildcard prefix: a public host
|
|
9
|
+
* never satisfies it, so the worst an attacker can aim a code at is a machine
|
|
10
|
+
* they already need LAN presence to read.
|
|
11
|
+
*
|
|
12
|
+
* Literal inspection only — deliberately NO DNS resolution. A resolver turns an
|
|
13
|
+
* authorize request into an outbound lookup an attacker controls (SSRF-shaped,
|
|
14
|
+
* and a DNS-rebind race on top: the address that answers at validation time is
|
|
15
|
+
* not the one the browser will use). Names are admitted only by suffix, from a
|
|
16
|
+
* closed list of suffixes that cannot be registered on the public internet.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.isPrivateHost = isPrivateHost;
|
|
20
|
+
/** Suffixes that are, by definition, not publicly routable names. */
|
|
21
|
+
const PRIVATE_NAME_SUFFIXES = ['.local', '.internal', '.home.arpa', '.ts.net'];
|
|
22
|
+
/** Names that are exactly the loopback host. */
|
|
23
|
+
const LOOPBACK_NAMES = ['localhost', 'localhost.localdomain'];
|
|
24
|
+
function isPrivateIpv4(host) {
|
|
25
|
+
const parts = host.split('.');
|
|
26
|
+
if (parts.length !== 4)
|
|
27
|
+
return false;
|
|
28
|
+
const octets = [];
|
|
29
|
+
for (const part of parts) {
|
|
30
|
+
if (!/^\d{1,3}$/.test(part))
|
|
31
|
+
return false;
|
|
32
|
+
const n = Number(part);
|
|
33
|
+
if (n > 255)
|
|
34
|
+
return false;
|
|
35
|
+
octets.push(n);
|
|
36
|
+
}
|
|
37
|
+
const [a, b] = octets;
|
|
38
|
+
if (a === 127)
|
|
39
|
+
return true; // loopback
|
|
40
|
+
if (a === 10)
|
|
41
|
+
return true; // RFC1918
|
|
42
|
+
if (a === 192 && b === 168)
|
|
43
|
+
return true; // RFC1918
|
|
44
|
+
if (a === 172 && b >= 16 && b <= 31)
|
|
45
|
+
return true; // RFC1918
|
|
46
|
+
if (a === 169 && b === 254)
|
|
47
|
+
return true; // link-local
|
|
48
|
+
if (a === 100 && b >= 64 && b <= 127)
|
|
49
|
+
return true; // CGNAT / Tailscale 100.64/10
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
function isPrivateIpv6(host) {
|
|
53
|
+
// URL.hostname keeps IPv6 literals in brackets.
|
|
54
|
+
const inner = host.startsWith('[') && host.endsWith(']') ? host.slice(1, -1) : host;
|
|
55
|
+
if (!inner.includes(':'))
|
|
56
|
+
return false;
|
|
57
|
+
const lower = inner.toLowerCase();
|
|
58
|
+
if (lower === '::1' || lower === '::')
|
|
59
|
+
return true; // loopback / unspecified
|
|
60
|
+
if (/^f[cd][0-9a-f]{2}:/.test(lower))
|
|
61
|
+
return true; // fc00::/7 ULA
|
|
62
|
+
if (/^fe[89ab][0-9a-f]:/.test(lower))
|
|
63
|
+
return true; // fe80::/10 link-local
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* True iff `host` (a `URL.hostname`, already lowercased by the URL parser) is
|
|
68
|
+
* private by literal inspection. Never resolves.
|
|
69
|
+
*/
|
|
70
|
+
function isPrivateHost(host) {
|
|
71
|
+
if (host === '')
|
|
72
|
+
return false;
|
|
73
|
+
if (LOOPBACK_NAMES.includes(host))
|
|
74
|
+
return true;
|
|
75
|
+
if (isPrivateIpv4(host))
|
|
76
|
+
return true;
|
|
77
|
+
if (isPrivateIpv6(host))
|
|
78
|
+
return true;
|
|
79
|
+
return PRIVATE_NAME_SUFFIXES.some((suffix) => host.endsWith(suffix));
|
|
80
|
+
}
|
|
@@ -61,22 +61,11 @@ function requireDeviceScoped(registry, capName) {
|
|
|
61
61
|
provider = registry.getProviderForDevice(capName, deviceId);
|
|
62
62
|
}
|
|
63
63
|
if (!provider) {
|
|
64
|
-
// No native and no device-bound provider.
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
69
|
-
// preserving today's error.
|
|
70
|
-
if (prop === 'getStatus') {
|
|
71
|
-
const deviceManager = registry.getSingleton('device-manager');
|
|
72
|
-
const synthesized = await deviceManager?.resolveLinkedStatus?.({
|
|
73
|
-
deviceId,
|
|
74
|
-
cap: String(capName),
|
|
75
|
-
baseStatus: null,
|
|
76
|
-
});
|
|
77
|
-
if (synthesized != null)
|
|
78
|
-
return synthesized;
|
|
79
|
-
}
|
|
64
|
+
// No native and no device-bound provider. There used to be one more
|
|
65
|
+
// branch here: SYNTHESIZE a `getStatus` out of device-links, so a
|
|
66
|
+
// cap with no provider at all still answered. Wiring was deleted on
|
|
67
|
+
// 2026-08-08 — a composed device's caps are native to it, so there
|
|
68
|
+
// is nothing to conjure and the honest answer is the error.
|
|
80
69
|
throw new server_1.TRPCError({
|
|
81
70
|
code: 'PRECONDITION_FAILED',
|
|
82
71
|
message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
|
|
@@ -89,21 +78,11 @@ function requireDeviceScoped(registry, capName) {
|
|
|
89
78
|
message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
|
|
90
79
|
});
|
|
91
80
|
}
|
|
92
|
-
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
const deviceManager = registry.getSingleton('device-manager');
|
|
98
|
-
const overlaid = await deviceManager?.resolveLinkedStatus?.({
|
|
99
|
-
deviceId,
|
|
100
|
-
cap: String(capName),
|
|
101
|
-
baseStatus: result,
|
|
102
|
-
});
|
|
103
|
-
if (overlaid != null)
|
|
104
|
-
return overlaid;
|
|
105
|
-
}
|
|
106
|
-
return result;
|
|
81
|
+
// The provider's answer, verbatim. `getStatus` used to be run through
|
|
82
|
+
// a read-time device-link overlay here — a second authority writing a
|
|
83
|
+
// field the provider owns, which is the field-level form of the sin
|
|
84
|
+
// D62 bans at feature level. Deleted with wiring on 2026-08-08.
|
|
85
|
+
return await fn.call(provider, input);
|
|
107
86
|
};
|
|
108
87
|
},
|
|
109
88
|
});
|
|
@@ -1829,6 +1829,15 @@ function createCapRouter_coreBlocks(getProvider, createRemoteProxy) {
|
|
|
1829
1829
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
1830
1830
|
return p.setEnabled(methodInput);
|
|
1831
1831
|
}),
|
|
1832
|
+
restart: trpc_middleware_js_1.adminProcedure
|
|
1833
|
+
.input(types_31.coreBlocksCapability.methods.restart.input.loose())
|
|
1834
|
+
.output(types_31.coreBlocksCapability.methods.restart.output)
|
|
1835
|
+
.mutation(async ({ input, ctx }) => {
|
|
1836
|
+
const { nodeId, ...methodInput } = input;
|
|
1837
|
+
const p = resolveProvider('core-blocks', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
1838
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
1839
|
+
return p.restart(methodInput);
|
|
1840
|
+
}),
|
|
1832
1841
|
compile: trpc_middleware_js_1.adminProcedure
|
|
1833
1842
|
.input(types_31.coreBlocksCapability.methods.compile.input.loose())
|
|
1834
1843
|
.output(types_31.coreBlocksCapability.methods.compile.output)
|
|
@@ -2573,15 +2582,6 @@ function createCapRouter_deviceManager(getProvider, createRemoteProxy) {
|
|
|
2573
2582
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
2574
2583
|
return p.setChildLayout(methodInput);
|
|
2575
2584
|
}),
|
|
2576
|
-
setDeviceLinks: trpc_middleware_js_1.adminProcedure
|
|
2577
|
-
.input(types_40.deviceManagerCapability.methods.setDeviceLinks.input.loose())
|
|
2578
|
-
.output(types_40.deviceManagerCapability.methods.setDeviceLinks.output)
|
|
2579
|
-
.mutation(async ({ input, ctx }) => {
|
|
2580
|
-
const { nodeId, ...methodInput } = input;
|
|
2581
|
-
const p = resolveProvider('device-manager', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
2582
|
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
2583
|
-
return p.setDeviceLinks(methodInput);
|
|
2584
|
-
}),
|
|
2585
2585
|
setDisplay: trpc_middleware_js_1.adminProcedure
|
|
2586
2586
|
.input(types_40.deviceManagerCapability.methods.setDisplay.input.loose())
|
|
2587
2587
|
.output(types_40.deviceManagerCapability.methods.setDisplay.output)
|
|
@@ -8258,6 +8258,24 @@ function createCapRouter_streamBroker(getProvider, createRemoteProxy) {
|
|
|
8258
8258
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8259
8259
|
return p.renderPreBufferClip(methodInput);
|
|
8260
8260
|
}),
|
|
8261
|
+
produceEventMedia: trpc_middleware_js_1.adminProcedure
|
|
8262
|
+
.input(types_103.streamBrokerCapability.methods.produceEventMedia.input.loose())
|
|
8263
|
+
.output(types_103.streamBrokerCapability.methods.produceEventMedia.output)
|
|
8264
|
+
.mutation(async ({ input, ctx }) => {
|
|
8265
|
+
const { nodeId, ...methodInput } = input;
|
|
8266
|
+
const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
8267
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8268
|
+
return p.produceEventMedia(methodInput);
|
|
8269
|
+
}),
|
|
8270
|
+
fetchEventMedia: trpc_middleware_js_1.adminProcedure
|
|
8271
|
+
.input(types_103.streamBrokerCapability.methods.fetchEventMedia.input.loose())
|
|
8272
|
+
.output(types_103.streamBrokerCapability.methods.fetchEventMedia.output)
|
|
8273
|
+
.mutation(async ({ input, ctx }) => {
|
|
8274
|
+
const { nodeId, ...methodInput } = input;
|
|
8275
|
+
const p = resolveProvider('stream-broker', nodeId, () => getProvider(ctx), createRemoteProxy);
|
|
8276
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
|
|
8277
|
+
return p.fetchEventMedia(methodInput);
|
|
8278
|
+
}),
|
|
8261
8279
|
listAllCameraStreams: trpc_middleware_js_1.protectedProcedure
|
|
8262
8280
|
.input(zod_1.z.object({ nodeId: zod_1.z.string().optional() }).optional())
|
|
8263
8281
|
.output(types_103.streamBrokerCapability.methods.listAllCameraStreams.output)
|
|
@@ -53,6 +53,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
53
53
|
const path = __importStar(require("node:path"));
|
|
54
54
|
const fs = __importStar(require("node:fs"));
|
|
55
55
|
const node_url_1 = require("node:url");
|
|
56
|
+
const require_cache_js_1 = require("./require-cache.js");
|
|
56
57
|
const addon_settings_provider_js_1 = require("./addon-settings-provider.js");
|
|
57
58
|
const addon_call_gateway_js_1 = require("./addon-call-gateway.js");
|
|
58
59
|
const system_6 = require("@camstack/system");
|
|
@@ -3081,8 +3082,27 @@ class AddonRegistryService {
|
|
|
3081
3082
|
return;
|
|
3082
3083
|
let catalog;
|
|
3083
3084
|
try {
|
|
3084
|
-
//
|
|
3085
|
-
//
|
|
3085
|
+
// ── The query string does NOT bust a CommonJS bundle ──────────────
|
|
3086
|
+
//
|
|
3087
|
+
// Every addon here builds to CJS, and Node's ESM loader serves a CJS
|
|
3088
|
+
// module out of the REQUIRE cache, which is keyed by the resolved
|
|
3089
|
+
// filename with the query stripped. So `?t=<now>` below busts nothing
|
|
3090
|
+
// for them: this function re-registered the catalog captured at hub boot
|
|
3091
|
+
// on every restart, forever.
|
|
3092
|
+
//
|
|
3093
|
+
// The consequence was invisible and total: an EXISTING action kept
|
|
3094
|
+
// working, so nothing looked broken, while a NEWLY added one could never
|
|
3095
|
+
// become reachable without restarting the whole hub process — which
|
|
3096
|
+
// quietly falsifies the reason bridge actions exist ("no codegen, no
|
|
3097
|
+
// republish, no train"). Found 2026-08-08, deploying `nc.injectTestEvent`:
|
|
3098
|
+
// the deploy succeeded, the restart logged "custom actions registered",
|
|
3099
|
+
// and the action 404'd.
|
|
3100
|
+
//
|
|
3101
|
+
// Dropping the addon's own modules from the require cache is what
|
|
3102
|
+
// actually re-reads the bundle — see `require-cache.ts` for the scoping
|
|
3103
|
+
// and the real-path match Node's cache keys demand.
|
|
3104
|
+
(0, require_cache_js_1.purgeRequireCacheUnder)(path.dirname(entryPath));
|
|
3105
|
+
// Kept for a genuinely ESM addon entry, where it IS the mechanism.
|
|
3086
3106
|
const cacheBustedUrl = `${(0, node_url_1.pathToFileURL)(entryPath).href}?t=${Date.now()}`;
|
|
3087
3107
|
// A plain `await import()` here is downleveled by tsc (the backend builds
|
|
3088
3108
|
// with `module: CommonJS`) into a `require()`-based shim. `require()` then
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.purgeRequireCacheUnder = purgeRequireCacheUnder;
|
|
7
|
+
/**
|
|
8
|
+
* require-cache — evicting an addon's own modules so a hot-updated bundle
|
|
9
|
+
* is actually re-read.
|
|
10
|
+
*
|
|
11
|
+
* Lives in its own file because the behaviour it encodes is a Node semantic
|
|
12
|
+
* that cannot be observed from inside the test runner: the runner transforms
|
|
13
|
+
* modules itself, so only a real `node` process re-executing a real CommonJS
|
|
14
|
+
* file proves anything. `require-cache.spec.ts` spawns one.
|
|
15
|
+
*/
|
|
16
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
17
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
18
|
+
/**
|
|
19
|
+
* Drop every `require`-cached module that lives under `dir`.
|
|
20
|
+
*
|
|
21
|
+
* Node's ESM loader serves a CommonJS module from the require cache, keyed by
|
|
22
|
+
* the resolved filename — the `?t=` query an ESM import uses to force a re-read
|
|
23
|
+
* is stripped before that lookup and therefore does nothing. Since every addon
|
|
24
|
+
* bundle here is CJS, evicting the addon's own entries is what makes a
|
|
25
|
+
* hot-updated bundle actually load.
|
|
26
|
+
*
|
|
27
|
+
* Matched against the REAL path as well as the given one. Node keys the cache
|
|
28
|
+
* by the resolved-and-symlink-followed filename, while an addon directory is
|
|
29
|
+
* assembled from the configured `dataDir` and never realpath'd — so a single
|
|
30
|
+
* symlink anywhere above it (a `dataDir` under macOS's `/tmp`, a relocated data
|
|
31
|
+
* root) makes a prefix match on the given path miss every entry and this
|
|
32
|
+
* function silently do nothing. Which is precisely the failure it exists to
|
|
33
|
+
* fix, wearing its clothes: the catalog would look refreshed and be stale.
|
|
34
|
+
*
|
|
35
|
+
* Scoped to one directory on purpose: a blanket cache clear would evict the
|
|
36
|
+
* hub's own modules and every other addon's, turning a catalog refresh into a
|
|
37
|
+
* process-wide reload. Best-effort — a cache that cannot be walked leaves the
|
|
38
|
+
* previous (stale) behaviour rather than failing the caller.
|
|
39
|
+
*/
|
|
40
|
+
function purgeRequireCacheUnder(dir) {
|
|
41
|
+
try {
|
|
42
|
+
const cache = require.cache;
|
|
43
|
+
if (cache === undefined)
|
|
44
|
+
return;
|
|
45
|
+
const prefixes = candidatePrefixes(dir);
|
|
46
|
+
for (const key of Object.keys(cache)) {
|
|
47
|
+
if (prefixes.some((prefix) => key.startsWith(prefix)))
|
|
48
|
+
delete cache[key];
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Non-CJS host, or a frozen cache. The caller's import still runs.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** The directory as given and as Node sees it, both as trailing-separator prefixes. */
|
|
56
|
+
function candidatePrefixes(dir) {
|
|
57
|
+
const withSep = (d) => (d.endsWith(node_path_1.default.sep) ? d : `${d}${node_path_1.default.sep}`);
|
|
58
|
+
const given = withSep(dir);
|
|
59
|
+
let real = given;
|
|
60
|
+
try {
|
|
61
|
+
real = withSep(node_fs_1.default.realpathSync(dir));
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
// Directory gone between the caller's existsSync and here — `given` alone
|
|
65
|
+
// is still a correct (if narrower) match.
|
|
66
|
+
}
|
|
67
|
+
return real === given ? [given] : [given, real];
|
|
68
|
+
}
|
package/dist/launcher.js
CHANGED
|
@@ -278,7 +278,30 @@ async function launch() {
|
|
|
278
278
|
// @camstack/system is imported DYNAMICALLY here — AFTER the active framework
|
|
279
279
|
// dir + NODE_PATH are resolved above — so the correct framework copy is what
|
|
280
280
|
// gets loaded. Never import it at module top.
|
|
281
|
-
const { AddonInstaller, bootstrapSchema, detectWorkspacePackagesDir } = await Promise.resolve().then(() => __importStar(require('@camstack/system')));
|
|
281
|
+
const { AddonInstaller, bootstrapSchema, detectWorkspacePackagesDir, quarantineAddonResidue } = await Promise.resolve().then(() => __importStar(require('@camstack/system')));
|
|
282
|
+
// Residue quarantine — FIRST thing that touches the addon root, before the
|
|
283
|
+
// bootstrap seed writes into it and long before any loader scans it.
|
|
284
|
+
//
|
|
285
|
+
// A directory under `addons/@camstack` whose name is not the package it
|
|
286
|
+
// declares is not an archive: the loader, the agent's boot scan and the
|
|
287
|
+
// install manifest all key on the `package.json` inside, so a rename
|
|
288
|
+
// INSTALLS. On 2026-08-07/08 that shipped `@camstack/system 1.2.3` as the
|
|
289
|
+
// reported version on a node running a 1.2.61 closure, and kept a 17-day-old
|
|
290
|
+
// `better_sqlite3.node` mapped into the live process. Moved, never deleted —
|
|
291
|
+
// the cleanup that deleted one of these gutted an agent the same evening.
|
|
292
|
+
//
|
|
293
|
+
// Guarded with a typeof check for the same reason `reconcileManifest` is: a
|
|
294
|
+
// system-only framework update can swap in a build that predates this.
|
|
295
|
+
if (typeof quarantineAddonResidue === 'function') {
|
|
296
|
+
const residue = quarantineAddonResidue(addonsDir, (msg) => console.log(msg));
|
|
297
|
+
if (residue.quarantined.length > 0 || residue.failed.length > 0) {
|
|
298
|
+
console.log(`[launcher] Addon residue — quarantined ${residue.quarantined.length}, ` +
|
|
299
|
+
`failed ${residue.failed.length}`);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else {
|
|
303
|
+
console.warn('[launcher] quarantineAddonResidue unavailable — skipping residue quarantine');
|
|
304
|
+
}
|
|
282
305
|
// Install source resolution:
|
|
283
306
|
// 1. CAMSTACK_BUNDLED_ADDONS_DIR — set by Electron-packaged builds
|
|
284
307
|
// to <resourcesPath>/addons. Pre-built addons ship with the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.77",
|
|
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.37",
|
|
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.48",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.31",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.53",
|
|
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.63",
|
|
47
|
+
"@camstack/types": "1.2.47",
|
|
48
|
+
"@camstack/ui-library": "1.2.35",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|