@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.
- package/dist/api/auth-whoami.js +0 -1
- package/dist/api/core/auth-audit.procedures.js +44 -0
- package/dist/api/core/auth-revoke-credentials.procedures.js +4 -78
- package/dist/api/core/auth-tokens.procedures.js +105 -72
- package/dist/api/core/auth.router.js +14 -280
- package/dist/api/core/preferences.router.js +98 -0
- package/dist/api/oauth2/oauth2-routes.js +40 -15
- package/dist/api/trpc/device-config-secret-redaction.js +2 -2
- package/dist/api/trpc/generated-cap-mounts.js +0 -1
- package/dist/api/trpc/generated-cap-routers.js +2 -38
- package/dist/api/trpc/principal-visibility.js +2 -2
- package/dist/api/trpc/share-view-access.js +1 -1
- package/dist/api/trpc/trpc-error-principal.js +1 -2
- package/dist/api/trpc/trpc.context.js +16 -13
- package/dist/api/trpc/trpc.router.js +6 -1
- package/dist/api/upload-auth.js +13 -25
- package/dist/auth/addon-route-share-gate.js +2 -2
- package/dist/auth/credential-verifier.js +63 -104
- package/dist/auth/embed-auth-route.js +45 -0
- package/dist/auth/live-user.js +19 -0
- package/dist/auth/scope-escalation.js +77 -2
- package/dist/boot/boot-config.js +8 -1
- package/dist/core/addon/addon-registry.service.js +14 -20
- package/dist/core/auth/credential-token.service.js +9 -14
- package/dist/core/auth/handoff-code.service.js +1 -1
- package/dist/core/auth/record-kinds.js +7 -2
- package/dist/main.js +18 -48
- package/package.json +14 -14
- package/dist/api/core/auth-admin-tokens.procedures.js +0 -156
- package/dist/core/auth/share-token.service.js +0 -459
|
@@ -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
|
|
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
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
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
|
|
225
|
-
// A minted token (admin, scoped
|
|
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,
|
|
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
|
|
804
|
-
*
|
|
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 —
|
|
855
|
-
* must never convert
|
|
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,
|
|
1417
|
-
...(0, auth_tokens_procedures_js_1.createTokenProcedures)({ auth, registry, network, credentialTokens
|
|
1418
|
-
...(0, auth_revoke_credentials_procedures_js_1.createRevokeCredentialsProcedures)({ registry,
|
|
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
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
.
|
|
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.
|
|
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
|
|
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
|
|
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
|