@camstack/server 0.2.2 → 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.
Files changed (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. 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
- })