@camstack/server 1.2.604 → 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.
Files changed (38) hide show
  1. package/dist/api/auth-whoami.js +27 -43
  2. package/dist/api/core/auth-audit.procedures.js +44 -0
  3. package/dist/api/core/auth-device-cert.procedures.js +1 -1
  4. package/dist/api/core/auth-reauth.procedures.js +1 -1
  5. package/dist/api/core/auth-revoke-credentials.procedures.js +25 -52
  6. package/dist/api/core/auth-sessions.procedures.js +1 -1
  7. package/dist/api/core/auth-tokens.procedures.js +283 -0
  8. package/dist/api/core/auth.router.js +20 -203
  9. package/dist/api/core/live-events.router.js +1 -1
  10. package/dist/api/core/preferences.router.js +98 -0
  11. package/dist/api/oauth2/oauth2-routes.js +40 -15
  12. package/dist/api/trpc/device-config-secret-redaction.js +2 -2
  13. package/dist/api/trpc/generated-cap-mounts.js +0 -1
  14. package/dist/api/trpc/generated-cap-routers.js +2 -38
  15. package/dist/api/trpc/principal-authorization.js +82 -0
  16. package/dist/api/trpc/principal-visibility.js +3 -3
  17. package/dist/api/trpc/share-view-access.js +1 -1
  18. package/dist/api/trpc/trpc-error-principal.js +9 -9
  19. package/dist/api/trpc/trpc.context.js +87 -258
  20. package/dist/api/trpc/trpc.middleware.js +42 -83
  21. package/dist/api/trpc/trpc.router.js +7 -1
  22. package/dist/api/upload-auth.js +19 -54
  23. package/dist/auth/addon-route-auth.js +44 -0
  24. package/dist/auth/addon-route-share-gate.js +18 -16
  25. package/dist/auth/credential-verifier.js +236 -0
  26. package/dist/auth/embed-auth-route.js +45 -0
  27. package/dist/auth/live-user.js +19 -0
  28. package/dist/auth/scope-escalation.js +133 -0
  29. package/dist/boot/boot-config.js +8 -1
  30. package/dist/core/addon/addon-registry.service.js +18 -7
  31. package/dist/core/auth/credential-token.service.js +245 -0
  32. package/dist/core/auth/handoff-code.service.js +1 -1
  33. package/dist/core/auth/record-kinds.js +38 -2
  34. package/dist/main.js +65 -141
  35. package/package.json +14 -14
  36. package/dist/api/core/auth-admin-tokens.procedures.js +0 -138
  37. package/dist/auth/addon-route-jwt-gate.js +0 -59
  38. package/dist/core/auth/share-token.service.js +0 -459
@@ -32,11 +32,10 @@ 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");
38
+ const auth_tokens_procedures_js_1 = require("./auth-tokens.procedures.js");
40
39
  const auth_revoke_credentials_procedures_js_1 = require("./auth-revoke-credentials.procedures.js");
41
40
  const auth_reauth_procedures_js_1 = require("./auth-reauth.procedures.js");
42
41
  const auth_device_cert_procedures_js_1 = require("./auth-device-cert.procedures.js");
@@ -169,59 +168,17 @@ const OwnExternalIdentitySchema = zod_1.z.object({
169
168
  /** Whether the provider instance is configured and enabled right now. */
170
169
  available: zod_1.z.boolean(),
171
170
  });
