@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.
Files changed (60) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/addon-upload.spec.ts +58 -0
  3. package/src/__tests__/bulk-update-coordinator.spec.ts +286 -0
  4. package/src/__tests__/cap-ownership-authority.spec.ts +400 -0
  5. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  6. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  7. package/src/__tests__/cap-providers-bulk-update.spec.ts +388 -0
  8. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  9. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  10. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  11. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  12. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  13. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  14. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  15. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  16. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  17. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  18. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +329 -0
  19. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  20. package/src/__tests__/native-cap-route.spec.ts +404 -0
  21. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  22. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  23. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  24. package/src/api/addon-upload.ts +27 -1
  25. package/src/api/capabilities.router.ts +1 -1
  26. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  27. package/src/api/core/bulk-update-coordinator.ts +302 -0
  28. package/src/api/core/cap-providers.ts +211 -9
  29. package/src/api/core/capabilities.router.ts +26 -3
  30. package/src/api/core/logs.router.ts +4 -0
  31. package/src/api/oauth2/oauth2-routes.ts +5 -1
  32. package/src/api/trpc/__tests__/client-ip.spec.ts +146 -0
  33. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  34. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  35. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  36. package/src/api/trpc/client-ip.ts +147 -0
  37. package/src/api/trpc/generated-cap-mounts.ts +299 -8
  38. package/src/api/trpc/generated-cap-routers.ts +2384 -302
  39. package/src/api/trpc/trpc.middleware.ts +5 -1
  40. package/src/api/trpc/trpc.router.ts +84 -3
  41. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  42. package/src/boot/integration-id-backfill.ts +109 -0
  43. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  44. package/src/core/addon/addon-call-gateway.ts +157 -0
  45. package/src/core/addon/addon-package.service.ts +9 -0
  46. package/src/core/addon/addon-registry.service.ts +453 -107
  47. package/src/core/addon/addon-row-manifest.ts +29 -0
  48. package/src/core/addon/addon-settings-provider.ts +40 -116
  49. package/src/core/capability/capability.service.ts +9 -0
  50. package/src/core/logging/logging.service.ts +7 -2
  51. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  52. package/src/core/moleculer/cap-call-fn.ts +103 -0
  53. package/src/core/moleculer/cap-route-authority.ts +182 -0
  54. package/src/core/moleculer/moleculer.service.ts +408 -36
  55. package/src/core/network/network-quality.service.spec.ts +2 -1
  56. package/src/main.ts +137 -12
  57. package/src/core/storage/settings-store.spec.ts +0 -213
  58. package/src/core/storage/settings-store.ts +0 -2
  59. package/src/core/storage/sql-schema.spec.ts +0 -140
  60. 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
- hubUrl: deps.publicHubUrl(),
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
- 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
  })
@@ -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
+ }