@camstack/server 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -14,12 +14,7 @@
14
14
  * and monitors that talk directly to an agent see identical payloads.
15
15
  */
16
16
  import type { FastifyInstance, FastifyReply } from 'fastify'
17
- import type {
18
- AgentHealth,
19
- AgentHealthError,
20
- ClusterHealth,
21
- HubHealth,
22
- } from '@camstack/types'
17
+ import type { AgentHealth, AgentHealthError, ClusterHealth, HubHealth } from '@camstack/types'
23
18
  import type { MoleculerService } from '../../core/moleculer/moleculer.service'
24
19
  import type { AgentRegistryService } from '../../core/agent/agent-registry.service'
25
20
 
@@ -42,7 +37,10 @@ function nowIso(): string {
42
37
  return new Date().toISOString()
43
38
  }
44
39
 
45
- async function buildHubHealth(deps: HealthRoutesDeps, proc: ProcessLike = process): Promise<HubHealth> {
40
+ async function buildHubHealth(
41
+ deps: HealthRoutesDeps,
42
+ proc: ProcessLike = process,
43
+ ): Promise<HubHealth> {
46
44
  const nodes = await deps.agentRegistry.listNodes()
47
45
  const remote = nodes.filter((n) => !n.isHub)
48
46
  const online = remote.filter((n) => n.isOnline !== false).length
@@ -89,9 +87,7 @@ export function registerHealthRoutes(fastify: FastifyInstance, deps: HealthRoute
89
87
  fastify.get('/health/agents', async (): Promise<{ readonly agents: readonly string[] }> => {
90
88
  const nodes = await deps.agentRegistry.listNodes()
91
89
  return {
92
- agents: nodes
93
- .filter((n) => !n.isHub && n.isOnline !== false)
94
- .map((n) => n.info.id),
90
+ agents: nodes.filter((n) => !n.isHub && n.isOnline !== false).map((n) => n.info.id),
95
91
  }
96
92
  })
97
93
 
@@ -114,9 +110,7 @@ export function registerHealthRoutes(fastify: FastifyInstance, deps: HealthRoute
114
110
  const hub = await buildHubHealth(deps)
115
111
  const nodes = await deps.agentRegistry.listNodes()
116
112
  const remote = nodes.filter((n) => !n.isHub && n.isOnline !== false)
117
- const agents = await Promise.all(
118
- remote.map((n) => fetchAgentHealth(deps, n.info.id)),
119
- )
113
+ const agents = await Promise.all(remote.map((n) => fetchAgentHealth(deps, n.info.id)))
120
114
  const ok = hub.ok && agents.every((a) => a.ok)
121
115
  return { ok, hub, agents, checkedAt: nowIso() }
122
116
  })
@@ -5,7 +5,12 @@ describe('validateAuthorizeQuery', () => {
5
5
  const known = new Set(['export-alexa'])
6
6
  it('accepts a well-formed query for a known integration', () => {
7
7
  const r = validateAuthorizeQuery(
8
- { response_type: 'code', integration: 'export-alexa', redirect_uri: 'https://cb/r', state: 's' },
8
+ {
9
+ response_type: 'code',
10
+ integration: 'export-alexa',
11
+ redirect_uri: 'https://cb/r',
12
+ state: 's',
13
+ },
9
14
  known,
10
15
  )
11
16
  expect(r.ok).toBe(true)
@@ -26,7 +31,12 @@ describe('validateAuthorizeQuery', () => {
26
31
  })
27
32
  it('rejects a non-code response_type', () => {
28
33
  const r = validateAuthorizeQuery(
29
- { response_type: 'token', integration: 'export-alexa', redirect_uri: 'https://cb/r', state: 's' },
34
+ {
35
+ response_type: 'token',
36
+ integration: 'export-alexa',
37
+ redirect_uri: 'https://cb/r',
38
+ state: 's',
39
+ },
30
40
  known,
31
41
  )
32
42
  expect(r.ok).toBe(false)
@@ -1,7 +1,8 @@
1
1
  function escapeHtml(s: string): string {
2
- return s.replace(/[&<>"']/g, (c) => (
3
- { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!
4
- ))
2
+ return s.replace(
3
+ /[&<>"']/g,
4
+ (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]!,
5
+ )
5
6
  }
6
7
 
7
8
  interface ConsentPageInput {
@@ -1,8 +1,17 @@
1
1
  import type { FastifyInstance } from 'fastify'
2
2
  import type { CapabilityRegistry } from '@camstack/kernel'
3
- import type { IOauthIntegrationProvider, IUserManagementProvider, OauthIntegrationDescriptor, TokenScope } from '@camstack/types'
3
+ import type {
4
+ IOauthIntegrationProvider,
5
+ IUserManagementProvider,
6
+ OauthIntegrationDescriptor,
7
+ TokenScope,
8
+ } from '@camstack/types'
4
9
  import { renderConsentPage } from './consent-page.js'
5
- import { SESSION_COOKIE, shouldRedirectToLogin, loginRedirectUrl } from '../../auth/session-cookie.js'
10
+ import {
11
+ SESSION_COOKIE,
12
+ shouldRedirectToLogin,
13
+ loginRedirectUrl,
14
+ } from '../../auth/session-cookie.js'
6
15
 
7
16
  export interface AuthorizeQuery {
8
17
  response_type?: string
@@ -17,16 +26,25 @@ export type AuthorizeValidation =
17
26
 
18
27
  /** Validate the inbound authorize query. `client_id` is intentionally
19
28
  * NOT checked — that pair is verified only at the Lambda boundary. */
20
- export function validateAuthorizeQuery(q: AuthorizeQuery, knownIntegrations: ReadonlySet<string>): AuthorizeValidation {
21
- if (q.response_type !== 'code') return { ok: false, status: 400, error: 'unsupported_response_type' }
22
- if (!q.integration || !knownIntegrations.has(q.integration)) return { ok: false, status: 400, error: 'invalid_request — unknown integration' }
23
- if (!q.redirect_uri) return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' }
29
+ export function validateAuthorizeQuery(
30
+ q: AuthorizeQuery,
31
+ knownIntegrations: ReadonlySet<string>,
32
+ ): AuthorizeValidation {
33
+ if (q.response_type !== 'code')
34
+ return { ok: false, status: 400, error: 'unsupported_response_type' }
35
+ if (!q.integration || !knownIntegrations.has(q.integration))
36
+ return { ok: false, status: 400, error: 'invalid_request — unknown integration' }
37
+ if (!q.redirect_uri)
38
+ return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' }
24
39
  if (!q.state) return { ok: false, status: 400, error: 'invalid_request — state required' }
25
40
  return { ok: true, integration: q.integration, redirectUri: q.redirect_uri, state: q.state }
26
41
  }
27
42
 
28
43
  /** True if `redirectUri` starts with one of the integration's allowed prefixes. */
29
- export function isRedirectUriAllowed(redirectUri: string, allowedPrefixes: readonly string[]): boolean {
44
+ export function isRedirectUriAllowed(
45
+ redirectUri: string,
46
+ allowedPrefixes: readonly string[],
47
+ ): boolean {
30
48
  return allowedPrefixes.some((p) => redirectUri.startsWith(p))
31
49
  }
32
50
 
@@ -121,7 +139,9 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
121
139
 
122
140
  const descriptor = descriptorMap.get(v.integration)!
123
141
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
124
- return reply.status(400).send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
142
+ return reply
143
+ .status(400)
144
+ .send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
125
145
  }
126
146
 
127
147
  const html = renderConsentPage({
@@ -178,11 +198,15 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
178
198
 
179
199
  const descriptor = descriptorMap.get(v.integration)!
180
200
  if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
181
- return reply.status(400).send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
201
+ return reply
202
+ .status(400)
203
+ .send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
182
204
  }
183
205
 
184
206
  if (body.consent !== 'allow') {
185
- return reply.redirect(`${v.redirectUri}?error=access_denied&state=${encodeURIComponent(v.state)}`)
207
+ return reply.redirect(
208
+ `${v.redirectUri}?error=access_denied&state=${encodeURIComponent(v.state)}`,
209
+ )
186
210
  }
187
211
 
188
212
  const userMgmt = registry.getSingleton<IUserManagementProvider>('user-management')
@@ -203,7 +227,9 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
203
227
  hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
204
228
  })
205
229
 
206
- return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`)
230
+ return reply.redirect(
231
+ `${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`,
232
+ )
207
233
  })
208
234
 
209
235
  // ─── POST /api/oauth2/token ───────────────────────────────────────────────
@@ -228,7 +254,10 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
228
254
  if (!body.code || !body.redirect_uri) {
229
255
  return reply.status(400).send({ error: 'invalid_request' })
230
256
  }
231
- tokenResult = await userMgmt.oauthExchangeCode({ code: body.code, redirectUri: body.redirect_uri })
257
+ tokenResult = await userMgmt.oauthExchangeCode({
258
+ code: body.code,
259
+ redirectUri: body.redirect_uri,
260
+ })
232
261
  } else if (body.grant_type === 'refresh_token') {
233
262
  if (!body.refresh_token) {
234
263
  return reply.status(400).send({ error: 'invalid_request' })
@@ -11,15 +11,17 @@
11
11
  */
12
12
  import { describe, it, expect } from 'vitest'
13
13
  import type { IncomingMessage } from 'node:http'
14
- import { extractClientIp, isRemoteClientIp } from '../client-ip.js'
14
+ import { extractClientIp, extractUserAgent, isRemoteClientIp } from '../client-ip.js'
15
15
 
16
16
  function reqWith(opts: {
17
17
  xff?: string | string[]
18
18
  ip?: string
19
19
  remoteAddress?: string
20
+ userAgent?: string | string[]
20
21
  }): IncomingMessage {
21
22
  const headers: Record<string, string | string[]> = {}
22
23
  if (opts.xff !== undefined) headers['x-forwarded-for'] = opts.xff
24
+ if (opts.userAgent !== undefined) headers['user-agent'] = opts.userAgent
23
25
  const req = {
24
26
  headers,
25
27
  socket: { remoteAddress: opts.remoteAddress },
@@ -66,6 +68,30 @@ describe('extractClientIp', () => {
66
68
  })
67
69
  })
68
70
 
71
+ describe('extractUserAgent', () => {
72
+ it('returns null for an absent request (mesh-originated call)', () => {
73
+ expect(extractUserAgent(undefined)).toBeNull()
74
+ })
75
+
76
+ it('returns null when the header is missing', () => {
77
+ expect(extractUserAgent(reqWith({}))).toBeNull()
78
+ })
79
+
80
+ it('reads the user-agent header', () => {
81
+ const req = reqWith({ userAgent: 'Mozilla/5.0 (Macintosh) Chrome/120' })
82
+ expect(extractUserAgent(req)).toBe('Mozilla/5.0 (Macintosh) Chrome/120')
83
+ })
84
+
85
+ it('reads the first entry when the header is an array', () => {
86
+ const req = reqWith({ userAgent: ['Mozilla/5.0 (X11) Firefox/121', 'ignored'] })
87
+ expect(extractUserAgent(req)).toBe('Mozilla/5.0 (X11) Firefox/121')
88
+ })
89
+
90
+ it('returns null for an empty header value', () => {
91
+ expect(extractUserAgent(reqWith({ userAgent: '' }))).toBeNull()
92
+ })
93
+ })
94
+
69
95
  describe('isRemoteClientIp', () => {
70
96
  it('null → false (treated as LAN — safe default)', () => {
71
97
  expect(isRemoteClientIp(null)).toBe(false)
@@ -38,13 +38,13 @@ interface DeviceNode {
38
38
  }
39
39
 
40
40
  const TREE: readonly DeviceNode[] = [
41
- { id: 5, parentDeviceId: null }, // Reolink (parent)
42
- { id: 51, parentDeviceId: 5 }, // siren child
43
- { id: 52, parentDeviceId: 5 }, // floodlight child
44
- { id: 53, parentDeviceId: 5 }, // PIR child
45
- { id: 7, parentDeviceId: null }, // Hikvision (parent)
46
- { id: 71, parentDeviceId: 7 }, // hik siren child
47
- { id: 9, parentDeviceId: null }, // Frigate (parent, NOT granted)
41
+ { id: 5, parentDeviceId: null }, // Reolink (parent)
42
+ { id: 51, parentDeviceId: 5 }, // siren child
43
+ { id: 52, parentDeviceId: 5 }, // floodlight child
44
+ { id: 53, parentDeviceId: 5 }, // PIR child
45
+ { id: 7, parentDeviceId: null }, // Hikvision (parent)
46
+ { id: 71, parentDeviceId: 7 }, // hik siren child
47
+ { id: 9, parentDeviceId: null }, // Frigate (parent, NOT granted)
48
48
  ]
49
49
 
50
50
  /** Walks the parent chain — mirrors the real lookup in trpc.context.ts. */
@@ -69,15 +69,15 @@ function deviceScope(targets: readonly string[], access: TokenScope['access']):
69
69
  * just on the matcher's logic.
70
70
  */
71
71
  const VIEW_METHODS = [
72
- 'webrtcSession.listStreams', // view, webrtc-session cap
73
- 'cameraStreams.getCameraStreams', // view, camera-streams cap
72
+ 'webrtcSession.listStreams', // view, webrtc-session cap
73
+ 'cameraStreams.getCameraStreams', // view, camera-streams cap
74
74
  'audioMetrics.getCurrentSnapshot', // view, audio-metrics cap
75
75
  ] as const
76
76
 
77
77
  const CREATE_METHODS = [
78
78
  'webrtcSession.createSession', // create, webrtc-session cap
79
- 'switch.setState', // create, switch cap (accessory typical)
80
- 'reboot.reboot', // create, reboot cap
79
+ 'switch.setState', // create, switch cap (accessory typical)
80
+ 'reboot.reboot', // create, reboot cap
81
81
  ] as const
82
82
 
83
83
  // ── Direct match — granted device, any cap method ──────────────────
@@ -102,7 +102,12 @@ describe('checkScopeAccess — device scope, direct grant', () => {
102
102
  it('admits multiple deviceIds listed in a single scope row', () => {
103
103
  const scopes = [deviceScope(['5', '7'], ['view'])]
104
104
  for (const id of [5, 7]) {
105
- const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: id }, lookupAncestors)
105
+ const result = checkScopeAccess(
106
+ scopes,
107
+ 'webrtcSession.listStreams',
108
+ { deviceId: id },
109
+ lookupAncestors,
110
+ )
106
111
  expect(result.ok, `device ${id} should be allowed`).toBe(true)
107
112
  }
108
113
  })
@@ -125,14 +130,24 @@ describe('checkScopeAccess — device scope, accessory inheritance', () => {
125
130
 
126
131
  it('grant on parent 5 covers PIR 53 (read-only)', () => {
127
132
  const scopes = [deviceScope(['5'], ['view'])]
128
- const result = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: 53 }, lookupAncestors)
133
+ const result = checkScopeAccess(
134
+ scopes,
135
+ 'cameraStreams.getCameraStreams',
136
+ { deviceId: 53 },
137
+ lookupAncestors,
138
+ )
129
139
  expect(result.ok).toBe(true)
130
140
  })
131
141
 
132
142
  it('grant on parent 5 does NOT cover Hikvision parent 7 nor its siren 71', () => {
133
143
  const scopes = [deviceScope(['5'], ['view', 'create'])]
134
144
  for (const orphanId of [7, 71]) {
135
- const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: orphanId }, lookupAncestors)
145
+ const result = checkScopeAccess(
146
+ scopes,
147
+ 'webrtcSession.listStreams',
148
+ { deviceId: orphanId },
149
+ lookupAncestors,
150
+ )
136
151
  expect(result.ok, `device ${orphanId} should NOT be allowed`).toBe(false)
137
152
  }
138
153
  })
@@ -143,14 +158,24 @@ describe('checkScopeAccess — device scope, accessory inheritance', () => {
143
158
  describe('checkScopeAccess — device scope, denial cases', () => {
144
159
  it('denies access to a sibling device not in the grant', () => {
145
160
  const scopes = [deviceScope(['5'], ['view'])]
146
- const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 9 }, lookupAncestors)
161
+ const result = checkScopeAccess(
162
+ scopes,
163
+ 'webrtcSession.listStreams',
164
+ { deviceId: 9 },
165
+ lookupAncestors,
166
+ )
147
167
  expect(result.ok).toBe(false)
148
168
  if (!result.ok) expect(result.reason).toContain('device=9')
149
169
  })
150
170
 
151
171
  it('denies create-flavoured methods when the grant is view-only', () => {
152
172
  const scopes = [deviceScope(['5'], ['view'])]
153
- const result = checkScopeAccess(scopes, 'webrtcSession.createSession', { deviceId: 5 }, lookupAncestors)
173
+ const result = checkScopeAccess(
174
+ scopes,
175
+ 'webrtcSession.createSession',
176
+ { deviceId: 5 },
177
+ lookupAncestors,
178
+ )
154
179
  expect(result.ok).toBe(false)
155
180
  })
156
181
 
@@ -162,11 +187,16 @@ describe('checkScopeAccess — device scope, denial cases', () => {
162
187
  })
163
188
 
164
189
  it('denies when the user has no scopes at all', () => {
165
- const result = checkScopeAccess([], 'webrtcSession.listStreams', { deviceId: 5 }, lookupAncestors)
190
+ const result = checkScopeAccess(
191
+ [],
192
+ 'webrtcSession.listStreams',
193
+ { deviceId: 5 },
194
+ lookupAncestors,
195
+ )
166
196
  expect(result.ok).toBe(false)
167
197
  })
168
198
 
169
- it('does not leak across system-scope caps — device grant doesn\'t cover `addons.list`', () => {
199
+ it("does not leak across system-scope caps — device grant doesn't cover `addons.list`", () => {
170
200
  const scopes = [deviceScope(['5'], ['view', 'create', 'delete'])]
171
201
  // `addons.list` is system-scope; device scope shouldn't help.
172
202
  const result = checkScopeAccess(scopes, 'addons.list')
@@ -182,21 +212,36 @@ describe('checkScopeAccess — device scope mixed with other types', () => {
182
212
  // Operator hands out a broad viewer access. No `device` scope needed.
183
213
  const scopes: TokenScope[] = [{ type: 'category', target: 'device', access: ['view'] }]
184
214
  for (const id of [5, 7, 9, 51, 71]) {
185
- const result = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: id }, lookupAncestors)
215
+ const result = checkScopeAccess(
216
+ scopes,
217
+ 'cameraStreams.getCameraStreams',
218
+ { deviceId: id },
219
+ lookupAncestors,
220
+ )
186
221
  expect(result.ok, `category grant should cover device ${id}`).toBe(true)
187
222
  }
188
223
  })
189
224
 
190
225
  it('disjunction — device grant + capability grant compose by union', () => {
191
226
  const scopes: TokenScope[] = [
192
- deviceScope(['5'], ['view', 'create']), // PTZ, WebRTC on device 5 + accessories
193
- { type: 'capability', target: 'camera-streams', access: ['view'] }, // read streams on any device
227
+ deviceScope(['5'], ['view', 'create']), // PTZ, WebRTC on device 5 + accessories
228
+ { type: 'capability', target: 'camera-streams', access: ['view'] }, // read streams on any device
194
229
  ]
195
230
  // Device 9 not in device grant, but capability grant lets streams through:
196
- const streamRead = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: 9 }, lookupAncestors)
231
+ const streamRead = checkScopeAccess(
232
+ scopes,
233
+ 'cameraStreams.getCameraStreams',
234
+ { deviceId: 9 },
235
+ lookupAncestors,
236
+ )
197
237
  expect(streamRead.ok).toBe(true)
198
238
  // But a create-flavoured method on device 9 still blocked:
199
- const ptzMove = checkScopeAccess(scopes, 'webrtcSession.createSession', { deviceId: 9 }, lookupAncestors)
239
+ const ptzMove = checkScopeAccess(
240
+ scopes,
241
+ 'webrtcSession.createSession',
242
+ { deviceId: 9 },
243
+ lookupAncestors,
244
+ )
200
245
  expect(ptzMove.ok).toBe(false)
201
246
  })
202
247
  })
@@ -11,7 +11,11 @@ import { describe, it, expect } from 'vitest'
11
11
  import { checkScopeAccess } from '../scope-access.js'
12
12
  import type { TokenScope } from '@camstack/types'
13
13
 
14
- function scope(type: 'addon' | 'capability', target: string, access: TokenScope['access']): TokenScope {
14
+ function scope(
15
+ type: 'addon' | 'capability',
16
+ target: string,
17
+ access: TokenScope['access'],
18
+ ): TokenScope {
15
19
  return { type, target, access }
16
20
  }
17
21
 
@@ -29,20 +33,14 @@ describe('checkScopeAccess', () => {
29
33
  // ── Capability-typed scopes ─────────────────────────────────────
30
34
 
31
35
  it('accepts when capability scope matches target + access', () => {
32
- const result = checkScopeAccess(
33
- [scope('capability', 'backup', ['view'])],
34
- 'backup.list',
35
- )
36
+ const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'backup.list')
36
37
  expect(result.ok).toBe(true)
37
38
  if (result.ok) expect(result.access).toBe('view')
38
39
  })
39
40
 
40
41
  it('rejects when capability scope matches target but lacks the required access', () => {
41
42
  // backup.trigger requires `create`; the scope only grants `view`.
42
- const result = checkScopeAccess(
43
- [scope('capability', 'backup', ['view'])],
44
- 'backup.trigger',
45
- )
43
+ const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'backup.trigger')
46
44
  expect(result.ok).toBe(false)
47
45
  if (!result.ok) {
48
46
  // Matcher's reason format: "No scope grants <access> on '<cap>' (<scope>-scope cap)"
@@ -93,10 +91,7 @@ describe('checkScopeAccess', () => {
93
91
  // ── Reason string contains debug-friendly diff ──────────────────
94
92
 
95
93
  it('reason string surfaces what the caller actually has', () => {
96
- const result = checkScopeAccess(
97
- [scope('capability', 'devices', ['view'])],
98
- 'backup.trigger',
99
- )
94
+ const result = checkScopeAccess([scope('capability', 'devices', ['view'])], 'backup.trigger')
100
95
  expect(result.ok).toBe(false)
101
96
  if (!result.ok) {
102
97
  // Format: "No scope grants create on 'backup' (system-scope cap). Have: capability:devices[view]"
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Unit tests for the User-Agent enrichment the hub applies to the
3
+ * `webrtc-session` mount. The hub reads the UA from the tRPC request
4
+ * context and merges it into the subscriber attribution so the
5
+ * stream-broker SUBSCRIBERS panel can identify a browser viewer. Any
6
+ * client-supplied `userAgent` is OVERWRITTEN — the server trusts only the
7
+ * request context, never the client.
8
+ */
9
+ import { describe, it, expect, vi } from 'vitest'
10
+ import type { IncomingMessage } from 'node:http'
11
+ import type { InferProvider, BrokerConsumerAttribution } from '@camstack/types'
12
+ import { webrtcSessionCapability } from '@camstack/types'
13
+ import { enrichInputWithUserAgent, wrapWebrtcSessionProviderWithRelay } from '../trpc.router.js'
14
+ import type { TrpcContext } from '../trpc.context.js'
15
+
16
+ type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
17
+
18
+ function reqWithUa(userAgent?: string): IncomingMessage {
19
+ const headers: Record<string, string | string[]> = {}
20
+ if (userAgent !== undefined) headers['user-agent'] = userAgent
21
+ return { headers, socket: {} } as unknown as IncomingMessage
22
+ }
23
+
24
+ describe('enrichInputWithUserAgent', () => {
25
+ it('passes input through unchanged when userAgent is null', () => {
26
+ const input = { deviceId: 1, consumerAttribution: { kind: 'webrtc-browser' } as const }
27
+ expect(enrichInputWithUserAgent(input, null)).toBe(input)
28
+ })
29
+
30
+ it('merges userAgent into an existing attribution (new object)', () => {
31
+ const attribution: BrokerConsumerAttribution = { kind: 'webrtc-browser', label: 'alice' }
32
+ const input = { deviceId: 1, consumerAttribution: attribution }
33
+ const out = enrichInputWithUserAgent(input, 'Chrome/120')
34
+ expect(out.consumerAttribution).toEqual({
35
+ kind: 'webrtc-browser',
36
+ label: 'alice',
37
+ userAgent: 'Chrome/120',
38
+ })
39
+ // Immutability: the original attribution is untouched.
40
+ expect(attribution.userAgent).toBeUndefined()
41
+ })
42
+
43
+ it('defaults to webrtc-browser when no attribution was supplied', () => {
44
+ const out = enrichInputWithUserAgent({ deviceId: 1 }, 'Firefox/121')
45
+ expect(out.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Firefox/121' })
46
+ })
47
+
48
+ it('OVERWRITES a client-supplied userAgent (never trust the client)', () => {
49
+ const input = {
50
+ deviceId: 1,
51
+ consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed' } as const,
52
+ }
53
+ const out = enrichInputWithUserAgent(input, 'Safari/17')
54
+ expect(out.consumerAttribution?.userAgent).toBe('Safari/17')
55
+ })
56
+ })
57
+
58
+ describe('wrapWebrtcSessionProviderWithRelay — UA enrichment', () => {
59
+ function mockProvider(): WebrtcSessionProvider {
60
+ const createSession = vi.fn().mockResolvedValue({ sessionId: 's', sdpOffer: 'o' })
61
+ const handleOffer = vi.fn().mockResolvedValue({ sessionId: 's', sdpAnswer: 'a' })
62
+ const passthrough = vi.fn().mockResolvedValue(undefined)
63
+ return {
64
+ createSession,
65
+ handleOffer,
66
+ listStreams: vi.fn().mockResolvedValue([]),
67
+ handleAnswer: passthrough,
68
+ addIceCandidate: passthrough,
69
+ getIceCandidates: vi.fn().mockResolvedValue({ candidates: [], done: true }),
70
+ closeSession: passthrough,
71
+ hasAdaptiveBitrate: vi.fn().mockResolvedValue(false),
72
+ getSessionState: vi.fn().mockResolvedValue({ pendingRenegotiation: null }),
73
+ }
74
+ }
75
+
76
+ function ctxWith(userAgent?: string): TrpcContext {
77
+ return { user: null, req: reqWithUa(userAgent) }
78
+ }
79
+
80
+ it('injects the request UA into createSession attribution', async () => {
81
+ const provider = mockProvider()
82
+ const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Edg/120'))
83
+
84
+ await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
85
+
86
+ expect(provider.createSession).toHaveBeenCalledTimes(1)
87
+ const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
88
+ expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Edg/120' })
89
+ })
90
+
91
+ it('injects the request UA into handleOffer attribution', async () => {
92
+ const provider = mockProvider()
93
+ const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('CriOS/120'))
94
+
95
+ await wrapped.handleOffer({ deviceId: 1, sdpOffer: 'x' })
96
+
97
+ const arg = vi.mocked(provider.handleOffer).mock.calls[0]![0]
98
+ expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'CriOS/120' })
99
+ })
100
+
101
+ it('overwrites a client-supplied UA on createSession', async () => {
102
+ const provider = mockProvider()
103
+ const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('TrustedUA/1'))
104
+
105
+ await wrapped.createSession({
106
+ deviceId: 1,
107
+ target: { kind: 'adaptive' },
108
+ consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed', label: 'bob' },
109
+ })
110
+
111
+ const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
112
+ expect(arg.consumerAttribution).toEqual({
113
+ kind: 'webrtc-browser',
114
+ label: 'bob',
115
+ userAgent: 'TrustedUA/1',
116
+ })
117
+ })
118
+
119
+ it('does not alter the call when no UA header is present', async () => {
120
+ const provider = mockProvider()
121
+ const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith(undefined))
122
+
123
+ await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
124
+
125
+ const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
126
+ expect(arg.consumerAttribution).toBeUndefined()
127
+ })
128
+
129
+ it('delegates other methods straight through', async () => {
130
+ const provider = mockProvider()
131
+ const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Chrome/120'))
132
+
133
+ await wrapped.closeSession({ deviceId: 1, sessionId: 's' })
134
+ expect(provider.closeSession).toHaveBeenCalledWith({ deviceId: 1, sessionId: 's' })
135
+ })
136
+ })