@camstack/server 1.1.46 → 1.1.47
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/core/auth.router.js +129 -3
- package/dist/api/core/live-events.router.js +14 -2
- package/dist/api/trpc/share-view-access.js +82 -0
- package/dist/api/trpc/trpc.context.js +36 -5
- package/dist/api/trpc/trpc.middleware.js +13 -0
- package/dist/api/trpc/trpc.router.js +1 -1
- package/dist/core/auth/share-token.service.js +235 -0
- package/dist/main.js +8 -2
- package/package.json +14 -14
|
@@ -17,7 +17,9 @@ exports.createAuthRouter = createAuthRouter;
|
|
|
17
17
|
* to (superseding the removed `auth.listProviders`).
|
|
18
18
|
*/
|
|
19
19
|
const zod_1 = require("zod");
|
|
20
|
+
const server_1 = require("@trpc/server");
|
|
20
21
|
const types_1 = require("@camstack/types");
|
|
22
|
+
const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
|
|
21
23
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
22
24
|
/**
|
|
23
25
|
* The available second-factor kinds a user may satisfy after the
|
|
@@ -123,6 +125,63 @@ const PublicLoginMethodSchema = zod_1.z.discriminatedUnion('kind', [
|
|
|
123
125
|
types_1.RedirectLoginMethodSchema,
|
|
124
126
|
PublicWidgetLoginMethodSchema,
|
|
125
127
|
]);
|
|
128
|
+
// ── Share tokens (grid-view standalone share links) ──────────────────
|
|
129
|
+
//
|
|
130
|
+
// Wire schemas for the `csv_*` share-token surface. The summary shape
|
|
131
|
+
// deliberately omits `tokenHash` — the raw token is returned exactly
|
|
132
|
+
// once (at mint); afterwards only prefix + scope + timestamps are
|
|
133
|
+
// visible.
|
|
134
|
+
//
|
|
135
|
+
// TTL: a bounded number of seconds, or the EXPLICIT literal `'never'`
|
|
136
|
+
// for a permanently-shared grid (revocation is the kill switch).
|
|
137
|
+
// Omitted → 7-day default; infinite is never a silent default.
|
|
138
|
+
const CreateShareTokenInputSchema = zod_1.z.object({
|
|
139
|
+
scope: share_token_service_js_1.ShareTokenScopeSchema,
|
|
140
|
+
ttlSec: zod_1.z
|
|
141
|
+
.union([
|
|
142
|
+
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),
|
|
143
|
+
zod_1.z.literal('never'),
|
|
144
|
+
])
|
|
145
|
+
.optional(),
|
|
146
|
+
});
|
|
147
|
+
const ShareTokenSummarySchema = zod_1.z.object({
|
|
148
|
+
id: zod_1.z.string(),
|
|
149
|
+
userId: zod_1.z.string(),
|
|
150
|
+
tokenPrefix: zod_1.z.string(),
|
|
151
|
+
scope: share_token_service_js_1.ShareTokenScopeSchema,
|
|
152
|
+
createdAt: zod_1.z.number(),
|
|
153
|
+
/** Epoch ms, or `null` for never-expiring tokens. */
|
|
154
|
+
expiresAt: zod_1.z.number().nullable(),
|
|
155
|
+
/** Derived convenience flag so listings surface infinite tokens clearly. */
|
|
156
|
+
neverExpires: zod_1.z.boolean(),
|
|
157
|
+
lastUsedAt: zod_1.z.number().optional(),
|
|
158
|
+
});
|
|
159
|
+
function toShareTokenSummary(record) {
|
|
160
|
+
return {
|
|
161
|
+
id: record.id,
|
|
162
|
+
userId: record.userId,
|
|
163
|
+
tokenPrefix: record.tokenPrefix,
|
|
164
|
+
scope: record.scope,
|
|
165
|
+
createdAt: record.createdAt,
|
|
166
|
+
expiresAt: record.expiresAt,
|
|
167
|
+
neverExpires: record.expiresAt === null,
|
|
168
|
+
...(record.lastUsedAt !== undefined ? { lastUsedAt: record.lastUsedAt } : {}),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Share-token management is for REAL user sessions only. Scoped API
|
|
173
|
+
* tokens (`cst_*`) and share-view principals (`csv_*`) must never mint,
|
|
174
|
+
* list, or revoke share links — a leaked restricted token would
|
|
175
|
+
* otherwise be able to widen its own reach.
|
|
176
|
+
*/
|
|
177
|
+
function assertRealUserSession(user) {
|
|
178
|
+
if (user.isScoped || user.shareView) {
|
|
179
|
+
throw new server_1.TRPCError({
|
|
180
|
+
code: 'FORBIDDEN',
|
|
181
|
+
message: 'Share-token management requires a real user session',
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
}
|
|
126
185
|
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
127
186
|
const MeSchema = zod_1.z
|
|
128
187
|
.object({
|
|
@@ -138,7 +197,13 @@ const MeSchema = zod_1.z
|
|
|
138
197
|
agentId: zod_1.z.string().optional(),
|
|
139
198
|
})
|
|
140
199
|
.nullable();
|
|
141
|
-
function createAuthRouter(auth, registry, moleculer = null) {
|
|
200
|
+
function createAuthRouter(auth, registry, moleculer = null, shareTokens = null) {
|
|
201
|
+
const requireShareTokens = () => {
|
|
202
|
+
if (!shareTokens) {
|
|
203
|
+
throw new Error('Share tokens unavailable — service not wired on this node');
|
|
204
|
+
}
|
|
205
|
+
return shareTokens;
|
|
206
|
+
};
|
|
142
207
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
143
208
|
login: trpc_middleware_js_1.publicProcedure
|
|
144
209
|
.input(zod_1.z.object({ username: zod_1.z.string(), password: zod_1.z.string() }))
|
|
@@ -479,6 +544,57 @@ function createAuthRouter(auth, registry, moleculer = null) {
|
|
|
479
544
|
throw new Error('Passkey management is not available');
|
|
480
545
|
return provider.removePasskey({ userId: ctx.user.id, credentialId: input.credentialId });
|
|
481
546
|
}),
|
|
547
|
+
// ── Share tokens — scoped, TTL'd view tokens for standalone share
|
|
548
|
+
// links (grid rework R6). Minted by any REAL user session (admin
|
|
549
|
+
// or regular); the resulting `csv_*` token authenticates as a
|
|
550
|
+
// restricted `share-view` principal limited to the embed surface
|
|
551
|
+
// + its in-scope devices (see `share-view-access.ts`). ──────────
|
|
552
|
+
createShareToken: trpc_middleware_js_1.protectedProcedure
|
|
553
|
+
.input(CreateShareTokenInputSchema)
|
|
554
|
+
.output(zod_1.z.object({ id: zod_1.z.string(), token: zod_1.z.string(), expiresAt: zod_1.z.number().nullable() }))
|
|
555
|
+
.mutation(async ({ input, ctx }) => {
|
|
556
|
+
assertRealUserSession(ctx.user);
|
|
557
|
+
const service = requireShareTokens();
|
|
558
|
+
const { token, record } = await service.create({
|
|
559
|
+
userId: ctx.user.id,
|
|
560
|
+
scope: input.scope,
|
|
561
|
+
...(input.ttlSec !== undefined ? { ttlSec: input.ttlSec } : {}),
|
|
562
|
+
});
|
|
563
|
+
return { id: record.id, token, expiresAt: record.expiresAt };
|
|
564
|
+
}),
|
|
565
|
+
revokeShareToken: trpc_middleware_js_1.protectedProcedure
|
|
566
|
+
.input(zod_1.z.object({ id: zod_1.z.string().min(1) }))
|
|
567
|
+
.output(zod_1.z.object({ success: zod_1.z.boolean() }))
|
|
568
|
+
.mutation(async ({ input, ctx }) => {
|
|
569
|
+
assertRealUserSession(ctx.user);
|
|
570
|
+
const service = requireShareTokens();
|
|
571
|
+
try {
|
|
572
|
+
const removed = await service.revoke({
|
|
573
|
+
id: input.id,
|
|
574
|
+
callerUserId: ctx.user.id,
|
|
575
|
+
callerIsAdmin: ctx.user.isAdmin,
|
|
576
|
+
});
|
|
577
|
+
return { success: removed };
|
|
578
|
+
}
|
|
579
|
+
catch (error) {
|
|
580
|
+
throw new server_1.TRPCError({
|
|
581
|
+
code: 'FORBIDDEN',
|
|
582
|
+
message: error instanceof Error ? error.message : 'Share token revoke failed',
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
}),
|
|
586
|
+
/** Own tokens for regular users; the full set for admins. */
|
|
587
|
+
listShareTokens: trpc_middleware_js_1.protectedProcedure
|
|
588
|
+
.input(zod_1.z.void())
|
|
589
|
+
.output(zod_1.z.array(ShareTokenSummarySchema))
|
|
590
|
+
.query(async ({ ctx }) => {
|
|
591
|
+
assertRealUserSession(ctx.user);
|
|
592
|
+
const service = requireShareTokens();
|
|
593
|
+
const records = ctx.user.isAdmin
|
|
594
|
+
? await service.listAll()
|
|
595
|
+
: await service.listForUser(ctx.user.id);
|
|
596
|
+
return records.map(toShareTokenSummary);
|
|
597
|
+
}),
|
|
482
598
|
logout: trpc_middleware_js_1.protectedProcedure
|
|
483
599
|
.input(zod_1.z.void())
|
|
484
600
|
.output(zod_1.z.object({ success: zod_1.z.literal(true) }))
|
|
@@ -503,6 +619,10 @@ function createAuthRouter(auth, registry, moleculer = null) {
|
|
|
503
619
|
if (!registry)
|
|
504
620
|
return [];
|
|
505
621
|
const out = [];
|
|
622
|
+
// Defense-in-depth dedupe: a routing bug in a grouped runner once
|
|
623
|
+
// resolved every provider to the same addon (3× identical magic-link
|
|
624
|
+
// buttons) — a duplicated contribution id must never render twice.
|
|
625
|
+
const seenIds = new Set();
|
|
506
626
|
for (const provider of registry.getCollection('login-method')) {
|
|
507
627
|
let contributions;
|
|
508
628
|
try {
|
|
@@ -512,10 +632,14 @@ function createAuthRouter(auth, registry, moleculer = null) {
|
|
|
512
632
|
continue;
|
|
513
633
|
}
|
|
514
634
|
for (const raw of contributions) {
|
|
635
|
+
if (seenIds.has(raw.id))
|
|
636
|
+
continue;
|
|
515
637
|
if (raw.kind === 'redirect') {
|
|
516
638
|
const parsed = types_1.RedirectLoginMethodSchema.safeParse(raw);
|
|
517
|
-
if (parsed.success)
|
|
639
|
+
if (parsed.success) {
|
|
518
640
|
out.push(parsed.data);
|
|
641
|
+
seenIds.add(parsed.data.id);
|
|
642
|
+
}
|
|
519
643
|
continue;
|
|
520
644
|
}
|
|
521
645
|
// Widget arm — stamp a public bundleUrl from addonId + bundle.
|
|
@@ -523,8 +647,10 @@ function createAuthRouter(auth, registry, moleculer = null) {
|
|
|
523
647
|
...raw,
|
|
524
648
|
bundleUrl: `/api/addon-widgets/${raw.addonId}/${raw.bundle}?v=${Date.now()}`,
|
|
525
649
|
});
|
|
526
|
-
if (parsed.success)
|
|
650
|
+
if (parsed.success) {
|
|
527
651
|
out.push(parsed.data);
|
|
652
|
+
seenIds.add(parsed.data.id);
|
|
653
|
+
}
|
|
528
654
|
}
|
|
529
655
|
}
|
|
530
656
|
return out;
|
|
@@ -9,6 +9,7 @@ exports.createLiveEventsRouter = createLiveEventsRouter;
|
|
|
9
9
|
*/
|
|
10
10
|
const zod_1 = require("zod");
|
|
11
11
|
const trpc_middleware_js_1 = require("../trpc/trpc.middleware.js");
|
|
12
|
+
const share_view_access_js_1 = require("../trpc/share-view-access.js");
|
|
12
13
|
function createLiveEventsRouter(eb, ar) {
|
|
13
14
|
return (0, trpc_middleware_js_1.trpcRouter)({
|
|
14
15
|
recentSystemEvents: trpc_middleware_js_1.protectedProcedure
|
|
@@ -21,12 +22,23 @@ function createLiveEventsRouter(eb, ar) {
|
|
|
21
22
|
.query(({ input }) => eb.getRecent(input ?? {}, input?.limit ?? 50)),
|
|
22
23
|
onEvent: trpc_middleware_js_1.protectedProcedure
|
|
23
24
|
.input(zod_1.z.object({ category: zod_1.z.string().optional() }))
|
|
24
|
-
.subscription(({ input }) => {
|
|
25
|
+
.subscription(({ input, ctx }) => {
|
|
26
|
+
// Share-view principals get a device-filtered stream: only events
|
|
27
|
+
// attributable to an in-scope device are pushed (fail closed —
|
|
28
|
+
// unattributable events are dropped). Everyone else gets the
|
|
29
|
+
// unfiltered stream, exactly as before.
|
|
30
|
+
const shareScope = ctx.user.shareView?.scope ?? null;
|
|
25
31
|
return (0, trpc_middleware_js_1.iterableSubscription)((push) => {
|
|
26
32
|
const filter = {};
|
|
27
33
|
if (input.category)
|
|
28
34
|
filter.category = input.category;
|
|
29
|
-
|
|
35
|
+
if (!shareScope)
|
|
36
|
+
return eb.subscribe(filter, push);
|
|
37
|
+
const allowedDeviceIds = new Set(shareScope.deviceIds);
|
|
38
|
+
return eb.subscribe(filter, (evt) => {
|
|
39
|
+
if ((0, share_view_access_js_1.liveEventInShareScope)(evt, allowedDeviceIds))
|
|
40
|
+
push(evt);
|
|
41
|
+
});
|
|
30
42
|
});
|
|
31
43
|
}),
|
|
32
44
|
onDeviceEvent: trpc_middleware_js_1.protectedProcedure
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkShareViewAccess = checkShareViewAccess;
|
|
4
|
+
exports.liveEventInShareScope = liveEventInShareScope;
|
|
5
|
+
/**
|
|
6
|
+
* Methods callable WITHOUT a per-device check — their input carries no
|
|
7
|
+
* deviceId. `live.onEvent` is here because its input is only a category;
|
|
8
|
+
* the pushed stream is device-filtered separately in the live router.
|
|
9
|
+
*/
|
|
10
|
+
const GRID_VIEW_OPEN_METHODS = new Set([
|
|
11
|
+
'auth.me',
|
|
12
|
+
'deviceManager.listAll',
|
|
13
|
+
'turnProvider.getTurnServers',
|
|
14
|
+
'live.onEvent',
|
|
15
|
+
]);
|
|
16
|
+
/**
|
|
17
|
+
* Methods callable ONLY with an in-scope `input.deviceId`. A missing or
|
|
18
|
+
* non-numeric deviceId is denied (fail closed).
|
|
19
|
+
*/
|
|
20
|
+
const GRID_VIEW_DEVICE_METHODS = new Set([
|
|
21
|
+
'snapshot.getSnapshot',
|
|
22
|
+
'pipelineOrchestrator.getCameraMetrics',
|
|
23
|
+
'webrtcSession.listStreams',
|
|
24
|
+
'webrtcSession.createSession',
|
|
25
|
+
'webrtcSession.handleOffer',
|
|
26
|
+
'webrtcSession.handleAnswer',
|
|
27
|
+
'webrtcSession.addIceCandidate',
|
|
28
|
+
'webrtcSession.getIceCandidates',
|
|
29
|
+
'webrtcSession.getSessionState',
|
|
30
|
+
'webrtcSession.closeSession',
|
|
31
|
+
]);
|
|
32
|
+
/** Pull `deviceId` off a raw tRPC input without casting. */
|
|
33
|
+
function extractDeviceId(input) {
|
|
34
|
+
if (input === null || typeof input !== 'object')
|
|
35
|
+
return null;
|
|
36
|
+
const candidate = Reflect.get(input, 'deviceId');
|
|
37
|
+
return typeof candidate === 'number' && Number.isFinite(candidate) ? candidate : null;
|
|
38
|
+
}
|
|
39
|
+
function checkShareViewAccess(scope, path, input) {
|
|
40
|
+
if (scope.kind !== 'grid-view') {
|
|
41
|
+
return { ok: false, reason: `Unknown share scope kind '${String(scope.kind)}'` };
|
|
42
|
+
}
|
|
43
|
+
if (GRID_VIEW_OPEN_METHODS.has(path)) {
|
|
44
|
+
return { ok: true };
|
|
45
|
+
}
|
|
46
|
+
if (GRID_VIEW_DEVICE_METHODS.has(path)) {
|
|
47
|
+
const deviceId = extractDeviceId(input);
|
|
48
|
+
if (deviceId === null) {
|
|
49
|
+
return { ok: false, reason: `'${path}' requires a numeric deviceId for share-view access` };
|
|
50
|
+
}
|
|
51
|
+
if (!scope.deviceIds.includes(deviceId)) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
reason: `Device ${deviceId} is outside this share link's scope`,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return { ok: true };
|
|
58
|
+
}
|
|
59
|
+
return { ok: false, reason: `'${path}' is not available to share-view tokens` };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Whether a live event belongs to a device inside the share scope.
|
|
63
|
+
* Matches the device identity two ways (fail closed — no match, no push):
|
|
64
|
+
* • `source.id` — device-sourced events (motion, detection, audio);
|
|
65
|
+
* tolerates the string-typed ids some emitters use;
|
|
66
|
+
* • `data.deviceId` — addon-sourced per-device telemetry
|
|
67
|
+
* (e.g. `pipeline.camera-metrics-snapshot`).
|
|
68
|
+
*/
|
|
69
|
+
function liveEventInShareScope(evt, allowedDeviceIds) {
|
|
70
|
+
const sourceId = evt.source?.id;
|
|
71
|
+
if (typeof sourceId === 'number' && allowedDeviceIds.has(sourceId))
|
|
72
|
+
return true;
|
|
73
|
+
if (typeof sourceId === 'string' && sourceId !== '') {
|
|
74
|
+
const numeric = Number(sourceId);
|
|
75
|
+
if (Number.isInteger(numeric) && allowedDeviceIds.has(numeric))
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
const dataDeviceId = evt.data?.['deviceId'];
|
|
79
|
+
if (typeof dataDeviceId === 'number' && allowedDeviceIds.has(dataDeviceId))
|
|
80
|
+
return true;
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.createMeshTrpcContext = createMeshTrpcContext;
|
|
4
4
|
exports.createTrpcContext = createTrpcContext;
|
|
5
5
|
exports.createWsTrpcContext = createWsTrpcContext;
|
|
6
|
+
const share_token_service_js_1 = require("../../core/auth/share-token.service.js");
|
|
6
7
|
/** Read `req.query` if present (Fastify-only) without losing type safety. */
|
|
7
8
|
function readQuery(req) {
|
|
8
9
|
if (!('query' in req))
|
|
@@ -50,9 +51,39 @@ function extractTokenFromRequest(req) {
|
|
|
50
51
|
* token. Caller (protectedProcedure) decides the failure response
|
|
51
52
|
* (typically UNAUTHORIZED).
|
|
52
53
|
*/
|
|
53
|
-
async function resolveUser(token, authService, addonRegistry) {
|
|
54
|
+
async function resolveUser(token, authService, addonRegistry, shareTokens = null) {
|
|
54
55
|
if (!token)
|
|
55
56
|
return null;
|
|
57
|
+
// Share-token path (`csv_*`): resolve through the ShareTokenService.
|
|
58
|
+
// The synthetic principal is maximally restricted — non-admin, no
|
|
59
|
+
// scopes; the `shareView` grant routes it into the fail-closed
|
|
60
|
+
// allowlist in `protectedProcedure` instead of the scope matcher.
|
|
61
|
+
if (token.startsWith(share_token_service_js_1.SHARE_TOKEN_PREFIX)) {
|
|
62
|
+
if (!shareTokens)
|
|
63
|
+
return null;
|
|
64
|
+
try {
|
|
65
|
+
const record = await shareTokens.validate(token);
|
|
66
|
+
if (!record)
|
|
67
|
+
return null;
|
|
68
|
+
return {
|
|
69
|
+
id: record.userId,
|
|
70
|
+
// Display label — `share:<prefix>` keeps audit logs readable
|
|
71
|
+
// without exposing the token hash.
|
|
72
|
+
username: `share:${record.tokenPrefix}`,
|
|
73
|
+
isAdmin: false,
|
|
74
|
+
permissions: {
|
|
75
|
+
isAdmin: false,
|
|
76
|
+
allowedProviders: '*',
|
|
77
|
+
allowedDevices: {},
|
|
78
|
+
},
|
|
79
|
+
isApiKey: true,
|
|
80
|
+
shareView: { tokenId: record.id, scope: record.scope },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
56
87
|
// Scoped-token path: hit the user-management cap. Synthetic user
|
|
57
88
|
// with `isAdmin: false` so admin-gated procedures bounce while
|
|
58
89
|
// protectedProcedure can still gate by scope match.
|
|
@@ -158,10 +189,10 @@ function createMeshTrpcContext() {
|
|
|
158
189
|
return { user };
|
|
159
190
|
}
|
|
160
191
|
/** Context factory for HTTP tRPC requests (Fastify adapter). */
|
|
161
|
-
async function createTrpcContext(req, authService, addonRegistry) {
|
|
192
|
+
async function createTrpcContext(req, authService, addonRegistry, shareTokens = null) {
|
|
162
193
|
const token = extractTokenFromRequest(req);
|
|
163
194
|
return {
|
|
164
|
-
user: await resolveUser(token, authService, addonRegistry),
|
|
195
|
+
user: await resolveUser(token, authService, addonRegistry, shareTokens),
|
|
165
196
|
req,
|
|
166
197
|
getDeviceAncestors: makeAncestorLookup(addonRegistry),
|
|
167
198
|
};
|
|
@@ -171,11 +202,11 @@ async function createTrpcContext(req, authService, addonRegistry) {
|
|
|
171
202
|
* Token is sent via tRPC connectionParams (a JSON message sent right after
|
|
172
203
|
* the WS handshake), which is more reliable than query params through proxies.
|
|
173
204
|
*/
|
|
174
|
-
async function createWsTrpcContext(opts, authService, addonRegistry) {
|
|
205
|
+
async function createWsTrpcContext(opts, authService, addonRegistry, shareTokens = null) {
|
|
175
206
|
// 1. connectionParams.token (sent by BackendClient's createWSClient)
|
|
176
207
|
const paramToken = opts.info.connectionParams?.['token'];
|
|
177
208
|
const token = (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req);
|
|
178
|
-
const user = await resolveUser(token, authService, addonRegistry);
|
|
209
|
+
const user = await resolveUser(token, authService, addonRegistry, shareTokens);
|
|
179
210
|
return {
|
|
180
211
|
user,
|
|
181
212
|
req: opts.req,
|
|
@@ -10,6 +10,7 @@ const server_1 = require("@trpc/server");
|
|
|
10
10
|
const superjson_1 = __importDefault(require("superjson"));
|
|
11
11
|
const system_1 = require("@camstack/system");
|
|
12
12
|
const scope_access_js_1 = require("./scope-access.js");
|
|
13
|
+
const share_view_access_js_1 = require("./share-view-access.js");
|
|
13
14
|
const cap_route_error_formatter_js_1 = require("./cap-route-error-formatter.js");
|
|
14
15
|
const t = server_1.initTRPC.context().create({
|
|
15
16
|
transformer: superjson_1.default,
|
|
@@ -93,6 +94,18 @@ exports.protectedProcedure = t.procedure.use(async ({ ctx, next, path, getRawInp
|
|
|
93
94
|
if (!ctx.user) {
|
|
94
95
|
throw new server_1.TRPCError({ code: 'UNAUTHORIZED' });
|
|
95
96
|
}
|
|
97
|
+
// Share-view principals (`csv_*` tokens) NEVER reach the admin bypass,
|
|
98
|
+
// the hand-written-route pass-through, or the scope matcher below —
|
|
99
|
+
// they are gated exclusively by the fail-closed embed-surface allowlist
|
|
100
|
+
// (`share-view-access.ts`): enumerated methods only, in-scope devices only.
|
|
101
|
+
if (ctx.user.shareView) {
|
|
102
|
+
const rawInput = await getRawInput();
|
|
103
|
+
const result = (0, share_view_access_js_1.checkShareViewAccess)(ctx.user.shareView.scope, path, rawInput);
|
|
104
|
+
if (!result.ok) {
|
|
105
|
+
throw new server_1.TRPCError({ code: 'FORBIDDEN', message: result.reason });
|
|
106
|
+
}
|
|
107
|
+
return next({ ctx: { ...ctx, user: ctx.user } });
|
|
108
|
+
}
|
|
96
109
|
// Spread+reassign of `user` narrows downstream ctx from `User | null`
|
|
97
110
|
// to `User` so `adminProcedure` / `agentProcedure` can read fields
|
|
98
111
|
// without re-checking.
|
|
@@ -166,7 +166,7 @@ function buildCapabilityRouters(services) {
|
|
|
166
166
|
// clusterNodes — fixed core API. Write-side purge for the durable
|
|
167
167
|
// offline-node history (Track A "Forget node"); read side is push-only.
|
|
168
168
|
clusterNodes: (0, cluster_nodes_router_js_1.createClusterNodesRouter)(services.agentRegistry),
|
|
169
|
-
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer),
|
|
169
|
+
auth: (0, auth_router_js_1.createAuthRouter)(services.authService, services.capabilityRegistry, services.moleculer, services.shareTokenService),
|
|
170
170
|
// NOT MOUNTED — `mount: { kind: 'skip' }` legacy provider shapes
|
|
171
171
|
// (positional args / sync returns) that don't match the codegen
|
|
172
172
|
// routers' {input}-object + Promise<T> contract. The runtime builder
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ShareTokenService = exports.ShareTokenRecordSchema = exports.ShareTokenScopeSchema = exports.SHARE_TOKEN_MAX_DEVICES = exports.SHARE_TOKEN_TTL_DEFAULT_SEC = exports.SHARE_TOKEN_TTL_MAX_SEC = exports.SHARE_TOKEN_TTL_MIN_SEC = exports.SHARE_TOKEN_PREFIX = void 0;
|
|
37
|
+
/**
|
|
38
|
+
* ShareTokenService — scoped, TTL'd VIEW tokens for standalone share links
|
|
39
|
+
* (grid rework R6: the embed page opened by a third party authenticates with
|
|
40
|
+
* a `csv_*` token carried in the share URL's `#t=` fragment).
|
|
41
|
+
*
|
|
42
|
+
* Design mirrors the existing `cst_*` scoped-token mechanism
|
|
43
|
+
* (`@camstack/system` ScopedTokenManager): a random opaque token, stored
|
|
44
|
+
* server-side as a SHA-256 hash so a DB leak never exposes live tokens,
|
|
45
|
+
* validated per request, revocable, with the TTL enforced at verification.
|
|
46
|
+
*
|
|
47
|
+
* Differences from `cst_*` tokens — a share token is NOT a user session:
|
|
48
|
+
* • it resolves to a RESTRICTED `share-view` principal (see
|
|
49
|
+
* `trpc.context.ts`) that can only call the allowlisted embed surface
|
|
50
|
+
* (`share-view-access.ts`), never the general cap surface;
|
|
51
|
+
* • the scope is a share grant (`kind: 'grid-view'` + deviceIds), not a
|
|
52
|
+
* capability/addon grant;
|
|
53
|
+
* • expiry is explicit at mint time: a bounded TTL (60s..30d, default
|
|
54
|
+
* 7d) or the deliberate `ttlSec: 'never'` choice (`expiresAt: null`)
|
|
55
|
+
* for permanently-shared grids — revocation stays the kill switch.
|
|
56
|
+
*
|
|
57
|
+
* Storage: the hub's settings backend (same SQLite store the scoped tokens
|
|
58
|
+
* live in, reached directly instead of over the settings-store cap since
|
|
59
|
+
* this service is hub-resident). Collection: `share_view_tokens`.
|
|
60
|
+
*/
|
|
61
|
+
const crypto = __importStar(require("node:crypto"));
|
|
62
|
+
const zod_1 = require("zod");
|
|
63
|
+
const SHARE_TOKENS_COLLECTION = 'share_view_tokens';
|
|
64
|
+
/** Wire prefix — `csv_` = CamStack Share View (cf. `cst_` scoped tokens). */
|
|
65
|
+
exports.SHARE_TOKEN_PREFIX = 'csv_';
|
|
66
|
+
// ── TTL / scope bounds (enforced at mint, re-checked by the router schema) ──
|
|
67
|
+
exports.SHARE_TOKEN_TTL_MIN_SEC = 60;
|
|
68
|
+
exports.SHARE_TOKEN_TTL_MAX_SEC = 30 * 24 * 60 * 60; // 30 days
|
|
69
|
+
exports.SHARE_TOKEN_TTL_DEFAULT_SEC = 7 * 24 * 60 * 60; // 7 days
|
|
70
|
+
exports.SHARE_TOKEN_MAX_DEVICES = 64;
|
|
71
|
+
/**
|
|
72
|
+
* What a share token is allowed to see. Discriminated on `kind` so future
|
|
73
|
+
* share surfaces (single-camera view, recording clip, …) extend the union
|
|
74
|
+
* without touching verification plumbing.
|
|
75
|
+
*/
|
|
76
|
+
exports.ShareTokenScopeSchema = zod_1.z.object({
|
|
77
|
+
kind: zod_1.z.literal('grid-view'),
|
|
78
|
+
deviceIds: zod_1.z.array(zod_1.z.number().int().nonnegative()).min(1).max(exports.SHARE_TOKEN_MAX_DEVICES),
|
|
79
|
+
});
|
|
80
|
+
/** Persisted record — never leaves the server with `tokenHash` attached. */
|
|
81
|
+
exports.ShareTokenRecordSchema = zod_1.z.object({
|
|
82
|
+
id: zod_1.z.string(),
|
|
83
|
+
/** The user that minted the share link (owner — may revoke it). */
|
|
84
|
+
userId: zod_1.z.string(),
|
|
85
|
+
tokenHash: zod_1.z.string(),
|
|
86
|
+
/** First 12 chars of the raw token — display/audit label only. */
|
|
87
|
+
tokenPrefix: zod_1.z.string(),
|
|
88
|
+
scope: exports.ShareTokenScopeSchema,
|
|
89
|
+
createdAt: zod_1.z.number(),
|
|
90
|
+
/** Epoch ms — or `null` for a never-expiring token (explicit choice at mint). */
|
|
91
|
+
expiresAt: zod_1.z.number().nullable(),
|
|
92
|
+
lastUsedAt: zod_1.z.number().optional(),
|
|
93
|
+
});
|
|
94
|
+
function parseShareToken(data) {
|
|
95
|
+
const parsed = exports.ShareTokenRecordSchema.safeParse(data);
|
|
96
|
+
return parsed.success ? parsed.data : null;
|
|
97
|
+
}
|
|
98
|
+
class ShareTokenService {
|
|
99
|
+
getStore;
|
|
100
|
+
logger;
|
|
101
|
+
constructor(getStore, logger = null) {
|
|
102
|
+
this.getStore = getStore;
|
|
103
|
+
this.logger = logger;
|
|
104
|
+
}
|
|
105
|
+
store() {
|
|
106
|
+
const store = this.getStore();
|
|
107
|
+
if (!store) {
|
|
108
|
+
throw new Error('Share tokens unavailable — settings backend not ready');
|
|
109
|
+
}
|
|
110
|
+
return store;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Mint a new share token. Throws on invalid scope or out-of-bounds TTL
|
|
114
|
+
* (fail fast — the router's Zod input schema should have caught both).
|
|
115
|
+
* `ttlSec: 'never'` is the explicit opt-in to a never-expiring token
|
|
116
|
+
* (`expiresAt: null`); omitted falls back to the 7-day default.
|
|
117
|
+
* Returns the RAW token exactly once; only its hash is persisted.
|
|
118
|
+
*/
|
|
119
|
+
async create(input) {
|
|
120
|
+
const scope = exports.ShareTokenScopeSchema.parse(input.scope);
|
|
121
|
+
const ttlSec = input.ttlSec ?? exports.SHARE_TOKEN_TTL_DEFAULT_SEC;
|
|
122
|
+
if (ttlSec !== 'never' &&
|
|
123
|
+
(!Number.isInteger(ttlSec) ||
|
|
124
|
+
ttlSec < exports.SHARE_TOKEN_TTL_MIN_SEC ||
|
|
125
|
+
ttlSec > exports.SHARE_TOKEN_TTL_MAX_SEC)) {
|
|
126
|
+
throw new Error(`Share token TTL out of bounds — got ${ttlSec}s, allowed ` +
|
|
127
|
+
`[${exports.SHARE_TOKEN_TTL_MIN_SEC}s, ${exports.SHARE_TOKEN_TTL_MAX_SEC}s] or 'never'`);
|
|
128
|
+
}
|
|
129
|
+
const rawToken = `${exports.SHARE_TOKEN_PREFIX}${crypto.randomBytes(32).toString('hex')}`;
|
|
130
|
+
const now = Date.now();
|
|
131
|
+
const record = {
|
|
132
|
+
id: crypto.randomUUID(),
|
|
133
|
+
userId: input.userId,
|
|
134
|
+
tokenHash: crypto.createHash('sha256').update(rawToken).digest('hex'),
|
|
135
|
+
tokenPrefix: rawToken.slice(0, 12),
|
|
136
|
+
scope,
|
|
137
|
+
createdAt: now,
|
|
138
|
+
expiresAt: ttlSec === 'never' ? null : now + ttlSec * 1000,
|
|
139
|
+
};
|
|
140
|
+
await this.store().insert({
|
|
141
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
142
|
+
record: { id: record.id, data: { ...record } },
|
|
143
|
+
});
|
|
144
|
+
this.logger?.info('Share token minted', {
|
|
145
|
+
meta: {
|
|
146
|
+
id: record.id,
|
|
147
|
+
userId: record.userId,
|
|
148
|
+
kind: scope.kind,
|
|
149
|
+
deviceIds: scope.deviceIds,
|
|
150
|
+
expiresAt: record.expiresAt,
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
return { token: rawToken, record };
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Resolve a raw `csv_*` token to its record. Returns `null` for:
|
|
157
|
+
* wrong prefix, unknown token, corrupt record, or expired token.
|
|
158
|
+
* Never throws on bad input — the auth boundary treats `null` as 401.
|
|
159
|
+
*/
|
|
160
|
+
async validate(rawToken) {
|
|
161
|
+
if (!rawToken.startsWith(exports.SHARE_TOKEN_PREFIX))
|
|
162
|
+
return null;
|
|
163
|
+
const tokenHash = crypto.createHash('sha256').update(rawToken).digest('hex');
|
|
164
|
+
const results = await this.store().query({
|
|
165
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
166
|
+
filter: { where: { tokenHash } },
|
|
167
|
+
});
|
|
168
|
+
const first = results[0];
|
|
169
|
+
if (!first)
|
|
170
|
+
return null;
|
|
171
|
+
const record = parseShareToken(first.data);
|
|
172
|
+
if (!record)
|
|
173
|
+
return null;
|
|
174
|
+
// `expiresAt: null` = never expires (explicit mint-time choice);
|
|
175
|
+
// revocation remains the kill switch for those.
|
|
176
|
+
if (record.expiresAt !== null && Date.now() > record.expiresAt)
|
|
177
|
+
return null;
|
|
178
|
+
this.touchLastUsed(record).catch(() => {
|
|
179
|
+
/* best-effort audit timestamp — never block validation */
|
|
180
|
+
});
|
|
181
|
+
return record;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Revoke (delete) a share token. Only the minting user or an admin may
|
|
185
|
+
* revoke. Returns `false` when the token doesn't exist; throws on a
|
|
186
|
+
* permission mismatch so the router surfaces a FORBIDDEN.
|
|
187
|
+
*/
|
|
188
|
+
async revoke(input) {
|
|
189
|
+
const results = await this.store().query({
|
|
190
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
191
|
+
filter: { where: { id: input.id } },
|
|
192
|
+
});
|
|
193
|
+
const first = results[0];
|
|
194
|
+
if (!first)
|
|
195
|
+
return false;
|
|
196
|
+
const record = parseShareToken(first.data);
|
|
197
|
+
if (!record) {
|
|
198
|
+
// Corrupt record — delete it regardless (it can never validate).
|
|
199
|
+
await this.store().delete({ collection: SHARE_TOKENS_COLLECTION, key: input.id });
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
if (!input.callerIsAdmin && record.userId !== input.callerUserId) {
|
|
203
|
+
throw new Error('Only the token owner or an admin can revoke a share token');
|
|
204
|
+
}
|
|
205
|
+
await this.store().delete({ collection: SHARE_TOKENS_COLLECTION, key: input.id });
|
|
206
|
+
this.logger?.info('Share token revoked', {
|
|
207
|
+
meta: { id: record.id, byUserId: input.callerUserId },
|
|
208
|
+
});
|
|
209
|
+
return true;
|
|
210
|
+
}
|
|
211
|
+
/** Every share token minted by `userId`, expired ones included. */
|
|
212
|
+
async listForUser(userId) {
|
|
213
|
+
const results = await this.store().query({
|
|
214
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
215
|
+
filter: { where: { userId } },
|
|
216
|
+
});
|
|
217
|
+
return results.map((r) => parseShareToken(r.data)).filter((r) => r !== null);
|
|
218
|
+
}
|
|
219
|
+
/** All share tokens (admin listing). */
|
|
220
|
+
async listAll() {
|
|
221
|
+
const results = await this.store().query({
|
|
222
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
223
|
+
filter: {},
|
|
224
|
+
});
|
|
225
|
+
return results.map((r) => parseShareToken(r.data)).filter((r) => r !== null);
|
|
226
|
+
}
|
|
227
|
+
async touchLastUsed(record) {
|
|
228
|
+
await this.store().update({
|
|
229
|
+
collection: SHARE_TOKENS_COLLECTION,
|
|
230
|
+
id: record.id,
|
|
231
|
+
data: { ...record, lastUsedAt: Date.now() },
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
exports.ShareTokenService = ShareTokenService;
|
package/dist/main.js
CHANGED
|
@@ -50,6 +50,7 @@ const logging_service_1 = require("./core/logging/logging.service");
|
|
|
50
50
|
const event_bus_service_1 = require("./core/events/event-bus.service");
|
|
51
51
|
const config_service_1 = require("./core/config/config.service");
|
|
52
52
|
const auth_service_1 = require("./core/auth/auth.service");
|
|
53
|
+
const share_token_service_1 = require("./core/auth/share-token.service");
|
|
53
54
|
// Boot-time capability declaration runs over the auto-generated
|
|
54
55
|
// `ALL_CAPABILITY_DEFINITIONS` array — every `*.cap.ts` file that ships
|
|
55
56
|
// with `@camstack/types` is included automatically. Adding a new cap
|
|
@@ -348,8 +349,13 @@ async function bootstrap() {
|
|
|
348
349
|
}
|
|
349
350
|
try {
|
|
350
351
|
const authService = app.get(auth_service_1.AuthService);
|
|
352
|
+
// Share-view (`csv_*`) tokens — hub-resident mint/verify/revoke over
|
|
353
|
+
// the settings backend (lazy getter: the backend lands after the
|
|
354
|
+
// sqlite-storage builtin registers; the service resolves it per call).
|
|
355
|
+
const shareTokenService = new share_token_service_1.ShareTokenService(() => addonRegistry.getSettingsBackend(), loggingService.createLogger('share-tokens'));
|
|
351
356
|
appRouter = (0, trpc_router_1.buildAppRouter)({
|
|
352
357
|
authService,
|
|
358
|
+
shareTokenService,
|
|
353
359
|
configService: config,
|
|
354
360
|
featureService: app.get(feature_service_1.FeatureService),
|
|
355
361
|
loggingService,
|
|
@@ -371,7 +377,7 @@ async function bootstrap() {
|
|
|
371
377
|
prefix: '/trpc',
|
|
372
378
|
trpcOptions: {
|
|
373
379
|
router: appRouter,
|
|
374
|
-
createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry),
|
|
380
|
+
createContext: ({ req }) => (0, trpc_context_1.createTrpcContext)(req, authService, addonRegistry, shareTokenService),
|
|
375
381
|
onError: ({ path: trpcPath, error, }) => {
|
|
376
382
|
const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC');
|
|
377
383
|
trpcLogger.warn('tRPC error', {
|
|
@@ -857,7 +863,7 @@ async function bootstrap() {
|
|
|
857
863
|
(0, ws_1.applyWSSHandler)({
|
|
858
864
|
wss,
|
|
859
865
|
router: appRouter,
|
|
860
|
-
createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry),
|
|
866
|
+
createContext: (opts) => (0, trpc_context_1.createWsTrpcContext)(opts, authService, addonRegistry, shareTokenService),
|
|
861
867
|
onError: ({ path: trpcPath, error, }) => {
|
|
862
868
|
const trpcLogger = app.get(logging_service_1.LoggingService).createLogger('tRPC:ws');
|
|
863
869
|
trpcLogger.warn('tRPC error', {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.47",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -23,19 +23,19 @@
|
|
|
23
23
|
"test:watch": "vitest"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
|
-
"@camstack/addon-admin-ui": "
|
|
27
|
-
"@camstack/addon-advanced-notifier": "
|
|
28
|
-
"@camstack/addon-auth": "
|
|
29
|
-
"@camstack/addon-decoder-nodeav": "
|
|
30
|
-
"@camstack/addon-notifiers": "
|
|
31
|
-
"@camstack/addon-pipeline": "
|
|
32
|
-
"@camstack/addon-pipeline-orchestrator": "
|
|
33
|
-
"@camstack/addon-post-analysis": "
|
|
34
|
-
"@camstack/sdk": "
|
|
35
|
-
"@camstack/shm-ring": "
|
|
36
|
-
"@camstack/system": "
|
|
37
|
-
"@camstack/types": "
|
|
38
|
-
"@camstack/ui-library": "
|
|
26
|
+
"@camstack/addon-admin-ui": "1.1.42",
|
|
27
|
+
"@camstack/addon-advanced-notifier": "1.1.21",
|
|
28
|
+
"@camstack/addon-auth": "1.1.4",
|
|
29
|
+
"@camstack/addon-decoder-nodeav": "1.1.9",
|
|
30
|
+
"@camstack/addon-notifiers": "1.1.21",
|
|
31
|
+
"@camstack/addon-pipeline": "1.1.49",
|
|
32
|
+
"@camstack/addon-pipeline-orchestrator": "1.1.35",
|
|
33
|
+
"@camstack/addon-post-analysis": "1.1.23",
|
|
34
|
+
"@camstack/sdk": "1.1.20",
|
|
35
|
+
"@camstack/shm-ring": "1.0.20",
|
|
36
|
+
"@camstack/system": "1.1.39",
|
|
37
|
+
"@camstack/types": "1.1.36",
|
|
38
|
+
"@camstack/ui-library": "1.1.31",
|
|
39
39
|
"@fastify/compress": "^9.0.0",
|
|
40
40
|
"@fastify/cookie": "^11.0.2",
|
|
41
41
|
"@fastify/multipart": "^10.0.0",
|