172
- // ── Share tokens (grid-view standalone share links) ──────────────────
173
- //
174
- // Wire schemas for the `csv_*` share-token surface. The summary shape
175
- // deliberately omits `tokenHash` — the raw token is returned exactly
176
- // once (at mint); afterwards only prefix + scope + timestamps are
177
- // visible.
178
- //
179
- // TTL: a bounded number of seconds, or the EXPLICIT literal `'never'`
180
- // for a permanently-shared grid (revocation is the kill switch).
181
- // Omitted → 7-day default; infinite is never a silent default.
182
- const CreateShareTokenInputSchema = zod_1.z.object({
183
- scope: share_token_service_js_1.ShareTokenScopeSchema,
184
- ttlSec: zod_1.z
185
- .union([
186
- 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),
187
- zod_1.z.literal('never'),
188
- ])
189
- .optional(),
190
- });
191
- const ShareTokenSummarySchema = zod_1.z.object({
192
- id: zod_1.z.string(),
193
- userId: zod_1.z.string(),
194
- tokenPrefix: zod_1.z.string(),
195
- scope: share_token_service_js_1.ShareTokenScopeSchema,
196
- createdAt: zod_1.z.number(),
197
- /** Epoch ms, or `null` for never-expiring tokens. */
198
- expiresAt: zod_1.z.number().nullable(),
199
- /** Derived convenience flag so listings surface infinite tokens clearly. */
200
- neverExpires: zod_1.z.boolean(),
201
- lastUsedAt: zod_1.z.number().optional(),
202
- });
203
- function toShareTokenSummary(record) {
204
- return {
205
- id: record.id,
206
- userId: record.userId,
207
- tokenPrefix: record.tokenPrefix,
208
- scope: record.scope,
209
- createdAt: record.createdAt,
210
- expiresAt: record.expiresAt,
211
- neverExpires: record.expiresAt === null,
212
- ...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
213
- };
214
- }
215
171
  /**
216
172
  * Token minting/management surfaces are for REAL user sessions only.
217
- * Scoped API tokens (`cst_*`) and share-view principals (`csv_*`) must
218
- * never mint, list, or revoke share links — nor mint handoff codes —
219
- * 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.
220
176
  */
