@camstack/server 1.0.0 → 1.0.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/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
- package/dist/api/addon-upload.js +441 -0
- package/dist/api/addons-custom.router.js +91 -0
- package/dist/api/auth-whoami.js +55 -0
- package/dist/api/bridge-addons.router.js +109 -0
- package/dist/api/capabilities.router.js +229 -0
- package/dist/api/core/addon-settings.router.js +117 -0
- package/dist/api/core/agents.router.js +73 -0
- package/dist/api/core/auth.router.js +286 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +1124 -0
- package/dist/api/core/capabilities.router.js +138 -0
- package/dist/api/core/collection-preference.js +17 -0
- package/dist/api/core/event-bus-proxy.router.js +45 -0
- package/dist/api/core/hwaccel.router.js +91 -0
- package/dist/api/core/live-events.router.js +61 -0
- package/dist/api/core/logs.router.js +172 -0
- package/dist/api/core/notifications.router.js +67 -0
- package/dist/api/core/repl.router.js +35 -0
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/core/stream-probe.router.js +58 -0
- package/dist/api/core/system-events.router.js +100 -0
- package/dist/api/health/health.routes.js +68 -0
- package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
- package/dist/api/oauth2/oauth2-routes.js +219 -0
- package/dist/api/trpc/cap-mount-helpers.js +194 -0
- package/dist/api/trpc/cap-route-error-formatter.js +133 -0
- package/dist/api/trpc/client-ip.js +147 -0
- package/dist/api/trpc/core-cap-bridge.js +115 -0
- package/dist/api/trpc/generated-cap-mounts.js +388 -0
- package/dist/api/trpc/generated-cap-routers.js +7635 -0
- package/dist/api/trpc/scope-access.js +93 -0
- package/dist/api/trpc/trpc.context.js +184 -0
- package/dist/api/trpc/trpc.middleware.js +139 -0
- package/dist/api/trpc/trpc.router.js +188 -0
- package/dist/auth/session-cookie.js +47 -0
- package/dist/boot/boot-config.js +241 -0
- package/dist/boot/integration-id-backfill.js +76 -0
- package/dist/boot/post-boot.service.js +85 -0
- package/dist/core/addon/addon-call-gateway.js +99 -0
- package/dist/core/addon/addon-package.service.js +1560 -0
- package/dist/core/addon/addon-registry.service.js +2739 -0
- package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
- package/dist/core/addon/addon-search.service.js +62 -0
- package/dist/core/addon/addon-settings-provider.js +102 -0
- package/dist/core/addon/addon.tokens.js +5 -0
- package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
- package/dist/core/addon-pages/addon-pages.service.js +107 -0
- package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
- package/dist/core/agent/agent-registry.service.js +477 -0
- package/dist/core/auth/auth.service.js +10 -0
- package/dist/core/capability/capability.service.js +58 -0
- package/dist/core/config/config.schema.js +7 -0
- package/dist/core/config/config.service.js +10 -0
- package/dist/core/events/event-bus.service.js +83 -0
- package/dist/core/feature/feature.service.js +10 -0
- package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
- package/dist/core/logging/log-ring-buffer.js +6 -0
- package/dist/core/logging/logging.service.js +130 -0
- package/dist/core/logging/scoped-logger.js +6 -0
- package/dist/core/moleculer/cap-call-fn.js +50 -0
- package/dist/core/moleculer/cap-route-authority.js +122 -0
- package/dist/core/moleculer/moleculer.service.js +898 -0
- package/dist/core/network/network-quality.service.js +7 -0
- package/dist/core/notification/notification-wrapper.service.js +33 -0
- package/dist/core/notification/toast-wrapper.service.js +25 -0
- package/dist/core/provider/provider.tokens.js +4 -0
- package/dist/core/repl/repl-engine.service.js +140 -0
- package/dist/core/storage/fs-storage-backend.js +6 -0
- package/dist/core/storage/storage-location-manager.js +6 -0
- package/dist/core/storage/storage.service.js +7 -0
- package/dist/core/streaming/stream-probe.service.js +209 -0
- package/dist/core/topology/topology-emitter.service.js +106 -0
- package/dist/launcher.js +325 -0
- package/dist/main.js +1098 -0
- package/dist/manual-boot.js +227 -0
- package/package.json +5 -1
- package/src/__tests__/addon-install-e2e.test.ts +0 -74
- package/src/__tests__/addon-pages-e2e.test.ts +0 -200
- package/src/__tests__/addon-route-session.test.ts +0 -17
- package/src/__tests__/addon-settings-router.spec.ts +0 -67
- package/src/__tests__/addon-upload.spec.ts +0 -475
- package/src/__tests__/agent-registry.spec.ts +0 -179
- package/src/__tests__/agent-status-page.spec.ts +0 -82
- package/src/__tests__/auth-session-cookie.test.ts +0 -48
- package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
- package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
- package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
- package/src/__tests__/cap-route-adapter.spec.ts +0 -302
- package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
- package/src/__tests__/cap-routers/harness.ts +0 -163
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
- package/src/__tests__/capability-e2e.test.ts +0 -384
- package/src/__tests__/cli-e2e.test.ts +0 -150
- package/src/__tests__/core-cap-bridge.spec.ts +0 -91
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
- package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
- package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
- package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
- package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
- package/src/__tests__/framework-allowlist.spec.ts +0 -96
- package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
- package/src/__tests__/https-e2e.test.ts +0 -124
- package/src/__tests__/lifecycle-e2e.test.ts +0 -189
- package/src/__tests__/live-events-subscription.spec.ts +0 -149
- package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
- package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
- package/src/__tests__/native-cap-route.spec.ts +0 -427
- package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
- package/src/__tests__/post-boot-restart.spec.ts +0 -161
- package/src/__tests__/singleton-contention.test.ts +0 -499
- package/src/__tests__/streaming-diagnostic.test.ts +0 -615
- package/src/__tests__/streaming-scale.test.ts +0 -314
- package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
- package/src/__tests__/uds-log-ingest.spec.ts +0 -183
- package/src/api/__tests__/addons-custom.spec.ts +0 -148
- package/src/api/__tests__/capabilities.router.test.ts +0 -56
- package/src/api/addon-upload.ts +0 -529
- package/src/api/addons-custom.router.ts +0 -101
- package/src/api/auth-whoami.ts +0 -101
- package/src/api/bridge-addons.router.ts +0 -122
- package/src/api/capabilities.router.ts +0 -265
- package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
- package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
- package/src/api/core/addon-settings.router.ts +0 -127
- package/src/api/core/agents.router.ts +0 -86
- package/src/api/core/auth.router.ts +0 -322
- package/src/api/core/bulk-update-coordinator.ts +0 -305
- package/src/api/core/cap-providers.ts +0 -1339
- package/src/api/core/capabilities.router.ts +0 -149
- package/src/api/core/collection-preference.ts +0 -40
- package/src/api/core/event-bus-proxy.router.ts +0 -45
- package/src/api/core/hwaccel.router.ts +0 -108
- package/src/api/core/live-events.router.ts +0 -67
- package/src/api/core/logs.router.ts +0 -195
- package/src/api/core/notifications.router.ts +0 -66
- package/src/api/core/repl.router.ts +0 -39
- package/src/api/core/settings-backend.router.ts +0 -140
- package/src/api/core/stream-probe.router.ts +0 -57
- package/src/api/core/system-events.router.ts +0 -125
- package/src/api/health/health.routes.ts +0 -117
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
- package/src/api/oauth2/oauth2-routes.ts +0 -281
- package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
- package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
- package/src/api/trpc/cap-mount-helpers.ts +0 -245
- package/src/api/trpc/cap-route-error-formatter.ts +0 -171
- package/src/api/trpc/client-ip.ts +0 -147
- package/src/api/trpc/core-cap-bridge.ts +0 -154
- package/src/api/trpc/generated-cap-mounts.ts +0 -1240
- package/src/api/trpc/generated-cap-routers.ts +0 -11523
- package/src/api/trpc/scope-access.ts +0 -110
- package/src/api/trpc/trpc.context.ts +0 -258
- package/src/api/trpc/trpc.middleware.ts +0 -146
- package/src/api/trpc/trpc.router.ts +0 -389
- package/src/auth/session-cookie.ts +0 -54
- package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
- package/src/boot/boot-config.ts +0 -259
- package/src/boot/integration-id-backfill.ts +0 -109
- package/src/boot/post-boot.service.ts +0 -105
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
- package/src/core/addon/addon-call-gateway.ts +0 -171
- package/src/core/addon/addon-package.service.ts +0 -1787
- package/src/core/addon/addon-registry.service.ts +0 -3130
- package/src/core/addon/addon-search.service.ts +0 -91
- package/src/core/addon/addon-settings-provider.ts +0 -220
- package/src/core/addon/addon.tokens.ts +0 -2
- package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
- package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
- package/src/core/addon-pages/addon-pages.service.ts +0 -82
- package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
- package/src/core/agent/agent-registry.service.ts +0 -529
- package/src/core/auth/auth.service.spec.ts +0 -86
- package/src/core/auth/auth.service.ts +0 -8
- package/src/core/capability/capability.service.ts +0 -66
- package/src/core/config/config.schema.ts +0 -3
- package/src/core/config/config.service.spec.ts +0 -175
- package/src/core/config/config.service.ts +0 -7
- package/src/core/events/event-bus.service.spec.ts +0 -235
- package/src/core/events/event-bus.service.ts +0 -89
- package/src/core/feature/feature.service.spec.ts +0 -99
- package/src/core/feature/feature.service.ts +0 -8
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
- package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
- package/src/core/logging/log-ring-buffer.ts +0 -3
- package/src/core/logging/logging.service.spec.ts +0 -287
- package/src/core/logging/logging.service.ts +0 -143
- package/src/core/logging/scoped-logger.ts +0 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
- package/src/core/moleculer/cap-call-fn.ts +0 -107
- package/src/core/moleculer/cap-route-authority.ts +0 -194
- package/src/core/moleculer/moleculer.service.ts +0 -1072
- package/src/core/network/network-quality.service.spec.ts +0 -53
- package/src/core/network/network-quality.service.ts +0 -5
- package/src/core/notification/notification-wrapper.service.ts +0 -34
- package/src/core/notification/toast-wrapper.service.ts +0 -27
- package/src/core/provider/provider.tokens.ts +0 -1
- package/src/core/repl/repl-engine.service.spec.ts +0 -444
- package/src/core/repl/repl-engine.service.ts +0 -155
- package/src/core/storage/fs-storage-backend.spec.ts +0 -70
- package/src/core/storage/fs-storage-backend.ts +0 -3
- package/src/core/storage/storage-location-manager.spec.ts +0 -130
- package/src/core/storage/storage-location-manager.ts +0 -3
- package/src/core/storage/storage.service.spec.ts +0 -73
- package/src/core/storage/storage.service.ts +0 -3
- package/src/core/streaming/stream-probe.service.ts +0 -221
- package/src/core/topology/topology-emitter.service.ts +0 -105
- package/src/launcher.ts +0 -314
- package/src/main.ts +0 -1245
- package/src/manual-boot.ts +0 -301
- package/tsconfig.build.json +0 -8
- package/tsconfig.json +0 -33
- package/vitest.config.ts +0 -26
|
@@ -1,281 +0,0 @@
|
|
|
1
|
-
import type { FastifyInstance } from 'fastify'
|
|
2
|
-
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
3
|
-
import type {
|
|
4
|
-
IOauthIntegrationProvider,
|
|
5
|
-
IUserManagementProvider,
|
|
6
|
-
OauthIntegrationDescriptor,
|
|
7
|
-
TokenScope,
|
|
8
|
-
} from '@camstack/types'
|
|
9
|
-
import { renderConsentPage } from './consent-page.js'
|
|
10
|
-
import {
|
|
11
|
-
SESSION_COOKIE,
|
|
12
|
-
shouldRedirectToLogin,
|
|
13
|
-
loginRedirectUrl,
|
|
14
|
-
} from '../../auth/session-cookie.js'
|
|
15
|
-
|
|
16
|
-
export interface AuthorizeQuery {
|
|
17
|
-
response_type?: string
|
|
18
|
-
integration?: string
|
|
19
|
-
redirect_uri?: string
|
|
20
|
-
state?: string
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
export type AuthorizeValidation =
|
|
24
|
-
| { ok: true; integration: string; redirectUri: string; state: string }
|
|
25
|
-
| { ok: false; status: number; error: string }
|
|
26
|
-
|
|
27
|
-
/** Validate the inbound authorize query. `client_id` is intentionally
|
|
28
|
-
* NOT checked — that pair is verified only at the Lambda boundary. */
|
|
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' }
|
|
39
|
-
if (!q.state) return { ok: false, status: 400, error: 'invalid_request — state required' }
|
|
40
|
-
return { ok: true, integration: q.integration, redirectUri: q.redirect_uri, state: q.state }
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
/** True if `redirectUri` starts with one of the integration's allowed prefixes. */
|
|
44
|
-
export function isRedirectUriAllowed(
|
|
45
|
-
redirectUri: string,
|
|
46
|
-
allowedPrefixes: readonly string[],
|
|
47
|
-
): boolean {
|
|
48
|
-
return allowedPrefixes.some((p) => redirectUri.startsWith(p))
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** One-line human summary of a scope list for the consent screen. */
|
|
52
|
-
export function summariseScopes(scopes: readonly TokenScope[]): string {
|
|
53
|
-
if (scopes.some((s) => s.type === 'category' && s.target === 'device')) {
|
|
54
|
-
return 'view and control all your cameras and devices'
|
|
55
|
-
}
|
|
56
|
-
return scopes.map((s) => s.type).join(', ') || 'no permissions'
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export interface Oauth2Deps {
|
|
60
|
-
getRegistry: () => CapabilityRegistry | null
|
|
61
|
-
verifyToken: (token: string) => { userId?: string; username?: string }
|
|
62
|
-
publicHubUrl: () => string
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
|
|
66
|
-
async function buildIntegrationMap(
|
|
67
|
-
registry: CapabilityRegistry,
|
|
68
|
-
): Promise<{ descriptorMap: Map<string, OauthIntegrationDescriptor>; knownSet: Set<string> }> {
|
|
69
|
-
const entries = registry.getCollectionEntries<IOauthIntegrationProvider>('oauth-integration')
|
|
70
|
-
const descriptorMap = new Map<string, OauthIntegrationDescriptor>()
|
|
71
|
-
for (const [, provider] of entries) {
|
|
72
|
-
const descriptor = await provider.getDescriptor()
|
|
73
|
-
descriptorMap.set(descriptor.integrationId, descriptor)
|
|
74
|
-
}
|
|
75
|
-
const knownSet = new Set(descriptorMap.keys())
|
|
76
|
-
return { descriptorMap, knownSet }
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/** Parse an application/x-www-form-urlencoded body string into a plain object. */
|
|
80
|
-
function parseFormBody(raw: string): Record<string, string> {
|
|
81
|
-
const params = new URLSearchParams(raw)
|
|
82
|
-
const result: Record<string, string> = {}
|
|
83
|
-
for (const [key, value] of params.entries()) {
|
|
84
|
-
result[key] = value
|
|
85
|
-
}
|
|
86
|
-
return result
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps): void {
|
|
90
|
-
// Register a content-type parser for application/x-www-form-urlencoded so that
|
|
91
|
-
// consent POST and token POST can read form bodies. @fastify/formbody is not in
|
|
92
|
-
// the project's dependencies; we use URLSearchParams (Node built-in) instead.
|
|
93
|
-
fastify.addContentTypeParser(
|
|
94
|
-
'application/x-www-form-urlencoded',
|
|
95
|
-
{ parseAs: 'string' },
|
|
96
|
-
(_req, body, done) => {
|
|
97
|
-
try {
|
|
98
|
-
done(null, parseFormBody(body as string))
|
|
99
|
-
} catch (err) {
|
|
100
|
-
done(err as Error, undefined)
|
|
101
|
-
}
|
|
102
|
-
},
|
|
103
|
-
)
|
|
104
|
-
|
|
105
|
-
// ─── GET /api/oauth2/authorize ────────────────────────────────────────────
|
|
106
|
-
// Mounted under /api/* so it is naturally excluded from the SPA catch-all,
|
|
107
|
-
// the PWA service-worker navigate fallback, and the Vite dev proxy — no
|
|
108
|
-
// per-path special-casing anywhere.
|
|
109
|
-
fastify.get('/api/oauth2/authorize', async (request, reply) => {
|
|
110
|
-
const cookie = (request.cookies as Record<string, string | undefined>)[SESSION_COOKIE]
|
|
111
|
-
if (!cookie) {
|
|
112
|
-
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
113
|
-
return reply.redirect(loginRedirectUrl(request.url))
|
|
114
|
-
}
|
|
115
|
-
return reply.status(401).send({ error: 'unauthorized' })
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
let tokenInfo: { userId?: string; username?: string }
|
|
119
|
-
try {
|
|
120
|
-
tokenInfo = deps.verifyToken(cookie)
|
|
121
|
-
} catch {
|
|
122
|
-
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
123
|
-
return reply.redirect(loginRedirectUrl(request.url))
|
|
124
|
-
}
|
|
125
|
-
return reply.status(401).send({ error: 'unauthorized' })
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
const registry = deps.getRegistry()
|
|
129
|
-
if (!registry) {
|
|
130
|
-
return reply.status(503).send({ error: 'service_unavailable' })
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const { descriptorMap, knownSet } = await buildIntegrationMap(registry)
|
|
134
|
-
const query = request.query as AuthorizeQuery
|
|
135
|
-
const v = validateAuthorizeQuery(query, knownSet)
|
|
136
|
-
if (!v.ok) {
|
|
137
|
-
return reply.status(v.status).send({ error: v.error })
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const descriptor = descriptorMap.get(v.integration)!
|
|
141
|
-
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
|
|
142
|
-
return reply
|
|
143
|
-
.status(400)
|
|
144
|
-
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
const html = renderConsentPage({
|
|
148
|
-
displayName: descriptor.displayName,
|
|
149
|
-
username: tokenInfo.username ?? '',
|
|
150
|
-
scopeSummary: summariseScopes(descriptor.requestedScopes),
|
|
151
|
-
hidden: {
|
|
152
|
-
integration: v.integration,
|
|
153
|
-
redirect_uri: v.redirectUri,
|
|
154
|
-
state: v.state,
|
|
155
|
-
response_type: 'code',
|
|
156
|
-
},
|
|
157
|
-
})
|
|
158
|
-
return reply.type('text/html').send(html)
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
// ─── POST /api/oauth2/authorize ───────────────────────────────────────────
|
|
162
|
-
fastify.post('/api/oauth2/authorize', async (request, reply) => {
|
|
163
|
-
const cookie = (request.cookies as Record<string, string | undefined>)[SESSION_COOKIE]
|
|
164
|
-
if (!cookie) {
|
|
165
|
-
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
166
|
-
return reply.redirect(loginRedirectUrl(request.url))
|
|
167
|
-
}
|
|
168
|
-
return reply.status(401).send({ error: 'unauthorized' })
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
let tokenInfo: { userId?: string; username?: string }
|
|
172
|
-
try {
|
|
173
|
-
tokenInfo = deps.verifyToken(cookie)
|
|
174
|
-
} catch {
|
|
175
|
-
if (shouldRedirectToLogin(request.method, request.headers.accept)) {
|
|
176
|
-
return reply.redirect(loginRedirectUrl(request.url))
|
|
177
|
-
}
|
|
178
|
-
return reply.status(401).send({ error: 'unauthorized' })
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const registry = deps.getRegistry()
|
|
182
|
-
if (!registry) {
|
|
183
|
-
return reply.status(503).send({ error: 'service_unavailable' })
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
const { descriptorMap, knownSet } = await buildIntegrationMap(registry)
|
|
187
|
-
const body = request.body as Record<string, string | undefined>
|
|
188
|
-
const formQuery: AuthorizeQuery = {
|
|
189
|
-
response_type: body.response_type,
|
|
190
|
-
integration: body.integration,
|
|
191
|
-
redirect_uri: body.redirect_uri,
|
|
192
|
-
state: body.state,
|
|
193
|
-
}
|
|
194
|
-
const v = validateAuthorizeQuery(formQuery, knownSet)
|
|
195
|
-
if (!v.ok) {
|
|
196
|
-
return reply.status(v.status).send({ error: v.error })
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
const descriptor = descriptorMap.get(v.integration)!
|
|
200
|
-
if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
|
|
201
|
-
return reply
|
|
202
|
-
.status(400)
|
|
203
|
-
.send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (body.consent !== 'allow') {
|
|
207
|
-
return reply.redirect(
|
|
208
|
-
`${v.redirectUri}?error=access_denied&state=${encodeURIComponent(v.state)}`,
|
|
209
|
-
)
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const userMgmt = registry.getSingleton<IUserManagementProvider>('user-management')
|
|
213
|
-
if (!userMgmt) {
|
|
214
|
-
return reply.status(503).send({ error: 'service_unavailable' })
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
const { code } = await userMgmt.oauthIssueCode({
|
|
218
|
-
integrationId: v.integration,
|
|
219
|
-
userId: tokenInfo.userId ?? '',
|
|
220
|
-
username: tokenInfo.username ?? '',
|
|
221
|
-
scopes: descriptor.requestedScopes,
|
|
222
|
-
redirectUri: v.redirectUri,
|
|
223
|
-
// Prefer the integration's own public origin (e.g. the operator-selected
|
|
224
|
-
// external-access endpoint surfaced by a forked exporter addon) so the
|
|
225
|
-
// claim the cloud Lambda routes back on is the reachable public URL, not
|
|
226
|
-
// the hub-global fallback (which defaults to localhost in dev).
|
|
227
|
-
hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
|
|
228
|
-
})
|
|
229
|
-
|
|
230
|
-
return reply.redirect(
|
|
231
|
-
`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`,
|
|
232
|
-
)
|
|
233
|
-
})
|
|
234
|
-
|
|
235
|
-
// ─── POST /api/oauth2/token ───────────────────────────────────────────────
|
|
236
|
-
// No session gate — called by Alexa/Lambda directly.
|
|
237
|
-
fastify.post('/api/oauth2/token', async (request, reply) => {
|
|
238
|
-
const registry = deps.getRegistry()
|
|
239
|
-
if (!registry) {
|
|
240
|
-
return reply.status(503).send({ error: 'service_unavailable' })
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const userMgmt = registry.getSingleton<IUserManagementProvider>('user-management')
|
|
244
|
-
if (!userMgmt) {
|
|
245
|
-
return reply.status(503).send({ error: 'service_unavailable' })
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
const body = request.body as Record<string, string | undefined>
|
|
249
|
-
|
|
250
|
-
type TokenResult = { accessToken: string; refreshToken: string; expiresIn: number } | null
|
|
251
|
-
|
|
252
|
-
let tokenResult: TokenResult
|
|
253
|
-
if (body.grant_type === 'authorization_code') {
|
|
254
|
-
if (!body.code || !body.redirect_uri) {
|
|
255
|
-
return reply.status(400).send({ error: 'invalid_request' })
|
|
256
|
-
}
|
|
257
|
-
tokenResult = await userMgmt.oauthExchangeCode({
|
|
258
|
-
code: body.code,
|
|
259
|
-
redirectUri: body.redirect_uri,
|
|
260
|
-
})
|
|
261
|
-
} else if (body.grant_type === 'refresh_token') {
|
|
262
|
-
if (!body.refresh_token) {
|
|
263
|
-
return reply.status(400).send({ error: 'invalid_request' })
|
|
264
|
-
}
|
|
265
|
-
tokenResult = await userMgmt.oauthRefresh({ refreshToken: body.refresh_token })
|
|
266
|
-
} else {
|
|
267
|
-
return reply.status(400).send({ error: 'unsupported_grant_type' })
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
if (tokenResult === null) {
|
|
271
|
-
return reply.status(400).send({ error: 'invalid_grant' })
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
return reply.send({
|
|
275
|
-
access_token: tokenResult.accessToken,
|
|
276
|
-
refresh_token: tokenResult.refreshToken,
|
|
277
|
-
expires_in: tokenResult.expiresIn,
|
|
278
|
-
token_type: 'Bearer',
|
|
279
|
-
})
|
|
280
|
-
})
|
|
281
|
-
}
|
|
@@ -1,146 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Unit tests for the client-IP extraction + LAN/remote classification
|
|
3
|
-
* used by the `webrtcSession.createSession` relay-only override.
|
|
4
|
-
*
|
|
5
|
-
* The classification is the load-bearing decision: a `true` from
|
|
6
|
-
* `isRemoteClientIp` forces TURN-relay-only ICE for that live-view
|
|
7
|
-
* session. A false positive would needlessly relay a LAN viewer
|
|
8
|
-
* (latency); a false negative would leave a remote viewer on the dead
|
|
9
|
-
* direct path (the bug being fixed). The private-range edges are
|
|
10
|
-
* therefore exercised explicitly.
|
|
11
|
-
*/
|
|
12
|
-
import { describe, it, expect } from 'vitest'
|
|
13
|
-
import type { IncomingMessage } from 'node:http'
|
|
14
|
-
import { extractClientIp, extractUserAgent, isRemoteClientIp } from '../client-ip.js'
|
|
15
|
-
|
|
16
|
-
function reqWith(opts: {
|
|
17
|
-
xff?: string | string[]
|
|
18
|
-
ip?: string
|
|
19
|
-
remoteAddress?: string
|
|
20
|
-
userAgent?: string | string[]
|
|
21
|
-
}): IncomingMessage {
|
|
22
|
-
const headers: Record<string, string | string[]> = {}
|
|
23
|
-
if (opts.xff !== undefined) headers['x-forwarded-for'] = opts.xff
|
|
24
|
-
if (opts.userAgent !== undefined) headers['user-agent'] = opts.userAgent
|
|
25
|
-
const req = {
|
|
26
|
-
headers,
|
|
27
|
-
socket: { remoteAddress: opts.remoteAddress },
|
|
28
|
-
} as unknown as IncomingMessage
|
|
29
|
-
if (opts.ip !== undefined) {
|
|
30
|
-
Object.defineProperty(req, 'ip', { value: opts.ip, enumerable: true })
|
|
31
|
-
}
|
|
32
|
-
return req
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
describe('extractClientIp', () => {
|
|
36
|
-
it('returns null for an absent request (mesh-originated call)', () => {
|
|
37
|
-
expect(extractClientIp(undefined)).toBeNull()
|
|
38
|
-
})
|
|
39
|
-
|
|
40
|
-
it('prefers the first X-Forwarded-For hop over socket/ip', () => {
|
|
41
|
-
const req = reqWith({ xff: '203.0.113.7, 10.0.0.1', ip: '10.0.0.1', remoteAddress: '10.0.0.1' })
|
|
42
|
-
expect(extractClientIp(req)).toBe('203.0.113.7')
|
|
43
|
-
})
|
|
44
|
-
|
|
45
|
-
it('handles X-Forwarded-For as an array', () => {
|
|
46
|
-
const req = reqWith({ xff: ['198.51.100.4, 10.0.0.1'] })
|
|
47
|
-
expect(extractClientIp(req)).toBe('198.51.100.4')
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('falls back to req.ip when no XFF', () => {
|
|
51
|
-
const req = reqWith({ ip: '192.168.1.50', remoteAddress: '192.168.1.50' })
|
|
52
|
-
expect(extractClientIp(req)).toBe('192.168.1.50')
|
|
53
|
-
})
|
|
54
|
-
|
|
55
|
-
it('falls back to socket.remoteAddress when no XFF or ip', () => {
|
|
56
|
-
const req = reqWith({ remoteAddress: '127.0.0.1' })
|
|
57
|
-
expect(extractClientIp(req)).toBe('127.0.0.1')
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('strips IPv4-mapped IPv6 prefix', () => {
|
|
61
|
-
const req = reqWith({ remoteAddress: '::ffff:192.168.1.5' })
|
|
62
|
-
expect(extractClientIp(req)).toBe('192.168.1.5')
|
|
63
|
-
})
|
|
64
|
-
|
|
65
|
-
it('strips IPv6 zone id', () => {
|
|
66
|
-
const req = reqWith({ remoteAddress: 'fe80::1%en0' })
|
|
67
|
-
expect(extractClientIp(req)).toBe('fe80::1')
|
|
68
|
-
})
|
|
69
|
-
})
|
|
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
|
-
|
|
95
|
-
describe('isRemoteClientIp', () => {
|
|
96
|
-
it('null → false (treated as LAN — safe default)', () => {
|
|
97
|
-
expect(isRemoteClientIp(null)).toBe(false)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
// Private / loopback / link-local / Tailscale ranges → LAN (direct path kept)
|
|
101
|
-
const lan = [
|
|
102
|
-
'10.0.0.1',
|
|
103
|
-
'10.255.255.255',
|
|
104
|
-
'172.16.0.1',
|
|
105
|
-
'172.31.255.255',
|
|
106
|
-
'192.168.1.1',
|
|
107
|
-
'127.0.0.1',
|
|
108
|
-
'169.254.1.1',
|
|
109
|
-
// Tailscale CGNAT overlay (100.64.0.0/10) — direct host↔host over the mesh
|
|
110
|
-
'100.64.0.1', // bottom of 100.64/10
|
|
111
|
-
'100.104.179.3', // the hub's own Tailscale IP (confirmed live)
|
|
112
|
-
'100.127.255.255', // top of 100.64/10
|
|
113
|
-
'::1',
|
|
114
|
-
'fe80::1',
|
|
115
|
-
'fc00::1',
|
|
116
|
-
'fd12:3456::1',
|
|
117
|
-
// Tailscale ULA overlay (fd7a::/16) — subset of fc00::/7
|
|
118
|
-
'fd7a:115c:a1e0::1',
|
|
119
|
-
]
|
|
120
|
-
for (const ip of lan) {
|
|
121
|
-
it(`LAN: ${ip} → not remote`, () => {
|
|
122
|
-
expect(isRemoteClientIp(ip)).toBe(false)
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
// Public ranges → remote (relay-only forced)
|
|
127
|
-
const remote = [
|
|
128
|
-
'203.0.113.7', // TEST-NET-3 (public)
|
|
129
|
-
'8.8.8.8',
|
|
130
|
-
'172.15.0.1', // just below the 172.16/12 private block
|
|
131
|
-
'172.32.0.1', // just above the 172.16/12 private block
|
|
132
|
-
'11.0.0.1', // just above 10/8
|
|
133
|
-
'100.63.255.255', // just below the 100.64/10 Tailscale block (public)
|
|
134
|
-
'100.128.0.1', // just above the 100.64/10 Tailscale block (public)
|
|
135
|
-
'2001:4860:4860::8888', // public IPv6
|
|
136
|
-
]
|
|
137
|
-
for (const ip of remote) {
|
|
138
|
-
it(`remote: ${ip} → remote`, () => {
|
|
139
|
-
expect(isRemoteClientIp(ip)).toBe(true)
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
it('unparseable literal → false (conservative LAN default)', () => {
|
|
144
|
-
expect(isRemoteClientIp('not-an-ip')).toBe(false)
|
|
145
|
-
})
|
|
146
|
-
})
|
|
@@ -1,268 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Behaviour tests for the `device` scope type — the operator's main lever
|
|
3
|
-
* for "this user can manage cameras 5 and 7 only".
|
|
4
|
-
*
|
|
5
|
-
* Coverage matrix:
|
|
6
|
-
* - Direct device match: every device-scope cap method on a granted
|
|
7
|
-
* deviceId is allowed (PTZ, WebRTC, switch, intercom, reboot, …).
|
|
8
|
-
* - Accessory inheritance: a grant on a Reolink parent (deviceId=5)
|
|
9
|
-
* transparently covers its child accessories (siren=51, floodlight=52,
|
|
10
|
-
* PIR=53). No re-listing required.
|
|
11
|
-
* - Negative path: devices NOT in the grant — including unrelated
|
|
12
|
-
* siblings AND accessories of un-granted parents — are denied.
|
|
13
|
-
* - Access tier: a `view`-only grant doesn't permit `create`-flavoured
|
|
14
|
-
* methods on the same device.
|
|
15
|
-
* - Decoupling: device scopes don't bleed into system-scope caps; a
|
|
16
|
-
* non-admin with only `device:5` still gets 403 on `addons.list` etc.
|
|
17
|
-
* - JWT staleness: a child accessory adopted AFTER the grant is still
|
|
18
|
-
* covered (the matcher walks the live tree, not a frozen snapshot).
|
|
19
|
-
*
|
|
20
|
-
* The fixture mimics the Reolink topology described in CLAUDE.md:
|
|
21
|
-
* - device 5 (Reolink camera, parent)
|
|
22
|
-
* ├── device 51 (siren accessory)
|
|
23
|
-
* ├── device 52 (floodlight accessory)
|
|
24
|
-
* └── device 53 (PIR accessory)
|
|
25
|
-
* - device 7 (Hikvision camera, parent)
|
|
26
|
-
* └── device 71 (siren accessory)
|
|
27
|
-
* - device 9 (Frigate camera, NO accessories, NOT granted)
|
|
28
|
-
*/
|
|
29
|
-
import { describe, it, expect } from 'vitest'
|
|
30
|
-
import { checkScopeAccess, type DeviceAncestorLookup } from '../scope-access.js'
|
|
31
|
-
import type { TokenScope } from '@camstack/types'
|
|
32
|
-
|
|
33
|
-
// ── Fixtures ────────────────────────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
interface DeviceNode {
|
|
36
|
-
readonly id: number
|
|
37
|
-
readonly parentDeviceId: number | null
|
|
38
|
-
}
|
|
39
|
-
|
|
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)
|
|
48
|
-
]
|
|
49
|
-
|
|
50
|
-
/** Walks the parent chain — mirrors the real lookup in trpc.context.ts. */
|
|
51
|
-
const lookupAncestors: DeviceAncestorLookup = (deviceId) => {
|
|
52
|
-
const out: number[] = []
|
|
53
|
-
let current = TREE.find((d) => d.id === deviceId)
|
|
54
|
-
for (let hop = 0; hop < 8 && current?.parentDeviceId != null; hop++) {
|
|
55
|
-
out.push(current.parentDeviceId)
|
|
56
|
-
current = TREE.find((d) => d.id === current?.parentDeviceId)
|
|
57
|
-
}
|
|
58
|
-
return out
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function deviceScope(targets: readonly string[], access: TokenScope['access']): TokenScope {
|
|
62
|
-
return { type: 'device', targets: [...targets], access: [...access] }
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Representative device-scope cap methods drawn from `METHOD_ACCESS_MAP`.
|
|
67
|
-
* Picked one of each access flavour and a mix of cap families so the
|
|
68
|
-
* test fails on real codegen drift (renamed cap, dropped method) not
|
|
69
|
-
* just on the matcher's logic.
|
|
70
|
-
*/
|
|
71
|
-
const VIEW_METHODS = [
|
|
72
|
-
'webrtcSession.listStreams', // view, webrtc-session cap
|
|
73
|
-
'cameraStreams.getCameraStreams', // view, camera-streams cap
|
|
74
|
-
'audioMetrics.getCurrentSnapshot', // view, audio-metrics cap
|
|
75
|
-
] as const
|
|
76
|
-
|
|
77
|
-
const CREATE_METHODS = [
|
|
78
|
-
'webrtcSession.createSession', // create, webrtc-session cap
|
|
79
|
-
'switch.setState', // create, switch cap (accessory typical)
|
|
80
|
-
'reboot.reboot', // create, reboot cap
|
|
81
|
-
] as const
|
|
82
|
-
|
|
83
|
-
// ── Direct match — granted device, any cap method ──────────────────
|
|
84
|
-
|
|
85
|
-
describe('checkScopeAccess — device scope, direct grant', () => {
|
|
86
|
-
it('admits every view-flavoured device cap on the granted deviceId', () => {
|
|
87
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
88
|
-
for (const path of VIEW_METHODS) {
|
|
89
|
-
const result = checkScopeAccess(scopes, path, { deviceId: 5 }, lookupAncestors)
|
|
90
|
-
expect(result.ok, `${path} should be allowed for view-granted device`).toBe(true)
|
|
91
|
-
}
|
|
92
|
-
})
|
|
93
|
-
|
|
94
|
-
it('admits create-flavoured methods when the grant includes `create`', () => {
|
|
95
|
-
const scopes = [deviceScope(['5'], ['view', 'create'])]
|
|
96
|
-
for (const path of CREATE_METHODS) {
|
|
97
|
-
const result = checkScopeAccess(scopes, path, { deviceId: 5 }, lookupAncestors)
|
|
98
|
-
expect(result.ok, `${path} should be allowed for create-granted device`).toBe(true)
|
|
99
|
-
}
|
|
100
|
-
})
|
|
101
|
-
|
|
102
|
-
it('admits multiple deviceIds listed in a single scope row', () => {
|
|
103
|
-
const scopes = [deviceScope(['5', '7'], ['view'])]
|
|
104
|
-
for (const id of [5, 7]) {
|
|
105
|
-
const result = checkScopeAccess(
|
|
106
|
-
scopes,
|
|
107
|
-
'webrtcSession.listStreams',
|
|
108
|
-
{ deviceId: id },
|
|
109
|
-
lookupAncestors,
|
|
110
|
-
)
|
|
111
|
-
expect(result.ok, `device ${id} should be allowed`).toBe(true)
|
|
112
|
-
}
|
|
113
|
-
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
// ── Accessory inheritance — children of granted parent ─────────────
|
|
117
|
-
|
|
118
|
-
describe('checkScopeAccess — device scope, accessory inheritance', () => {
|
|
119
|
-
it('grant on parent 5 covers siren child 51', () => {
|
|
120
|
-
const scopes = [deviceScope(['5'], ['view', 'create'])]
|
|
121
|
-
const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 51 }, lookupAncestors)
|
|
122
|
-
expect(result.ok).toBe(true)
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('grant on parent 5 covers floodlight 52', () => {
|
|
126
|
-
const scopes = [deviceScope(['5'], ['view', 'create'])]
|
|
127
|
-
const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 52 }, lookupAncestors)
|
|
128
|
-
expect(result.ok).toBe(true)
|
|
129
|
-
})
|
|
130
|
-
|
|
131
|
-
it('grant on parent 5 covers PIR 53 (read-only)', () => {
|
|
132
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
133
|
-
const result = checkScopeAccess(
|
|
134
|
-
scopes,
|
|
135
|
-
'cameraStreams.getCameraStreams',
|
|
136
|
-
{ deviceId: 53 },
|
|
137
|
-
lookupAncestors,
|
|
138
|
-
)
|
|
139
|
-
expect(result.ok).toBe(true)
|
|
140
|
-
})
|
|
141
|
-
|
|
142
|
-
it('grant on parent 5 does NOT cover Hikvision parent 7 nor its siren 71', () => {
|
|
143
|
-
const scopes = [deviceScope(['5'], ['view', 'create'])]
|
|
144
|
-
for (const orphanId of [7, 71]) {
|
|
145
|
-
const result = checkScopeAccess(
|
|
146
|
-
scopes,
|
|
147
|
-
'webrtcSession.listStreams',
|
|
148
|
-
{ deviceId: orphanId },
|
|
149
|
-
lookupAncestors,
|
|
150
|
-
)
|
|
151
|
-
expect(result.ok, `device ${orphanId} should NOT be allowed`).toBe(false)
|
|
152
|
-
}
|
|
153
|
-
})
|
|
154
|
-
})
|
|
155
|
-
|
|
156
|
-
// ── Negative paths — unscoped devices, missing access ──────────────
|
|
157
|
-
|
|
158
|
-
describe('checkScopeAccess — device scope, denial cases', () => {
|
|
159
|
-
it('denies access to a sibling device not in the grant', () => {
|
|
160
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
161
|
-
const result = checkScopeAccess(
|
|
162
|
-
scopes,
|
|
163
|
-
'webrtcSession.listStreams',
|
|
164
|
-
{ deviceId: 9 },
|
|
165
|
-
lookupAncestors,
|
|
166
|
-
)
|
|
167
|
-
expect(result.ok).toBe(false)
|
|
168
|
-
if (!result.ok) expect(result.reason).toContain('device=9')
|
|
169
|
-
})
|
|
170
|
-
|
|
171
|
-
it('denies create-flavoured methods when the grant is view-only', () => {
|
|
172
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
173
|
-
const result = checkScopeAccess(
|
|
174
|
-
scopes,
|
|
175
|
-
'webrtcSession.createSession',
|
|
176
|
-
{ deviceId: 5 },
|
|
177
|
-
lookupAncestors,
|
|
178
|
-
)
|
|
179
|
-
expect(result.ok).toBe(false)
|
|
180
|
-
})
|
|
181
|
-
|
|
182
|
-
it('denies access on accessory whose parent is NOT in the grant', () => {
|
|
183
|
-
// grant on 7, but try to act on accessory 51 (whose parent is 5)
|
|
184
|
-
const scopes = [deviceScope(['7'], ['view', 'create'])]
|
|
185
|
-
const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 51 }, lookupAncestors)
|
|
186
|
-
expect(result.ok).toBe(false)
|
|
187
|
-
})
|
|
188
|
-
|
|
189
|
-
it('denies when the user has no scopes at all', () => {
|
|
190
|
-
const result = checkScopeAccess(
|
|
191
|
-
[],
|
|
192
|
-
'webrtcSession.listStreams',
|
|
193
|
-
{ deviceId: 5 },
|
|
194
|
-
lookupAncestors,
|
|
195
|
-
)
|
|
196
|
-
expect(result.ok).toBe(false)
|
|
197
|
-
})
|
|
198
|
-
|
|
199
|
-
it("does not leak across system-scope caps — device grant doesn't cover `addons.list`", () => {
|
|
200
|
-
const scopes = [deviceScope(['5'], ['view', 'create', 'delete'])]
|
|
201
|
-
// `addons.list` is system-scope; device scope shouldn't help.
|
|
202
|
-
const result = checkScopeAccess(scopes, 'addons.list')
|
|
203
|
-
expect(result.ok).toBe(false)
|
|
204
|
-
if (!result.ok) expect(result.reason).toContain('system-scope cap')
|
|
205
|
-
})
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
// ── Composition with other scope types ─────────────────────────────
|
|
209
|
-
|
|
210
|
-
describe('checkScopeAccess — device scope mixed with other types', () => {
|
|
211
|
-
it('a `category:device [view]` grant is open across all devices regardless of device scope', () => {
|
|
212
|
-
// Operator hands out a broad viewer access. No `device` scope needed.
|
|
213
|
-
const scopes: TokenScope[] = [{ type: 'category', target: 'device', access: ['view'] }]
|
|
214
|
-
for (const id of [5, 7, 9, 51, 71]) {
|
|
215
|
-
const result = checkScopeAccess(
|
|
216
|
-
scopes,
|
|
217
|
-
'cameraStreams.getCameraStreams',
|
|
218
|
-
{ deviceId: id },
|
|
219
|
-
lookupAncestors,
|
|
220
|
-
)
|
|
221
|
-
expect(result.ok, `category grant should cover device ${id}`).toBe(true)
|
|
222
|
-
}
|
|
223
|
-
})
|
|
224
|
-
|
|
225
|
-
it('disjunction — device grant + capability grant compose by union', () => {
|
|
226
|
-
const scopes: TokenScope[] = [
|
|
227
|
-
deviceScope(['5'], ['view', 'create']), // PTZ, WebRTC on device 5 + accessories
|
|
228
|
-
{ type: 'capability', target: 'camera-streams', access: ['view'] }, // read streams on any device
|
|
229
|
-
]
|
|
230
|
-
// Device 9 not in device grant, but capability grant lets streams through:
|
|
231
|
-
const streamRead = checkScopeAccess(
|
|
232
|
-
scopes,
|
|
233
|
-
'cameraStreams.getCameraStreams',
|
|
234
|
-
{ deviceId: 9 },
|
|
235
|
-
lookupAncestors,
|
|
236
|
-
)
|
|
237
|
-
expect(streamRead.ok).toBe(true)
|
|
238
|
-
// But a create-flavoured method on device 9 still blocked:
|
|
239
|
-
const ptzMove = checkScopeAccess(
|
|
240
|
-
scopes,
|
|
241
|
-
'webrtcSession.createSession',
|
|
242
|
-
{ deviceId: 9 },
|
|
243
|
-
lookupAncestors,
|
|
244
|
-
)
|
|
245
|
-
expect(ptzMove.ok).toBe(false)
|
|
246
|
-
})
|
|
247
|
-
})
|
|
248
|
-
|
|
249
|
-
// ── Edge cases — input shape & malformed scopes ────────────────────
|
|
250
|
-
|
|
251
|
-
describe('checkScopeAccess — device scope, edge cases', () => {
|
|
252
|
-
it('falls back to deny when the input has no deviceId (device-scope cap requires one)', () => {
|
|
253
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
254
|
-
// No deviceId in input → matcher can't locate the target → reject.
|
|
255
|
-
const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', {}, lookupAncestors)
|
|
256
|
-
expect(result.ok).toBe(false)
|
|
257
|
-
})
|
|
258
|
-
|
|
259
|
-
it('works without a registry — direct match only, no inheritance', () => {
|
|
260
|
-
// No `getDeviceAncestors` (e.g. in a tRPC context that didn't wire one).
|
|
261
|
-
// Direct device grant still works; child accessory does NOT inherit.
|
|
262
|
-
const scopes = [deviceScope(['5'], ['view'])]
|
|
263
|
-
const directHit = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 5 })
|
|
264
|
-
expect(directHit.ok).toBe(true)
|
|
265
|
-
const childMiss = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 51 })
|
|
266
|
-
expect(childMiss.ok).toBe(false)
|
|
267
|
-
})
|
|
268
|
-
})
|