@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.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- 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
|
+
})
|