@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.
- package/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- 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(
|
|
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
|
-
{
|
|
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
|
-
{
|
|
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(
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
return s.replace(
|
|
3
|
+
/[&<>"']/g,
|
|
4
|
+
(c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 {
|
|
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 {
|
|
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(
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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(
|
|
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
|
|
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
|
|
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(
|
|
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(
|
|
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({
|
|
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,
|
|
42
|
-
{ id: 51, parentDeviceId: 5 },
|
|
43
|
-
{ id: 52, parentDeviceId: 5 },
|
|
44
|
-
{ id: 53, parentDeviceId: 5 },
|
|
45
|
-
{ id: 7,
|
|
46
|
-
{ id: 71, parentDeviceId: 7 },
|
|
47
|
-
{ id: 9,
|
|
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',
|
|
73
|
-
'cameraStreams.getCameraStreams',
|
|
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',
|
|
80
|
-
'reboot.reboot',
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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']),
|
|
193
|
-
{ type: 'capability', target: 'camera-streams', access: ['view'] },
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
+
})
|