@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
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Routing regression spec for the `broker` system-collection cap.
3
+ *
4
+ * The bug: `broker` is `scope:'system', mode:'collection'`. Two addons
5
+ * register a `broker` provider, each owning a DISJOINT id set
6
+ * (mqtt-broker owns `mqtt_*`, provider-homeassistant owns `ha_*`). The
7
+ * generated collection mount only fans out the array-output methods
8
+ * (`list` / `listProviders`); every id-keyed method (`get` / `getSettings`
9
+ * / `remove` / `testConnection` / …) falls through to `providers[0]`
10
+ * (the FIRST-registered provider = mqtt-broker). So operating on an HA
11
+ * broker `ha_1` hit mqtt-broker → "broker 'ha_1' not found".
12
+ *
13
+ * The fix: each broker carries its owning `addonId`; the admin UI threads
14
+ * it back as the `{ addonId }` system-collection selector so the call
15
+ * routes to the OWNING provider via `getProviderByAddonId`. Providers
16
+ * also return `null` (not throw) for ids they don't own, so the
17
+ * no-addonId fallback degrades gracefully.
18
+ *
19
+ * This spec exercises `createCapRouter_broker` with the SAME selector
20
+ * the generated mount builds (registry-backed: addonId → provider, else
21
+ * first-provider aggregate with array methods fanned out). It registers
22
+ * two mock providers and asserts:
23
+ * - `list()` returns both, each tagged with its `addonId`.
24
+ * - `get({id:'ha_1'}, addonId:'ha')` routes to the HA provider.
25
+ * - `get({id:'ha_1'})` WITHOUT addonId returns null (graceful) — the
26
+ * HA-owned id isn't in providers[0]'s registry, so it doesn't throw.
27
+ * - `listProviders()` aggregates both providers' entries.
28
+ */
29
+ import { describe, it, expect } from 'vitest'
30
+ import { createCapRouter_broker } from '../../api/trpc/generated-cap-routers.js'
31
+ import { type IBrokerProvider, type BrokerInfo, type BrokerProviderInfo } from '@camstack/types'
32
+ import { concatCollection } from '../../api/trpc/cap-mount-helpers.js'
33
+ import { makeCtx, invokeProcedure } from './harness.js'
34
+
35
+ /**
36
+ * A self-contained mock `broker` provider that owns a fixed set of
37
+ * broker ids and self-tags every returned record with its `addonId`.
38
+ * Returns `null` / no-op for ids it does NOT own (never throws) — the
39
+ * exact graceful-degradation contract the real providers must honour.
40
+ */
41
+ function makeProvider(addonId: string, ownedIds: readonly string[], kind: string): IBrokerProvider {
42
+ const owns = (id: string): boolean => ownedIds.includes(id)
43
+ const infoFor = (id: string): BrokerInfo => ({
44
+ id,
45
+ addonId,
46
+ name: `${kind}-${id}`,
47
+ kind,
48
+ status: 'connected',
49
+ info: {},
50
+ lastCheckedAt: null,
51
+ error: null,
52
+ })
53
+ return {
54
+ list: async () => ownedIds.map(infoFor),
55
+ get: async ({ id }) => (owns(id) ? infoFor(id) : null),
56
+ listProviders: async (): Promise<BrokerProviderInfo[]> => [
57
+ { addonId, kinds: [{ kind, label: kind }] },
58
+ ],
59
+ add: async () => ({ id: 'new' }),
60
+ remove: async () => {
61
+ // no-op for foreign ids (and owned ids in this mock)
62
+ },
63
+ testConnection: async ({ id }) =>
64
+ owns(id) ? { ok: true, latencyMs: 1 } : { ok: false, error: 'unknown broker' },
65
+ getSettings: async ({ id }) => (owns(id) ? { secret: id } : null),
66
+ setSettings: async () => {
67
+ // no-op
68
+ },
69
+ getBrokerConfig: async ({ id }) => (owns(id) ? { url: id } : null),
70
+ getSettingsSchema: async ({ kind: k }) => (k === kind ? { form: kind } : null),
71
+ testSettings: async () => ({ ok: true }),
72
+ publish: async () => null,
73
+ subscribe: async () => ({ subscriptionId: 's' }),
74
+ unsubscribe: async () => {
75
+ // no-op
76
+ },
77
+ getState: async () => null,
78
+ getStatus: async () => ({ brokerCount: ownedIds.length, connectedCount: ownedIds.length }),
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Mirror of the generated collection mount selector for `broker`
84
+ * (`generated-cap-mounts.ts`): addonId → that provider directly; else a
85
+ * first-provider aggregate whose array-output methods are
86
+ * `concatCollection`-fanned. Two providers registered in order:
87
+ * mqtt (owns `mqtt_1`) first, ha (owns `ha_1`) second.
88
+ */
89
+ function makeSelector(): (addonId?: string) => IBrokerProvider | null {
90
+ const mqtt = makeProvider('mqtt', ['mqtt_1'], 'mqtt')
91
+ const ha = makeProvider('ha', ['ha_1'], 'home-assistant')
92
+ const byAddonId: Record<string, IBrokerProvider> = { mqtt, ha }
93
+ const providers: readonly IBrokerProvider[] = [mqtt, ha]
94
+ return (addonId?: string): IBrokerProvider | null => {
95
+ if (addonId !== undefined) return byAddonId[addonId] ?? null
96
+ const first = providers[0]!
97
+ return {
98
+ ...first,
99
+ list: concatCollection(providers, 'list') as IBrokerProvider['list'],
100
+ listProviders: concatCollection(
101
+ providers,
102
+ 'listProviders',
103
+ ) as IBrokerProvider['listProviders'],
104
+ }
105
+ }
106
+ }
107
+
108
+ describe('broker cap — addonId ownership routing', () => {
109
+ it('list() aggregates both providers, each broker tagged with its addonId', async () => {
110
+ const selector = makeSelector()
111
+ const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
112
+
113
+ const outcome = await invokeProcedure(router, 'list', makeCtx('admin'), {})
114
+
115
+ expect(outcome.ok).toBe(true)
116
+ if (!outcome.ok) return
117
+ const rows = outcome.value as BrokerInfo[]
118
+ const byId = new Map(rows.map((r) => [r.id, r]))
119
+ expect(byId.get('mqtt_1')?.addonId).toBe('mqtt')
120
+ expect(byId.get('ha_1')?.addonId).toBe('ha')
121
+ })
122
+
123
+ it('get({id:ha_1}, addonId:ha) routes to the HA provider (not mqtt-broker)', async () => {
124
+ const selector = makeSelector()
125
+ const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
126
+
127
+ const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), {
128
+ id: 'ha_1',
129
+ addonId: 'ha',
130
+ })
131
+
132
+ expect(outcome.ok).toBe(true)
133
+ if (!outcome.ok) return
134
+ const row = outcome.value as BrokerInfo | null
135
+ expect(row).not.toBeNull()
136
+ expect(row?.id).toBe('ha_1')
137
+ expect(row?.addonId).toBe('ha')
138
+ })
139
+
140
+ it('get({id:ha_1}) WITHOUT addonId returns null gracefully (first provider does not own it)', async () => {
141
+ const selector = makeSelector()
142
+ const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
143
+
144
+ const outcome = await invokeProcedure(router, 'get', makeCtx('admin'), { id: 'ha_1' })
145
+
146
+ expect(outcome.ok).toBe(true)
147
+ if (!outcome.ok) return
148
+ expect(outcome.value).toBeNull()
149
+ })
150
+
151
+ it('testConnection({id:ha_1}, addonId:ha) routes to HA provider and succeeds', async () => {
152
+ const selector = makeSelector()
153
+ const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
154
+
155
+ const outcome = await invokeProcedure(router, 'testConnection', makeCtx('admin'), {
156
+ id: 'ha_1',
157
+ addonId: 'ha',
158
+ })
159
+
160
+ expect(outcome.ok).toBe(true)
161
+ if (!outcome.ok) return
162
+ expect(outcome.value).toEqual({ ok: true, latencyMs: 1 })
163
+ })
164
+
165
+ it('listProviders() aggregates both providers entries', async () => {
166
+ const selector = makeSelector()
167
+ const router = createCapRouter_broker((_ctx, addonId) => selector(addonId))
168
+
169
+ const outcome = await invokeProcedure(router, 'listProviders', makeCtx('admin'))
170
+
171
+ expect(outcome.ok).toBe(true)
172
+ if (!outcome.ok) return
173
+ const entries = outcome.value as BrokerProviderInfo[]
174
+ const addonIds = entries.map((e) => e.addonId).toSorted()
175
+ expect(addonIds).toEqual(['ha', 'mqtt'])
176
+ })
177
+ })
@@ -13,7 +13,9 @@ import { formatTrpcError } from '../../api/trpc/cap-route-error-formatter.js'
13
13
  // ── Helpers ──────────────────────────────────────────────────────────────────
