@camstack/server 1.2.134 → 1.2.136
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.
|
@@ -93,10 +93,11 @@ function extractTokenFromRequest(req) {
|
|
|
93
93
|
* its REST auth chain).
|
|
94
94
|
*
|
|
95
95
|
* Returns `null` for: missing token, malformed JWT, unknown scoped
|
|
96
|
-
* token
|
|
97
|
-
*
|
|
96
|
+
* token, and a JWT that is not an ACCESS CREDENTIAL (see the classifier
|
|
97
|
+
* branch below). Caller (protectedProcedure) decides the failure
|
|
98
|
+
* response (typically UNAUTHORIZED).
|
|
98
99
|
*/
|
|
99
|
-
async function resolveUser(token, authService, addonRegistry, shareTokens = null) {
|
|
100
|
+
async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null) {
|
|
100
101
|
if (!token)
|
|
101
102
|
return null;
|
|
102
103
|
// Share-token path (`csv_*`): resolve through the ShareTokenService.
|
|
@@ -174,14 +175,42 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
|
|
|
174
175
|
if (typeof payload.isAdmin !== 'boolean') {
|
|
175
176
|
return null;
|
|
176
177
|
}
|
|
178
|
+
// What the token IS decides both WHETHER it authenticates at all and
|
|
179
|
+
// WHERE its scopes come from.
|
|
180
|
+
const principal = (0, types_1.classifyBearerPrincipal)(payload);
|
|
181
|
+
// ── The allowlist ────────────────────────────────────────────────
|
|
182
|
+
// Every token this hub mints verifies under the same `auth.jwtSecret`, so
|
|
183
|
+
// `verifyToken` succeeding means "we minted this", never "this is an API
|
|
184
|
+
// credential" (D103). Exactly two kinds are credentials here:
|
|
185
|
+
//
|
|
186
|
+
// • `session` — no `kind` claim: `auth.login`'s JWT, the SSO-minted
|
|
187
|
+
// session from `/api/auth/sso/finish`, a `type: 'service'` agent
|
|
188
|
+
// token, a `type: 'api_key'` token.
|
|
189
|
+
// • `integration` — `provider: 'oauth-access'`: the account-link
|
|
190
|
+
// credential a component (Home Assistant) calls `/trpc` with.
|
|
191
|
+
//
|
|
192
|
+
// Everything else is minted for ONE hand-off step and is refused: an
|
|
193
|
+
// `oauth-code` is single-use, lives 60s and travels in a browser redirect
|
|
194
|
+
// URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
|
|
195
|
+
// `POST /oauth/token`; an `oidc`/`magic-link` bridge belongs to
|
|
196
|
+
// `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
|
|
197
|
+
// `/addon/:addonId/*` has refused these since D103 — this closes the same
|
|
198
|
+
// hole on the other surface.
|
|
199
|
+
//
|
|
200
|
+
// The TOTP login is unaffected BY CONSTRUCTION: `loginVerifyTotp` takes
|
|
201
|
+
// the challenge as procedure INPUT on a public rate-limited procedure, and
|
|
202
|
+
// neither the SDK (`login()` calls `setToken` only when
|
|
203
|
+
// `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
|
|
204
|
+
if (principal.kind === 'not-a-credential') {
|
|
205
|
+
logger?.warn('trpc: refused a bearer that is not an API credential', {
|
|
206
|
+
meta: { reason: principal.reason, userId: payload.userId ?? payload.keyId ?? 'unknown' },
|
|
207
|
+
});
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
177
210
|
// What the token says it IS. An `oauth-access` token is an account link,
|
|
178
211
|
// and until this was carried onto the principal a refusal of one was
|
|
179
212
|
// indistinguishable in the logs from a refusal of a browser session.
|
|
180
213
|
const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
|
|
181
|
-
// What the token IS decides WHERE its scopes come from. A `session` JWT is
|
|
182
|
-
// governed by its user's record; anything else carries its own grant and
|
|
183
|
-
// must keep it — see the live-scope note on the spread below.
|
|
184
|
-
const principal = (0, types_1.classifyBearerPrincipal)(payload);
|
|
185
214
|
return {
|
|
186
215
|
id: payload.userId ?? payload.keyId ?? 'unknown',
|
|
187
216
|
username: payload.username ?? 'unknown',
|
|
@@ -270,10 +299,10 @@ function createMeshTrpcContext() {
|
|
|
270
299
|
return { user };
|
|
271
300
|
}
|
|
272
301
|
/** Context factory for HTTP tRPC requests (Fastify adapter). */
|
|
273
|
-
async function createTrpcContext(req, authService, addonRegistry, shareTokens = null) {
|
|
302
|
+
async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null) {
|
|
274
303
|
const token = extractTokenFromRequest(req);
|
|
275
304
|
return {
|
|
276
|
-
user: await resolveUser(token, authService, addonRegistry, shareTokens),
|
|
305
|
+
user: await resolveUser(token, authService, addonRegistry, shareTokens, logger),
|
|
277
306
|
req,
|
|
278
307
|
deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
|
|
279
308
|
deviceFleet: () => addonRegistry.getPersistedDeviceList(),
|
|
@@ -284,11 +313,11 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
|
|
|
284
313
|
* Token is sent via tRPC connectionParams (a JSON message sent right after
|
|
285
314
|
* the WS handshake), which is more reliable than query params through proxies.
|
|
286
315
|
*/
|
|
287
|
-
async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null) {
|
|
316
|
+
async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null) {
|
|
288
317
|
// 1. connectionParams.token (sent by BackendClient's createWSClient)
|
|
289
318
|
const paramToken = opts.info.connectionParams?.['token'];
|
|
290
319
|
const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
|
|
291
|
-
const user = await resolveUser(token, authService, addonRegistry, shareTokens);
|
|
320
|
+
const user = await resolveUser(token, authService, addonRegistry, shareTokens, logger);
|
|
292
321
|
return {
|
|
293
322
|
user,
|
|
294
323
|
req: opts.req,
|
|
@@ -54,6 +54,7 @@ const prune_misplaced_addons_js_1 = require("./prune-misplaced-addons.js");
|
|
|
54
54
|
const require_cache_js_1 = require("./require-cache.js");
|
|
55
55
|
const runner_convergence_1 = require("./runner-convergence");
|
|
56
56
|
const runner_spawn_fanout_js_1 = require("./runner-spawn-fanout.js");
|
|
57
|
+
const single_flight_refresh_js_1 = require("./single-flight-refresh.js");
|
|
57
58
|
/**
|
|
58
59
|
* Decide whether an in-memory addon entry should be evicted because it is no
|
|
59
60
|
* longer on disk (the uninstall path in `loadNewAddons`).
|
|
@@ -1153,11 +1154,18 @@ class AddonRegistryService {
|
|
|
1153
1154
|
}
|
|
1154
1155
|
return out;
|
|
1155
1156
|
};
|
|
1157
|
+
/** One full-fleet rebuild in flight, at most one queued behind it. Every
|
|
1158
|
+
* event below asks for the SAME total rebuild, so running it once per event
|
|
1159
|
+
* was ~1 000 rebuilds of a 974-row mirror on a boot — each one a 625 KB read
|
|
1160
|
+
* and parse on hub-main's event loop. No timer: the first event still
|
|
1161
|
+
* refreshes immediately, because this mirror backs scope enforcement.
|
|
1162
|
+
* See `single-flight-refresh.ts`. */
|
|
1163
|
+
deviceMirrorGate = (0, single_flight_refresh_js_1.createSingleFlightRefresh)(() => this.refreshDeviceParentMirror());
|
|
1156
1164
|
/** Subscribe the mirror to every device-meta lifecycle event, and warm it once
|
|
1157
1165
|
* the addon set (device-manager included) is up. */
|
|
1158
1166
|
wireDeviceParentMirror() {
|
|
1159
1167
|
const refresh = () => {
|
|
1160
|
-
void this.
|
|
1168
|
+
void this.deviceMirrorGate.request();
|
|
1161
1169
|
};
|
|
1162
1170
|
for (const category of [
|
|
1163
1171
|
types_1.EventCategory.DeviceMetaChanged,
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createSingleFlightRefresh = createSingleFlightRefresh;
|
|
4
|
+
/**
|
|
5
|
+
* @param run the total rebuild. Called with no arguments, never concurrently
|
|
6
|
+
* with itself.
|
|
7
|
+
*/
|
|
8
|
+
function createSingleFlightRefresh(run) {
|
|
9
|
+
let inFlight = null;
|
|
10
|
+
// Set while a run is active and another request arrived. One flag, not a
|
|
11
|
+
// counter: N requests during one run need exactly ONE follow-up between
|
|
12
|
+
// them, because the follow-up reads the world as it is when it starts.
|
|
13
|
+
let pending = false;
|
|
14
|
+
let pendingPromise = null;
|
|
15
|
+
let releasePending = () => { };
|
|
16
|
+
const start = () => {
|
|
17
|
+
const active = (async () => {
|
|
18
|
+
try {
|
|
19
|
+
await run();
|
|
20
|
+
}
|
|
21
|
+
finally {
|
|
22
|
+
inFlight = null;
|
|
23
|
+
if (pending) {
|
|
24
|
+
pending = false;
|
|
25
|
+
const release = releasePending;
|
|
26
|
+
releasePending = () => { };
|
|
27
|
+
pendingPromise = null;
|
|
28
|
+
// Chain the follow-up, then settle everyone who waited for it.
|
|
29
|
+
void start().finally(release);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
})();
|
|
33
|
+
inFlight = active;
|
|
34
|
+
return active;
|
|
35
|
+
};
|
|
36
|
+
return {
|
|
37
|
+
request: async () => {
|
|
38
|
+
if (inFlight === null) {
|
|
39
|
+
await start();
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (!pending) {
|
|
43
|
+
pending = true;
|
|
44
|
+
pendingPromise = new Promise((resolve) => {
|
|
45
|
+
releasePending = resolve;
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
// `pendingPromise` is non-null whenever `pending` is set; the local
|
|
49
|
+
// binding keeps that visible to the type checker after the await.
|
|
50
|
+
const wait = pendingPromise;
|
|
51
|
+
if (wait !== null)
|
|
52
|
+
await wait;
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -504,7 +504,11 @@ async function bootstrap() {
|
|
|
504
504
|
prefix: '/trpc',
|
|
505
505
|
trpcOptions: {
|
|
506
506
|
router: appRouter,
|
|
507
|
-
createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService
|
|
507
|
+
createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService,
|
|
508
|
+
// A bearer refused for NOT being an API credential never reaches a
|
|
509
|
+
// procedure, so `onError` below never sees it — the refusal would
|
|
510
|
+
// be silent without this. Same reason `gateAddonJwt` logs (D103).
|
|
511
|
+
app.get(logging_service_1.LoggingService).createLogger('tRPC')),
|
|
508
512
|
onError: ({ path: trpcPath, error, ctx, }) => {
|
|
509
513
|
const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC');
|
|
510
514
|
trpcLogger.warn('tRPC error', {
|
|
@@ -1065,7 +1069,7 @@ async function bootstrap() {
|
|
|
1065
1069
|
(0, ws_1.applyWSSHandler)({
|
|
1066
1070
|
wss,
|
|
1067
1071
|
router: appRouter,
|
|
1068
|
-
createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService),
|
|
1072
|
+
createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService, app.get(logging_service_1.LoggingService).createLogger('tRPC:ws')),
|
|
1069
1073
|
onError: ({ path: trpcPath, error, ctx, }) => {
|
|
1070
1074
|
const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
|
|
1071
1075
|
trpcLogger.warn('tRPC error', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.136",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -38,12 +38,12 @@
|
|
|
38
38
|
"@camstack/addon-auth": "1.2.26",
|
|
39
39
|
"@camstack/addon-decoder-nodeav": "1.2.22",
|
|
40
40
|
"@camstack/addon-notifiers": "1.2.27",
|
|
41
|
-
"@camstack/addon-pipeline": "1.2.
|
|
41
|
+
"@camstack/addon-pipeline": "1.2.99",
|
|
42
42
|
"@camstack/addon-pipeline-orchestrator": "1.2.83",
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.100",
|
|
44
44
|
"@camstack/sdk": "1.2.25",
|
|
45
45
|
"@camstack/shm-ring": "1.1.22",
|
|
46
|
-
"@camstack/system": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.107",
|
|
47
47
|
"@camstack/types": "1.2.90",
|
|
48
48
|
"@camstack/ui-library": "1.2.62",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|