@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
@@ -0,0 +1,302 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
2
+ import { randomUUID } from 'node:crypto'
3
+ import {
4
+ EventCategory,
5
+ type BulkUpdateItem,
6
+ type BulkUpdateState,
7
+ type BulkUpdatePhase,
8
+ type BulkUpdateItemStatus,
9
+ } from '@camstack/types'
10
+
11
+ /**
12
+ * Narrow event-bus interface required by BulkUpdateCoordinator.
13
+ * Intentionally narrower than IEventBus: the coordinator only emits a single
14
+ * topic, so we avoid importing the full IEventBus (which lives in @camstack/types
15
+ * and would pull in all event-catalog types). The full IEventBus satisfies this
16
+ * interface structurally, so wiring in cap-providers.ts is cast-free.
17
+ */
18
+ export interface IBulkUpdateEventBus {
19
+ emit(category: EventCategory.AddonsBulkUpdateProgress, payload: BulkUpdateState): void
20
+ }
21
+
22
+ export interface IBulkUpdateLogger {
23
+ info(message: string, ...args: unknown[]): void
24
+ warn(message: string, ...args: unknown[]): void
25
+ error(message: string, ...args: unknown[]): void
26
+ debug(message: string, ...args: unknown[]): void
27
+ }
28
+
29
+ export interface BulkUpdateCoordinatorDeps {
30
+ readonly eventBus: IBulkUpdateEventBus
31
+ readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
32
+ readonly updateFrameworkPackage: (input: { packageName: string; version: string; deferRestart: boolean }) => Promise<void>
33
+ readonly restartServer: (input: { confirm: true }) => Promise<void>
34
+ readonly logger: IBulkUpdateLogger
35
+ /** Injectable clock for tests. Default: `() => Date.now()`. */
36
+ readonly clock?: () => number
37
+ /** How long to keep completed state before purging. Default: 5 minutes. */
38
+ readonly cleanupAfterMs?: number
39
+ }
40
+
41
+ export interface StartBulkUpdateInput {
42
+ readonly nodeId: string
43
+ readonly items: readonly { name: string; version: string; isSystem: boolean }[]
44
+ }
45
+
46
+ const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000
47
+
48
+ export class BulkUpdateCoordinator {
49
+ private readonly states = new Map<string, BulkUpdateState>()
50
+ private readonly cancelFlags = new Map<string, { cancelled: boolean }>()
51
+ /**
52
+ * Tracks wall-clock time (ms) when each bulk completed. Used for lazy
53
+ * cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
54
+ * would be eagerly fired by `vi.runAllTimersAsync()` in tests.
55
+ */
56
+ private readonly completedWallMs = new Map<string, number>()
57
+ /** Tracks which nodeIds currently have an active (non-completed) bulk update. */
58
+ private readonly activeNodeIds = new Set<string>()
59
+
60
+ private readonly now: () => number
61
+ private readonly cleanupAfterMs: number
62
+ /** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
63
+ private readonly wallNow: () => number
64
+
65
+ constructor(private readonly deps: BulkUpdateCoordinatorDeps) {
66
+ this.now = deps.clock ?? (() => Date.now())
67
+ this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS
68
+ this.wallNow = () => Date.now()
69
+ }
70
+
71
+ // ── Public API ────────────────────────────────────────────────────
72
+
73
+ start(input: StartBulkUpdateInput): { id: string } {
74
+ if (this.activeNodeIds.has(input.nodeId)) {
75
+ throw new Error(`Bulk update already in progress for node ${input.nodeId}`)
76
+ }
77
+
78
+ const id = randomUUID()
79
+
80
+ const items: BulkUpdateItem[] = input.items.map(i => ({
81
+ name: i.name,
82
+ isSystem: i.isSystem,
83
+ // fromVersion: the cap interface receives name+version+isSystem only;
84
+ // the caller (cap-providers.ts) may enrich this with the current version
85
+ // if available. Empty string is acceptable per plan spec.
86
+ fromVersion: '',
87
+ toVersion: i.version,
88
+ status: 'queued' as BulkUpdateItemStatus,
89
+ }))
90
+
91
+ const state: BulkUpdateState = {
92
+ id,
93
+ nodeId: input.nodeId,
94
+ startedAtMs: this.now(),
95
+ total: items.length,
96
+ completed: 0,
97
+ failed: 0,
98
+ current: null,
99
+ phase: 'regular',
100
+ cancelled: false,
101
+ items,
102
+ }
103
+
104
+ this.states.set(id, state)
105
+ this.activeNodeIds.add(input.nodeId)
106
+
107
+ const cancelFlag = { cancelled: false }
108
+ this.cancelFlags.set(id, cancelFlag)
109
+
110
+ // Emit initial state so clients see the bulk as started immediately
111
+ this.emit(state)
112
+
113
+ void this.runLoop(id, cancelFlag).catch(err => {
114
+ this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
115
+ })
116
+
117
+ return { id }
118
+ }
119
+
120
+ get(id: string): BulkUpdateState | null {
121
+ const state = this.states.get(id)
122
+ if (state === undefined) return null
123
+ // Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
124
+ // This avoids scheduling a long-lived setTimeout that would be eagerly fired
125
+ // by vi.runAllTimersAsync() in tests.
126
+ const completedWall = this.completedWallMs.get(id)
127
+ if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
128
+ this.purge(id)
129
+ return null
130
+ }
131
+ return state
132
+ }
133
+
134
+ list(nodeId?: string): readonly BulkUpdateState[] {
135
+ const all = [...this.states.keys()]
136
+ .map(id => this.get(id)) // get() applies lazy-cleanup
137
+ .filter((s): s is BulkUpdateState => s !== null)
138
+ return nodeId === undefined ? all : all.filter(s => s.nodeId === nodeId)
139
+ }
140
+
141
+ cancel(id: string): { cancelled: boolean } {
142
+ const state = this.states.get(id)
143
+ const flag = this.cancelFlags.get(id)
144
+ if (state === undefined || flag === undefined) return { cancelled: false }
145
+ // Once restarting, the hub restart is committed — cancel has no effect.
146
+ if (state.phase === 'restarting') return { cancelled: false }
147
+ // Already completed.
148
+ if (state.completedAtMs !== undefined) return { cancelled: false }
149
+
150
+ flag.cancelled = true
151
+ this.mutate(id, s => ({ ...s, cancelled: true }))
152
+ return { cancelled: true }
153
+ }
154
+
155
+ // ── Internal loop ─────────────────────────────────────────────────
156
+
157
+ private async runLoop(id: string, cancelFlag: { cancelled: boolean }): Promise<void> {
158
+ const initial = this.states.get(id)!
159
+
160
+ // ── Phase 1: regular addons ──────────────────────────────────────
161
+ this.transitionPhase(id, 'regular')
162
+
163
+ for (const item of initial.items.filter(i => !i.isSystem)) {
164
+ if (cancelFlag.cancelled) break
165
+ await this.processItem(id, item, false)
166
+ }
167
+
168
+ // ── Phase 2: system packages (deferRestart: true) ────────────────
169
+ if (!cancelFlag.cancelled && initial.items.some(i => i.isSystem)) {
170
+ this.transitionPhase(id, 'system')
171
+
172
+ for (const item of initial.items.filter(i => i.isSystem)) {
173
+ if (cancelFlag.cancelled) break
174
+ await this.processItem(id, item, true)
175
+ }
176
+
177
+ // ── Phase 3: single restart ──────────────────────────────────
178
+ const anySystemPendingRestart = this.states.get(id)!.items.some(
179
+ i => i.isSystem && i.status === 'done-pending-restart',
180
+ )
181
+
182
+ if (anySystemPendingRestart && !cancelFlag.cancelled) {
183
+ this.transitionPhase(id, 'restarting')
184
+ try {
185
+ await this.deps.restartServer({ confirm: true })
186
+ // NOTE: In production, restartServer kills+respawns the hub process.
187
+ // Code below this point will not execute in that scenario.
188
+ // If the mock/stub returns (e.g. in tests), we fall through to finalizing.
189
+ } catch (err) {
190
+ // Restart failed but the npm installs already completed. Promote all
191
+ // done-pending-restart items to done with a caveat error so the UI
192
+ // can inform the user that a manual restart is needed.
193
+ this.deps.logger.error('BulkUpdateCoordinator: restart failed', err)
194
+ const errMsg = err instanceof Error ? err.message : String(err)
195
+ for (const it of this.states.get(id)!.items) {
196
+ if (it.status === 'done-pending-restart') {
197
+ this.setItemStatus(id, it.name, 'done', {
198
+ error: `Restart failed; manual restart required (${errMsg})`,
199
+ })
200
+ }
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // ── Phase 4: finalize ────────────────────────────────────────────
207
+ // Reached when:
208
+ // a) no system packages at all, OR
209
+ // b) restart failed (process continued), OR
210
+ // c) cancelled before the restart phase.
211
+ this.transitionPhase(id, 'finalizing')
212
+ this.completeBulk(id)
213
+ }
214
+
215
+ private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
216
+ this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
217
+ this.mutate(id, s => ({ ...s, current: item.name }))
218
+ this.emit(this.states.get(id)!)
219
+
220
+ try {
221
+ if (isSystem) {
222
+ await this.deps.updateFrameworkPackage({
223
+ packageName: item.name,
224
+ version: item.toVersion,
225
+ deferRestart: true,
226
+ })
227
+ this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() })
228
+ } else {
229
+ await this.deps.updateAddon({ name: item.name, version: item.toVersion })
230
+ this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() })
231
+ }
232
+ } catch (err) {
233
+ const msg = err instanceof Error ? err.message : String(err)
234
+ this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
235
+ }
236
+
237
+ this.mutate(id, s => ({ ...s, current: null }))
238
+ this.emit(this.states.get(id)!)
239
+ }
240
+
241
+ // ── State mutation helpers ────────────────────────────────────────
242
+
243
+ private setItemStatus(
244
+ id: string,
245
+ name: string,
246
+ status: BulkUpdateItemStatus,
247
+ fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
248
+ ): void {
249
+ this.mutate(id, s => {
250
+ const items = s.items.map(it =>
251
+ it.name === name ? { ...it, status, ...fields } : it,
252
+ )
253
+ // completed = all terminal states: done | done-pending-restart | failed
254
+ const completed = items.filter(
255
+ it => it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
256
+ ).length
257
+ const failed = items.filter(it => it.status === 'failed').length
258
+ return { ...s, items, completed, failed }
259
+ })
260
+ }
261
+
262
+ private transitionPhase(id: string, phase: BulkUpdatePhase): void {
263
+ this.mutate(id, s => ({ ...s, phase }))
264
+ this.emit(this.states.get(id)!)
265
+ }
266
+
267
+ private completeBulk(id: string): void {
268
+ this.mutate(id, s => ({ ...s, completedAtMs: this.now(), current: null }))
269
+ this.emit(this.states.get(id)!)
270
+
271
+ // Free the nodeId slot so a new bulk for the same node can be started
272
+ const nodeId = this.states.get(id)!.nodeId
273
+ this.activeNodeIds.delete(nodeId)
274
+
275
+ // Record wall-clock completion time for lazy cleanup in `get()`.
276
+ // We intentionally avoid scheduling a setTimeout here: a long-lived
277
+ // setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
278
+ // in tests, causing `get()` to return null immediately after the run.
279
+ // Instead, `get()` lazily checks whether the cleanup threshold has
280
+ // elapsed using Date.now() — which fake timers DO advance via
281
+ // advanceTimersByTimeAsync(), making the cleanup testable without
282
+ // a long-running timer.
283
+ this.completedWallMs.set(id, this.wallNow())
284
+ }
285
+
286
+ private purge(id: string): void {
287
+ this.states.delete(id)
288
+ this.cancelFlags.delete(id)
289
+ this.completedWallMs.delete(id)
290
+ }
291
+
292
+ /** Immutably update the state for the given id. No-op if id is unknown. */
293
+ private mutate(id: string, update: (s: BulkUpdateState) => BulkUpdateState): void {
294
+ const current = this.states.get(id)
295
+ if (current === undefined) return
296
+ this.states.set(id, update(current))
297
+ }
298
+
299
+ private emit(state: BulkUpdateState): void {
300
+ this.deps.eventBus.emit(EventCategory.AddonsBulkUpdateProgress, state)
301
+ }
302
+ }
@@ -41,6 +41,7 @@ import type {
41
41
  Integration,
42
42
  IIntegrationRegistry,
43
43
  IDeviceProvider,
44
+ IBrokerProvider,
44
45
  CapabilityMethodAuth,
45
46
  } from '@camstack/types'
