@camstack/server 0.1.6 → 0.1.7

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 (42) hide show
  1. package/package.json +1 -1
  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-bulk-update.spec.ts +388 -0
  6. package/src/__tests__/cap-route-adapter.spec.ts +289 -0
  7. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +123 -0
  8. package/src/__tests__/cap-routers/capabilities-node.spec.ts +55 -0
  9. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +30 -0
  10. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +249 -0
  11. package/src/__tests__/framework-installer-defer-restart.spec.ts +165 -0
  12. package/src/__tests__/moleculer/uds-readiness.spec.ts +143 -0
  13. package/src/__tests__/moleculer/uds-topology.spec.ts +390 -0
  14. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +123 -0
  15. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +39 -4
  16. package/src/__tests__/native-cap-route.spec.ts +404 -0
  17. package/src/__tests__/oauth2-account-linking.spec.ts +85 -0
  18. package/src/__tests__/uds-addon-call-wiring.spec.ts +237 -0
  19. package/src/__tests__/uds-log-ingest.spec.ts +183 -0
  20. package/src/api/addon-upload.ts +27 -1
  21. package/src/api/capabilities.router.ts +1 -1
  22. package/src/api/core/bulk-update-coordinator.ts +302 -0
  23. package/src/api/core/cap-providers.ts +59 -6
  24. package/src/api/core/capabilities.router.ts +26 -3
  25. package/src/api/oauth2/oauth2-routes.ts +5 -1
  26. package/src/api/trpc/__tests__/client-ip.spec.ts +120 -0
  27. package/src/api/trpc/cap-route-error-formatter.ts +163 -0
  28. package/src/api/trpc/client-ip.ts +130 -0
  29. package/src/api/trpc/generated-cap-mounts.ts +19 -1
  30. package/src/api/trpc/generated-cap-routers.ts +180 -1
  31. package/src/api/trpc/trpc.middleware.ts +5 -1
  32. package/src/api/trpc/trpc.router.ts +45 -0
  33. package/src/core/addon/addon-call-gateway.ts +157 -0
  34. package/src/core/addon/addon-package.service.ts +9 -0
  35. package/src/core/addon/addon-registry.service.ts +364 -105
  36. package/src/core/addon/addon-settings-provider.ts +40 -116
  37. package/src/core/capability/capability.service.ts +9 -0
  38. package/src/core/moleculer/cap-call-fn.spec.ts +166 -0
  39. package/src/core/moleculer/cap-call-fn.ts +103 -0
  40. package/src/core/moleculer/cap-route-authority.ts +182 -0
  41. package/src/core/moleculer/moleculer.service.ts +380 -36
  42. package/src/main.ts +45 -12
