@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.
@@ -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
- if (!q.integration || !knownIntegrations.has(q.integration))
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
- return { ok: true, integration: q.integration, redirectUri: q.redirect_uri, state: q.state };
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
- /** True if `redirectUri` starts with one of the integration's allowed prefixes. */
23
- function isRedirectUriAllowed(redirectUri, allowedPrefixes) {
24
- return allowedPrefixes.some((p) => redirectUri.startsWith(p));
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
- const knownSet = new Set(descriptorMap.keys());
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 { descriptorMap, knownSet } = await buildIntegrationMap(registry);
136
+ const descriptorMap = await buildIntegrationMap(registry);
92
137
  const query = request.query;
93
- const v = validateAuthorizeQuery(query, knownSet);
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 { descriptorMap, knownSet } = await buildIntegrationMap(registry);
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
- const v = validateAuthorizeQuery(formQuery, knownSet);
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. For `getStatus`, the
65
- // device may still have a SYNTHESIZED status from device-links
66
- // (battery/consumables wired from sibling sensors). Consult the
67
- // resolver with a null base (the synthesize path) before failing
68
- // it returns null when the device has no links for this cap,
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
- const result = await fn.call(provider, input);
93
- // Device-property-wiring overlay (read-time): only `getStatus`, and only
94
- // when the device has links for this cap (resolveLinkedStatus returns
95
- // null otherwise base result untouched). One in-process singleton hop.
96
- if (prop === 'getStatus') {
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 system_1 = require("@camstack/system");
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. Scoped to the addon directory so no other
3102
- // addon's (or the hub's own) modules are evicted, and best-effort: a
3103
- // cache that cannot be walked leaves the previous behaviour rather than
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. Discriminated on `kind` so future
73
- * share surfaces (single-camera view, recording clip, …) extend the union
74
- * without touching verification plumbing.
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.literal('grid-view'),
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.76",
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",
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.47",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.30",
43
- "@camstack/addon-post-analysis": "1.2.52",
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.62",
47
- "@camstack/types": "1.2.46",
48
- "@camstack/ui-library": "1.2.34",
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",