@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.
- package/package.json +9 -7
- 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 +24 -4
- 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 +64 -15
- 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 +14 -6
- 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 +11 -6
- 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 +71 -17
- 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/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 +346 -202
- 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 +54 -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__/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 +10 -2
- package/src/api/trpc/cap-mount-helpers.ts +64 -55
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +593 -351
- package/src/api/trpc/generated-cap-routers.ts +3680 -579
- 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 +79 -46
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +21 -6
- package/src/boot/boot-config.ts +103 -122
- 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/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1163 -1305
- 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 +12 -3
- 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 +120 -32
- package/src/core/network/network-quality.service.spec.ts +6 -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 +602 -531
- package/src/manual-boot.ts +133 -154
- 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
|
-
{
|
|
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' })
|
|
@@ -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]"
|
|
@@ -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({
|
|
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({
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 {
|
|
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>([
|
|
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(
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
|
76
|
+
return (
|
|
77
|
+
def !== null && typeof def === 'object' && (def as { procedure?: unknown }).procedure === true
|
|
78
|
+
)
|
|
77
79
|
}
|
|
78
80
|
|
|
79
81
|
interface DiscoveredProcedure {
|