@camstack/server 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,248 @@
1
+ import type { FastifyInstance } from 'fastify'
2
+ import type { CapabilityRegistry } from '@camstack/kernel'
3
+ import type { IOauthIntegrationProvider, IUserManagementProvider, OauthIntegrationDescriptor, TokenScope } from '@camstack/types'
4
+ import { renderConsentPage } from './consent-page.js'
5
+ import { SESSION_COOKIE, shouldRedirectToLogin, loginRedirectUrl } from '../../auth/session-cookie.js'
6
+
7
+ export interface AuthorizeQuery {
8
+ response_type?: string
9
+ integration?: string
10
+ redirect_uri?: string
11
+ state?: string
12
+ }
13
+
14
+ export type AuthorizeValidation =
15
+ | { ok: true; integration: string; redirectUri: string; state: string }
16
+ | { ok: false; status: number; error: string }
17
+
18
+ /** Validate the inbound authorize query. `client_id` is intentionally
19
+ * NOT checked — that pair is verified only at the Lambda boundary. */
20
+ export function validateAuthorizeQuery(q: AuthorizeQuery, knownIntegrations: ReadonlySet<string>): AuthorizeValidation {
21
+ if (q.response_type !== 'code') return { ok: false, status: 400, error: 'unsupported_response_type' }
22
+ if (!q.integration || !knownIntegrations.has(q.integration)) return { ok: false, status: 400, error: 'invalid_request — unknown integration' }
23
+ if (!q.redirect_uri) return { ok: false, status: 400, error: 'invalid_request — redirect_uri required' }
24
+ if (!q.state) return { ok: false, status: 400, error: 'invalid_request — state required' }
25
+ return { ok: true, integration: q.integration, redirectUri: q.redirect_uri, state: q.state }
26
+ }
27
+
28
+ /** True if `redirectUri` starts with one of the integration's allowed prefixes. */
29
+ export function isRedirectUriAllowed(redirectUri: string, allowedPrefixes: readonly string[]): boolean {
30
+ return allowedPrefixes.some((p) => redirectUri.startsWith(p))
31
+ }
32
+
33
+ /** One-line human summary of a scope list for the consent screen. */
34
+ export function summariseScopes(scopes: readonly TokenScope[]): string {
35
+ if (scopes.some((s) => s.type === 'category' && s.target === 'device')) {
36
+ return 'view and control all your cameras and devices'
37
+ }
38
+ return scopes.map((s) => s.type).join(', ') || 'no permissions'
39
+ }
40
+
41
+ export interface Oauth2Deps {
42
+ getRegistry: () => CapabilityRegistry | null
43
+ verifyToken: (token: string) => { userId?: string; username?: string }
44
+ publicHubUrl: () => string
45
+ }
46
+
47
+ /** Build a map of integrationId → descriptor from all registered oauth-integration providers. */
48
+ async function buildIntegrationMap(
49
+ registry: CapabilityRegistry,
50
+ ): Promise<{ descriptorMap: Map<string, OauthIntegrationDescriptor>; knownSet: Set<string> }> {
51
+ const entries = registry.getCollectionEntries<IOauthIntegrationProvider>('oauth-integration')
52
+ const descriptorMap = new Map<string, OauthIntegrationDescriptor>()
53
+ for (const [, provider] of entries) {
54
+ const descriptor = await provider.getDescriptor()
55
+ descriptorMap.set(descriptor.integrationId, descriptor)
56
+ }
57
+ const knownSet = new Set(descriptorMap.keys())
58
+ return { descriptorMap, knownSet }
59
+ }
60
+
61
+ /** Parse an application/x-www-form-urlencoded body string into a plain object. */
62
+ function parseFormBody(raw: string): Record<string, string> {
63
+ const params = new URLSearchParams(raw)
64
+ const result: Record<string, string> = {}
65
+ for (const [key, value] of params.entries()) {
66
+ result[key] = value
67
+ }
68
+ return result
69
+ }
70
+
71
+ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps): void {
72
+ // Register a content-type parser for application/x-www-form-urlencoded so that
73
+ // consent POST and token POST can read form bodies. @fastify/formbody is not in
74
+ // the project's dependencies; we use URLSearchParams (Node built-in) instead.
75
+ fastify.addContentTypeParser(
76
+ 'application/x-www-form-urlencoded',
77
+ { parseAs: 'string' },
78
+ (_req, body, done) => {
79
+ try {
80
+ done(null, parseFormBody(body as string))
81
+ } catch (err) {
82
+ done(err as Error, undefined)
83
+ }
84
+ },
85
+ )
86
+
87
+ // ─── GET /api/oauth2/authorize ────────────────────────────────────────────
88
+ // Mounted under /api/* so it is naturally excluded from the SPA catch-all,
89
+ // the PWA service-worker navigate fallback, and the Vite dev proxy — no
90
+ // per-path special-casing anywhere.
91
+ fastify.get('/api/oauth2/authorize', async (request, reply) => {
92
+ const cookie = (request.cookies as Record<string, string | undefined>)[SESSION_COOKIE]
93
+ if (!cookie) {
94
+ if (shouldRedirectToLogin(request.method, request.headers.accept)) {
95
+ return reply.redirect(loginRedirectUrl(request.url))
96
+ }
97
+ return reply.status(401).send({ error: 'unauthorized' })
98
+ }
99
+
100
+ let tokenInfo: { userId?: string; username?: string }
101
+ try {
102
+ tokenInfo = deps.verifyToken(cookie)
103
+ } catch {
104
+ if (shouldRedirectToLogin(request.method, request.headers.accept)) {
105
+ return reply.redirect(loginRedirectUrl(request.url))
106
+ }
107
+ return reply.status(401).send({ error: 'unauthorized' })
108
+ }
109
+
110
+ const registry = deps.getRegistry()
111
+ if (!registry) {
112
+ return reply.status(503).send({ error: 'service_unavailable' })
113
+ }
114
+
115
+ const { descriptorMap, knownSet } = await buildIntegrationMap(registry)
116
+ const query = request.query as AuthorizeQuery
117
+ const v = validateAuthorizeQuery(query, knownSet)
118
+ if (!v.ok) {
119
+ return reply.status(v.status).send({ error: v.error })
120
+ }
121
+
122
+ const descriptor = descriptorMap.get(v.integration)!
123
+ if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
124
+ return reply.status(400).send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
125
+ }
126
+
127
+ const html = renderConsentPage({
128
+ displayName: descriptor.displayName,
129
+ username: tokenInfo.username ?? '',
130
+ scopeSummary: summariseScopes(descriptor.requestedScopes),
131
+ hidden: {
132
+ integration: v.integration,
133
+ redirect_uri: v.redirectUri,
134
+ state: v.state,
135
+ response_type: 'code',
136
+ },
137
+ })
138
+ return reply.type('text/html').send(html)
139
+ })
140
+
141
+ // ─── POST /api/oauth2/authorize ───────────────────────────────────────────
142
+ fastify.post('/api/oauth2/authorize', async (request, reply) => {
143
+ const cookie = (request.cookies as Record<string, string | undefined>)[SESSION_COOKIE]
144
+ if (!cookie) {
145
+ if (shouldRedirectToLogin(request.method, request.headers.accept)) {
146
+ return reply.redirect(loginRedirectUrl(request.url))
147
+ }
148
+ return reply.status(401).send({ error: 'unauthorized' })
149
+ }
150
+
151
+ let tokenInfo: { userId?: string; username?: string }
152
+ try {
153
+ tokenInfo = deps.verifyToken(cookie)
154
+ } catch {
155
+ if (shouldRedirectToLogin(request.method, request.headers.accept)) {
156
+ return reply.redirect(loginRedirectUrl(request.url))
157
+ }
158
+ return reply.status(401).send({ error: 'unauthorized' })
159
+ }
160
+
161
+ const registry = deps.getRegistry()
162
+ if (!registry) {
163
+ return reply.status(503).send({ error: 'service_unavailable' })
164
+ }
165
+
166
+ const { descriptorMap, knownSet } = await buildIntegrationMap(registry)
167
+ const body = request.body as Record<string, string | undefined>
168
+ const formQuery: AuthorizeQuery = {
169
+ response_type: body.response_type,
170
+ integration: body.integration,
171
+ redirect_uri: body.redirect_uri,
172
+ state: body.state,
173
+ }
174
+ const v = validateAuthorizeQuery(formQuery, knownSet)
175
+ if (!v.ok) {
176
+ return reply.status(v.status).send({ error: v.error })
177
+ }
178
+
179
+ const descriptor = descriptorMap.get(v.integration)!
180
+ if (!isRedirectUriAllowed(v.redirectUri, descriptor.allowedRedirectPrefixes)) {
181
+ return reply.status(400).send({ error: 'invalid_request — redirect_uri not allowed for this integration' })
182
+ }
183
+
184
+ if (body.consent !== 'allow') {
185
+ return reply.redirect(`${v.redirectUri}?error=access_denied&state=${encodeURIComponent(v.state)}`)
186
+ }
187
+
188
+ const userMgmt = registry.getSingleton<IUserManagementProvider>('user-management')
189
+ if (!userMgmt) {
190
+ return reply.status(503).send({ error: 'service_unavailable' })
191
+ }
192
+
193
+ const { code } = await userMgmt.oauthIssueCode({
194
+ integrationId: v.integration,
195
+ userId: tokenInfo.userId ?? '',
196
+ username: tokenInfo.username ?? '',
197
+ scopes: descriptor.requestedScopes,
198
+ redirectUri: v.redirectUri,
199
+ hubUrl: deps.publicHubUrl(),
200
+ })
201
+
202
+ return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`)
203
+ })
204
+
205
+ // ─── POST /api/oauth2/token ───────────────────────────────────────────────
206
+ // No session gate — called by Alexa/Lambda directly.
207
+ fastify.post('/api/oauth2/token', async (request, reply) => {
208
+ const registry = deps.getRegistry()
209
+ if (!registry) {
210
+ return reply.status(503).send({ error: 'service_unavailable' })
211
+ }
212
+
213
+ const userMgmt = registry.getSingleton<IUserManagementProvider>('user-management')
214
+ if (!userMgmt) {
215
+ return reply.status(503).send({ error: 'service_unavailable' })
216
+ }
217
+
218
+ const body = request.body as Record<string, string | undefined>
219
+
220
+ type TokenResult = { accessToken: string; refreshToken: string; expiresIn: number } | null
221
+
222
+ let tokenResult: TokenResult
223
+ if (body.grant_type === 'authorization_code') {
224
+ if (!body.code || !body.redirect_uri) {
225
+ return reply.status(400).send({ error: 'invalid_request' })
226
+ }
227
+ tokenResult = await userMgmt.oauthExchangeCode({ code: body.code, redirectUri: body.redirect_uri })
228
+ } else if (body.grant_type === 'refresh_token') {
229
+ if (!body.refresh_token) {
230
+ return reply.status(400).send({ error: 'invalid_request' })
231
+ }
232
+ tokenResult = await userMgmt.oauthRefresh({ refreshToken: body.refresh_token })
233
+ } else {
234
+ return reply.status(400).send({ error: 'unsupported_grant_type' })
235
+ }
236
+
237
+ if (tokenResult === null) {
238
+ return reply.status(400).send({ error: 'invalid_grant' })
239
+ }
240
+
241
+ return reply.send({
242
+ access_token: tokenResult.accessToken,
243
+ refresh_token: tokenResult.refreshToken,
244
+ expires_in: tokenResult.expiresIn,
245
+ token_type: 'Bearer',
246
+ })
247
+ })
248
+ }
@@ -0,0 +1,223 @@
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(scopes, 'webrtcSession.listStreams', { deviceId: id }, lookupAncestors)
106
+ expect(result.ok, `device ${id} should be allowed`).toBe(true)
107
+ }
108
+ })
109
+ })
110
+
111
+ // ── Accessory inheritance — children of granted parent ─────────────
112
+
113
+ describe('checkScopeAccess — device scope, accessory inheritance', () => {
114
+ it('grant on parent 5 covers siren child 51', () => {
115
+ const scopes = [deviceScope(['5'], ['view', 'create'])]
116
+ const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 51 }, lookupAncestors)
117
+ expect(result.ok).toBe(true)
118
+ })
119
+
120
+ it('grant on parent 5 covers floodlight 52', () => {
121
+ const scopes = [deviceScope(['5'], ['view', 'create'])]
122
+ const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 52 }, lookupAncestors)
123
+ expect(result.ok).toBe(true)
124
+ })
125
+
126
+ it('grant on parent 5 covers PIR 53 (read-only)', () => {
127
+ const scopes = [deviceScope(['5'], ['view'])]
128
+ const result = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: 53 }, lookupAncestors)
129
+ expect(result.ok).toBe(true)
130
+ })
131
+
132
+ it('grant on parent 5 does NOT cover Hikvision parent 7 nor its siren 71', () => {
133
+ const scopes = [deviceScope(['5'], ['view', 'create'])]
134
+ for (const orphanId of [7, 71]) {
135
+ const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: orphanId }, lookupAncestors)
136
+ expect(result.ok, `device ${orphanId} should NOT be allowed`).toBe(false)
137
+ }
138
+ })
139
+ })
140
+
141
+ // ── Negative paths — unscoped devices, missing access ──────────────
142
+
143
+ describe('checkScopeAccess — device scope, denial cases', () => {
144
+ it('denies access to a sibling device not in the grant', () => {
145
+ const scopes = [deviceScope(['5'], ['view'])]
146
+ const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 9 }, lookupAncestors)
147
+ expect(result.ok).toBe(false)
148
+ if (!result.ok) expect(result.reason).toContain('device=9')
149
+ })
150
+
151
+ it('denies create-flavoured methods when the grant is view-only', () => {
152
+ const scopes = [deviceScope(['5'], ['view'])]
153
+ const result = checkScopeAccess(scopes, 'webrtcSession.createSession', { deviceId: 5 }, lookupAncestors)
154
+ expect(result.ok).toBe(false)
155
+ })
156
+
157
+ it('denies access on accessory whose parent is NOT in the grant', () => {
158
+ // grant on 7, but try to act on accessory 51 (whose parent is 5)
159
+ const scopes = [deviceScope(['7'], ['view', 'create'])]
160
+ const result = checkScopeAccess(scopes, 'switch.setState', { deviceId: 51 }, lookupAncestors)
161
+ expect(result.ok).toBe(false)
162
+ })
163
+
164
+ it('denies when the user has no scopes at all', () => {
165
+ const result = checkScopeAccess([], 'webrtcSession.listStreams', { deviceId: 5 }, lookupAncestors)
166
+ expect(result.ok).toBe(false)
167
+ })
168
+
169
+ it('does not leak across system-scope caps — device grant doesn\'t cover `addons.list`', () => {
170
+ const scopes = [deviceScope(['5'], ['view', 'create', 'delete'])]
171
+ // `addons.list` is system-scope; device scope shouldn't help.
172
+ const result = checkScopeAccess(scopes, 'addons.list')
173
+ expect(result.ok).toBe(false)
174
+ if (!result.ok) expect(result.reason).toContain('system-scope cap')
175
+ })
176
+ })
177
+
178
+ // ── Composition with other scope types ─────────────────────────────
179
+
180
+ describe('checkScopeAccess — device scope mixed with other types', () => {
181
+ it('a `category:device [view]` grant is open across all devices regardless of device scope', () => {
182
+ // Operator hands out a broad viewer access. No `device` scope needed.
183
+ const scopes: TokenScope[] = [{ type: 'category', target: 'device', access: ['view'] }]
184
+ for (const id of [5, 7, 9, 51, 71]) {
185
+ const result = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: id }, lookupAncestors)
186
+ expect(result.ok, `category grant should cover device ${id}`).toBe(true)
187
+ }
188
+ })
189
+
190
+ it('disjunction — device grant + capability grant compose by union', () => {
191
+ const scopes: TokenScope[] = [
192
+ deviceScope(['5'], ['view', 'create']), // PTZ, WebRTC on device 5 + accessories
193
+ { type: 'capability', target: 'camera-streams', access: ['view'] }, // read streams on any device
194
+ ]
195
+ // Device 9 not in device grant, but capability grant lets streams through:
196
+ const streamRead = checkScopeAccess(scopes, 'cameraStreams.getCameraStreams', { deviceId: 9 }, lookupAncestors)
197
+ expect(streamRead.ok).toBe(true)
198
+ // But a create-flavoured method on device 9 still blocked:
199
+ const ptzMove = checkScopeAccess(scopes, 'webrtcSession.createSession', { deviceId: 9 }, lookupAncestors)
200
+ expect(ptzMove.ok).toBe(false)
201
+ })
202
+ })
203
+
204
+ // ── Edge cases — input shape & malformed scopes ────────────────────
205
+
206
+ describe('checkScopeAccess — device scope, edge cases', () => {
207
+ it('falls back to deny when the input has no deviceId (device-scope cap requires one)', () => {
208
+ const scopes = [deviceScope(['5'], ['view'])]
209
+ // No deviceId in input → matcher can't locate the target → reject.
210
+ const result = checkScopeAccess(scopes, 'webrtcSession.listStreams', {}, lookupAncestors)
211
+ expect(result.ok).toBe(false)
212
+ })
213
+
214
+ it('works without a registry — direct match only, no inheritance', () => {
215
+ // No `getDeviceAncestors` (e.g. in a tRPC context that didn't wire one).
216
+ // Direct device grant still works; child accessory does NOT inherit.
217
+ const scopes = [deviceScope(['5'], ['view'])]
218
+ const directHit = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 5 })
219
+ expect(directHit.ok).toBe(true)
220
+ const childMiss = checkScopeAccess(scopes, 'webrtcSession.listStreams', { deviceId: 51 })
221
+ expect(childMiss.ok).toBe(false)
222
+ })
223
+ })
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Matrix tests for `checkScopeAccess`. Drives the scope-gate in
3
+ * `protectedProcedure`.
4
+ *
5
+ * The map (`METHOD_ACCESS_MAP`) is codegen-emitted; the test imports
6
+ * it indirectly via the helper so any drift between cap definitions
7
+ * and runtime would surface here as a test failure (e.g. a renamed
8
+ * method would no longer resolve to a known entry).
9
+ */
10
+ import { describe, it, expect } from 'vitest'
11
+ import { checkScopeAccess } from '../scope-access.js'
12
+ import type { TokenScope } from '@camstack/types'
13
+
14
+ function scope(type: 'addon' | 'capability', target: string, access: TokenScope['access']): TokenScope {
15
+ return { type, target, access }
16
+ }
17
+
18
+ describe('checkScopeAccess', () => {
19
+ // ── Sanity: known paths resolve, unknown paths fail closed ──────
20
+
21
+ it('returns FORBIDDEN with codegen-drift reason for unknown paths', () => {
22
+ const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'no-such.method')
23
+ expect(result.ok).toBe(false)
24
+ if (!result.ok) {
25
+ expect(result.reason).toContain('codegen drift')
26
+ }
27
+ })
28
+
29
+ // ── Capability-typed scopes ─────────────────────────────────────
30
+
31
+ it('accepts when capability scope matches target + access', () => {
32
+ const result = checkScopeAccess(
33
+ [scope('capability', 'backup', ['view'])],
34
+ 'backup.list',
35
+ )
36
+ expect(result.ok).toBe(true)
37
+ if (result.ok) expect(result.access).toBe('view')
38
+ })
39
+
40
+ it('rejects when capability scope matches target but lacks the required access', () => {
41
+ // backup.trigger requires `create`; the scope only grants `view`.
42
+ const result = checkScopeAccess(
43
+ [scope('capability', 'backup', ['view'])],
44
+ 'backup.trigger',
45
+ )
46
+ expect(result.ok).toBe(false)
47
+ if (!result.ok) {
48
+ // Matcher's reason format: "No scope grants <access> on '<cap>' (<scope>-scope cap)"
49
+ expect(result.reason).toMatch(/No scope grants create on 'backup'/)
50
+ }
51
+ })
52
+
53
+ it('rejects when capability scope targets a different cap entirely', () => {
54
+ const result = checkScopeAccess(
55
+ [scope('capability', 'devices', ['view', 'create', 'delete'])],
56
+ 'backup.list',
57
+ )
58
+ expect(result.ok).toBe(false)
59
+ })
60
+
61
+ it('accepts destructive method when scope includes delete', () => {
62
+ const result = checkScopeAccess(
63
+ [scope('capability', 'backup', ['view', 'delete'])],
64
+ 'backup.delete',
65
+ )
66
+ expect(result.ok).toBe(true)
67
+ if (result.ok) expect(result.access).toBe('delete')
68
+ })
69
+
70
+ // ── Scope unions ────────────────────────────────────────────────
71
+
72
+ it('union of access flavours satisfies any single requirement', () => {
73
+ const scopes: TokenScope[] = [
74
+ scope('capability', 'backup', ['view']),
75
+ scope('capability', 'backup', ['create']),
76
+ ]
77
+ // view request → first scope matches
78
+ expect(checkScopeAccess(scopes, 'backup.list').ok).toBe(true)
79
+ // create request → second scope matches
80
+ expect(checkScopeAccess(scopes, 'backup.trigger').ok).toBe(true)
81
+ })
82
+
83
+ // ── Empty scope set ─────────────────────────────────────────────
84
+
85
+ it('rejects when no scopes are granted at all', () => {
86
+ const result = checkScopeAccess([], 'backup.list')
87
+ expect(result.ok).toBe(false)
88
+ if (!result.ok) {
89
+ expect(result.reason).toContain('Have: (none)')
90
+ }
91
+ })
92
+
93
+ // ── Reason string contains debug-friendly diff ──────────────────
94
+
95
+ it('reason string surfaces what the caller actually has', () => {
96
+ const result = checkScopeAccess(
97
+ [scope('capability', 'devices', ['view'])],
98
+ 'backup.trigger',
99
+ )
100
+ expect(result.ok).toBe(false)
101
+ if (!result.ok) {
102
+ // Format: "No scope grants create on 'backup' (system-scope cap). Have: capability:devices[view]"
103
+ expect(result.reason).toMatch(/No scope grants create on 'backup'/)
104
+ expect(result.reason).toContain('capability:devices[view]')
105
+ }
106
+ })
107
+ })