@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.
@@ -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
- return eb.subscribe(filter, push);
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.46",
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",