14
14
 
15
15
  /** Minimal DefaultErrorShape stub. */
16
- function makeShape(overrides: Partial<{ message: string }> = {}): Parameters<typeof formatTrpcError>[0]['shape'] {
16
+ function makeShape(
17
+ overrides: Partial<{ message: string }> = {},
18
+ ): Parameters<typeof formatTrpcError>[0]['shape'] {
17
19
  return {
18
20
  message: overrides.message ?? 'Something went wrong',
19
21
  code: -32603, // INTERNAL_SERVER_ERROR code number
@@ -15,14 +15,25 @@ import { createCapabilitiesRouter } from '../../api/core/capabilities.router.js'
15
15
  import { makeCtx } from './harness.js'
16
16
 
17
17
  function harness() {
18
- const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = { setActiveSingleton: [], clear: [] }
18
+ const calls: { setActiveSingleton: unknown[][]; clear: unknown[][] } = {
19
+ setActiveSingleton: [],
20
+ clear: [],
21
+ }
19
22
  const sets: Record<string, unknown> = {}
20
23
  const registry = {
21
- setActiveSingleton: vi.fn(async (...a: unknown[]) => { calls.setActiveSingleton.push(a) }),
22
- clearSingletonNodeOverride: vi.fn((...a: unknown[]) => { calls.clear.push(a) }),
24
+ setActiveSingleton: vi.fn(async (...a: unknown[]) => {
25
+ calls.setActiveSingleton.push(a)
26
+ }),
27
+ clearSingletonNodeOverride: vi.fn((...a: unknown[]) => {
28
+ calls.clear.push(a)
29
+ }),
23
30
  listCapabilities: () => [],
24
31
  } as unknown as CapabilityRegistry
25
- const config = { set: (k: string, v: unknown) => { sets[k] = v } } as unknown as ConfigService
32
+ const config = {
33
+ set: (k: string, v: unknown) => {
34
+ sets[k] = v
35
+ },
36
+ } as unknown as ConfigService
26
37
  const router = createCapabilitiesRouter(registry, config)
27
38
  return { router, calls, sets, registry }
28
39
  }
@@ -32,7 +43,9 @@ describe('capabilities.router — per-node singleton', () => {
32
43
  const { router, calls, sets } = harness()
33
44
  const caller = router.createCaller(makeCtx('admin'))
34
45
  await caller.setActiveSingleton({
35
- capability: 'webrtc-session', addonId: 'webrtc-native', nodeId: 'dev-agent-0',
46
+ capability: 'webrtc-session',
47
+ addonId: 'webrtc-native',
48
+ nodeId: 'dev-agent-0',
36
49
  })
37
50
  expect(calls.setActiveSingleton[0]).toEqual(['webrtc-session', 'webrtc-native', 'dev-agent-0'])
38
51
  expect(sets['capabilities.singletonNode.webrtc-session.dev-agent-0']).toBe('webrtc-native')
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tests that `requireDeviceScoped` overlays `getStatus` results via
3
+ * `device-manager`'s `resolveLinkedStatus` when the device has linked
4
+ * properties for the requested cap.
5
+ *
6
+ * The overlay is transparent (null return = no-op) for the vast majority
7
+ * of reads; only devices with active links for the cap get the merged
8
+ * status back.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import type { CapabilityRegistry } from '@camstack/kernel'
12
+ import { requireDeviceScoped } from '../../api/trpc/cap-mount-helpers.js'
13
+
14
+ // ── Fake registry ────────────────────────────────────────────────────────────
15
+
16
+ type ResolveLinkedStatus = (i: {
17
+ deviceId: number
18
+ cap: string
19
+ baseStatus: unknown
20
+ }) => Promise<Record<string, unknown> | null>
21
+
22
+ interface FakeDeviceManager {
23
+ resolveLinkedStatus: ResolveLinkedStatus
24
+ }
25
+
26
+ function makeRegistry(opts: {
27
+ nativeGetStatus: () => Promise<Record<string, unknown>>
28
+ nativeSetFanSpeed?: (i: unknown) => Promise<void>
29
+ resolveLinkedStatus: ResolveLinkedStatus
30
+ }): CapabilityRegistry {
31
+ const nativeProvider = {
32
+ getStatus: opts.nativeGetStatus,
33
+ setFanSpeed: opts.nativeSetFanSpeed ?? vi.fn(async () => undefined),
34
+ }
35
+ const deviceManager: FakeDeviceManager = {
36
+ resolveLinkedStatus: opts.resolveLinkedStatus,
37
+ }
38
+ return {
39
+ getNativeProvider<T>(_capName: string, _deviceId: number): T | null {
40
+ return nativeProvider as unknown as T
41
+ },
42
+ getSingleton<T>(capability: string): T | null {
43
+ if (capability === 'device-manager') {
44
+ return deviceManager as unknown as T
45
+ }
46
+ return null
47
+ },
48
+ // Minimal no-op stubs for the rest of the CapabilityRegistry surface
49
+ // so TypeScript is satisfied without pulling in the real kernel class.
50
+ listCapabilities: vi.fn(() => []),
51
+ registerProvider: vi.fn(),
52
+ unregisterProvider: vi.fn(),
53
+ getCollection: vi.fn(() => []),
54
+ getCollectionEntries: vi.fn(() => []),
55
+ registerNativeProvider: vi.fn(),
56
+ unregisterNativeProvider: vi.fn(),
57
+ getProviderForDevice: vi.fn(() => null),
58
+ getBindings: vi.fn(() => ({ entries: [] })),
59
+ setActiveSingleton: vi.fn(),
60
+ getSingletonAddonId: vi.fn(() => null),
61
+ getAddonIdForProvider: vi.fn(() => null),
62
+ on: vi.fn(),
63
+ off: vi.fn(),
64
+ dispose: vi.fn(),
65
+ } as unknown as CapabilityRegistry
66
+ }
67
+
68
+ // ── Tests ────────────────────────────────────────────────────────────────────
69
+
70
+ const DEVICE_ID = 668
71
+ const CAP_NAME = 'vacuum-control' as Parameters<typeof requireDeviceScoped>[1]
72
+
73
+ describe('requireDeviceScoped — getStatus overlay via resolveLinkedStatus', () => {
74
+ it('returns the overlaid status when resolveLinkedStatus returns a non-null object', async () => {
75
+ const base = { state: 'idle', battery: 80 }
76
+ const overlaid = { state: 'paused', cleanWater: { status: 'low', level: null } }
77
+
78
+ const registry = makeRegistry({
79
+ nativeGetStatus: vi.fn(async () => base),
80
+ resolveLinkedStatus: vi.fn(async () => overlaid),
81
+ })
82
+
83
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
84
+ expect(dispatcher).not.toBeNull()
85
+
86
+ // Call via the Proxy — method is resolved lazily
87
+ const result = await (
88
+ dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> }
89
+ ).getStatus({ deviceId: DEVICE_ID })
90
+
91
+ expect(result).toEqual(overlaid)
92
+ expect(result).not.toEqual(base)
93
+ })
94
+
95
+ it('returns the base provider result unchanged when resolveLinkedStatus returns null (no links)', async () => {
96
+ const base = { state: 'cleaning', battery: 60 }
97
+
98
+ const registry = makeRegistry({
99
+ nativeGetStatus: vi.fn(async () => base),
100
+ resolveLinkedStatus: vi.fn(async () => null),
101
+ })
102
+
103
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
104
+ expect(dispatcher).not.toBeNull()
105
+
106
+ const result = await (
107
+ dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> }
108
+ ).getStatus({ deviceId: DEVICE_ID })
109
+
110
+ expect(result).toEqual(base)
111
+ })
112
+
113
+ it('does NOT call resolveLinkedStatus for non-getStatus methods', async () => {
114
+ const base = { state: 'idle', battery: 90 }
115
+ const resolveLinkedStatus = vi.fn(async () => null)
116
+ const nativeSetFanSpeed = vi.fn(async () => undefined)
117
+
118
+ const registry = makeRegistry({
119
+ nativeGetStatus: vi.fn(async () => base),
120
+ nativeSetFanSpeed,
121
+ resolveLinkedStatus,
122
+ })
123
+
124
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
125
+ expect(dispatcher).not.toBeNull()
126
+
127
+ await (
128
+ dispatcher as unknown as {
129
+ setFanSpeed: (i: { deviceId: number; speed: string }) => Promise<void>
130
+ }
131
+ ).setFanSpeed({ deviceId: DEVICE_ID, speed: 'high' })
132
+
133
+ expect(nativeSetFanSpeed).toHaveBeenCalledOnce()
134
+ // The overlay path must NOT be consulted for mutations
135
+ expect(resolveLinkedStatus).not.toHaveBeenCalled()
136
+ })
137
+ })
@@ -24,25 +24,54 @@ function makeProvider(): IDeviceManagerProvider {
24
24
  title: 'Identity',
25
25
  tab: 'general',
26
26
  order: 0,
27
- fields: [{ type: 'text', key: '_stableId', label: 'Stable ID', readonlyField: true, value: String(deviceId) }],
27
+ fields: [
28
+ {
29
+ type: 'text',
30
+ key: '_stableId',
31
+ label: 'Stable ID',
32
+ readonlyField: true,
33
+ value: String(deviceId),
34
+ },
35
+ ],
28
36
  },
29
37
  {
30
38
  id: 'motion-tuning',
31
39
  title: 'Motion Tuning',
32
40
  tab: 'detection',
33
41
  order: 5,
34
- fields: [{ type: 'number', key: 'threshold', label: 'Threshold', writerCapName: 'motion-detection', writerAddonId: 'motion-wasm', source: 'settings', value: 20 }],
42
+ fields: [
43
+ {
44
+ type: 'number',
45
+ key: 'threshold',
46
+ label: 'Threshold',
47
+ writerCapName: 'motion-detection',
48
+ writerAddonId: 'motion-wasm',
49
+ source: 'settings',
50
+ value: 20,
51
+ },
52
+ ],
35
53
  },
36
54
  ],
37
55
  })),
