@camstack/server 1.2.605 → 1.2.606

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.
@@ -32,11 +32,9 @@ const session_cookie_js_1 = require("../../auth/session-cookie.js");
32
32
  const hub_sessions_js_1 = require("../../auth/hub-sessions.js");
33
33
  const session_mint_js_1 = require("../../auth/session-mint.js");
34
34
  const handoff_code_service_js_1 = require("../../core/auth/handoff-code.service.js");
35
- const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
36
- const principal_visibility_js_1 = require("../trpc/principal-visibility.js");
37
35
  const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
38
36
  const auth_limits_js_1 = require("./auth-limits.js");
39
- const auth_admin_tokens_procedures_js_1 = require("./auth-admin-tokens.procedures.js");
37
+ const auth_audit_procedures_js_1 = require("./auth-audit.procedures.js");
40
38
  const auth_tokens_procedures_js_1 = require("./auth-tokens.procedures.js");
41
39
  const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentials.procedures.js");
42
40
  const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
@@ -170,59 +168,14 @@ const OwnExternalIdentitySchema = zod_1.z.object({
170
168
  /** Whether the provider instance is configured and enabled right now. */
171
169
  available: zod_1.z.boolean(),
172
170
  });
173
- // ── Share tokens (grid-view standalone share links) ──────────────────
174
- //
175
- // Wire schemas for the `csv_*` share-token surface. The summary shape
176
- // deliberately omits `tokenHash` — the raw token is returned exactly
177
- // once (at mint); afterwards only prefix + scope + timestamps are
178
- // visible.
179
- //
180
- // TTL: a bounded number of seconds, or the EXPLICIT literal `'never'`
181
- // for a permanently-shared grid (revocation is the kill switch).
182
- // Omitted → 7-day default; infinite is never a silent default.
183
- const CreateShareTokenInputSchema = zod_1.z.object({
184
- scope: share_token_service_js_1.ShareTokenScopeSchema,
185
- ttlSec: zod_1.z
186
- .union([
187
- zod_1.z.number().int().min(share_token_service_js_1.SHARE_TOKEN_TTL_MIN_SEC).max(share_token_service_js_1.SHARE_TOKEN_TTL_MAX_SEC),
188
- zod_1.z.literal('never'),
189
- ])
190
- .optional(),
191
- });
192
- const ShareTokenSummarySchema = zod_1.z.object({
193
- id: zod_1.z.string(),
194
- userId: zod_1.z.string(),
195
- tokenPrefix: zod_1.z.string(),
196
- scope: share_token_service_js_1.ShareTokenScopeSchema,
197
- createdAt: zod_1.z.number(),
198
- /** Epoch ms, or `null` for never-expiring tokens. */
199
- expiresAt: zod_1.z.number().nullable(),
200
- /** Derived convenience flag so listings surface infinite tokens clearly. */
201
- neverExpires: zod_1.z.boolean(),
202
- lastUsedAt: zod_1.z.number().optional(),
203
- /** A pre-D892 `csv_` link: valid until it expires, never minted again. */
204
- legacy: zod_1.z.boolean().optional(),
205
- });
206
- function toShareTokenSummary(record) {
207
- return {
208
- id: record.id,
209
- userId: record.userId,
210
- tokenPrefix: record.tokenPrefix,
211
- scope: record.scope,
212
- createdAt: record.createdAt,
213
- expiresAt: record.expiresAt,
214
- neverExpires: record.expiresAt === null,
215
- ...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
216
- };
217
- }
218
171
  /**
219
172
  * Token minting/management surfaces are for REAL user sessions only.
220
- * Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
221
- * never mint, list, or revoke share links — nor mint handoff codes —
222
- * a leaked restricted token would otherwise widen its own reach.
173
+ * A minted token — whatever its scopes — must never mint handoff codes or
174
+ * link sign-in accounts: a leaked restricted token would otherwise widen its
175
+ * own reach.
223
176
  */
