@camstack/server 1.2.76 → 1.2.78
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/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 +9 -9
- package/dist/api/trpc/share-view-access.js +142 -0
- package/dist/api/trpc/trpc.middleware.js +16 -2
- package/dist/core/addon/addon-registry.service.js +4 -34
- package/dist/core/addon/require-cache.js +68 -0
- package/dist/core/auth/share-token.service.js +17 -4
- package/package.json +8 -8
|
@@ -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)
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.SHARE_SCOPE_PROJECTED_METHODS = void 0;
|
|
3
4
|
exports.checkShareViewAccess = checkShareViewAccess;
|
|
4
5
|
exports.projectListAllForShareScope = projectListAllForShareScope;
|
|
6
|
+
exports.projectShareScopeRows = projectShareScopeRows;
|
|
5
7
|
exports.liveEventInShareScope = liveEventInShareScope;
|
|
6
8
|
/**
|
|
7
9
|
* Methods callable WITHOUT a per-device check — their input carries no
|
|
@@ -37,7 +39,95 @@ function extractDeviceId(input) {
|
|
|
37
39
|
const candidate = Reflect.get(input, 'deviceId');
|
|
38
40
|
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
|
|
39
41
|
}
|
|
42
|
+
/**
|
|
43
|
+
* The EVENTS embed's real call graph (`events-client.ts` in the embed):
|
|
44
|
+
* • `auth.me` — SDK boot/auth probe
|
|
45
|
+
* • `deviceManager.listAll` — camera names + the "all
|
|
46
|
+
* cameras" resolution (already projected to the scope)
|
|
47
|
+
* • `pipelineAnalytics.listRecentTracks` — the feed itself
|
|
48
|
+
* • `pipelineAnalytics.listEventKindsBatch` — the class→macro taxonomy
|
|
49
|
+
* • `pipelineAnalytics.searchObjectEvents` — CLIP text search
|
|
50
|
+
*
|
|
51
|
+
* Intentionally NOT here: everything the grid link gets (WebRTC signalling,
|
|
52
|
+
* snapshots, TURN, camera metrics) — a shared events page is not a camera
|
|
53
|
+
* wall — and every mutating analytics method (`deleteTracks`, `setTrackFlags`,
|
|
54
|
+
* the retrain surface, the export planes).
|
|
55
|
+
*/
|
|
56
|
+
const EVENTS_VIEW_OPEN_METHODS = new Set(['auth.me', 'deviceManager.listAll']);
|
|
57
|
+
/**
|
|
58
|
+
* Methods whose input carries a `deviceIds` ARRAY. The rule is INTERSECTION,
|
|
59
|
+
* and the outcome is FILTER-AND-SERVE: a request that names at least one
|
|
60
|
+
* in-scope device passes, and the RESPONSE is stripped to the scope by
|
|
61
|
+
* {@link projectShareScopeRows}.
|
|
62
|
+
*
|
|
63
|
+
* Filter rather than reject, because a multi-camera link whose owner later
|
|
64
|
+
* narrowed the share must keep working for the cameras it still covers —
|
|
65
|
+
* rejecting the whole page would break the link on a change that only removed
|
|
66
|
+
* one camera. A FULLY disjoint request is still denied: there is nothing to
|
|
67
|
+
* serve, and answering `[]` would read as "these cameras had no events" rather
|
|
68
|
+
* than "you cannot see these cameras".
|
|
69
|
+
*/
|
|
70
|
+
const EVENTS_VIEW_DEVICE_ARRAY_METHODS = new Set([
|
|
71
|
+
'pipelineAnalytics.listRecentTracks',
|
|
72
|
+
'pipelineAnalytics.listEventKindsBatch',
|
|
73
|
+
]);
|
|
74
|
+
/**
|
|
75
|
+
* Methods whose input carries an OPTIONAL single `deviceId`, where omitting it
|
|
76
|
+
* means EVERY camera in the deployment.
|
|
77
|
+
*
|
|
78
|
+
* A present id must be in scope. An ABSENT id is allowed — and that is safe
|
|
79
|
+
* only because the response is filtered server-side
|
|
80
|
+
* ({@link projectShareScopeRows}); the caller is never trusted to have scoped
|
|
81
|
+
* its own query. Rejecting the absent case instead would leave a multi-camera
|
|
82
|
+
* share link unable to search at all, and would not be one bit safer.
|
|
83
|
+
*/
|
|
84
|
+
const EVENTS_VIEW_OPTIONAL_DEVICE_METHODS = new Set([
|
|
85
|
+
'pipelineAnalytics.searchObjectEvents',
|
|
86
|
+
]);
|
|
87
|
+
/** Pull a `deviceIds` array off a raw tRPC input. Null when absent/malformed. */
|
|
88
|
+
function extractDeviceIds(input) {
|
|
89
|
+
if (input === null || typeof input !== 'object')
|
|
90
|
+
return null;
|
|
91
|
+
const candidate = Reflect.get(input, 'deviceIds');
|
|
92
|
+
if (!Array.isArray(candidate))
|
|
93
|
+
return null;
|
|
94
|
+
const ids = candidate.filter((x) => typeof x === 'number' && Number.isFinite(x));
|
|
95
|
+
return ids.length === candidate.length ? ids : null;
|
|
96
|
+
}
|
|
97
|
+
function checkEventsViewAccess(scope, path, input) {
|
|
98
|
+
if (EVENTS_VIEW_OPEN_METHODS.has(path)) {
|
|
99
|
+
return { ok: true };
|
|
100
|
+
}
|
|
101
|
+
if (EVENTS_VIEW_DEVICE_ARRAY_METHODS.has(path)) {
|
|
102
|
+
const requested = extractDeviceIds(input);
|
|
103
|
+
if (requested === null || requested.length === 0) {
|
|
104
|
+
return { ok: false, reason: `'${path}' requires a deviceIds array for share-view access` };
|
|
105
|
+
}
|
|
106
|
+
const allowed = new Set(scope.deviceIds);
|
|
107
|
+
if (!requested.some((id) => allowed.has(id))) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: `Devices ${requested.join(', ')} are outside this share link's scope`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { ok: true };
|
|
114
|
+
}
|
|
115
|
+
if (EVENTS_VIEW_OPTIONAL_DEVICE_METHODS.has(path)) {
|
|
116
|
+
const deviceId = extractDeviceId(input);
|
|
117
|
+
// Absent = every camera; the RESPONSE filter is what scopes it.
|
|
118
|
+
if (deviceId === null)
|
|
119
|
+
return { ok: true };
|
|
120
|
+
if (!scope.deviceIds.includes(deviceId)) {
|
|
121
|
+
return { ok: false, reason: `Device ${deviceId} is outside this share link's scope` };
|
|
122
|
+
}
|
|
123
|
+
return { ok: true };
|
|
124
|
+
}
|
|
125
|
+
return { ok: false, reason: `'${path}' is not available to share-view tokens` };
|
|
126
|
+
}
|
|
40
127
|
function checkShareViewAccess(scope, path, input) {
|
|
128
|
+
if (scope.kind === 'events-view') {
|
|
129
|
+
return checkEventsViewAccess(scope, path, input);
|
|
130
|
+
}
|
|
41
131
|
if (scope.kind !== 'grid-view') {
|
|
42
132
|
return { ok: false, reason: `Unknown share scope kind '${String(scope.kind)}'` };
|
|
43
133
|
}
|
|
@@ -88,6 +178,58 @@ function projectListAllForShareScope(data, scope) {
|
|
|
88
178
|
}
|
|
89
179
|
return rows;
|
|
90
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Strip every row a share token may not see, by `deviceId`.
|
|
183
|
+
*
|
|
184
|
+
* This is the half that makes the events surface safe, and it is not
|
|
185
|
+
* defence-in-depth — it is the ACTUAL enforcement for two of the three
|
|
186
|
+
* methods:
|
|
187
|
+
*
|
|
188
|
+
* - `searchObjectEvents` with no `deviceId` searches the whole deployment.
|
|
189
|
+
* The call is allowed (see the enumeration) precisely because the answer is
|
|
190
|
+
* cut here; trusting the caller to scope its own query would be trusting a
|
|
191
|
+
* URL a third party is holding.
|
|
192
|
+
* - `listRecentTracks` / `listEventKindsBatch` pass the intersection check, so
|
|
193
|
+
* a partially in-scope request reaches the provider — and comes back with
|
|
194
|
+
* rows the link may not see. Those are removed here.
|
|
195
|
+
*
|
|
196
|
+
* Two payload shapes, because the methods answer differently: a bare array of
|
|
197
|
+
* rows (`searchObjectEvents`, `listEventKindsBatch`) and a PAGED envelope
|
|
198
|
+
* (`listRecentTracks` → `{tracks, nextCursor}`). The cursor is preserved —
|
|
199
|
+
* dropping it would silently end a shared page's infinite scroll at page one.
|
|
200
|
+
*
|
|
201
|
+
* A row with NO numeric `deviceId` is DROPPED, never kept: "it probably
|
|
202
|
+
* belongs to a device in scope" is not a security argument.
|
|
203
|
+
*
|
|
204
|
+
* Anything that is not a row payload passes through untouched (the route
|
|
205
|
+
* errored upstream, and rewriting an error into `[]` would hide it).
|
|
206
|
+
*/
|
|
207
|
+
function projectShareScopeRows(data, scope) {
|
|
208
|
+
const allowed = new Set(scope.deviceIds);
|
|
209
|
+
const keep = (row) => {
|
|
210
|
+
if (row === null || typeof row !== 'object')
|
|
211
|
+
return false;
|
|
212
|
+
const id = Reflect.get(row, 'deviceId');
|
|
213
|
+
return typeof id === 'number' && allowed.has(id);
|
|
214
|
+
};
|
|
215
|
+
if (Array.isArray(data))
|
|
216
|
+
return data.filter(keep);
|
|
217
|
+
if (data !== null && typeof data === 'object') {
|
|
218
|
+
const tracks = Reflect.get(data, 'tracks');
|
|
219
|
+
if (Array.isArray(tracks)) {
|
|
220
|
+
return { ...data, tracks: tracks.filter(keep) };
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return data;
|
|
224
|
+
}
|
|
225
|
+
/** Paths whose RESPONSE must be cut to the share scope before it leaves the
|
|
226
|
+
* server. Kept beside the enumeration so a method added to one and forgotten
|
|
227
|
+
* in the other is visible in a single file. */
|
|
228
|
+
exports.SHARE_SCOPE_PROJECTED_METHODS = new Set([
|
|
229
|
+
'pipelineAnalytics.listRecentTracks',
|
|
230
|
+
'pipelineAnalytics.listEventKindsBatch',
|
|
231
|
+
'pipelineAnalytics.searchObjectEvents',
|
|
232
|
+
]);
|
|
91
233
|
/**
|
|
92
234
|
* Whether a live event belongs to a device inside the share scope.
|
|
93
235
|
* Matches the device identity two ways (fail closed — no match, no push):
|
|
@@ -6,12 +6,12 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
6
6
|
exports.agentProcedure = exports.adminProcedure = exports.protectedProcedure = exports.createCallerFactory = exports.publicProcedure = exports.trpcRouter = void 0;
|
|
7
7
|
exports.iterableSubscription = iterableSubscription;
|
|
8
8
|
exports.iterableInterval = iterableInterval;
|
|
9
|
+
const system_1 = require("@camstack/system");
|
|
9
10
|
const server_1 = require("@trpc/server");
|
|
10
11
|
const superjson_1 = __importDefault(require("superjson"));
|
|
11
|
-
const
|
|
12
|
+
const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
|
|
12
13
|
const scope_access_js_1 = require("./scope-access.js");
|
|
13
14
|
const share_view_access_js_1 = require("./share-view-access.js");
|
|
14
|
-
const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
|
|
15
15
|
const t = server_1.initTRPC.context().create({
|
|
16
16
|
transformer: superjson_1.default,
|
|
17
17
|
errorFormatter: cap_route_error_formatter_js_1.formatTrpcError,
|
|
@@ -114,6 +114,20 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
|
|
|
114
114
|
}
|
|
115
115
|
return out;
|
|
116
116
|
}
|
|
117
|
+
// The events surface answers with ROWS carrying a `deviceId`, and two of
|
|
118
|
+
// its three methods can legitimately reach devices outside the scope: a
|
|
119
|
+
// partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
|
|
120
|
+
// no `deviceId` at all (which means EVERY camera). The enumeration lets
|
|
121
|
+
// those calls through — this is where the answer is cut. Without it the
|
|
122
|
+
// allowlist would be a doorman who checks the ticket and then hands over
|
|
123
|
+
// the whole building.
|
|
124
|
+
if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
|
|
125
|
+
const out = await next({ ctx: { ...ctx, user: ctx.user } });
|
|
126
|
+
if (out.ok) {
|
|
127
|
+
return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
117
131
|
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
118
132
|
}
|
|
119
133
|
// Spread+reassign of `user` narrows downstream ctx from `User | null`
|
|
@@ -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");
|
|
@@ -3098,11 +3099,9 @@ class AddonRegistryService {
|
|
|
3098
3099
|
// and the action 404'd.
|
|
3099
3100
|
//
|
|
3100
3101
|
// Dropping the addon's own modules from the require cache is what
|
|
3101
|
-
// actually re-reads the bundle
|
|
3102
|
-
//
|
|
3103
|
-
|
|
3104
|
-
// failing the registration.
|
|
3105
|
-
purgeRequireCacheUnder(path.dirname(entryPath));
|
|
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));
|
|
3106
3105
|
// Kept for a genuinely ESM addon entry, where it IS the mechanism.
|
|
3107
3106
|
const cacheBustedUrl = `${(0, node_url_1.pathToFileURL)(entryPath).href}?t=${Date.now()}`;
|
|
3108
3107
|
// A plain `await import()` here is downleveled by tsc (the backend builds
|
|
@@ -3157,32 +3156,3 @@ class AddonRegistryService {
|
|
|
3157
3156
|
}
|
|
3158
3157
|
}
|
|
3159
3158
|
exports.AddonRegistryService = AddonRegistryService;
|
|
3160
|
-
/**
|
|
3161
|
-
* Drop every `require`-cached module that lives under `dir`.
|
|
3162
|
-
*
|
|
3163
|
-
* Node's ESM loader serves a CommonJS module from the require cache, keyed by
|
|
3164
|
-
* the resolved filename — the `?t=` query an ESM import uses to force a re-read
|
|
3165
|
-
* is stripped before that lookup and therefore does nothing. Since every addon
|
|
3166
|
-
* bundle here is CJS, evicting the addon's own entries is what makes a
|
|
3167
|
-
* hot-updated bundle actually load.
|
|
3168
|
-
*
|
|
3169
|
-
* Scoped to one directory on purpose: a blanket cache clear would evict the
|
|
3170
|
-
* hub's own modules and every other addon's, turning a catalog refresh into a
|
|
3171
|
-
* process-wide reload. Best-effort — a cache that cannot be walked leaves the
|
|
3172
|
-
* previous (stale) behaviour rather than failing the caller.
|
|
3173
|
-
*/
|
|
3174
|
-
function purgeRequireCacheUnder(dir) {
|
|
3175
|
-
try {
|
|
3176
|
-
const cache = require.cache;
|
|
3177
|
-
if (cache === undefined)
|
|
3178
|
-
return;
|
|
3179
|
-
const prefix = dir.endsWith(path.sep) ? dir : `${dir}${path.sep}`;
|
|
3180
|
-
for (const key of Object.keys(cache)) {
|
|
3181
|
-
if (key.startsWith(prefix))
|
|
3182
|
-
delete cache[key];
|
|
3183
|
-
}
|
|
3184
|
-
}
|
|
3185
|
-
catch {
|
|
3186
|
-
// Non-CJS host, or a frozen cache. The import below still runs.
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
@@ -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
|
+
}
|
|
@@ -69,12 +69,25 @@ exports.SHARE_TOKEN_TTL_MAX_SEC = 30 * 24 * 60 * 60; // 30 days
|
|
|
69
69
|
exports.SHARE_TOKEN_TTL_DEFAULT_SEC = 7 * 24 * 60 * 60; // 7 days
|
|
70
70
|
exports.SHARE_TOKEN_MAX_DEVICES = 64;
|
|
71
71
|
/**
|
|
72
|
-
* What a share token is allowed to see.
|
|
73
|
-
*
|
|
74
|
-
*
|
|
72
|
+
* What a share token is allowed to see. Keyed on `kind` so future share
|
|
73
|
+
* surfaces (single-camera view, recording clip, …) extend the set without
|
|
74
|
+
* touching verification plumbing.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* `grid-view` — the live camera wall (WebRTC + snapshots).
|
|
78
|
+
* `events-view` — the events embed (track feed, kind taxonomy, CLIP search).
|
|
79
|
+
*
|
|
80
|
+
* They are SEPARATE kinds, not one kind with a bigger surface. Adding the
|
|
81
|
+
* track methods to `grid-view` would retroactively widen every share link an
|
|
82
|
+
* operator has already handed out: a link minted to show a camera wall would
|
|
83
|
+
* silently start serving that camera's event history. A token keeps the
|
|
84
|
+
* perimeter it was minted with, and a new perimeter needs a new mint.
|
|
85
|
+
*
|
|
86
|
+
* Neither kind is a superset of the other — an events link cannot open a
|
|
87
|
+
* WebRTC session, and a grid link cannot read a track.
|
|
75
88
|
*/
|
|
76
89
|
exports.ShareTokenScopeSchema = zod_1.z.object({
|
|
77
|
-
kind: zod_1.z.
|
|
90
|
+
kind: zod_1.z.enum(['grid-view', 'events-view']),
|
|
78
91
|
deviceIds: zod_1.z.array(zod_1.z.number().int().nonnegative()).min(1).max(exports.SHARE_TOKEN_MAX_DEVICES),
|
|
79
92
|
});
|
|
80
93
|
/** Persisted record — never leaves the server with `tokenHash` attached. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.78",
|
|
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.38",
|
|
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.49",
|
|
42
|
+
"@camstack/addon-pipeline-orchestrator": "1.2.32",
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.54",
|
|
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.64",
|
|
47
|
+
"@camstack/types": "1.2.48",
|
|
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",
|