@camstack/server 0.1.7 → 0.2.0

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 (135) hide show
  1. package/package.json +11 -9
  2. package/src/__tests__/addon-install-e2e.test.ts +0 -1
  3. package/src/__tests__/addon-pages-e2e.test.ts +40 -18
  4. package/src/__tests__/addon-settings-router.spec.ts +6 -1
  5. package/src/__tests__/addon-upload.spec.ts +91 -29
  6. package/src/__tests__/agent-registry.spec.ts +26 -9
  7. package/src/__tests__/agent-status-page.spec.ts +1 -3
  8. package/src/__tests__/auth-session-cookie.test.ts +28 -1
  9. package/src/__tests__/bulk-update-coordinator.spec.ts +48 -31
  10. package/src/__tests__/cap-ownership-authority.spec.ts +39 -8
  11. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +206 -0
  12. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +17 -3
  13. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +57 -11
  14. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +292 -0
  15. package/src/__tests__/cap-providers-bulk-update.spec.ts +27 -7
  16. package/src/__tests__/cap-route-adapter.spec.ts +28 -15
  17. package/src/__tests__/cap-routers/_meta.spec.ts +6 -7
  18. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +19 -10
  19. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +177 -0
  20. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +3 -1
  21. package/src/__tests__/cap-routers/capabilities-node.spec.ts +18 -5
  22. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +137 -0
  23. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +72 -20
  24. package/src/__tests__/cap-routers/harness.ts +11 -7
  25. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +17 -3
  26. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +5 -7
  27. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +35 -11
  28. package/src/__tests__/cap-routers/settings-store.router.spec.ts +59 -15
  29. package/src/__tests__/capability-e2e.test.ts +9 -11
  30. package/src/__tests__/cli-e2e.test.ts +80 -59
  31. package/src/__tests__/core-cap-bridge.spec.ts +3 -1
  32. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +12 -2
  33. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +61 -30
  34. package/src/__tests__/embedded-deps-e2e.test.ts +35 -19
  35. package/src/__tests__/event-bus-proxy-router.spec.ts +3 -0
  36. package/src/__tests__/framework-allowlist.spec.ts +5 -4
  37. package/src/__tests__/https-e2e.test.ts +12 -6
  38. package/src/__tests__/lifecycle-e2e.test.ts +60 -11
  39. package/src/__tests__/live-events-subscription.spec.ts +17 -18
  40. package/src/__tests__/moleculer/uds-readiness.spec.ts +11 -4
  41. package/src/__tests__/moleculer/uds-topology.spec.ts +39 -11
  42. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +265 -5
  43. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +16 -7
  44. package/src/__tests__/native-cap-route.spec.ts +42 -19
  45. package/src/__tests__/oauth2-account-linking.spec.ts +63 -17
  46. package/src/__tests__/singleton-contention.test.ts +23 -11
  47. package/src/__tests__/streaming-diagnostic.test.ts +156 -53
  48. package/src/__tests__/streaming-scale.test.ts +69 -35
  49. package/src/__tests__/uds-addon-call-wiring.spec.ts +6 -1
  50. package/src/agent-status-page.ts +4 -3
  51. package/src/api/__tests__/addons-custom.spec.ts +22 -8
  52. package/src/api/__tests__/capabilities.router.test.ts +18 -9
  53. package/src/api/addon-upload.ts +46 -15
  54. package/src/api/addons-custom.router.ts +7 -6
  55. package/src/api/auth-whoami.ts +3 -1
  56. package/src/api/bridge-addons.router.ts +3 -1
  57. package/src/api/capabilities.router.ts +117 -78
  58. package/src/api/core/__tests__/auth-router-totp.spec.ts +57 -16
  59. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  60. package/src/api/core/addon-settings.router.ts +4 -1
  61. package/src/api/core/agents.router.ts +52 -53
  62. package/src/api/core/auth.router.ts +55 -36
  63. package/src/api/core/bulk-update-coordinator.ts +25 -22
  64. package/src/api/core/cap-providers.ts +459 -166
  65. package/src/api/core/capabilities.router.ts +30 -23
  66. package/src/api/core/hwaccel.router.ts +37 -10
  67. package/src/api/core/live-events.router.ts +16 -9
  68. package/src/api/core/logs.router.ts +58 -25
  69. package/src/api/core/notifications.router.ts +2 -1
  70. package/src/api/core/repl.router.ts +1 -3
  71. package/src/api/core/settings-backend.router.ts +68 -70
  72. package/src/api/core/system-events.router.ts +41 -32
  73. package/src/api/health/health.routes.ts +7 -13
  74. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +12 -2
  75. package/src/api/oauth2/consent-page.ts +4 -3
  76. package/src/api/oauth2/oauth2-routes.ts +41 -12
  77. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  78. package/src/api/trpc/__tests__/scope-access-device.spec.ts +68 -23
  79. package/src/api/trpc/__tests__/scope-access.spec.ts +8 -13
  80. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +136 -0
  81. package/src/api/trpc/cap-mount-helpers.ts +64 -44
  82. package/src/api/trpc/cap-route-error-formatter.ts +17 -9
  83. package/src/api/trpc/client-ip.ts +17 -0
  84. package/src/api/trpc/core-cap-bridge.ts +3 -1
  85. package/src/api/trpc/generated-cap-mounts.ts +801 -286
  86. package/src/api/trpc/generated-cap-routers.ts +5723 -719
  87. package/src/api/trpc/scope-access.ts +7 -7
  88. package/src/api/trpc/trpc.context.ts +7 -4
  89. package/src/api/trpc/trpc.middleware.ts +4 -2
  90. package/src/api/trpc/trpc.router.ts +117 -48
  91. package/src/auth/session-cookie.ts +10 -0
  92. package/src/boot/__tests__/integration-id-backfill.spec.ts +131 -0
  93. package/src/boot/boot-config.ts +103 -122
  94. package/src/boot/integration-id-backfill.ts +109 -0
  95. package/src/boot/post-boot.service.ts +5 -3
  96. package/src/core/addon/__tests__/addon-registry-capability.test.ts +12 -3
  97. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  98. package/src/core/addon/addon-call-gateway.ts +20 -6
  99. package/src/core/addon/addon-package.service.ts +183 -89
  100. package/src/core/addon/addon-registry.service.ts +1212 -1267
  101. package/src/core/addon/addon-row-manifest.ts +29 -0
  102. package/src/core/addon/addon-search.service.ts +2 -1
  103. package/src/core/addon/addon-settings-provider.ts +27 -7
  104. package/src/core/addon-bridge/addon-bridge.service.ts +11 -6
  105. package/src/core/addon-pages/addon-pages.service.ts +3 -1
  106. package/src/core/addon-widgets/addon-widgets.service.ts +5 -2
  107. package/src/core/agent/agent-registry.service.ts +60 -38
  108. package/src/core/auth/auth.service.spec.ts +6 -8
  109. package/src/core/config/config.service.spec.ts +1 -1
  110. package/src/core/events/event-bus.service.spec.ts +44 -21
  111. package/src/core/events/event-bus.service.ts +5 -1
  112. package/src/core/feature/feature.service.spec.ts +4 -1
  113. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +8 -10
  114. package/src/core/logging/logging.service.spec.ts +61 -21
  115. package/src/core/logging/logging.service.ts +19 -5
  116. package/src/core/moleculer/cap-call-fn.spec.ts +17 -10
  117. package/src/core/moleculer/cap-call-fn.ts +5 -1
  118. package/src/core/moleculer/cap-route-authority.ts +18 -6
  119. package/src/core/moleculer/moleculer.service.ts +145 -29
  120. package/src/core/network/network-quality.service.spec.ts +7 -1
  121. package/src/core/notification/notification-wrapper.service.ts +1 -3
  122. package/src/core/notification/toast-wrapper.service.ts +1 -5
  123. package/src/core/repl/repl-engine.service.spec.ts +66 -39
  124. package/src/core/repl/repl-engine.service.ts +11 -12
  125. package/src/core/storage/storage-location-manager.spec.ts +12 -3
  126. package/src/core/streaming/stream-probe.service.ts +22 -13
  127. package/src/core/topology/topology-emitter.service.ts +5 -1
  128. package/src/launcher.ts +14 -9
  129. package/src/main.ts +658 -495
  130. package/src/manual-boot.ts +133 -154
  131. package/tsconfig.json +20 -8
  132. package/src/core/storage/settings-store.spec.ts +0 -213
  133. package/src/core/storage/settings-store.ts +0 -2
  134. package/src/core/storage/sql-schema.spec.ts +0 -140
  135. package/src/core/storage/sql-schema.ts +0 -3