224
- function assertRealUserSession(user, what = 'Share-token management') {
225
- // A minted token (admin, scoped, share — legacy or unified) is a script's
177
+ function assertRealUserSession(user, what) {
178
+ // A minted token (admin, scoped or share-view) is a script's
226
179
  // credential, never a person's: it must not turn itself into a session
227
180
  // (handoff codes) or mint links. Provenance, not permission (D892).
228
181
  if (user.userDelegated !== true) {
@@ -301,7 +254,7 @@ const MeSchema = zod_1.z
301
254
  scope: EffectiveScopeSchema.optional(),
302
255
  })
303
256
  .nullable();
304
- function createAuthRouter(auth, registry, moleculer = null, shareTokens = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents(), nativeLink = null, options = {}) {
257
+ function createAuthRouter(auth, registry, moleculer = null, handoffCodes = new handoff_code_service_js_1.HandoffCodeService(), linkIntents = new external_link_intents_js_1.ExternalLinkIntents(), nativeLink = null, options = {}) {
305
258
  const network = options.network ?? auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY;
306
259
  const limiters = options.limiters ?? auth_limits_js_1.PROCESS_AUTH_LIMITERS;
307
260
  /** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
@@ -309,20 +262,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
309
262
  /** Challenge-minting begin-legs — looser, they verify nothing. */
310
263
  const ceremonyProcedure = makeRateLimitedProcedure(limiters.ceremony, network);
311
264
  const credentialTokens = options.credentialTokens ?? null;
312
- const tokenDeps = { auth, registry, network, credentialTokens, shareTokens };
313
- /** The unified share link behind `id`, or `null` (a legacy `csv_`, or none). */
314
- const unifiedShare = async (id) => {
315
- if (credentialTokens === null)
316
- return null;
317
- const record = await credentialTokens.get(id);
318
- return record !== null && (0, types_2.shareViewGrantOf)(record.scopes) !== null ? record : null;
319
- };
320
- const requireShareTokens = () => {
321
- if (!shareTokens) {
322
- throw new Error('Share tokens unavailable — service not wired on this node');
323
- }
324
- return shareTokens;
325
- };
326
265
  /**
327
266
  * THE session mint (`auth/session-mint.ts`, D712) — the single shared tail
328
267
  * of every successful login leg, including `/api/auth/sso/finish`. It
@@ -800,8 +739,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
800
739
  * semantics (see `buildSessionCookie` — Lax already withholds
801
740
  * the cookie from cross-site POSTs).
802
741
  * • Only REAL session JWTs qualify: bridge tokens (`kind:
803
- * 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
804
- * tokens are rejected — the cookie must carry a v2 session.
742
+ * 'totp-challenge'` / `'sso-bridge'`) and minted tokens are
743
+ * rejected — the cookie must carry a v2 session.
805
744
  * • The returned bearer is minted FRESH through the shared
806
745
  * `mintSessionForUserId` tail (re-fetched user → up-to-date
807
746
  * scopes), equivalent to what `auth.login` hands out.
@@ -851,8 +790,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
851
790
  // for a real session. TTL 60s, single-use, bound to the minting
852
791
  // user — see `HandoffCodeService`.
853
792
  /** Mint a one-time handoff code for the CALLING user. Real user
854
- * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
855
- * must never convert themselves into a full session. */
793
+ * sessions only — a minted token (scoped, admin or share-view)
794
+ * must never convert itself into a full session. */
856
795
  createHandoffCode: trpc_middleware_js_1.protectedProcedure
857
796
  .input(zod_1.z
858
797
  .object({
@@ -1191,211 +1130,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1191
1130
  enabled: input.enabled,
1192
1131
  });
1193
1132
  }),
1194
- // ── Share tokens — scoped, TTL'd view tokens for standalone share
1195
- // links (grid rework R6). Minted by any REAL user session (admin
1196
- // or regular); the resulting `csv_*` token authenticates as a
1197
- // restricted `share-view` principal limited to the embed surface
1198
- // + its in-scope devices (see `share-view-access.ts`). ──────────
1199
- createShareToken: trpc_middleware_js_1.protectedProcedure
1200
- .input(CreateShareTokenInputSchema)
1201
- .output(zod_1.z.object({ id: zod_1.z.string(), token: zod_1.z.string(), expiresAt: zod_1.z.number().nullable() }))
1202
- .mutation(async ({ input, ctx }) => {
1203
- assertRealUserSession(ctx.user);
1204
- // SECURITY (F0.4): a share token is a PRIVILEGE HANDOFF — it authorises
1205
- // whoever holds the link to view its devices with no further auth. A
1206
- // scoped (non-admin) minter must therefore not be able to mint for a
1207
- // device it cannot itself see: without this, a `device:[5]` token could
1208
- // mint a `csv_*` grid link for cameras 5..99. Admins mint anything;
1209
- // every other caller's requested deviceIds are INTERSECTED with what
1210
- // they can view (device:[parent] covers child accessories via the
1211
- // ancestor walk). An empty intersection is a refusal, not a silent
1212
- // "share nothing".
1213
- const scope = ctx.user.isAdmin
1214
- ? input.scope
1215
- : {
1216
- ...input.scope,
1217
- deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)((0, types_2.capabilityScopesOf)(ctx.user.scopes ?? []), input.scope.deviceIds, ctx.deviceScopeLookup),
1218
- };
1219
- if (scope.deviceIds.length === 0) {
1220
- throw new server_1.TRPCError({
1221
- code: 'FORBIDDEN',
1222
- message: 'None of the requested devices are within your scope — you cannot mint a share token for devices you cannot see',
1223
- });
1224
- }
1225
- // A thin wrapper for one release (D892): the link is a unified token
1226
- // whose only scope is the share-view grant. Without the store (a
1227
- // node that cannot mint) it is still a legacy `csv_`.
1228
- if (credentialTokens !== null) {
1229
- const ttl = input.ttlSec ?? share_token_service_js_1.SHARE_TOKEN_TTL_DEFAULT_SEC;
1230
- const minted = await (0, auth_tokens_procedures_js_1.mintFor)(tokenDeps, ctx, {
1231
- scopes: [{ type: 'share-view', grant: scope }],
1232
- expiresIn: ttl,
1233
- label: `share ${scope.kind}`,
1234
- allowAccountLink: true,
1235
- });
1236
- return { id: minted.record.id, token: minted.token, expiresAt: minted.record.expiresAt };
1237
- }
1238
- const { token, record } = await requireShareTokens().create({
1239
- userId: ctx.user.id,
1240
- scope,
1241
- ...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
1242
- });
1243
- return { id: record.id, token, expiresAt: record.expiresAt };
1244
- }),
1245
- /**
1246
- * Slide expiry on a still-valid share token (same `csv_*`). Used by the
1247
- * Home Assistant embed cache so a long-lived dashboard does not mint a
1248
- * new row every hour. Expired / missing → NOT_FOUND (caller must create).
1249
- */
1250
- extendShareToken: trpc_middleware_js_1.protectedProcedure
1251
- .input(zod_1.z.object({
1252
- id: zod_1.z.string().min(1),
1253
- ttlSec: zod_1.z.number().int().min(share_token_service_js_1.SHARE_TOKEN_TTL_MIN_SEC).max(share_token_service_js_1.SHARE_TOKEN_TTL_MAX_SEC),
1254
- }))
1255
- .output(zod_1.z.object({ id: zod_1.z.string(), expiresAt: zod_1.z.number() }))
1256
- .mutation(async ({ input, ctx }) => {
1257
- assertRealUserSession(ctx.user);
1258
- const unified = await unifiedShare(input.id);
1259
- if (unified !== null && credentialTokens !== null) {
1260
- if (!ctx.user.isAdmin && unified.subjectId !== ctx.user.id) {
1261
- throw new server_1.TRPCError({
1262
- code: 'FORBIDDEN',
1263
- message: 'Only the token owner or an admin can extend a share token',
1264
- });
1265
- }
1266
- if (unified.expiresAt === null) {
1267
- throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
1268
- }
1269
- const moved = await credentialTokens.setExpiry(input.id, Date.now() + input.ttlSec * 1000);
1270
- if (moved === null || moved.expiresAt === null) {
1271
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message: `Share token expired: ${input.id}` });
1272
- }
1273
- return { id: moved.id, expiresAt: moved.expiresAt };
1274
- }
1275
- const service = requireShareTokens();
1276
- try {
1277
- const record = await service.extend({
1278
- id: input.id,
1279
- ttlSec: input.ttlSec,
1280
- callerUserId: ctx.user.id,
1281
- callerIsAdmin: ctx.user.isAdmin,
1282
- });
1283
- if (record.expiresAt === null) {
1284
- throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
1285
- }
1286
- return { id: record.id, expiresAt: record.expiresAt };
1287
- }
1288
- catch (error) {
1289
- const message = error instanceof Error ? error.message : 'Share token extend failed';
1290
- if (/not found|expired/i.test(message)) {
1291
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message });
1292
- }
1293
- if (/owner or an admin|never-expiring/i.test(message)) {
1294
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message });
1295
- }
1296
- throw new server_1.TRPCError({ code: 'BAD_REQUEST', message });
1297
- }
1298
- }),
1299
- revokeShareToken: trpc_middleware_js_1.protectedProcedure
1300
- .input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
1301
- .output(zod_1.z.object({ success: zod_1.z.boolean() }))
1302
- .mutation(async ({ input, ctx }) => {
1303
- assertRealUserSession(ctx.user);
1304
- const unified = await unifiedShare(input.id);
1305
- if (unified !== null && credentialTokens !== null) {
1306
- if (!ctx.user.isAdmin && unified.subjectId !== ctx.user.id) {
1307
- throw new server_1.TRPCError({
1308
- code: 'FORBIDDEN',
1309
- message: 'Only the token owner or an admin can revoke a share token',
1310
- });
1311
- }
1312
- return { success: await credentialTokens.revoke(input.id) };
1313
- }
1314
- const service = requireShareTokens();
1315
- try {
1316
- const removed = await service.revoke({
1317
- id: input.id,
1318
- callerUserId: ctx.user.id,
1319
- callerIsAdmin: ctx.user.isAdmin,
1320
- });
1321
- if (removed) {
1322
- const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
1323
- if (internal !== null) {
1324
- const ip = network.clientAddress(ctx.req).ip;
1325
- await internal.audit.record({
1326
- action: 'credentials-revoked',
1327
- userId: ctx.user.id,
1328
- ...(ip !== null ? { ip } : {}),
1329
- detail: {
1330
- kind: 'share-link',
1331
- requested: 1,
1332
- revoked: 1,
1333
- skipped: 0,
1334
- ids: input.id,
1335
- },
1336
- });
1337
- }
1338
- }
1339
- return { success: removed };
1340
- }
1341
- catch (error) {
1342
- throw new server_1.TRPCError({
1343
- code: 'FORBIDDEN',
1344
- message: error instanceof Error ? error.message : 'Share token revoke failed',
1345
- });
1346
- }
1347
- }),
1348
- /**
1349
- * Share links. `owner: 'self'` — the caller's own (My access);
1350
- * `owner: 'all'` — every user's, admin only. Omitted keeps the original
1351
- * answer (own for a user, all for an admin) for clients that predate it.
1352
- */
1353
- listShareTokens: trpc_middleware_js_1.protectedProcedure
1354
- .input(zod_1.z.object({ owner: zod_1.z.enum(['self', 'all']).optional() }).optional())
1355
- .output(zod_1.z.array(ShareTokenSummarySchema))
1356
- .query(async ({ ctx, input }) => {
1357
- assertRealUserSession(ctx.user);
1358
- // The legacy `csv_` store, or — on a node with only the unified store —
1359
- // none; with neither, the clear refusal below.
1360
- const service = credentialTokens === null ? requireShareTokens() : shareTokens;
1361
- const owner = input?.owner ?? (ctx.user.isAdmin ? 'all' : 'self');
1362
- if (owner === 'all' && !ctx.user.isAdmin) {
1363
- throw new server_1.TRPCError({
1364
- code: 'FORBIDDEN',
1365
- message: "Listing every user's share links requires an admin",
1366
- });
1367
- }
1368
- const records = service === null
1369
- ? []
1370
- : owner === 'all'
1371
- ? await service.listAll()
1372
- : await service.listForUser(ctx.user.id);
1373
- const unified = credentialTokens === null
1374
- ? []
1375
- : await credentialTokens.list(owner === 'all' ? undefined : ctx.user.id);
1376
- const unifiedRows = unified.flatMap((record) => {
1377
- const grant = (0, types_2.shareViewGrantOf)(record.scopes);
1378
- if (grant === null || record.revokedAt !== null)
1379
- return [];
1380
- return [
1381
- {
1382
- id: record.id,
1383
- userId: record.subjectId,
1384
- tokenPrefix: `token:${record.id.slice(0, 8)}`,
1385
- scope: grant,
1386
- createdAt: record.createdAt,
1387
- expiresAt: record.expiresAt,
1388
- neverExpires: record.expiresAt === null,
1389
- ...(record.lastUsedAt !== null ? { lastUsedAt: record.lastUsedAt } : {}),
1390
- legacy: false,
1391
- },
1392
- ];
1393
- });
1394
- return [
1395
- ...unifiedRows,
1396
- ...records.map((record) => ({ ...toShareTokenSummary(record), legacy: true })),
1397
- ];
1398
- }),
1399
1133
  ...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
