@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,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
+ }