@camstack/server 1.2.83 → 1.2.85
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api/oauth2/oauth2-routes.js +120 -31
- package/dist/api/trpc/cap-router-runtime.js +86 -0
- package/dist/api/trpc/trpc-error-principal.js +58 -0
- package/dist/api/trpc/trpc.context.js +7 -0
- package/dist/auth/addon-route-jwt-gate.js +59 -0
- package/dist/core/moleculer/moleculer.service.js +16 -0
- package/dist/main.js +51 -25
- package/package.json +4 -4
|
@@ -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
|
-
|
|
69
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
|
|
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.
|
|
166
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,8 @@
|
|
|
22
22
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
23
|
exports.COLLECTION_ARRAY_METHODS = void 0;
|
|
24
24
|
exports.aggregateCollectionCall = aggregateCollectionCall;
|
|
25
|
+
exports.addonPinOf = addonPinOf;
|
|
26
|
+
exports.pinnedCollectionCall = pinnedCollectionCall;
|
|
25
27
|
exports.createCapRouterPrimitives = createCapRouterPrimitives;
|
|
26
28
|
exports.createCapRouterServices = createCapRouterServices;
|
|
27
29
|
exports.buildRuntimeCapRouters = buildRuntimeCapRouters;
|
|
@@ -119,6 +121,90 @@ async function aggregateCollectionCall(reg, capName, method, args) {
|
|
|
119
121
|
const out = await fn(args);
|
|
120
122
|
return Array.isArray(out) ? out : null;
|
|
121
123
|
}
|
|
124
|
+
/**
|
|
125
|
+
* The `{ addonId }` a call PINS to, or `null` when the call is not an
|
|
126
|
+
* addon-pinned collection call and must route normally.
|
|
127
|
+
*
|
|
128
|
+
* ONE definition, because there are two places that must agree about it and
|
|
129
|
+
* they are in different packages. `LocalChildRegistry` decides whether to
|
|
130
|
+
* short-circuit a child's egress to a local sibling — and that decision happens
|
|
131
|
+
* BEFORE `onUnownedCall` runs, so a pin honoured only there is honoured never.
|
|
132
|
+
* That is precisely how the first attempt at this fix failed on the live hub:
|
|
133
|
+
* the handler was correct and unreachable, and the probe that proved it was a
|
|
134
|
+
* DELIBERATELY BOGUS addonId — refused if the selector routes, answered `null`
|
|
135
|
+
* if it is discarded. It came back `null`.
|
|
136
|
+
*
|
|
137
|
+
* Not an array method (that contract is the union) and not a non-collection cap
|
|
138
|
+
* (there `addonId` is provider DATA: `addons.getLogs({addonId})` names the addon
|
|
139
|
+
* whose logs you want, it does not name a provider).
|
|
140
|
+
*/
|
|
141
|
+
function addonPinOf(reg, capName, method, args) {
|
|
142
|
+
if (!reg)
|
|
143
|
+
return null;
|
|
144
|
+
if (reg.getDefinition(capName)?.mode !== 'collection')
|
|
145
|
+
return null;
|
|
146
|
+
if (exports.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method) === true)
|
|
147
|
+
return null;
|
|
148
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args))
|
|
149
|
+
return null;
|
|
150
|
+
const addonId = args['addonId'];
|
|
151
|
+
return typeof addonId === 'string' && addonId.length > 0 ? addonId : null;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* The `{ addonId }` PIN for a cap call arriving OUTSIDE the tRPC router — a
|
|
155
|
+
* forked addon's `ctx.api.<cap>.<method>` reaching the parent's
|
|
156
|
+
* `onUnownedCall`. The scalar twin of {@link aggregateCollectionCall}, and it
|
|
157
|
+
* exists for the same reason: the two paths answered differently and only the
|
|
158
|
+
* tRPC one was ever measured.
|
|
159
|
+
*
|
|
160
|
+
* `addonId` is not provider data on a system-scoped collection cap — the
|
|
161
|
+
* codegen INJECTS it as the `{nodeId?, addonId?}` selector on every method
|
|
162
|
+
* (`GetInputSchema` for `broker.getBrokerConfig` declares only `{ id }`), and
|
|
163
|
+
* `cap-router-builder.ts` routes the tRPC call by it. The child→parent path
|
|
164
|
+
* lifted only `nodeId` and `deviceId` out of a child's args, so the selector
|
|
165
|
+
* was DISCARDED IN SILENCE and the call landed on whichever provider registered
|
|
166
|
+
* first. Measured on the live hub 2026-08-09:
|
|
167
|
+
*
|
|
168
|
+
* hub, getBrokerConfig {id:'ha_001'} → null
|
|
169
|
+
* hub, getBrokerConfig {id:'ha_001', addonId:'provider-…'} → the config
|
|
170
|
+
* addon, getBrokerConfig {id:'ha_001', addonId:'provider-…'} → null
|
|
171
|
+
*
|
|
172
|
+
* The third row is the bug: from an addon the pinned call returned exactly the
|
|
173
|
+
* UNPINNED answer — Homematic's `null` for a broker it does not own, which the
|
|
174
|
+
* Home Assistant export could not tell from "no such broker". It could not open
|
|
175
|
+
* a link, ever, and every write-up of the pin had measured only the first two
|
|
176
|
+
* rows.
|
|
177
|
+
*
|
|
178
|
+
* Mirrors the tRPC branch exactly, including what it does NOT do:
|
|
179
|
+
* - ARRAY methods are never rerouted — their contract is the fan-out union
|
|
180
|
+
* ({@link aggregateCollectionCall}), and a pin that turns a provider's
|
|
181
|
+
* absence into a throw is what took `broker.list` from "fewer rows" to
|
|
182
|
+
* `BAD_REQUEST` and the operator from "one instance" to "no instance".
|
|
183
|
+
* - Only the EXPLICIT top-level selector routes. No nested recovery here:
|
|
184
|
+
* that heuristic reads a domain payload, and this path has no schema.
|
|
185
|
+
*
|
|
186
|
+
* A pin naming a non-provider REFUSES with the valid ids spelled out rather
|
|
187
|
+
* than degrading to another provider's answer — the whole point of pinning.
|
|
188
|
+
*
|
|
189
|
+
* Returns a `{ value }` box, never the bare result: `getBrokerConfig` answers
|
|
190
|
+
* `null` legitimately, so a bare `null` could not be told from "not handled".
|
|
191
|
+
*/
|
|
192
|
+
async function pinnedCollectionCall(reg, capName, method, args) {
|
|
193
|
+
const addonId = addonPinOf(reg, capName, method, args);
|
|
194
|
+
if (addonId === null)
|
|
195
|
+
return null;
|
|
196
|
+
const provider = reg?.getProviderByAddonId(capName, addonId) ?? null;
|
|
197
|
+
if (provider === null) {
|
|
198
|
+
const valid = reg?.getProviderAddonIds(capName) ?? [];
|
|
199
|
+
throw new Error(`Capability "${capName}" has no provider with addonId "${addonId}". ` +
|
|
200
|
+
`Registered: ${valid.length > 0 ? valid.join(', ') : '(none)'}`);
|
|
201
|
+
}
|
|
202
|
+
const fn = provider[method];
|
|
203
|
+
// Args pass through unchanged, exactly as the `nodeId` pin on this path
|
|
204
|
+
// already does: a provider destructures the fields it needs and ignores the
|
|
205
|
+
// selector. Stripping it here would diverge from that for no gain.
|
|
206
|
+
return { value: typeof fn === 'function' ? await fn(args) : undefined };
|
|
207
|
+
}
|
|
122
208
|
/**
|
|
123
209
|
* Fan an array-returning method across every provider and flatten the
|
|
124
210
|
* 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.
|
|
@@ -341,6 +349,14 @@ class MoleculerService {
|
|
|
341
349
|
return false;
|
|
342
350
|
return cap_router_runtime_js_1.COLLECTION_ARRAY_METHODS.get(capName)?.includes(method) === true;
|
|
343
351
|
},
|
|
352
|
+
// Same story for an explicit `{ addonId }` selector: the sibling
|
|
353
|
+
// short-circuit picks the FIRST-registered child, which is precisely
|
|
354
|
+
// what the selector exists to override. `onUnownedCall` then resolves
|
|
355
|
+
// the named provider via `callPinnedCollection`. Shares `addonPinOf`
|
|
356
|
+
// with it — the bypass and the routing must never disagree about what
|
|
357
|
+
// counts as a pin, or the call is bounced out of the sibling path and
|
|
358
|
+
// then routed by first-provider anyway.
|
|
359
|
+
isAddonPinnedCall: (capName, method, args) => (0, cap_router_runtime_js_1.addonPinOf)(this.capabilityService.getRegistry(), capName, method, args) !== null,
|
|
344
360
|
// Bound every UDS cap call by the cap's own declared timeout, falling
|
|
345
361
|
// back to the channel default. Same resolver the REMOTE plane uses
|
|
346
362
|
// below, so a method that survives 16 minutes across nodes survives it
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|
|
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.
|
|
3
|
+
"version": "1.2.85",
|
|
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.
|
|
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.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.72",
|
|
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",
|