1400
1134
  ...(0, auth_device_cert_procedures_js_1.createDeviceCertProcedures)({
1401
1135
  registry,
@@ -1413,9 +1147,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1413
1147
  issueFor,
1414
1148
  }),
1415
1149
  ...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
1416
- ...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network, credentialTokens, shareTokens }),
1417
- ...(0, auth_tokens_procedures_js_1.createTokenProcedures)({ auth, registry, network, credentialTokens, shareTokens }),
1418
- ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, shareTokens, network, credentialTokens }),
1150
+ ...(0, auth_audit_procedures_js_1.createAuthAuditProcedures)({ registry }),
1151
+ ...(0, auth_tokens_procedures_js_1.createTokenProcedures)({ auth, registry, network, credentialTokens }),
1152
+ ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, network, credentialTokens }),
1419
1153
  // ── External sign-in identities (D782) ────────────────────────
1420
1154
  //
1421
1155
  // A signed-in user links an OIDC identity to THEIR OWN account. The
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.preferencesFromRegistry = preferencesFromRegistry;
4
+ exports.preferencesOwnerOf = preferencesOwnerOf;
5
+ exports.createPreferencesRouter = createPreferencesRouter;
6
+ /**
7
+ * Account preferences — fixed core API (D894), not a capability.
8
+ *
9
+ * get the caller's rows (all, or the namespaces asked)
10
+ * put write one namespace; `baseRevision` turns it into a
11
+ * compare-and-set that answers `conflict` with the current row
12
+ * delete remove one namespace
13
+ * changedSince the cheap poll: only rows the client does not hold yet
14
+ *
15
+ * There is no `userId` input anywhere: every call acts on the PRINCIPAL's own
16
+ * rows, admin included — an admin cannot read another user's preferences
17
+ * through this router, and nothing else serves them. The principal must hold
18
+ * `preferences-self` (every person's session does; a unified token only when
19
+ * minted with it), so a share link, a legacy token, an account link and the
20
+ * hub's own mesh principal are refused.
21
+ *
22
+ * Sync is by polling `changedSince`: there is no per-user push channel to a
23
+ * user's sessions, and the event bus is fleet-wide telemetry (D8).
24
+ *
25
+ * The rows live in `local-auth` (it owns the users, and a user's delete wipes
26
+ * them), reached through the hub-internal half of `user-management`.
27
+ */
28
+ const system_1 = require("@camstack/system");
29
+ const types_1 = require("@camstack/types");
30
+ const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
31
+ const user_error_js_1 = require("../trpc/user-error.js");
32
+ /** The store behind the hub's in-process `user-management` provider. */
33
+ function preferencesFromRegistry(registry) {
34
+ return () => registry === null
35
+ ? null
36
+ : ((0, system_1.getUserManagementInternal)(registry.getSingleton('user-management'))?.preferences ?? null);
37
+ }
38
+ /** The user whose rows this call may touch: the principal itself, or a refusal. */
39
+ function preferencesOwnerOf(principal) {
40
+ if (!(0, types_1.scopesAllowOwnPreferences)(principal.scopes ?? [])) {
41
+ throw (0, user_error_js_1.trpcUserError)('FORBIDDEN', 'preferences.forbidden', undefined, 'This credential cannot reach account preferences');
42
+ }
43
+ return principal.id;
44
+ }
45
+ function storeOf(deps) {
46
+ const store = deps.preferences();
47
+ if (store === null) {
48
+ throw (0, user_error_js_1.trpcUserError)('PRECONDITION_FAILED', 'preferences.unavailable', undefined, 'Account preferences are not available');
49
+ }
50
+ return store;
51
+ }
52
+ function limitRefusal(error) {
53
+ const reason = (0, system_1.preferencesLimitReasonOf)(error);
54
+ if (reason === 'value-too-large') {
55
+ throw (0, user_error_js_1.trpcUserError)('PAYLOAD_TOO_LARGE', 'preferences.value-too-large', undefined, 'This preference value is too large');
56
+ }
57
+ if (reason === 'too-many-namespaces') {
58
+ throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'preferences.too-many-namespaces', undefined, 'Too many preference namespaces for this account');
59
+ }
60
+ throw error;
61
+ }
62
+ function createPreferencesRouter(deps) {
63
+ return (0, trpc_middleware_js_1.trpcRouter)({
64
+ get: trpc_middleware_js_1.protectedProcedure
65
+ .input(types_1.PreferencesGetInputSchema)
66
+ .output(types_1.PreferencesGetOutputSchema)
67
+ .query(async ({ ctx, input }) => {
68
+ const owner = preferencesOwnerOf(ctx.user);
69
+ return { rows: [...(await storeOf(deps).get(owner, input.namespaces))] };
70
+ }),
71
+ put: trpc_middleware_js_1.protectedProcedure
72
+ .input(types_1.PreferencesPutInputSchema)
73
+ .output(types_1.PreferencesPutOutputSchema)
74
+ .mutation(async ({ ctx, input }) => {
75
+ const owner = preferencesOwnerOf(ctx.user);
76
+ try {
77
+ return await storeOf(deps).put(owner, input);
78
+ }
79
+ catch (error) {
80
+ return limitRefusal(error);
81
+ }
82
+ }),
83
+ delete: trpc_middleware_js_1.protectedProcedure
84
+ .input(types_1.PreferencesDeleteInputSchema)
85
+ .output(types_1.PreferencesDeleteOutputSchema)
86
+ .mutation(async ({ ctx, input }) => {
87
+ const owner = preferencesOwnerOf(ctx.user);
88
+ return { deleted: await storeOf(deps).delete(owner, input.namespace) };
89
+ }),
90
+ changedSince: trpc_middleware_js_1.protectedProcedure
91
+ .input(types_1.PreferencesChangedSinceInputSchema)
92
+ .output(types_1.PreferencesChangedSinceOutputSchema)
93
+ .query(async ({ ctx, input }) => {
94
+ const owner = preferencesOwnerOf(ctx.user);
95
+ return storeOf(deps).changedSince(owner, input.revisionByNamespace);
96
+ }),
97
+ });
98
+ }
@@ -269,6 +269,35 @@ function answerRefusal(request, reply, status, body, reason, integration) {
269
269
  }