221
- function assertRealUserSession(user, what = 'Share-token management') {
222
- // An admin token is a script's credential, never a person's session: it
223
- // must not turn itself into a session (handoff codes) or mint links.
224
- if (user.isScoped || user.shareView || user.adminTokenId !== undefined) {
177
+ function assertRealUserSession(user, what) {
178
+ // A minted token (admin, scoped or share-view) is a script's
179
+ // credential, never a person's: it must not turn itself into a session
180
+ // (handoff codes) or mint links. Provenance, not permission (D892).
181
+ if (user.userDelegated !== true) {
225
182
  throw new server_1.TRPCError({
226
183
  code: 'FORBIDDEN',
227
184
  message: `${what} requires a real user session`,
@@ -297,19 +254,14 @@ const MeSchema = zod_1.z
297
254
  scope: EffectiveScopeSchema.optional(),
298
255
  })
299
256
  .nullable();
300
- 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 = {}) {
301
258
  const network = options.network ?? auth_network_policy_js_1.DEFAULT_AUTH_NETWORK_POLICY;
302
259
  const limiters = options.limiters ?? auth_limits_js_1.PROCESS_AUTH_LIMITERS;
303
260
  /** Secret-verifying legs (password / TOTP / passkey assertion / codes). */
304
261
  const credentialProcedure = makeRateLimitedProcedure(limiters.credential, network);
305
262
  /** Challenge-minting begin-legs — looser, they verify nothing. */
306
263
  const ceremonyProcedure = makeRateLimitedProcedure(limiters.ceremony, network);
307
- const requireShareTokens = () => {
308
- if (!shareTokens) {
309
- throw new Error('Share tokens unavailable — service not wired on this node');
310
- }
311
- return shareTokens;
312
- };
264
+ const credentialTokens = options.credentialTokens ?? null;
313
265
  /**
314
266
  * THE session mint (`auth/session-mint.ts`, D712) — the single shared tail
315
267
  * of every successful login leg, including `/api/auth/sso/finish`. It
@@ -787,8 +739,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
787
739
  * semantics (see `buildSessionCookie` — Lax already withholds
788
740
  * the cookie from cross-site POSTs).
789
741
  * • Only REAL session JWTs qualify: bridge tokens (`kind:
790
- * 'totp-challenge'` / `'sso-bridge'`) and `cst_`/`csv_` opaque
791
- * 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.
792
744
  * • The returned bearer is minted FRESH through the shared
793
745
  * `mintSessionForUserId` tail (re-fetched user → up-to-date
794
746
  * scopes), equivalent to what `auth.login` hands out.
@@ -838,8 +790,8 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
838
790
  // for a real session. TTL 60s, single-use, bound to the minting
839
791
  // user — see `HandoffCodeService`.
840
792
  /** Mint a one-time handoff code for the CALLING user. Real user
841
- * sessions only — scoped (`cst_`) and share-view (`csv_`) callers
842
- * 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. */
843
795
  createHandoffCode: trpc_middleware_js_1.protectedProcedure
844
796
  .input(zod_1.z
845
797
  .object({
@@ -957,7 +909,7 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
957
909
  // hide surfaces from a single probe (F1 #5). Admins summarise to
958
910
  // "everything"; a scoped principal's selectors expand to counts.
959
911
  const fleet = ctx.deviceFleet?.() ?? [];
960
- const scope = (0, types_1.summarizeEffectiveScope)(ctx.user.scopes ?? [], fleet, ctx.user.isAdmin);
912
+ const scope = (0, types_1.summarizeEffectiveScope)((0, types_2.capabilityScopesOf)(ctx.user.scopes ?? []), fleet, ctx.user.isAdmin);
961
913
  return { ...ctx.user, scope };
962
914
  }),
963
915
  /**
@@ -1178,142 +1130,6 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1178
1130
  enabled: input.enabled,
1179
1131
  });
1180
1132
  }),
1181
- // ── Share tokens — scoped, TTL'd view tokens for standalone share
1182
- // links (grid rework R6). Minted by any REAL user session (admin
1183
- // or regular); the resulting `csv_*` token authenticates as a
1184
- // restricted `share-view` principal limited to the embed surface
1185
- // + its in-scope devices (see `share-view-access.ts`). ──────────
1186
- createShareToken: trpc_middleware_js_1.protectedProcedure
1187
- .input(CreateShareTokenInputSchema)
1188
- .output(zod_1.z.object({ id: zod_1.z.string(), token: zod_1.z.string(), expiresAt: zod_1.z.number().nullable() }))
1189
- .mutation(async ({ input, ctx }) => {
1190
- assertRealUserSession(ctx.user);
1191
- const service = requireShareTokens();
1192
- // SECURITY (F0.4): a share token is a PRIVILEGE HANDOFF — it authorises
1193
- // whoever holds the link to view its devices with no further auth. A
1194
- // scoped (non-admin) minter must therefore not be able to mint for a
1195
- // device it cannot itself see: without this, a `device:[5]` token could
1196
- // mint a `csv_*` grid link for cameras 5..99. Admins mint anything;
1197
- // every other caller's requested deviceIds are INTERSECTED with what
1198
- // they can view (device:[parent] covers child accessories via the
1199
- // ancestor walk). An empty intersection is a refusal, not a silent
1200
- // "share nothing".
1201
- const scope = ctx.user.isAdmin
1202
- ? input.scope
1203
- : {
1204
- ...input.scope,
1205
- deviceIds: (0, principal_visibility_js_1.filterViewableDeviceIds)(ctx.user.scopes ?? [], input.scope.deviceIds, ctx.deviceScopeLookup),
1206
- };
1207
- if (scope.deviceIds.length === 0) {
1208
- throw new server_1.TRPCError({
1209
- code: 'FORBIDDEN',
1210
- message: 'None of the requested devices are within your scope — you cannot mint a share token for devices you cannot see',
1211
- });
1212
- }
1213
- const { token, record } = await service.create({
1214
- userId: ctx.user.id,
1215
- scope,
1216
- ...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
1217
- });
1218
- return { id: record.id, token, expiresAt: record.expiresAt };
1219
- }),
1220
- /**
1221
- * Slide expiry on a still-valid share token (same `csv_*`). Used by the
1222
- * Home Assistant embed cache so a long-lived dashboard does not mint a
1223
- * new row every hour. Expired / missing → NOT_FOUND (caller must create).
1224
- */
1225
- extendShareToken: trpc_middleware_js_1.protectedProcedure
1226
- .input(zod_1.z.object({
1227
- id: zod_1.z.string().min(1),
1228
- 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),
1229
- }))
1230
- .output(zod_1.z.object({ id: zod_1.z.string(), expiresAt: zod_1.z.number() }))
1231
- .mutation(async ({ input, ctx }) => {
1232
- assertRealUserSession(ctx.user);
1233
- const service = requireShareTokens();
1234
- try {
1235
- const record = await service.extend({
1236
- id: input.id,
1237
- ttlSec: input.ttlSec,
1238
- callerUserId: ctx.user.id,
1239
- callerIsAdmin: ctx.user.isAdmin,
1240
- });
1241
- if (record.expiresAt === null) {
1242
- throw (0, user_error_js_1.trpcUserError)('BAD_REQUEST', 'auth.share-never-expiring-extend', undefined, 'A never-expiring share token cannot be extended');
1243
- }
1244
- return { id: record.id, expiresAt: record.expiresAt };
1245
- }
1246
- catch (error) {
1247
- const message = error instanceof Error ? error.message : 'Share token extend failed';
1248
- if (/not found|expired/i.test(message)) {
1249
- throw new server_1.TRPCError({ code: 'NOT_FOUND', message });
1250
- }
1251
- if (/owner or an admin|never-expiring/i.test(message)) {
1252
- throw new server_1.TRPCError({ code: 'FORBIDDEN', message });
1253
- }
1254
- throw new server_1.TRPCError({ code: 'BAD_REQUEST', message });
1255
- }
1256
- }),
1257
- revokeShareToken: trpc_middleware_js_1.protectedProcedure
1258
- .input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
1259
- .output(zod_1.z.object({ success: zod_1.z.boolean() }))
1260
- .mutation(async ({ input, ctx }) => {
1261
- assertRealUserSession(ctx.user);
1262
- const service = requireShareTokens();
1263
- try {
1264
- const removed = await service.revoke({
1265
- id: input.id,
1266
- callerUserId: ctx.user.id,
1267
- callerIsAdmin: ctx.user.isAdmin,
1268
- });
1269
- if (removed) {
1270
- const internal = registry === null ? null : (0, hub_sessions_js_1.userManagementInternal)(registry);
1271
- if (internal !== null) {
1272
- const ip = network.clientAddress(ctx.req).ip;
1273
- await internal.audit.record({
1274
- action: 'credentials-revoked',
1275
- userId: ctx.user.id,
1276
- ...(ip !== null ? { ip } : {}),
1277
- detail: {
1278
- kind: 'share-link',
1279
- requested: 1,
1280
- revoked: 1,
1281
- skipped: 0,
1282
- ids: input.id,
1283
- },
1284
- });
1285
- }
1286
- }
1287
- return { success: removed };
1288
- }
1289
- catch (error) {
1290
- throw new server_1.TRPCError({
1291
- code: 'FORBIDDEN',
1292
- message: error instanceof Error ? error.message : 'Share token revoke failed',
1293
- });
1294
- }
1295
- }),
1296
- /**
1297
- * Share links. `owner: 'self'` — the caller's own (My access);
1298
- * `owner: 'all'` — every user's, admin only. Omitted keeps the original
1299
- * answer (own for a user, all for an admin) for clients that predate it.
1300
- */
1301
- listShareTokens: trpc_middleware_js_1.protectedProcedure
1302
- .input(zod_1.z.object({ owner: zod_1.z.enum(['self', 'all']).optional() }).optional())
1303
- .output(zod_1.z.array(ShareTokenSummarySchema))
1304
- .query(async ({ ctx, input }) => {
1305
- assertRealUserSession(ctx.user);
1306
- const service = requireShareTokens();
1307
- const owner = input?.owner ?? (ctx.user.isAdmin ? 'all' : 'self');
1308
- if (owner === 'all' && !ctx.user.isAdmin) {
1309
- throw new server_1.TRPCError({
1310
- code: 'FORBIDDEN',
1311
- message: "Listing every user's share links requires an admin",
1312
- });
1313
- }
1314
- const records = owner === 'all' ? await service.listAll() : await service.listForUser(ctx.user.id);
1315
- return records.map(toShareTokenSummary);
1316
- }),
1317
1133
  ...(0, auth_sessions_procedures_js_1.createSessionProcedures)({ registry, minter, credentialProcedure }),
1318
1134
  ...(0, auth_device_cert_procedures_js_1.createDeviceCertProcedures)({
1319
1135
  registry,
@@ -1331,8 +1147,9 @@ function createAuthRouter(auth, registry, moleculer = null, shareTokens = null,
1331
1147
  issueFor,
1332
1148
  }),
1333
1149
  ...(0, auth_reauth_procedures_js_1.createReauthProcedures)({ auth, registry, moleculer, limiters }),
1334
- ...(0, auth_admin_tokens_procedures_js_1.createAdminTokenProcedures)({ auth, registry, network }),
1335
- ...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry, shareTokens, network }),
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 }),
1336
1153
  // ── External sign-in identities (D782) ────────────────────────
1337
1154
  //
1338
1155
  // A signed-in user links an OIDC identity to THEIR OWN account. The
@@ -45,7 +45,7 @@ function createLiveEventsRouter(eb, ar) {
45
45
  // Share-view principals get a device-filtered stream: only events
46
46
  // attributable to an in-scope device are pushed (fail closed —
47
47
  // unattributable events are dropped).
48
- const shareScope = ctx.user.shareView?.scope ?? null;
48
+ const shareScope = (0, types_1.shareViewGrantOf)(ctx.user.scopes ?? []);
49
49
  // Non-share scoped tokens get the SAME projection keyed to what they may
50
50
  // view (F0.5); admins and broad category-view operators are unfiltered.
51
51
  const scopedKeep = shareScope
@@ -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)
@@ -0,0 +1,82 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.authorizeProcedure = authorizeProcedure;
4
+ exports.authorizeAddonRoute = authorizeAddonRoute;
5
+ /**
6
+ * The ONE scope matcher (D892): every authorization decision about a verified
7
+ * principal is a function of `principal.scopes`, here. It answers for a tRPC
8
+ * procedure and for an addon's own HTTP surface (routes and data planes);
9
+ * neither asks what kind of credential the principal came from.
10
+ *
11
+ * Order, the same on both surfaces:
12
+ * 1. a `share-view` grant RESTRICTS the principal to its fail-closed
13
+ * perimeter — nothing else it holds is honoured;
14
+ * 2. `'*'` passes;
15
+ * 3. the capability / addon grants decide.
16
+ */
17
+ const system_1 = require("@camstack/system");
18
+ const types_1 = require("@camstack/types");
19
+ const addon_route_share_gate_js_1 = require("../../auth/addon-route-share-gate.js");
20
+ const scope_access_js_1 = require("./scope-access.js");
21
+ const share_view_access_js_1 = require("./share-view-access.js");
22
+ /**
23
+ * May a principal holding `scopes` call the tRPC procedure at `path`?
24
+ *
25
+ * A hand-written core route (`auth.me`, `system.info`) has no cap entry: the
26
+ * authentication already passed, and an explicit `adminProcedure` on top of it
27
+ * gates what is destructive. A share-view principal never reaches that
28
+ * pass-through — its allowlist is enumerated.
29
+ */
30
+ function authorizeProcedure(scopes, path, rawInput, lookup) {
31
+ const grant = (0, types_1.shareViewGrantOf)(scopes);
32
+ if (grant !== null) {
33
+ const result = (0, share_view_access_js_1.checkShareViewAccess)(grant, path, rawInput);
34
+ return result.ok ? { ok: true, mode: 'share', grant } : { ok: false, reason: result.reason };
35
+ }
36
+ if ((0, types_1.scopesGrantAdmin)(scopes))
37
+ return { ok: true, mode: 'all' };
38
+ const capability = (0, types_1.capabilityScopesOf)(scopes);
39
+ if (path in system_1.METHOD_ACCESS_MAP) {
40
+ const result = (0, scope_access_js_1.checkScopeAccess)(capability, path, rawInput, lookup);
41
+ if (!result.ok)
42
+ return { ok: false, reason: result.reason };
43
+ }
44
+ return { ok: true, mode: 'scoped', scopes: capability };
45
+ }
46
+ /** May a principal holding `scopes` reach `/addon/<addonId>/…` with this verb? */
47
+ function authorizeAddonRoute(scopes, target) {
48
+ const grant = (0, types_1.shareViewGrantOf)(scopes);
49
+ if (grant !== null) {
50
+ if (!(target.share ?? []).includes(grant.kind)) {
51
+ return {
52
+ ok: false,
53
+ status: 403,
54
+ error: 'Share token not accepted here',
55
+ reason: `share-view ${grant.kind} not accepted by ${target.addonId}`,
56
+ };
57
+ }
58
+ return {
59
+ ok: true,
60
+ shareHeaders: {
61
+ [addon_route_share_gate_js_1.SHARE_KIND_HEADER]: grant.kind,
62
+ [addon_route_share_gate_js_1.SHARE_DEVICES_HEADER]: grant.deviceIds.join(','),
63
+ },
64
+ };
65
+ }
66
+ if ((0, types_1.scopesGrantAdmin)(scopes))
67
+ return { ok: true, shareHeaders: null };
68
+ if (target.access === 'admin') {
69
+ return { ok: false, status: 403, error: 'Admin required', reason: 'admin route' };
70
+ }
71
+ const access = (0, types_1.methodAccessForHttpMethod)(target.method);
72
+ if ((0, types_1.scopesAllowAddonRoutes)(scopes, access) ||
73
+ (0, types_1.scopesAllowAddon)((0, types_1.capabilityScopesOf)(scopes), target.addonId, access)) {
74
+ return { ok: true, shareHeaders: null };
75
+ }
76
+ return {
77
+ ok: false,
78
+ status: 403,
79
+ error: 'Token scope mismatch',
80
+ reason: `holds no addon:${target.addonId}[${access}] grant`,
81
+ };
82
+ }