38
56
  getDeviceLiveInfoAggregate: vi.fn(async ({ deviceId }) => ({
39
- sections: [{
40
- id: 'orchestrator-live',
41
- title: 'Pipeline Status',
42
- tab: 'detection',
43
- order: 100,
44
- fields: [{ type: 'text', key: 'assignedRunner', label: 'Assigned Runner', readonlyField: true, source: 'live', value: deviceId === 1 ? 'hub' : '' }],
45
- }],
57
+ sections: [
58
+ {
59
+ id: 'orchestrator-live',
60
+ title: 'Pipeline Status',
61
+ tab: 'detection',
62
+ order: 100,
63
+ fields: [
64
+ {
65
+ type: 'text',
66
+ key: 'assignedRunner',
67
+ label: 'Assigned Runner',
68
+ readonlyField: true,
69
+ source: 'live',
70
+ value: deviceId === 1 ? 'hub' : '',
71
+ },
72
+ ],
73
+ },
74
+ ],
46
75
  })),
47
76
  updateDeviceField: vi.fn(async () => ({ success: true as const })),
48
77
 
@@ -60,14 +89,21 @@ function makeProvider(): IDeviceManagerProvider {
60
89
  getStreamSources: vi.fn(async () => []) as IDeviceManagerProvider['getStreamSources'],
61
90
  getConfigSchema: vi.fn(async () => []) as IDeviceManagerProvider['getConfigSchema'],
62
91
  getSettingsSchema: vi.fn(async () => null) as IDeviceManagerProvider['getSettingsSchema'],
63
- updateConfig: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['updateConfig'],
92
+ updateConfig: vi.fn(async () => ({
93
+ success: true as const,
94
+ })) as IDeviceManagerProvider['updateConfig'],
64
95
  enable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['enable'],
65
96
  disable: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['disable'],
66
97
  remove: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['remove'],
67
98
  getStreamProfileMap: vi.fn(async () => ({})) as IDeviceManagerProvider['getStreamProfileMap'],
68
- setStreamProfileMap: vi.fn(async () => ({ success: true as const })) as IDeviceManagerProvider['setStreamProfileMap'],
99
+ setStreamProfileMap: vi.fn(async () => ({
100
+ success: true as const,
101
+ })) as IDeviceManagerProvider['setStreamProfileMap'],
69
102
  probeStreams: vi.fn(async () => []) as IDeviceManagerProvider['probeStreams'],
70
- getBindings: vi.fn(async ({ deviceId }: { deviceId: number }) => ({ deviceId, entries: [] })) as IDeviceManagerProvider['getBindings'],
103
+ getBindings: vi.fn(async ({ deviceId }: { deviceId: number }) => ({
104
+ deviceId,
105
+ entries: [],
106
+ })) as IDeviceManagerProvider['getBindings'],
71
107
  setWrapperActive: vi.fn() as IDeviceManagerProvider['setWrapperActive'],
72
108
  }