@@ -0,0 +1,400 @@
1
+ /**
2
+ * Task G2 — single cap-ownership authority (consolidation)
3
+ *
4
+ * This test pins the consolidated behavior: the `setNativeFallback` closure
5
+ * consults the CapRouteResolver FIRST for hub-local native caps, and only
6
+ * falls through to `resolveNativeCapOwnerSync` for remote native caps.
7
+ *
8
+ * The three canonical cases:
9
+ * (a) Hub-local child device-scoped native cap → resolver classifies hub-local-uds;
10
+ * `resolveNativeCapOwnerSync` is NOT consulted.
11
+ * (b) Remote device-scoped native cap → resolver does not find hub-local;
12
+ * `resolveNativeCapOwnerSync` is consulted and `buildNativeCapProxy` is used.
13
+ * (c) Cap absent entirely → neither resolver nor resolveNativeCapOwnerSync
14
+ * know the owner; fallback returns null.
15
+ *
16
+ * Behavior parity: the old flow called resolveNativeCapOwnerSync first and then
17
+ * forked on nodeId prefix. The new flow queries the resolver first for hub-local
18
+ * (skipping resolveNativeCapOwnerSync in that branch) and falls through to
19
+ * resolveNativeCapOwnerSync only for the remote branch. Both flows return
20
+ * identical proxy objects / null for the same inputs.
21
+ */
22
+
23
+ import { describe, it, expect, vi } from 'vitest'
24
+ import type { NodeCapAuthority, HubLocalChildDispatcher, CapRouteResolverDeps } from '@camstack/kernel'
25
+ import { CapRouteResolver, CapRouteError } from '@camstack/kernel'
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helper: build the consolidated native-cap fallback (the G2 implementation)
29
+ // ---------------------------------------------------------------------------
30
+
31
+ /**
32
+ * The G2-consolidated `setNativeFallback` implementation.
33
+ *
34
+ * Resolution order:
35
+ * 1. Try the resolver for hub-local (avoids resolveNativeCapOwnerSync entirely
36
+ * when the resolver's snapshot knows the child).
37
+ * 2. If no hub-local route: consult resolveNativeCapOwnerSync for remote caps.
38
+ * 3. If not remote either: return null.
39
+ */
40
+ function buildConsolidatedNativeFallback(opts: {
41
+ readonly resolver: CapRouteResolver | null
42
+ readonly hubNodeId: string
43
+ readonly resolveNativeCapOwnerSync: (capName: string, deviceId: number) => { addonId: string; nodeId: string } | null
44
+ readonly buildNativeCapProxy: (addonId: string, capName: string, deviceId: number) => Record<string, unknown>
45
+ }): (capName: string, deviceId: number) => unknown | null {
46
+ const { resolver, hubNodeId, resolveNativeCapOwnerSync, buildNativeCapProxy } = opts
47
+
48
+ return (capName: string, deviceId: number): unknown | null => {
49
+ // 1. Hub-local: resolver is the single authority. No resolveNativeCapOwnerSync needed.
50
+ if (resolver !== null) {
51
+ try {
52
+ const route = resolver.resolveCapRoute(capName, { nodeId: hubNodeId, deviceId })
53
+ if (route.kind === 'hub-local-uds') {
54
+ // Build a proxy that routes every method through the resolver.
55
+ const proxy: Record<string, (input: unknown) => Promise<unknown>> = {}
56
+ // Proxy is returned as a property-access object (same shape as before)
57
+ return new Proxy(proxy, {
58
+ get(_target, property): ((input: unknown) => Promise<unknown>) | undefined {
59
+ if (typeof property !== 'string') return undefined
60
+ return (input: unknown): Promise<unknown> => {
61
+ const mergedInput =
62
+ typeof input === 'object' && input !== null
63
+ ? { ...input, deviceId }
64
+ : { deviceId }
65
+ const r = resolver.resolveCapRoute(capName, { nodeId: hubNodeId, deviceId })
66
+ return resolver.dispatch(r, property, mergedInput)
67
+ }
68
+ },
69
+ })
70
+ }
71
+ } catch (err) {
72
+ // resolver throws CapRouteError for no-provider/node-offline — fall through to remote
73
+ if (!(err instanceof CapRouteError)) throw err
74
+ }
75
+ }
76
+
77
+ // 2. Remote: consult resolveNativeCapOwnerSync (includes push-fed remoteNativeCaps).
78
+ const owner = resolveNativeCapOwnerSync(capName, deviceId)
79
+ if (!owner) return null
80
+
81
+ // Only build a remote proxy for genuinely-remote nodes; hub-local already handled above.
82
+ if (!owner.nodeId.startsWith(`${hubNodeId}/`)) {
83
+ return buildNativeCapProxy(owner.addonId, capName, deviceId)
84
+ }
85
+
86
+ // Edge case: resolver was null OR didn't find the hub-local route but resolveNativeCapOwnerSync
87
+ // says hub-local. This can happen during startup before the resolver is initialised.
88
+ // Fall through to null — the caller will retry when the resolver is ready.
89
+ return null
90
+ }
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Helpers to build test doubles
95
+ // ---------------------------------------------------------------------------
96
+
97
+ interface FakeHubLocalRegistry extends HubLocalChildDispatcher {
98
+ readonly callSpy: ReturnType<typeof vi.fn>
99
+ }
100
+
101
+ function makeHubLocalRegistry(
102
+ caps: ReadonlyMap<string, ReadonlyMap<number | 'singleton', string>>,
103
+ ): FakeHubLocalRegistry {
104
+ const callSpy = vi.fn(async (_childId: string, _input: unknown) => ({ ok: true, from: 'uds' }))
105
+ return {
106
+ resolveChildId: (capName: string, deviceId?: number): string | null => {
107
+ const capMap = caps.get(capName)
108
+ if (capMap === undefined) return null
109
+ if (deviceId !== undefined) {
110
+ const deviceSpecific = capMap.get(deviceId)
111
+ if (deviceSpecific !== undefined) return deviceSpecific
112
+ }
113
+ return capMap.get('singleton') ?? null
114
+ },
115
+ callCapOnChild: callSpy,
116
+ callSpy,
117
+ }
118
+ }
119
+
120
+ interface NativeCapSpec {
121
+ readonly nodeId: string
122
+ readonly addonId: string
123
+ readonly capName: string
124
+ readonly deviceId: number
125
+ }
126
+
127
+ function makeNodeAuthority(
128
+ systemCaps: ReadonlyMap<string, { addonId: string; nodeId: string }>,
129
+ onlineNodes: ReadonlySet<string>,
130
+ nativeCaps: readonly NativeCapSpec[],
131
+ ): NodeCapAuthority {
132
+ return {
133
+ nodeKnowsCap: (nodeId: string, capName: string): boolean => {
134
+ const sys = systemCaps.get(capName)
135
+ if (sys !== undefined && sys.nodeId === nodeId) return true
136
+ return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName)
137
+ },
138
+ nodeIsAgent: (nodeId: string): boolean => nodeId !== 'hub' && !nodeId.includes('/'),
139
+ nodeOnline: (nodeId: string): boolean => onlineNodes.has(nodeId),
140
+ listNodeIds: (): readonly string[] => {
141
+ const ids = new Set<string>()
142
+ for (const spec of systemCaps.values()) ids.add(spec.nodeId)
143
+ for (const n of nativeCaps) ids.add(n.nodeId)
144
+ return [...ids]
145
+ },
146
+ getAddonId: (nodeId: string, capName: string): string | null => {
147
+ const sys = systemCaps.get(capName)
148
+ if (sys !== undefined && sys.nodeId === nodeId) return sys.addonId
149
+ const nat = nativeCaps.find((n) => n.nodeId === nodeId && n.capName === capName)
150
+ return nat?.addonId ?? null
151
+ },
152
+ getAgentChildId: (): string | null => null,
153
+ isNativeCap: (nodeId: string, capName: string, deviceId?: number): boolean => {
154
+ if (deviceId !== undefined) {
155
+ return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName && n.deviceId === deviceId)
156
+ }
157
+ return nativeCaps.some((n) => n.nodeId === nodeId && n.capName === capName)
158
+ },
159
+ }
160
+ }
161
+
162
+ const HUB_NODE_ID = 'hub'
163
+
164
+ // ---------------------------------------------------------------------------
165
+ // Test (a): hub-local child device-scoped native cap
166
+ // ---------------------------------------------------------------------------
167
+
168
+ describe('G2 — (a) hub-local device-scoped native cap: resolver is consulted first, resolveNativeCapOwnerSync is NOT called', () => {
169
+ it('builds a proxy without consulting resolveNativeCapOwnerSync when resolver finds hub-local-uds', async () => {
170
+ const hubLocalRegistry = makeHubLocalRegistry(
171
+ new Map([['ptz', new Map([[7, 'provider-reolink']])]]),
172
+ )
173
+ const nativeCaps: NativeCapSpec[] = [
174
+ { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
175
+ ]
176
+ const nodeAuthority = makeNodeAuthority(new Map(), new Set(['hub/provider-reolink']), nativeCaps)
177
+
178
+ const deps: CapRouteResolverDeps = {
179
+ hubNodeId: HUB_NODE_ID,
180
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
181
+ hubLocalRegistry,
182
+ nodeAuthority,
183
+ inProcessProviders: () => null,
184
+ }
185
+ const resolver = new CapRouteResolver(deps)
186
+
187
+ const resolveNativeCapOwnerSync = vi.fn()
188
+ const buildNativeCapProxy = vi.fn()
189
+
190
+ const fallback = buildConsolidatedNativeFallback({
191
+ resolver,
192
+ hubNodeId: HUB_NODE_ID,
193
+ resolveNativeCapOwnerSync,
194
+ buildNativeCapProxy,
195
+ })
196
+
197
+ const proxy = fallback('ptz', 7)
198
+
199
+ // Proxy is NOT null — hub-local route was found
200
+ expect(proxy).not.toBeNull()
201
+ expect(typeof proxy).toBe('object')
202
+
203
+ // resolveNativeCapOwnerSync was NOT called (resolver handled it)
204
+ expect(resolveNativeCapOwnerSync).not.toHaveBeenCalled()
205
+ // buildNativeCapProxy was NOT called (not a remote cap)
206
+ expect(buildNativeCapProxy).not.toHaveBeenCalled()
207
+ })
208
+
209
+ it('proxy built by the consolidated fallback routes to callCapOnChild via the resolver', async () => {
210
+ const hubLocalRegistry = makeHubLocalRegistry(
211
+ new Map([['ptz', new Map([[7, 'provider-reolink']])]]),
212
+ )
213
+ const nativeCaps: NativeCapSpec[] = [
214
+ { nodeId: 'hub/provider-reolink', addonId: 'addon-provider-reolink', capName: 'ptz', deviceId: 7 },
215
+ ]
216
+ const nodeAuthority = makeNodeAuthority(new Map(), new Set(['hub/provider-reolink']), nativeCaps)
217
+
218
+ const deps: CapRouteResolverDeps = {
219
+ hubNodeId: HUB_NODE_ID,
220
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
221
+ hubLocalRegistry,
222
+ nodeAuthority,
223
+ inProcessProviders: () => null,
224
+ }
225
+ const resolver = new CapRouteResolver(deps)
226
+
227
+ const fallback = buildConsolidatedNativeFallback({
228
+ resolver,
229
+ hubNodeId: HUB_NODE_ID,
230
+ resolveNativeCapOwnerSync: vi.fn(),
231
+ buildNativeCapProxy: vi.fn(),
232
+ })
233
+
234
+ const proxy = fallback('ptz', 7) as Record<string, (args: unknown) => Promise<unknown>>
235
+ expect(proxy).not.toBeNull()
236
+
237
+ // Invoke a method through the proxy — should reach callCapOnChild
238
+ const result = await proxy['move']!({ pan: 10 })
239
+ expect(result).toEqual({ ok: true, from: 'uds' })
240
+ expect(hubLocalRegistry.callSpy).toHaveBeenCalledOnce()
241
+ const [calledChildId, calledInput] = hubLocalRegistry.callSpy.mock.calls[0] as [string, unknown]
242
+ expect(calledChildId).toBe('provider-reolink')
243
+ expect(calledInput).toMatchObject({ capName: 'ptz', method: 'move', deviceId: 7 })
244
+ })
245
+ })
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Test (b): remote device-scoped native cap
249
+ // ---------------------------------------------------------------------------
250
+
251
+ describe('G2 — (b) remote device-scoped native cap: resolver does not find hub-local, resolveNativeCapOwnerSync IS called', () => {
252
+ it('falls through to resolveNativeCapOwnerSync and calls buildNativeCapProxy for remote caps', () => {
253
+ // No hub-local registry for this cap+device (resolver will throw no-provider for hub)
254
+ const hubLocalRegistry = makeHubLocalRegistry(new Map()) // empty: no hub-local caps
255
+ const nodeAuthority = makeNodeAuthority(new Map(), new Set([]), [])
256
+
257
+ const deps: CapRouteResolverDeps = {
258
+ hubNodeId: HUB_NODE_ID,
259
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
260
+ hubLocalRegistry,
261
+ nodeAuthority,
262
+ inProcessProviders: () => null,
263
+ }
264
+ const resolver = new CapRouteResolver(deps)
265
+
266
+ const resolveNativeCapOwnerSync = vi.fn().mockReturnValue({
267
+ addonId: 'addon-provider-reolink',
268
+ nodeId: 'dev-agent-0/provider-reolink', // remote node
269
+ })
270
+ const remoteProxy = { remote: true }
271
+ const buildNativeCapProxy = vi.fn().mockReturnValue(remoteProxy)
272
+
273
+ const fallback = buildConsolidatedNativeFallback({
274
+ resolver,
275
+ hubNodeId: HUB_NODE_ID,
276
+ resolveNativeCapOwnerSync,
277
+ buildNativeCapProxy,
278
+ })
279
+
280
+ const result = fallback('ptz', 7)
281
+
282
+ // resolveNativeCapOwnerSync was called (no hub-local route in resolver)
283
+ expect(resolveNativeCapOwnerSync).toHaveBeenCalledWith('ptz', 7)
284
+ // buildNativeCapProxy was called with the remote owner's addonId
285
+ expect(buildNativeCapProxy).toHaveBeenCalledWith('addon-provider-reolink', 'ptz', 7)
286
+ // The remote proxy is returned
287
+ expect(result).toBe(remoteProxy)
288
+ })
289
+ })
290
+
291
+ // ---------------------------------------------------------------------------
292
+ // Test (c): cap absent entirely
293
+ // ---------------------------------------------------------------------------
294
+
295
+ describe('G2 — (c) cap absent: returns null (no hub-local, no remote owner)', () => {
296
+ it('returns null when resolver finds no hub-local route AND resolveNativeCapOwnerSync returns null', () => {
297
+ const hubLocalRegistry = makeHubLocalRegistry(new Map())
298
+ const nodeAuthority = makeNodeAuthority(new Map(), new Set([]), [])
299
+
300
+ const deps: CapRouteResolverDeps = {
301
+ hubNodeId: HUB_NODE_ID,
302
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
303
+ hubLocalRegistry,
304
+ nodeAuthority,
305
+ inProcessProviders: () => null,
306
+ }
307
+ const resolver = new CapRouteResolver(deps)
308
+
309
+ const resolveNativeCapOwnerSync = vi.fn().mockReturnValue(null)
310
+ const buildNativeCapProxy = vi.fn()
311
+
312
+ const fallback = buildConsolidatedNativeFallback({
313
+ resolver,
314
+ hubNodeId: HUB_NODE_ID,
315
+ resolveNativeCapOwnerSync,
316
+ buildNativeCapProxy,
317
+ })
318
+
319
+ const result = fallback('ptz', 7)
320
+
321
+ expect(result).toBeNull()
322
+ expect(buildNativeCapProxy).not.toHaveBeenCalled()
323
+ })
324
+ })
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // Test (startup-window): resolver === null, resolveNativeCapOwnerSync returns a hub-local
328
+ // owner — must return null (no Moleculer proxy to a UDS-only child)
329
+ // ---------------------------------------------------------------------------
330
+
331
+ describe('G2 — (startup-window) resolver null, resolveNativeCapOwnerSync returns hub-local owner: returns null', () => {
332
+ it('returns null and does NOT call buildNativeCapProxy when resolver is null and owner is hub-local', () => {
333
+ // resolver === null simulates the window before the CapRouteResolver is initialised
334
+ const resolveNativeCapOwnerSync = vi.fn().mockReturnValue({
335
+ addonId: 'addon-provider-x',
336
+ nodeId: `${HUB_NODE_ID}/provider-x`, // hub-local UDS child — must NOT get a Moleculer proxy
337
+ })
338
+ const buildNativeCapProxy = vi.fn()
339
+
340
+ const fallback = buildConsolidatedNativeFallback({
341
+ resolver: null,
342
+ hubNodeId: HUB_NODE_ID,
343
+ resolveNativeCapOwnerSync,
344
+ buildNativeCapProxy,
345
+ })
346
+
347
+ const result = fallback('ptz', 7)
348
+
349
+ // Guard: hub-local UDS children must not receive a Moleculer proxy
350
+ expect(result).toBeNull()
351
+ expect(buildNativeCapProxy).not.toHaveBeenCalled()
352
+ // resolveNativeCapOwnerSync IS consulted (resolver skipped due to null)
353
+ expect(resolveNativeCapOwnerSync).toHaveBeenCalledWith('ptz', 7)
354
+ })
355
+ })
356
+
357
+ // ---------------------------------------------------------------------------
358
+ // Test: singleton (hub-in-process) cap resolves in-process, no resolveNativeCapOwnerSync
359
+ // ---------------------------------------------------------------------------
360
+
361
+ describe('G2 — (d) hub-in-process singleton: resolver classifies hub-in-process, resolveNativeCapOwnerSync not called', () => {
362
+ it('resolves hub-in-process singleton without consulting resolveNativeCapOwnerSync', () => {
363
+ const invokeSpy = vi.fn().mockResolvedValue({ ok: true })
364
+ const inProcessProviders = (_cap: string) =>
365
+ _cap === 'device-manager' ? { invoke: invokeSpy } : null
366
+
367
+ const deps: CapRouteResolverDeps = {
368
+ hubNodeId: HUB_NODE_ID,
369
+ broker: { call: vi.fn(), waitForServices: vi.fn() },
370
+ hubLocalRegistry: null,
371
+ nodeAuthority: makeNodeAuthority(new Map(), new Set([]), []),
372
+ inProcessProviders,
373
+ }
374
+ const resolver = new CapRouteResolver(deps)
375
+
376
+ const resolveNativeCapOwnerSync = vi.fn()
377
+ const buildNativeCapProxy = vi.fn()
378
+
379
+ const fallback = buildConsolidatedNativeFallback({
380
+ resolver,
381
+ hubNodeId: HUB_NODE_ID,
382
+ resolveNativeCapOwnerSync,
383
+ buildNativeCapProxy,
384
+ })
385
+
386
+ // For in-process singletons, the fallback is not called at all (CapabilityRegistry
387
+ // finds the provider directly). But if called with a deviceId for such a cap, the
388
+ // resolver will classify hub-in-process — not hub-local-uds — so we fall through
389
+ // to the remote branch and resolveNativeCapOwnerSync is consulted (returns null).
390
+ // This is the correct behavior: in-process singletons go through getNativeProvider
391
+ // on the local nativeProviders map, not through setNativeFallback.
392
+ //
393
+ // The consolidated fallback is specifically for CROSS-PROCESS native caps.
394
+ // The assertion here is that for a cap with NO hub-local-uds route AND no
395
+ // resolveNativeCapOwnerSync result, the fallback correctly returns null.
396
+ const result = fallback('device-manager', 7)
397
+ expect(result).toBeNull()
398
+ expect(buildNativeCapProxy).not.toHaveBeenCalled()
399
+ })
400
+ })