@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.
- package/package.json +11 -9
- package/src/__tests__/addon-install-e2e.test.ts +0 -1
- package/src/__tests__/addon-pages-e2e.test.ts +40 -18
- package/src/__tests__/addon-settings-router.spec.ts +6 -1
- package/src/__tests__/addon-upload.spec.ts +91 -29
- package/src/__tests__/agent-registry.spec.ts +26 -9
- package/src/__tests__/agent-status-page.spec.ts +1 -3
- package/src/__tests__/auth-session-cookie.test.ts +28 -1
- package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
- package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
- package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
- package/src/__tests__/cap-route-adapter.spec.ts +28 -15
- package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
- package/src/__tests__/cap-routers/harness.ts +11 -7
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
- package/src/__tests__/capability-e2e.test.ts +9 -11
- package/src/__tests__/cli-e2e.test.ts +80 -59
- package/src/__tests__/core-cap-bridge.spec.ts +3 -1
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
- package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
- package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
- package/src/__tests__/framework-allowlist.spec.ts +5 -4
- package/src/__tests__/https-e2e.test.ts +12 -6
- package/src/__tests__/lifecycle-e2e.test.ts +60 -11
- package/src/__tests__/live-events-subscription.spec.ts +17 -18
- package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
- package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
- package/src/__tests__/native-cap-route.spec.ts +42 -19
- package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
- package/src/__tests__/singleton-contention.test.ts +23 -11
- package/src/__tests__/streaming-diagnostic.test.ts +156 -53
- package/src/__tests__/streaming-scale.test.ts +69 -35
- package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
- package/src/agent-status-page.ts +4 -3
- package/src/api/__tests__/addons-custom.spec.ts +22 -8
- package/src/api/__tests__/capabilities.router.test.ts +18 -9
- package/src/api/addon-upload.ts +46 -15
- package/src/api/addons-custom.router.ts +7 -6
- package/src/api/auth-whoami.ts +3 -1
- package/src/api/bridge-addons.router.ts +3 -1
- package/src/api/capabilities.router.ts +117 -78
- package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/addon-settings.router.ts +4 -1
- package/src/api/core/agents.router.ts +52 -53
- package/src/api/core/auth.router.ts +55 -36
- package/src/api/core/bulk-update-coordinator.ts +25 -22
- package/src/api/core/cap-providers.ts +459 -166
- package/src/api/core/capabilities.router.ts +30 -23
- package/src/api/core/hwaccel.router.ts +37 -10
- package/src/api/core/live-events.router.ts +16 -9
- package/src/api/core/logs.router.ts +58 -25
- package/src/api/core/notifications.router.ts +2 -1
- package/src/api/core/repl.router.ts +1 -3
- package/src/api/core/settings-backend.router.ts +68 -70
- package/src/api/core/system-events.router.ts +41 -32
- package/src/api/health/health.routes.ts +7 -13
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
- package/src/api/oauth2/consent-page.ts +4 -3
- package/src/api/oauth2/oauth2-routes.ts +41 -12
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
- package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
- package/src/api/trpc/cap-mount-helpers.ts +64 -44
- package/src/api/trpc/cap-route-error-formatter.ts +17 -9
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/core-cap-bridge.ts +3 -1
- package/src/api/trpc/generated-cap-mounts.ts +801 -286
- package/src/api/trpc/generated-cap-routers.ts +5723 -719
- package/src/api/trpc/scope-access.ts +7 -7
- package/src/api/trpc/trpc.context.ts +7 -4
- package/src/api/trpc/trpc.middleware.ts +4 -2
- package/src/api/trpc/trpc.router.ts +117 -48
- package/src/auth/session-cookie.ts +10 -0
- package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
- package/src/boot/boot-config.ts +103 -122
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/boot/post-boot.service.ts +5 -3
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-call-gateway.ts +20 -6
- package/src/core/addon/addon-package.service.ts +183 -89
- package/src/core/addon/addon-registry.service.ts +1212 -1267
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-search.service.ts +2 -1
- package/src/core/addon/addon-settings-provider.ts +27 -7
- package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
- package/src/core/addon-pages/addon-pages.service.ts +3 -1
- package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
- package/src/core/agent/agent-registry.service.ts +60 -38
- package/src/core/auth/auth.service.spec.ts +6 -8
- package/src/core/config/config.service.spec.ts +1 -1
- package/src/core/events/event-bus.service.spec.ts +44 -21
- package/src/core/events/event-bus.service.ts +5 -1
- package/src/core/feature/feature.service.spec.ts +4 -1
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
- package/src/core/logging/logging.service.spec.ts +61 -21
- package/src/core/logging/logging.service.ts +19 -5
- package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
- package/src/core/moleculer/cap-call-fn.ts +5 -1
- package/src/core/moleculer/cap-route-authority.ts +18 -6
- package/src/core/moleculer/moleculer.service.ts +145 -29
- package/src/core/network/network-quality.service.spec.ts +7 -1
- package/src/core/notification/notification-wrapper.service.ts +1 -3
- package/src/core/notification/toast-wrapper.service.ts +1 -5
- package/src/core/repl/repl-engine.service.spec.ts +66 -39
- package/src/core/repl/repl-engine.service.ts +11 -12
- package/src/core/storage/storage-location-manager.spec.ts +12 -3
- package/src/core/streaming/stream-probe.service.ts +22 -13
- package/src/core/topology/topology-emitter.service.ts +5 -1
- package/src/launcher.ts +14 -9
- package/src/main.ts +658 -495
- package/src/manual-boot.ts +133 -154
- package/tsconfig.json +20 -8
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- 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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
|
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(
|
|
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) => (
|
|
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) => {
|
|
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,
|
|
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
|
|
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
|
-
|
|
159
|
+
ctx: TrpcContext,
|
|
132
160
|
): WebrtcSessionProvider {
|
|
133
|
-
|
|
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
|
-
|
|
219
|
+
networkQuality: createCapRouter_networkQuality((_ctx) =>
|
|
220
|
+
buildNetworkQualityProvider(services.networkQualityService),
|
|
186
221
|
),
|
|
187
|
-
system: createCapRouter_system(
|
|
188
|
-
|
|
222
|
+
system: createCapRouter_system((_ctx) =>
|
|
223
|
+
buildSystemProvider(services.featureService, services.capabilityRegistry),
|
|
189
224
|
),
|
|
190
|
-
toast: createCapRouter_toast(
|
|
191
|
-
|
|
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
|
-
|
|
204
|
-
(
|
|
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) =>
|
|
222
|
-
|
|
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) =>
|
|
226
|
-
|
|
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) =>
|
|
230
|
-
|
|
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) =>
|
|
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) =>
|
|
238
|
-
|
|
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) =>
|
|
242
|
-
|
|
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) =>
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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 =
|
|
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
|
+
})
|