@camstack/server 0.1.3

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 (133) hide show
  1. package/.env.example +17 -0
  2. package/package.json +55 -0
  3. package/src/__tests__/addon-install-e2e.test.ts +75 -0
  4. package/src/__tests__/addon-pages-e2e.test.ts +178 -0
  5. package/src/__tests__/addon-route-session.test.ts +17 -0
  6. package/src/__tests__/addon-settings-router.spec.ts +62 -0
  7. package/src/__tests__/addon-upload.spec.ts +355 -0
  8. package/src/__tests__/agent-registry.spec.ts +162 -0
  9. package/src/__tests__/agent-status-page.spec.ts +84 -0
  10. package/src/__tests__/auth-session-cookie.test.ts +21 -0
  11. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
  12. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
  13. package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
  14. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
  15. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
  16. package/src/__tests__/cap-routers/harness.ts +159 -0
  17. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
  18. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
  19. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
  20. package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
  21. package/src/__tests__/capability-e2e.test.ts +386 -0
  22. package/src/__tests__/cli-e2e.test.ts +129 -0
  23. package/src/__tests__/core-cap-bridge.spec.ts +89 -0
  24. package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
  25. package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
  26. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
  27. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
  28. package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
  29. package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
  30. package/src/__tests__/framework-allowlist.spec.ts +95 -0
  31. package/src/__tests__/https-e2e.test.ts +118 -0
  32. package/src/__tests__/lifecycle-e2e.test.ts +140 -0
  33. package/src/__tests__/live-events-subscription.spec.ts +150 -0
  34. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
  35. package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
  36. package/src/__tests__/post-boot-restart.spec.ts +161 -0
  37. package/src/__tests__/singleton-contention.test.ts +487 -0
  38. package/src/__tests__/streaming-diagnostic.test.ts +512 -0
  39. package/src/__tests__/streaming-scale.test.ts +280 -0
  40. package/src/agent-status-page.ts +121 -0
  41. package/src/api/__tests__/addons-custom.spec.ts +134 -0
  42. package/src/api/__tests__/capabilities.router.test.ts +47 -0
  43. package/src/api/addon-upload.ts +472 -0
  44. package/src/api/addons-custom.router.ts +100 -0
  45. package/src/api/auth-whoami.ts +99 -0
  46. package/src/api/bridge-addons.router.ts +120 -0
  47. package/src/api/capabilities.router.ts +226 -0
  48. package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
  49. package/src/api/core/addon-settings.router.ts +124 -0
  50. package/src/api/core/agents.router.ts +87 -0
  51. package/src/api/core/auth.router.ts +303 -0
  52. package/src/api/core/cap-providers.ts +993 -0
  53. package/src/api/core/capabilities.router.ts +119 -0
  54. package/src/api/core/collection-preference.ts +40 -0
  55. package/src/api/core/event-bus-proxy.router.ts +45 -0
  56. package/src/api/core/hwaccel.router.ts +81 -0
  57. package/src/api/core/live-events.router.ts +60 -0
  58. package/src/api/core/logs.router.ts +162 -0
  59. package/src/api/core/notifications.router.ts +65 -0
  60. package/src/api/core/repl.router.ts +41 -0
  61. package/src/api/core/settings-backend.router.ts +142 -0
  62. package/src/api/core/stream-probe.router.ts +57 -0
  63. package/src/api/core/system-events.router.ts +116 -0
  64. package/src/api/health/health.routes.ts +123 -0
  65. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
  66. package/src/api/oauth2/consent-page.ts +42 -0
  67. package/src/api/oauth2/oauth2-routes.ts +248 -0
  68. package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
  69. package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
  70. package/src/api/trpc/cap-mount-helpers.ts +225 -0
  71. package/src/api/trpc/core-cap-bridge.ts +152 -0
  72. package/src/api/trpc/generated-cap-mounts.ts +707 -0
  73. package/src/api/trpc/generated-cap-routers.ts +6340 -0
  74. package/src/api/trpc/scope-access.ts +110 -0
  75. package/src/api/trpc/trpc.context.ts +255 -0
  76. package/src/api/trpc/trpc.middleware.ts +140 -0
  77. package/src/api/trpc/trpc.router.ts +275 -0
  78. package/src/auth/session-cookie.ts +44 -0
  79. package/src/boot/boot-config.ts +278 -0
  80. package/src/boot/post-boot.service.ts +103 -0
  81. package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
  82. package/src/core/addon/addon-package.service.ts +1684 -0
  83. package/src/core/addon/addon-registry.service.ts +2926 -0
  84. package/src/core/addon/addon-search.service.ts +90 -0
  85. package/src/core/addon/addon-settings-provider.ts +276 -0
  86. package/src/core/addon/addon.tokens.ts +2 -0
  87. package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
  88. package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
  89. package/src/core/addon-pages/addon-pages.service.ts +80 -0
  90. package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
  91. package/src/core/agent/agent-registry.service.ts +507 -0
  92. package/src/core/auth/auth.service.spec.ts +88 -0
  93. package/src/core/auth/auth.service.ts +8 -0
  94. package/src/core/capability/capability.service.ts +57 -0
  95. package/src/core/config/config.schema.ts +3 -0
  96. package/src/core/config/config.service.spec.ts +175 -0
  97. package/src/core/config/config.service.ts +7 -0
  98. package/src/core/events/event-bus.service.spec.ts +212 -0
  99. package/src/core/events/event-bus.service.ts +85 -0
  100. package/src/core/feature/feature.service.spec.ts +96 -0
  101. package/src/core/feature/feature.service.ts +8 -0
  102. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
  103. package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
  104. package/src/core/logging/log-ring-buffer.ts +3 -0
  105. package/src/core/logging/logging.service.spec.ts +247 -0
  106. package/src/core/logging/logging.service.ts +129 -0
  107. package/src/core/logging/scoped-logger.ts +3 -0
  108. package/src/core/moleculer/moleculer.service.ts +612 -0
  109. package/src/core/network/network-quality.service.spec.ts +47 -0
  110. package/src/core/network/network-quality.service.ts +5 -0
  111. package/src/core/notification/notification-wrapper.service.ts +36 -0
  112. package/src/core/notification/toast-wrapper.service.ts +31 -0
  113. package/src/core/provider/provider.tokens.ts +1 -0
  114. package/src/core/repl/repl-engine.service.spec.ts +417 -0
  115. package/src/core/repl/repl-engine.service.ts +156 -0
  116. package/src/core/storage/fs-storage-backend.spec.ts +70 -0
  117. package/src/core/storage/fs-storage-backend.ts +3 -0
  118. package/src/core/storage/settings-store.spec.ts +213 -0
  119. package/src/core/storage/settings-store.ts +2 -0
  120. package/src/core/storage/sql-schema.spec.ts +140 -0
  121. package/src/core/storage/sql-schema.ts +3 -0
  122. package/src/core/storage/storage-location-manager.spec.ts +121 -0
  123. package/src/core/storage/storage-location-manager.ts +3 -0
  124. package/src/core/storage/storage.service.spec.ts +73 -0
  125. package/src/core/storage/storage.service.ts +3 -0
  126. package/src/core/streaming/stream-probe.service.ts +212 -0
  127. package/src/core/topology/topology-emitter.service.ts +101 -0
  128. package/src/launcher.ts +309 -0
  129. package/src/main.ts +1049 -0
  130. package/src/manual-boot.ts +322 -0
  131. package/tsconfig.build.json +8 -0
  132. package/tsconfig.json +21 -0
  133. package/vitest.config.ts +26 -0