@@ -23,11 +23,16 @@ import { describe, it, expect, vi } from 'vitest'
23
23
  import {
24
24
  createParentUnownedCallHandler,
25
25
  CapRouteResolver,
26
+ CapRouteError,
27
+ HubNodeRegistry,
26
28
  } from '@camstack/kernel'
27
29
  import type {
28
30
  NodeCapAuthority,
29
31
  InProcessProviderLookup,
30
32
  CapRouteResolverDeps,
33
+ NodeNativeCapEntry,
34
+ HubLocalChildDispatcher,
35
+ CapCallInput,
31
36
  } from '@camstack/kernel'
32
37
  import type { ServiceBroker } from 'moleculer'
33
38
 
@@ -63,6 +68,44 @@ function hubBrokerFake(): ServiceBroker {
63
68
  return broker as unknown as ServiceBroker
64
69
  }
65
70
 
71
+ /**
72
+ * Minimal `HubNodeRegistry`-shaped fake exposing only the lookup the unowned
73
+ * handler uses: `listNativeCapEntriesForDevice`. The handler depends solely on
74
+ * this method, so we don't need the full registry to exercise its routing.
75
+ */
76
+ function nodeRegistryFake(
77
+ byDevice: Record<number, readonly NodeNativeCapEntry[]> = {},
78
+ ): HubNodeRegistry {
79
+ const fake = {
80
+ listNativeCapEntriesForDevice: (deviceId: number): readonly NodeNativeCapEntry[] =>
81
+ byDevice[deviceId] ?? [],
82
+ }
83
+ return fake as unknown as HubNodeRegistry
84
+ }
85
+
86
+ /**
87
+ * Hub-local UDS dispatcher fake (LocalChildRegistry surface). `resolveChildId`
88
+ * returns the configured childId on a (capName, deviceId) match else null;
89
+ * `callCapOnChild` records the call and echoes a sentinel result.
90
+ */
91
+ function localDispatcherFake(owner?: { capName: string; deviceId: number; childId: string }): {
92
+ dispatcher: HubLocalChildDispatcher
93
+ resolveChildId: ReturnType<typeof vi.fn>
94
+ callCapOnChild: ReturnType<typeof vi.fn>
95
+ } {
96
+ const resolveChildId = vi.fn((capName: string, deviceId?: number): string | null =>
97
+ owner !== undefined && capName === owner.capName && deviceId === owner.deviceId
98
+ ? owner.childId
99
+ : null,
100
+ )
101
+ const callCapOnChild = vi.fn(async (childId: string, input: CapCallInput) => ({
102
+ routedOverUds: childId,
103
+ capName: input.capName,
104
+ }))
105
+ const dispatcher: HubLocalChildDispatcher = { resolveChildId, callCapOnChild }
106
+ return { dispatcher, resolveChildId, callCapOnChild }
107
+ }
108
+
66
109
  describe('hub onUnownedCall wiring (F0)', () => {
67
110
  it('resolver-first: resolves a hub-in-process cap through the real CapRouteResolver', async () => {
68
111
  const broker = hubBrokerFake()
@@ -71,7 +114,12 @@ describe('hub onUnownedCall wiring (F0)', () => {
71
114
  const settingsStore = { get: async (params: unknown) => ({ value: 'hub-resolved', params }) }
72
115
  const inProcessProviders: InProcessProviderLookup = (capName) =>
73
116
  capName === 'settings-store'
74
- ? { invoke: (method, args) => Promise.resolve((settingsStore as Record<string, (a: unknown) => unknown>)[method](args)) }
117
+ ? {
118
+ invoke: (method, args) =>
119
+ Promise.resolve(
120
+ (settingsStore as Record<string, (a: unknown) => unknown>)[method](args),
121
+ ),
122
+ }
75
123
  : null
76
124
 
77
125
  const resolverDeps: CapRouteResolverDeps = {
@@ -83,12 +131,16 @@ describe('hub onUnownedCall wiring (F0)', () => {
83
131
  }
84
132
  const resolver = new CapRouteResolver(resolverDeps)
85
133
 
86
- const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
134
+ const handler = createParentUnownedCallHandler({
135
+ getResolver: () => resolver,
136
+ broker,
137
+ nodeRegistry: nodeRegistryFake(),
138
+ })
87
139
 
88
140
  const result = await handler({ capName: 'settings-store', method: 'get', args: { key: 'k' } })
89
141
  expect(result).toEqual({ value: 'hub-resolved', params: { key: 'k' } })
90
142
  // Resolver served it — broker untouched.
91
- expect((broker.call as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled()
143
+ expect(broker.call as ReturnType<typeof vi.fn>).not.toHaveBeenCalled()
92
144
  })
93
145
 
94
146
  it('broker-fallback: a `$`-infra core service (`$core-caps.system.info`) routes via the broker', async () => {
@@ -104,7 +156,11 @@ describe('hub onUnownedCall wiring (F0)', () => {
104
156
  inProcessProviders: () => null,
105
157
  })
106
158
 
107
- const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
159
+ const handler = createParentUnownedCallHandler({
160
+ getResolver: () => resolver,
161
+ broker,
162
+ nodeRegistry: nodeRegistryFake(),
163
+ })
108
164
 
109
165
  const result = await handler({ capName: 'system', method: 'info', args: undefined })
110
166
  expect(result).toEqual({ uptimeSec: 99, params: undefined })
@@ -115,9 +171,213 @@ describe('hub onUnownedCall wiring (F0)', () => {
115
171
  const broker = hubBrokerFake()
116
172
  // Mirrors the window before `this.resolver` is constructed: getResolver
117
173
  // returns null, so the handler goes straight to the broker fallback.
118
- const handler = createParentUnownedCallHandler({ getResolver: () => null, broker })
174
+ const handler = createParentUnownedCallHandler({
175
+ getResolver: () => null,
176
+ broker,
177
+ nodeRegistry: nodeRegistryFake(),
178
+ })
119
179
 
120
180
  const result = await handler({ capName: 'system', method: 'info', args: undefined })
121
181
  expect(result).toEqual({ uptimeSec: 99, params: undefined })
122
182
  })
183
+
184
+ it('deviceId-aware resolver: derives deviceId from args and routes via the resolver (no broker fallback)', async () => {
185
+ const broker = hubBrokerFake()
186
+
187
+ // A resolver-shaped fake that resolves a route ONLY when invoked with a
188
+ // deviceId — mirroring the real resolver routing a device-scoped native cap
189
+ // to its owning provider. Without a deviceId it returns no-provider.
190
+ let resolvedWithDeviceId: number | undefined
191
+ const resolver = {
192
+ resolveCapRoute: (capName: string, opts: { deviceId?: number }) => {
193
+ if (opts.deviceId === undefined) {
194
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
195
+ }
196
+ resolvedWithDeviceId = opts.deviceId
197
+ return { kind: 'hub-in-process', capName, deviceId: opts.deviceId }
198
+ },
199
+ dispatch: async () => ({ catalog: ['stream-a'] }),
200
+ }
201
+
202
+ const handler = createParentUnownedCallHandler({
203
+ getResolver: () => resolver as unknown as CapRouteResolver,
204
+ broker,
205
+ nodeRegistry: nodeRegistryFake(),
206
+ })
207
+
208
+ // deviceId lives ONLY in args, NOT top-level — the handler must derive it.
209
+ const result = await handler({
210
+ capName: 'stream-catalog',
211
+ method: 'getCatalog',
212
+ args: { deviceId: 7 },
213
+ })
214
+ expect(result).toEqual({ catalog: ['stream-a'] })
215
+ expect(resolvedWithDeviceId).toBe(7)
216
+ // Resolver served it via the derived deviceId — broker untouched.
217
+ expect(broker.call).not.toHaveBeenCalled()
218
+ })
219
+
220
+ it('pinned broker fallback: resolver finds nothing → call is pinned to the owning node', async () => {
221
+ const broker = hubBrokerFake()
222
+
223
+ // Resolver always returns no-provider for this device-scoped cap.
224
+ const resolver = {
225
+ resolveCapRoute: (capName: string) => {
226
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
227
+ },
228
+ dispatch: async () => {
229
+ throw new Error('dispatch should not be reached')
230
+ },
231
+ }
232
+
233
+ const owner: NodeNativeCapEntry = {
234
+ nodeId: 'agent',
235
+ addonId: 'reolink',
236
+ capName: 'stream-catalog',
237
+ deviceId: 7,
238
+ }
239
+ const handler = createParentUnownedCallHandler({
240
+ getResolver: () => resolver as unknown as CapRouteResolver,
241
+ broker,
242
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
243
+ })
244
+
245
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(
246
+ () => undefined,
247
+ )
248
+
249
+ // The broker call is pinned to the owning node via call-opts `{ nodeID }`.
250
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
251
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
252
+ })
253
+
254
+ it('back-compat: no resolvable device-owner → broker call stays UNPINNED (load-balanced)', async () => {
255
+ const broker = hubBrokerFake()
256
+
257
+ const resolver = {
258
+ resolveCapRoute: (capName: string) => {
259
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
260
+ },
261
+ dispatch: async () => {
262
+ throw new Error('dispatch should not be reached')
263
+ },
264
+ }
265
+
266
+ const handler = createParentUnownedCallHandler({
267
+ getResolver: () => resolver as unknown as CapRouteResolver,
268
+ broker,
269
+ nodeRegistry: nodeRegistryFake(), // no owners for any device
270
+ })
271
+
272
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
273
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
274
+ // Unpinned: third arg (call-opts) is undefined — today's behavior preserved.
275
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
276
+ expect(callArgs[2]).toBeUndefined()
277
+ })
278
+
279
+ it('hub-local owner: device-native cap owned by a hub-local UDS child routes over UDS (broker untouched)', async () => {
280
+ const broker = hubBrokerFake()
281
+ // Resolver misses the device-scoped native cap (mirrors live behavior).
282
+ const resolver = {
283
+ resolveCapRoute: (capName: string) => {
284
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
285
+ },
286
+ dispatch: async () => {
287
+ throw new Error('dispatch should not be reached')
288
+ },
289
+ }
290
+ const { dispatcher, resolveChildId, callCapOnChild } = localDispatcherFake({
291
+ capName: 'stream-catalog',
292
+ deviceId: 7,
293
+ childId: 'child-reolink',
294
+ })
295
+
296
+ const handler = createParentUnownedCallHandler({
297
+ getResolver: () => resolver as unknown as CapRouteResolver,
298
+ broker,
299
+ nodeRegistry: nodeRegistryFake(), // empty — hub-local child is NOT in HubNodeRegistry
300
+ getLocalDispatcher: () => dispatcher,
301
+ })
302
+
303
+ const result = await handler({
304
+ capName: 'stream-catalog',
305
+ method: 'getCatalog',
306
+ args: { deviceId: 7 },
307
+ })
308
+ expect(result).toEqual({ routedOverUds: 'child-reolink', capName: 'stream-catalog' })
309
+ expect(resolveChildId).toHaveBeenCalledWith('stream-catalog', 7)
310
+ expect(callCapOnChild).toHaveBeenCalledWith('child-reolink', {
311
+ capName: 'stream-catalog',
312
+ method: 'getCatalog',
313
+ args: { deviceId: 7 },
314
+ deviceId: 7,
315
+ })
316
+ expect(broker.call).not.toHaveBeenCalled()
317
+ })
318
+
319
+ it('remote owner: hub-local dispatcher misses → pinned broker call', async () => {
320
+ const broker = hubBrokerFake()
321
+ const resolver = {
322
+ resolveCapRoute: (capName: string) => {
323
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
324
+ },
325
+ dispatch: async () => {
326
+ throw new Error('dispatch should not be reached')
327
+ },
328
+ }
329
+ const { dispatcher, callCapOnChild } = localDispatcherFake() // no local owner
330
+ const owner: NodeNativeCapEntry = {
331
+ nodeId: 'agent',
332
+ addonId: 'reolink',
333
+ capName: 'stream-catalog',
334
+ deviceId: 7,
335
+ }
336
+
337
+ const handler = createParentUnownedCallHandler({
338
+ getResolver: () => resolver as unknown as CapRouteResolver,
339
+ broker,
340
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
341
+ getLocalDispatcher: () => dispatcher,
342
+ })
343
+
344
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(
345
+ () => undefined,
346
+ )
347
+
348
+ expect(callCapOnChild).not.toHaveBeenCalled()
349
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
350
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
351
+ })
352
+
353
+ it('no local dispatcher (getter returns null): behaves as before — HubNodeRegistry → broker', async () => {
354
+ const broker = hubBrokerFake()
355
+ const resolver = {
356
+ resolveCapRoute: (capName: string) => {
357
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
358
+ },
359
+ dispatch: async () => {
360
+ throw new Error('dispatch should not be reached')
361
+ },
362
+ }
363
+ const owner: NodeNativeCapEntry = {
364
+ nodeId: 'agent',
365
+ addonId: 'reolink',
366
+ capName: 'stream-catalog',
367
+ deviceId: 7,
368
+ }
369
+
370
+ const handler = createParentUnownedCallHandler({
371
+ getResolver: () => resolver as unknown as CapRouteResolver,
372
+ broker,
373
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
374
+ getLocalDispatcher: () => null,
375
+ })
376
+
377
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(
378
+ () => undefined,
379
+ )
380
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
381
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
382
+ })
123
383
  })
@@ -46,7 +46,10 @@ import type { StreamProbeService } from '../core/streaming/stream-probe.service.
46
46
  */
47
47
  interface RegisterNodeDriver {
48
48
  onRegisterNode: (params: RegisterNodeParams) => void
49
- createCapabilityProxy: (capabilityName: string, nodeId: string) => Record<string, (params: unknown) => Promise<unknown>> | null
49
+ createCapabilityProxy: (
50
+ capabilityName: string,
51
+ nodeId: string,
52
+ ) => Record<string, (params: unknown) => Promise<unknown>> | null
50
53
  }
51
54
 
52
55
  /** Build a harness whose declared caps are SINGLETON, for active-provider preference tests. */
@@ -55,7 +58,10 @@ function createSingletonHarness(capNames: readonly string[]): Harness {
55
58
  }
56
59
 
57
60
  /** A minimal real `CapabilityDefinition` so `getDefinition`/`expandCapMethods` resolve. */
58
- function makeCapDef(name: string, mode: 'collection' | 'singleton' = 'collection'): CapabilityDefinition {
61
+ function makeCapDef(
62
+ name: string,
63
+ mode: 'collection' | 'singleton' = 'collection',
64
+ ): CapabilityDefinition {
59
65
  return {
60
66
  name,
61
67
  scope: 'system',
@@ -98,7 +104,10 @@ interface Harness {
98
104
  * The broker is never started, so there is nothing to stop in teardown —
99
105
  * no `afterEach` teardown is needed.
100
106
  */
101
- function createHarness(capNames: readonly string[], mode: 'collection' | 'singleton' = 'collection'): Harness {
107
+ function createHarness(
108
+ capNames: readonly string[],
109
+ mode: 'collection' | 'singleton' = 'collection',
110
+ ): Harness {
102
111
  const registry = new CapabilityRegistry(makeLogger())
103
112
  for (const name of capNames) {
104
113
  registry.declareCapability(makeCapDef(name, mode))
@@ -248,8 +257,8 @@ describe('MoleculerService.applyNodeManifest — singleton local-first preferenc
248
257
 
249
258
  it('prefers the hub-local provider when the REMOTE one registered first', () => {
250
259
  const { driver, registry } = createSingletonHarness(['pipeline-executor'])
251
- driver.onRegisterNode(singleton(REMOTE)) // agent first → would win under first-wins
252
- driver.onRegisterNode(singleton(LOCAL)) // hub-local second
260
+ driver.onRegisterNode(singleton(REMOTE)) // agent first → would win under first-wins
261
+ driver.onRegisterNode(singleton(LOCAL)) // hub-local second
253
262
  // Active provider must be the hub-local key (bare addonId, no '@').
254
263
  expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
255
264
  expect(registry.getSingletonAddonId('pipeline-executor')).not.toContain('@')
@@ -257,8 +266,8 @@ describe('MoleculerService.applyNodeManifest — singleton local-first preferenc
257
266
 
258
267
  it('keeps the hub-local provider active when a REMOTE one registers afterwards', () => {
259
268
  const { driver, registry } = createSingletonHarness(['pipeline-executor'])
260
- driver.onRegisterNode(singleton(LOCAL)) // hub-local first
261
- driver.onRegisterNode(singleton(REMOTE)) // agent second must NOT steal active
269
+ driver.onRegisterNode(singleton(LOCAL)) // hub-local first
270
+ driver.onRegisterNode(singleton(REMOTE)) // agent second must NOT steal active
262
271
  expect(registry.getSingletonAddonId('pipeline-executor')).toBe('detection-pipeline')
263
272
  })
264
273
  })
@@ -38,7 +38,10 @@ interface FakeHubLocalRegistry extends HubLocalChildDispatcher {
38
38
  function makeDeviceAwareHubLocalRegistry(
39
39
  caps: ReadonlyMap<string, ReadonlyMap<number | 'singleton', string>>,
40
40
  ): FakeHubLocalRegistry {
41
- const callCapOnChildSpy = vi.fn(async (_childId: string, _input: unknown) => ({ ok: true, from: 'uds' }))
41
+ const callCapOnChildSpy = vi.fn(async (_childId: string, _input: unknown) => ({
42
+ ok: true,
43
+ from: 'uds',
44
+ }))
42
45
  return {
43
46
  resolveChildId: (capName: string, deviceId?: number): string | null => {
44
47
  const capMap = caps.get(capName)
@@ -79,8 +82,7 @@ function makeNativeAwareNodeAuthority(
79
82
  return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName)
80
83
  },
81
84
 
82
- nodeIsAgent: (nodeId: string): boolean =>
83
- nodeId !== HUB_NODE_ID && !nodeId.includes('/'),
85
+ nodeIsAgent: (nodeId: string): boolean => nodeId !== HUB_NODE_ID && !nodeId.includes('/'),
84
86
 
85
87
  nodeOnline: (nodeId: string): boolean => onlineNodes.has(nodeId),
86
88
 
@@ -104,7 +106,9 @@ function makeNativeAwareNodeAuthority(
104
106
 
105
107
  isNativeCap: (nodeId: string, capName: string, deviceId?: number): boolean => {
106
108
  if (deviceId !== undefined) {
107
- return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId)
109
+ return nativeCaps.some(
110
+ (n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId,
111
+ )
108
112
  }
109
113
  return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName)
110
114
  },
@@ -119,15 +123,22 @@ describe('Task5 – device-scoped native cap on hub-local child', () => {
119
123
  it('resolves to hub-local-uds and calls callCapOnChild with {capName, method, args+deviceId, deviceId}', async () => {
120
124
  // ptz is a device-scoped native cap owned by hub child 'provider-reolink'
121
125
  const hubLocalCaps = makeDeviceAwareHubLocalRegistry(
122
- new Map([
123
- ['ptz', new Map([[7, 'provider-reolink']])],
124
- ]),
126
+ new Map([['ptz', new Map([[7, 'provider-reolink']])]]),
125
127
  )
126
128
 
127
129
  const nativeCaps: NativeCapSpec[] = [
128
- { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
130
+ {
131
+ nodeId: 'hub/provider-reolink',
132
+ addonId: 'addon-provider-reolink',
133
+ capName: 'ptz',
134
+ deviceId: 7,
135
+ },
129
136
  ]
130
- const nodeAuthority = makeNativeAwareNodeAuthority(new Map(), new Set(['hub/provider-reolink']), nativeCaps)
137
+ const nodeAuthority = makeNativeAwareNodeAuthority(
138
+ new Map(),
139
+ new Set(['hub/provider-reolink']),
140
+ nativeCaps,
141
+ )
131
142
 
132
143
  const deps: CapRouteResolverDeps = {
133
144
  hubNodeId: HUB_NODE_ID,
@@ -149,7 +160,10 @@ describe('Task5 – device-scoped native cap on hub-local child', () => {
149
160
  expect(result).toEqual({ ok: true, from: 'uds' })
150
161
 
151
162
  expect(hubLocalCaps.callCapOnChildSpy).toHaveBeenCalledOnce()
152
- const [calledChildId, calledInput] = hubLocalCaps.callCapOnChildSpy.mock.calls[0] as [string, unknown]
163
+ const [calledChildId, calledInput] = hubLocalCaps.callCapOnChildSpy.mock.calls[0] as [
164
+ string,
165
+ unknown,
166
+ ]
153
167
  expect(calledChildId).toBe('provider-reolink')
154
168
  expect(calledInput).toMatchObject({
155
169
  capName: 'ptz',
@@ -191,9 +205,7 @@ describe('Task5 – d9ba709 regression: singleton prefers hub-local over remote'
191
205
  it('singleton cap provided by both hub-local child AND remote node classifies to hub-local-uds (no nodeId given)', () => {
192
206
  // 'pipeline-executor' is on BOTH local child addon-detection-pipeline AND remote-agent-0
193
207
  const hubLocalCaps = makeDeviceAwareHubLocalRegistry(
194
- new Map([
195
- ['pipeline-executor', new Map([['singleton', 'addon-detection-pipeline']])],
196
- ]),
208
+ new Map([['pipeline-executor', new Map([['singleton', 'addon-detection-pipeline']])]]),
197
209
  )
198
210
 
199
211
  const systemCaps = new Map([
@@ -307,7 +319,12 @@ describe('Task5 – remote native cap uses NATIVE_PROVIDER_SERVICE_INFIX in acti
307
319
  describe('Task5 – nodeKnowsCap includes native caps', () => {
308
320
  it('nodeKnowsCap returns true when the node has a native cap entry (even with no manifest cap)', () => {
309
321
  const nativeCaps: NativeCapSpec[] = [
310
- { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
322
+ {
323
+ nodeId: 'hub/provider-reolink',
324
+ addonId: 'addon-provider-reolink',
325
+ capName: 'ptz',
326
+ deviceId: 7,
327
+ },
311
328
  ]
312
329
  const nodeAuthority = makeNativeAwareNodeAuthority(
313
330
  new Map(), // no system caps
@@ -338,14 +355,20 @@ describe('native-fallback – wrapper must not shadow the hub-local native child
338
355
  // snapshot has an in-hub wrapper AND a forked native child for device 7.
339
356
  const wrapperRef: InProcessProviderRef = { invoke: vi.fn(async () => ({ from: 'wrapper' })) }
340
357
 
341
- function makeSnapshotResolver(): { resolver: CapRouteResolver; hubLocalCaps: FakeHubLocalRegistry } {
358
+ function makeSnapshotResolver(): {
359
+ resolver: CapRouteResolver
360
+ hubLocalCaps: FakeHubLocalRegistry
361
+ } {
342
362
  const hubLocalCaps = makeDeviceAwareHubLocalRegistry(
343
- new Map([
344
- ['snapshot', new Map([[7, 'provider-reolink']])],
345
- ]),
363
+ new Map([['snapshot', new Map([[7, 'provider-reolink']])]]),
346
364
  )
347
365
  const nativeCaps: NativeCapSpec[] = [
348
- { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'snapshot', deviceId: 7 },
366
+ {
367
+ nodeId: 'hub/provider-reolink',
368
+ addonId: 'addon-provider-reolink',
369
+ capName: 'snapshot',
370
+ deviceId: 7,
371
+ },
349
372
  ]
350
373
  const nodeAuthority = makeNativeAwareNodeAuthority(
351
374
  new Map(),
@@ -17,7 +17,11 @@ import * as crypto from 'node:crypto'
17
17
  import { registerOauth2Routes } from '../api/oauth2/oauth2-routes.js'
18
18
  import { createOauthGrants } from '../../../../packages/core/src/builtins/local-auth/oauth-grants.js'
19
19
  import type { ISsoBridgeProvider } from '@camstack/types'
20
- import type { IOauthIntegrationProvider, IUserManagementProvider, TokenScope } from '@camstack/types'
20
+ import type {
21
+ IOauthIntegrationProvider,
22
+ IUserManagementProvider,
23
+ TokenScope,
24
+ } from '@camstack/types'
21
25
  import type { OauthSession } from '../../../../packages/core/src/builtins/local-auth/oauth-session-manager.js'
22
26
  import { SESSION_COOKIE } from '../auth/session-cookie.js'
23
27
 
@@ -59,7 +63,10 @@ function verifyHmacJwt(token: string): Record<string, unknown> | null {
59
63
  )
60
64
  if (sig !== expected) return null
61
65
  try {
62
- const decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record<string, unknown>
66
+ const decoded = JSON.parse(Buffer.from(body, 'base64url').toString('utf8')) as Record<
67
+ string,
68
+ unknown
69
+ >
63
70
  if (typeof decoded['exp'] === 'number' && decoded['exp'] < Math.floor(Date.now() / 1000)) {
64
71
  return null
65
72
  }
@@ -92,7 +99,12 @@ function fakeSessionManager() {
92
99
 
93
100
  return {
94
101
  store,
95
- async create(input: { userId: string; username: string; integrationId: string; scopes: TokenScope[] }): Promise<OauthSession> {
102
+ async create(input: {
103
+ userId: string
104
+ username: string
105
+ integrationId: string
106
+ scopes: TokenScope[]
107
+ }): Promise<OauthSession> {
96
108
  const now = Date.now()
97
109
  const session: OauthSession = {
98
110
  id: `session-${++seq}`,
@@ -139,7 +151,13 @@ const VALID_SESSION_TOKEN = 'valid-session-token-abc123'
139
151
  const ALEXA_DESCRIPTOR = {
140
152
  integrationId: 'export-alexa',
141
153
  displayName: 'Alexa Smart Home',
142
- requestedScopes: [{ type: 'category' as const, target: 'device' as const, access: ['view', 'create'] as ('view' | 'create' | 'delete')[] }],
154
+ requestedScopes: [
155
+ {
156
+ type: 'category' as const,
157
+ target: 'device' as const,
158
+ access: ['view', 'create'] as ('view' | 'create' | 'delete')[],
159
+ },
160
+ ],
143
161
  allowedRedirectPrefixes: ['https://cb.example/'],
144
162
  }
145
163
 
@@ -204,7 +222,10 @@ function buildFakeRegistry(
204
222
 
205
223
  // ─── Test setup ───────────────────────────────────────────────────────────────
206
224
 
207
- function buildApp(grants: ReturnType<typeof createOauthGrants>, sessionManager: ReturnType<typeof fakeSessionManager>) {
225
+ function buildApp(
226
+ grants: ReturnType<typeof createOauthGrants>,
227
+ sessionManager: ReturnType<typeof fakeSessionManager>,
228
+ ) {
208
229
  const fastify = Fastify({ logger: false })
209
230
  void fastify.register(cookie)
210
231
 
@@ -291,7 +312,11 @@ describe('OAuth2 account-linking flow', () => {
291
312
 
292
313
  expect(res.statusCode).toBe(302)
293
314
  const location = res.headers['location'] as string
294
- expect(location).toMatch(new RegExp(`^${REDIRECT_URI.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\?code=.+&state=${STATE}`))
315
+ expect(location).toMatch(
316
+ new RegExp(
317
+ `^${REDIRECT_URI.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\?code=.+&state=${STATE}`,
318
+ ),
319
+ )
295
320
  })
296
321
 
297
322
  // ── Case 4 ──────────────────────────────────────────────────────────────────
@@ -336,7 +361,12 @@ describe('OAuth2 account-linking flow', () => {
336
361
  })
337
362
 
338
363
  expect(tokenRes.statusCode).toBe(200)
339
- const json = tokenRes.json<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }>()
364
+ const json = tokenRes.json<{
365
+ access_token: string
366
+ refresh_token: string
367
+ expires_in: number
368
+ token_type: string
369
+ }>()
340
370
  expect(json).toMatchObject({
341
371
  expires_in: 3600,
342
372
  token_type: 'Bearer',
@@ -550,7 +580,12 @@ describe('OAuth2 account-linking flow', () => {
550
580
  })
551
581
 
552
582
  expect(refreshRes.statusCode).toBe(200)
553
- const refreshJson = refreshRes.json<{ access_token: string; refresh_token: string; expires_in: number; token_type: string }>()
583
+ const refreshJson = refreshRes.json<{
584
+ access_token: string
585
+ refresh_token: string
586
+ expires_in: number
587
+ token_type: string
588
+ }>()
554
589
  expect(refreshJson).toMatchObject({ expires_in: 3600, token_type: 'Bearer' })
555
590
  expect(typeof refreshJson.access_token).toBe('string')
556
591
  expect(refreshJson.access_token.length).toBeGreaterThan(0)
@@ -604,7 +639,11 @@ describe('OAuth2 account-linking flow', () => {
604
639
  describe('session registry lifecycle', () => {
605
640
  // Shared helper: run the full authorize → consent → exchange flow and
606
641
  // return the issued token pair plus the raw location URL.
607
- async function doFullFlow(): Promise<{ accessToken: string; refreshToken: string; location: string }> {
642
+ async function doFullFlow(): Promise<{
643
+ accessToken: string
644
+ refreshToken: string
645
+ location: string
646
+ }> {
608
647
  const authorizeBody = new URLSearchParams({
609
648
  consent: 'allow',
610
649
  integration: 'export-alexa',
@@ -661,9 +700,7 @@ describe('OAuth2 account-linking flow', () => {
661
700
 
662
701
  // Scopes must contain the category/device entry declared in ALEXA_DESCRIPTOR.
663
702
  expect(Array.isArray(session.scopes)).toBe(true)
664
- const deviceScope = session.scopes.find(
665
- (s) => s.type === 'category' && s.target === 'device',
666
- )
703
+ const deviceScope = session.scopes.find((s) => s.type === 'category' && s.target === 'device')
667
704
  expect(deviceScope).toBeDefined()
668
705
  })
669
706
 
@@ -692,7 +729,9 @@ describe('OAuth2 account-linking flow', () => {
692
729
  const sessionId = sessions[0]!.id
693
730
 
694
731
  // Revoke via the fake user-management method (same as the real addon does).
695
- const revokeResult = await sessionManager.markRevoked(sessionId).then((ok) => ({ success: ok }))
732
+ const revokeResult = await sessionManager
733
+ .markRevoked(sessionId)
734
+ .then((ok) => ({ success: ok }))
696
735
  expect(revokeResult).toEqual({ success: true })
697
736
 
698
737
  // The session must now carry a non-null revokedAt.
@@ -729,7 +768,9 @@ describe('OAuth2 account-linking flow', () => {
729
768
 
730
769
  // ── Case e ────────────────────────────────────────────────────────────────
731
770
  it('e) revokeOauthSession with unknown id → {success: false}', async () => {
732
- const result = await sessionManager.markRevoked('non-existent-session-id').then((ok) => ({ success: ok }))
771
+ const result = await sessionManager
772
+ .markRevoked('non-existent-session-id')
773
+ .then((ok) => ({ success: ok }))
733
774
  expect(result).toEqual({ success: false })
734
775
  })
735
776
  })
@@ -777,7 +818,8 @@ describe('integration-driven hubUrl claim (Phase A public-origin bridge)', () =>
777
818
  registerOauth2Routes(fastify, {
778
819
  getRegistry: () => reg as any,
779
820
  verifyToken: (token: string) => {
780
- if (token === VALID_SESSION_TOKEN) return { userId: OPERATOR_USER_ID, username: OPERATOR_USERNAME }
821
+ if (token === VALID_SESSION_TOKEN)
822
+ return { userId: OPERATOR_USER_ID, username: OPERATOR_USERNAME }
781
823
  throw new Error('invalid token')
782
824
  },
783
825
  publicHubUrl: () => GLOBAL_FALLBACK,
@@ -785,7 +827,9 @@ describe('integration-driven hubUrl claim (Phase A public-origin bridge)', () =>
785
827
  return fastify
786
828
  }
787
829
 
788
- async function issuedCodeHubUrl(app: ReturnType<typeof buildAppWithDescriptor>): Promise<unknown> {
830
+ async function issuedCodeHubUrl(
831
+ app: ReturnType<typeof buildAppWithDescriptor>,
832
+ ): Promise<unknown> {
789
833
  const body = new URLSearchParams({
790
834
  consent: 'allow',
791
835
  integration: 'export-alexa',
@@ -805,7 +849,9 @@ describe('integration-driven hubUrl claim (Phase A public-origin bridge)', () =>
805
849
  expect(res.statusCode).toBe(302)
806
850
  const loc = res.headers['location'] as string
807
851
  const code = decodeURIComponent(loc.match(/[?&]code=([^&]+)/)![1]!)
808
- const payload = JSON.parse(Buffer.from(code.split('.')[1]!, 'base64url').toString('utf8')) as Record<string, unknown>
852
+ const payload = JSON.parse(
853
+ Buffer.from(code.split('.')[1]!, 'base64url').toString('utf8'),
854
+ ) as Record<string, unknown>
809
855
  return payload['hubUrl']
810
856
  }
811
857