@camstack/server 0.1.6 → 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__/addon-upload.spec.ts +58 -0
- package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
- package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
- 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-providers-bulk-update.spec.ts +388 -0
- package/src/__tests__/cap-route-adapter.spec.ts +289 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
- package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
- package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
- package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
- package/src/__tests__/native-cap-route.spec.ts +404 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
- package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
- package/src/__tests__/uds-log-ingest.spec.ts +183 -0
- package/src/api/addon-upload.ts +27 -1
- package/src/api/capabilities.router.ts +1 -1
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/bulk-update-coordinator.ts +302 -0
- package/src/api/core/cap-providers.ts +211 -9
- package/src/api/core/capabilities.router.ts +26 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/oauth2/oauth2-routes.ts +5 -1
- package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
- 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/cap-route-error-formatter.ts +163 -0
- package/src/api/trpc/client-ip.ts +147 -0
- package/src/api/trpc/generated-cap-mounts.ts +299 -8
- package/src/api/trpc/generated-cap-routers.ts +2384 -302
- package/src/api/trpc/trpc.middleware.ts +5 -1
- package/src/api/trpc/trpc.router.ts +84 -3
- 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-call-gateway.ts +157 -0
- package/src/core/addon/addon-package.service.ts +9 -0
- package/src/core/addon/addon-registry.service.ts +453 -107
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/addon/addon-settings-provider.ts +40 -116
- package/src/core/capability/capability.service.ts +9 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
- package/src/core/moleculer/cap-call-fn.ts +103 -0
- package/src/core/moleculer/cap-route-authority.ts +182 -0
- package/src/core/moleculer/moleculer.service.ts +408 -36
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +137 -12
- 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
|
@@ -196,7 +196,11 @@ export function registerOauth2Routes(fastify: FastifyInstance, deps: Oauth2Deps)
|
|
|
196
196
|
username: tokenInfo.username ?? '',
|
|
197
197
|
scopes: descriptor.requestedScopes,
|
|
198
198
|
redirectUri: v.redirectUri,
|
|
199
|
-
|
|
199
|
+
// Prefer the integration's own public origin (e.g. the operator-selected
|
|
200
|
+
// external-access endpoint surfaced by a forked exporter addon) so the
|
|
201
|
+
// claim the cloud Lambda routes back on is the reachable public URL, not
|
|
202
|
+
// the hub-global fallback (which defaults to localhost in dev).
|
|
203
|
+
hubUrl: descriptor.hubUrl ?? deps.publicHubUrl(),
|
|
200
204
|
})
|
|
201
205
|
|
|
202
206
|
return reply.redirect(`${v.redirectUri}?code=${encodeURIComponent(code)}&state=${encodeURIComponent(v.state)}`)
|
|
@@ -0,0 +1,146 @@
|
|
|
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
|
+
})
|
|
@@ -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
|
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tRPC error formatter that serializes CapRouteError diagnostic fields
|
|
3
|
+
* across the server→client boundary.
|
|
4
|
+
*
|
|
5
|
+
* tRPC only carries `error.message` by default. This formatter augments
|
|
6
|
+
* the default shape's `data` block with typed CapRouteError fields so the
|
|
7
|
+
* admin-UI can read `capRouteReason` instead of substring-matching message text.
|
|
8
|
+
*
|
|
9
|
+
* Fields added (all optional — absent when the error is not a CapRouteError):
|
|
10
|
+
* - `capRouteReason` — 'no-provider' | 'node-offline' | 'cap-unknown' | 'transport-failed'
|
|
11
|
+
* - `capRouteRejected` — array of `{ kind: string; why: string }` route-rejection descriptors
|
|
12
|
+
* - `capRouteNodeId` — the target node id, when known
|
|
13
|
+
*
|
|
14
|
+
* The formatter is EXPORTED for unit testing (no side-effects, pure function).
|
|
15
|
+
*/
|
|
16
|
+
import { CapRouteError } from '@camstack/kernel'
|
|
17
|
+
import type { RejectedRoute } from '@camstack/kernel'
|
|
18
|
+
import type { TRPCError } from '@trpc/server'
|
|
19
|
+
import type { DefaultErrorShape } from '@trpc/server/unstable-core-do-not-import'
|
|
20
|
+
|
|
21
|
+
// ---------------------------------------------------------------------------
|
|
22
|
+
// Types
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
/** The augmented data block we attach when a CapRouteError is present. */
|
|
26
|
+
export interface CapRouteErrorData {
|
|
27
|
+
readonly capRouteReason: string
|
|
28
|
+
readonly capRouteRejected: readonly RejectedRoute[]
|
|
29
|
+
readonly capRouteNodeId?: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface AugmentedErrorShape extends DefaultErrorShape {
|
|
33
|
+
readonly data: DefaultErrorShape['data'] & Partial<CapRouteErrorData>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Type guards
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/** Known CapRouteError reason values — used as a runtime safety rail. */
|
|
41
|
+
const KNOWN_REASONS = new Set<string>(['no-provider', 'node-offline', 'cap-unknown', 'transport-failed'])
|
|
42
|
+
|
|
43
|
+
/** Narrows a plain string to the `CapRouteError['reason']` union. */
|
|
44
|
+
function isCapRouteReason(r: string): r is CapRouteError['reason'] {
|
|
45
|
+
return KNOWN_REASONS.has(r)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Narrows an `unknown` value to `RejectedRoute` by checking structural shape. */
|
|
49
|
+
function isRejectedRoute(r: unknown): r is RejectedRoute {
|
|
50
|
+
if (typeof r !== 'object' || r === null) return false
|
|
51
|
+
const kind: unknown = Reflect.get(r, 'kind')
|
|
52
|
+
const why: unknown = Reflect.get(r, 'why')
|
|
53
|
+
return typeof kind === 'string' && typeof why === 'string'
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// ---------------------------------------------------------------------------
|
|
57
|
+
// CapRouteError extraction helpers
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Walks the `.cause` chain of an error to find a CapRouteError.
|
|
62
|
+
* Returns the first one found, or null.
|
|
63
|
+
*
|
|
64
|
+
* Detection is dual-mode:
|
|
65
|
+
* 1. `instanceof CapRouteError` — works when the same module is loaded.
|
|
66
|
+
* 2. Duck-type: `name === 'CapRouteError'` + `typeof reason === 'string'`
|
|
67
|
+
* — robust against module-boundary issues (self-contained addons).
|
|
68
|
+
*/
|
|
69
|
+
function extractCapRouteError(err: unknown): CapRouteError | null {
|
|
70
|
+
let current: unknown = err
|
|
71
|
+
for (let depth = 0; depth < 8; depth++) {
|
|
72
|
+
if (current === null || current === undefined) return null
|
|
73
|
+
|
|
74
|
+
if (current instanceof CapRouteError) {
|
|
75
|
+
return current
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Duck-type fallback: err.name + err.reason field present
|
|
79
|
+
if (typeof current === 'object' && Reflect.get(current, 'name') === 'CapRouteError') {
|
|
80
|
+
const rawReason: unknown = Reflect.get(current, 'reason')
|
|
81
|
+
if (typeof rawReason === 'string') {
|
|
82
|
+
// Runtime safety: reject unrecognised reason strings so the formatter
|
|
83
|
+
// only promotes values it knows are valid CapRouteError reasons.
|
|
84
|
+
if (!isCapRouteReason(rawReason)) {
|
|
85
|
+
// Unrecognised reason — treat as a non-CapRouteError and keep walking
|
|
86
|
+
const cause: unknown = Reflect.get(current, 'cause')
|
|
87
|
+
if (cause === current) return null
|
|
88
|
+
current = cause
|
|
89
|
+
continue
|
|
90
|
+
}
|
|
91
|
+
const reason: CapRouteError['reason'] = rawReason
|
|
92
|
+
|
|
93
|
+
const rawRejected: unknown = Reflect.get(current, 'rejected')
|
|
94
|
+
const rejected: readonly RejectedRoute[] = Array.isArray(rawRejected)
|
|
95
|
+
? rawRejected.filter(isRejectedRoute)
|
|
96
|
+
: []
|
|
97
|
+
|
|
98
|
+
const nodeId: unknown = Reflect.get(current, 'nodeId')
|
|
99
|
+
const message: unknown = Reflect.get(current, 'message')
|
|
100
|
+
const rawCapName: unknown = Reflect.get(current, 'capName')
|
|
101
|
+
const capName: string = typeof rawCapName === 'string' ? rawCapName : '(unknown)'
|
|
102
|
+
|
|
103
|
+
// Build a minimal object with the same shape — enough for the formatter.
|
|
104
|
+
const synthetic = Object.assign(new CapRouteError(capName, undefined, {
|
|
105
|
+
reason,
|
|
106
|
+
rejected,
|
|
107
|
+
...(typeof nodeId === 'string' ? { nodeId } : {}),
|
|
108
|
+
}), {
|
|
109
|
+
// Override message from the original if available
|
|
110
|
+
message: typeof message === 'string' ? message : '(duck-typed CapRouteError)',
|
|
111
|
+
})
|
|
112
|
+
return synthetic
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Walk the cause chain
|
|
117
|
+
if (typeof current !== 'object') return null
|
|
118
|
+
const cause: unknown = Reflect.get(current, 'cause')
|
|
119
|
+
if (cause === current) return null // Guard against circular refs
|
|
120
|
+
current = cause
|
|
121
|
+
}
|
|
122
|
+
return null
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
// Formatter — exported for unit tests
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
export interface FormatTrpcErrorOpts {
|
|
130
|
+
readonly error: TRPCError
|
|
131
|
+
readonly shape: DefaultErrorShape
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Augments the default tRPC error shape with CapRouteError diagnostic fields
|
|
136
|
+
* when the thrown error (or any error in its `.cause` chain) is a CapRouteError.
|
|
137
|
+
* Returns the shape unchanged for all other errors.
|
|
138
|
+
*/
|
|
139
|
+
export function formatTrpcError(opts: FormatTrpcErrorOpts): AugmentedErrorShape {
|
|
140
|
+
const { error, shape } = opts
|
|
141
|
+
|
|
142
|
+
// extractCapRouteError already walks the full .cause chain, so a single call
|
|
143
|
+
// starting from `error` covers both `error instanceof CapRouteError` and
|
|
144
|
+
// `error.cause` (and deeper nesting). No second call needed.
|
|
145
|
+
const capRouteError = extractCapRouteError(error)
|
|
146
|
+
if (capRouteError === null) {
|
|
147
|
+
return { ...shape, data: { ...shape.data } }
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const extraData: CapRouteErrorData = {
|
|
151
|
+
capRouteReason: capRouteError.reason,
|
|
152
|
+
capRouteRejected: capRouteError.rejected,
|
|
153
|
+
...(capRouteError.nodeId !== undefined ? { capRouteNodeId: capRouteError.nodeId } : {}),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
...shape,
|
|
158
|
+
data: {
|
|
159
|
+
...shape.data,
|
|
160
|
+
...extraData,
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-IP extraction + LAN/remote classification for the tRPC layer.
|
|
3
|
+
*
|
|
4
|
+
* Used by the `webrtcSession.createSession` override to decide whether a
|
|
5
|
+
* live-view session should force TURN-relay-only ICE: a viewer behind
|
|
6
|
+
* CGNAT/4G (a non-LAN source IP) only ever offers a relay candidate, so
|
|
7
|
+
* the broker must offer a genuinely relay-only SDP (the patched werift
|
|
8
|
+
* emits one under `iceTransportPolicy:'relay'`) to get a clean
|
|
9
|
+
* relay↔relay media path. LAN viewers (private/loopback source IP) keep
|
|
10
|
+
* the low-latency direct host/srflx path. The SERVER computes this — the
|
|
11
|
+
* client never sends a relay-only flag.
|
|
12
|
+
*/
|
|
13
|
+
import type { FastifyRequest } from 'fastify'
|
|
14
|
+
import type { IncomingMessage } from 'node:http'
|
|
15
|
+
|
|
16
|
+
export type ClientRequest = FastifyRequest | IncomingMessage
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Best-effort extraction of the originating client IP from a tRPC
|
|
20
|
+
* request. Order of preference:
|
|
21
|
+
* 1. `X-Forwarded-For` first hop (when behind a reverse proxy — the
|
|
22
|
+
* hub is typically fronted by Caddy/nginx/Cloudflare for remote
|
|
23
|
+
* access, so the socket peer is the proxy, not the viewer).
|
|
24
|
+
* 2. Fastify's `req.ip` (already proxy-aware when `trustProxy` is on).
|
|
25
|
+
* 3. The raw socket remote address.
|
|
26
|
+
*
|
|
27
|
+
* Returns `null` when no address can be determined (e.g. mesh-originated
|
|
28
|
+
* calls that carry no HTTP request) — the caller treats `null` as "not
|
|
29
|
+
* remote" so the LAN/direct path stays the safe default.
|
|
30
|
+
*/
|
|
31
|
+
export function extractClientIp(req: ClientRequest | undefined): string | null {
|
|
32
|
+
if (!req) return null
|
|
33
|
+
|
|
34
|
+
// 1. X-Forwarded-For — comma-separated list, client is the FIRST entry.
|
|
35
|
+
const xff = req.headers['x-forwarded-for']
|
|
36
|
+
const xffValue = Array.isArray(xff) ? xff[0] : xff
|
|
37
|
+
if (typeof xffValue === 'string' && xffValue.length > 0) {
|
|
38
|
+
const first = xffValue.split(',')[0]?.trim()
|
|
39
|
+
if (first) return normalizeIp(first)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// 2. Fastify's parsed `req.ip` (honours `trustProxy` config).
|
|
43
|
+
if ('ip' in req && typeof req.ip === 'string' && req.ip.length > 0) {
|
|
44
|
+
return normalizeIp(req.ip)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 3. Raw socket peer (WS / IncomingMessage path).
|
|
48
|
+
const remote = req.socket?.remoteAddress
|
|
49
|
+
if (typeof remote === 'string' && remote.length > 0) {
|
|
50
|
+
return normalizeIp(remote)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
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
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Strip an IPv4-mapped IPv6 prefix (`::ffff:192.168.1.5` → `192.168.1.5`)
|
|
75
|
+
* and any zone id (`fe80::1%en0` → `fe80::1`) so the range checks below
|
|
76
|
+
* see a clean address.
|
|
77
|
+
*/
|
|
78
|
+
function normalizeIp(ip: string): string {
|
|
79
|
+
let out = ip.trim()
|
|
80
|
+
if (out.startsWith('::ffff:')) out = out.slice('::ffff:'.length)
|
|
81
|
+
const pct = out.indexOf('%')
|
|
82
|
+
if (pct !== -1) out = out.slice(0, pct)
|
|
83
|
+
return out
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* True when the address is NOT in a private / loopback / link-local /
|
|
88
|
+
* Tailscale range — i.e. an internet (remote) viewer. Covers IPv4
|
|
89
|
+
* (10/8, 172.16/12, 192.168/16, 127/8, 100.64/10 Tailscale CGNAT) and
|
|
90
|
+
* IPv6 (::1, fc00::/7 unique-local, fe80::/10 link-local — fd7a::/16
|
|
91
|
+
* Tailscale ULA is a subset of fc00::/7 and is therefore already
|
|
92
|
+
* covered).
|
|
93
|
+
*
|
|
94
|
+
* Tailscale clients (the 100.64.0.0/10 CGNAT overlay or the fd7a::/16
|
|
95
|
+
* ULA overlay) are deliberately classified LOCAL: over the Tailscale
|
|
96
|
+
* mesh BOTH peers sit on the 100.x / fd7a:: overlay and are mutually
|
|
97
|
+
* reachable, so the broker offers ALL candidates (host/srflx/relay)
|
|
98
|
+
* — including the hub's Tailscale host candidate — and a direct
|
|
99
|
+
* host↔host pair wins ICE with native (non-re-encoded) media. Forcing
|
|
100
|
+
* relay-only for Tailscale would push them onto the broken relay path.
|
|
101
|
+
*
|
|
102
|
+
* Unparseable or null addresses return `false` (treated as LAN — the
|
|
103
|
+
* safe default that preserves the existing direct path).
|
|
104
|
+
*/
|
|
105
|
+
export function isRemoteClientIp(ip: string | null): boolean {
|
|
106
|
+
if (!ip) return false
|
|
107
|
+
if (ip.includes(':')) return !isPrivateIpv6(ip)
|
|
108
|
+
if (isIpv4(ip)) return !isPrivateIpv4(ip)
|
|
109
|
+
// Not a recognisable IP literal — be conservative, treat as LAN.
|
|
110
|
+
return false
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function isIpv4(ip: string): boolean {
|
|
114
|
+
const parts = ip.split('.')
|
|
115
|
+
if (parts.length !== 4) return false
|
|
116
|
+
return parts.every((p) => {
|
|
117
|
+
if (!/^\d{1,3}$/.test(p)) return false
|
|
118
|
+
const n = Number.parseInt(p, 10)
|
|
119
|
+
return n >= 0 && n <= 255
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isPrivateIpv4(ip: string): boolean {
|
|
124
|
+
const parts = ip.split('.').map((p) => Number.parseInt(p, 10))
|
|
125
|
+
const [a, b] = parts
|
|
126
|
+
if (a === undefined || b === undefined) return false
|
|
127
|
+
if (a === 10) return true // 10.0.0.0/8
|
|
128
|
+
if (a === 127) return true // 127.0.0.0/8 loopback
|
|
129
|
+
if (a === 172 && b >= 16 && b <= 31) return true // 172.16.0.0/12
|
|
130
|
+
if (a === 192 && b === 168) return true // 192.168.0.0/16
|
|
131
|
+
if (a === 169 && b === 254) return true // 169.254.0.0/16 link-local
|
|
132
|
+
// 100.64.0.0/10 — Tailscale CGNAT overlay (100.64.0.0 – 100.127.255.255).
|
|
133
|
+
// Both Tailscale peers are mutually reachable on this overlay, so treat
|
|
134
|
+
// it as local (direct host↔host pair, no relay).
|
|
135
|
+
if (a === 100 && b >= 64 && b <= 127) return true
|
|
136
|
+
return false
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function isPrivateIpv6(ip: string): boolean {
|
|
140
|
+
const lower = ip.toLowerCase()
|
|
141
|
+
if (lower === '::1') return true // loopback
|
|
142
|
+
if (lower === '::') return true // unspecified
|
|
143
|
+
if (lower.startsWith('fe80')) return true // fe80::/10 link-local
|
|
144
|
+
// fc00::/7 unique-local — covers fc.. and fd..
|
|
145
|
+
if (lower.startsWith('fc') || lower.startsWith('fd')) return true
|
|
146
|
+
return false
|
|
147
|
+
}
|