@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
@@ -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({ transformer: superjson })
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, recording → `recordingEngine` cap, network →
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',