@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. Caller (protectedProcedure) decides the failure response
97
- * (typically UNAUTHORIZED).
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.refreshDeviceParentMirror();
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.134",
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.98",
41
+ "@camstack/addon-pipeline": "1.2.99",
42
42
  "@camstack/addon-pipeline-orchestrator": "1.2.83",
43
- "@camstack/addon-post-analysis": "1.2.98",
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.106",
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",