@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
|
@@ -3,8 +3,12 @@ import superjson from 'superjson'
|
|
|
3
3
|
import { METHOD_ACCESS_MAP } from '@camstack/types'
|
|
4
4
|
import type { TrpcContext } from './trpc.context'
|
|
5
5
|
import { checkScopeAccess } from './scope-access.js'
|
|
6
|
+
import { formatTrpcError } from './cap-route-error-formatter.js'
|
|
6
7
|
|
|
7
|
-
const t = initTRPC.context<TrpcContext>().create({
|
|
8
|
+
const t = initTRPC.context<TrpcContext>().create({
|
|
9
|
+
transformer: superjson,
|
|
10
|
+
errorFormatter: formatTrpcError,
|
|
11
|
+
})
|
|
8
12
|
|
|
9
13
|
// ---------------------------------------------------------------------------
|
|
10
14
|
// Async-generator subscription helpers (tRPC v11 — replaces deprecated observable)
|
|
@@ -11,7 +11,7 @@ import { trpcRouter } from './trpc.middleware'
|
|
|
11
11
|
// streaming → `streamingManagement` cap, events → `eventQuery` cap,
|
|
12
12
|
// logs → kept manual, live → kept manual, processes → `processMgmt`
|
|
13
13
|
// cap, agents → `nodes` cap, sessions → `session` cap, trackMedia /
|
|
14
|
-
// trackTrail → caps,
|
|
14
|
+
// trackTrail → caps, network →
|
|
15
15
|
// `networkQuality` cap, addons → `addons` cap, bridgePipeline removed
|
|
16
16
|
// (legacy), detection → `detectionConfig` cap, capabilities → kept
|
|
17
17
|
// manual, update → addons cap, addonPages → cap, notification →
|
|
@@ -20,7 +20,7 @@ import { trpcRouter } from './trpc.middleware'
|
|
|
20
20
|
// `pipelineExecutor` cap, pipeline → `pipelineConfig` cap,
|
|
21
21
|
// systemEvents → kept manual.
|
|
22
22
|
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
23
|
-
import type { InferProvider } from '@camstack/types'
|
|
23
|
+
import type { InferProvider, BrokerConsumerAttribution } from '@camstack/types'
|
|
24
24
|
import {
|
|
25
25
|
pipelineExecutorCapability,
|
|
26
26
|
pipelineRunnerCapability,
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
platformProbeCapability,
|
|
31
31
|
decoderCapability,
|
|
32
32
|
localNetworkCapability,
|
|
33
|
+
webrtcSessionCapability,
|
|
33
34
|
} from '@camstack/types'
|
|
34
35
|
import {
|
|
35
36
|
// The auto-mount covers ~75 caps. The handful re-imported below back
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
createCapRouter_integrations,
|
|
52
53
|
createCapRouter_nodes,
|
|
53
54
|
createCapRouter_addons,
|
|
55
|
+
createCapRouter_webrtcSession,
|
|
54
56
|
} from './generated-cap-routers'
|
|
55
57
|
import { mountAllCaps } from './generated-cap-mounts.js'
|
|
56
58
|
import {
|
|
@@ -74,6 +76,8 @@ import { createCapabilitiesRouter } from '../core/capabilities.router.js'
|
|
|
74
76
|
import { createStreamProbeRouter } from '../core/stream-probe.router.js'
|
|
75
77
|
import { createHwAccelRouter } from '../core/hwaccel.router.js'
|
|
76
78
|
import { requireSingleton, firstSupported, anySupports } from './cap-mount-helpers.js'
|
|
79
|
+
import { extractUserAgent } from './client-ip.js'
|
|
80
|
+
import type { TrpcContext } from './trpc.context.js'
|
|
77
81
|
import type { AuthService } from '../../core/auth/auth.service'
|
|
78
82
|
import type { ConfigService } from '../../core/config/config.service'
|
|
79
83
|
import type { FeatureService } from '../../core/feature/feature.service'
|
|
@@ -108,6 +112,63 @@ export interface RouterServices {
|
|
|
108
112
|
streamProbe: StreamProbeService | null
|
|
109
113
|
}
|
|
110
114
|
|
|
115
|
+
type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
|
|
116
|
+
type CreateSessionInput = Parameters<WebrtcSessionProvider['createSession']>[0]
|
|
117
|
+
type HandleOfferInput = Parameters<WebrtcSessionProvider['handleOffer']>[0]
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge the server-read User-Agent into a signaling call's
|
|
121
|
+
* `consumerAttribution`, building a NEW input object (immutable — never
|
|
122
|
+
* mutates the caller's input). When `userAgent` is null (mesh-originated
|
|
123
|
+
* call, or a client that omits the header) the input passes through
|
|
124
|
+
* unchanged. Any client-supplied `userAgent` is OVERWRITTEN — the hub
|
|
125
|
+
* trusts only the request context, never the client.
|
|
126
|
+
*/
|
|
127
|
+
export function enrichInputWithUserAgent<TInput extends { consumerAttribution?: BrokerConsumerAttribution }>(
|
|
128
|
+
input: TInput,
|
|
129
|
+
userAgent: string | null,
|
|
130
|
+
): TInput {
|
|
131
|
+
if (userAgent === null) return input
|
|
132
|
+
const base: BrokerConsumerAttribution = input.consumerAttribution ?? { kind: 'webrtc-browser' }
|
|
133
|
+
return {
|
|
134
|
+
...input,
|
|
135
|
+
consumerAttribution: { ...base, userAgent },
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Relay-only forcing for remote viewers is DISABLED (2026-05-26).
|
|
141
|
+
*
|
|
142
|
+
* It was meant to give CGNAT/4G viewers a clean relay↔relay path, but werift's
|
|
143
|
+
* TURN media-forward is unreliable between two real TURN servers (relay↔relay
|
|
144
|
+
* connects yet media never arrives → connected-but-black), and forcing relay
|
|
145
|
+
* ALSO kills the direct LAN/Tailscale host pair — which carries full native
|
|
146
|
+
* quality with no relay. We now offer ALL candidates (host incl. the hub's
|
|
147
|
+
* advertised Tailscale address, srflx, relay) and let ICE nominate the best
|
|
148
|
+
* reachable pair: direct when possible, relay only as a fallback. The
|
|
149
|
+
* `relayOnly` cap field + broker support remain for when relay media-forward
|
|
150
|
+
* is fixed.
|
|
151
|
+
*
|
|
152
|
+
* The wrapper additionally enriches the `createSession` / `handleOffer`
|
|
153
|
+
* subscriber attribution with the originating client's User-Agent, read
|
|
154
|
+
* from the tRPC request context (browser sessions). All OTHER methods
|
|
155
|
+
* delegate straight through — auth, the remote-proxy factory and every
|
|
156
|
+
* signaling behaviour are untouched.
|
|
157
|
+
*/
|
|
158
|
+
export function wrapWebrtcSessionProviderWithRelay(
|
|
159
|
+
provider: WebrtcSessionProvider,
|
|
160
|
+
ctx: TrpcContext,
|
|
161
|
+
): WebrtcSessionProvider {
|
|
162
|
+
const userAgent = extractUserAgent(ctx.req)
|
|
163
|
+
return {
|
|
164
|
+
...provider,
|
|
165
|
+
createSession: (input: CreateSessionInput) =>
|
|
166
|
+
provider.createSession(enrichInputWithUserAgent(input, userAgent)),
|
|
167
|
+
handleOffer: (input: HandleOfferInput) =>
|
|
168
|
+
provider.handleOffer(enrichInputWithUserAgent(input, userAgent)),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
111
172
|
/**
|
|
112
173
|
* Build the AppRouter. Mounts every codegen'd cap router via the auto-
|
|
113
174
|
* mount entrypoint and overrides the handful that need service-backed
|
|
@@ -166,7 +227,7 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
166
227
|
(ctx) => buildToastProvider(services.toastService, ctx),
|
|
167
228
|
),
|
|
168
229
|
integrations: createCapRouter_integrations(
|
|
169
|
-
(_ctx) => buildIntegrationsProvider(services.addonRegistry, services.eventBus, services.loggingService),
|
|
230
|
+
(_ctx) => buildIntegrationsProvider(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry),
|
|
170
231
|
),
|
|
171
232
|
nodes: createCapRouter_nodes(
|
|
172
233
|
(_ctx) => buildNodesProvider(
|
|
@@ -183,6 +244,7 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
183
244
|
services.moleculer,
|
|
184
245
|
services.configService,
|
|
185
246
|
ctx,
|
|
247
|
+
services.eventBus,
|
|
186
248
|
),
|
|
187
249
|
),
|
|
188
250
|
|
|
@@ -253,6 +315,25 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
253
315
|
},
|
|
254
316
|
),
|
|
255
317
|
|
|
318
|
+
// ── Cap override: server-detected remote → relay-only ────────────
|
|
319
|
+
// The broker (a forked addon) can't see the HTTP request, so it
|
|
320
|
+
// can't tell a LAN viewer from a remote one. We override only the
|
|
321
|
+
// `getProvider` accessor to return a per-request provider whose
|
|
322
|
+
// `createSession` carries a server-computed `relayOnly` flag derived
|
|
323
|
+
// from the client IP in `ctx.req`. Remote (CGNAT/4G) viewers force
|
|
324
|
+
// TURN-relay-only ICE; LAN viewers keep the direct host/srflx path.
|
|
325
|
+
// All other methods delegate straight through, and the cross-node
|
|
326
|
+
// remote-proxy routing is preserved (forked/agent-hosted brokers).
|
|
327
|
+
webrtcSession: createCapRouter_webrtcSession(
|
|
328
|
+
(ctx) => {
|
|
329
|
+
const provider = services.capabilityRegistry
|
|
330
|
+
?.getSingleton<WebrtcSessionProvider>('webrtc-session') ?? null
|
|
331
|
+
return provider ? wrapWebrtcSessionProviderWithRelay(provider, ctx) : null
|
|
332
|
+
},
|
|
333
|
+
(capName, nodeId) =>
|
|
334
|
+
services.moleculer.createCapabilityProxy(capName, nodeId) as WebrtcSessionProvider | null,
|
|
335
|
+
),
|
|
336
|
+
|
|
256
337
|
// NOT MOUNTED — legacy provider shapes (positional args / sync
|
|
257
338
|
// returns) that don't match the codegen routers' {input}-object +
|
|
258
339
|
// Promise<T> contract. Tracked by `LEGACY_SHAPE_SKIP` in
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { planIntegrationIdBackfill, planDeleteTimeStamps, runIntegrationIdBackfill } from '../integration-id-backfill'
|
|
3
|
+
|
|
4
|
+
describe('planDeleteTimeStamps', () => {
|
|
5
|
+
it('claims an untagged top-level device of the deleted integration\'s single-integration addon', () => {
|
|
6
|
+
const stamps = planDeleteTimeStamps(
|
|
7
|
+
'int_rtsp',
|
|
8
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
|
|
9
|
+
[
|
|
10
|
+
{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
11
|
+
{ id: 6, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
12
|
+
],
|
|
13
|
+
)
|
|
14
|
+
expect(stamps).toEqual([
|
|
15
|
+
{ deviceId: 4, integrationId: 'int_rtsp' },
|
|
16
|
+
{ deviceId: 6, integrationId: 'int_rtsp' },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns no stamps for a multi-integration addon (ambiguous — never auto-claim)', () => {
|
|
21
|
+
const stamps = planDeleteTimeStamps(
|
|
22
|
+
'int_a',
|
|
23
|
+
[{ id: 'int_a', addonId: 'provider-ha' }, { id: 'int_b', addonId: 'provider-ha' }],
|
|
24
|
+
[{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
|
|
25
|
+
)
|
|
26
|
+
expect(stamps).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('only returns stamps for the integration being deleted, not siblings of other addons', () => {
|
|
30
|
+
const stamps = planDeleteTimeStamps(
|
|
31
|
+
'int_rtsp',
|
|
32
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }, { id: 'int_onvif', addonId: 'provider-onvif' }],
|
|
33
|
+
[
|
|
34
|
+
{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
35
|
+
{ id: 5, addonId: 'provider-onvif', parentDeviceId: null },
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
expect(stamps).toEqual([{ deviceId: 4, integrationId: 'int_rtsp' }])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('skips devices already tagged with the deleted integration', () => {
|
|
42
|
+
const stamps = planDeleteTimeStamps(
|
|
43
|
+
'int_rtsp',
|
|
44
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
|
|
45
|
+
[{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_rtsp' }],
|
|
46
|
+
)
|
|
47
|
+
expect(stamps).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('planIntegrationIdBackfill', () => {
|
|
52
|
+
it('stamps a top-level untagged device whose addon has exactly one integration', () => {
|
|
53
|
+
const stamps = planIntegrationIdBackfill(
|
|
54
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
55
|
+
[{ id: 10, addonId: 'provider-rtsp', parentDeviceId: null }],
|
|
56
|
+
)
|
|
57
|
+
expect(stamps).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('skips devices whose addon hosts multiple integrations (ambiguous)', () => {
|
|
61
|
+
const stamps = planIntegrationIdBackfill(
|
|
62
|
+
[{ id: 'int_a', addonId: 'provider-ha' }, { id: 'int_b', addonId: 'provider-ha' }],
|
|
63
|
+
[{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
|
|
64
|
+
)
|
|
65
|
+
expect(stamps).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips already-tagged devices and child devices', () => {
|
|
69
|
+
const stamps = planIntegrationIdBackfill(
|
|
70
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
71
|
+
[
|
|
72
|
+
{ id: 12, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_1' },
|
|
73
|
+
{ id: 13, addonId: 'provider-rtsp', parentDeviceId: 12 },
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
expect(stamps).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips devices whose addon has no integration', () => {
|
|
80
|
+
const stamps = planIntegrationIdBackfill(
|
|
81
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
82
|
+
[{ id: 14, addonId: 'provider-onvif', parentDeviceId: null }],
|
|
83
|
+
)
|
|
84
|
+
expect(stamps).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('runIntegrationIdBackfill', () => {
|
|
89
|
+
it('applies stamps and reports the count, skipping failures', async () => {
|
|
90
|
+
const stamped: Array<{ deviceId: number; integrationId: string }> = []
|
|
91
|
+
const result = await runIntegrationIdBackfill({
|
|
92
|
+
listIntegrations: async () => [{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
93
|
+
listDevices: async () => [
|
|
94
|
+
{ id: 10, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
95
|
+
{ id: 11, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
96
|
+
],
|
|
97
|
+
setIntegrationId: async (deviceId, integrationId) => {
|
|
98
|
+
if (deviceId === 11) throw new Error('boom')
|
|
99
|
+
stamped.push({ deviceId, integrationId })
|
|
100
|
+
},
|
|
101
|
+
logger: { info: () => {}, warn: () => {} },
|
|
102
|
+
})
|
|
103
|
+
expect(result).toEqual({ stamped: 1 })
|
|
104
|
+
expect(stamped).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does nothing when there is nothing to stamp', async () => {
|
|
108
|
+
const result = await runIntegrationIdBackfill({
|
|
109
|
+
listIntegrations: async () => [],
|
|
110
|
+
listDevices: async () => [],
|
|
111
|
+
setIntegrationId: async () => { throw new Error('should not be called') },
|
|
112
|
+
logger: { info: () => {}, warn: () => {} },
|
|
113
|
+
})
|
|
114
|
+
expect(result).toEqual({ stamped: 0 })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time integration-id backfill for devices created before the
|
|
3
|
+
* device-manager forwarder started stamping `integrationId` (camera
|
|
4
|
+
* providers). Maps each addon that hosts EXACTLY ONE integration to that
|
|
5
|
+
* integration id, then stamps top-level untagged devices of those addons.
|
|
6
|
+
* Multi-instance addons (e.g. Home Assistant with several brokers) are
|
|
7
|
+
* ambiguous on `addonId` alone and are skipped — they stamp going forward.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface BackfillIntegration {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly addonId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BackfillDevice {
|
|
16
|
+
readonly id: number
|
|
17
|
+
readonly addonId: string
|
|
18
|
+
readonly parentDeviceId: number | null
|
|
19
|
+
readonly integrationId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BackfillStamp {
|
|
23
|
+
readonly deviceId: number
|
|
24
|
+
readonly integrationId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planIntegrationIdBackfill(
|
|
28
|
+
integrations: readonly BackfillIntegration[],
|
|
29
|
+
devices: readonly BackfillDevice[],
|
|
30
|
+
): readonly BackfillStamp[] {
|
|
31
|
+
const singleByAddon = new Map<string, string>()
|
|
32
|
+
const ambiguous = new Set<string>()
|
|
33
|
+
for (const integration of integrations) {
|
|
34
|
+
if (ambiguous.has(integration.addonId)) continue
|
|
35
|
+
if (singleByAddon.has(integration.addonId)) {
|
|
36
|
+
singleByAddon.delete(integration.addonId)
|
|
37
|
+
ambiguous.add(integration.addonId)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
singleByAddon.set(integration.addonId, integration.id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stamps: BackfillStamp[] = []
|
|
44
|
+
for (const device of devices) {
|
|
45
|
+
if (device.parentDeviceId !== null) continue
|
|
46
|
+
if (device.integrationId !== undefined && device.integrationId !== '') continue
|
|
47
|
+
const integrationId = singleByAddon.get(device.addonId)
|
|
48
|
+
if (integrationId === undefined) continue
|
|
49
|
+
stamps.push({ deviceId: device.id, integrationId })
|
|
50
|
+
}
|
|
51
|
+
return stamps
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stamps to apply at integration-DELETE time so a cascade removes legacy
|
|
56
|
+
* un-tagged devices too. The boot backfill only runs on startup; a device
|
|
57
|
+
* created before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
|
|
58
|
+
* keeps no `integrationId`, so once its integration is deleted it would orphan
|
|
59
|
+
* forever — `removeByIntegration` matches on `integrationId` and finds nothing.
|
|
60
|
+
*
|
|
61
|
+
* Run this in the delete handler BEFORE deleting the integration record (while
|
|
62
|
+
* it is still present in `integrations`), then stamp the returned devices and
|
|
63
|
+
* let `removeByIntegration` cascade them. Reuses the boot backfill's safety
|
|
64
|
+
* rule (only addons hosting exactly ONE integration are unambiguous) and
|
|
65
|
+
* filters to the integration being deleted so siblings are never touched.
|
|
66
|
+
*/
|
|
67
|
+
export function planDeleteTimeStamps(
|
|
68
|
+
integrationId: string,
|
|
69
|
+
integrations: readonly BackfillIntegration[],
|
|
70
|
+
devices: readonly BackfillDevice[],
|
|
71
|
+
): readonly BackfillStamp[] {
|
|
72
|
+
return planIntegrationIdBackfill(integrations, devices).filter(
|
|
73
|
+
(stamp) => stamp.integrationId === integrationId,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackfillLogger {
|
|
78
|
+
readonly info: (message: string, meta?: Record<string, unknown>) => void
|
|
79
|
+
readonly warn: (message: string, meta?: Record<string, unknown>) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IntegrationIdBackfillDeps {
|
|
83
|
+
readonly listIntegrations: () => Promise<readonly BackfillIntegration[]>
|
|
84
|
+
readonly listDevices: () => Promise<readonly BackfillDevice[]>
|
|
85
|
+
readonly setIntegrationId: (deviceId: number, integrationId: string) => Promise<void>
|
|
86
|
+
readonly logger: BackfillLogger
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runIntegrationIdBackfill(
|
|
90
|
+
deps: IntegrationIdBackfillDeps,
|
|
91
|
+
): Promise<{ stamped: number }> {
|
|
92
|
+
const [integrations, devices] = await Promise.all([deps.listIntegrations(), deps.listDevices()])
|
|
93
|
+
const stamps = planIntegrationIdBackfill(integrations, devices)
|
|
94
|
+
let stamped = 0
|
|
95
|
+
for (const stamp of stamps) {
|
|
96
|
+
try {
|
|
97
|
+
await deps.setIntegrationId(stamp.deviceId, stamp.integrationId)
|
|
98
|
+
stamped++
|
|
99
|
+
} catch (err) {
|
|
100
|
+
deps.logger.warn('integrationId backfill: stamp failed', {
|
|
101
|
+
deviceId: stamp.deviceId,
|
|
102
|
+
integrationId: stamp.integrationId,
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stamped > 0) deps.logger.info('integrationId backfill complete', { stamped })
|
|
108
|
+
return { stamped }
|
|
109
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { overlayDeclaration } from '../addon-row-manifest.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: after the HA broker rework, redeploying provider-homeassistant
|
|
7
|
+
* (broker + device-adoption) left the integration picker without Home
|
|
8
|
+
* Assistant. `loadNewAddons` refreshed `entry.declaration` to the new caps but
|
|
9
|
+
* `listAddons` built its row manifest from the STALE `entry.addon.manifest`
|
|
10
|
+
* (still [broker, ha-discovery]), so `getAvailableTypes` filtered HA out.
|
|
11
|
+
*/
|
|
12
|
+
interface TestManifest {
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
icon?: string
|
|
16
|
+
brokerKind?: string
|
|
17
|
+
capabilities?: ReadonlyArray<{ name: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('overlayDeclaration — listAddons row manifest freshness', () => {
|
|
21
|
+
const base = { id: 'provider-homeassistant', name: 'Home Assistant' }
|
|
22
|
+
|
|
23
|
+
it('prefers the fresh declaration capabilities over the stale instance manifest', () => {
|
|
24
|
+
const instanceManifest: TestManifest = {
|
|
25
|
+
...base,
|
|
26
|
+
capabilities: [{ name: 'broker' }, { name: 'ha-discovery' }],
|
|
27
|
+
}
|
|
28
|
+
const declaration: Partial<TestManifest> = {
|
|
29
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
30
|
+
brokerKind: 'home-assistant',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
34
|
+
|
|
35
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }, { name: 'device-adoption' }])
|
|
36
|
+
expect(merged.brokerKind).toBe('home-assistant')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the instance manifest when no fresh declaration exists', () => {
|
|
40
|
+
const instanceManifest: TestManifest = { ...base, capabilities: [{ name: 'broker' }] }
|
|
41
|
+
|
|
42
|
+
const merged = overlayDeclaration(instanceManifest, undefined)
|
|
43
|
+
|
|
44
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('fills gaps from the instance manifest for keys the declaration omits', () => {
|
|
48
|
+
const instanceManifest: TestManifest = {
|
|
49
|
+
...base,
|
|
50
|
+
icon: 'assets/icon.svg',
|
|
51
|
+
capabilities: [{ name: 'broker' }],
|
|
52
|
+
}
|
|
53
|
+
const declaration: Partial<TestManifest> = {
|
|
54
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
58
|
+
|
|
59
|
+
expect(merged.icon).toBe('assets/icon.svg')
|
|
60
|
+
expect(merged.capabilities).toContainEqual({ name: 'device-adoption' })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AddonCallGateway` — the SINGLE hub-side router for addon-LEVEL calls (the
|
|
3
|
+
* surfaces the removed per-addon Moleculer broker used to carry: routes,
|
|
4
|
+
* custom-actions, settings — see `AddonCallTarget`).
|
|
5
|
+
*
|
|
6
|
+
* Why this exists: that routing decision (is the addon running in-process on
|
|
7
|
+
* the hub, as a forked hub-local CHILD reachable over UDS, or on a REMOTE
|
|
8
|
+
* agent over Moleculer?) used to be duplicated per surface — routes/custom in
|
|
9
|
+
* `addon-registry.service.ts`, settings in `addon-settings-provider.ts`. When
|
|
10
|
+
* the UDS migration ported routes+custom to `callAddonOnChild`, settings was
|
|
11
|
+
* left on the dead `<addonId>.settings.<method>` Moleculer path because nothing
|
|
12
|
+
* centralised "dispatch an addon-level call to wherever the addon runs". Every
|
|
13
|
+
* forked hub-local addon's settings panel silently went empty for months.
|
|
14
|
+
*
|
|
15
|
+
* Now every addon-level surface routes through `callForked` here. Combined with
|
|
16
|
+
* the exhaustive `AddonCallTarget` union (a `never`-checked dispatch in
|
|
17
|
+
* `createChildAddonCallDispatch`), a future surface can't be half-wired without
|
|
18
|
+
* a compile error — the class of "missed link" is closed.
|
|
19
|
+
*/
|
|
20
|
+
import type { AddonCallInput, LocalChildRegistry } from '@camstack/kernel'
|
|
21
|
+
|
|
22
|
+
/** An addon-level call minus its `addonId` — the gateway merges that in. */
|
|
23
|
+
export type AddonCallSurface = Omit<AddonCallInput, 'addonId'>
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Minimal structural view of the Moleculer broker (remote-agent leg) — typed
|
|
27
|
+
* locally because the lint type-checker can't resolve Moleculer's
|
|
28
|
+
* `ServiceBroker` (mirrors the pattern in `addon-settings-provider.ts`).
|
|
29
|
+
*/
|
|
30
|
+
export interface AddonCallBroker {
|
|
31
|
+
readonly registry: unknown
|
|
32
|
+
call(
|
|
33
|
+
action: string,
|
|
34
|
+
params: Record<string, unknown>,
|
|
35
|
+
opts?: { readonly nodeID?: string; readonly timeout?: number },
|
|
36
|
+
): Promise<unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Where an addon physically runs — the routing decision, made in one place. */
|
|
40
|
+
export type AddonCallDestination =
|
|
41
|
+
| { readonly kind: 'in-process' }
|
|
42
|
+
| { readonly kind: 'hub-local-child' }
|
|
43
|
+
| { readonly kind: 'remote-agent'; readonly baseNodeId: string }
|
|
44
|
+
|
|
45
|
+
export interface AddonCallGatewayDeps {
|
|
46
|
+
/** This hub's node id (for the local short-circuit). */
|
|
47
|
+
readonly hubNodeId: string
|
|
48
|
+
/** Which node hosts a given addon ('hub' / hubNodeId = on this hub). */
|
|
49
|
+
readonly resolveNode: (addonId: string) => string
|
|
50
|
+
/** UDS registry of forked hub-local children (null before it's wired). */
|
|
51
|
+
readonly getChildRegistry: () => LocalChildRegistry | null
|
|
52
|
+
/** Moleculer broker for the remote-agent leg. */
|
|
53
|
+
readonly broker: AddonCallBroker
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const REMOTE_TIMEOUT_MS = 10_000
|
|
57
|
+
|
|
58
|
+
export class AddonCallGateway {
|
|
59
|
+
constructor(private readonly deps: AddonCallGatewayDeps) {}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Classify where an addon runs. `nodeId: 'hub'` from a caller means "the hub
|
|
63
|
+
* cluster", NOT "force in-process" — a forked hub-local addon is still a
|
|
64
|
+
* `hub-local-child` (UDS), never the in-process path (which is only the
|
|
65
|
+
* `@camstack/core` builtins that have no forked runner).
|
|
66
|
+
*/
|
|
67
|
+
classify(addonId: string, explicitNodeId?: string): AddonCallDestination {
|
|
68
|
+
const resolved = this.deps.resolveNode(addonId)
|
|
69
|
+
const onHub = resolved === 'hub' || resolved === this.deps.hubNodeId
|
|
70
|
+
if (onHub) {
|
|
71
|
+
const childRegistry = this.deps.getChildRegistry()
|
|
72
|
+
if (childRegistry !== null && childRegistry.isChildKnown(addonId)) {
|
|
73
|
+
return { kind: 'hub-local-child' }
|
|
74
|
+
}
|
|
75
|
+
return { kind: 'in-process' }
|
|
76
|
+
}
|
|
77
|
+
const onThisHub = explicitNodeId === 'hub' || explicitNodeId === this.deps.hubNodeId
|
|
78
|
+
const baseNodeId = explicitNodeId && !onThisHub ? explicitNodeId : resolved
|
|
79
|
+
return { kind: 'remote-agent', baseNodeId }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** True when the addon is an in-process hub builtin (caller invokes directly). */
|
|
83
|
+
isInProcess(addonId: string, explicitNodeId?: string): boolean {
|
|
84
|
+
return this.classify(addonId, explicitNodeId).kind === 'in-process'
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Dispatch a forked addon-level call to wherever the addon runs:
|
|
89
|
+
* - `hub-local-child` → UDS `LocalChildRegistry.callAddonOnChild`
|
|
90
|
+
* - `remote-agent` → Moleculer `broker.call`
|
|
91
|
+
* Throws for `in-process` — that addon has no forked surface, so the caller
|
|
92
|
+
* must invoke the in-process instance directly (the invocation is
|
|
93
|
+
* surface-specific; only the ROUTING is centralised here).
|
|
94
|
+
*/
|
|
95
|
+
async callForked(addonId: string, input: AddonCallSurface, explicitNodeId?: string): Promise<unknown> {
|
|
96
|
+
const dest = this.classify(addonId, explicitNodeId)
|
|
97
|
+
const fullInput: AddonCallInput = { ...input, addonId }
|
|
98
|
+
switch (dest.kind) {
|
|
99
|
+
case 'hub-local-child': {
|
|
100
|
+
const childRegistry = this.deps.getChildRegistry()
|
|
101
|
+
if (childRegistry === null) {
|
|
102
|
+
throw new Error(`AddonCallGateway: child registry unavailable for "${addonId}"`)
|
|
103
|
+
}
|
|
104
|
+
return childRegistry.callAddonOnChild(addonId, fullInput)
|
|
105
|
+
}
|
|
106
|
+
case 'remote-agent':
|
|
107
|
+
return this.callRemoteAgent(addonId, dest.baseNodeId, fullInput)
|
|
108
|
+
case 'in-process':
|
|
109
|
+
throw new Error(`AddonCallGateway: addon "${addonId}" runs in-process — invoke it directly`)
|
|
110
|
+
default: {
|
|
111
|
+
const _exhaustive: never = dest
|
|
112
|
+
throw new Error(`AddonCallGateway: unhandled destination ${JSON.stringify(_exhaustive)}`)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Map an addon-level call to the remote agent's Moleculer action. */
|
|
118
|
+
private async callRemoteAgent(addonId: string, baseNodeId: string, input: AddonCallInput): Promise<unknown> {
|
|
119
|
+
const workerNodeId = this.resolveWorkerNodeId(addonId, baseNodeId)
|
|
120
|
+
const opts = workerNodeId
|
|
121
|
+
? { nodeID: workerNodeId, timeout: REMOTE_TIMEOUT_MS }
|
|
122
|
+
: { timeout: REMOTE_TIMEOUT_MS }
|
|
123
|
+
if (input.target === 'settings') {
|
|
124
|
+
if (input.method == null) {
|
|
125
|
+
throw new Error(`AddonCallGateway: settings call to "${addonId}" missing method`)
|
|
126
|
+
}
|
|
127
|
+
return this.deps.broker.call(
|
|
128
|
+
`${addonId}.settings.${input.method}`,
|
|
129
|
+
(input.args ?? {}) as Record<string, unknown>,
|
|
130
|
+
opts,
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
// routes/custom are hub-local-child surfaces (mounted / invoked on the
|
|
134
|
+
// owning node); they are not proxied to a remote agent through this gateway.
|
|
135
|
+
throw new Error(`AddonCallGateway: target "${input.target}" not supported for remote agent "${baseNodeId}"`)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Resolve the Moleculer nodeID that actually hosts an addon's service.
|
|
140
|
+
* Forkable addons register under `${baseNodeId}/${addonId}`; in-process
|
|
141
|
+
* addons under the base nodeId. The registry is the ground truth — baseNodeId
|
|
142
|
+
* is a hint. (Moved verbatim from `addon-settings-provider.ts`.)
|
|
143
|
+
*/
|
|
144
|
+
private resolveWorkerNodeId(addonId: string, baseNodeId: string): string | null {
|
|
145
|
+
const registry = this.deps.broker.registry
|
|
146
|
+
const services = (registry as unknown as {
|
|
147
|
+
getServiceList: (opts: { onlyAvailable: boolean }) => readonly { name: string; nodeID: string }[]
|
|
148
|
+
}).getServiceList({ onlyAvailable: true })
|
|
149
|
+
const exactNode = `${baseNodeId}/${addonId}`
|
|
150
|
+
const preferred = services.find((s) => s.name === addonId && s.nodeID === exactNode)
|
|
151
|
+
if (preferred) return preferred.nodeID
|
|
152
|
+
const anyForBase = services.find((s) => s.name === addonId && s.nodeID === baseNodeId)
|
|
153
|
+
if (anyForBase) return anyForBase.nodeID
|
|
154
|
+
const anyWithName = services.find((s) => s.name === addonId)
|
|
155
|
+
return anyWithName?.nodeID ?? null
|
|
156
|
+
}
|
|
157
|
+
}
|
|
@@ -1022,6 +1022,7 @@ export class AddonPackageService {
|
|
|
1022
1022
|
readonly packageName: string
|
|
1023
1023
|
readonly version?: string
|
|
1024
1024
|
readonly requestedBy?: string
|
|
1025
|
+
readonly deferRestart?: boolean
|
|
1025
1026
|
}): Promise<{
|
|
1026
1027
|
packageName: string
|
|
1027
1028
|
fromVersion: string
|
|
@@ -1052,6 +1053,14 @@ export class AddonPackageService {
|
|
|
1052
1053
|
const args = ['install', '--prefix', appRoot, spec, '--no-save', ...buildNpmRegistryArgs(registry)]
|
|
1053
1054
|
await execFileAsync('npm', args, { timeout: 180_000 })
|
|
1054
1055
|
|
|
1056
|
+
if (input.deferRestart === true) {
|
|
1057
|
+
this.logger.info(
|
|
1058
|
+
`updateFrameworkPackage(${packageName}@${toVersion}): install done, restart deferred`,
|
|
1059
|
+
)
|
|
1060
|
+
// Sentinel: 0 signals "no restart scheduled" to the caller
|
|
1061
|
+
return { packageName, fromVersion, toVersion, restartingAt: 0 }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1055
1064
|
const restartingAt = Date.now()
|
|
1056
1065
|
const markerPayload: PendingRestartMarkerPayload = {
|
|
1057
1066
|
kind: 'framework-update',
|