@camstack/server 0.1.8 → 0.2.1

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 (125) hide show
  1. package/package.json +9 -7
  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 +24 -4
  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 +64 -15
  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 +14 -6
  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 +11 -6
  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 +71 -17
  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/addon-settings.router.ts +4 -1
  60. package/src/api/core/agents.router.ts +52 -53
  61. package/src/api/core/auth.router.ts +55 -36
  62. package/src/api/core/bulk-update-coordinator.ts +25 -22
  63. package/src/api/core/cap-providers.ts +346 -202
  64. package/src/api/core/capabilities.router.ts +30 -23
  65. package/src/api/core/hwaccel.router.ts +37 -10
  66. package/src/api/core/live-events.router.ts +16 -9
  67. package/src/api/core/logs.router.ts +54 -25
  68. package/src/api/core/notifications.router.ts +2 -1
  69. package/src/api/core/repl.router.ts +1 -3
  70. package/src/api/core/settings-backend.router.ts +68 -70
  71. package/src/api/core/system-events.router.ts +41 -32
  72. package/src/api/health/health.routes.ts +7 -13
  73. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  74. package/src/api/oauth2/consent-page.ts +4 -3
  75. package/src/api/oauth2/oauth2-routes.ts +41 -12
  76. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  77. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  78. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +10 -2
  79. package/src/api/trpc/cap-mount-helpers.ts +64 -55
  80. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  81. package/src/api/trpc/core-cap-bridge.ts +3 -1
  82. package/src/api/trpc/generated-cap-mounts.ts +593 -351
  83. package/src/api/trpc/generated-cap-routers.ts +3680 -579
  84. package/src/api/trpc/scope-access.ts +7 -7
  85. package/src/api/trpc/trpc.context.ts +7 -4
  86. package/src/api/trpc/trpc.middleware.ts +4 -2
  87. package/src/api/trpc/trpc.router.ts +79 -46
  88. package/src/auth/session-cookie.ts +10 -0
  89. package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
  90. package/src/boot/boot-config.ts +103 -122
  91. package/src/boot/post-boot.service.ts +5 -3
  92. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  93. package/src/core/addon/addon-call-gateway.ts +20 -6
  94. package/src/core/addon/addon-package.service.ts +183 -89
  95. package/src/core/addon/addon-registry.service.ts +1163 -1305
  96. package/src/core/addon/addon-search.service.ts +2 -1
  97. package/src/core/addon/addon-settings-provider.ts +27 -7
  98. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  99. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  100. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  101. package/src/core/agent/agent-registry.service.ts +60 -38
  102. package/src/core/auth/auth.service.spec.ts +6 -8
  103. package/src/core/config/config.service.spec.ts +1 -1
  104. package/src/core/events/event-bus.service.spec.ts +44 -21
  105. package/src/core/events/event-bus.service.ts +5 -1
  106. package/src/core/feature/feature.service.spec.ts +4 -1
  107. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  108. package/src/core/logging/logging.service.spec.ts +61 -21
  109. package/src/core/logging/logging.service.ts +12 -3
  110. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  111. package/src/core/moleculer/cap-call-fn.ts +5 -1
  112. package/src/core/moleculer/cap-route-authority.ts +18 -6
  113. package/src/core/moleculer/moleculer.service.ts +120 -32
  114. package/src/core/network/network-quality.service.spec.ts +6 -1
  115. package/src/core/notification/notification-wrapper.service.ts +1 -3
  116. package/src/core/notification/toast-wrapper.service.ts +1 -5
  117. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  118. package/src/core/repl/repl-engine.service.ts +11 -12
  119. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  120. package/src/core/streaming/stream-probe.service.ts +22 -13
  121. package/src/core/topology/topology-emitter.service.ts +5 -1
  122. package/src/launcher.ts +14 -9
  123. package/src/main.ts +602 -531
  124. package/src/manual-boot.ts +133 -154
  125. package/tsconfig.json +20 -8
@@ -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' })
@@ -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]"
@@ -31,7 +31,11 @@ describe('enrichInputWithUserAgent', () => {
31
31
  const attribution: BrokerConsumerAttribution = { kind: 'webrtc-browser', label: 'alice' }
32
32
  const input = { deviceId: 1, consumerAttribution: attribution }
33
33
  const out = enrichInputWithUserAgent(input, 'Chrome/120')
34
- expect(out.consumerAttribution).toEqual({ kind: 'webrtc-browser', label: 'alice', userAgent: 'Chrome/120' })
34
+ expect(out.consumerAttribution).toEqual({
35
+ kind: 'webrtc-browser',
36
+ label: 'alice',
37
+ userAgent: 'Chrome/120',
38
+ })
35
39
  // Immutability: the original attribution is untouched.
36
40
  expect(attribution.userAgent).toBeUndefined()
37
41
  })
@@ -105,7 +109,11 @@ describe('wrapWebrtcSessionProviderWithRelay — UA enrichment', () => {
105
109
  })
106
110
 
107
111
  const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
