@camstack/server 1.2.83 → 1.2.84

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.
@@ -1,5 +1,7 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.redactAuthorizeQuery = redactAuthorizeQuery;
4
+ exports.describeAuthorizeCall = describeAuthorizeCall;
3
5
  exports.validateAuthorizeQuery = validateAuthorizeQuery;
4
6
  exports.isRedirectUriAllowed = isRedirectUriAllowed;
5
7
  exports.summariseScopes = summariseScopes;
@@ -7,6 +9,64 @@ exports.registerOauth2Routes = registerOauth2Routes;
7
9
  const session_cookie_js_1 = require("../../auth/session-cookie.js");
8
10
  const consent_page_js_1 = require("./consent-page.js");
9
11
  const private_host_js_1 = require("./private-host.js");
12
+ /**
13
+ * Query keys whose VALUE may be logged.
14
+ *
15
+ * Deliberately an allow-list, not a deny-list: a parameter this file has never
16
+ * heard of is redacted by default, so adding one cannot silently start
17
+ * publishing a secret. `state` is a per-attempt nonce the client binds its
18
+ * session to, `code_challenge` is public by design and `code_verifier`/`code`
19
+ * are outright credentials — all four are redacted under the one rule, because
20
+ * the diagnosis needs the KEY NAMES, not the values.
21
+ */
22
+ const VALUE_SAFE_QUERY_KEYS = [
23
+ 'response_type',
24
+ 'integration',
25
+ 'code_challenge_method',
26
+ 'client_id',
27
+ ];
28
+ const REDACTED = '<redacted>';
29
+ /** Bound on the logged query and user-agent. A refusal must not be able to
30
+ * write an unbounded caller-controlled string into the log. */
31
+ const MAX_LOGGED_FIELD_LEN = 400;
32
+ function truncate(value, max) {
33
+ return value.length > max ? `${value.slice(0, max)}…` : value;
34
+ }
35
+ /** Rebuild a query string with every non-allow-listed value replaced. */
36
+ function redactAuthorizeQuery(rawQuery) {
37
+ if (rawQuery === '')
38
+ return '';
39
+ const parts = [];
40
+ for (const [key, value] of new URLSearchParams(rawQuery).entries()) {
41
+ parts.push(VALUE_SAFE_QUERY_KEYS.includes(key) ? `${key}=${value}` : `${key}=${REDACTED}`);
42
+ }
43
+ return truncate(parts.join('&'), MAX_LOGGED_FIELD_LEN);
44
+ }
45
+ /** Strip the query from a `Referer`, keeping origin + path. Returns `null` for
46
+ * an absent or unparsable header rather than logging it raw. */
47
+ function describeReferer(raw) {
48
+ if (raw === undefined || raw === '')
49
+ return null;
50
+ try {
51
+ const url = new URL(raw);
52
+ return `${url.origin}${url.pathname}`;
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ }
58
+ /** Everything the log is allowed to know about an inbound authorize call. */
59
+ function describeAuthorizeCall(url, headers, hasSessionCookie) {
60
+ const q = url.indexOf('?');
61
+ const ua = headers['user-agent'];
62
+ return {
63
+ path: q === -1 ? url : url.slice(0, q),
64
+ query: q === -1 ? '' : redactAuthorizeQuery(url.slice(q + 1)),
65
+ referer: describeReferer(headers.referer),
66
+ userAgent: ua === undefined || ua === '' ? null : truncate(ua, MAX_LOGGED_FIELD_LEN),
67
+ hasSessionCookie,
68
+ };
69
+ }
10
70
  /** Longest caller-supplied `integration` value echoed back in an error. */
11
71
  const MAX_ECHOED_INTEGRATION_LEN = 100;
12
72
  /**
@@ -65,15 +125,17 @@ function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
65
125
  // arrived nor what would have been accepted.
66
126
  const detail = {
67
127
  received: describeReceivedIntegration(q.integration),
68
- known_integrations: [...knownIntegrations.keys()],
69
- ...(pendingAddons.length > 0 ? { pending_addons: pendingAddons } : {}),
128
+ };
129
+ const diagnostics = {
130
+ knownIntegrations: [...knownIntegrations.keys()],
131
+ pendingAddons,
70
132
  };
71
133
  const param = resolveIntegrationParam(q.integration);
72
134
  if (!param.ok) {
73
135
  const error = param.reason === 'missing'
74
136
  ? 'invalid_request — integration parameter missing'
75
137
  : 'invalid_request — integration parameter repeated with conflicting values';
76
- return { ok: false, status: 400, error, detail };
138
+ return { ok: false, status: 400, error, detail, diagnostics };
77
139
  }
78
140
  const policy = knownIntegrations.get(param.value);
79
141
  if (!policy) {
@@ -89,10 +151,17 @@ function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
89
151
  status: 503,
90
152
  error: 'temporarily_unavailable — integration not registered yet',
91
153
  detail,
154
+ diagnostics,
92
155
  retryAfterSec: INTEGRATION_PENDING_RETRY_AFTER_SEC,
93
156
  };
94
157
  }
95
- return { ok: false, status: 400, error: 'invalid_request — unknown integration', detail };
158
+ return {
159
+ ok: false,
160
+ status: 400,
161
+ error: 'invalid_request — unknown integration',
162
+ detail,
163
+ diagnostics,
164
+ };
96
165
  }
97
166
  if (!q.redirect_uri)
98
167
  return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
@@ -153,19 +222,23 @@ function summariseScopes(scopes) {
153
222
  }
154
223
  return scopes.map((s) => s.type).join(', ') || 'no permissions';
155
224
  }
156
- /** A refused `/authorize`. Logged at `warn` and echoed to the caller: both
157
- * halves name the value that ARRIVED and the ids that would have worked, so
158
- * neither the operator nor the log reader has to guess which of them is
159
- * wrong. */
160
- function refuseAuthorize(reply, logger, method, refusal) {
225
+ /**
226
+ * A refused `/authorize`. The two audiences get different things, on purpose.
227
+ *
228
+ * The LOG names the value that arrived next to the ids that would have worked
229
+ * and the addons still owed — that pairing is what turned "unknown
230
+ * integration" from a week-long outage into a diagnosis, and it must not be
231
+ * weakened. The RESPONSE carries only the caller's own echoed input; it tells
232
+ * a client nothing about what else this hub has installed.
233
+ */
234
+ function refuseAuthorize(reply, logger, method, call, refusal) {
161
235
  logger.warn(`oauth2 authorize refused: ${refusal.error}`, {
162
236
  meta: {
163
237
  method,
238
+ ...call,
164
239
  received: refusal.detail?.received ?? null,
165
- knownIntegrations: refusal.detail?.known_integrations ?? [],
166
- // Without this, a refusal during the boot window is byte-identical in
167
- // Loki to one against a hub that has been up for a week.
168
- pendingAddons: refusal.detail?.pending_addons ?? [],
240
+ knownIntegrations: refusal.diagnostics?.knownIntegrations ?? [],
241
+ pendingAddons: refusal.diagnostics?.pendingAddons ?? [],
169
242
  },
170
243
  });
171
244
  if (refusal.retryAfterSec !== undefined) {
@@ -173,6 +246,22 @@ function refuseAuthorize(reply, logger, method, refusal) {
173
246
  }
174
247
  return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
175
248
  }
249
+ /**
250
+ * An authorize call turned away for want of a usable session.
251
+ *
252
+ * It is not a fault, and it is still logged: the browser bounce DEFERS the
253
+ * request through `/login?next=…`, and whatever the replay drops is dropped
254
+ * silently. That is exactly the branch CLAUDE.md requires to speak — with only
255
+ * refusals on the record, "arrived carrying `integration`, came back without
256
+ * it" is invisible and every refusal reads as a first arrival.
257
+ */
258
+ function refuseUnauthenticated(request, reply, logger, call, reason) {
259
+ const redirected = (0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept);
260
+ logger.info(redirected ? 'oauth2 authorize bounced to login' : 'oauth2 authorize refused: unauthenticated', { meta: { method: request.method, reason, redirected, ...call } });
261
+ if (redirected)
262
+ return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
263
+ return reply.status(401).send({ error: 'unauthorized' });
264
+ }
176
265
  /** Read integrationId → descriptor from every registered `oauth-integration`
177
266
  * provider, plus the addons that still owe one. */
178
267
  async function readIntegrationRegistry(registry) {
@@ -214,21 +303,16 @@ function registerOauth2Routes(fastify, deps) {
214
303
  // per-path special-casing anywhere.
215
304
  fastify.get('/api/oauth2/authorize', async (request, reply) => {
216
305
  const cookie = request.cookies[session_cookie_js_1.SESSION_COOKIE];
306
+ const call = describeAuthorizeCall(request.url, request.headers, Boolean(cookie));
217
307
  if (!cookie) {
218
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
219
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
220
- }
221
- return reply.status(401).send({ error: 'unauthorized' });
308
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
222
309
  }
223
310
  let tokenInfo;
224
311
  try {
225
312
  tokenInfo = deps.verifyToken(cookie);
226
313
  }
227
314
  catch {
228
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
229
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
230
- }
231
- return reply.status(401).send({ error: 'unauthorized' });
315
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
232
316
  }
233
317
  const registry = deps.getRegistry();
234
318
  if (!registry) {
@@ -238,13 +322,14 @@ function registerOauth2Routes(fastify, deps) {
238
322
  const query = request.query;
239
323
  const v = validateAuthorizeQuery(query, descriptors, pendingAddons);
240
324
  if (!v.ok) {
241
- return refuseAuthorize(reply, deps.logger, 'GET', v);
325
+ return refuseAuthorize(reply, deps.logger, 'GET', call, v);
242
326
  }
243
327
  const descriptor = descriptors.get(v.integration);
244
328
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
245
329
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
246
330
  meta: {
247
331
  method: 'GET',
332
+ ...call,
248
333
  integration: v.integration,
249
334
  redirectUri: v.redirectUri,
250
335
  allowedPrefixes: descriptor.allowedRedirectPrefixes,
@@ -312,21 +397,18 @@ function registerOauth2Routes(fastify, deps) {
312
397
  // ─── POST /api/oauth2/authorize ───────────────────────────────────────────
313
398
  fastify.post('/api/oauth2/authorize', async (request, reply) => {
314
399
  const cookie = request.cookies[session_cookie_js_1.SESSION_COOKIE];
400
+ // `query` is empty here by nature, not by loss: the consent POST carries its
401
+ // parameters as form fields. `method: POST` in the same line says so.
402
+ const call = describeAuthorizeCall(request.url, request.headers, Boolean(cookie));
315
403
  if (!cookie) {
316
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
317
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
318
- }
319
- return reply.status(401).send({ error: 'unauthorized' });
404
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
320
405
  }
321
406
  let tokenInfo;
322
407
  try {
323
408
  tokenInfo = deps.verifyToken(cookie);
324
409
  }
325
410
  catch {
326
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
327
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
328
- }
329
- return reply.status(401).send({ error: 'unauthorized' });
411
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
330
412
  }
331
413
  const registry = deps.getRegistry();
332
414
  if (!registry) {
@@ -346,13 +428,14 @@ function registerOauth2Routes(fastify, deps) {
346
428
  // and every one of them is attacker-editable.
347
429
  const v = validateAuthorizeQuery(formQuery, descriptors, pendingAddons);
348
430
  if (!v.ok) {
349
- return refuseAuthorize(reply, deps.logger, 'POST', v);
431
+ return refuseAuthorize(reply, deps.logger, 'POST', call, v);
350
432
  }
351
433
  const descriptor = descriptors.get(v.integration);
352
434
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
353
435
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
354
436
  meta: {
355
437
  method: 'POST',
438
+ ...call,
356
439
  integration: v.integration,
357
440
  redirectUri: v.redirectUri,
358
441
  allowedPrefixes: descriptor.allowedRedirectPrefixes,
@@ -382,6 +465,12 @@ function registerOauth2Routes(fastify, deps) {
382
465
  // the hub-global fallback (which defaults to localhost in dev).
383
466
  hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
384
467
  ...(v.codeChallenge !== '' ? { codeChallenge: v.codeChallenge } : {}),
468
+ // The integration declares its own refresh lifetime; omitting the field
469
+ // keeps the 30-day default. Baked in HERE, at consent, so the link the
470
+ // operator approved carries the lifetime they approved.
471
+ ...(descriptor.refreshTokenTtlSec !== undefined
472
+ ? { refreshTtlSec: descriptor.refreshTokenTtlSec }
473
+ : {}),
385
474
  });
386
475
  return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`);
387
476
  });
@@ -22,6 +22,7 @@
22
22
  Object.defineProperty(exports, "__esModule", { value: true });
23
23
  exports.COLLECTION_ARRAY_METHODS = void 0;
24
24
  exports.aggregateCollectionCall = aggregateCollectionCall;
25
+ exports.pinnedCollectionCall = pinnedCollectionCall;
25
26
  exports.createCapRouterPrimitives = createCapRouterPrimitives;
26
27
  exports.createCapRouterServices = createCapRouterServices;
27
28
  exports.buildRuntimeCapRouters = buildRuntimeCapRouters;
@@ -119,6 +120,69 @@ async function aggregateCollectionCall(reg, capName, method, args) {
119
120
  const out = await fn(args);
120
121
  return Array.isArray(out) ? out : null;
121
122
  }
123
+ /**
124
+ * The `{ addonId }` PIN for a cap call arriving OUTSIDE the tRPC router — a
125
+ * forked addon's `ctx.api.<cap>.<method>` reaching the parent's
126
+ * `onUnownedCall`. The scalar twin of {@link aggregateCollectionCall}, and it
127
+ * exists for the same reason: the two paths answered differently and only the
128
+ * tRPC one was ever measured.
129
+ *
130
+ * `addonId` is not provider data on a system-scoped collection cap — the
131
+ * codegen INJECTS it as the `{nodeId?, addonId?}` selector on every method
132
+ * (`GetInputSchema` for `broker.getBrokerConfig` declares only `{ id }`), and
133
+ * `cap-router-builder.ts` routes the tRPC call by it. The child→parent path
134
+ * lifted only `nodeId` and `deviceId` out of a child's args, so the selector
135
+ * was DISCARDED IN SILENCE and the call landed on whichever provider registered
136
+ * first. Measured on the live hub 2026-08-09:
137
+ *
138
+ * hub, getBrokerConfig {id:'ha_001'} → null
139
+ * hub, getBrokerConfig {id:'ha_001', addonId:'provider-…'} → the config
140
+ * addon, getBrokerConfig {id:'ha_001', addonId:'provider-…'} → null
141
+ *
142
+ * The third row is the bug: from an addon the pinned call returned exactly the
143
+ * UNPINNED answer — Homematic's `null` for a broker it does not own, which the
144
+ * Home Assistant export could not tell from "no such broker". It could not open
145
+ * a link, ever, and every write-up of the pin had measured only the first two
146
+ * rows.
147
+ *
148
+ * Mirrors the tRPC branch exactly, including what it does NOT do:
149
+ * - ARRAY methods are never rerouted — their contract is the fan-out union
150
+ * ({@link aggregateCollectionCall}), and a pin that turns a provider's
151
+ * absence into a throw is what took `broker.list` from "fewer rows" to
152
+ * `BAD_REQUEST` and the operator from "one instance" to "no instance".
153
+ * - Only the EXPLICIT top-level selector routes. No nested recovery here:
154
+ * that heuristic reads a domain payload, and this path has no schema.
155
+ *
156
+ * A pin naming a non-provider REFUSES with the valid ids spelled out rather
157
+ * than degrading to another provider's answer — the whole point of pinning.
158
+ *
159
+ * Returns a `{ value }` box, never the bare result: `getBrokerConfig` answers
160
+ * `null` legitimately, so a bare `null` could not be told from "not handled".
161
+ */
162
+ async function pinnedCollectionCall(reg, capName, method, args) {
163
+ if (!reg)
164
+ return null;
165
+ if (reg.getDefinition(capName)?.mode !== 'collection')
166
+ return null;
167
+ if (exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method) === true)
168
+ return null;
169
+ if (args === null || typeof args !== 'object' || Array.isArray(args))
170
+ return null;
171
+ const addonId = args['addonId'];
172
+ if (typeof addonId !== 'string' || addonId.length === 0)
173
+ return null;
174
+ const provider = reg.getProviderByAddonId(capName, addonId);
175
+ if (provider === null) {
176
+ const valid = reg.getProviderAddonIds(capName);
177
+ throw new Error(`Capability "${capName}" has no provider with addonId "${addonId}". ` +
178
+ `Registered: ${valid.length > 0 ? valid.join(', ') : '(none)'}`);
179
+ }
180
+ const fn = provider[method];
181
+ // Args pass through unchanged, exactly as the `nodeId` pin on this path
182
+ // already does: a provider destructures the fields it needs and ignores the
183
+ // selector. Stripping it here would diverge from that for no gain.
184
+ return { value: typeof fn === 'function' ? await fn(args) : undefined };
185
+ }
122
186
  /**
123
187
  * Fan an array-returning method across every provider and flatten the
124
188
  * results — the runtime equivalent of `concatCollection` for the
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.readCredentialIdentity = readCredentialIdentity;
4
+ exports.describeTrpcPrincipal = describeTrpcPrincipal;
5
+ /**
6
+ * Read the credential's self-description off verified claims.
7
+ *
8
+ * The claim set on the wire is WIDER than `TokenPayload` describes — an
9
+ * `oauth-access` token is an sso-bridge token and carries `kind`, `provider`
10
+ * and `sessionId` besides. Those are read structurally rather than by widening
11
+ * `TokenPayload`, which would invite every consumer to assume they are always
12
+ * there.
13
+ *
14
+ * Gated on `kind === 'sso-bridge'`: `provider` is a common enough field name
15
+ * that a future token type could carry one meaning something else entirely, and
16
+ * mislabelling a principal in a security log is worse than not labelling it.
17
+ */
18
+ function readCredentialIdentity(claims) {
19
+ const kind = Reflect.get(claims, 'kind');
20
+ if (kind !== 'sso-bridge')
21
+ return {};
22
+ const provider = Reflect.get(claims, 'provider');
23
+ const sessionId = Reflect.get(claims, 'sessionId');
24
+ return {
25
+ ...(typeof provider === 'string' && provider !== '' ? { credential: provider } : {}),
26
+ ...(typeof sessionId === 'string' && sessionId !== '' ? { sessionId } : {}),
27
+ };
28
+ }
29
+ /**
30
+ * Render a principal for one log line.
31
+ *
32
+ * Shapes, in the order they are distinguished:
33
+ *
34
+ * anonymous
35
+ * apocaliss92 (admin)
36
+ * share:ab12cd34 (share-view)
37
+ * scoped:1a2b3c4d (scoped-token)
38
+ * apocaliss92 (oauth-access session=910a6179-…)
39
+ * apocaliss92 (session)
40
+ */
41
+ function describeTrpcPrincipal(user) {
42
+ // Never blank: an empty field reads as a logging defect rather than as an
43
+ // unauthenticated request, and the two need different fixes.
44
+ if (!user)
45
+ return 'anonymous';
46
+ const name = user.username || user.id || 'unknown';
47
+ if (user.shareView)
48
+ return `${name} (share-view)`;
49
+ if (user.isAdmin)
50
+ return `${name} (admin)`;
51
+ if (user.credential) {
52
+ const session = user.oauthSessionId ? ` session=${user.oauthSessionId}` : '';
53
+ return `${name} (${user.credential}${session})`;
54
+ }
55
+ if (user.isScoped)
56
+ return `${name} (scoped-token)`;
57
+ return `${name} (session)`;
58
+ }
@@ -4,6 +4,7 @@ exports.createMeshTrpcContext = createMeshTrpcContext;
4
4
  exports.createTrpcContext = createTrpcContext;
5
5
  exports.createWsTrpcContext = createWsTrpcContext;
6
6
  const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
7
+ const trpc_error_principal_js_1 = require("./trpc-error-principal.js");
7
8
  /** Read `req.query` if present (Fastify-only) without losing type safety. */
8
9
  function readQuery(req) {
9
10
  if (!('query' in req))
@@ -127,6 +128,10 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
127
128
  if (typeof payload.isAdmin !== 'boolean') {
128
129
  return null;
129
130
  }
131
+ // What the token says it IS. An `oauth-access` token is an account link,
132
+ // and until this was carried onto the principal a refusal of one was
133
+ // indistinguishable in the logs from a refusal of a browser session.
134
+ const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
130
135
  return {
131
136
  id: payload.userId ?? payload.keyId ?? 'unknown',
132
137
  username: payload.username ?? 'unknown',
@@ -141,6 +146,8 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
141
146
  // Scopes are baked into the JWT at login; the middleware uses
142
147
  // them to gate every call until the user re-logs.
143
148
  ...(payload.scopes !== undefined ? { scopes: payload.scopes } : {}),
149
+ ...(credential.credential !== undefined ? { credential: credential.credential } : {}),
150
+ ...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
144
151
  };
145
152
  }
146
153
  catch {
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.gateAddonJwt = gateAddonJwt;
4
+ /**
5
+ * The JWT half of the `/addon/:addonId/*` gate — shared by the HTTP data-plane
6
+ * branch and the addon-route branch so there is exactly ONE answer to "may this
7
+ * bearer reach this addon".
8
+ *
9
+ * Before this existed the two branches disagreed with themselves and with the
10
+ * `cst_` branch: a scoped token needed an `addon:<id>` grant, while ANY valid
11
+ * hub JWT passed on nothing but a signature check. Measured on the live hub
12
+ * 2026-08-09, that let an `oauth-access` token minted for the `export-alexa`
13
+ * account link drive `POST /addon/homeassistant-export/command` — PTZ, reboot,
14
+ * the per-camera switches and notification snooze. A 60-second authorization
15
+ * CODE and a 30-day refresh token passed the same way. See D103.
16
+ */
17
+ const types_1 = require("@camstack/types");
18
+ function readScopes(raw) {
19
+ return Array.isArray(raw) ? raw : [];
20
+ }
21
+ /**
22
+ * Decide, and REPORT. A refusal always returns its reason to the log — a branch
23
+ * that drops a request silently reads as "never happened", and this one drops
24
+ * requests an operator will be trying to explain.
25
+ *
26
+ * Order is deliberate. `Admin required` is answered before the scope check so
27
+ * an admin-only route keeps the response it already had; nothing that is not a
28
+ * session or an `oauth-access` token gets that far at all.
29
+ */
30
+ function gateAddonJwt(input, logger) {
31
+ const principal = (0, types_1.classifyBearerPrincipal)(input.payload);
32
+ const isAdmin = input.payload.isAdmin === true;
33
+ if (principal.kind === 'not-a-credential') {
34
+ logger.warn('addon route: refused a bearer that is not an API credential', {
35
+ meta: { addonId: input.addonId, method: input.method, reason: principal.reason },
36
+ });
37
+ return { ok: false, status: 401, error: 'Invalid token' };
38
+ }
39
+ if (input.access === 'admin' && !isAdmin) {
40
+ logger.warn('addon route: refused a non-admin bearer on an admin route', {
41
+ meta: { addonId: input.addonId, method: input.method, principal: principal.kind },
42
+ });
43
+ return { ok: false, status: 403, error: 'Admin required' };
44
+ }
45
+ const verdict = (0, types_1.principalMayReachAddon)({
46
+ principal,
47
+ scopes: readScopes(input.payload.scopes),
48
+ isAdmin,
49
+ addonId: input.addonId,
50
+ method: input.method,
51
+ });
52
+ if (!verdict.allowed) {
53
+ logger.warn('addon route: refused an integration token outside its declared grant', {
54
+ meta: { addonId: input.addonId, method: input.method, reason: verdict.reason },
55
+ });
56
+ return { ok: false, status: 403, error: 'Token scope mismatch' };
57
+ }
58
+ return { ok: true };
59
+ }
@@ -297,6 +297,14 @@ class MoleculerService {
297
297
  // how the Notification Center saw an empty `notification-output`
298
298
  // catalog and dead-lettered every notification (2026-07-30).
299
299
  callCollectionAggregate: (capName, method, args) => (0, cap_router_runtime_js_1.aggregateCollectionCall)(this.capabilityService.getRegistry(), capName, method, args),
300
+ // `{ addonId }` PIN for the same child→parent path — the scalar twin of
301
+ // the fan-out above, sharing this server's provider resolution so the
302
+ // two paths cannot disagree. Routing takes only `nodeId`/`deviceId`, so
303
+ // without this a forked addon's pin is discarded and the first
304
+ // registered provider answers for a resource it does not own: HA's
305
+ // export read Homematic's `null` for `ha_001` and never opened a link
306
+ // (2026-08-09).
307
+ callPinnedCollection: (capName, method, args) => (0, cap_router_runtime_js_1.pinnedCollectionCall)(this.capabilityService.getRegistry(), capName, method, args),
300
308
  // Hub-core `$`-service discriminant for the general
301
309
  // provider-not-yet-registered recovery. A resolver miss on a NON-core
302
310
  // cap (any addon-provided system/singleton cap, e.g.
package/dist/main.js CHANGED
@@ -80,11 +80,13 @@ const addon_widgets_service_1 = require("./core/addon-widgets/addon-widgets.serv
80
80
  const trpc_router_1 = require("./api/trpc/trpc.router");
81
81
  const core_cap_bridge_1 = require("./api/trpc/core-cap-bridge");
82
82
  const trpc_context_1 = require("./api/trpc/trpc.context");
83
+ const trpc_error_principal_1 = require("./api/trpc/trpc-error-principal");
83
84
  const addon_upload_1 = require("./api/addon-upload");
84
85
  const server_upload_1 = require("./api/server-upload");
85
86
  const auth_whoami_1 = require("./api/auth-whoami");
86
87
  const system_2 = require("@camstack/system");
87
88
  const session_cookie_js_1 = require("./auth/session-cookie.js");
89
+ const addon_route_jwt_gate_js_1 = require("./auth/addon-route-jwt-gate.js");
88
90
  const health_routes_1 = require("./api/health/health.routes");
89
91
  const spa_static_1 = require("./api/static/spa-static");
90
92
  const precompressed_asset_js_1 = require("./api/static/precompressed-asset.js");
@@ -461,10 +463,19 @@ async function bootstrap() {
461
463
  trpcOptions: {
462
464
  router: appRouter,
463
465
  createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService),
464
- onError: ({ path: trpcPath, error, }) => {
466
+ onError: ({ path: trpcPath, error, ctx, }) => {
465
467
  const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC');
466
468
  trpcLogger.warn('tRPC error', {
467
- meta: { code: error.code, path: trpcPath ?? '?', message: error.message },
469
+ meta: {
470
+ code: error.code,
471
+ path: trpcPath ?? '?',
472
+ // WHO was refused. A FORBIDDEN with no principal cannot be tied
473
+ // to the integration that hit it, and an operator grepping for
474
+ // the integration finds nothing — which reads as "the hub logged
475
+ // nothing" (2026-08-09).
476
+ principal: (0, trpc_error_principal_1.describeTrpcPrincipal)(ctx?.user),
477
+ message: error.message,
478
+ },
468
479
  });
469
480
  if (error.cause)
470
481
  trpcLogger.warn('tRPC error cause', {
@@ -784,6 +795,10 @@ async function bootstrap() {
784
795
  reply.setCookie(c.name, c.value, c.options);
785
796
  return reply.redirect(next);
786
797
  });
798
+ // Every refusal on the addon gate is a request an operator will be asking
799
+ // about ("why did Home Assistant stop actuating?"). Its own scope so the
800
+ // reason is greppable rather than buried in the bootstrap logger.
801
+ const addonGateLogger = app.get(logging_service_1.LoggingService).createLogger('addon-route-auth');
787
802
  // Addon HTTP API route catch-all: /addon/:addonId/*
788
803
  // Only handles non-GET or routes that actually exist in the addon route registry.
789
804
  // GET requests that don't match an addon route are SPA pages — handled by the /* fallback.
@@ -832,15 +847,17 @@ async function bootstrap() {
832
847
  }
833
848
  }
834
849
  else {
850
+ let payload;
835
851
  try {
836
- const payload = authService.verifyToken(token);
837
- if (access === 'admin' && !payload.isAdmin) {
838
- return reply.status(403).send({ error: 'Admin required' });
839
- }
852
+ payload = authService.verifyToken(token);
840
853
  }
841
854
  catch {
842
855
  return reply.status(401).send({ error: 'Invalid token' });
843
856
  }
857
+ const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access }, addonGateLogger);
858
+ if (!gate.ok) {
859
+ return reply.status(gate.status).send({ error: gate.error });
860
+ }
844
861
  }
845
862
  }
846
863
  const qIdx = request.url.indexOf('?');
@@ -915,28 +932,32 @@ async function bootstrap() {
915
932
  return match.route.handler(addonRequest, addonReply);
916
933
  }
917
934
  else {
935
+ let payload;
918
936
  try {
919
- const payload = authService.verifyToken(token);
920
- if (match.route.access === 'admin' && !payload.isAdmin) {
921
- return reply.status(403).send({ error: 'Admin required' });
922
- }
923
- const addonRequest = {
924
- params: match.params,
925
- query,
926
- body: request.body,
927
- headers,
928
- user: {
929
- id: payload.userId ?? 'unknown',
930
- username: payload.username ?? 'unknown',
931
- isAdmin: payload.isAdmin,
932
- },
933
- };
934
- const addonReply = buildAddonReply(reply);
935
- return match.route.handler(addonRequest, addonReply);
937
+ payload = authService.verifyToken(token);
936
938
  }
937
939
  catch {
938
940
  return reply.status(401).send({ error: 'Invalid token' });
939
941
  }
942
+ // NOTE: the handler call used to sit INSIDE this try, so a throw from
943
+ // an addon's own route handler was reported as `401 Invalid token`.
944
+ const gate = (0, addon_route_jwt_gate_js_1.gateAddonJwt)({ payload, addonId, method, access: match.route.access }, addonGateLogger);
945
+ if (!gate.ok) {
946
+ return reply.status(gate.status).send({ error: gate.error });
947
+ }
948
+ const addonRequest = {
949
+ params: match.params,
950
+ query,
951
+ body: request.body,
952
+ headers,
953
+ user: {
954
+ id: payload.userId ?? 'unknown',
955
+ username: payload.username ?? 'unknown',
956
+ isAdmin: payload.isAdmin,
957
+ },
958
+ };
959
+ const addonReply = buildAddonReply(reply);
960
+ return match.route.handler(addonRequest, addonReply);
940
961
  }
941
962
  }
942
963
  // Public route — no auth required
@@ -997,10 +1018,15 @@ async function bootstrap() {
997
1018
  wss,
998
1019
  router: appRouter,
999
1020
  createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService),
1000
- onError: ({ path: trpcPath, error, }) => {
1021
+ onError: ({ path: trpcPath, error, ctx, }) => {
1001
1022
  const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
1002
1023
  trpcLogger.warn('tRPC error', {
1003
- meta: { code: error.code, path: trpcPath ?? '?', message: error.message },
1024
+ meta: {
1025
+ code: error.code,
1026
+ path: trpcPath ?? '?',
1027
+ principal: (0, trpc_error_principal_1.describeTrpcPrincipal)(ctx?.user),
1028
+ message: error.message,
1029
+ },
1004
1030
  });
1005
1031
  if (error.cause)
1006
1032
  trpcLogger.warn('tRPC error cause', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/server",
3
- "version": "1.2.83",
3
+ "version": "1.2.84",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,7 +33,7 @@
33
33
  ]
34
34
  },
35
35
  "dependencies": {
36
- "@camstack/addon-admin-ui": "1.2.41",
36
+ "@camstack/addon-admin-ui": "1.2.42",
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",
@@ -43,8 +43,8 @@
43
43
  "@camstack/addon-post-analysis": "1.2.56",
44
44
  "@camstack/sdk": "1.2.11",
45
45
  "@camstack/shm-ring": "1.1.9",
46
- "@camstack/system": "1.2.70",
47
- "@camstack/types": "1.2.54",
46
+ "@camstack/system": "1.2.71",
47
+ "@camstack/types": "1.2.55",
48
48
  "@camstack/ui-library": "1.2.38",
49
49
  "@fastify/compress": "^9.0.0",
50
50
  "@fastify/cookie": "^11.0.2",