46
47
  import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
@@ -57,7 +58,10 @@ import type { AddonRegistryService } from '../../core/addon/addon-registry.servi
57
58
  import type { AddonPackageService } from '../../core/addon/addon-package.service'
58
59
  import type { NetworkQualityService } from '../../core/network/network-quality.service'
59
60
  import type { ConfigService } from '../../core/config/config.service'
61
+ import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
60
62
  import { persistCollectionDisabled } from './collection-preference.js'
63
+ import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
64
+ import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
61
65
 
62
66
  const execFileAsync = promisify(execFile)
63
67
 
@@ -114,6 +118,7 @@ export function buildNetworkQualityProvider(
114
118
  rttMs: input.rttMs,
115
119
  jitterMs: input.jitterMs,
116
120
  estimatedBandwidthKbps: input.estimatedBandwidthKbps,
121
+ packetLossPercent: input.packetLossPercent,
117
122
  })
118
123
  },
119
124
  }
@@ -477,9 +482,17 @@ export function buildNodesProvider(
477
482
  return reg.getGraph({ windowSeconds: input.windowSeconds, nowMs: Date.now() })
478
483
  },
479
484
  setProcessLogLevel: async (input) => {
480
- await broker.call('$node-mgmt.setLogLevel', {
481
- level: input.level,
482
- }, { nodeID: input.nodeId, timeout: 5_000 })
485
+ // E2: for hub-local children, try the UDS path first (fire-and-forget);
486
+ // fall back to the Moleculer $node-mgmt.setLogLevel action for remote
487
+ // nodes (agents) that still run a per-node broker. The UDS path is always
488
+ // attempted for hub/<runner> nodeIds even when the Moleculer path would
489
+ // also work — both are safe to run in parallel during Phase E.
490
+ const reachedViaUds = moleculer.setChildLogLevelByNodeId(input.nodeId, input.level)
491
+ if (!reachedViaUds) {
492
+ await broker.call('$node-mgmt.setLogLevel', {
493
+ level: input.level,
494
+ }, { nodeID: input.nodeId, timeout: 5_000 })
495
+ }
483
496
  return { success: true }
484
497
  },
