@camstack/server 1.2.82 → 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,8 +9,75 @@ 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;
72
+ /**
73
+ * `Retry-After` on a refusal caused by an addon that has not registered yet.
74
+ *
75
+ * A forked addon's runner spawns, loads and initialises in seconds. This is
76
+ * advice to the CLIENT and nothing more — the hub never sleeps, never polls
77
+ * and never queues the request ([D3](../../../../../docs/decisions/adr-0003.md));
78
+ * it answers immediately with what is true right now.
79
+ */
80
+ const INTEGRATION_PENDING_RETRY_AFTER_SEC = 5;
12
81
  /** Render the received `integration` for a human, bounded. */
13
82
  function describeReceivedIntegration(raw) {
14
83
  if (raw === undefined)
@@ -48,26 +117,51 @@ function resolveIntegrationParam(raw) {
48
117
  * NOT checked — that pair is verified only at the Lambda boundary, and a
49
118
  * PUBLIC client (`requiresPkce`) has no secret to check it against at all;
50
119
  * the S256 challenge is what binds the code to its requester instead. */
51
- function validateAuthorizeQuery(q, knownIntegrations) {
120
+ function validateAuthorizeQuery(q, knownIntegrations, pendingAddons = []) {
52
121
  if (q.response_type !== 'code')
53
122
  return { ok: false, status: 400, error: 'unsupported_response_type' };
54
- // Three distinct client faults used to collapse into one opaque string.
123
+ // Four distinct faults used to collapse into one opaque string.
55
124
  // Alexa linking was down for a day because the message named neither what
56
125
  // arrived nor what would have been accepted.
57
126
  const detail = {
58
127
  received: describeReceivedIntegration(q.integration),
59
- known_integrations: [...knownIntegrations.keys()],
128
+ };
129
+ const diagnostics = {
130
+ knownIntegrations: [...knownIntegrations.keys()],
131
+ pendingAddons,
60
132
  };
61
133
  const param = resolveIntegrationParam(q.integration);
62
134
  if (!param.ok) {
63
135
  const error = param.reason === 'missing'
64
136
  ? 'invalid_request — integration parameter missing'
65
137
  : 'invalid_request — integration parameter repeated with conflicting values';
66
- return { ok: false, status: 400, error, detail };
138
+ return { ok: false, status: 400, error, detail, diagnostics };
67
139
  }
68
140
  const policy = knownIntegrations.get(param.value);
69
141
  if (!policy) {
70
- return { ok: false, status: 400, error: 'invalid_request unknown integration', detail };
142
+ // The fourth fault, and the one moving every descriptor into an addon made
143
+ // likelier: the id is absent because the addon that owns it has not
144
+ // registered yet. `invalid_request` means "your request is wrong, do not
145
+ // repeat it" — during boot that is a lie, and it is the lie that sends a
146
+ // client to its degraded fallback and leaves it there. `temporarily_
147
+ // unavailable` is RFC 6749 §4.1.2.1 and says the opposite.
148
+ if (pendingAddons.length > 0) {
149
+ return {
150
+ ok: false,
151
+ status: 503,
152
+ error: 'temporarily_unavailable — integration not registered yet',
153
+ detail,
154
+ diagnostics,
155
+ retryAfterSec: INTEGRATION_PENDING_RETRY_AFTER_SEC,
156
+ };
157
+ }
158
+ return {
159
+ ok: false,
160
+ status: 400,
161
+ error: 'invalid_request — unknown integration',
162
+ detail,
163
+ diagnostics,
164
+ };
71
165
  }
72
166
  if (!q.redirect_uri)
73
167
  return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' };
@@ -128,29 +222,59 @@ function summariseScopes(scopes) {
128
222
  }
129
223
  return scopes.map((s) => s.type).join(', ') || 'no permissions';
130
224
  }
131
- /** A refused `/authorize`. Logged at `warn` and echoed to the caller: both
132
- * halves name the value that ARRIVED and the ids that would have worked, so
133
- * neither the operator nor the log reader has to guess which of them is
134
- * wrong. */
135
- function refuseAuthorize(reply, logger, method, refusal) {
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) {
136
235
  logger.warn(`oauth2 authorize refused: ${refusal.error}`, {
137
236
  meta: {
138
237
  method,
238
+ ...call,
139
239
  received: refusal.detail?.received ?? null,
140
- knownIntegrations: refusal.detail?.known_integrations ?? [],
240
+ knownIntegrations: refusal.diagnostics?.knownIntegrations ?? [],
241
+ pendingAddons: refusal.diagnostics?.pendingAddons ?? [],
141
242
  },
142
243
  });
244
+ if (refusal.retryAfterSec !== undefined) {
245
+ void reply.header('Retry-After', String(refusal.retryAfterSec));
246
+ }
143
247
  return reply.status(refusal.status).send({ error: refusal.error, ...refusal.detail });
144
248
  }
145
- /** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
146
- async function buildIntegrationMap(registry) {
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
+ }
265
+ /** Read integrationId → descriptor from every registered `oauth-integration`
266
+ * provider, plus the addons that still owe one. */
267
+ async function readIntegrationRegistry(registry) {
147
268
  const entries = registry.getCollectionEntries('oauth-integration');
148
- const descriptorMap = new Map();
269
+ const descriptors = new Map();
149
270
  for (const [, provider] of entries) {
150
271
  const descriptor = await provider.getDescriptor();
151
- descriptorMap.set(descriptor.integrationId, descriptor);
272
+ descriptors.set(descriptor.integrationId, descriptor);
152
273
  }
153
- return descriptorMap;
274
+ const pendingAddons = registry
275
+ .getManifestDeclarers('oauth-integration')
276
+ .filter((addonId) => !registry.hasProvider('oauth-integration', addonId));
277
+ return { descriptors, pendingAddons };
154
278
  }
155
279
  /** Parse an application/x-www-form-urlencoded body string into a plain object. */
156
280
  function parseFormBody(raw) {
@@ -179,37 +303,33 @@ function registerOauth2Routes(fastify, deps) {
179
303
  // per-path special-casing anywhere.
180
304
  fastify.get('/api/oauth2/authorize', async (request, reply) => {
181
305
  const cookie = request.cookies[session_cookie_js_1.SESSION_COOKIE];
306
+ const call = describeAuthorizeCall(request.url, request.headers, Boolean(cookie));
182
307
  if (!cookie) {
183
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
184
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
185
- }
186
- return reply.status(401).send({ error: 'unauthorized' });
308
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
187
309
  }
188
310
  let tokenInfo;
189
311
  try {
190
312
  tokenInfo = deps.verifyToken(cookie);
191
313
  }
192
314
  catch {
193
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
194
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
195
- }
196
- return reply.status(401).send({ error: 'unauthorized' });
315
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
197
316
  }
198
317
  const registry = deps.getRegistry();
199
318
  if (!registry) {
200
319
  return reply.status(503).send({ error: 'service_unavailable' });
201
320
  }
202
- const descriptorMap = await buildIntegrationMap(registry);
321
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
203
322
  const query = request.query;
204
- const v = validateAuthorizeQuery(query, descriptorMap);
323
+ const v = validateAuthorizeQuery(query, descriptors, pendingAddons);
205
324
  if (!v.ok) {
206
- return refuseAuthorize(reply, deps.logger, 'GET', v);
325
+ return refuseAuthorize(reply, deps.logger, 'GET', call, v);
207
326
  }
208
- const descriptor = descriptorMap.get(v.integration);
327
+ const descriptor = descriptors.get(v.integration);
209
328
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
210
329
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
211
330
  meta: {
212
331
  method: 'GET',
332
+ ...call,
213
333
  integration: v.integration,
214
334
  redirectUri: v.redirectUri,
215
335
  allowedPrefixes: descriptor.allowedRedirectPrefixes,
@@ -247,44 +367,54 @@ function registerOauth2Routes(fastify, deps) {
247
367
  // from one that is refusing it, or the client dead-ends on a 400 with no way
248
368
  // to fall back. Unauthenticated (so is /token) and it discloses only which
249
369
  // integrations are installed.
370
+ //
371
+ // `complete` exists because absence-means-old-hub is a STICKY verdict: a
372
+ // client that probes 200ms after a restart sees a short list, concludes the
373
+ // hub cannot do OAuth, and settles into its password fallback for good. The
374
+ // flag says whether the list is final; a client SHOULD re-probe while it is
375
+ // false rather than downgrade. The pending addon IDS are deliberately not
376
+ // here — this route needs no token, and "not final yet" is the entire signal
377
+ // a client can act on. They are on the session-gated /authorize refusal,
378
+ // where a human is reading them as a diagnosis.
250
379
  fastify.get('/api/oauth2/integrations', async (_request, reply) => {
251
380
  const registry = deps.getRegistry();
252
381
  if (!registry) {
253
382
  return reply.status(503).send({ error: 'service_unavailable' });
254
383
  }
255
- const descriptorMap = await buildIntegrationMap(registry);
384
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
385
+ if (pendingAddons.length > 0) {
386
+ void reply.header('Retry-After', String(INTEGRATION_PENDING_RETRY_AFTER_SEC));
387
+ }
256
388
  return reply.send({
257
- integrations: [...descriptorMap.values()].map((d) => ({
389
+ integrations: [...descriptors.values()].map((d) => ({
258
390
  integrationId: d.integrationId,
259
391
  displayName: d.displayName,
260
392
  requiresPkce: d.requiresPkce === true,
261
393
  })),
394
+ complete: pendingAddons.length === 0,
262
395
  });
263
396
  });
264
397
  // ─── POST /api/oauth2/authorize ───────────────────────────────────────────
265
398
  fastify.post('/api/oauth2/authorize', async (request, reply) => {
266
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));
267
403
  if (!cookie) {
268
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
269
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
270
- }
271
- return reply.status(401).send({ error: 'unauthorized' });
404
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
272
405
  }
273
406
  let tokenInfo;
274
407
  try {
275
408
  tokenInfo = deps.verifyToken(cookie);
276
409
  }
277
410
  catch {
278
- if ((0, session_cookie_js_1.shouldRedirectToLogin)(request.method, request.headers.accept)) {
279
- return reply.redirect((0, session_cookie_js_1.loginRedirectUrl)(request.url));
280
- }
281
- return reply.status(401).send({ error: 'unauthorized' });
411
+ return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
282
412
  }
283
413
  const registry = deps.getRegistry();
284
414
  if (!registry) {
285
415
  return reply.status(503).send({ error: 'service_unavailable' });
286
416
  }
287
- const descriptorMap = await buildIntegrationMap(registry);
417
+ const { descriptors, pendingAddons } = await readIntegrationRegistry(registry);
288
418
  const body = request.body;
289
419
  const formQuery = {
290
420
  response_type: body.response_type,
@@ -296,15 +426,16 @@ function registerOauth2Routes(fastify, deps) {
296
426
  };
297
427
  // Re-validated, not trusted: the hidden fields came back from a browser
298
428
  // and every one of them is attacker-editable.
299
- const v = validateAuthorizeQuery(formQuery, descriptorMap);
429
+ const v = validateAuthorizeQuery(formQuery, descriptors, pendingAddons);
300
430
  if (!v.ok) {
301
- return refuseAuthorize(reply, deps.logger, 'POST', v);
431
+ return refuseAuthorize(reply, deps.logger, 'POST', call, v);
302
432
  }
303
- const descriptor = descriptorMap.get(v.integration);
433
+ const descriptor = descriptors.get(v.integration);
304
434
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes, descriptor.allowedPrivateHostPaths ?? [])) {
305
435
  deps.logger.warn('oauth2 authorize refused: redirect_uri not allowed', {
306
436
  meta: {
307
437
  method: 'POST',
438
+ ...call,
308
439
  integration: v.integration,
309
440
  redirectUri: v.redirectUri,
310
441
  allowedPrefixes: descriptor.allowedRedirectPrefixes,
@@ -334,6 +465,12 @@ function registerOauth2Routes(fastify, deps) {
334
465
  // the hub-global fallback (which defaults to localhost in dev).
335
466
  hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
336
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
+ : {}),
337
474
  });
338
475
  return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`);
339
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.82",
3
+ "version": "1.2.84",
4
4
  "private": false,
5
5
  "files": [
6
6
  "dist",
@@ -33,18 +33,18 @@
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",
40
40
  "@camstack/addon-notifiers": "1.2.13",
41
41
  "@camstack/addon-pipeline": "1.2.52",
42
- "@camstack/addon-pipeline-orchestrator": "1.2.33",
42
+ "@camstack/addon-pipeline-orchestrator": "1.2.34",
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.69",
47
- "@camstack/types": "1.2.53",
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",