108
- expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', label: 'bob', userAgent: 'TrustedUA/1' })
112
+ expect(arg.consumerAttribution).toEqual({
113
+ kind: 'webrtc-browser',
114
+ label: 'bob',
115
+ userAgent: 'TrustedUA/1',
116
+ })
109
117
  })
110
118
 
111
119
  it('does not alter the call when no UA header is present', async () => {
@@ -64,49 +64,60 @@ export function requireDeviceScoped<K extends keyof CapabilityProviderMap>(
64
64
  // a function that, on call, looks up the per-device native and
65
65
  // forwards the call. No caching — the lookup is cheap (Map.get) and
66
66
  // re-doing it per call lets devices come/go without stale refs.
67
- const dispatcher = new Proxy({}, {
68
- get(_target, prop: string | symbol) {
69
- if (typeof prop !== 'string') return undefined
70
- return async (input: { deviceId?: number } & Record<string, unknown>) => {
71
- const deviceId = input?.deviceId
72
- if (typeof deviceId !== 'number') {
73
- throw new TRPCError({
74
- code: 'BAD_REQUEST',
75
- message: `${String(capName)}.${prop}: input must carry numeric "deviceId"`,
76
- })
67
+ const dispatcher = new Proxy(
68
+ {},
69
+ {
70
+ get(_target, prop: string | symbol) {
71
+ if (typeof prop !== 'string') return undefined
72
+ return async (input: { deviceId?: number } & Record<string, unknown>) => {
73
+ const deviceId = input?.deviceId
74
+ if (typeof deviceId !== 'number') {
75
+ throw new TRPCError({
76
+ code: 'BAD_REQUEST',
77
+ message: `${String(capName)}.${prop}: input must carry numeric "deviceId"`,
78
+ })
79
+ }
80
+ const native = registry.getNativeProvider<Record<string, (i: unknown) => unknown>>(
81
+ capName,
82
+ deviceId,
83
+ )
84
+ if (!native) {
85
+ throw new TRPCError({
86
+ code: 'PRECONDITION_FAILED',
87
+ message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
88
+ })
89
+ }
90
+ const fn = native[prop]
91
+ if (typeof fn !== 'function') {
92
+ throw new TRPCError({
93
+ code: 'NOT_IMPLEMENTED',
94
+ message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
95
+ })
96
+ }
97
+ const result = await fn.call(native, input)
98
+ // Device-property-wiring overlay (read-time): only `getStatus`, and only
99
+ // when the device has links for this cap (resolveLinkedStatus returns
100
+ // null otherwise → base result untouched). One in-process singleton hop.
101
+ if (prop === 'getStatus') {
102
+ const deviceManager = registry.getSingleton<{
103
+ resolveLinkedStatus?: (i: {
104
+ deviceId: number
105
+ cap: string
106
+ baseStatus: unknown
107
+ }) => Promise<Record<string, unknown> | null>
108
+ }>('device-manager')
109
+ const overlaid = await deviceManager?.resolveLinkedStatus?.({
110
+ deviceId,
111
+ cap: String(capName),
112
+ baseStatus: result,
113
+ })
114
+ if (overlaid != null) return overlaid
115
+ }
116
+ return result
77
117
  }
78
- const native = registry.getNativeProvider<Record<string, (i: unknown) => unknown>>(
79
- capName,
80
- deviceId,
81
- )
82
- if (!native) {
83
- throw new TRPCError({
84
- code: 'PRECONDITION_FAILED',
85
- message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
86
- })
87
- }
88
- const fn = native[prop]
89
- if (typeof fn !== 'function') {
90
- throw new TRPCError({
91
- code: 'NOT_IMPLEMENTED',
92
- message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
93
- })
94
- }
95
- const result = await fn.call(native, input)
96
- // Device-property-wiring overlay (read-time): only `getStatus`, and only
97
- // when the device has links for this cap (resolveLinkedStatus returns
98
- // null otherwise → base result untouched). One in-process singleton hop.
99
- if (prop === 'getStatus') {
100
- const deviceManager = registry.getSingleton<{
101
- resolveLinkedStatus?: (i: { deviceId: number; cap: string; baseStatus: unknown }) => Promise<Record<string, unknown> | null>
102
- }>('device-manager')
103
- const overlaid = await deviceManager?.resolveLinkedStatus?.({ deviceId, cap: String(capName), baseStatus: result })
104
- if (overlaid != null) return overlaid
105
- }
106
- return result
107
- }
118
+ },
108
119
  },
109
- })
120
+ )
110
121
  return dispatcher as unknown as CapabilityProviderMap[K]
111
122
  }
112
123
 
@@ -114,16 +125,16 @@ export function requireDeviceScoped<K extends keyof CapabilityProviderMap>(
114
125
 
115
126
  /** A key on T whose value is a function with array / promise-array return. */
116
127
  type ArrayReturningMethodKey<T> = {
117
- [K in keyof T]: T[K] extends (...args: infer _A) => readonly unknown[] | Promise<readonly unknown[]>
128
+ [K in keyof T]: T[K] extends (
129
+ ...args: infer _A
130
+ ) => readonly unknown[] | Promise<readonly unknown[]>
118
131
  ? K
119
132
  : never
120
133
  }[keyof T]
121
134
 
122
135
  /** A key on T whose value is a function returning boolean / promise-boolean. */
123
136
  type BoolReturningMethodKey<T> = {
124
- [K in keyof T]: T[K] extends (...args: infer _A) => boolean | Promise<boolean>
125
- ? K
126
- : never
137
+ [K in keyof T]: T[K] extends (...args: infer _A) => boolean | Promise<boolean> ? K : never
127
138
  }[keyof T]
128
139
 
129
140
  /**
@@ -131,10 +142,7 @@ type BoolReturningMethodKey<T> = {
131
142
  * and concatenates their array results. Useful for contribution-style
132
143
  * caps where each provider adds to a shared pool.
133
144
  */
134
- export function concatCollection<
135
- T extends object,
136
- K extends ArrayReturningMethodKey<T>,
137
- >(
145
+ export function concatCollection<T extends object, K extends ArrayReturningMethodKey<T>>(
138
146
  providers: readonly T[],
139
147
  method: K,
140
148
  ): T[K] extends (...args: infer A) => readonly (infer R)[] | Promise<readonly (infer R)[]>
@@ -158,7 +166,9 @@ export function concatCollection<
158
166
  // matches the declared generic conditional return; TypeScript's
159
167
  // conditional types can't be narrowed inside a function body, so this
160
168
  // boundary assertion is required.
161
- return wrapper as T[K] extends (...args: infer A) => readonly (infer R)[] | Promise<readonly (infer R)[]>
169
+ return wrapper as T[K] extends (
170
+ ...args: infer A
171
+ ) => readonly (infer R)[] | Promise<readonly (infer R)[]>
162
172
  ? (...args: A) => Promise<readonly R[]>
163
173
  : never
164
174
  }
@@ -209,10 +219,7 @@ export function firstSupported<
209
219
  * Convenience for collection caps that want a "logical OR of probes"
210
220
  * (e.g. `supportsDevice` across every snapshot-provider).
211
221
  */
212
- export function anySupports<
213
- T extends object,
214
- K extends BoolReturningMethodKey<T>,
215
- >(
222
+ export function anySupports<T extends object, K extends BoolReturningMethodKey<T>>(
216
223
  providers: readonly T[],
217
224
  probe: K,
218
225
  ): T[K] extends (...args: infer A) => boolean | Promise<boolean>
@@ -225,7 +232,9 @@ export function anySupports<
225
232
  try {
226
233
  const result: unknown = await Reflect.apply(member, p, args)
227
234
  if (result === true) return true
228
- } catch { /* next */ }
235
+ } catch {
236
+ /* next */
237
+ }
229
238
  }
230
239
  return false
231
240
  }
@@ -38,7 +38,12 @@ export interface AugmentedErrorShape extends DefaultErrorShape {
38
38
  // ---------------------------------------------------------------------------
39
39
 
40
40
  /** Known CapRouteError reason values — used as a runtime safety rail. */
41
- const KNOWN_REASONS = new Set<string>(['no-provider', 'node-offline', 'cap-unknown', 'transport-failed'])
41
+ const KNOWN_REASONS = new Set<string>([
42
+ 'no-provider',
43
+ 'node-offline',
44
+ 'cap-unknown',
45
+ 'transport-failed',
46
+ ])
42
47
 
43
48
  /** Narrows a plain string to the `CapRouteError['reason']` union. */
44
49
  function isCapRouteReason(r: string): r is CapRouteError['reason'] {
@@ -101,14 +106,17 @@ function extractCapRouteError(err: unknown): CapRouteError | null {
101
106
  const capName: string = typeof rawCapName === 'string' ? rawCapName : '(unknown)'
102
107
 
103
108
  // Build a minimal object with the same shape — enough for the formatter.
104
- const synthetic = Object.assign(new CapRouteError(capName, undefined, {
105
- reason,
106
- rejected,
107
- ...(typeof nodeId === 'string' ? { nodeId } : {}),
108
- }), {
109
- // Override message from the original if available
110
- message: typeof message === 'string' ? message : '(duck-typed CapRouteError)',
111
- })
109
+ const synthetic = Object.assign(
110
+ new CapRouteError(capName, undefined, {
111
+ reason,
112
+ rejected,
113
+ ...(typeof nodeId === 'string' ? { nodeId } : {}),
114
+ }),
115
+ {
116
+ // Override message from the original if available
117
+ message: typeof message === 'string' ? message : '(duck-typed CapRouteError)',
118
+ },
119
+ )
112
120
  return synthetic
113
121
  }
114
122
  }
@@ -73,7 +73,9 @@ function isProcedureNode(value: unknown): value is ProcedureNode {
73
73
  // stores the procedure function directly, not a wrapper object.
74
74
  if (value === null || (typeof value !== 'object' && typeof value !== 'function')) return false
75
75
  const def: unknown = (value as { _def?: unknown })._def
76
- return def !== null && typeof def === 'object' && (def as { procedure?: unknown }).procedure === true
76
+ return (
77
+ def !== null && typeof def === 'object' && (def as { procedure?: unknown }).procedure === true
78
+ )
77
79
  }
78
80
 
79
81
  interface DiscoveredProcedure {