@camstack/server 0.1.7 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -24,9 +24,7 @@
24
24
  import { METHOD_ACCESS_MAP } from '@camstack/types'
25
25
  import type { MethodAccess, TokenScope } from '@camstack/types'
26
26
 
27
- export type ScopeAccessResult =
28
- | { ok: true; access: MethodAccess }
29
- | { ok: false; reason: string }
27
+ export type ScopeAccessResult = { ok: true; access: MethodAccess } | { ok: false; reason: string }
30
28
 
31
29
  /**
32
30
  * Resolves a deviceId to its ancestor chain (parent, grandparent, …).
@@ -101,10 +99,12 @@ export function checkScopeAccess(
101
99
  reason: `No scope grants ${meta.access} on '${meta.capName}' (${meta.capScope}-scope cap${
102
100
  deviceId !== null ? `, device=${deviceId}` : ''
103
101
  }). Have: ${
104
- scopes.map((s) => {
105
- const target = s.type === 'device' ? `[${s.targets.join(',')}]` : s.target
106
- return `${s.type}:${target}[${s.access.join(',')}]`
107
- }).join(', ') || '(none)'
102
+ scopes
103
+ .map((s) => {
104
+ const target = s.type === 'device' ? `[${s.targets.join(',')}]` : s.target
105
+ return `${s.type}:${target}[${s.access.join(',')}]`
106
+ })
107
+ .join(', ') || '(none)'
108
108
  }`,
109
109
  }
110
110
  }
@@ -117,7 +117,9 @@ async function resolveUser(
117
117
  // protectedProcedure can still gate by scope match.
118
118
  if (token.startsWith('cst_')) {
119
119
  try {
120
- const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management') as UserManagementLike | undefined
120
+ const userMgmt = addonRegistry.getCapabilityRegistry().getSingleton('user-management') as
121
+ | UserManagementLike
122
+ | undefined
121
123
  if (!userMgmt) return null
122
124
  const record = await userMgmt.validateScopedToken({ token })
123
125
  if (!record) return null
@@ -182,7 +184,9 @@ async function resolveUser(
182
184
  * Bounded by hop count (defence-in-depth — the device tree should
183
185
  * never exceed 2-3 levels but a corrupt registry shouldn't loop forever).
184
186
  */