485
498
  executeQuery: async (input) => {
@@ -515,10 +528,27 @@ function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDevicePr
515
528
  return isDeviceProvider(provider) ? provider : null
516
529
  }
517
530
 
531
+ /**
532
+ * Marker caps that flag an addon as a creatable integration type:
533
+ * - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
534
+ * that expose `createDevice` + `discoverDevices` via their
535
+ * device-provider cap.
536
+ * - `device-adoption` — integration-style providers (Home Assistant
537
+ * and future siblings) that materialise devices via a generic
538
+ * adoption cap instead of a manual create-form. The picker treats
539
+ * them the same way; the wizard's discovery step routes through the
540
+ * specific cap based on the addon's declared surface.
541
+ *
542
+ * Exported so the integration-markers spec can assert the recognised set
543
+ * without booting the whole provider factory.
544
+ */
545
+ export const INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption'])
546
+
518
547
  export function buildIntegrationsProvider(
519
548
  ar: AddonRegistryService,
520
549
  eb: EventBusService,
521
550
  loggingService: LoggingService,
551
+ capabilityRegistry: CapabilityRegistry | null,
522
552
  ): IIntegrationsProvider {
523
553
  const logger = loggingService.createLogger('integrations')
524
554
  const withProcessState = (i: Integration): IntegrationWithProcessState => ({
@@ -645,6 +675,77 @@ export function buildIntegrationsProvider(
645
675
  'removing',
646
676
  { tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'delete', name: integration.name } },
647
677
  )
678
+
679
+ // Cascade-delete every live device whose integrationId matches.
680
+ // Best-effort: a device-removal hiccup must not abort the integration
681
+ // delete — log a warning and continue so the record + event always fire.
682
+ const dm = capabilityRegistry?.getSingleton<{
683
+ removeByIntegration?: (input: { integrationId: string }) => Promise<{ removed: number }>
684
+ listAll?: (input: Record<string, never>) => Promise<readonly {
685
+ id: number; addonId: string; parentDeviceId: number | null; integrationId?: string
686
+ }[]>
687
+ setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
688
+ }>('device-manager') ?? null
689
+
690
+ // Claim legacy un-tagged devices BEFORE the cascade. Devices created
691
+ // before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
692
+ // carry no integrationId, so `removeByIntegration` (which matches on
693
+ // integrationId) would leave them orphaned forever once their integration
694
+ // is gone. While the integration record still exists, stamp the
695
+ // unambiguous ones (addons hosting exactly one integration) so the cascade
696
+ // below removes them too. Best-effort: never abort the delete.
697
+ if (dm?.listAll && dm?.setIntegrationId) {
698
+ try {
699
+ const [integrations, devices] = await Promise.all([
700
+ reg.listIntegrations(),
701
+ dm.listAll({}),
702
+ ])
703
+ const stamps = planDeleteTimeStamps(
704
+ input.id,
705
+ integrations.map((i) => ({ id: i.id, addonId: i.addonId })),
706
+ devices.map((d) => ({
707
+ id: d.id,
708
+ addonId: d.addonId,
709
+ parentDeviceId: d.parentDeviceId,
710
+ ...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
711
+ })),
712
+ )
713
+ for (const stamp of stamps) {
714
+ await dm.setIntegrationId({ deviceId: stamp.deviceId, integrationId: stamp.integrationId })
715
+ }
716
+ if (stamps.length > 0) {
717
+ logger.info('claimed legacy un-tagged devices for cascade', {
718
+ tags: { integrationId: input.id, addonId: integration.addonId },
719
+ meta: { phase: 'delete', claimed: stamps.length },
720
+ })
721
+ }
722
+ } catch (err) {
723
+ logger.warn('legacy device claim failed (best-effort — continuing)', {
724
+ tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) },
725
+ })
726
+ }
727
+ }
728
+
729
+ if (dm?.removeByIntegration) {
730
+ try {
731
+ const result = await dm.removeByIntegration({ integrationId: input.id })
732
+ logger.info(
733
+ 'cascade-removed devices',
734
+ { tags: { integrationId: input.id }, meta: { phase: 'delete', removed: result.removed } },
735
+ )
736
+ } catch (err) {
737
+ logger.warn(
738
+ 'device cascade-remove failed (best-effort — continuing)',
739
+ { tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) } },
740
+ )
741
+ }
742
+ } else {
743
+ logger.warn(
744
+ 'device-manager not available — skipping cascade device removal',
745
+ { tags: { integrationId: input.id }, meta: { phase: 'delete' } },
746
+ )
747
+ }
748
+
648
749
  await reg.deleteIntegration(input.id)
