@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Addon settings router — raw DB proxy for the common settings API.
|
|
3
|
+
*
|
|
4
|
+
* Exposes four protected procedures consumed by:
|
|
5
|
+
* 1. Forked addons (via the tRPC WSS client in `WorkerBootstrapService`)
|
|
6
|
+
* to read/write their 3-level settings chain from the worker process.
|
|
7
|
+
* 2. Future UI flows that want to inspect/mutate addon settings through
|
|
8
|
+
* a single well-typed endpoint.
|
|
9
|
+
*
|
|
10
|
+
* The router is deliberately thin — it does NOT perform schema-based
|
|
11
|
+
* resolver merging (defaults → global → per-device). That happens on the
|
|
12
|
+
* consumer side where the addon's `ConfigUISchema` is available:
|
|
13
|
+
* - In-process addons: handled by `SettingsResolverService.createView()`
|
|
14
|
+
* wired into `AddonContext.settings` during `createAddonContext()`.
|
|
15
|
+
* - Forked addons: handled by the `AddonSettingsView` constructed inside
|
|
16
|
+
* `WorkerBootstrapService`, which has access to the worker's local
|
|
17
|
+
* addon schema.
|
|
18
|
+
*
|
|
19
|
+
* Introduced in session 5 Sprint 3a (worker-bootstrap cap-aware wiring).
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod'
|
|
22
|
+
import type { ConfigService } from '../../core/config/config.service.js'
|
|
23
|
+
import { trpcRouter, protectedProcedure } from '../trpc/trpc.middleware.js'
|
|
24
|
+
|
|
25
|
+
const AddonSettingsRecordSchema = z.record(z.string(), z.unknown())
|
|
26
|
+
|
|
27
|
+
const AddonIdInputSchema = z.object({
|
|
28
|
+
addonId: z.string(),
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const AddonDeviceInputSchema = z.object({
|
|
32
|
+
addonId: z.string(),
|
|
33
|
+
deviceId: z.string(),
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const UpdateGlobalInputSchema = z.object({
|
|
37
|
+
addonId: z.string(),
|
|
38
|
+
field: z.string(),
|
|
39
|
+
value: z.unknown(),
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const UpdateDeviceInputSchema = z.object({
|
|
43
|
+
addonId: z.string(),
|
|
44
|
+
deviceId: z.string(),
|
|
45
|
+
field: z.string(),
|
|
46
|
+
value: z.unknown(),
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
const SuccessSchema = z.object({ success: z.literal(true) })
|
|
50
|
+
|
|
51
|
+
const ReplaceGlobalInputSchema = z.object({
|
|
52
|
+
addonId: z.string(),
|
|
53
|
+
config: z.record(z.string(), z.unknown()),
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
export function createAddonSettingsRouter(cfg: ConfigService) {
|
|
57
|
+
return trpcRouter({
|
|
58
|
+
/**
|
|
59
|
+
* Read the addon-global settings record for the given addon.
|
|
60
|
+
* Returns the raw stored values (no defaults, no device overrides).
|
|
61
|
+
*/
|
|
62
|
+
getGlobal: protectedProcedure
|
|
63
|
+
.input(AddonIdInputSchema)
|
|
64
|
+
.output(AddonSettingsRecordSchema)
|
|
65
|
+
.query(({ input }) => cfg.getAddonConfig(input.addonId)),
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Read the per-device override record for the given addon × device.
|
|
69
|
+
* Returns the raw stored values (schema filtering happens on the
|
|
70
|
+
* consumer side at merge time).
|
|
71
|
+
*/
|
|
72
|
+
getDeviceOverrides: protectedProcedure
|
|
73
|
+
.input(AddonDeviceInputSchema)
|
|
74
|
+
.output(AddonSettingsRecordSchema)
|
|
75
|
+
.query(({ input }) => cfg.getAddonDevice(input.addonId, input.deviceId)),
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Update a single field in the addon-global settings record.
|
|
79
|
+
* Reads the current record, merges the new value, and writes back
|
|
80
|
+
* via `setAddonConfig` (bulk replace). Intended for small per-field
|
|
81
|
+
* writes from addon code; bulk updates should use a dedicated admin
|
|
82
|
+
* endpoint (not exposed here).
|
|
83
|
+
*/
|
|
84
|
+
updateGlobal: protectedProcedure
|
|
85
|
+
.input(UpdateGlobalInputSchema)
|
|
86
|
+
.output(SuccessSchema)
|
|
87
|
+
.mutation(({ input }) => {
|
|
88
|
+
const current = cfg.getAddonConfig(input.addonId)
|
|
89
|
+
cfg.setAddonConfig(input.addonId, { ...current, [input.field]: input.value })
|
|
90
|
+
return { success: true as const }
|
|
91
|
+
}),
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Update a single field in the per-device override record for the
|
|
95
|
+
* given addon × device. Merges with the existing overrides and
|
|
96
|
+
* writes back via `setAddonDevice`. Scope enforcement (dropping
|
|
97
|
+
* fields not declared as `scope: 'device'`) is the consumer's
|
|
98
|
+
* responsibility — we preserve the raw shape at this layer so the
|
|
99
|
+
* resolver contract remains symmetric with `getDeviceOverrides`.
|
|
100
|
+
*/
|
|
101
|
+
updateDevice: protectedProcedure
|
|
102
|
+
.input(UpdateDeviceInputSchema)
|
|
103
|
+
.output(SuccessSchema)
|
|
104
|
+
.mutation(({ input }) => {
|
|
105
|
+
const current = cfg.getAddonDevice(input.addonId, input.deviceId)
|
|
106
|
+
cfg.setAddonDevice(input.addonId, input.deviceId, { ...current, [input.field]: input.value })
|
|
107
|
+
return { success: true as const }
|
|
108
|
+
}),
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Replace the entire addon-global settings record in one call.
|
|
112
|
+
* Used by forked workers for `context.config.setAll()`. Unlike
|
|
113
|
+
* `updateGlobal` (single-field merge), this overwrites the full record.
|
|
114
|
+
* Admin-level write: only workers with valid hub tokens can call this.
|
|
115
|
+
*/
|
|
116
|
+
replaceGlobal: protectedProcedure
|
|
117
|
+
.input(ReplaceGlobalInputSchema)
|
|
118
|
+
.output(SuccessSchema)
|
|
119
|
+
.mutation(({ input }) => {
|
|
120
|
+
cfg.setAddonConfig(input.addonId, input.config)
|
|
121
|
+
return { success: true as const }
|
|
122
|
+
}),
|
|
123
|
+
})
|
|
124
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agents router — fixed core API (not a capability).
|
|
3
|
+
*
|
|
4
|
+
* Thin binding over AgentRegistryService which now delegates to Moleculer
|
|
5
|
+
* for node discovery and health. Only role-assignment management and
|
|
6
|
+
* node listing remain; protocol endpoints (register, heartbeat, task
|
|
7
|
+
* dispatch) are handled natively by the Moleculer service mesh.
|
|
8
|
+
*/
|
|
9
|
+
import { z } from 'zod'
|
|
10
|
+
import type { AgentRegistryService } from '../../core/agent/agent-registry.service.js'
|
|
11
|
+
import type { MoleculerService } from '../../core/moleculer/moleculer.service.js'
|
|
12
|
+
import {
|
|
13
|
+
trpcRouter, adminProcedure,
|
|
14
|
+
} from '../trpc/trpc.middleware.js'
|
|
15
|
+
|
|
16
|
+
const AgentRoleSchema = z.enum(['decoder', 'transcoder', 'detector', 'recorder'])
|
|
17
|
+
|
|
18
|
+
export function createAgentsRouter(
|
|
19
|
+
ar: AgentRegistryService,
|
|
20
|
+
moleculer: MoleculerService,
|
|
21
|
+
) {
|
|
22
|
+
return trpcRouter({
|
|
23
|
+
// ── Node listing (replaces listAgents / listConnected) ────────────
|
|
24
|
+
listNodes: adminProcedure
|
|
25
|
+
.input(z.void())
|
|
26
|
+
.query(async () => {
|
|
27
|
+
const items = await ar.listNodes()
|
|
28
|
+
// Spread to mutable copies: AgentListItem uses readonly arrays; Zod output schema uses mutable.
|
|
29
|
+
return items.map(a => ({
|
|
30
|
+
...a,
|
|
31
|
+
info: {
|
|
32
|
+
...a.info,
|
|
33
|
+
capabilities: [...a.info.capabilities],
|
|
34
|
+
},
|
|
35
|
+
status: {
|
|
36
|
+
...a.status,
|
|
37
|
+
fps: { ...a.status.fps },
|
|
38
|
+
errors: [...a.status.errors],
|
|
39
|
+
},
|
|
40
|
+
subProcesses: [...a.subProcesses],
|
|
41
|
+
}))
|
|
42
|
+
}),
|
|
43
|
+
|
|
44
|
+
// ── Capability discovery (via Moleculer service list) ─────────────
|
|
45
|
+
getAgentCapabilities: adminProcedure
|
|
46
|
+
.input(z.void())
|
|
47
|
+
.query(async () => {
|
|
48
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access -- moleculer.broker.call typing chain unresolvable; runtime shape validated by the cast below
|
|
49
|
+
const services = await moleculer.broker.call('$node.services', {}) as Array<{ name: string }>
|
|
50
|
+
const capSet = new Set<string>()
|
|
51
|
+
for (const svc of services) {
|
|
52
|
+
if (svc.name.startsWith('$')) continue
|
|
53
|
+
capSet.add(svc.name)
|
|
54
|
+
}
|
|
55
|
+
return [...capSet].sort()
|
|
56
|
+
}),
|
|
57
|
+
|
|
58
|
+
// ── Role assignments ──────────────────────────────────────────────
|
|
59
|
+
getAssignments: adminProcedure
|
|
60
|
+
.input(z.object({ cameraId: z.number().optional() }))
|
|
61
|
+
.query(({ input }) => ar.getAssignments(input.cameraId)),
|
|
62
|
+
|
|
63
|
+
setAssignment: adminProcedure
|
|
64
|
+
.input(z.object({
|
|
65
|
+
cameraId: z.number(),
|
|
66
|
+
role: AgentRoleSchema,
|
|
67
|
+
agentId: z.string(),
|
|
68
|
+
priority: z.enum(['primary', 'backup', 'overflow']),
|
|
69
|
+
rtspUrl: z.string().optional(),
|
|
70
|
+
}))
|
|
71
|
+
.mutation(({ input }) => ar.setAssignment(input as never)),
|
|
72
|
+
|
|
73
|
+
removeAssignment: adminProcedure
|
|
74
|
+
.input(z.object({
|
|
75
|
+
cameraId: z.number(),
|
|
76
|
+
role: AgentRoleSchema,
|
|
77
|
+
}))
|
|
78
|
+
.mutation(({ input }) => ar.removeAssignment(input.cameraId, input.role as never)),
|
|
79
|
+
|
|
80
|
+
activateBackup: adminProcedure
|
|
81
|
+
.input(z.object({
|
|
82
|
+
cameraId: z.number(),
|
|
83
|
+
role: AgentRoleSchema,
|
|
84
|
+
}))
|
|
85
|
+
.mutation(({ input }) => ar.activateBackup(input.cameraId, input.role as never)),
|
|
86
|
+
})
|
|
87
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Auth router — core API for login/logout/me.
|
|
3
|
+
*
|
|
4
|
+
* Login validates credentials via the `user-management` capability
|
|
5
|
+
* (owned by the local-auth addon) and signs a JWT.
|
|
6
|
+
*
|
|
7
|
+
* JWT signing stays in the server — it's a transport-level concern
|
|
8
|
+
* (the server owns the secret and the HTTP session).
|
|
9
|
+
*
|
|
10
|
+
* External auth providers are listed via the `auth-provider` cap collection.
|
|
11
|
+
*/
|
|
12
|
+
import { z } from 'zod'
|
|
13
|
+
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
14
|
+
import type { AuthService } from '../../core/auth/auth.service.js'
|
|
15
|
+
import { trpcRouter, publicProcedure, protectedProcedure } from '../trpc/trpc.middleware.js'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Login response — discriminated on `requiresTotp`.
|
|
19
|
+
*
|
|
20
|
+
* • `requiresTotp: false` (or absent in the legacy path): the
|
|
21
|
+
* credentials were sufficient, `token` is the real session JWT.
|
|
22
|
+
* • `requiresTotp: true`: the user has TOTP enrolled. `token` is a
|
|
23
|
+
* short-lived (5 min) challenge token, NOT a session. The client
|
|
24
|
+
* prompts for a 6-digit code and submits to `loginVerifyTotp`
|
|
25
|
+
* with `{challengeToken, code}`. Only on success does the server
|
|
26
|
+
* mint the real session.
|
|
27
|
+
*/
|
|
28
|
+
const LoginResultSchema = z.object({
|
|
29
|
+
token: z.string(),
|
|
30
|
+
user: z.object({
|
|
31
|
+
id: z.string(),
|
|
32
|
+
username: z.string(),
|
|
33
|
+
isAdmin: z.boolean(),
|
|
34
|
+
}),
|
|
35
|
+
requiresTotp: z.boolean().optional(),
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
const AuthProviderSummarySchema = z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
name: z.string(),
|
|
41
|
+
icon: z.string(),
|
|
42
|
+
flowType: z.string(),
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
/** Wire shape of the authenticated user returned by `auth.me`. */
|
|
46
|
+
const MeSchema = z.object({
|
|
47
|
+
id: z.string(),
|
|
48
|
+
username: z.string(),
|
|
49
|
+
isAdmin: z.boolean(),
|
|
50
|
+
permissions: z.object({
|
|
51
|
+
isAdmin: z.boolean(),
|
|
52
|
+
allowedProviders: z.union([z.literal('*'), z.array(z.string())]),
|
|
53
|
+
allowedDevices: z.record(z.string(), z.unknown()),
|
|
54
|
+
}),
|
|
55
|
+
isApiKey: z.boolean(),
|
|
56
|
+
agentId: z.string().optional(),
|
|
57
|
+
}).nullable()
|
|
58
|
+
|
|
59
|
+
export function createAuthRouter(
|
|
60
|
+
auth: AuthService,
|
|
61
|
+
registry: CapabilityRegistry | null,
|
|
62
|
+
) {
|
|
63
|
+
return trpcRouter({
|
|
64
|
+
login: publicProcedure
|
|
65
|
+
.input(z.object({ username: z.string(), password: z.string() }))
|
|
66
|
+
.output(LoginResultSchema)
|
|
67
|
+
.mutation(async ({ input }) => {
|
|
68
|
+
const userMgmt = registry?.getSingleton('user-management')
|
|
69
|
+
if (!userMgmt) {
|
|
70
|
+
// The local-auth addon owns this cap. When this fires the
|
|
71
|
+
// addon either failed to initialize or is still booting.
|
|
72
|
+
// Check the server log for `[hub/local-auth]` errors —
|
|
73
|
+
// common cause is a settings-store regression that breaks
|
|
74
|
+
// the `users` collection query path.
|
|
75
|
+
throw new Error(
|
|
76
|
+
'Login unavailable — `user-management` capability not registered. '
|
|
77
|
+
+ 'The `local-auth` addon failed to initialize; check server logs '
|
|
78
|
+
+ 'for an error tagged `[hub/local-auth]`.',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const user = await userMgmt.validateCredentials({ username: input.username, password: input.password })
|
|
83
|
+
if (!user) throw new Error('Invalid credentials')
|
|
84
|
+
|
|
85
|
+
// ── TOTP gate ────────────────────────────────────────────────
|
|
86
|
+
// After credentials validate, check whether the user has
|
|
87
|
+
// active 2FA enrollment. If yes, mint a SHORT-LIVED challenge
|
|
88
|
+
// token instead of the real session — the client must follow
|
|
89
|
+
// up via `loginVerifyTotp` with a valid 6-digit code before
|
|
90
|
+
// we hand out the actual JWT. The challenge token carries
|
|
91
|
+
// `kind: 'totp-challenge'` so it can't be replayed against
|
|
92
|
+
// protected endpoints (the auth middleware rejects anything
|
|
93
|
+
// without the standard session shape).
|
|
94
|
+
const totpStatus = typeof userMgmt.getTotpStatus === 'function'
|
|
95
|
+
? await userMgmt.getTotpStatus({ userId: user.id })
|
|
96
|
+
: { enabled: false }
|
|
97
|
+
if (totpStatus.enabled) {
|
|
98
|
+
const challengeToken = auth.signTotpChallengeToken({
|
|
99
|
+
userId: user.id,
|
|
100
|
+
username: user.username,
|
|
101
|
+
isAdmin: user.isAdmin,
|
|
102
|
+
})
|
|
103
|
+
return {
|
|
104
|
+
token: challengeToken,
|
|
105
|
+
user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
|
|
106
|
+
requiresTotp: true,
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Snapshot `scopes` into the JWT payload. Admins ignore the
|
|
111
|
+
// field (middleware bypass); for non-admins this drives the
|
|
112
|
+
// entire scope-access check until the user logs out + back in.
|
|
113
|
+
// setUserScopes mutations take effect on next login — old JWTs
|
|
114
|
+
// carry the snapshot from issue time. This is the standard JWT
|
|
115
|
+
// staleness tradeoff; if you need immediate revocation, force a
|
|
116
|
+
// logout from the admin UI.
|
|
117
|
+
const token = auth.signToken({
|
|
118
|
+
userId: user.id,
|
|
119
|
+
username: user.username,
|
|
120
|
+
isAdmin: user.isAdmin,
|
|
121
|
+
allowedProviders: user.allowedProviders ?? '*',
|
|
122
|
+
allowedDevices: user.allowedDevices ?? {},
|
|
123
|
+
scopes: user.scopes ?? [],
|
|
124
|
+
})
|
|
125
|
+
return {
|
|
126
|
+
token,
|
|
127
|
+
user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
|
|
128
|
+
requiresTotp: false,
|
|
129
|
+
}
|
|
130
|
+
}),
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Second leg of the 2FA login. Accepts the challenge token minted
|
|
134
|
+
* by `login` (when `requiresTotp: true`) plus the operator-entered
|
|
135
|
+
* 6-digit code. Re-validates both, then mints the real session.
|
|
136
|
+
*
|
|
137
|
+
* Failure modes:
|
|
138
|
+
* • Invalid/expired challenge token → 401 (session timed out,
|
|
139
|
+
* restart login).
|
|
140
|
+
* • Code doesn't match → 401 (try again with a fresh code from
|
|
141
|
+
* the authenticator app).
|
|
142
|
+
* • User vanished between leg 1 and leg 2 → 401 (operator was
|
|
143
|
+
* deleted concurrently — rare).
|
|
144
|
+
*/
|
|
145
|
+
loginVerifyTotp: publicProcedure
|
|
146
|
+
.input(z.object({ challengeToken: z.string(), code: z.string() }))
|
|
147
|
+
.output(LoginResultSchema)
|
|
148
|
+
.mutation(async ({ input }) => {
|
|
149
|
+
const claims = auth.verifyTotpChallengeToken(input.challengeToken)
|
|
150
|
+
if (!claims) {
|
|
151
|
+
throw new Error('Invalid or expired TOTP challenge — please re-enter your password')
|
|
152
|
+
}
|
|
153
|
+
const userMgmt = registry?.getSingleton('user-management')
|
|
154
|
+
if (!userMgmt) {
|
|
155
|
+
throw new Error('Login unavailable — `user-management` capability not registered')
|
|
156
|
+
}
|
|
157
|
+
const verify = typeof userMgmt.verifyTotp === 'function'
|
|
158
|
+
? await userMgmt.verifyTotp({ userId: claims.userId, code: input.code.trim() })
|
|
159
|
+
: { valid: false }
|
|
160
|
+
if (!verify.valid) {
|
|
161
|
+
throw new Error('Invalid TOTP code')
|
|
162
|
+
}
|
|
163
|
+
// Re-fetch the user record so scopes / allowedDevices reflect
|
|
164
|
+
// any admin change made between the two legs. Without this we
|
|
165
|
+
// could mint a stale-scope session.
|
|
166
|
+
const fresh = typeof userMgmt.listUsers === 'function'
|
|
167
|
+
? (await userMgmt.listUsers()).find((u) => u.id === claims.userId)
|
|
168
|
+
: null
|
|
169
|
+
if (!fresh) {
|
|
170
|
+
throw new Error('User no longer exists')
|
|
171
|
+
}
|
|
172
|
+
const sessionToken = auth.signToken({
|
|
173
|
+
userId: fresh.id,
|
|
174
|
+
username: fresh.username,
|
|
175
|
+
isAdmin: fresh.isAdmin,
|
|
176
|
+
allowedProviders: fresh.allowedProviders ?? '*',
|
|
177
|
+
allowedDevices: fresh.allowedDevices ?? {},
|
|
178
|
+
scopes: fresh.scopes ?? [],
|
|
179
|
+
})
|
|
180
|
+
return {
|
|
181
|
+
token: sessionToken,
|
|
182
|
+
user: { id: fresh.id, username: fresh.username, isAdmin: fresh.isAdmin },
|
|
183
|
+
requiresTotp: false,
|
|
184
|
+
}
|
|
185
|
+
}),
|
|
186
|
+
|
|
187
|
+
me: protectedProcedure
|
|
188
|
+
.input(z.void())
|
|
189
|
+
.output(MeSchema)
|
|
190
|
+
.query(({ ctx }) => ctx.user),
|
|
191
|
+
|
|
192
|
+
// ── Self-service profile operations ───────────────────────────────
|
|
193
|
+
//
|
|
194
|
+
// These route through the `user-management` capability provider but
|
|
195
|
+
// bind `userId` to the CALLER's session identity (`ctx.user.id`)
|
|
196
|
+
// — i.e. every authenticated user can manage THEIR OWN password and
|
|
197
|
+
// TOTP enrollment, without the admin gate that the corresponding
|
|
198
|
+
// cap methods enforce on the trpc layer.
|
|
199
|
+
//
|
|
200
|
+
// Provider methods themselves don't check admin status, so calling
|
|
201
|
+
// them directly here is fine. The trpc admin-gate on the cap router
|
|
202
|
+
// exists only to block third-party tampering — operators acting on
|
|
203
|
+
// themselves bypass it intentionally.
|
|
204
|
+
changeOwnPassword: protectedProcedure
|
|
205
|
+
.input(z.object({
|
|
206
|
+
currentPassword: z.string().min(1),
|
|
207
|
+
newPassword: z.string().min(8),
|
|
208
|
+
}))
|
|
209
|
+
.output(z.object({ success: z.literal(true) }))
|
|
210
|
+
.mutation(async ({ input, ctx }) => {
|
|
211
|
+
const userMgmt = registry?.getSingleton('user-management') as
|
|
212
|
+
| {
|
|
213
|
+
validateCredentials: (i: { username: string; password: string }) => Promise<{ id: string } | null>
|
|
214
|
+
resetPassword: (i: { id: string; newPassword: string }) => Promise<{ success: true }>
|
|
215
|
+
}
|
|
216
|
+
| null
|
|
217
|
+
| undefined
|
|
218
|
+
if (!userMgmt) throw new Error('user-management capability not available')
|
|
219
|
+
if (!ctx.user) throw new Error('Not authenticated')
|
|
220
|
+
// Re-validate the current password against the live store to
|
|
221
|
+
// confirm session identity → password ownership match. Stops a
|
|
222
|
+
// stolen session-token from rotating credentials silently.
|
|
223
|
+
const ok = await userMgmt.validateCredentials({ username: ctx.user.username, password: input.currentPassword })
|
|
224
|
+
if (!ok) throw new Error('Current password is incorrect')
|
|
225
|
+
await userMgmt.resetPassword({ id: ctx.user.id, newPassword: input.newPassword })
|
|
226
|
+
return { success: true as const }
|
|
227
|
+
}),
|
|
228
|
+
|
|
229
|
+
setupOwnTotp: protectedProcedure
|
|
230
|
+
.input(z.void())
|
|
231
|
+
.output(z.object({ secret: z.string(), otpauthUrl: z.string() }))
|
|
232
|
+
.mutation(async ({ ctx }) => {
|
|
233
|
+
const userMgmt = registry?.getSingleton('user-management') as
|
|
234
|
+
| { setupTotp: (i: { userId: string }) => Promise<{ secret: string; otpauthUrl: string }> }
|
|
235
|
+
| null
|
|
236
|
+
| undefined
|
|
237
|
+
if (!userMgmt) throw new Error('user-management capability not available')
|
|
238
|
+
if (!ctx.user) throw new Error('Not authenticated')
|
|
239
|
+
return userMgmt.setupTotp({ userId: ctx.user.id })
|
|
240
|
+
}),
|
|
241
|
+
|
|
242
|
+
confirmOwnTotp: protectedProcedure
|
|
243
|
+
.input(z.object({ code: z.string().min(1) }))
|
|
244
|
+
.output(z.object({ success: z.literal(true) }))
|
|
245
|
+
.mutation(async ({ input, ctx }) => {
|
|
246
|
+
const userMgmt = registry?.getSingleton('user-management') as
|
|
247
|
+
| { confirmTotp: (i: { userId: string; code: string }) => Promise<{ success: true }> }
|
|
248
|
+
| null
|
|
249
|
+
| undefined
|
|
250
|
+
if (!userMgmt) throw new Error('user-management capability not available')
|
|
251
|
+
if (!ctx.user) throw new Error('Not authenticated')
|
|
252
|
+
return userMgmt.confirmTotp({ userId: ctx.user.id, code: input.code })
|
|
253
|
+
}),
|
|
254
|
+
|
|
255
|
+
disableOwnTotp: protectedProcedure
|
|
256
|
+
.input(z.void())
|
|
257
|
+
.output(z.object({ success: z.literal(true) }))
|
|
258
|
+
.mutation(async ({ ctx }) => {
|
|
259
|
+
const userMgmt = registry?.getSingleton('user-management') as
|
|
260
|
+
| { disableTotp: (i: { userId: string }) => Promise<{ success: true }> }
|
|
261
|
+
| null
|
|
262
|
+
| undefined
|
|
263
|
+
if (!userMgmt) throw new Error('user-management capability not available')
|
|
264
|
+
if (!ctx.user) throw new Error('Not authenticated')
|
|
265
|
+
return userMgmt.disableTotp({ userId: ctx.user.id })
|
|
266
|
+
}),
|
|
267
|
+
|
|
268
|
+
getOwnTotpStatus: protectedProcedure
|
|
269
|
+
.input(z.void())
|
|
270
|
+
.output(z.object({ enabled: z.boolean(), confirmedAt: z.number().nullable() }))
|
|
271
|
+
.query(async ({ ctx }) => {
|
|
272
|
+
const userMgmt = registry?.getSingleton('user-management') as
|
|
273
|
+
| { getTotpStatus: (i: { userId: string }) => Promise<{ enabled: boolean; confirmedAt: number | null }> }
|
|
274
|
+
| null
|
|
275
|
+
| undefined
|
|
276
|
+
if (!userMgmt || !ctx.user) return { enabled: false, confirmedAt: null }
|
|
277
|
+
return userMgmt.getTotpStatus({ userId: ctx.user.id })
|
|
278
|
+
}),
|
|
279
|
+
|
|
280
|
+
logout: protectedProcedure
|
|
281
|
+
.input(z.void())
|
|
282
|
+
.output(z.object({ success: z.literal(true) }))
|
|
283
|
+
.mutation(() => ({ success: true as const })),
|
|
284
|
+
|
|
285
|
+
listProviders: publicProcedure
|
|
286
|
+
.input(z.void())
|
|
287
|
+
.output(z.array(AuthProviderSummarySchema).readonly())
|
|
288
|
+
.query(() => {
|
|
289
|
+
if (!registry) return []
|
|
290
|
+
// Validate each auth-provider entry independently. A single
|
|
291
|
+
// malformed entry (e.g. an auth addon that registers without an
|
|
292
|
+
// `icon`) must NOT sink the whole array through the tRPC output
|
|
293
|
+
// validator — that would 500 the query and lock every login
|
|
294
|
+
// method out of the UI. Drop the bad entry, keep the rest.
|
|
295
|
+
const out: z.infer<typeof AuthProviderSummarySchema>[] = []
|
|
296
|
+
for (const entry of registry.getCollection('auth-provider')) {
|
|
297
|
+
const parsed = AuthProviderSummarySchema.safeParse(entry)
|
|
298
|
+
if (parsed.success) out.push(parsed.data)
|
|
299
|
+
}
|
|
300
|
+
return out
|
|
301
|
+
}),
|
|
302
|
+
})
|
|
303
|
+
}
|