@camstack/server 1.2.603 → 1.2.605

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,56 +1,64 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.agentFromPrincipal = agentFromPrincipal;
4
+ exports.bindHubCredentialStores = bindHubCredentialStores;
5
+ exports.hubCredentialVerifier = hubCredentialVerifier;
6
+ exports.requestFacts = requestFacts;
3
7
  exports.createMeshTrpcContext = createMeshTrpcContext;
4
8
  exports.createTrpcContext = createTrpcContext;
5
9
  exports.createWsTrpcContext = createWsTrpcContext;
6
10
  const system_1 = require("@camstack/system");
7
- const types_1 = require("@camstack/types");
8
11
  const auth_network_policy_js_1 = require("../../auth/auth-network-policy.js");
9
- const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
12
+ const credential_verifier_js_1 = require("../../auth/credential-verifier.js");
10
13
  const request_lang_js_1 = require("../../i18n/request-lang.js");
11
- const trpc_error_principal_js_1 = require("./trpc-error-principal.js");
14
+ /** The tRPC principal a verified credential becomes. */
15
+ function agentFromPrincipal(principal) {
16
+ return {
17
+ id: principal.subjectId,
18
+ username: principal.username,
19
+ isAdmin: principal.isAdmin,
20
+ permissions: { isAdmin: principal.isAdmin, allowedProviders: '*', allowedDevices: {} },
21
+ isApiKey: !principal.interactive,
22
+ scopes: principal.scopes,
23
+ credentialId: principal.credentialId,
24
+ auditTag: principal.auditTag,
25
+ interactive: principal.interactive,
26
+ userDelegated: principal.userDelegated,
27
+ legacy: principal.legacy,
28
+ ...(principal.sessionId !== undefined ? { sessionId: principal.sessionId } : {}),
29
+ ...(principal.oauthSessionId !== undefined ? { oauthSessionId: principal.oauthSessionId } : {}),
30
+ ...(principal.credential !== undefined ? { credential: principal.credential } : {}),
31
+ };
32
+ }
12
33
  /**
13
- * Live-scope refresh memo (see the JWT branch of `resolveUser`).
14
- *
15
- * The JWT embeds scopes at login, so an admin's scope edit used to reach a
16
- * live session only at the next login — a user granted cameras while logged
17
- * in saw NONE until they signed out and back in (2026-08-15, user report).
18
- * The store is therefore re-read per user, but memoized: a scope change
19
- * propagates within {@link SCOPE_REFRESH_TTL_MS} and a busy session costs at
20
- * most one store read per TTL window, not one per request.
34
+ * The live-scope memo of non-admin sessions, shared by every verifier this
35
+ * process builds (a request builds one; the memo must outlive it).
21
36
  */