270
270
  return reply.status(status).send(body);
271
271
  }
272
+ /**
273
+ * Who consents. ONLY a person's login session: a session-grade JWT (no
274
+ * `kind` — not a bridge, a challenge or a minted token) naming a user that
275
+ * still exists. Anything else this hub signed — a share link, a script's
276
+ * token, an account link — is refused, because a consent mints an account
277
+ * link with the integration's whole grant (security review 2026-10-21: a
278
+ * share link set as the cookie reached `consent=allow` and came back with a
279
+ * fleet-wide device grant for `userId: ''`).
280
+ */
281
+ async function consentingUserOf(deps, cookie) {
282
+ let payload;
283
+ try {
284
+ payload = deps.verifyToken(cookie);
285
+ }
286
+ catch {
287
+ return { ok: false, reason: 'session-invalid' };
288
+ }
289
+ if (typeof payload !== 'object' || payload === null || !(0, session_cookie_js_1.isSessionGradeJwtPayload)(payload)) {
290
+ return { ok: false, reason: 'not-a-login-session' };
291
+ }
292
+ const userId = Reflect.get(payload, 'userId');
293
+ const username = Reflect.get(payload, 'username');
294
+ if (typeof userId !== 'string' || userId === '') {
295
+ return { ok: false, reason: 'not-a-login-session' };
296
+ }
297
+ if (!(await deps.isLiveUser(userId)))
298
+ return { ok: false, reason: 'user-gone' };
299
+ return { ok: true, user: { userId, username: typeof username === 'string' ? username : '' } };
300
+ }
272
301
  /**
273
302
  * An authorize call turned away for want of a usable session.
274
303
  *
@@ -330,13 +359,11 @@ function registerOauth2Routes(fastify, deps) {
330
359
  if (!cookie) {
331
360
  return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
332
361
  }
333
- let tokenInfo;
334
- try {
335
- tokenInfo = deps.verifyToken(cookie);
336
- }
337
- catch {
338
- return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
362
+ const consenting = await consentingUserOf(deps, cookie);
363
+ if (!consenting.ok) {
364
+ return refuseUnauthenticated(request, reply, deps.logger, call, consenting.reason);
339
365
  }
366
+ const tokenInfo = consenting.user;
340
367
  const registry = deps.getRegistry();
341
368
  if (!registry) {
342
369
  return answerRefusal(request, reply, 503, { error: 'service_unavailable' }, 'unavailable');
@@ -367,7 +394,7 @@ function registerOauth2Routes(fastify, deps) {
367
394
  acceptLanguage: request.headers['accept-language'],
368
395
  }),
369
396
  displayName: descriptor.displayName,
370
- username: tokenInfo.username ?? '',
397
+ username: tokenInfo.username,
371
398
  scopeSummary: summariseScopes(descriptor.requestedScopes),
372
399
  // Shown, not merely validated. A `redirect_uri` on a private host is
373
400
  // admitted by pattern rather than by registration, so the operator is
@@ -428,13 +455,11 @@ function registerOauth2Routes(fastify, deps) {
428
455
  if (!cookie) {
429
456
  return refuseUnauthenticated(request, reply, deps.logger, call, 'no-session-cookie');
430
457
  }
431
- let tokenInfo;
432
- try {
433
- tokenInfo = deps.verifyToken(cookie);
434
- }
435
- catch {
436
- return refuseUnauthenticated(request, reply, deps.logger, call, 'session-invalid');
458
+ const consenting = await consentingUserOf(deps, cookie);
459
+ if (!consenting.ok) {
460
+ return refuseUnauthenticated(request, reply, deps.logger, call, consenting.reason);
437
461
  }
462
+ const tokenInfo = consenting.user;
438
463
  const registry = deps.getRegistry();
439
464
  if (!registry) {
440
465
  return answerRefusal(request, reply, 503, { error: 'service_unavailable' }, 'unavailable');
@@ -478,8 +503,8 @@ function registerOauth2Routes(fastify, deps) {
478
503
  }
479
504
  const { code } = await userMgmt.oauthIssueCode({
480
505
  integrationId: v.integration,
481
- userId: tokenInfo.userId ?? '',
482
- username: tokenInfo.username ?? '',
506
+ userId: tokenInfo.userId,
507
+ username: tokenInfo.username,
483
508
  scopes: descriptor.requestedScopes,
484
509
  redirectUri: v.redirectUri,
485
510
  // Prefer the integration's own public origin (e.g. the operator-selected
@@ -10,7 +10,7 @@
10
10
  *
11
11
  * Those methods are declared `auth: 'protected'` (the default in
12
12
  * `capability-definition.ts`), which is NOT admin — it is "authenticated, and
13
- * scope-matched". Measured against the live hub: a `cst_*` scoped token
13
+ * scope-matched". Measured against the live hub: a scoped token
14
14
  * carrying nothing but `category:system` + `view` — a READ-ONLY grant, the one
15
15
  * the deploy runbook mints for CI — read `deviceManager.getDevice` and received
16
16
  * `config.password` verbatim, and `deviceManager.listAll` answered with a
@@ -31,7 +31,7 @@
31
31
  * write-only for admins too is a bigger, UI-side change; it is not needed to
32
32
  * close this hole and would regress the form.
33
33
  *
34
- * Share-view (`csv_*`) principals never reach this code — they are gated
34
+ * Share-view principals never reach this code — they are gated
35
35
  * earlier by the fail-closed allowlist in `share-view-access.ts`, which does
36
36
  * not expose `getDevice` at all and projects `listAll` down to seven presentation
37
37
  * fields. This module is the gate for the tier BETWEEN a share link and an admin.
@@ -314,7 +314,6 @@ function mountAllCaps(services) {
314
314
  ...first,
315
315
  listProfileKinds: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listProfileKinds'),
316
316
  listProfiles: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listProfiles'),
317
- listModels: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listModels'),
318
317
  getDefaults: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'getDefaults'),
319
318
  getUsage: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'getUsage'),
320
319
  listModelCatalog: (0, cap_mount_helpers_js_1.concatCollection)(providers, 'listModelCatalog'),
@@ -5300,10 +5300,10 @@ function createCapRouter_llm(getProvider, _createRemoteProxy) {
5300
5300
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5301
5301
  return p.testProfile(input);
5302
5302
  }),
5303
- listModels: trpc_middleware_js_1.protectedProcedure
5303
+ listModels: trpc_middleware_js_1.adminProcedure
5304
5304
  .input(types_69.llmCapability.methods.listModels.input.loose())
5305
5305
  .output(types_69.llmCapability.methods.listModels.output)
5306
- .query(async ({ input, ctx }) => {
5306
+ .mutation(async ({ input, ctx }) => {
5307
5307
  const p = requireCapProvider('llm', () => getProvider(ctx));
5308
5308
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
5309
5309
  return p.listModels(input);
@@ -12446,42 +12446,6 @@ function createCapRouter_userManagement(getProvider, createRemoteProxy) {
12446
12446
  // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
12447
12447
  return p.revokeApiKey(methodInput);
12448
12448
  }),
12449
- createScopedToken: trpc_middleware_js_1.adminProcedure
12450
- .input(types_142.userManagementCapability.methods.createScopedToken.input.loose())
12451
- .output(types_142.userManagementCapability.methods.createScopedToken.output)
12452
- .mutation(async ({ input, ctx }) => {
12453
- const { nodeId, ...methodInput } = input;
12454
- const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
12455
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
12456
- return p.createScopedToken(methodInput);
12457
- }),
12458
- revokeScopedToken: trpc_middleware_js_1.adminProcedure
12459
- .input(types_142.userManagementCapability.methods.revokeScopedToken.input.loose())
12460
- .output(types_142.userManagementCapability.methods.revokeScopedToken.output)
12461
- .mutation(async ({ input, ctx }) => {
12462
- const { nodeId, ...methodInput } = input;
12463
- const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
12464
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
12465
- return p.revokeScopedToken(methodInput);
12466
- }),
12467
- validateScopedToken: trpc_middleware_js_1.protectedProcedure
12468
- .input(types_142.userManagementCapability.methods.validateScopedToken.input.loose())
12469
- .output(types_142.userManagementCapability.methods.validateScopedToken.output)
12470
- .query(async ({ input, ctx }) => {
12471
- const { nodeId, ...methodInput } = input;
12472
- const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
12473
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
12474
- return p.validateScopedToken(methodInput);
12475
- }),
12476
- listScopedTokens: trpc_middleware_js_1.adminProcedure
12477
- .input(types_142.userManagementCapability.methods.listScopedTokens.input.loose())
12478
- .output(types_142.userManagementCapability.methods.listScopedTokens.output)
12479
- .query(async ({ input, ctx }) => {
12480
- const { nodeId, ...methodInput } = input;
12481
- const p = resolveProvider('user-management', nodeId, () => getProvider(ctx), createRemoteProxy);
12482
- // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-argument
12483
- return p.listScopedTokens(methodInput);
12484
- }),
12485
12449
  setupTotp: trpc_middleware_js_1.adminProcedure
12486
12450
  .input(types_142.userManagementCapability.methods.setupTotp.input.loose())
12487
12451
  .output(types_142.userManagementCapability.methods.setupTotp.output)
@@ -10,7 +10,7 @@ exports.scopedEventVisibility = scopedEventVisibility;
10
10
  * Non-admin principal device visibility — the shared answer to "which cameras
11
11
  * may this scoped caller see?", reused by two hotfixes:
12
12
  *
13
- * - F0.4 `auth.createShareToken` — a scoped user must not mint a `csv_*`
13
+ * - F0.4 `auth.mintToken` (a share-view grant) — a scoped user must not mint a share link
14
14
  * share token for a device it cannot see. The requested deviceIds are
15
15
  * INTERSECTED with what the caller can view.
16
16
  * - F0.5 the event firehose (`live.onEvent`, `systemEvents.subscribe`, …) —
@@ -141,7 +141,7 @@ function projectListAllForPrincipal(data, isAdmin, scopes, fleet) {
141
141
  * view; an event with NO device identity is DROPPED (fail closed — "probably in
142
142
  * scope" is not a security argument).
143
143
  *
144
- * Share-view (`csv_*`) principals are NOT handled here — they keep their own,
144
+ * Share-view principals are NOT handled here — they keep their own,
145
145
  * stricter `liveEventInShareScope` filter, applied by the routers first.
146
146
  */
147
147
  function scopedEventVisibility(user, lookup) {
@@ -181,7 +181,7 @@ function projectListAllForShareScope(data, scope) {
181
181
  * The projection ENGINE: drop every `listAll` row whose id is not in
182
182
  * `allowed`, and project the survivors onto the whitelisted row.
183
183
  *
184
- * Shared by the `csv_*` share path ({@link projectListAllForShareScope}) and
184
+ * Shared by the share-link path ({@link projectListAllForShareScope}) and
185
185
  * the scoped-principal path (`projectListAllForPrincipal` in
186
186
  * `principal-visibility.ts`) so enumeration is cut ONE way. Two projectors
187
187
  * would be two chances to forget `config` — which carries the camera