73
109
  }
@@ -78,24 +114,32 @@ describe('device-manager aggregator via tRPC router', () => {
78
114
  it('getDeviceSettingsAggregate returns the merged shape for a known device', async () => {
79
115
  const provider = makeProvider()
80
116
  const router = createCapRouter_deviceManager(() => provider)
81
- const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
117
+ const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
118
+ deviceId: DEVICE_ID,
119
+ })
82
120
 
83
121
  expect(result.ok).toBe(true)
84
122
  if (!result.ok) return
85
- const value = result.value as { sections: readonly { id: string; tab?: string; fields: readonly unknown[] }[] }
86
- expect(value.sections.map(s => s.id)).toEqual(['identity', 'motion-tuning'])
123
+ const value = result.value as {
124
+ sections: readonly { id: string; tab?: string; fields: readonly unknown[] }[]
125
+ }
126
+ expect(value.sections.map((s) => s.id)).toEqual(['identity', 'motion-tuning'])
87
127
  expect(provider.getDeviceSettingsAggregate).toHaveBeenCalledWith({ deviceId: DEVICE_ID })
88
128
  })
89
129
 
90
130
  it('getDeviceLiveInfoAggregate returns live sections', async () => {
91
131
  const provider = makeProvider()
92
132
  const router = createCapRouter_deviceManager(() => provider)
93
- const result = await invokeProcedure(router, 'getDeviceLiveInfoAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
133
+ const result = await invokeProcedure(router, 'getDeviceLiveInfoAggregate', makeCtx('admin'), {
134
+ deviceId: DEVICE_ID,
135
+ })
94
136
 
95
137
  expect(result.ok).toBe(true)
96
138
  if (!result.ok) return
97
- const value = result.value as { sections: readonly { fields: readonly { key?: string; value?: unknown }[] }[] }
98
- const runner = value.sections[0]!.fields.find(f => f.key === 'assignedRunner')
139
+ const value = result.value as {
140
+ sections: readonly { fields: readonly { key?: string; value?: unknown }[] }[]
141
+ }
142
+ const runner = value.sections[0]!.fields.find((f) => f.key === 'assignedRunner')
99
143
  expect(runner?.value).toBe('hub')
100
144
  })
101
145
 
@@ -124,7 +168,13 @@ describe('device-manager aggregator via tRPC router', () => {
124
168
  const provider = makeProvider()
125
169
  const router = createCapRouter_deviceManager(() => provider)
126
170
 
127
- const payload = { deviceId: DEVICE_ID, writerCapName: 'motion-detection', writerAddonId: 'motion-wasm', key: 'threshold', value: 1 }
171
+ const payload = {
172
+ deviceId: DEVICE_ID,
173
+ writerCapName: 'motion-detection',
174
+ writerAddonId: 'motion-wasm',
175
+ key: 'threshold',
176
+ value: 1,
177
+ }
128
178
  const viewer = await invokeProcedure(router, 'updateDeviceField', makeCtx('user'), payload)
129
179
  expect(viewer.ok).toBe(false)
130
180
  if (!viewer.ok) expect(viewer.code).toBe('FORBIDDEN')
@@ -135,7 +185,9 @@ describe('device-manager aggregator via tRPC router', () => {
135
185
 
136
186
  it('router returns PRECONDITION_FAILED when the provider is missing', async () => {
137
187
  const router = createCapRouter_deviceManager(() => null)
138
- const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), { deviceId: DEVICE_ID })
188
+ const result = await invokeProcedure(router, 'getDeviceSettingsAggregate', makeCtx('admin'), {
189
+ deviceId: DEVICE_ID,
190
+ })
139
191
  expect(result.ok).toBe(false)
140
192
  if (!result.ok) expect(result.code).toBe('PRECONDITION_FAILED')
141
193
  })
@@ -1,4 +1,3 @@
1
-
2
1
  /**
3
2
  * Test harness for codegen'd capability tRPC routers.
4
3
  *
@@ -24,7 +23,10 @@ export type AuthLevel = 'public' | 'protected' | 'admin'
24
23
  export type TestRole = 'anonymous' | 'user' | 'admin' | 'agent'
25
24
 
26
25
  /** Build an AuthenticatedAgent stub for a given synthetic identity. */
27
- export function makeUser(role: Exclude<TestRole, 'anonymous'>, overrides: Partial<AuthenticatedAgent> = {}): AuthenticatedAgent {
26
+ export function makeUser(
27
+ role: Exclude<TestRole, 'anonymous'>,
28
+ overrides: Partial<AuthenticatedAgent> = {},
29
+ ): AuthenticatedAgent {
28
30
  const isAdmin = role === 'admin' || role === 'agent'
29
31
  const base: AuthenticatedAgent = {
30
32
  id: `user-${role}`,
@@ -111,11 +113,13 @@ export async function checkAuthMatrix(
111
113
  procedure: string,
112
114
  auth: AuthLevel,
113
115
  input?: unknown,
114
- ): Promise<ReadonlyArray<{
115
- readonly role: TestRole
116
- readonly allowed: boolean
117
- readonly outcome: Awaited<ReturnType<typeof invokeProcedure>>
118
- }>> {
116
+ ): Promise<
117
+ ReadonlyArray<{
118
+ readonly role: TestRole
119
+ readonly allowed: boolean
120
+ readonly outcome: Awaited<ReturnType<typeof invokeProcedure>>
121
+ }>
122
+ > {
119
123
  const results: Array<{
120
124
  role: TestRole
121
125
  allowed: boolean
@@ -1,4 +1,3 @@
1
-
2
1
  /**
3
2
  * Example spec exercising the codegen'd metrics-provider router end-to-end:
4
3
  * - Wires a mock provider into `createCapRouter_metricsProvider`
@@ -14,9 +13,24 @@ function makeMockProvider(overrides: Partial<IMetricsProvider> = {}): IMetricsPr
14
13
  return {
15
14
  collectSnapshot: async () => ({
16
15
  cpu: { total: 0, user: 0, system: 0, irq: 0, nice: 0, loadAvg: [0, 0, 0], cores: 1 },
17
- memory: { percent: 0, totalBytes: 0, usedBytes: 0, availableBytes: 0, swapUsedBytes: 0, swapTotalBytes: 0 },
16
+ memory: {
17
+ percent: 0,
18
+ totalBytes: 0,
19
+ usedBytes: 0,
20
+ availableBytes: 0,
21
+ swapUsedBytes: 0,
22
+ swapTotalBytes: 0,
23
+ },
18
24
  gpu: null,
19
- network: { rxBytes: 0, txBytes: 0, rxPackets: 0, txPackets: 0, rxErrors: 0, txErrors: 0, timestampMs: 0 },
25
+ network: {
26
+ rxBytes: 0,
27
+ txBytes: 0,
28
+ rxPackets: 0,
29
+ txPackets: 0,
30
+ rxErrors: 0,
31
+ txErrors: 0,
32
+ timestampMs: 0,
33
+ },
20
34
  disk: { readBytes: 0, writeBytes: 0, readOps: 0, writeOps: 0, timestampMs: 0 },
21
35
  pressure: { cpu: null, memory: null, io: null },
22
36
  process: { openFds: 0, threadCount: 0, activeHandles: 0, activeRequests: 0 },
@@ -36,11 +36,12 @@ describe('null-provider guard — all capabilities', () => {
36
36
  const router = factory(() => null)
37
37
 
38
38
  // Find the first method on the router's caller
39
- const caller = (router as { createCaller: (ctx: unknown) => Record<string, unknown> })
40
- .createCaller(makeCtx('admin'))
39
+ const caller = (
40
+ router as { createCaller: (ctx: unknown) => Record<string, unknown> }
41
+ ).createCaller(makeCtx('admin'))
41
42
 
42
43
  const methods = Object.keys(caller).filter(
43
- k => typeof caller[k] === 'function' && !k.startsWith('_'),
44
+ (k) => typeof caller[k] === 'function' && !k.startsWith('_'),
44
45
  )
45
46
 
46
47
  if (methods.length === 0) return // No methods — skip
@@ -57,10 +58,7 @@ describe('null-provider guard — all capabilities', () => {
57
58
  // caught by codegen guard) or BAD_REQUEST (input validation before guard)
58
59
  // or INTERNAL_SERVER_ERROR (provider method call on null).
59
60
  // Any of these is acceptable — the important thing is ok !== true.
60
- expect(
61
- result.ok,
62
- `${name}.${firstMethod} returned success with null provider!`,
63
- ).toBe(false)
61
+ expect(result.ok, `${name}.${firstMethod} returned success with null provider!`).toBe(false)
64
62
  })
65
63
  }
66
64
  })