@@ -0,0 +1,120 @@
1
+ import { z } from 'zod'
2
+ import { TRPCError } from '@trpc/server'
3
+ import { protectedProcedure, adminProcedure, trpcRouter } from './trpc/trpc.middleware'
4
+ import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service'
5
+ import type { AddonSearchService } from '../core/addon/addon-search.service'
6
+ import type { AddonRegistryService } from '../core/addon/addon-registry.service'
7
+ import type { ToastService } from '@camstack/core'
8
+
9
+ export function createBridgeAddonsRouter(
10
+ bridge: AddonBridgeService,
11
+ addonSearch: AddonSearchService,
12
+ addonRegistry?: AddonRegistryService,
13
+ toastService?: ToastService,
14
+ ) {
15
+ return trpcRouter({
16
+ /** List all addon packages installed in the addons directory */
17
+ listPackages: protectedProcedure.query(() => {
18
+ const installer = bridge.getInstaller()
19
+ if (!installer) return []
20
+ return installer.listInstalled()
21
+ }),
22
+
23
+ /** List all available addons across all loaded packages */
24
+ listAddons: protectedProcedure.query(() => {
25
+ return bridge.listAvailableAddons().map((id) => {
26
+ const addon = bridge.getLoader().getAddon(id)
27
+ return {
28
+ id,
29
+ packageName: addon?.packageName ?? 'unknown',
30
+ slot: addon?.declaration.slot ?? null,
31
+ }
32
+ })
33
+ }),
34
+
35
+ /** Install a community addon package from npm */
36
+ installPackage: adminProcedure
37
+ .input(z.object({ packageName: z.string(), version: z.string().optional() }))
38
+ .mutation(async ({ input }) => {
39
+ const installer = bridge.getInstaller()
40
+ if (!installer) {
41
+ throw new TRPCError({
42
+ code: 'PRECONDITION_FAILED',
43
+ message: 'Addon installer not available — bridge may have failed to initialize',
44
+ })
45
+ }
46
+ await installer.install(input.packageName, input.version)
47
+ await bridge.reloadPackages()
48
+ const result = addonRegistry ? await addonRegistry.loadNewAddons() : { loaded: [], failed: [] }
49
+ toastService?.broadcast({
50
+ title: 'Addon Installed',
51
+ message: `${input.packageName} installed successfully${result.loaded.length ? ` (${result.loaded.join(', ')})` : ''}`,
52
+ severity: result.failed.length ? 'warning' : 'info',
53
+ })
54
+ return { success: true, loaded: result.loaded, failed: result.failed }
55
+ }),
56
+
57
+ /** Uninstall a community addon package */
58
+ uninstallPackage: adminProcedure
59
+ .input(z.object({ packageName: z.string() }))
60
+ .mutation(async ({ input }) => {
61
+ // Server-side guard: prevent uninstalling required packages.
62
+ // After Phase D bundle merge, the pipeline-related packages
63
+ // (stream-broker, detection-pipeline, motion-wasm, decoders,
64
+ // audio) all live in @camstack/addon-pipeline.
65
+ const REQUIRED = new Set([
66
+ '@camstack/core',
67
+ '@camstack/addon-pipeline',
68
+ '@camstack/addon-pipeline-orchestrator',
69
+ '@camstack/addon-post-analysis',
70
+ '@camstack/addon-admin-ui',
71
+ ])
72
+ if (REQUIRED.has(input.packageName)) {
73
+ throw new TRPCError({
74
+ code: 'FORBIDDEN',
75
+ message: `Package ${input.packageName} is required and cannot be uninstalled`,
76
+ })
77
+ }
78
+
79
+ const installer = bridge.getInstaller()
80
+ if (!installer) {
81
+ throw new TRPCError({
82
+ code: 'PRECONDITION_FAILED',
83
+ message: 'Addon installer not available — bridge may have failed to initialize',
84
+ })
85
+ }
86
+ await installer.uninstall(input.packageName)
87
+ await bridge.reloadPackages()
88
+ if (addonRegistry) await addonRegistry.loadNewAddons()
89
+ toastService?.broadcast({
90
+ title: 'Addon Uninstalled',
91
+ message: `${input.packageName} has been removed`,
92
+ severity: 'info',
93
+ })
94
+ return { success: true }
95
+ }),
96
+
97
+ /** Force reload all addon packages (re-scan directories, re-import modules) */
98
+ reloadPackages: adminProcedure.mutation(async () => {
99
+ await bridge.reloadPackages()
100
+ return { success: true, message: 'Addon packages reloaded' }
101
+ }),
102
+
103
+ /** Search npm for available CamStack addons */
104
+ searchAvailable: protectedProcedure
105
+ .input(z.object({ query: z.string().optional() }).optional())
106
+ .query(async ({ input }) => {
107
+ const results = await addonSearch.searchAddons(input?.query)
108
+
109
+ // Enrich with install status from locally installed packages
110
+ const installed = bridge.getInstaller()?.listInstalled() ?? []
111
+ const installedMap = new Map(installed.map((p) => [p.name, p.version]))
112
+
113
+ return results.map((r) => ({
114
+ ...r,
115
+ installed: installedMap.has(r.name),
116
+ installedVersion: installedMap.get(r.name),
117
+ }))
118
+ }),
119
+ })
120
+ }
@@ -0,0 +1,226 @@
1
+ import { z } from 'zod'
2
+ import { TRPCError } from '@trpc/server'
3
+ import { adminProcedure, trpcRouter } from './trpc/trpc.middleware'
4
+ import type { AddonRegistryService } from '../core/addon/addon-registry.service'
5
+ import type { ConfigService } from '../core/config/config.service'
6
+ import type { LoggingService } from '../core/logging/logging.service'
7
+ import { isInfraCapability } from '@camstack/kernel'
8
+ import { collectionPreferenceKey, persistCollectionDisabled } from './core/collection-preference'
9
+
10
+ // ─── Zod Schemas ───────────────────────────────────────────────────────────
11
+
12
+ const setPreferenceInput = z.discriminatedUnion('mode', [
13
+ z.object({
14
+ mode: z.literal('singleton'),
15
+ capability: z.string(),
16
+ addonId: z.string(),
17
+ }),
18
+ z.object({
19
+ mode: z.literal('collection'),
20
+ capability: z.string(),
21
+ addonId: z.string(),
22
+ enabled: z.boolean(),
23
+ }),
24
+ ])
25
+
26
+ // ─── Router ────────────────────────────────────────────────────────────────
27
+
28
+ export function createCapabilitiesRouter(
29
+ addonRegistry: AddonRegistryService,
30
+ configService: ConfigService,
31
+ loggingService: LoggingService,
32
+ ) {
33
+ const logger = loggingService.createLogger('CapabilitiesRouter')
34
+
35
+ /** Build enriched provider details from addon metadata */
36
+ function getProviderDetails(addonIds: readonly string[]) {
37
+ const allAddons = addonRegistry.listAllAddons()
38
+ return addonIds.map((addonId) => {
39
+ const addon = allAddons.find((a) => a.manifest.id === addonId)
40
+ return {
41
+ addonId,
42
+ displayName: addon?.manifest.name ?? addonId,
43
+ packageName: addon?.manifest.packageName ?? addonId,
44
+ }
45
+ })
46
+ }
47
+
48
+ return trpcRouter({
49
+ // ─── List all capabilities with enriched metadata ──────────────────
50
+
51
+ list: adminProcedure.query(() => {
52
+ const registry = addonRegistry.getCapabilityRegistry()
53
+ const caps = registry.listCapabilities()
54
+ return caps.map((cap) => ({
55
+ ...cap,
56
+ providerDetails: getProviderDetails(cap.providers),
57
+ isInfra: isInfraCapability(cap.name),
58
+ }))
59
+ }),
60
+
61
+ // ─── Get current preference for a capability ──────────────────────
62
+
63
+ getPreference: adminProcedure
64
+ .input(z.object({ capability: z.string() }))
65
+ .query(({ input }) => {
66
+ const registry = addonRegistry.getCapabilityRegistry()
67
+ const mode = registry.getMode(input.capability)
68
+ if (!mode) return null
69
+
70
+ if (mode === 'singleton') {
71
+ const addonId = configService.get<string>(`capabilities.singleton.${input.capability}`)
72
+ return {
73
+ capability: input.capability,
74
+ mode: mode as 'singleton',
75
+ preference: addonId ? { addonId } : null,
76
+ }
77
+ }
78
+
79
+ // collection
80
+ const raw = configService.get<string>(collectionPreferenceKey(input.capability))
81
+ let disabled: string[] = []
82
+ if (raw) {
83
+ try {
84
+ const parsed = JSON.parse(raw) as { disabled?: string[] }
85
+ disabled = Array.isArray(parsed.disabled) ? parsed.disabled : []
86
+ } catch { /* ignore malformed */ }
87
+ }
88
+ return {
89
+ capability: input.capability,
90
+ mode: mode as 'collection',
91
+ preference: { disabled },
92
+ }
93
+ }),
94
+
95
+ // ─── Set preference (singleton switch or collection toggle) ───────
96
+
97
+ setPreference: adminProcedure
98
+ .input(setPreferenceInput)
99
+ .mutation(async ({ input }) => {
100
+ const registry = addonRegistry.getCapabilityRegistry()
101
+
102
+ if (input.mode === 'singleton') {
103
+ const { capability, addonId } = input
104
+ const caps = registry.listCapabilities()
105
+ const cap = caps.find((c) => c.name === capability)
106
+ if (!cap) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
107
+ if (cap.mode !== 'singleton') throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a singleton` })
108
+ if (!cap.providers.includes(addonId)) {
109
+ throw new TRPCError({ code: 'BAD_REQUEST', message: `Provider "${addonId}" is not registered for "${capability}"` })
110
+ }
111
+
112
+ const requiresRestart = isInfraCapability(capability)
113
+
114
+ if (!requiresRestart) {
115
+ // Hot-swap at runtime
116
+ await registry.setActiveSingleton(capability, addonId, true)
117
+ }
118
+
119
+ // Persist preference
120
+ configService.set(`capabilities.singleton.${capability}`, addonId)
121
+ logger.info('Singleton preference set', { tags: { addonId }, meta: { capability, requiresRestart } })
122
+
123
+ return { success: true, requiresRestart }
124
+ }
125
+
126
+ // collection toggle
127
+ const { capability, addonId, enabled } = input
128
+ const caps = registry.listCapabilities()
129
+ const cap = caps.find((c) => c.name === capability)
130
+ if (!cap) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${capability}` })
131
+ if (cap.mode !== 'collection') throw new TRPCError({ code: 'BAD_REQUEST', message: `"${capability}" is not a collection` })
132
+ if (!cap.providers.includes(addonId)) {
133
+ throw new TRPCError({ code: 'BAD_REQUEST', message: `Provider "${addonId}" is not registered for "${capability}"` })
134
+ }
135
+
136
+ if (enabled) {
137
+ registry.enableCollectionProvider(capability, addonId)
138
+ } else {
139
+ registry.disableCollectionProvider(capability, addonId)
140
+ }
141
+
142
+ // Persist disabled list via the shared canonical writer.
143
+ const updatedCap = registry.listCapabilities().find((c) => c.name === capability)
144
+ persistCollectionDisabled(configService, capability, updatedCap?.disabledProviders ?? [])
145
+ logger.info('Collection provider toggled', { tags: { addonId }, meta: { capability, enabled } })
146
+
147
+ return { success: true, requiresRestart: false }
148
+ }),
149
+
150
+ // ─── Reset preference to default ──────────────────────────────────
151
+
152
+ resetPreference: adminProcedure
153
+ .input(z.object({ capability: z.string() }))
154
+ .mutation(({ input }) => {
155
+ const registry = addonRegistry.getCapabilityRegistry()
156
+ const mode = registry.getMode(input.capability)
157
+ if (!mode) throw new TRPCError({ code: 'NOT_FOUND', message: `Unknown capability: ${input.capability}` })
158
+
159
+ if (mode === 'singleton') {
160
+ configService.set(`capabilities.singleton.${input.capability}`, null)
161
+ logger.info('Singleton preference reset (takes effect on restart)', { meta: { capability: input.capability } })
162
+ return { success: true, requiresRestart: true }
163
+ }
164
+
165
+ // collection: re-enable all disabled providers
166
+ const caps = registry.listCapabilities()
167
+ const cap = caps.find((c) => c.name === input.capability)
168
+ if (cap) {
169
+ for (const addonId of cap.disabledProviders) {
170
+ registry.enableCollectionProvider(input.capability, addonId)
171
+ }
172
+ }
173
+ configService.set(`capabilities.collection.${input.capability}`, null)
174
+ logger.info('Collection preference reset (all providers re-enabled)', { meta: { capability: input.capability } })
175
+ return { success: true, requiresRestart: false }
176
+ }),
177
+
178
+ // ─── Per-device overrides (existing, unchanged) ───────────────────
179
+
180
+ listCapabilities: adminProcedure.query(() => {
181
+ const registry = addonRegistry.getCapabilityRegistry()
182
+ return registry.listCapabilities()
183
+ }),
184
+
185
+ setDeviceCapability: adminProcedure
186
+ .input(z.object({ deviceId: z.string(), capability: z.string(), addonId: z.string() }))
187
+ .mutation(({ input }) => {
188
+ const registry = addonRegistry.getCapabilityRegistry()
189
+ registry.setDeviceOverride(input.deviceId, input.capability, input.addonId)
190
+ logger.info('Device capability override set', { tags: { deviceId: Number(input.deviceId), addonId: input.addonId }, meta: { capability: input.capability } })
191
+ }),
192
+
193
+ clearDeviceCapability: adminProcedure
194
+ .input(z.object({ deviceId: z.string(), capability: z.string() }))
195
+ .mutation(({ input }) => {
196
+ const registry = addonRegistry.getCapabilityRegistry()
197
+ registry.clearDeviceOverride(input.deviceId, input.capability)
198
+ logger.info('Device capability override cleared', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability } })
199
+ }),
200
+
201
+ getDeviceCapabilities: adminProcedure
202
+ .input(z.object({ deviceId: z.string() }))
203
+ .output(z.record(z.string(), z.string()))
204
+ .query(({ input }): Record<string, string> => {
205
+ const registry = addonRegistry.getCapabilityRegistry()
206
+ const overrides = registry.getDeviceOverrides(input.deviceId)
207
+ return Object.fromEntries(overrides) as Record<string, string>
208
+ }),
209
+
210
+ setDeviceCollectionFilter: adminProcedure
211
+ .input(z.object({ deviceId: z.string(), capability: z.string(), addonIds: z.array(z.string()) }))
212
+ .mutation(({ input }) => {
213
+ const registry = addonRegistry.getCapabilityRegistry()
214
+ registry.setDeviceCollectionFilter(input.deviceId, input.capability, input.addonIds)
215
+ logger.info('Device collection filter set', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability, addonIds: input.addonIds } })
216
+ }),
217
+
218
+ clearDeviceCollectionFilter: adminProcedure
219
+ .input(z.object({ deviceId: z.string(), capability: z.string() }))
220
+ .mutation(({ input }) => {
221
+ const registry = addonRegistry.getCapabilityRegistry()
222
+ registry.clearDeviceCollectionFilter(input.deviceId, input.capability)
223
+ logger.info('Device collection filter cleared', { tags: { deviceId: Number(input.deviceId) }, meta: { capability: input.capability } })
224
+ }),
225
+ })
226
+ }
@@ -0,0 +1,256 @@
1
+ /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return -- mock factories cross typed boundaries */
2
+ import { describe, it, expect, beforeEach, vi } from 'vitest'
3
+ import { createAuthRouter } from '../auth.router.js'
4
+ import { AuthService } from '../../../core/auth/auth.service.js'
5
+
6
+ /**
7
+ * Two-phase TOTP login flow tests for `auth.router`. Focus on the
8
+ * cap-router contracts the SDK + admin-ui depend on:
9
+ *
10
+ * • `login` returns `requiresTotp: true` + a short-lived challenge
11
+ * token when the user has TOTP enrolled.
12
+ * • `login` returns a regular session token when the user has NO
13
+ * TOTP (backwards compat).
14
+ * • `loginVerifyTotp` validates the challenge token + code and
15
+ * mints the real session JWT.
16
+ * • Failure paths: wrong code, expired/forged challenge, missing
17
+ * user, missing user-management cap.
18
+ */
19
+
20
+ interface MockUser {
21
+ id: string
22
+ username: string
23
+ isAdmin: boolean
24
+ passwordHash: string
25
+ allowedProviders: string | string[]
26
+ allowedDevices: Record<string, unknown>
27
+ scopes: unknown[]
28
+ }
29
+
30
+ interface MockUserManagement {
31
+ validateCredentials: ReturnType<typeof vi.fn>
32
+ getTotpStatus: ReturnType<typeof vi.fn>
33
+ verifyTotp: ReturnType<typeof vi.fn>
34
+ listUsers: ReturnType<typeof vi.fn>
35
+ }
36
+
37
+ function makeUser(overrides: Partial<MockUser> = {}): MockUser {
38
+ return {
39
+ id: 'u-1',
40
+ username: 'alice',
41
+ isAdmin: false,
42
+ passwordHash: 'hashed',
43
+ allowedProviders: '*',
44
+ allowedDevices: {},
45
+ scopes: [],
46
+ ...overrides,
47
+ }
48
+ }
49
+
50
+ function makeUserMgmt(overrides: Partial<MockUserManagement> = {}): MockUserManagement {
51
+ return {
52
+ validateCredentials: vi.fn(),
53
+ getTotpStatus: vi.fn(async () => ({ enabled: false, confirmedAt: null })),
54
+ verifyTotp: vi.fn(async () => ({ valid: true })),
55
+ listUsers: vi.fn(async () => [makeUser()]),
56
+ ...overrides,
57
+ }
58
+ }
59
+
60
+ function makeConfig(overrides: Record<string, unknown> = {}): { get<T>(p: string): T; update(s: string, d: Record<string, unknown>): void } {
61
+ const store: Record<string, unknown> = { 'auth.jwtSecret': 'unit-test-secret', ...overrides }
62
+ return {
63
+ get<T>(path: string): T { return store[path] as T },
64
+ update(_section: string, _data: Record<string, unknown>): void { /* no-op */ },
65
+ }
66
+ }
67
+
68
+ function makeRegistry(userMgmt: MockUserManagement): { getSingleton: (name: string) => unknown | null } {
69
+ return {
70
+ getSingleton: (name: string) => (name === 'user-management' ? userMgmt : null),
71
+ }
72
+ }
73
+
74
+ /** Invoke a tRPC procedure directly via the router's internal API. */
75
+ async function callProc(router: ReturnType<typeof createAuthRouter>, name: 'login' | 'loginVerifyTotp', input: unknown): Promise<unknown> {
76
+ const caller = router.createCaller({} as never)
77
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
78
+ const fn = (caller as any)[name]
79
+ return fn(input)
80
+ }
81
+
82
+ describe('auth.router — TOTP gate', () => {
83
+ let auth: AuthService
84
+ let userMgmt: MockUserManagement
85
+ let registry: ReturnType<typeof makeRegistry>
86
+
87
+ beforeEach(() => {
88
+ auth = new AuthService(makeConfig() as never)
89
+ userMgmt = makeUserMgmt()
90
+ registry = makeRegistry(userMgmt)
91
+ })
92
+
93
+ describe('login (phase 1)', () => {
94
+ it('returns a regular session token + requiresTotp:false when user has NO TOTP', async () => {
95
+ const u = makeUser()
96
+ userMgmt.validateCredentials.mockResolvedValueOnce(u)
97
+ userMgmt.getTotpStatus.mockResolvedValueOnce({ enabled: false, confirmedAt: null })
98
+
99
+ const router = createAuthRouter(auth, registry as never)
100
+ const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as {
101
+ token: string
102
+ user: { id: string; username: string; isAdmin: boolean }
103
+ requiresTotp?: boolean
104
+ }
105
+
106
+ expect(result.requiresTotp).toBe(false)
107
+ expect(result.user).toEqual({ id: 'u-1', username: 'alice', isAdmin: false })
108
+ // The token verifies as a regular session token (NOT a challenge).
109
+ const verified = auth.verifyToken(result.token)
110
+ expect(verified.userId).toBe('u-1')
111
+ // verifyTotpChallengeToken should NOT recognize a regular session token.
112
+ expect(auth.verifySsoBridgeToken(result.token)).toBeNull()
113
+ })
114
+
115
+ it('returns a challenge token + requiresTotp:true when user HAS TOTP', async () => {
116
+ const u = makeUser()
117
+ userMgmt.validateCredentials.mockResolvedValueOnce(u)
118
+ userMgmt.getTotpStatus.mockResolvedValueOnce({ enabled: true, confirmedAt: Date.now() })
119
+
120
+ const router = createAuthRouter(auth, registry as never)
121
+ const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as {
122
+ token: string
123
+ user: { id: string; username: string; isAdmin: boolean }
124
+ requiresTotp?: boolean
125
+ }
126
+
127
+ expect(result.requiresTotp).toBe(true)
128
+ // The challenge token MUST NOT verify as a session JWT — it has
129
+ // `kind: 'totp-challenge'` and the standard verifyToken expects
130
+ // the session shape (it'd throw or return garbage).
131
+ const challengeClaims = auth.verifyTotpChallengeToken(result.token)
132
+ expect(challengeClaims).not.toBeNull()
133
+ expect(challengeClaims!.userId).toBe('u-1')
134
+ expect(challengeClaims!.isAdmin).toBe(false)
135
+ })
136
+
137
+ it('rejects invalid credentials', async () => {
138
+ userMgmt.validateCredentials.mockResolvedValueOnce(null)
139
+ const router = createAuthRouter(auth, registry as never)
140
+ await expect(callProc(router, 'login', { username: 'alice', password: 'wrong' })).rejects.toThrow('Invalid credentials')
141
+ })
142
+
143
+ it('throws when user-management cap is unregistered (boot failure)', async () => {
144
+ const emptyRegistry = { getSingleton: () => null } as never
145
+ const router = createAuthRouter(auth, emptyRegistry)
146
+ await expect(callProc(router, 'login', { username: 'alice', password: 'pw' })).rejects.toThrow(/user-management.*capability not registered/)
147
+ })
148
+
149
+ it('falls through to session-token branch when getTotpStatus method is absent (older user-mgmt builds)', async () => {
150
+ const u = makeUser()
151
+ const legacyMgmt = {
152
+ validateCredentials: vi.fn().mockResolvedValueOnce(u),
153
+ // getTotpStatus deliberately undefined
154
+ verifyTotp: vi.fn(),
155
+ listUsers: vi.fn(),
156
+ }
157
+ const router = createAuthRouter(auth, makeRegistry(legacyMgmt as never) as never)
158
+ const result = await callProc(router, 'login', { username: 'alice', password: 'pw' }) as { requiresTotp?: boolean }
159
+ expect(result.requiresTotp).toBe(false)
160
+ })
161
+ })
162
+
163
+ describe('loginVerifyTotp (phase 2)', () => {
164
+ it('mints the real session JWT when challenge + code are both valid', async () => {
165
+ const u = makeUser({ scopes: [{ type: 'category', target: 'storage', access: ['view'] }] })
166
+ userMgmt.verifyTotp.mockResolvedValueOnce({ valid: true })
167
+ userMgmt.listUsers.mockResolvedValueOnce([u])
168
+
169
+ const challengeToken = auth.signTotpChallengeToken({
170
+ userId: u.id,
171
+ username: u.username,
172
+ isAdmin: u.isAdmin,
173
+ })
174
+
175
+ const router = createAuthRouter(auth, registry as never)
176
+ const result = await callProc(router, 'loginVerifyTotp', { challengeToken, code: '123456' }) as {
177
+ token: string
178
+ user: { id: string; username: string; isAdmin: boolean }
179
+ requiresTotp?: boolean
180
+ }
181
+
182
+ expect(result.requiresTotp).toBe(false)
183
+ expect(result.user.id).toBe('u-1')
184
+ // Session JWT verifies + carries the user's scopes snapshot.
185
+ const decoded = auth.verifyToken(result.token)
186
+ expect(decoded.userId).toBe('u-1')
187
+ })
188
+
189
+ it('rejects an invalid/expired challenge token', async () => {
190
+ const router = createAuthRouter(auth, registry as never)
191
+ await expect(
192
+ callProc(router, 'loginVerifyTotp', { challengeToken: 'not.a.real.jwt', code: '123456' }),
193
+ ).rejects.toThrow(/Invalid or expired TOTP challenge/)
194
+ })
195
+
196
+ it('rejects a forged challenge token (signed with wrong secret)', async () => {
197
+ const other = new AuthService(makeConfig({ 'auth.jwtSecret': 'attacker-secret' }) as never)
198
+ const forged = other.signTotpChallengeToken({ userId: 'u-1', username: 'alice', isAdmin: true })
199
+ const router = createAuthRouter(auth, registry as never)
200
+ await expect(
201
+ callProc(router, 'loginVerifyTotp', { challengeToken: forged, code: '000000' }),
202
+ ).rejects.toThrow(/Invalid or expired TOTP challenge/)
203
+ })
204
+
205
+ it('rejects a session token reused as a challenge token (wrong kind)', async () => {
206
+ const sessionTok = auth.signToken({
207
+ userId: 'u-1',
208
+ username: 'alice',
209
+ isAdmin: true,
210
+ allowedProviders: '*',
211
+ allowedDevices: {},
212
+ })
213
+ const router = createAuthRouter(auth, registry as never)
214
+ await expect(
215
+ callProc(router, 'loginVerifyTotp', { challengeToken: sessionTok, code: '000000' }),
216
+ ).rejects.toThrow(/Invalid or expired TOTP challenge/)
217
+ })
218
+
219
+ it('rejects a wrong 6-digit code', async () => {
220
+ userMgmt.verifyTotp.mockResolvedValueOnce({ valid: false })
221
+ const challengeToken = auth.signTotpChallengeToken({ userId: 'u-1', username: 'alice', isAdmin: false })
222
+ const router = createAuthRouter(auth, registry as never)
223
+ await expect(
224
+ callProc(router, 'loginVerifyTotp', { challengeToken, code: '000000' }),
225
+ ).rejects.toThrow(/Invalid TOTP code/)
226
+ })
227
+
228
+ it('rejects when the user vanishes between legs (deleted concurrently)', async () => {
229
+ userMgmt.verifyTotp.mockResolvedValueOnce({ valid: true })
230
+ // The fresh listUsers() call returns nothing — user was deleted.
231
+ userMgmt.listUsers.mockResolvedValueOnce([])
232
+ const challengeToken = auth.signTotpChallengeToken({ userId: 'ghost', username: 'alice', isAdmin: false })
233
+ const router = createAuthRouter(auth, registry as never)
234
+ await expect(
235
+ callProc(router, 'loginVerifyTotp', { challengeToken, code: '123456' }),
236
+ ).rejects.toThrow(/User no longer exists/)
237
+ })
238
+
239
+ it('uses the FRESHLY fetched user record (scope changes pick up immediately)', async () => {
240
+ // Issue challenge for u-1 with empty scopes (the in-token snapshot).
241
+ const challengeToken = auth.signTotpChallengeToken({ userId: 'u-1', username: 'alice', isAdmin: false })
242
+ // Between the two legs, an admin granted u-1 a new scope.
243
+ userMgmt.verifyTotp.mockResolvedValueOnce({ valid: true })
244
+ userMgmt.listUsers.mockResolvedValueOnce([
245
+ makeUser({ scopes: [{ type: 'category', target: 'addon', access: ['view'] }] }),
246
+ ])
247
+ const router = createAuthRouter(auth, registry as never)
248
+ const result = await callProc(router, 'loginVerifyTotp', { challengeToken, code: '123456' }) as { token: string }
249
+ const decoded = auth.verifyToken(result.token)
250
+ // The fresh scope is in the minted session JWT, not the snapshot
251
+ // from the (potentially stale) password leg.
252
+ const scopes = (decoded as { scopes?: unknown[] }).scopes
253
+ expect(scopes).toHaveLength(1)
254
+ })
255
+ })
256
+ })