@camstack/server 0.1.7 → 0.1.8
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 +3 -3
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/cap-providers.ts +152 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/generated-cap-mounts.ts +281 -8
- package/src/api/trpc/generated-cap-routers.ts +2087 -184
- package/src/api/trpc/trpc.router.ts +43 -7
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-registry.service.ts +89 -2
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/moleculer.service.ts +28 -0
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +92 -0
- 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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the User-Agent enrichment the hub applies to the
|
|
3
|
+
* `webrtc-session` mount. The hub reads the UA from the tRPC request
|
|
4
|
+
* context and merges it into the subscriber attribution so the
|
|
5
|
+
* stream-broker SUBSCRIBERS panel can identify a browser viewer. Any
|
|
6
|
+
* client-supplied `userAgent` is OVERWRITTEN — the server trusts only the
|
|
7
|
+
* request context, never the client.
|
|
8
|
+
*/
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
10
|
+
import type { IncomingMessage } from 'node:http'
|
|
11
|
+
import type { InferProvider, BrokerConsumerAttribution } from '@camstack/types'
|
|
12
|
+
import { webrtcSessionCapability } from '@camstack/types'
|
|
13
|
+
import { enrichInputWithUserAgent, wrapWebrtcSessionProviderWithRelay } from '../trpc.router.js'
|
|
14
|
+
import type { TrpcContext } from '../trpc.context.js'
|
|
15
|
+
|
|
16
|
+
type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
|
|
17
|
+
|
|
18
|
+
function reqWithUa(userAgent?: string): IncomingMessage {
|
|
19
|
+
const headers: Record<string, string | string[]> = {}
|
|
20
|
+
if (userAgent !== undefined) headers['user-agent'] = userAgent
|
|
21
|
+
return { headers, socket: {} } as unknown as IncomingMessage
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('enrichInputWithUserAgent', () => {
|
|
25
|
+
it('passes input through unchanged when userAgent is null', () => {
|
|
26
|
+
const input = { deviceId: 1, consumerAttribution: { kind: 'webrtc-browser' } as const }
|
|
27
|
+
expect(enrichInputWithUserAgent(input, null)).toBe(input)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('merges userAgent into an existing attribution (new object)', () => {
|
|
31
|
+
const attribution: BrokerConsumerAttribution = { kind: 'webrtc-browser', label: 'alice' }
|
|
32
|
+
const input = { deviceId: 1, consumerAttribution: attribution }
|
|
33
|
+
const out = enrichInputWithUserAgent(input, 'Chrome/120')
|
|
34
|
+
expect(out.consumerAttribution).toEqual({ kind: 'webrtc-browser', label: 'alice', userAgent: 'Chrome/120' })
|
|
35
|
+
// Immutability: the original attribution is untouched.
|
|
36
|
+
expect(attribution.userAgent).toBeUndefined()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('defaults to webrtc-browser when no attribution was supplied', () => {
|
|
40
|
+
const out = enrichInputWithUserAgent({ deviceId: 1 }, 'Firefox/121')
|
|
41
|
+
expect(out.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Firefox/121' })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('OVERWRITES a client-supplied userAgent (never trust the client)', () => {
|
|
45
|
+
const input = {
|
|
46
|
+
deviceId: 1,
|
|
47
|
+
consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed' } as const,
|
|
48
|
+
}
|
|
49
|
+
const out = enrichInputWithUserAgent(input, 'Safari/17')
|
|
50
|
+
expect(out.consumerAttribution?.userAgent).toBe('Safari/17')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('wrapWebrtcSessionProviderWithRelay — UA enrichment', () => {
|
|
55
|
+
function mockProvider(): WebrtcSessionProvider {
|
|
56
|
+
const createSession = vi.fn().mockResolvedValue({ sessionId: 's', sdpOffer: 'o' })
|
|
57
|
+
const handleOffer = vi.fn().mockResolvedValue({ sessionId: 's', sdpAnswer: 'a' })
|
|
58
|
+
const passthrough = vi.fn().mockResolvedValue(undefined)
|
|
59
|
+
return {
|
|
60
|
+
createSession,
|
|
61
|
+
handleOffer,
|
|
62
|
+
listStreams: vi.fn().mockResolvedValue([]),
|
|
63
|
+
handleAnswer: passthrough,
|
|
64
|
+
addIceCandidate: passthrough,
|
|
65
|
+
getIceCandidates: vi.fn().mockResolvedValue({ candidates: [], done: true }),
|
|
66
|
+
closeSession: passthrough,
|
|
67
|
+
hasAdaptiveBitrate: vi.fn().mockResolvedValue(false),
|
|
68
|
+
getSessionState: vi.fn().mockResolvedValue({ pendingRenegotiation: null }),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function ctxWith(userAgent?: string): TrpcContext {
|
|
73
|
+
return { user: null, req: reqWithUa(userAgent) }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
it('injects the request UA into createSession attribution', async () => {
|
|
77
|
+
const provider = mockProvider()
|
|
78
|
+
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Edg/120'))
|
|
79
|
+
|
|
80
|
+
await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
|
|
81
|
+
|
|
82
|
+
expect(provider.createSession).toHaveBeenCalledTimes(1)
|
|
83
|
+
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
84
|
+
expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Edg/120' })
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('injects the request UA into handleOffer attribution', async () => {
|
|
88
|
+
const provider = mockProvider()
|
|
89
|
+
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('CriOS/120'))
|
|
90
|
+
|
|
91
|
+
await wrapped.handleOffer({ deviceId: 1, sdpOffer: 'x' })
|
|
92
|
+
|
|
93
|
+
const arg = vi.mocked(provider.handleOffer).mock.calls[0]![0]
|
|
94
|
+
expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'CriOS/120' })
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('overwrites a client-supplied UA on createSession', async () => {
|
|
98
|
+
const provider = mockProvider()
|
|
99
|
+
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('TrustedUA/1'))
|
|
100
|
+
|
|
101
|
+
await wrapped.createSession({
|
|
102
|
+
deviceId: 1,
|
|
103
|
+
target: { kind: 'adaptive' },
|
|
104
|
+
consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed', label: 'bob' },
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
108
|
+
expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', label: 'bob', userAgent: 'TrustedUA/1' })
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('does not alter the call when no UA header is present', async () => {
|
|
112
|
+
const provider = mockProvider()
|
|
113
|
+
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith(undefined))
|
|
114
|
+
|
|
115
|
+
await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
|
|
116
|
+
|
|
117
|
+
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
118
|
+
expect(arg.consumerAttribution).toBeUndefined()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('delegates other methods straight through', async () => {
|
|
122
|
+
const provider = mockProvider()
|
|
123
|
+
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Chrome/120'))
|
|
124
|
+
|
|
125
|
+
await wrapped.closeSession({ deviceId: 1, sessionId: 's' })
|
|
126
|
+
expect(provider.closeSession).toHaveBeenCalledWith({ deviceId: 1, sessionId: 's' })
|
|
127
|
+
})
|
|
128
|
+
})
|
|
@@ -92,7 +92,18 @@ export function requireDeviceScoped<K extends keyof CapabilityProviderMap>(
|
|
|
92
92
|
message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
|
|
93
93
|
})
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
const result = await fn.call(native, input)
|
|
96
|
+
// Device-property-wiring overlay (read-time): only `getStatus`, and only
|
|
97
|
+
// when the device has links for this cap (resolveLinkedStatus returns
|
|
98
|
+
// null otherwise → base result untouched). One in-process singleton hop.
|
|
99
|
+
if (prop === 'getStatus') {
|
|
100
|
+
const deviceManager = registry.getSingleton<{
|
|
101
|
+
resolveLinkedStatus?: (i: { deviceId: number; cap: string; baseStatus: unknown }) => Promise<Record<string, unknown> | null>
|
|
102
|
+
}>('device-manager')
|
|
103
|
+
const overlaid = await deviceManager?.resolveLinkedStatus?.({ deviceId, cap: String(capName), baseStatus: result })
|
|
104
|
+
if (overlaid != null) return overlaid
|
|
105
|
+
}
|
|
106
|
+
return result
|
|
96
107
|
}
|
|
97
108
|
},
|
|
98
109
|
})
|
|
@@ -53,6 +53,23 @@ export function extractClientIp(req: ClientRequest | undefined): string | null {
|
|
|
53
53
|
return null
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Best-effort read of the originating client's `User-Agent` header. Both
|
|
58
|
+
* request shapes in the union (`FastifyRequest` and the raw WS-transport
|
|
59
|
+
* `IncomingMessage`) expose `.headers['user-agent']`. Returns `null` when
|
|
60
|
+
* absent (mesh-originated calls carry no HTTP request, and some clients
|
|
61
|
+
* omit the header). Used by the `webrtc-session` mount to tag a browser
|
|
62
|
+
* subscriber's broker attribution with the viewer's UA — the SERVER reads
|
|
63
|
+
* it from the request context; any client-supplied value is ignored.
|
|
64
|
+
*/
|
|
65
|
+
export function extractUserAgent(req: ClientRequest | undefined): string | null {
|
|
66
|
+
if (!req) return null
|
|
67
|
+
const ua = req.headers['user-agent']
|
|
68
|
+
const value = Array.isArray(ua) ? ua[0] : ua
|
|
69
|
+
if (typeof value === 'string' && value.length > 0) return value
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
56
73
|
/**
|
|
57
74
|
* Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.1.5` → `192.168.1.5`)
|
|
58
75
|
* and any zone id (`fe80::1%en0` → `fe80::1`) so the range checks below
|