@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.
Files changed (29) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  3. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  4. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  5. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  6. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
  7. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  8. package/src/api/core/cap-providers.ts +152 -3
  9. package/src/api/core/logs.router.ts +4 -0
  10. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  11. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  12. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  13. package/src/api/trpc/client-ip.ts +17 -0
  14. package/src/api/trpc/generated-cap-mounts.ts +281 -8
  15. package/src/api/trpc/generated-cap-routers.ts +2087 -184
  16. package/src/api/trpc/trpc.router.ts +43 -7
  17. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  18. package/src/boot/integration-id-backfill.ts +109 -0
  19. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  20. package/src/core/addon/addon-registry.service.ts +89 -2
  21. package/src/core/addon/addon-row-manifest.ts +29 -0
  22. package/src/core/logging/logging.service.ts +7 -2
  23. package/src/core/moleculer/moleculer.service.ts +28 -0
  24. package/src/core/network/network-quality.service.spec.ts +2 -1
  25. package/src/main.ts +92 -0
  26. package/src/core/storage/settings-store.spec.ts +0 -213
  27. package/src/core/storage/settings-store.ts +0 -2
  28. package/src/core/storage/sql-schema.spec.ts +0 -140
  29. 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
- return fn.call(native, input)
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