22
- const SCOPE_REFRESH_TTL_MS = 30_000;
23
- const SCOPE_REFRESH_MISS_TTL_MS = 5_000;
24
- const liveScopeMemo = new Map();
25
- async function refreshScopesFromStore(userId, addonRegistry) {
26
- const cached = liveScopeMemo.get(userId);
27
- const now = Date.now();
28
- if (cached &&
29
- now - cached.at < (cached.scopes === null ? SCOPE_REFRESH_MISS_TTL_MS : SCOPE_REFRESH_TTL_MS)) {
30
- return cached.scopes;
31
- }
32
- let scopes = null;
33
- try {
34
- const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management');
35
- const fresh = typeof userMgmt?.listUsers === 'function'
36
- ? (await userMgmt.listUsers()).find((u) => u.id === userId)
37
- : null;
38
- if (fresh)
39
- scopes = (0, types_1.normalizeTokenScopes)(fresh.scopes ?? []);
40
- }
41
- catch {
42
- scopes = null;
43
- }
44
- liveScopeMemo.set(userId, { at: now, scopes });
45
- // Bound the map: sessions come and go, entries are tiny, and a missed
46
- // sweep would grow it by one row per distinct user per TTL window.
47
- if (liveScopeMemo.size > 512) {
48
- for (const [key, entry] of liveScopeMemo) {
49
- if (now - entry.at > SCOPE_REFRESH_TTL_MS * 4)
50
- liveScopeMemo.delete(key);
51
- }
52
- }
53
- return scopes;
37
+ const hubScopeMemo = new Map();
38
+ const hubStores = { shareTokens: null, tokenRows: null };
39
+ function bindHubCredentialStores(stores) {
40
+ if (stores.shareTokens !== undefined)
41
+ hubStores.shareTokens = stores.shareTokens;
42
+ if (stores.tokenRows !== undefined)
43
+ hubStores.tokenRows = stores.tokenRows;
44
+ }
45
+ /**
46
+ * The hub's verifier over its own stores — the same one for tRPC and the
47
+ * addon routes. Documented type boundary: the registry hands the
48
+ * `user-management` provider back untyped; the two methods the adapters read
49
+ * (`validateScopedToken`, `listUsers`) are asserted here.
50
+ */
51
+ function hubCredentialVerifier(deps) {
52
+ const provider = () => deps.addonRegistry.getCapabilityRegistry().getSingleton('user-management');
53
+ return (0, credential_verifier_js_1.createCredentialVerifier)({
54
+ verifyJwt: (token) => deps.authService.verifyToken(token),
55
+ users: () => provider() ?? null,
56
+ internal: () => (0, system_1.getUserManagementInternal)(provider()),
57
+ shareTokens: () => deps.shareTokens ?? hubStores.shareTokens,
58
+ tokenRows: () => deps.tokenRows ?? hubStores.tokenRows,
59
+ logger: deps.logger ?? null,
60
+ scopeMemo: hubScopeMemo,
61
+ });
54
62
  }
55
63
  /** Read `req.query` if present (Fastify-only) without losing type safety. */
56
64
  function readQuery(req) {
@@ -62,7 +70,7 @@ function readQuery(req) {
62
70
  return { ...q };
63
71
  }
64
72
  /**
65
- * Extract a JWT token from an HTTP request.
73
+ * Extract a bearer from an HTTP request.
66
74
  * Priority: Authorization header → Fastify req.query → URL query string
67
75
  */
68
76
  function extractTokenFromRequest(req) {
@@ -89,6 +97,7 @@ function extractTokenFromRequest(req) {
89
97
  }
90
98
  return null;
91
99
  }
100
+ /** What the audit of an admin-capable token's use records about the request. */
92
101
  function requestFacts(req) {
93
102
  if (req === undefined)
94
103
  return {};
@@ -97,203 +106,9 @@ function requestFacts(req) {
97
106
  const path = typeof url === 'string' ? url.split('?')[0] : undefined;
98
107
  return { ...(path !== undefined ? { path } : {}), ...(ip !== null ? { ip } : {}) };
99
108
  }
100
- /**
101
- * An admin token (`csa_`): an ADMIN principal, attributed to the admin who
102
- * created it, every use audited. Validation (owner still exists and is still
103
- * an admin, not revoked, not expired) is local-auth's (`AdminTokenManager`).
104
- */
105
- async function resolveAdminToken(token, addonRegistry, facts) {
106
- const internal = (0, system_1.getUserManagementInternal)(addonRegistry.getCapabilityRegistry().getSingleton('user-management'));
107
- if (internal === null)
108
- return null;
109
- const record = await internal.adminTokens.validate(token).catch(() => null);
110
- if (record === null)
111
- return null;
112
- void internal.audit.record({
113
- action: 'admin-token-used',
114
- userId: record.createdBy,
115
- ...(facts.ip !== undefined ? { ip: facts.ip } : {}),
116
- detail: {
117
- tokenId: record.id,
118
- name: record.name,
119
- prefix: record.tokenPrefix,
120
- ...(facts.path !== undefined ? { path: facts.path } : {}),
121
- },
122
- });
123
- return {
124
- id: record.createdBy,
125
- username: `admin-token:${record.name}`,
126
- isAdmin: true,
127
- permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
128
- isApiKey: true,
129
- credential: 'admin-token',
130
- adminTokenId: record.id,
131
- };
132
- }
133
- async function resolveUser(token, authService, addonRegistry, shareTokens = null, logger = null, facts = {}) {
134
- if (!token)
135
- return null;
136
- if (token.startsWith(system_1.ADMIN_TOKEN_PREFIX))
137
- return resolveAdminToken(token, addonRegistry, facts);
138
- // Share-token path (`csv_*`): resolve through the ShareTokenService.
139
- // The synthetic principal is maximally restricted — non-admin, no
140
- // scopes; the `shareView` grant routes it into the fail-closed
141
- // allowlist in `protectedProcedure` instead of the scope matcher.
142
- if (token.startsWith(share_token_service_js_1.SHARE_TOKEN_PREFIX)) {
143
- if (!shareTokens)
144
- return null;
145
- try {
146
- const record = await shareTokens.validate(token);
147
- if (!record)
148
- return null;
149
- return {
150
- id: record.userId,
151
- // Display label — `share:<prefix>` keeps audit logs readable
152
- // without exposing the token hash.
153
- username: `share:${record.tokenPrefix}`,
154
- isAdmin: false,
155
- permissions: {
156
- isAdmin: false,
157
- allowedProviders: '*',
158
- allowedDevices: {},
159
- },
160
- isApiKey: true,
161
- shareView: { tokenId: record.id, scope: record.scope },
162
- };
163
- }
164
- catch {
165
- return null;
166
- }
167
- }
168
- // Scoped-token path: hit the user-management cap. Synthetic user
169
- // with `isAdmin: false` so admin-gated procedures bounce while
170
- // protectedProcedure can still gate by scope match.
171
- if (token.startsWith('cst_')) {
172
- try {
173
- const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management');
174
- if (!userMgmt)
175
- return null;
176
- const record = await userMgmt.validateScopedToken({ token });
177
- if (!record)
178
- return null;
179
- return {
180
- id: record.userId,
181
- // Display label — `scoped:<prefix>` makes audit logs read
182
- // naturally without exposing the token hash.
183
- username: `scoped:${record.tokenPrefix}`,
184
- isAdmin: false,
185
- permissions: {
186
- isAdmin: false,
187
- allowedProviders: '*',
188
- allowedDevices: {},
189
- },
190
- isApiKey: true,
191
- isScoped: true,
192
- // Migrate any v2 `device:targets` grant to a v3 selector at the
193
- // boundary so the enforcement matcher only ever sees v3.
194
- scopes: (0, types_1.normalizeTokenScopes)(record.scopes),
195
- };
196
- }
197
- catch {
198
- return null;
199
- }
200
- }
201
- // JWT path.
202
- try {
203
- const payload = authService.verifyToken(token);
204
- // Reject pre-v2 JWTs at the boundary. Tokens issued before the
205
- // role → isAdmin migration don't carry the `isAdmin` field, so
206
- // letting them through would degrade silently into "non-admin
207
- // with no scopes" → locked out of every cap. Returning null forces
208
- // the client to land on 401 → re-login, where it picks up a fresh
209
- // v2 token. No back-compat shim — the role enum is gone.
210
- if (typeof payload.isAdmin !== 'boolean') {
211
- return null;
212
- }
213
- // What the token IS decides both WHETHER it authenticates at all and
214
- // WHERE its scopes come from.
215
- const principal = (0, types_1.classifyBearerPrincipal)(payload);
216
- // ── The allowlist ────────────────────────────────────────────────
217
- // Every token this hub mints verifies under the same `auth.jwtSecret`, so
218
- // `verifyToken` succeeding means "we minted this", never "this is an API
219
- // credential" (D103). Exactly two kinds are credentials here:
220
- //
221
- // • `session` — no `kind` claim: `auth.login`'s JWT and the SSO-minted
222
- // session from `/api/auth/sso/finish`.
223
- // • `integration` — `provider: 'oauth-access'`: the account-link
224
- // credential a component (Home Assistant) calls `/trpc` with.
225
- //
226
- // Everything else is minted for ONE hand-off step and is refused: an
227
- // `oauth-code` is single-use, lives 60s and travels in a browser redirect
228
- // URL (history, referrers, proxy logs); an `oauth-refresh` belongs to
229
- // `POST /oauth/token`; an `oidc` login bridge belongs to
230
- // `?bridge=` on `/api/auth/sso/finish`; a `totp-challenge` is login leg 1.
231
- // `/addon/:addonId/*` has refused these since D103 — this closes the same
232
- // hole on the other surface.
233
- //
234
- // The TOTP login is unaffected BY CONSTRUCTION: `loginVerifyTotp` takes
235
- // the challenge as procedure INPUT on a public rate-limited procedure, and
236
- // neither the SDK (`login()` calls `setToken` only when
237
- // `!requiresSecondFactor`) nor the admin UI ever puts it in a header.
238
- if (principal.kind === 'not-a-credential') {
239
- logger?.warn('trpc: refused a bearer that is not an API credential', {
240
- meta: { reason: principal.reason, userId: payload.userId ?? 'unknown' },
241
- });
242
- return null;
243
- }
244
- // What the token says it IS. An `oauth-access` token is an account link,
245
- // and until this was carried onto the principal a refusal of one was
246
- // indistinguishable in the logs from a refusal of a browser session.
247
- const credential = (0, trpc_error_principal_js_1.readCredentialIdentity)(payload);
248
- return {
249
- id: payload.userId ?? 'unknown',
250
- username: payload.username ?? 'unknown',
251
- isAdmin: payload.isAdmin,
252
- permissions: {
253
- isAdmin: payload.isAdmin,
254
- allowedProviders: payload.allowedProviders,
255
- allowedDevices: payload.allowedDevices,
256
- },
257
- isApiKey: false,
258
- ...(principal.kind === 'session' && typeof payload.sid === 'string'
259
- ? { sessionId: payload.sid }
260
- : {}),
261
- // Scopes are baked into the JWT at login; the middleware uses
262
- // them to gate every call until the user re-logs. Normalised at the
263
- // boundary — a v2 `device:targets` JWT (issued before the v3 model)
264
- // migrates to a selector here, so a pre-v3 token keeps working.
265
- //
266
- // …but a login-time snapshot is exactly the bug: an admin editing a
267
- // user's scopes expects the change to APPLY, and the live session
268
- // kept gating on the old set until re-login (2026-08-15: a user
269
- // granted the whole fleet kept seeing zero cameras). For non-admin
270
- // SESSION principals the store's current scopes win when readable
271
- // (memoized — see above); the JWT's copy is the fallback when the
272
- // user-management cap is unreachable, so a store hiccup never
273
- // locks anybody out mid-request.
274
- //
275
- // `session` ONLY, and that word is the whole fix. An ACCOUNT-LINK token
276
- // is governed by the grant the operator consented to, not by the linking
277
- // user's record: it always carries `isAdmin: false` (`oauth-grants.ts`
278
- // → `baseClaims`) and its owner is normally an admin, whose record is
279
- // deliberately unscoped — so the refresh replaced a six-scope Home
280
- // Assistant grant with `[]` and the hub refused the component's first
281
- // call with `Have: (none)` (2026-08-19). The user store knows nothing
282
- // about a link and must not get a vote on one.
283
- ...(payload.scopes !== undefined ? { scopes: (0, types_1.normalizeTokenScopes)(payload.scopes) } : {}),
284
- ...(!payload.isAdmin && principal.kind === 'session'
285
- ? await (async () => {
286
- const fresh = await refreshScopesFromStore(payload.userId ?? 'unknown', addonRegistry);
287
- return fresh !== null ? { scopes: fresh } : {};
288
- })()
289
- : {}),
290
- ...(credential.credential !== undefined ? { credential: credential.credential } : {}),
291
- ...(credential.sessionId !== undefined ? { oauthSessionId: credential.sessionId } : {}),
292
- };
293
- }
294
- catch {
295
- return null;
296
- }
109
+ async function resolveUser(token, verifier, facts) {
110
+ const principal = await verifier.verify(token, facts);
111
+ return principal === null ? null : agentFromPrincipal(principal);
297
112
  }
298
113
  /**
299
114
  * Build the parent-chain walker for the scope-access matcher. Returns every
@@ -304,8 +119,7 @@ async function resolveUser(token, authService, addonRegistry, shareTokens = null
304
119
  * the device-manager's PERSISTED parentage (F0.6). It MUST NOT walk the hub
305
120
  * `DeviceRegistry` directly: that registry only holds hub-local devices, so it
306
121
  * is empty for every forked provider and the inheritance this function exists to
307
- * provide silently did nothing (0/6 providers inherited). The mirror is warmed
308
- * off the event path and read synchronously here.
122
+ * provide silently did nothing (0/6 providers inherited).
309
123
  */
310
124
  function makeDeviceScopeLookup(addonRegistry) {
311
125
  return {
@@ -321,15 +135,12 @@ function makeDeviceScopeLookup(addonRegistry) {
321
135
  * `principal` names WHO is calling (D802): `mesh` for the hub's own runners
322
136
  * (they reach the bridge over UDS, inside the hub's trust boundary), and
323
137
  * `node:<nodeId>` for another node — whose id the hub's mesh gate has bound
324
- * to its CERTIFICATE before the call got here (`createMeshAccessMiddleware`:
325
- * the TLS handshake chained to the mesh CA, the leaf CN is that id, D857).
138
+ * to its CERTIFICATE before the call got here (D857).
326
139
  *
327
- * Both principals are still admins: that keeps every `adminProcedure` an
328
- * agent's addons call working. What is NOT reachable from another node is
329
- * decided by the bridge itself (`REMOTE_DENIED_NAMESPACES`), and `repl` is not
330
- * bridged at all. Narrowing the remote principal to a per-method grant is the
331
- * next step once the mesh census has measured what each node calls (D802).
332
- * There is no HTTP request behind the call.
140
+ * Both principals hold `'*'`: that keeps every `adminProcedure` an agent's
141
+ * addons call working. What is NOT reachable from another node is decided by
142
+ * the bridge itself (`REMOTE_DENIED_NAMESPACES`), and `repl` is not bridged at
143
+ * all. There is no HTTP request behind the call.
333
144
  */
334
145
  function createMeshTrpcContext(principal = 'mesh') {
335
146
  const user = {
@@ -338,14 +149,22 @@ function createMeshTrpcContext(principal = 'mesh') {
338
149
  isAdmin: true,
339
150
  permissions: { isAdmin: true, allowedProviders: '*', allowedDevices: {} },
340
151
  isApiKey: true,
152
+ scopes: ['*'],
153
+ credentialId: principal,
341
154
  };
342
155
  return { user };
343
156
  }
344
157
  /** Context factory for HTTP tRPC requests (Fastify adapter). */
345
- async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null) {
346
- const token = extractTokenFromRequest(req);
158
+ async function createTrpcContext(req, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
159
+ const verifier = hubCredentialVerifier({
160
+ authService,
161
+ addonRegistry,
162
+ shareTokens,
163
+ tokenRows,
164
+ logger,
165
+ });
347
166
  return {
348
- user: await resolveUser(token, authService, addonRegistry, shareTokens, logger, requestFacts(req)),
167
+ user: await resolveUser(extractTokenFromRequest(req), verifier, requestFacts(req)),
349
168
  req,
350
169
  lang: (0, request_lang_js_1.resolveRequestLang)((0, request_lang_js_1.requestLangInputFrom)(req.headers, readQuery(req))),
351
170
  deviceScopeLookup: makeDeviceScopeLookup(addonRegistry),
@@ -357,11 +176,18 @@ async function createTrpcContext(req, authService, addonRegistry, shareTokens =
357
176
  * Token is sent via tRPC connectionParams (a JSON message sent right after
358
177
  * the WS handshake), which is more reliable than query params through proxies.
359
178
  */
360
- async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null) {
179
+ async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null, logger = null, tokenRows = null) {
361
180
  // 1. connectionParams.token (sent by BackendClient's createWSClient)
362
181
  const paramToken = opts.info.connectionParams?.['token'];
363
182
  const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
364
- const user = await resolveUser(token, authService, addonRegistry, shareTokens, logger, requestFacts(opts.req));
183
+ const verifier = hubCredentialVerifier({
184
+ authService,
185
+ addonRegistry,
186
+ shareTokens,
187
+ tokenRows,
188
+ logger,
189
+ });
190
+ const user = await resolveUser(token, verifier, requestFacts(opts.req));
365
191
  const sessionId = user?.sessionId;
366
192
  return {
367
193
  user,
@@ -14,6 +14,7 @@ const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js")
14
14
  const device_config_secret_redaction_js_1 = require("./device-config-secret-redaction.js");
15
15
  const principal_visibility_js_1 = require("./principal-visibility.js");
16
16
  const localized_schema_paths_js_1 = require("./localized-schema-paths.js");
17
+ const principal_authorization_js_1 = require("./principal-authorization.js");
17
18
  const scope_access_js_1 = require("./scope-access.js");
18
19
  const share_view_access_js_1 = require("./share-view-access.js");
19
20
  const user_error_js_1 = require("./user-error.js");
@@ -91,24 +92,20 @@ exports.publicProcedure = t.procedure;
91
92
  */
92
93
  exports.createCallerFactory = t.createCallerFactory;
93
94
  /**
94
- * Caps-only authenticated procedure (v2).
95
+ * The authenticated procedure (D892).
95
96
  *
96
- * - `isAdmin: true` → pass-through. Admin's `scopes` field is ignored.
97
- * - `isAdmin: false` → `METHOD_ACCESS_MAP[path]` lookup + scope match.
98
- * The caller's scope set must grant the required (capName, access)
99
- * pair via one of the three forms (`category`/`capability`/`addon`).
97
+ * The principal's `scopes` decide, through ONE matcher
98
+ * (`authorizeProcedure`, `principal-authorization.ts`):
100
99
  *
101
- * Hand-written core routers (`auth.*`, `system.info`, etc.) are not
102
- * codegen'd from cap definitions and therefore not in
103
- * `METHOD_ACCESS_MAP`. Those routes carry their own gating via
104
- * `adminProcedure` when destructive; the bare `protectedProcedure`
105
- * authentication check is the only gate. The middleware skips the
106
- * scope-check for unknown paths so the SDK boot probe (`auth.me`) and
107
- * `system.info` reach non-admin / scoped-token callers — without
108
- * pulling every core route into the codegen map.
100
+ * - a `share-view` grant restricts it to the fail-closed embed allowlist
101
+ * (`share-view-access.ts`) — enumerated methods, in-scope devices only —
102
+ * and its answers are projected to that perimeter;
103
+ * - `'*'` passes;
104
+ * - otherwise a cap route (`METHOD_ACCESS_MAP`) is scope-matched, and a
105
+ * hand-written core route (`auth.*`, `system.info` …) needs authentication
106
+ * only — `adminProcedure` on top gates what is destructive.
109
107
  *
110
- * Single source of authority for caps: `isAdmin`. The legacy role enum
111
- * collapsed onto this boolean in v2.
108
+ * What kind of credential the principal came from is never read here.
112
109
  */
113
110
  exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput }) => {
114
111
  if (!ctx.user) {
@@ -120,93 +117,55 @@ exports.protectedProcedure = localized.use(async ({ ctx, next, path, getRawInput
120
117
  if (ctx.sessionLive !== undefined && !ctx.sessionLive()) {
121
118
  throw (0, user_error_js_1.trpcUserError)('UNAUTHORIZED', 'auth.session-ended', undefined, 'Session ended — sign in again');
122
119
  }
123
- // Share-view principals (`csv_*` tokens) NEVER reach the admin bypass,
124
- // the hand-written-route pass-through, or the scope matcher below —
125
- // they are gated exclusively by the fail-closed embed-surface allowlist
126
- // (`share-view-access.ts`): enumerated methods only, in-scope devices only.
127
- if (ctx.user.shareView) {
128
- const rawInput = await getRawInput();
129
- const result = (0, share_view_access_js_1.checkShareViewAccess)(ctx.user.shareView.scope, path, rawInput);
130
- if (!result.ok) {
131
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
132
- }
133
- // `deviceManager.listAll` is allowlisted for the embed's tile names,
134
- // but its raw rows describe EVERY device (config incl. credentials).
135
- // Project the output down to the in-scope whitelisted rows.
120
+ const user = ctx.user;
121
+ const verdict = (0, principal_authorization_js_1.authorizeProcedure)(user.scopes ?? [], path, await getRawInput(), ctx.deviceScopeLookup);
122
+ if (!verdict.ok) {
123
+ throw new server_1.TRPCError({ code: 'FORBIDDEN', message: verdict.reason });
124
+ }
125
+ // Spread+reassign of `user` narrows downstream ctx from `User | null` to
126
+ // `User` so `adminProcedure` can read fields without re-checking.
127
+ if (verdict.mode === 'all') {
128
+ return next({ ctx: { ...ctx, user } });
129
+ }
130
+ if (verdict.mode === 'share') {
131
+ const grant = verdict.grant;
132
+ // `deviceManager.listAll` is allowlisted for the embed's tile names, but
133
+ // its raw rows describe EVERY device (config incl. credentials): project
134
+ // it down to the in-scope whitelisted rows. The events surface answers
135
+ // with ROWS carrying a `deviceId`, and two of its methods can reach
136
+ // devices outside the scope — this is where that answer is cut.
136
137
  if (path === 'deviceManager.listAll') {
137
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
138
- if (out.ok) {
139
- return { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, ctx.user.shareView.scope) };
140
- }
141
- return out;
138
+ const out = await next({ ctx: { ...ctx, user } });
139
+ return out.ok ? { ...out, data: (0, share_view_access_js_1.projectListAllForShareScope)(out.data, grant) } : out;
142
140
  }
143
- // The events surface answers with ROWS carrying a `deviceId`, and two of
144
- // its three methods can legitimately reach devices outside the scope: a
145
- // partially in-scope `listRecentTracks`, and a `searchObjectEvents` with
146
- // no `deviceId` at all (which means EVERY camera). The enumeration lets
147
- // those calls through — this is where the answer is cut. Without it the
148
- // allowlist would be a doorman who checks the ticket and then hands over
149
- // the whole building.
150
141
  if (share_view_access_js_1.SHARE_SCOPE_PROJECTED_METHODS.has(path)) {
151
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
152
- if (out.ok) {
153
- return { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, ctx.user.shareView.scope) };
154
- }
155
- return out;
156
- }
157
- return next({ ctx: { ...ctx, user: ctx.user } });
158
- }
159
- // Spread+reassign of `user` narrows downstream ctx from `User | null`
160
- // to `User` so `adminProcedure` can read fields
161
- // without re-checking.
162
- if (ctx.user.isAdmin) {
163
- return next({ ctx: { ...ctx, user: ctx.user } });
164
- }
165
- // Everything below this line is a NON-ADMIN principal.
166
- //
167
- // A hand-written core route has no cap entry. Authentication has
168
- // already passed; defer further gating to any explicit
169
- // `adminProcedure` chained on top of this one. A cap route is
170
- // scope-matched first.
171
- if (path in system_1.METHOD_ACCESS_MAP) {
172
- // Device-scope caps may be gated by a `device:N` scope. Resolve the
173
- // raw input once so the matcher can read `input.deviceId` without
174
- // re-doing the Zod parse (tRPC caches the parsed input downstream).
175
- // The `getDeviceAncestors` hook lets the matcher walk parent → child
176
- // accessory inheritance (grant on Reolink also covers its siren / PIR).
177
- const rawInput = await getRawInput();
178
- const result = (0, scope_access_js_1.checkScopeAccess)(ctx.user.scopes ?? [], path, rawInput, ctx.deviceScopeLookup);
179
- if (!result.ok) {
180
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
142
+ const out = await next({ ctx: { ...ctx, user } });
143
+ return out.ok ? { ...out, data: (0, share_view_access_js_1.projectShareScopeRows)(out.data, grant) } : out;
181
144
  }
145
+ return next({ ctx: { ...ctx, user } });
182
146
  }
183
147
  // A non-admin who is allowed to READ a device is not thereby allowed to
184
148
  // read the credential that opens it. `deviceManager.getDevice` &co. are
185
149
  // `auth: 'protected'`, so a read-only `category:system` grant reaches
186
- // them — and the raw row carries `config.password` in plaintext for
187
- // every camera in the deployment. The value is withheld HERE, at the
188
- // transport boundary, so in-process callers (`ctx.api`, the pipeline
189
- // orchestrator, the drivers that must actually connect) are unaffected.
150
+ // them — and the raw row carries `config.password` in plaintext. The value
151
+ // is withheld HERE, at the transport boundary, so in-process callers
152
+ // (`ctx.api`, the drivers that must actually connect) are unaffected.
190
153
  const redactConfigSecrets = device_config_secret_redaction_js_1.NON_ADMIN_CONFIG_REDACTED_METHODS.get(path);
191
- const out = await next({ ctx: { ...ctx, user: ctx.user } });
154
+ const out = await next({ ctx: { ...ctx, user } });
192
155
  if (!out.ok)
193
156
  return out;
194
157
  // A device-scoped principal ENUMERATES only its own devices (F1 #4). The
195
158
  // matcher lets a `device`-selector grant reach `deviceManager.listAll`
196
- // precisely because the answer is cut here; without this the grant would
197
- // hand back the whole fleet — names, locations, online state.
159
+ // precisely because the answer is cut here.
198
160
  if (scope_access_js_1.DEVICE_ENUMERATION_METHODS.has(path)) {
199
- const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, ctx.user.isAdmin, ctx.user.scopes ?? [], ctx.deviceFleet?.() ?? []);
161
+ const projected = (0, principal_visibility_js_1.projectListAllForPrincipal)(out.data, false, verdict.scopes, ctx.deviceFleet?.() ?? []);
200
162
  return {
201
163
  ...out,
202
164
  data: redactConfigSecrets ? redactConfigSecrets(projected) : projected,
203
165
  };
204
166
  }
205
- // Batch device methods (`deviceIds[]` / `targets[].deviceId` inputs — the
206
- // snapshot overview/links and the merged events feed) need no response
207
- // projection: the matcher already required EVERY device the request names
208
- // to be covered (see `referencedDeviceIds`), so the answer can only
209
- // contain rows the caller is entitled to.
167
+ // Batch device methods need no response projection: the matcher already
168
+ // required EVERY device the request names to be covered.
210
169
  if (redactConfigSecrets) {
211
170
  return { ...out, data: redactConfigSecrets(out.data) };
212
171
  }
@@ -362,6 +362,7 @@ function buildCapabilityRouters(services) {
362
362
  ? { network: services.authNetworkPolicy }
363
363
  : {}),
364
364
  ...(services.sessionBacking !== undefined ? { sessions: services.sessionBacking } : {}),
365
+ credentialTokens: services.credentialTokenService ?? null,
365
366
  logger: services.loggingService.createLogger('auth'),
366
367
  }),
367
368
  // NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes