@camstack/server 1.0.0 → 1.0.1
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/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
- package/dist/api/addon-upload.js +441 -0
- package/dist/api/addons-custom.router.js +91 -0
- package/dist/api/auth-whoami.js +55 -0
- package/dist/api/bridge-addons.router.js +109 -0
- package/dist/api/capabilities.router.js +229 -0
- package/dist/api/core/addon-settings.router.js +117 -0
- package/dist/api/core/agents.router.js +73 -0
- package/dist/api/core/auth.router.js +286 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +1124 -0
- package/dist/api/core/capabilities.router.js +138 -0
- package/dist/api/core/collection-preference.js +17 -0
- package/dist/api/core/event-bus-proxy.router.js +45 -0
- package/dist/api/core/hwaccel.router.js +91 -0
- package/dist/api/core/live-events.router.js +61 -0
- package/dist/api/core/logs.router.js +172 -0
- package/dist/api/core/notifications.router.js +67 -0
- package/dist/api/core/repl.router.js +35 -0
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/core/stream-probe.router.js +58 -0
- package/dist/api/core/system-events.router.js +100 -0
- package/dist/api/health/health.routes.js +68 -0
- package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
- package/dist/api/oauth2/oauth2-routes.js +219 -0
- package/dist/api/trpc/cap-mount-helpers.js +194 -0
- package/dist/api/trpc/cap-route-error-formatter.js +133 -0
- package/dist/api/trpc/client-ip.js +147 -0
- package/dist/api/trpc/core-cap-bridge.js +115 -0
- package/dist/api/trpc/generated-cap-mounts.js +388 -0
- package/dist/api/trpc/generated-cap-routers.js +7635 -0
- package/dist/api/trpc/scope-access.js +93 -0
- package/dist/api/trpc/trpc.context.js +184 -0
- package/dist/api/trpc/trpc.middleware.js +139 -0
- package/dist/api/trpc/trpc.router.js +188 -0
- package/dist/auth/session-cookie.js +47 -0
- package/dist/boot/boot-config.js +241 -0
- package/dist/boot/integration-id-backfill.js +76 -0
- package/dist/boot/post-boot.service.js +85 -0
- package/dist/core/addon/addon-call-gateway.js +99 -0
- package/dist/core/addon/addon-package.service.js +1560 -0
- package/dist/core/addon/addon-registry.service.js +2739 -0
- package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
- package/dist/core/addon/addon-search.service.js +62 -0
- package/dist/core/addon/addon-settings-provider.js +102 -0
- package/dist/core/addon/addon.tokens.js +5 -0
- package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
- package/dist/core/addon-pages/addon-pages.service.js +107 -0
- package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
- package/dist/core/agent/agent-registry.service.js +477 -0
- package/dist/core/auth/auth.service.js +10 -0
- package/dist/core/capability/capability.service.js +58 -0
- package/dist/core/config/config.schema.js +7 -0
- package/dist/core/config/config.service.js +10 -0
- package/dist/core/events/event-bus.service.js +83 -0
- package/dist/core/feature/feature.service.js +10 -0
- package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
- package/dist/core/logging/log-ring-buffer.js +6 -0
- package/dist/core/logging/logging.service.js +130 -0
- package/dist/core/logging/scoped-logger.js +6 -0
- package/dist/core/moleculer/cap-call-fn.js +50 -0
- package/dist/core/moleculer/cap-route-authority.js +122 -0
- package/dist/core/moleculer/moleculer.service.js +898 -0
- package/dist/core/network/network-quality.service.js +7 -0
- package/dist/core/notification/notification-wrapper.service.js +33 -0
- package/dist/core/notification/toast-wrapper.service.js +25 -0
- package/dist/core/provider/provider.tokens.js +4 -0
- package/dist/core/repl/repl-engine.service.js +140 -0
- package/dist/core/storage/fs-storage-backend.js +6 -0
- package/dist/core/storage/storage-location-manager.js +6 -0
- package/dist/core/storage/storage.service.js +7 -0
- package/dist/core/streaming/stream-probe.service.js +209 -0
- package/dist/core/topology/topology-emitter.service.js +106 -0
- package/dist/launcher.js +325 -0
- package/dist/main.js +1098 -0
- package/dist/manual-boot.js +227 -0
- package/package.json +5 -1
- package/src/__tests__/addon-install-e2e.test.ts +0 -74
- package/src/__tests__/addon-pages-e2e.test.ts +0 -200
- package/src/__tests__/addon-route-session.test.ts +0 -17
- package/src/__tests__/addon-settings-router.spec.ts +0 -67
- package/src/__tests__/addon-upload.spec.ts +0 -475
- package/src/__tests__/agent-registry.spec.ts +0 -179
- package/src/__tests__/agent-status-page.spec.ts +0 -82
- package/src/__tests__/auth-session-cookie.test.ts +0 -48
- package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
- package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
- package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
- package/src/__tests__/cap-route-adapter.spec.ts +0 -302
- package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
- package/src/__tests__/cap-routers/harness.ts +0 -163
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
- package/src/__tests__/capability-e2e.test.ts +0 -384
- package/src/__tests__/cli-e2e.test.ts +0 -150
- package/src/__tests__/core-cap-bridge.spec.ts +0 -91
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
- package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
- package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
- package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
- package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
- package/src/__tests__/framework-allowlist.spec.ts +0 -96
- package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
- package/src/__tests__/https-e2e.test.ts +0 -124
- package/src/__tests__/lifecycle-e2e.test.ts +0 -189
- package/src/__tests__/live-events-subscription.spec.ts +0 -149
- package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
- package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
- package/src/__tests__/native-cap-route.spec.ts +0 -427
- package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
- package/src/__tests__/post-boot-restart.spec.ts +0 -161
- package/src/__tests__/singleton-contention.test.ts +0 -499
- package/src/__tests__/streaming-diagnostic.test.ts +0 -615
- package/src/__tests__/streaming-scale.test.ts +0 -314
- package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
- package/src/__tests__/uds-log-ingest.spec.ts +0 -183
- package/src/api/__tests__/addons-custom.spec.ts +0 -148
- package/src/api/__tests__/capabilities.router.test.ts +0 -56
- package/src/api/addon-upload.ts +0 -529
- package/src/api/addons-custom.router.ts +0 -101
- package/src/api/auth-whoami.ts +0 -101
- package/src/api/bridge-addons.router.ts +0 -122
- package/src/api/capabilities.router.ts +0 -265
- package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
- package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
- package/src/api/core/addon-settings.router.ts +0 -127
- package/src/api/core/agents.router.ts +0 -86
- package/src/api/core/auth.router.ts +0 -322
- package/src/api/core/bulk-update-coordinator.ts +0 -305
- package/src/api/core/cap-providers.ts +0 -1339
- package/src/api/core/capabilities.router.ts +0 -149
- package/src/api/core/collection-preference.ts +0 -40
- package/src/api/core/event-bus-proxy.router.ts +0 -45
- package/src/api/core/hwaccel.router.ts +0 -108
- package/src/api/core/live-events.router.ts +0 -67
- package/src/api/core/logs.router.ts +0 -195
- package/src/api/core/notifications.router.ts +0 -66
- package/src/api/core/repl.router.ts +0 -39
- package/src/api/core/settings-backend.router.ts +0 -140
- package/src/api/core/stream-probe.router.ts +0 -57
- package/src/api/core/system-events.router.ts +0 -125
- package/src/api/health/health.routes.ts +0 -117
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
- package/src/api/oauth2/oauth2-routes.ts +0 -281
- package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
- package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
- package/src/api/trpc/cap-mount-helpers.ts +0 -245
- package/src/api/trpc/cap-route-error-formatter.ts +0 -171
- package/src/api/trpc/client-ip.ts +0 -147
- package/src/api/trpc/core-cap-bridge.ts +0 -154
- package/src/api/trpc/generated-cap-mounts.ts +0 -1240
- package/src/api/trpc/generated-cap-routers.ts +0 -11523
- package/src/api/trpc/scope-access.ts +0 -110
- package/src/api/trpc/trpc.context.ts +0 -258
- package/src/api/trpc/trpc.middleware.ts +0 -146
- package/src/api/trpc/trpc.router.ts +0 -389
- package/src/auth/session-cookie.ts +0 -54
- package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
- package/src/boot/boot-config.ts +0 -259
- package/src/boot/integration-id-backfill.ts +0 -109
- package/src/boot/post-boot.service.ts +0 -105
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
- package/src/core/addon/addon-call-gateway.ts +0 -171
- package/src/core/addon/addon-package.service.ts +0 -1787
- package/src/core/addon/addon-registry.service.ts +0 -3130
- package/src/core/addon/addon-search.service.ts +0 -91
- package/src/core/addon/addon-settings-provider.ts +0 -220
- package/src/core/addon/addon.tokens.ts +0 -2
- package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
- package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
- package/src/core/addon-pages/addon-pages.service.ts +0 -82
- package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
- package/src/core/agent/agent-registry.service.ts +0 -529
- package/src/core/auth/auth.service.spec.ts +0 -86
- package/src/core/auth/auth.service.ts +0 -8
- package/src/core/capability/capability.service.ts +0 -66
- package/src/core/config/config.schema.ts +0 -3
- package/src/core/config/config.service.spec.ts +0 -175
- package/src/core/config/config.service.ts +0 -7
- package/src/core/events/event-bus.service.spec.ts +0 -235
- package/src/core/events/event-bus.service.ts +0 -89
- package/src/core/feature/feature.service.spec.ts +0 -99
- package/src/core/feature/feature.service.ts +0 -8
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
- package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
- package/src/core/logging/log-ring-buffer.ts +0 -3
- package/src/core/logging/logging.service.spec.ts +0 -287
- package/src/core/logging/logging.service.ts +0 -143
- package/src/core/logging/scoped-logger.ts +0 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
- package/src/core/moleculer/cap-call-fn.ts +0 -107
- package/src/core/moleculer/cap-route-authority.ts +0 -194
- package/src/core/moleculer/moleculer.service.ts +0 -1072
- package/src/core/network/network-quality.service.spec.ts +0 -53
- package/src/core/network/network-quality.service.ts +0 -5
- package/src/core/notification/notification-wrapper.service.ts +0 -34
- package/src/core/notification/toast-wrapper.service.ts +0 -27
- package/src/core/provider/provider.tokens.ts +0 -1
- package/src/core/repl/repl-engine.service.spec.ts +0 -444
- package/src/core/repl/repl-engine.service.ts +0 -155
- package/src/core/storage/fs-storage-backend.spec.ts +0 -70
- package/src/core/storage/fs-storage-backend.ts +0 -3
- package/src/core/storage/storage-location-manager.spec.ts +0 -130
- package/src/core/storage/storage-location-manager.ts +0 -3
- package/src/core/storage/storage.service.spec.ts +0 -73
- package/src/core/storage/storage.service.ts +0 -3
- package/src/core/streaming/stream-probe.service.ts +0 -221
- package/src/core/topology/topology-emitter.service.ts +0 -105
- package/src/launcher.ts +0 -314
- package/src/main.ts +0 -1245
- package/src/manual-boot.ts +0 -301
- package/tsconfig.build.json +0 -8
- package/tsconfig.json +0 -33
- package/vitest.config.ts +0 -26
|
@@ -1,102 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matrix tests for `checkScopeAccess`. Drives the scope-gate in
|
|
3
|
-
* `protectedProcedure`.
|
|
4
|
-
*
|
|
5
|
-
* The map (`METHOD_ACCESS_MAP`) is codegen-emitted; the test imports
|
|
6
|
-
* it indirectly via the helper so any drift between cap definitions
|
|
7
|
-
* and runtime would surface here as a test failure (e.g. a renamed
|
|
8
|
-
* method would no longer resolve to a known entry).
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect } from 'vitest'
|
|
11
|
-
import { checkScopeAccess } from '../scope-access.js'
|
|
12
|
-
import type { TokenScope } from '@camstack/types'
|
|
13
|
-
|
|
14
|
-
function scope(
|
|
15
|
-
type: 'addon' | 'capability',
|
|
16
|
-
target: string,
|
|
17
|
-
access: TokenScope['access'],
|
|
18
|
-
): TokenScope {
|
|
19
|
-
return { type, target, access }
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe('checkScopeAccess', () => {
|
|
23
|
-
// ── Sanity: known paths resolve, unknown paths fail closed ──────
|
|
24
|
-
|
|
25
|
-
it('returns FORBIDDEN with codegen-drift reason for unknown paths', () => {
|
|
26
|
-
const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'no-such.method')
|
|
27
|
-
expect(result.ok).toBe(false)
|
|
28
|
-
if (!result.ok) {
|
|
29
|
-
expect(result.reason).toContain('codegen drift')
|
|
30
|
-
}
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
// ── Capability-typed scopes ─────────────────────────────────────
|
|
34
|
-
|
|
35
|
-
it('accepts when capability scope matches target + access', () => {
|
|
36
|
-
const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'backup.list')
|
|
37
|
-
expect(result.ok).toBe(true)
|
|
38
|
-
if (result.ok) expect(result.access).toBe('view')
|
|
39
|
-
})
|
|
40
|
-
|
|
41
|
-
it('rejects when capability scope matches target but lacks the required access', () => {
|
|
42
|
-
// backup.trigger requires `create`; the scope only grants `view`.
|
|
43
|
-
const result = checkScopeAccess([scope('capability', 'backup', ['view'])], 'backup.trigger')
|
|
44
|
-
expect(result.ok).toBe(false)
|
|
45
|
-
if (!result.ok) {
|
|
46
|
-
// Matcher's reason format: "No scope grants <access> on '<cap>' (<scope>-scope cap)"
|
|
47
|
-
expect(result.reason).toMatch(/No scope grants create on 'backup'/)
|
|
48
|
-
}
|
|
49
|
-
})
|
|
50
|
-
|
|
51
|
-
it('rejects when capability scope targets a different cap entirely', () => {
|
|
52
|
-
const result = checkScopeAccess(
|
|
53
|
-
[scope('capability', 'devices', ['view', 'create', 'delete'])],
|
|
54
|
-
'backup.list',
|
|
55
|
-
)
|
|
56
|
-
expect(result.ok).toBe(false)
|
|
57
|
-
})
|
|
58
|
-
|
|
59
|
-
it('accepts destructive method when scope includes delete', () => {
|
|
60
|
-
const result = checkScopeAccess(
|
|
61
|
-
[scope('capability', 'backup', ['view', 'delete'])],
|
|
62
|
-
'backup.delete',
|
|
63
|
-
)
|
|
64
|
-
expect(result.ok).toBe(true)
|
|
65
|
-
if (result.ok) expect(result.access).toBe('delete')
|
|
66
|
-
})
|
|
67
|
-
|
|
68
|
-
// ── Scope unions ────────────────────────────────────────────────
|
|
69
|
-
|
|
70
|
-
it('union of access flavours satisfies any single requirement', () => {
|
|
71
|
-
const scopes: TokenScope[] = [
|
|
72
|
-
scope('capability', 'backup', ['view']),
|
|
73
|
-
scope('capability', 'backup', ['create']),
|
|
74
|
-
]
|
|
75
|
-
// view request → first scope matches
|
|
76
|
-
expect(checkScopeAccess(scopes, 'backup.list').ok).toBe(true)
|
|
77
|
-
// create request → second scope matches
|
|
78
|
-
expect(checkScopeAccess(scopes, 'backup.trigger').ok).toBe(true)
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
// ── Empty scope set ─────────────────────────────────────────────
|
|
82
|
-
|
|
83
|
-
it('rejects when no scopes are granted at all', () => {
|
|
84
|
-
const result = checkScopeAccess([], 'backup.list')
|
|
85
|
-
expect(result.ok).toBe(false)
|
|
86
|
-
if (!result.ok) {
|
|
87
|
-
expect(result.reason).toContain('Have: (none)')
|
|
88
|
-
}
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
// ── Reason string contains debug-friendly diff ──────────────────
|
|
92
|
-
|
|
93
|
-
it('reason string surfaces what the caller actually has', () => {
|
|
94
|
-
const result = checkScopeAccess([scope('capability', 'devices', ['view'])], 'backup.trigger')
|
|
95
|
-
expect(result.ok).toBe(false)
|
|
96
|
-
if (!result.ok) {
|
|
97
|
-
// Format: "No scope grants create on 'backup' (system-scope cap). Have: capability:devices[view]"
|
|
98
|
-
expect(result.reason).toMatch(/No scope grants create on 'backup'/)
|
|
99
|
-
expect(result.reason).toContain('capability:devices[view]')
|
|
100
|
-
}
|
|
101
|
-
})
|
|
102
|
-
})
|
|
@@ -1,136 +0,0 @@
|
|
|
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({
|
|
35
|
-
kind: 'webrtc-browser',
|
|
36
|
-
label: 'alice',
|
|
37
|
-
userAgent: 'Chrome/120',
|
|
38
|
-
})
|
|
39
|
-
// Immutability: the original attribution is untouched.
|
|
40
|
-
expect(attribution.userAgent).toBeUndefined()
|
|
41
|
-
})
|
|
42
|
-
|
|
43
|
-
it('defaults to webrtc-browser when no attribution was supplied', () => {
|
|
44
|
-
const out = enrichInputWithUserAgent({ deviceId: 1 }, 'Firefox/121')
|
|
45
|
-
expect(out.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Firefox/121' })
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
it('OVERWRITES a client-supplied userAgent (never trust the client)', () => {
|
|
49
|
-
const input = {
|
|
50
|
-
deviceId: 1,
|
|
51
|
-
consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed' } as const,
|
|
52
|
-
}
|
|
53
|
-
const out = enrichInputWithUserAgent(input, 'Safari/17')
|
|
54
|
-
expect(out.consumerAttribution?.userAgent).toBe('Safari/17')
|
|
55
|
-
})
|
|
56
|
-
})
|
|
57
|
-
|
|
58
|
-
describe('wrapWebrtcSessionProviderWithRelay — UA enrichment', () => {
|
|
59
|
-
function mockProvider(): WebrtcSessionProvider {
|
|
60
|
-
const createSession = vi.fn().mockResolvedValue({ sessionId: 's', sdpOffer: 'o' })
|
|
61
|
-
const handleOffer = vi.fn().mockResolvedValue({ sessionId: 's', sdpAnswer: 'a' })
|
|
62
|
-
const passthrough = vi.fn().mockResolvedValue(undefined)
|
|
63
|
-
return {
|
|
64
|
-
createSession,
|
|
65
|
-
handleOffer,
|
|
66
|
-
listStreams: vi.fn().mockResolvedValue([]),
|
|
67
|
-
handleAnswer: passthrough,
|
|
68
|
-
addIceCandidate: passthrough,
|
|
69
|
-
getIceCandidates: vi.fn().mockResolvedValue({ candidates: [], done: true }),
|
|
70
|
-
closeSession: passthrough,
|
|
71
|
-
hasAdaptiveBitrate: vi.fn().mockResolvedValue(false),
|
|
72
|
-
getSessionState: vi.fn().mockResolvedValue({ pendingRenegotiation: null }),
|
|
73
|
-
}
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function ctxWith(userAgent?: string): TrpcContext {
|
|
77
|
-
return { user: null, req: reqWithUa(userAgent) }
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
it('injects the request UA into createSession attribution', async () => {
|
|
81
|
-
const provider = mockProvider()
|
|
82
|
-
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Edg/120'))
|
|
83
|
-
|
|
84
|
-
await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
|
|
85
|
-
|
|
86
|
-
expect(provider.createSession).toHaveBeenCalledTimes(1)
|
|
87
|
-
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
88
|
-
expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'Edg/120' })
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
it('injects the request UA into handleOffer attribution', async () => {
|
|
92
|
-
const provider = mockProvider()
|
|
93
|
-
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('CriOS/120'))
|
|
94
|
-
|
|
95
|
-
await wrapped.handleOffer({ deviceId: 1, sdpOffer: 'x' })
|
|
96
|
-
|
|
97
|
-
const arg = vi.mocked(provider.handleOffer).mock.calls[0]![0]
|
|
98
|
-
expect(arg.consumerAttribution).toEqual({ kind: 'webrtc-browser', userAgent: 'CriOS/120' })
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('overwrites a client-supplied UA on createSession', async () => {
|
|
102
|
-
const provider = mockProvider()
|
|
103
|
-
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('TrustedUA/1'))
|
|
104
|
-
|
|
105
|
-
await wrapped.createSession({
|
|
106
|
-
deviceId: 1,
|
|
107
|
-
target: { kind: 'adaptive' },
|
|
108
|
-
consumerAttribution: { kind: 'webrtc-browser', userAgent: 'spoofed', label: 'bob' },
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
112
|
-
expect(arg.consumerAttribution).toEqual({
|
|
113
|
-
kind: 'webrtc-browser',
|
|
114
|
-
label: 'bob',
|
|
115
|
-
userAgent: 'TrustedUA/1',
|
|
116
|
-
})
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
it('does not alter the call when no UA header is present', async () => {
|
|
120
|
-
const provider = mockProvider()
|
|
121
|
-
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith(undefined))
|
|
122
|
-
|
|
123
|
-
await wrapped.createSession({ deviceId: 1, target: { kind: 'adaptive' } })
|
|
124
|
-
|
|
125
|
-
const arg = vi.mocked(provider.createSession).mock.calls[0]![0]
|
|
126
|
-
expect(arg.consumerAttribution).toBeUndefined()
|
|
127
|
-
})
|
|
128
|
-
|
|
129
|
-
it('delegates other methods straight through', async () => {
|
|
130
|
-
const provider = mockProvider()
|
|
131
|
-
const wrapped = wrapWebrtcSessionProviderWithRelay(provider, ctxWith('Chrome/120'))
|
|
132
|
-
|
|
133
|
-
await wrapped.closeSession({ deviceId: 1, sessionId: 's' })
|
|
134
|
-
expect(provider.closeSession).toHaveBeenCalledWith({ deviceId: 1, sessionId: 's' })
|
|
135
|
-
})
|
|
136
|
-
})
|
|
@@ -1,245 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Small helpers for wiring capability routers to the `CapabilityRegistry`
|
|
3
|
-
* in `trpc.router.ts`. These functions don't generate code — they just
|
|
4
|
-
* remove boilerplate from the mount lambdas we pass to `createCapRouter_X`.
|
|
5
|
-
*
|
|
6
|
-
* When to use what:
|
|
7
|
-
* - `requireSingleton(registry, name)` — singleton caps with no custom
|
|
8
|
-
* composition. Returns the active provider or null (the codegen'd
|
|
9
|
-
* router itself throws PRECONDITION_FAILED when null).
|
|
10
|
-
* - `concatCollection(providers, method)` — collection caps whose
|
|
11
|
-
* methods return arrays and where the desired behaviour is "union of
|
|
12
|
-
* every provider's contribution" (e.g. `turn-provider.getTurnServers`).
|
|
13
|
-
* - `firstSupported(providers, probe, action)` — collection caps where
|
|
14
|
-
* exactly one provider should handle each request, selected by a
|
|
15
|
-
* probe method (e.g. `snapshot-provider.supportsDevice`).
|
|
16
|
-
*
|
|
17
|
-
* Collections that route by an input key (e.g. `webrtc` picking the
|
|
18
|
-
* provider responsible for a given `streamId`) are NOT covered here —
|
|
19
|
-
* they have app-specific routing logic that belongs in the mount.
|
|
20
|
-
*/
|
|
21
|
-
import { TRPCError } from '@trpc/server'
|
|
22
|
-
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
23
|
-
import type { CapabilityProviderMap } from '@camstack/types'
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Fetch the currently active singleton provider for a capability.
|
|
27
|
-
* Returns null if no provider is registered; the downstream codegen'd
|
|
28
|
-
* router surfaces this as `PRECONDITION_FAILED` to the caller.
|
|
29
|
-
*/
|
|
30
|
-
export function requireSingleton<K extends keyof CapabilityProviderMap>(
|
|
31
|
-
registry: CapabilityRegistry | null,
|
|
32
|
-
capName: K,
|
|
33
|
-
): CapabilityProviderMap[K] | null {
|
|
34
|
-
return registry?.getSingleton(capName) ?? null
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* Build a per-device dispatcher that satisfies the singleton-provider
|
|
39
|
-
* shape but resolves the actual implementation lazily via
|
|
40
|
-
* `registry.getNativeProvider(capName, deviceId)` on every method call.
|
|
41
|
-
*
|
|
42
|
-
* Use for device-scoped caps that have NO system-level wrapper (PTZ,
|
|
43
|
-
* reboot, doorbell, brightness, motion-trigger, switch, …) — drivers
|
|
44
|
-
* register per-device native providers via
|
|
45
|
-
* `DeviceContext.registerNativeCap`, and this helper bridges the cap-
|
|
46
|
-
* router's "fetch a singleton then call methods on it" flow into a
|
|
47
|
-
* "resolve native by deviceId per call" flow.
|
|
48
|
-
*
|
|
49
|
-
* Method input MUST carry `deviceId: number`. Methods without that
|
|
50
|
-
* field (auto-injected `getStatus({deviceId})` for caps with a status
|
|
51
|
-
* block, every business method that follows the cap-definition
|
|
52
|
-
* convention) work transparently.
|
|
53
|
-
*
|
|
54
|
-
* Throws PRECONDITION_FAILED with a device-specific message when no
|
|
55
|
-
* native provider exists for the requested deviceId — much friendlier
|
|
56
|
-
* than the singleton fallthrough's "no provider" generic error.
|
|
57
|
-
*/
|
|
58
|
-
export function requireDeviceScoped<K extends keyof CapabilityProviderMap>(
|
|
59
|
-
registry: CapabilityRegistry | null,
|
|
60
|
-
capName: K,
|
|
61
|
-
): CapabilityProviderMap[K] | null {
|
|
62
|
-
if (!registry) return null
|
|
63
|
-
// The Proxy is the singleton stand-in. Each property access returns
|
|
64
|
-
// a function that, on call, looks up the per-device native and
|
|
65
|
-
// forwards the call. No caching — the lookup is cheap (Map.get) and
|
|
66
|
-
// re-doing it per call lets devices come/go without stale refs.
|
|
67
|
-
const dispatcher = new Proxy(
|
|
68
|
-
{},
|
|
69
|
-
{
|
|
70
|
-
get(_target, prop: string | symbol) {
|
|
71
|
-
if (typeof prop !== 'string') return undefined
|
|
72
|
-
return async (input: { deviceId?: number } & Record<string, unknown>) => {
|
|
73
|
-
const deviceId = input?.deviceId
|
|
74
|
-
if (typeof deviceId !== 'number') {
|
|
75
|
-
throw new TRPCError({
|
|
76
|
-
code: 'BAD_REQUEST',
|
|
77
|
-
message: `${String(capName)}.${prop}: input must carry numeric "deviceId"`,
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
|
-
const native = registry.getNativeProvider<Record<string, (i: unknown) => unknown>>(
|
|
81
|
-
capName,
|
|
82
|
-
deviceId,
|
|
83
|
-
)
|
|
84
|
-
if (!native) {
|
|
85
|
-
throw new TRPCError({
|
|
86
|
-
code: 'PRECONDITION_FAILED',
|
|
87
|
-
message: `Capability "${String(capName)}" not registered for device ${deviceId}`,
|
|
88
|
-
})
|
|
89
|
-
}
|
|
90
|
-
const fn = native[prop]
|
|
91
|
-
if (typeof fn !== 'function') {
|
|
92
|
-
throw new TRPCError({
|
|
93
|
-
code: 'NOT_IMPLEMENTED',
|
|
94
|
-
message: `Capability "${String(capName)}" provider for device ${deviceId} does not implement "${prop}"`,
|
|
95
|
-
})
|
|
96
|
-
}
|
|
97
|
-
const result = await fn.call(native, input)
|
|
98
|
-
// Device-property-wiring overlay (read-time): only `getStatus`, and only
|
|
99
|
-
// when the device has links for this cap (resolveLinkedStatus returns
|
|
100
|
-
// null otherwise → base result untouched). One in-process singleton hop.
|
|
101
|
-
if (prop === 'getStatus') {
|
|
102
|
-
const deviceManager = registry.getSingleton<{
|
|
103
|
-
resolveLinkedStatus?: (i: {
|
|
104
|
-
deviceId: number
|
|
105
|
-
cap: string
|
|
106
|
-
baseStatus: unknown
|
|
107
|
-
}) => Promise<Record<string, unknown> | null>
|
|
108
|
-
}>('device-manager')
|
|
109
|
-
const overlaid = await deviceManager?.resolveLinkedStatus?.({
|
|
110
|
-
deviceId,
|
|
111
|
-
cap: String(capName),
|
|
112
|
-
baseStatus: result,
|
|
113
|
-
})
|
|
114
|
-
if (overlaid != null) return overlaid
|
|
115
|
-
}
|
|
116
|
-
return result
|
|
117
|
-
}
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
)
|
|
121
|
-
return dispatcher as unknown as CapabilityProviderMap[K]
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// ── Method-on-provider callable types ────────────────────────────────
|
|
125
|
-
|
|
126
|
-
/** A key on T whose value is a function with array / promise-array return. */
|
|
127
|
-
type ArrayReturningMethodKey<T> = {
|
|
128
|
-
[K in keyof T]: T[K] extends (
|
|
129
|
-
...args: infer _A
|
|
130
|
-
) => readonly unknown[] | Promise<readonly unknown[]>
|
|
131
|
-
? K
|
|
132
|
-
: never
|
|
133
|
-
}[keyof T]
|
|
134
|
-
|
|
135
|
-
/** A key on T whose value is a function returning boolean / promise-boolean. */
|
|
136
|
-
type BoolReturningMethodKey<T> = {
|
|
137
|
-
[K in keyof T]: T[K] extends (...args: infer _A) => boolean | Promise<boolean> ? K : never
|
|
138
|
-
}[keyof T]
|
|
139
|
-
|
|
140
|
-
/**
|
|
141
|
-
* Build a method that fan-outs a call to every provider in a collection
|
|
142
|
-
* and concatenates their array results. Useful for contribution-style
|
|
143
|
-
* caps where each provider adds to a shared pool.
|
|
144
|
-
*/
|
|
145
|
-
export function concatCollection<T extends object, K extends ArrayReturningMethodKey<T>>(
|
|
146
|
-
providers: readonly T[],
|
|
147
|
-
method: K,
|
|
148
|
-
): T[K] extends (...args: infer A) => readonly (infer R)[] | Promise<readonly (infer R)[]>
|
|
149
|
-
? (...args: A) => Promise<readonly R[]>
|
|
150
|
-
: never {
|
|
151
|
-
const wrapper = async (...args: unknown[]): Promise<readonly unknown[]> => {
|
|
152
|
-
const results = await Promise.all(
|
|
153
|
-
providers.map(async (p): Promise<readonly unknown[]> => {
|
|
154
|
-
const member = Reflect.get(p, method)
|
|
155
|
-
if (typeof member !== 'function') return []
|
|
156
|
-
// `Reflect.apply` returns `any`; funnel through unknown.
|
|
157
|
-
const out: unknown = await Reflect.apply(member, p, args)
|
|
158
|
-
if (!Array.isArray(out)) return []
|
|
159
|
-
const arr: readonly unknown[] = out
|
|
160
|
-
return arr
|
|
161
|
-
}),
|
|
162
|
-
)
|
|
163
|
-
return results.flat()
|
|
164
|
-
}
|
|
165
|
-
// Type-level bridge: the runtime wrapper signature (unknown → Promise<unknown[]>)
|
|
166
|
-
// matches the declared generic conditional return; TypeScript's
|
|
167
|
-
// conditional types can't be narrowed inside a function body, so this
|
|
168
|
-
// boundary assertion is required.
|
|
169
|
-
return wrapper as T[K] extends (
|
|
170
|
-
...args: infer A
|
|
171
|
-
) => readonly (infer R)[] | Promise<readonly (infer R)[]>
|
|
172
|
-
? (...args: A) => Promise<readonly R[]>
|
|
173
|
-
: never
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Iterate a collection asking each provider "do you handle this?" via a
|
|
178
|
-
* probe method, then call an action on the first one that answers yes.
|
|
179
|
-
* Each provider is tried in registration order; errors are swallowed and
|
|
180
|
-
* the next provider is attempted. Returns null if no provider matches or
|
|
181
|
-
* if every matching provider's action throws/returns null.
|
|
182
|
-
*/
|
|
183
|
-
export function firstSupported<
|
|
184
|
-
T extends object,
|
|
185
|
-
Probe extends BoolReturningMethodKey<T>,
|
|
186
|
-
Action extends keyof T,
|
|
187
|
-
>(
|
|
188
|
-
providers: readonly T[],
|
|
189
|
-
probe: Probe,
|
|
190
|
-
action: Action,
|
|
191
|
-
): T[Action] extends (...args: infer A) => infer R
|
|
192
|
-
? (...args: A) => Promise<Awaited<R> | null>
|
|
193
|
-
: never {
|
|
194
|
-
const wrapper = async (...args: unknown[]): Promise<unknown> => {
|
|
195
|
-
const [first] = args
|
|
196
|
-
for (const p of providers) {
|
|
197
|
-
try {
|
|
198
|
-
const probeMember = Reflect.get(p, probe)
|
|
199
|
-
if (typeof probeMember !== 'function') continue
|
|
200
|
-
const supported: unknown = await Reflect.apply(probeMember, p, [first])
|
|
201
|
-
if (supported !== true) continue
|
|
202
|
-
const actionMember = Reflect.get(p, action)
|
|
203
|
-
if (typeof actionMember !== 'function') continue
|
|
204
|
-
const result: unknown = await Reflect.apply(actionMember, p, args)
|
|
205
|
-
if (result !== null && result !== undefined) return result
|
|
206
|
-
} catch {
|
|
207
|
-
// try next provider
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
return null
|
|
211
|
-
}
|
|
212
|
-
// Type-level bridge — see concatCollection for the same pattern.
|
|
213
|
-
return wrapper as T[Action] extends (...args: infer A) => infer R
|
|
214
|
-
? (...args: A) => Promise<Awaited<R> | null>
|
|
215
|
-
: never
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
/**
|
|
219
|
-
* Convenience for collection caps that want a "logical OR of probes"
|
|
220
|
-
* (e.g. `supportsDevice` across every snapshot-provider).
|
|
221
|
-
*/
|
|
222
|
-
export function anySupports<T extends object, K extends BoolReturningMethodKey<T>>(
|
|
223
|
-
providers: readonly T[],
|
|
224
|
-
probe: K,
|
|
225
|
-
): T[K] extends (...args: infer A) => boolean | Promise<boolean>
|
|
226
|
-
? (...args: A) => Promise<boolean>
|
|
227
|
-
: never {
|
|
228
|
-
const wrapper = async (...args: unknown[]): Promise<boolean> => {
|
|
229
|
-
for (const p of providers) {
|
|
230
|
-
const member = Reflect.get(p, probe)
|
|
231
|
-
if (typeof member !== 'function') continue
|
|
232
|
-
try {
|
|
233
|
-
const result: unknown = await Reflect.apply(member, p, args)
|
|
234
|
-
if (result === true) return true
|
|
235
|
-
} catch {
|
|
236
|
-
/* next */
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
return false
|
|
240
|
-
}
|
|
241
|
-
// Type-level bridge — see concatCollection for the same pattern.
|
|
242
|
-
return wrapper as T[K] extends (...args: infer A) => boolean | Promise<boolean>
|
|
243
|
-
? (...args: A) => Promise<boolean>
|
|
244
|
-
: never
|
|
245
|
-
}
|
|
@@ -1,171 +0,0 @@
|
|
|
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>([
|
|
42
|
-
'no-provider',
|
|
43
|
-
'node-offline',
|
|
44
|
-
'cap-unknown',
|
|
45
|
-
'transport-failed',
|
|
46
|
-
])
|
|
47
|
-
|
|
48
|
-
/** Narrows a plain string to the `CapRouteError['reason']` union. */
|
|
49
|
-
function isCapRouteReason(r: string): r is CapRouteError['reason'] {
|
|
50
|
-
return KNOWN_REASONS.has(r)
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/** Narrows an `unknown` value to `RejectedRoute` by checking structural shape. */
|
|
54
|
-
function isRejectedRoute(r: unknown): r is RejectedRoute {
|
|
55
|
-
if (typeof r !== 'object' || r === null) return false
|
|
56
|
-
const kind: unknown = Reflect.get(r, 'kind')
|
|
57
|
-
const why: unknown = Reflect.get(r, 'why')
|
|
58
|
-
return typeof kind === 'string' && typeof why === 'string'
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// ---------------------------------------------------------------------------
|
|
62
|
-
// CapRouteError extraction helpers
|
|
63
|
-
// ---------------------------------------------------------------------------
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* Walks the `.cause` chain of an error to find a CapRouteError.
|
|
67
|
-
* Returns the first one found, or null.
|
|
68
|
-
*
|
|
69
|
-
* Detection is dual-mode:
|
|
70
|
-
* 1. `instanceof CapRouteError` — works when the same module is loaded.
|
|
71
|
-
* 2. Duck-type: `name === 'CapRouteError'` + `typeof reason === 'string'`
|
|
72
|
-
* — robust against module-boundary issues (self-contained addons).
|
|
73
|
-
*/
|
|
74
|
-
function extractCapRouteError(err: unknown): CapRouteError | null {
|
|
75
|
-
let current: unknown = err
|
|
76
|
-
for (let depth = 0; depth < 8; depth++) {
|
|
77
|
-
if (current === null || current === undefined) return null
|
|
78
|
-
|
|
79
|
-
if (current instanceof CapRouteError) {
|
|
80
|
-
return current
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// Duck-type fallback: err.name + err.reason field present
|
|
84
|
-
if (typeof current === 'object' && Reflect.get(current, 'name') === 'CapRouteError') {
|
|
85
|
-
const rawReason: unknown = Reflect.get(current, 'reason')
|
|
86
|
-
if (typeof rawReason === 'string') {
|
|
87
|
-
// Runtime safety: reject unrecognised reason strings so the formatter
|
|
88
|
-
// only promotes values it knows are valid CapRouteError reasons.
|
|
89
|
-
if (!isCapRouteReason(rawReason)) {
|
|
90
|
-
// Unrecognised reason — treat as a non-CapRouteError and keep walking
|
|
91
|
-
const cause: unknown = Reflect.get(current, 'cause')
|
|
92
|
-
if (cause === current) return null
|
|
93
|
-
current = cause
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
const reason: CapRouteError['reason'] = rawReason
|
|
97
|
-
|
|
98
|
-
const rawRejected: unknown = Reflect.get(current, 'rejected')
|
|
99
|
-
const rejected: readonly RejectedRoute[] = Array.isArray(rawRejected)
|
|
100
|
-
? rawRejected.filter(isRejectedRoute)
|
|
101
|
-
: []
|
|
102
|
-
|
|
103
|
-
const nodeId: unknown = Reflect.get(current, 'nodeId')
|
|
104
|
-
const message: unknown = Reflect.get(current, 'message')
|
|
105
|
-
const rawCapName: unknown = Reflect.get(current, 'capName')
|
|
106
|
-
const capName: string = typeof rawCapName === 'string' ? rawCapName : '(unknown)'
|
|
107
|
-
|
|
108
|
-
// Build a minimal object with the same shape — enough for the formatter.
|
|
109
|
-
const synthetic = Object.assign(
|
|
110
|
-
new CapRouteError(capName, undefined, {
|
|
111
|
-
reason,
|
|
112
|
-
rejected,
|
|
113
|
-
...(typeof nodeId === 'string' ? { nodeId } : {}),
|
|
114
|
-
}),
|
|
115
|
-
{
|
|
116
|
-
// Override message from the original if available
|
|
117
|
-
message: typeof message === 'string' ? message : '(duck-typed CapRouteError)',
|
|
118
|
-
},
|
|
119
|
-
)
|
|
120
|
-
return synthetic
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
// Walk the cause chain
|
|
125
|
-
if (typeof current !== 'object') return null
|
|
126
|
-
const cause: unknown = Reflect.get(current, 'cause')
|
|
127
|
-
if (cause === current) return null // Guard against circular refs
|
|
128
|
-
current = cause
|
|
129
|
-
}
|
|
130
|
-
return null
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ---------------------------------------------------------------------------
|
|
134
|
-
// Formatter — exported for unit tests
|
|
135
|
-
// ---------------------------------------------------------------------------
|
|
136
|
-
|
|
137
|
-
export interface FormatTrpcErrorOpts {
|
|
138
|
-
readonly error: TRPCError
|
|
139
|
-
readonly shape: DefaultErrorShape
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
/**
|
|
143
|
-
* Augments the default tRPC error shape with CapRouteError diagnostic fields
|
|
144
|
-
* when the thrown error (or any error in its `.cause` chain) is a CapRouteError.
|
|
145
|
-
* Returns the shape unchanged for all other errors.
|
|
146
|
-
*/
|
|
147
|
-
export function formatTrpcError(opts: FormatTrpcErrorOpts): AugmentedErrorShape {
|
|
148
|
-
const { error, shape } = opts
|
|
149
|
-
|
|
150
|
-
// extractCapRouteError already walks the full .cause chain, so a single call
|
|
151
|
-
// starting from `error` covers both `error instanceof CapRouteError` and
|
|
152
|
-
// `error.cause` (and deeper nesting). No second call needed.
|
|
153
|
-
const capRouteError = extractCapRouteError(error)
|
|
154
|
-
if (capRouteError === null) {
|
|
155
|
-
return { ...shape, data: { ...shape.data } }
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
const extraData: CapRouteErrorData = {
|
|
159
|
-
capRouteReason: capRouteError.reason,
|
|
160
|
-
capRouteRejected: capRouteError.rejected,
|
|
161
|
-
...(capRouteError.nodeId !== undefined ? { capRouteNodeId: capRouteError.nodeId } : {}),
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return {
|
|
165
|
-
...shape,
|
|
166
|
-
data: {
|
|
167
|
-
...shape.data,
|
|
168
|
-
...extraData,
|
|
169
|
-
},
|
|
170
|
-
}
|
|
171
|
-
}
|