649
750
 
650
751
  eb.emit({
@@ -694,11 +795,16 @@ export function buildIntegrationsProvider(
694
795
  // integration against an addon that didn't load produces an orphaned
695
796
  // row that `createFilteredRegistry` then filters out — silent data
696
797
  // loss from the operator's POV. Filter at the source instead.
798
+ //
799
+ // Markers that flag an addon as a creatable integration type live
800
+ // in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
801
+ // integration-markers spec can assert the recognised caps).
697
802
  const providerAddons = addons.filter(a =>
698
803
  a.process?.state !== 'failed' &&
699
- a.manifest.capabilities?.some(c =>
700
- typeof c === 'string' ? c === 'device-provider' : c.name === 'device-provider',
701
- ),
804
+ a.manifest.capabilities?.some(c => {
805
+ const name = typeof c === 'string' ? c : c.name
806
+ return typeof name === 'string' && INTEGRATION_CAP_MARKERS.has(name)
807
+ }),
702
808
  )
703
809
  const integrations = await reg.listIntegrations()
704
810
  return providerAddons.map(addon => {
@@ -710,6 +816,29 @@ export function buildIntegrationsProvider(
710
816
  const existing = integrations.filter(i => i.addonId === m.id)
711
817
  const provider = getDeviceProvider(ar, m.id)
712
818
  const discoveryMode = provider?.discoveryMode ?? 'manual'
819
+
820
+ // Branch by CAP, not by addon name. Surface which integration-marker
821
+ // cap the addon declared so the wizard routes `device-adoption`
822
+ // (Approach A: pick/create a broker, store `{ brokerId }`) vs the
823
+ // legacy `device-provider` config → discovery flow. A `device-adoption`
824
+ // marker wins when both are present (an integration-style addon may
825
+ // also expose a `device-provider` shim); the broker step is the
826
+ // intended entry point for it.
827
+ const capNames = (m.capabilities ?? []).map(c => (typeof c === 'string' ? c : c.name))
828
+ const kind: 'device-adoption' | 'device-provider' =
829
+ capNames.includes('device-adoption') ? 'device-adoption' : 'device-provider'
830
+
831
+ // For device-adoption addons, the broker kind to create/link comes
832
+ // from the addon manifest (`brokerKind`). Null for device-provider
833
+ // addons, which carry no broker.
834
+ const brokerKind = kind === 'device-adoption'
835
+ ? (d?.brokerKind ?? m.brokerKind ?? null)
836
+ : null
837
+
838
+ const supportsLocationImport = kind === 'device-adoption'
839
+ ? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
840
+ : false
841
+
713
842
  return {
714
843
  addonId: m.id,
715
844
  name: m.name ?? m.id,
@@ -718,6 +847,9 @@ export function buildIntegrationsProvider(
718
847
  color,
719
848
  instanceMode,
720
849
  discoveryMode,
850
+ kind,
851
+ brokerKind,
852
+ supportsLocationImport,
721
853
  existingInstances: existing.map(i => ({
722
854
  id: i.id,
723
855
  name: i.name,
@@ -727,6 +859,33 @@ export function buildIntegrationsProvider(
727
859
  })
728
860
  },
729
861
  testConnection: async (input) => {
862
+ // Broker-backed integrations (Approach A) carry their connection
863
+ // identity as a `brokerId` in settings — testing is a broker
864
+ // concern now, so delegate to the addon's `broker` cap. The broker
865
+ // already owns the real semantic check (HA opens a temporary WS
866
+ // handshake; MQTT pings the bridge). We translate the broker's
867
+ // discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
868
+ // the integrations `{success, error?}` output shape. Falls back to
869
+ // the default RTSP/ffprobe path below for legacy device-provider
870
+ // addons (Reolink/Frigate/ONVIF) that probe a stream URL.
871
+ const registry = ar.getCapabilityRegistry()
872
+ const brokerId = input.settings['brokerId']
873
+ if (typeof brokerId === 'string' && brokerId.length > 0) {
874
+ const brokerProvider = registry.getProviderByAddonId<IBrokerProvider>('broker', input.addonId)
875
+ if (!brokerProvider) {
876
+ return { success: false, error: `Broker provider for addon '${input.addonId}' is not available` }
877
+ }
878
+ try {
879
+ const result = await brokerProvider.testConnection({ id: brokerId })
880
+ return result.ok
881
+ ? { success: true }
882
+ : { success: false, error: result.error }
883
+ } catch (err) {
884
+ return { success: false, error: errMsg(err) }
885
+ }
886
+ }
887
+
888
+ // Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
730
889
  const url = String(
731
890
  input.settings['main_stream_url'] ?? input.settings['url'] ?? '',
732
891
  ).trim()
@@ -806,8 +965,45 @@ export function buildAddonsProvider(
806
965
  moleculer: MoleculerService,
807
966
  configService: ConfigService,
808
967
  ctx: TrpcContext,
968
+ eb: EventBusService,
809
969
  ): IAddonsProvider {
810
970
  const broker = moleculer.broker as unknown as BrokerLike
971
+
972
+ // Adapt the hub EventBusService (which takes a full SystemEvent object) to
973
+ // the IBulkUpdateEventBus interface (which takes (category, payload) pairs).
974
+ // Using `import { EventCategory }` from @camstack/types avoids a new import
975
+ // — it is already resolved in the generated-cap-routers layer above. The
976
+ // `eb.emit` overload that takes a TypedSystemEvent is the type-safe path.
977
+ const bulkEventBus = {
978
+ emit: (category: Parameters<typeof eb.emit>[0]['category'], payload: unknown) => {
979
+ eb.emit({
980
+ id: randomUUID(),
981
+ timestamp: new Date(),
982
+ source: { type: 'core', id: 'bulk-update-coordinator' },
983
+ category,
984
+ data: payload as Record<string, unknown>,
985
+ })
986
+ },
987
+ }
988
+
989
+ const bulkCoordinator = new BulkUpdateCoordinator({
990
+ eventBus: bulkEventBus,
991
+ updateAddon: async (i) => { await ps.updatePackage(i.name, i.version) },
992
+ updateFrameworkPackage: async (i) => {
993
+ await ps.updateFrameworkPackage({
994
+ packageName: i.packageName,
995
+ version: i.version,
996
+ deferRestart: i.deferRestart,
997
+ })
998
+ },
999
+ restartServer: async () => {
1000
+ await ps.restartServer(ctx.user?.username ?? ctx.user?.id)
1001
+ },
1002
+ logger: ls.createLogger('bulk-update'),
1003
+ })
1004
+
1005
+ const frameworkAllowSet = new Set<string>(FRAMEWORK_PACKAGE_ALLOWLIST)
1006
+
811
1007
  return {
812
1008
  list: async () => {
813
1009
  const rollbackable = ps.getRollbackablePackages()
@@ -843,9 +1039,10 @@ export function buildAddonsProvider(
843
1039
  },
844
1040
  listUpdates: async (input) => {
845
1041
  const nodeId = input.nodeId
846
- if (nodeId === undefined || isHubNode(nodeId)) return ps.checkUpdates()
847
- const installed = await fetchAgentInstalledPackages(broker, nodeId)
848
- return ps.checkUpdatesForInstalled(installed)
1042
+ const updates = nodeId === undefined || isHubNode(nodeId)
1043
+ ? await ps.checkUpdates()
1044
+ : await ps.checkUpdatesForInstalled(await fetchAgentInstalledPackages(broker, nodeId))
1045
+ return updates.map(u => ({ ...u, isSystem: frameworkAllowSet.has(u.name) }))
849
1046
  },
850
1047
  updatePackage: async (input) => {
851
1048
  const nodeId = input.nodeId
@@ -937,6 +1134,7 @@ export function buildAddonsProvider(
937
1134
  : ctx.user?.id !== undefined
938
1135
  ? { requestedBy: ctx.user.id }
939
1136
  : {}),
1137
+ ...(input.deferRestart !== undefined ? { deferRestart: input.deferRestart } : {}),
940
1138
  }),
941
1139
  getVersions: async (input) => ps.getPackageVersions(input.name),
942
1140
  restartAddon: async (input) => ar.restartAddon(input.addonId),
@@ -955,6 +1153,10 @@ export function buildAddonsProvider(
955
1153
  }
956
1154
  return { success: true as const }
957
1155
  },
1156
+ startBulkUpdate: async (input) => bulkCoordinator.start(input),
1157
+ getBulkUpdateState: async ({ id }) => bulkCoordinator.get(id),
1158
+ cancelBulkUpdate: async ({ id }) => bulkCoordinator.cancel(id),
1159
+ listActiveBulkUpdates: async ({ nodeId }) => bulkCoordinator.list(nodeId),
958
1160
  custom: async (input) => {
959
1161
  const registry = ar.getCustomActionRegistry()
960
1162
  const entry = registry.resolve(input.addonId, input.action)
@@ -28,12 +28,35 @@ export function createCapabilitiesRouter(
28
28
  }),
29
29
 
30
30
  setActiveSingleton: adminProcedure
31
- .input(z.object({ capability: z.string(), addonId: z.string() }))
31
+ .input(z.object({
32
+ capability: z.string(),
33
+ addonId: z.string(),
34
+ nodeId: z.string().optional(),
35
+ }))
32
36
  .output(z.void())
33
37
  .mutation(async ({ input }) => {
34
38
  if (!registry) throw new Error('Capability registry unavailable')
35
- await registry.setActiveSingleton(input.capability, input.addonId)
36
- config.update('capabilities', { [input.capability]: input.addonId })
39
+ await registry.setActiveSingleton(input.capability, input.addonId, input.nodeId)
40
+ if (input.nodeId !== undefined) {
41
+ // Per-node override: persist under the node-qualified key so the boot
42
+ // restorer (`setNodeConfigReader`) replays it on restart.
43
+ config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, input.addonId)
44
+ } else {
45
+ // Cluster-global default: persist to the SAME key the boot restorer reads
46
+ // (addon-registry `setConfigReader` → `capabilities.singleton.<cap>`).
47
+ config.set(`capabilities.singleton.${input.capability}`, input.addonId)
48
+ }
49
+ }),
50
+
51
+ clearSingletonNodeOverride: adminProcedure
52
+ .input(z.object({ capability: z.string(), nodeId: z.string() }))
53
+ .output(z.void())
54
+ .mutation(({ input }) => {
55
+ if (!registry) throw new Error('Capability registry unavailable')
56
+ registry.clearSingletonNodeOverride(input.capability, input.nodeId)
57
+ // Persist the cleared state as null so the boot restorer doesn't re-apply
58
+ // a stale override on restart.
59
+ config.set(`capabilities.singletonNode.${input.capability}.${input.nodeId}`, null)
37
60
  }),
38
61
 
39
62
  /**
@@ -18,6 +18,10 @@ const LogTagsSchema = z.object({
18
18
  nodeId: z.string().optional(),
19
19
  /** Numeric progressive id (or its string form for legacy callers). */
20
20
  deviceId: z.union([z.string(), z.number()]).optional(),
21
+ /** Parent container id — set on every accessory child's logs (and on the
22
+ * container's own logs). Filtering by it returns the whole container subtree
23
+ * (container + all children) in one query. */
24
+ containerDeviceId: z.union([z.string(), z.number()]).optional(),
21
25
  deviceName: z.string().optional(),
22
26
  integrationId: z.string().optional(),
23
27
  addonId: z.string().optional(),