185
- function makeAncestorLookup(addonRegistry: AddonRegistryService): (deviceId: number) => readonly number[] {
187
+ function makeAncestorLookup(
188
+ addonRegistry: AddonRegistryService,
189
+ ): (deviceId: number) => readonly number[] {
186
190
  return (deviceId: number) => {
187
191
  const out: number[] = []
188
192
  const registry = addonRegistry.getDeviceRegistry()
@@ -243,8 +247,7 @@ export async function createWsTrpcContext(
243
247
  // 1. connectionParams.token (sent by BackendClient's createWSClient)
244
248
  const paramToken = opts.info.connectionParams?.['token']
245
249
  const token =
246
- (typeof paramToken === 'string' ? paramToken : null) ??
247
- extractTokenFromRequest(opts.req)
250
+ (typeof paramToken === 'string' ? paramToken : null) ?? extractTokenFromRequest(opts.req)
248
251
 
249
252
  const user = await resolveUser(token, authService, addonRegistry)
250
253
  return {
@@ -21,7 +21,7 @@ const t = initTRPC.context<TrpcContext>().create({
21
21
  * @param subscribe — called once; receives a `push` callback and must return an unsubscribe fn.
22
22
  */
23
23
  export async function* iterableSubscription<T>(
24
- subscribe: (push: (value: T) => void) => (() => void),
24
+ subscribe: (push: (value: T) => void) => () => void,
25
25
  ): AsyncGenerator<T> {
26
26
  const queue: T[] = []
27
27
  let resolve: (() => void) | null = null
@@ -36,7 +36,9 @@ export async function* iterableSubscription<T>(
36
36
  while (queue.length > 0) {
37
37
  yield queue.shift()!
38
38
  }
39
- await new Promise<void>((r) => { resolve = r })
39
+ await new Promise<void>((r) => {
40
+ resolve = r
41
+ })
40
42
  }
41
43
  } finally {
42
44
  unsub()
@@ -11,7 +11,7 @@ import { trpcRouter } from './trpc.middleware'
11
11
  // streaming → `streamingManagement` cap, events → `eventQuery` cap,
12
12
  // logs → kept manual, live → kept manual, processes → `processMgmt`
13
13
  // cap, agents → `nodes` cap, sessions → `session` cap, trackMedia /
14
- // trackTrail → caps, recording → `recordingEngine` cap, network →
14
+ // trackTrail → caps, network →
15
15
  // `networkQuality` cap, addons → `addons` cap, bridgePipeline removed
16
16
  // (legacy), detection → `detectionConfig` cap, capabilities → kept
17
17
  // manual, update → addons cap, addonPages → cap, notification →
@@ -20,7 +20,7 @@ import { trpcRouter } from './trpc.middleware'
20
20
  // `pipelineExecutor` cap, pipeline → `pipelineConfig` cap,
21
21
  // systemEvents → kept manual.
22
22
  import type { CapabilityRegistry } from '@camstack/kernel'
23
- import type { InferProvider } from '@camstack/types'
23
+ import type { InferProvider, BrokerConsumerAttribution } from '@camstack/types'
24
24
  import {
25
25
  pipelineExecutorCapability,
26
26
  pipelineRunnerCapability,
@@ -76,6 +76,7 @@ import { createCapabilitiesRouter } from '../core/capabilities.router.js'
76
76
  import { createStreamProbeRouter } from '../core/stream-probe.router.js'
77
77
  import { createHwAccelRouter } from '../core/hwaccel.router.js'
78
78
  import { requireSingleton, firstSupported, anySupports } from './cap-mount-helpers.js'
79
+ import { extractUserAgent } from './client-ip.js'
79
80
  import type { TrpcContext } from './trpc.context.js'
80
81
  import type { AuthService } from '../../core/auth/auth.service'
81
82
  import type { ConfigService } from '../../core/config/config.service'
@@ -112,6 +113,27 @@ export interface RouterServices {
112
113
  }
113
114
 
114
115
  type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
116
+ type CreateSessionInput = Parameters<WebrtcSessionProvider['createSession']>[0]
117
+ type HandleOfferInput = Parameters<WebrtcSessionProvider['handleOffer']>[0]
118
+
119
+ /**
120
+ * Merge the server-read User-Agent into a signaling call's
121
+ * `consumerAttribution`, building a NEW input object (immutable — never
122
+ * mutates the caller's input). When `userAgent` is null (mesh-originated
123
+ * call, or a client that omits the header) the input passes through
124
+ * unchanged. Any client-supplied `userAgent` is OVERWRITTEN — the hub
125
+ * trusts only the request context, never the client.
126
+ */
127
+ export function enrichInputWithUserAgent<
128
+ TInput extends { consumerAttribution?: BrokerConsumerAttribution },
129
+ >(input: TInput, userAgent: string | null): TInput {
130
+ if (userAgent === null) return input
131
+ const base: BrokerConsumerAttribution = input.consumerAttribution ?? { kind: 'webrtc-browser' }
132
+ return {
133
+ ...input,
134
+ consumerAttribution: { ...base, userAgent },
135
+ }
136
+ }
115
137
 
116
138
  /**
117
139
  * Relay-only forcing for remote viewers is DISABLED (2026-05-26).
@@ -124,13 +146,26 @@ type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
124
146
  * advertised Tailscale address, srflx, relay) and let ICE nominate the best
125
147
  * reachable pair: direct when possible, relay only as a fallback. The
126
148
  * `relayOnly` cap field + broker support remain for when relay media-forward
127
- * is fixed; this wrapper is a pass-through for now.
149
+ * is fixed.
150
+ *
151
+ * The wrapper additionally enriches the `createSession` / `handleOffer`
152
+ * subscriber attribution with the originating client's User-Agent, read
153
+ * from the tRPC request context (browser sessions). All OTHER methods
154
+ * delegate straight through — auth, the remote-proxy factory and every
155
+ * signaling behaviour are untouched.
128
156
  */
129
- function wrapWebrtcSessionProviderWithRelay(
157
+ export function wrapWebrtcSessionProviderWithRelay(
130
158
  provider: WebrtcSessionProvider,
131
- _ctx: TrpcContext,
159
+ ctx: TrpcContext,
132
160
  ): WebrtcSessionProvider {
133
- return provider
161
+ const userAgent = extractUserAgent(ctx.req)
162
+ return {
163
+ ...provider,
164
+ createSession: (input: CreateSessionInput) =>
165
+ provider.createSession(enrichInputWithUserAgent(input, userAgent)),
166
+ handleOffer: (input: HandleOfferInput) =>
167
+ provider.handleOffer(enrichInputWithUserAgent(input, userAgent)),
168
+ }
134
169
  }
135
170
 
136
171
  /**
@@ -181,27 +216,26 @@ function buildCapabilityRouters(services: RouterServices) {
181
216
  // CapabilityRegistry — the provider is built on-demand from
182
217
  // backend services. `mountAllCaps` would return `null` for them
183
218
  // (registry lookup miss), so we re-mount with `buildXProvider`.
184
- networkQuality: createCapRouter_networkQuality(
185
- (_ctx) => buildNetworkQualityProvider(services.networkQualityService),
219
+ networkQuality: createCapRouter_networkQuality((_ctx) =>
220
+ buildNetworkQualityProvider(services.networkQualityService),
186
221
  ),
187
- system: createCapRouter_system(
188
- (_ctx) => buildSystemProvider(services.featureService, services.capabilityRegistry),
222
+ system: createCapRouter_system((_ctx) =>
223
+ buildSystemProvider(services.featureService, services.capabilityRegistry),
189
224
  ),
190
- toast: createCapRouter_toast(
191
- (ctx) => buildToastProvider(services.toastService, ctx),
192
- ),
193
- integrations: createCapRouter_integrations(
194
- (_ctx) => buildIntegrationsProvider(services.addonRegistry, services.eventBus, services.loggingService),
195
- ),
196
- nodes: createCapRouter_nodes(
197
- (_ctx) => buildNodesProvider(
198
- services.agentRegistry,
199
- services.moleculer,
225
+ toast: createCapRouter_toast((ctx) => buildToastProvider(services.toastService, ctx)),
226
+ integrations: createCapRouter_integrations((_ctx) =>
227
+ buildIntegrationsProvider(
200
228
  services.addonRegistry,
229
+ services.eventBus,
230
+ services.loggingService,
231
+ services.capabilityRegistry,
201
232
  ),
202
233
  ),
203
- addons: createCapRouter_addons(
204
- (ctx) => buildAddonsProvider(
234
+ nodes: createCapRouter_nodes((_ctx) =>
235
+ buildNodesProvider(services.agentRegistry, services.moleculer, services.addonRegistry),
236
+ ),
237
+ addons: createCapRouter_addons((ctx) =>
238
+ buildAddonsProvider(
205
239
  services.addonRegistry,
206
240
  services.addonPackageService,
207
241
  services.loggingService,
@@ -218,32 +252,68 @@ function buildCapabilityRouters(services: RouterServices) {
218
252
  // distinct. Casting at the override site is cheaper than reworking
219
253
  // the provider declarations. Auto-mount can't infer the cast.
220
254
  pipelineExecutor: createCapRouter_pipelineExecutor(
221
- (_ctx) => requireSingleton(services.capabilityRegistry, 'pipeline-executor') as InferProvider<typeof pipelineExecutorCapability> | null,
222
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof pipelineExecutorCapability> | null,
255
+ (_ctx) =>
256
+ requireSingleton(services.capabilityRegistry, 'pipeline-executor') as InferProvider<
257
+ typeof pipelineExecutorCapability
258
+ > | null,
259
+ (capName, nodeId) =>
260
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
261
+ typeof pipelineExecutorCapability
262
+ > | null,
223
263
  ),
224
264
  pipelineRunner: createCapRouter_pipelineRunner(
225
- (_ctx) => requireSingleton(services.capabilityRegistry, 'pipeline-runner') as InferProvider<typeof pipelineRunnerCapability> | null,
226
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof pipelineRunnerCapability> | null,
265
+ (_ctx) =>
266
+ requireSingleton(services.capabilityRegistry, 'pipeline-runner') as InferProvider<
267
+ typeof pipelineRunnerCapability
268
+ > | null,
269
+ (capName, nodeId) =>
270
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
271
+ typeof pipelineRunnerCapability
272
+ > | null,
227
273
  ),
228
274
  pipelineOrchestrator: createCapRouter_pipelineOrchestrator(
229
- (_ctx) => requireSingleton(services.capabilityRegistry, 'pipeline-orchestrator') as InferProvider<typeof pipelineOrchestratorCapability> | null,
230
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof pipelineOrchestratorCapability> | null,
275
+ (_ctx) =>
276
+ requireSingleton(services.capabilityRegistry, 'pipeline-orchestrator') as InferProvider<
277
+ typeof pipelineOrchestratorCapability
278
+ > | null,
279
+ (capName, nodeId) =>
280
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
281
+ typeof pipelineOrchestratorCapability
282
+ > | null,
231
283
  ),
232
284
  audioAnalyzer: createCapRouter_audioAnalyzer(
233
285
  (_ctx) => requireSingleton(services.capabilityRegistry, 'audio-analyzer'),
234
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof audioAnalyzerCapability> | null,
286
+ (capName, nodeId) =>
287
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
288
+ typeof audioAnalyzerCapability
289
+ > | null,
235
290
  ),
236
291
  audioCodec: createCapRouter_audioCodec(
237
- (_ctx) => requireSingleton(services.capabilityRegistry, 'audio-codec') as InferProvider<typeof audioCodecCapability> | null,
238
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof audioCodecCapability> | null,
292
+ (_ctx) =>
293
+ requireSingleton(services.capabilityRegistry, 'audio-codec') as InferProvider<
294
+ typeof audioCodecCapability
295
+ > | null,
296
+ (capName, nodeId) =>
297
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
298
+ typeof audioCodecCapability
299
+ > | null,
239
300
  ),
240
301
  decoder: createCapRouter_decoder(
241
- (_ctx) => requireSingleton(services.capabilityRegistry, 'decoder') as InferProvider<typeof decoderCapability> | null,
242
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof decoderCapability> | null,
302
+ (_ctx) =>
303
+ requireSingleton(services.capabilityRegistry, 'decoder') as InferProvider<
304
+ typeof decoderCapability
305
+ > | null,
306
+ (capName, nodeId) =>
307
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
308
+ typeof decoderCapability
309
+ > | null,
243
310
  ),
244
311
  platformProbe: createCapRouter_platformProbe(
245
312
  (_ctx) => requireSingleton(services.capabilityRegistry, 'platform-probe'),
246
- (capName, nodeId) => services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<typeof platformProbeCapability> | null,
313
+ (capName, nodeId) =>
314
+ services.moleculer.createCapabilityProxy(capName, nodeId) as InferProvider<
315
+ typeof platformProbeCapability
316
+ > | null,
247
317
  ),
248
318
 
249
319
  // ── Cap overrides: hub-only, no remote fallback ─────────────────
@@ -266,18 +336,17 @@ function buildCapabilityRouters(services: RouterServices) {
266
336
  // `getSnapshot` picks the first one that claims the device. The
267
337
  // generic first-provider resolver from the auto-mount can't model
268
338
  // this — we hand-write the probe + fan-out logic.
269
- snapshotProvider: createCapRouter_snapshotProvider(
270
- (_ctx) => {
271
- const reg = services.capabilityRegistry
272
- if (!reg) return null
273
- type SnapshotProviderInferred = import('@camstack/types').CapabilityProviderMap['snapshot-provider']
274
- const providers = reg.getCollection<SnapshotProviderInferred>('snapshot-provider')
275
- if (!providers || providers.length === 0) return null
276
- const supportsDevice = anySupports(providers, 'supportsDevice')
277
- const getSnapshot = firstSupported(providers, 'supportsDevice', 'getSnapshot')
278
- return { supportsDevice, getSnapshot }
279
- },
280
- ),
339
+ snapshotProvider: createCapRouter_snapshotProvider((_ctx) => {
340
+ const reg = services.capabilityRegistry
341
+ if (!reg) return null
342
+ type SnapshotProviderInferred =
343
+ import('@camstack/types').CapabilityProviderMap['snapshot-provider']
344
+ const providers = reg.getCollection<SnapshotProviderInferred>('snapshot-provider')
345
+ if (!providers || providers.length === 0) return null
346
+ const supportsDevice = anySupports(providers, 'supportsDevice')
347
+ const getSnapshot = firstSupported(providers, 'supportsDevice', 'getSnapshot')
348
+ return { supportsDevice, getSnapshot }
349
+ }),
281
350
 
282
351
  // ── Cap override: server-detected remote → relay-only ────────────
283
352
  // The broker (a forked addon) can't see the HTTP request, so it
@@ -290,8 +359,8 @@ function buildCapabilityRouters(services: RouterServices) {
290
359
  // remote-proxy routing is preserved (forked/agent-hosted brokers).
291
360
  webrtcSession: createCapRouter_webrtcSession(
292
361
  (ctx) => {
293
- const provider = services.capabilityRegistry
294
- ?.getSingleton<WebrtcSessionProvider>('webrtc-session') ?? null
362
+ const provider =
363
+ services.capabilityRegistry?.getSingleton<WebrtcSessionProvider>('webrtc-session') ?? null
295
364
  return provider ? wrapWebrtcSessionProviderWithRelay(provider, ctx) : null
296
365
  },
297
366
  (capName, nodeId) =>
@@ -42,3 +42,13 @@ export function shouldRedirectToLogin(method: string, accept: string | undefined
42
42
  export function loginRedirectUrl(originalUrl: string): string {
43
43
  return `/login?next=${encodeURIComponent(originalUrl)}`
44
44
  }
45
+
46
+ /** Allowed redirect target for `GET /api/embed-auth`: a same-origin RELATIVE
47
+ * path to a stream-broker embed page. Defeats open-redirects — the endpoint
48
+ * sets the session cookie from a Bearer token, so the `next` must be safe to
49
+ * bounce to. Rejects absolute/protocol-relative URLs, backslashes, and `..`. */
50
+ export function isEmbedRedirectTarget(next: string): boolean {
51
+ if (!next.startsWith('/addon/stream-broker/embed/')) return false
52
+ if (next.includes('\\') || next.includes('://') || next.includes('..')) return false
53
+ return true
54
+ }
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ planIntegrationIdBackfill,
4
+ planDeleteTimeStamps,
5
+ runIntegrationIdBackfill,
6
+ } from '../integration-id-backfill'
7
+
8
+ describe('planDeleteTimeStamps', () => {
9
+ it("claims an untagged top-level device of the deleted integration's single-integration addon", () => {
10
+ const stamps = planDeleteTimeStamps(
11
+ 'int_rtsp',
12
+ [{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
13
+ [
14
+ { id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
15
+ { id: 6, addonId: 'provider-rtsp', parentDeviceId: null },
16
+ ],
17
+ )
18
+ expect(stamps).toEqual([
19
+ { deviceId: 4, integrationId: 'int_rtsp' },
20
+ { deviceId: 6, integrationId: 'int_rtsp' },
21
+ ])
22
+ })
23
+
24
+ it('returns no stamps for a multi-integration addon (ambiguous — never auto-claim)', () => {
25
+ const stamps = planDeleteTimeStamps(
26
+ 'int_a',
27
+ [
28
+ { id: 'int_a', addonId: 'provider-ha' },
29
+ { id: 'int_b', addonId: 'provider-ha' },
30
+ ],
31
+ [{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
32
+ )
33
+ expect(stamps).toEqual([])
34
+ })
35
+
36
+ it('only returns stamps for the integration being deleted, not siblings of other addons', () => {
37
+ const stamps = planDeleteTimeStamps(
38
+ 'int_rtsp',
39
+ [
40
+ { id: 'int_rtsp', addonId: 'provider-rtsp' },
41
+ { id: 'int_onvif', addonId: 'provider-onvif' },
42
+ ],
43
+ [
44
+ { id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
45
+ { id: 5, addonId: 'provider-onvif', parentDeviceId: null },
46
+ ],
47
+ )
48
+ expect(stamps).toEqual([{ deviceId: 4, integrationId: 'int_rtsp' }])
49
+ })
50
+
51
+ it('skips devices already tagged with the deleted integration', () => {
52
+ const stamps = planDeleteTimeStamps(
53
+ 'int_rtsp',
54
+ [{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
55
+ [{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_rtsp' }],
56
+ )
57
+ expect(stamps).toEqual([])
58
+ })
59
+ })
60
+
61
+ describe('planIntegrationIdBackfill', () => {
62
+ it('stamps a top-level untagged device whose addon has exactly one integration', () => {
63
+ const stamps = planIntegrationIdBackfill(
64
+ [{ id: 'int_1', addonId: 'provider-rtsp' }],
65
+ [{ id: 10, addonId: 'provider-rtsp', parentDeviceId: null }],
66
+ )
67
+ expect(stamps).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
68
+ })
69
+
70
+ it('skips devices whose addon hosts multiple integrations (ambiguous)', () => {
71
+ const stamps = planIntegrationIdBackfill(
72
+ [
73
+ { id: 'int_a', addonId: 'provider-ha' },
74
+ { id: 'int_b', addonId: 'provider-ha' },
75
+ ],
76
+ [{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
77
+ )
78
+ expect(stamps).toEqual([])
79
+ })
80
+
81
+ it('skips already-tagged devices and child devices', () => {
82
+ const stamps = planIntegrationIdBackfill(
83
+ [{ id: 'int_1', addonId: 'provider-rtsp' }],
84
+ [
85
+ { id: 12, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_1' },
86
+ { id: 13, addonId: 'provider-rtsp', parentDeviceId: 12 },
87
+ ],
88
+ )
89
+ expect(stamps).toEqual([])
90
+ })
91
+
92
+ it('skips devices whose addon has no integration', () => {
93
+ const stamps = planIntegrationIdBackfill(
94
+ [{ id: 'int_1', addonId: 'provider-rtsp' }],
95
+ [{ id: 14, addonId: 'provider-onvif', parentDeviceId: null }],
96
+ )
97
+ expect(stamps).toEqual([])
98
+ })
99
+ })
100
+
101
+ describe('runIntegrationIdBackfill', () => {
102
+ it('applies stamps and reports the count, skipping failures', async () => {
103
+ const stamped: Array<{ deviceId: number; integrationId: string }> = []
104
+ const result = await runIntegrationIdBackfill({
105
+ listIntegrations: async () => [{ id: 'int_1', addonId: 'provider-rtsp' }],
106
+ listDevices: async () => [
107
+ { id: 10, addonId: 'provider-rtsp', parentDeviceId: null },
108
+ { id: 11, addonId: 'provider-rtsp', parentDeviceId: null },
109
+ ],
110
+ setIntegrationId: async (deviceId, integrationId) => {
111
+ if (deviceId === 11) throw new Error('boom')
112
+ stamped.push({ deviceId, integrationId })
113
+ },
114
+ logger: { info: () => {}, warn: () => {} },
115
+ })
116
+ expect(result).toEqual({ stamped: 1 })
117
+ expect(stamped).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
118
+ })
119
+
120
+ it('does nothing when there is nothing to stamp', async () => {
121
+ const result = await runIntegrationIdBackfill({
122
+ listIntegrations: async () => [],
123
+ listDevices: async () => [],
124
+ setIntegrationId: async () => {
125
+ throw new Error('should not be called')
126
+ },
127
+ logger: { info: () => {}, warn: () => {} },
128
+ })
129
+ expect(result).toEqual({ stamped: 0 })
130
+ })
131
+ })