@camstack/server 0.2.2 → 1.0.1

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 (234) hide show
  1. package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
  2. package/dist/api/addon-upload.js +441 -0
  3. package/dist/api/addons-custom.router.js +91 -0
  4. package/dist/api/auth-whoami.js +55 -0
  5. package/dist/api/bridge-addons.router.js +109 -0
  6. package/dist/api/capabilities.router.js +229 -0
  7. package/dist/api/core/addon-settings.router.js +117 -0
  8. package/dist/api/core/agents.router.js +73 -0
  9. package/dist/api/core/auth.router.js +286 -0
  10. package/dist/api/core/bulk-update-coordinator.js +229 -0
  11. package/dist/api/core/cap-providers.js +1124 -0
  12. package/dist/api/core/capabilities.router.js +138 -0
  13. package/dist/api/core/collection-preference.js +17 -0
  14. package/dist/api/core/event-bus-proxy.router.js +45 -0
  15. package/dist/api/core/hwaccel.router.js +91 -0
  16. package/dist/api/core/live-events.router.js +61 -0
  17. package/dist/api/core/logs.router.js +172 -0
  18. package/dist/api/core/notifications.router.js +67 -0
  19. package/dist/api/core/repl.router.js +35 -0
  20. package/dist/api/core/settings-backend.router.js +121 -0
  21. package/dist/api/core/stream-probe.router.js +58 -0
  22. package/dist/api/core/system-events.router.js +100 -0
  23. package/dist/api/health/health.routes.js +68 -0
  24. package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
  25. package/dist/api/oauth2/oauth2-routes.js +219 -0
  26. package/dist/api/trpc/cap-mount-helpers.js +194 -0
  27. package/dist/api/trpc/cap-route-error-formatter.js +133 -0
  28. package/dist/api/trpc/client-ip.js +147 -0
  29. package/dist/api/trpc/core-cap-bridge.js +115 -0
  30. package/dist/api/trpc/generated-cap-mounts.js +388 -0
  31. package/dist/api/trpc/generated-cap-routers.js +7635 -0
  32. package/dist/api/trpc/scope-access.js +93 -0
  33. package/dist/api/trpc/trpc.context.js +184 -0
  34. package/dist/api/trpc/trpc.middleware.js +139 -0
  35. package/dist/api/trpc/trpc.router.js +188 -0
  36. package/dist/auth/session-cookie.js +47 -0
  37. package/dist/boot/boot-config.js +241 -0
  38. package/dist/boot/integration-id-backfill.js +76 -0
  39. package/dist/boot/post-boot.service.js +85 -0
  40. package/dist/core/addon/addon-call-gateway.js +99 -0
  41. package/dist/core/addon/addon-package.service.js +1560 -0
  42. package/dist/core/addon/addon-registry.service.js +2739 -0
  43. package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
  44. package/dist/core/addon/addon-search.service.js +62 -0
  45. package/dist/core/addon/addon-settings-provider.js +102 -0
  46. package/dist/core/addon/addon.tokens.js +5 -0
  47. package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
  48. package/dist/core/addon-pages/addon-pages.service.js +107 -0
  49. package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
  50. package/dist/core/agent/agent-registry.service.js +477 -0
  51. package/dist/core/auth/auth.service.js +10 -0
  52. package/dist/core/capability/capability.service.js +58 -0
  53. package/dist/core/config/config.schema.js +7 -0
  54. package/dist/core/config/config.service.js +10 -0
  55. package/dist/core/events/event-bus.service.js +83 -0
  56. package/dist/core/feature/feature.service.js +10 -0
  57. package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
  58. package/dist/core/logging/log-ring-buffer.js +6 -0
  59. package/dist/core/logging/logging.service.js +130 -0
  60. package/dist/core/logging/scoped-logger.js +6 -0
  61. package/dist/core/moleculer/cap-call-fn.js +50 -0
  62. package/dist/core/moleculer/cap-route-authority.js +122 -0
  63. package/dist/core/moleculer/moleculer.service.js +898 -0
  64. package/dist/core/network/network-quality.service.js +7 -0
  65. package/dist/core/notification/notification-wrapper.service.js +33 -0
  66. package/dist/core/notification/toast-wrapper.service.js +25 -0
  67. package/dist/core/provider/provider.tokens.js +4 -0
  68. package/dist/core/repl/repl-engine.service.js +140 -0
  69. package/dist/core/storage/fs-storage-backend.js +6 -0
  70. package/dist/core/storage/storage-location-manager.js +6 -0
  71. package/dist/core/storage/storage.service.js +7 -0
  72. package/dist/core/streaming/stream-probe.service.js +209 -0
  73. package/dist/core/topology/topology-emitter.service.js +106 -0
  74. package/dist/launcher.js +325 -0
  75. package/dist/main.js +1098 -0
  76. package/dist/manual-boot.js +227 -0
  77. package/package.json +5 -1
  78. package/src/__tests__/addon-install-e2e.test.ts +0 -74
  79. package/src/__tests__/addon-pages-e2e.test.ts +0 -200
  80. package/src/__tests__/addon-route-session.test.ts +0 -17
  81. package/src/__tests__/addon-settings-router.spec.ts +0 -67
  82. package/src/__tests__/addon-upload.spec.ts +0 -475
  83. package/src/__tests__/agent-registry.spec.ts +0 -179
  84. package/src/__tests__/agent-status-page.spec.ts +0 -82
  85. package/src/__tests__/auth-session-cookie.test.ts +0 -48
  86. package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
  87. package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
  88. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
  89. package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
  90. package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
  91. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
  92. package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
  93. package/src/__tests__/cap-route-adapter.spec.ts +0 -302
  94. package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
  95. package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
  96. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
  97. package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
  98. package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
  99. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
  100. package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
  101. package/src/__tests__/cap-routers/harness.ts +0 -163
  102. package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
  103. package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
  104. package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
  105. package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
  106. package/src/__tests__/capability-e2e.test.ts +0 -384
  107. package/src/__tests__/cli-e2e.test.ts +0 -150
  108. package/src/__tests__/core-cap-bridge.spec.ts +0 -91
  109. package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
  110. package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
  111. package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
  112. package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
  113. package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
  114. package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
  115. package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
  116. package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
  117. package/src/__tests__/framework-allowlist.spec.ts +0 -96
  118. package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
  119. package/src/__tests__/https-e2e.test.ts +0 -124
  120. package/src/__tests__/lifecycle-e2e.test.ts +0 -189
  121. package/src/__tests__/live-events-subscription.spec.ts +0 -149
  122. package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
  123. package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
  124. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
  125. package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
  126. package/src/__tests__/native-cap-route.spec.ts +0 -427
  127. package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
  128. package/src/__tests__/post-boot-restart.spec.ts +0 -161
  129. package/src/__tests__/singleton-contention.test.ts +0 -499
  130. package/src/__tests__/streaming-diagnostic.test.ts +0 -615
  131. package/src/__tests__/streaming-scale.test.ts +0 -314
  132. package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
  133. package/src/__tests__/uds-log-ingest.spec.ts +0 -183
  134. package/src/api/__tests__/addons-custom.spec.ts +0 -148
  135. package/src/api/__tests__/capabilities.router.test.ts +0 -56
  136. package/src/api/addon-upload.ts +0 -529
  137. package/src/api/addons-custom.router.ts +0 -101
  138. package/src/api/auth-whoami.ts +0 -101
  139. package/src/api/bridge-addons.router.ts +0 -122
  140. package/src/api/capabilities.router.ts +0 -265
  141. package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
  142. package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
  143. package/src/api/core/addon-settings.router.ts +0 -127
  144. package/src/api/core/agents.router.ts +0 -86
  145. package/src/api/core/auth.router.ts +0 -322
  146. package/src/api/core/bulk-update-coordinator.ts +0 -305
  147. package/src/api/core/cap-providers.ts +0 -1339
  148. package/src/api/core/capabilities.router.ts +0 -149
  149. package/src/api/core/collection-preference.ts +0 -40
  150. package/src/api/core/event-bus-proxy.router.ts +0 -45
  151. package/src/api/core/hwaccel.router.ts +0 -108
  152. package/src/api/core/live-events.router.ts +0 -67
  153. package/src/api/core/logs.router.ts +0 -195
  154. package/src/api/core/notifications.router.ts +0 -66
  155. package/src/api/core/repl.router.ts +0 -39
  156. package/src/api/core/settings-backend.router.ts +0 -140
  157. package/src/api/core/stream-probe.router.ts +0 -57
  158. package/src/api/core/system-events.router.ts +0 -125
  159. package/src/api/health/health.routes.ts +0 -117
  160. package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
  161. package/src/api/oauth2/oauth2-routes.ts +0 -281
  162. package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
  163. package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
  164. package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
  165. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
  166. package/src/api/trpc/cap-mount-helpers.ts +0 -245
  167. package/src/api/trpc/cap-route-error-formatter.ts +0 -171
  168. package/src/api/trpc/client-ip.ts +0 -147
  169. package/src/api/trpc/core-cap-bridge.ts +0 -154
  170. package/src/api/trpc/generated-cap-mounts.ts +0 -1240
  171. package/src/api/trpc/generated-cap-routers.ts +0 -11523
  172. package/src/api/trpc/scope-access.ts +0 -110
  173. package/src/api/trpc/trpc.context.ts +0 -258
  174. package/src/api/trpc/trpc.middleware.ts +0 -146
  175. package/src/api/trpc/trpc.router.ts +0 -389
  176. package/src/auth/session-cookie.ts +0 -54
  177. package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
  178. package/src/boot/boot-config.ts +0 -259
  179. package/src/boot/integration-id-backfill.ts +0 -109
  180. package/src/boot/post-boot.service.ts +0 -105
  181. package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
  182. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
  183. package/src/core/addon/addon-call-gateway.ts +0 -171
  184. package/src/core/addon/addon-package.service.ts +0 -1787
  185. package/src/core/addon/addon-registry.service.ts +0 -3130
  186. package/src/core/addon/addon-search.service.ts +0 -91
  187. package/src/core/addon/addon-settings-provider.ts +0 -220
  188. package/src/core/addon/addon.tokens.ts +0 -2
  189. package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
  190. package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
  191. package/src/core/addon-pages/addon-pages.service.ts +0 -82
  192. package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
  193. package/src/core/agent/agent-registry.service.ts +0 -529
  194. package/src/core/auth/auth.service.spec.ts +0 -86
  195. package/src/core/auth/auth.service.ts +0 -8
  196. package/src/core/capability/capability.service.ts +0 -66
  197. package/src/core/config/config.schema.ts +0 -3
  198. package/src/core/config/config.service.spec.ts +0 -175
  199. package/src/core/config/config.service.ts +0 -7
  200. package/src/core/events/event-bus.service.spec.ts +0 -235
  201. package/src/core/events/event-bus.service.ts +0 -89
  202. package/src/core/feature/feature.service.spec.ts +0 -99
  203. package/src/core/feature/feature.service.ts +0 -8
  204. package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
  205. package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
  206. package/src/core/logging/log-ring-buffer.ts +0 -3
  207. package/src/core/logging/logging.service.spec.ts +0 -287
  208. package/src/core/logging/logging.service.ts +0 -143
  209. package/src/core/logging/scoped-logger.ts +0 -3
  210. package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
  211. package/src/core/moleculer/cap-call-fn.ts +0 -107
  212. package/src/core/moleculer/cap-route-authority.ts +0 -194
  213. package/src/core/moleculer/moleculer.service.ts +0 -1072
  214. package/src/core/network/network-quality.service.spec.ts +0 -53
  215. package/src/core/network/network-quality.service.ts +0 -5
  216. package/src/core/notification/notification-wrapper.service.ts +0 -34
  217. package/src/core/notification/toast-wrapper.service.ts +0 -27
  218. package/src/core/provider/provider.tokens.ts +0 -1
  219. package/src/core/repl/repl-engine.service.spec.ts +0 -444
  220. package/src/core/repl/repl-engine.service.ts +0 -155
  221. package/src/core/storage/fs-storage-backend.spec.ts +0 -70
  222. package/src/core/storage/fs-storage-backend.ts +0 -3
  223. package/src/core/storage/storage-location-manager.spec.ts +0 -130
  224. package/src/core/storage/storage-location-manager.ts +0 -3
  225. package/src/core/storage/storage.service.spec.ts +0 -73
  226. package/src/core/storage/storage.service.ts +0 -3
  227. package/src/core/streaming/stream-probe.service.ts +0 -221
  228. package/src/core/topology/topology-emitter.service.ts +0 -105
  229. package/src/launcher.ts +0 -314
  230. package/src/main.ts +0 -1245
  231. package/src/manual-boot.ts +0 -301
  232. package/tsconfig.build.json +0 -8
  233. package/tsconfig.json +0 -33
  234. package/vitest.config.ts +0 -26
@@ -1,322 +0,0 @@
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
47
- .object({
48
- id: z.string(),
49
- username: z.string(),
50
- isAdmin: z.boolean(),
51
- permissions: z.object({
52
- isAdmin: z.boolean(),
53
- allowedProviders: z.union([z.literal('*'), z.array(z.string())]),
54
- allowedDevices: z.record(z.string(), z.unknown()),
55
- }),
56
- isApiKey: z.boolean(),
57
- agentId: z.string().optional(),
58
- })
59
- .nullable()
60
-
61
- export function createAuthRouter(auth: AuthService, registry: CapabilityRegistry | null) {
62
- return trpcRouter({
63
- login: publicProcedure
64
- .input(z.object({ username: z.string(), password: z.string() }))
65
- .output(LoginResultSchema)
66
- .mutation(async ({ input }) => {
67
- const userMgmt = registry?.getSingleton('user-management')
68
- if (!userMgmt) {
69
- // The local-auth addon owns this cap. When this fires the
70
- // addon either failed to initialize or is still booting.
71
- // Check the server log for `[hub/local-auth]` errors —
72
- // common cause is a settings-store regression that breaks
73
- // the `users` collection query path.
74
- throw new Error(
75
- 'Login unavailable — `user-management` capability not registered. ' +
76
- 'The `local-auth` addon failed to initialize; check server logs ' +
77
- 'for an error tagged `[hub/local-auth]`.',
78
- )
79
- }
80
-
81
- const user = await userMgmt.validateCredentials({
82
- username: input.username,
83
- password: input.password,
84
- })
85
- if (!user) throw new Error('Invalid credentials')
86
-
87
- // ── TOTP gate ────────────────────────────────────────────────
88
- // After credentials validate, check whether the user has
89
- // active 2FA enrollment. If yes, mint a SHORT-LIVED challenge
90
- // token instead of the real session — the client must follow
91
- // up via `loginVerifyTotp` with a valid 6-digit code before
92
- // we hand out the actual JWT. The challenge token carries
93
- // `kind: 'totp-challenge'` so it can't be replayed against
94
- // protected endpoints (the auth middleware rejects anything
95
- // without the standard session shape).
96
- const totpStatus =
97
- typeof userMgmt.getTotpStatus === 'function'
98
- ? await userMgmt.getTotpStatus({ userId: user.id })
99
- : { enabled: false }
100
- if (totpStatus.enabled) {
101
- const challengeToken = auth.signTotpChallengeToken({
102
- userId: user.id,
103
- username: user.username,
104
- isAdmin: user.isAdmin,
105
- })
106
- return {
107
- token: challengeToken,
108
- user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
109
- requiresTotp: true,
110
- }
111
- }
112
-
113
- // Snapshot `scopes` into the JWT payload. Admins ignore the
114
- // field (middleware bypass); for non-admins this drives the
115
- // entire scope-access check until the user logs out + back in.
116
- // setUserScopes mutations take effect on next login — old JWTs
117
- // carry the snapshot from issue time. This is the standard JWT
118
- // staleness tradeoff; if you need immediate revocation, force a
119
- // logout from the admin UI.
120
- const token = auth.signToken({
121
- userId: user.id,
122
- username: user.username,
123
- isAdmin: user.isAdmin,
124
- allowedProviders: user.allowedProviders ?? '*',
125
- allowedDevices: user.allowedDevices ?? {},
126
- scopes: user.scopes ?? [],
127
- })
128
- return {
129
- token,
130
- user: { id: user.id, username: user.username, isAdmin: user.isAdmin },
131
- requiresTotp: false,
132
- }
133
- }),
134
-
135
- /**
136
- * Second leg of the 2FA login. Accepts the challenge token minted
137
- * by `login` (when `requiresTotp: true`) plus the operator-entered
138
- * 6-digit code. Re-validates both, then mints the real session.
139
- *
140
- * Failure modes:
141
- * • Invalid/expired challenge token → 401 (session timed out,
142
- * restart login).
143
- * • Code doesn't match → 401 (try again with a fresh code from
144
- * the authenticator app).
145
- * • User vanished between leg 1 and leg 2 → 401 (operator was
146
- * deleted concurrently — rare).
147
- */
148
- loginVerifyTotp: publicProcedure
149
- .input(z.object({ challengeToken: z.string(), code: z.string() }))
150
- .output(LoginResultSchema)
151
- .mutation(async ({ input }) => {
152
- const claims = auth.verifyTotpChallengeToken(input.challengeToken)
153
- if (!claims) {
154
- throw new Error('Invalid or expired TOTP challenge — please re-enter your password')
155
- }
156
- const userMgmt = registry?.getSingleton('user-management')
157
- if (!userMgmt) {
158
- throw new Error('Login unavailable — `user-management` capability not registered')
159
- }
160
- const verify =
161
- typeof userMgmt.verifyTotp === 'function'
162
- ? await userMgmt.verifyTotp({ userId: claims.userId, code: input.code.trim() })
163
- : { valid: false }
164
- if (!verify.valid) {
165
- throw new Error('Invalid TOTP code')
166
- }
167
- // Re-fetch the user record so scopes / allowedDevices reflect
168
- // any admin change made between the two legs. Without this we
169
- // could mint a stale-scope session.
170
- const fresh =
171
- typeof userMgmt.listUsers === 'function'
172
- ? (await userMgmt.listUsers()).find((u) => u.id === claims.userId)
173
- : null
174
- if (!fresh) {
175
- throw new Error('User no longer exists')
176
- }
177
- const sessionToken = auth.signToken({
178
- userId: fresh.id,
179
- username: fresh.username,
180
- isAdmin: fresh.isAdmin,
181
- allowedProviders: fresh.allowedProviders ?? '*',
182
- allowedDevices: fresh.allowedDevices ?? {},
183
- scopes: fresh.scopes ?? [],
184
- })
185
- return {
186
- token: sessionToken,
187
- user: { id: fresh.id, username: fresh.username, isAdmin: fresh.isAdmin },
188
- requiresTotp: false,
189
- }
190
- }),
191
-
192
- me: protectedProcedure
193
- .input(z.void())
194
- .output(MeSchema)
195
- .query(({ ctx }) => ctx.user),
196
-
197
- // ── Self-service profile operations ───────────────────────────────
198
- //
199
- // These route through the `user-management` capability provider but
200
- // bind `userId` to the CALLER's session identity (`ctx.user.id`)
201
- // — i.e. every authenticated user can manage THEIR OWN password and
202
- // TOTP enrollment, without the admin gate that the corresponding
203
- // cap methods enforce on the trpc layer.
204
- //
205
- // Provider methods themselves don't check admin status, so calling
206
- // them directly here is fine. The trpc admin-gate on the cap router
207
- // exists only to block third-party tampering — operators acting on
208
- // themselves bypass it intentionally.
209
- changeOwnPassword: protectedProcedure
210
- .input(
211
- z.object({
212
- currentPassword: z.string().min(1),
213
- newPassword: z.string().min(8),
214
- }),
215
- )
216
- .output(z.object({ success: z.literal(true) }))
217
- .mutation(async ({ input, ctx }) => {
218
- const userMgmt = registry?.getSingleton('user-management') as
219
- | {
220
- validateCredentials: (i: {
221
- username: string
222
- password: string
223
- }) => Promise<{ id: string } | null>
224
- resetPassword: (i: { id: string; newPassword: string }) => Promise<{ success: true }>
225
- }
226
- | null
227
- | undefined
228
- if (!userMgmt) throw new Error('user-management capability not available')
229
- if (!ctx.user) throw new Error('Not authenticated')
230
- // Re-validate the current password against the live store to
231
- // confirm session identity → password ownership match. Stops a
232
- // stolen session-token from rotating credentials silently.
233
- const ok = await userMgmt.validateCredentials({
234
- username: ctx.user.username,
235
- password: input.currentPassword,
236
- })
237
- if (!ok) throw new Error('Current password is incorrect')
238
- await userMgmt.resetPassword({ id: ctx.user.id, newPassword: input.newPassword })
239
- return { success: true as const }
240
- }),
241
-
242
- setupOwnTotp: protectedProcedure
243
- .input(z.void())
244
- .output(z.object({ secret: z.string(), otpauthUrl: z.string() }))
245
- .mutation(async ({ ctx }) => {
246
- const userMgmt = registry?.getSingleton('user-management') as
247
- | {
248
- setupTotp: (i: { userId: string }) => Promise<{ secret: string; otpauthUrl: string }>
249
- }
250
- | null
251
- | undefined
252
- if (!userMgmt) throw new Error('user-management capability not available')
253
- if (!ctx.user) throw new Error('Not authenticated')
254
- return userMgmt.setupTotp({ userId: ctx.user.id })
255
- }),
256
-
257
- confirmOwnTotp: protectedProcedure
258
- .input(z.object({ code: z.string().min(1) }))
259
- .output(z.object({ success: z.literal(true) }))
260
- .mutation(async ({ input, ctx }) => {
261
- const userMgmt = registry?.getSingleton('user-management') as
262
- | { confirmTotp: (i: { userId: string; code: string }) => Promise<{ success: true }> }
263
- | null
264
- | undefined
265
- if (!userMgmt) throw new Error('user-management capability not available')
266
- if (!ctx.user) throw new Error('Not authenticated')
267
- return userMgmt.confirmTotp({ userId: ctx.user.id, code: input.code })
268
- }),
269
-
270
- disableOwnTotp: protectedProcedure
271
- .input(z.void())
272
- .output(z.object({ success: z.literal(true) }))
273
- .mutation(async ({ ctx }) => {
274
- const userMgmt = registry?.getSingleton('user-management') as
275
- | { disableTotp: (i: { userId: string }) => Promise<{ success: true }> }
276
- | null
277
- | undefined
278
- if (!userMgmt) throw new Error('user-management capability not available')
279
- if (!ctx.user) throw new Error('Not authenticated')
280
- return userMgmt.disableTotp({ userId: ctx.user.id })
281
- }),
282
-
283
- getOwnTotpStatus: protectedProcedure
284
- .input(z.void())
285
- .output(z.object({ enabled: z.boolean(), confirmedAt: z.number().nullable() }))
286
- .query(async ({ ctx }) => {
287
- const userMgmt = registry?.getSingleton('user-management') as
288
- | {
289
- getTotpStatus: (i: {
290
- userId: string
291
- }) => Promise<{ enabled: boolean; confirmedAt: number | null }>
292
- }
293
- | null
294
- | undefined
295
- if (!userMgmt || !ctx.user) return { enabled: false, confirmedAt: null }
296
- return userMgmt.getTotpStatus({ userId: ctx.user.id })
297
- }),
298
-
299
- logout: protectedProcedure
300
- .input(z.void())
301
- .output(z.object({ success: z.literal(true) }))
302
- .mutation(() => ({ success: true as const })),
303
-
304
- listProviders: publicProcedure
305
- .input(z.void())
306
- .output(z.array(AuthProviderSummarySchema).readonly())
307
- .query(() => {
308
- if (!registry) return []
309
- // Validate each auth-provider entry independently. A single
310
- // malformed entry (e.g. an auth addon that registers without an
311
- // `icon`) must NOT sink the whole array through the tRPC output
312
- // validator — that would 500 the query and lock every login
313
- // method out of the UI. Drop the bad entry, keep the rest.
314
- const out: z.infer<typeof AuthProviderSummarySchema>[] = []
315
- for (const entry of registry.getCollection('auth-provider')) {
316
- const parsed = AuthProviderSummarySchema.safeParse(entry)
317
- if (parsed.success) out.push(parsed.data)
318
- }
319
- return out
320
- }),
321
- })
322
- }
@@ -1,305 +0,0 @@
1
- /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-argument -- The server installs @camstack/types@0.1.38 (last published) in server/backend/node_modules, while the workspace has 0.1.39 with the new BulkUpdate* types. ESLint's type-checker resolves against 0.1.38 and treats the new imports as `any`. Runtime is correct because Node module resolution walks up to root node_modules → workspace symlink. This disable mirrors the pattern in cap-providers.ts (same root cause). Will resolve when 0.1.39 is published and the local dist is synced. */
2
- import { randomUUID } from 'node:crypto'
3
- import {
4
- EventCategory,
5
- type BulkUpdateItem,
6
- type BulkUpdateState,
7
- type BulkUpdatePhase,
8
- type BulkUpdateItemStatus,
9
- } from '@camstack/types'
10
-
11
- /**
12
- * Narrow event-bus interface required by BulkUpdateCoordinator.
13
- * Intentionally narrower than IEventBus: the coordinator only emits a single
14
- * topic, so we avoid importing the full IEventBus (which lives in @camstack/types
15
- * and would pull in all event-catalog types). The full IEventBus satisfies this
16
- * interface structurally, so wiring in cap-providers.ts is cast-free.
17
- */
18
- export interface IBulkUpdateEventBus {
19
- emit(category: EventCategory.AddonsBulkUpdateProgress, payload: BulkUpdateState): void
20
- }
21
-
22
- export interface IBulkUpdateLogger {
23
- info(message: string, ...args: unknown[]): void
24
- warn(message: string, ...args: unknown[]): void
25
- error(message: string, ...args: unknown[]): void
26
- debug(message: string, ...args: unknown[]): void
27
- }
28
-
29
- export interface BulkUpdateCoordinatorDeps {
30
- readonly eventBus: IBulkUpdateEventBus
31
- readonly updateAddon: (input: { name: string; version: string }) => Promise<void>
32
- readonly updateFrameworkPackage: (input: {
33
- packageName: string
34
- version: string
35
- deferRestart: boolean
36
- }) => Promise<void>
37
- readonly restartServer: (input: { confirm: true }) => Promise<void>
38
- readonly logger: IBulkUpdateLogger
39
- /** Injectable clock for tests. Default: `() => Date.now()`. */
40
- readonly clock?: () => number
41
- /** How long to keep completed state before purging. Default: 5 minutes. */
42
- readonly cleanupAfterMs?: number
43
- }
44
-
45
- export interface StartBulkUpdateInput {
46
- readonly nodeId: string
47
- readonly items: readonly { name: string; version: string; isSystem: boolean }[]
48
- }
49
-
50
- const DEFAULT_CLEANUP_AFTER_MS = 5 * 60 * 1_000
51
-
52
- export class BulkUpdateCoordinator {
53
- private readonly states = new Map<string, BulkUpdateState>()
54
- private readonly cancelFlags = new Map<string, { cancelled: boolean }>()
55
- /**
56
- * Tracks wall-clock time (ms) when each bulk completed. Used for lazy
57
- * cleanup in `get()` — avoids scheduling a fake-timer `setTimeout` that
58
- * would be eagerly fired by `vi.runAllTimersAsync()` in tests.
59
- */
60
- private readonly completedWallMs = new Map<string, number>()
61
- /** Tracks which nodeIds currently have an active (non-completed) bulk update. */
62
- private readonly activeNodeIds = new Set<string>()
63
-
64
- private readonly now: () => number
65
- private readonly cleanupAfterMs: number
66
- /** Wall-clock source. Fake timers intercept `Date.now`, so tests can advance via `advanceTimersByTimeAsync`. */
67
- private readonly wallNow: () => number
68
-
69
- constructor(private readonly deps: BulkUpdateCoordinatorDeps) {
70
- this.now = deps.clock ?? (() => Date.now())
71
- this.cleanupAfterMs = deps.cleanupAfterMs ?? DEFAULT_CLEANUP_AFTER_MS
72
- this.wallNow = () => Date.now()
73
- }
74
-
75
- // ── Public API ────────────────────────────────────────────────────
76
-
77
- start(input: StartBulkUpdateInput): { id: string } {
78
- if (this.activeNodeIds.has(input.nodeId)) {
79
- throw new Error(`Bulk update already in progress for node ${input.nodeId}`)
80
- }
81
-
82
- const id = randomUUID()
83
-
84
- const items: BulkUpdateItem[] = input.items.map((i) => ({
85
- name: i.name,
86
- isSystem: i.isSystem,
87
- // fromVersion: the cap interface receives name+version+isSystem only;
88
- // the caller (cap-providers.ts) may enrich this with the current version
89
- // if available. Empty string is acceptable per plan spec.
90
- fromVersion: '',
91
- toVersion: i.version,
92
- status: 'queued' as BulkUpdateItemStatus,
93
- }))
94
-
95
- const state: BulkUpdateState = {
96
- id,
97
- nodeId: input.nodeId,
98
- startedAtMs: this.now(),
99
- total: items.length,
100
- completed: 0,
101
- failed: 0,
102
- current: null,
103
- phase: 'regular',
104
- cancelled: false,
105
- items,
106
- }
107
-
108
- this.states.set(id, state)
109
- this.activeNodeIds.add(input.nodeId)
110
-
111
- const cancelFlag = { cancelled: false }
112
- this.cancelFlags.set(id, cancelFlag)
113
-
114
- // Emit initial state so clients see the bulk as started immediately
115
- this.emit(state)
116
-
117
- void this.runLoop(id, cancelFlag).catch((err) => {
118
- this.deps.logger.error('BulkUpdateCoordinator: loop crashed unexpectedly', err)
119
- })
120
-
121
- return { id }
122
- }
123
-
124
- get(id: string): BulkUpdateState | null {
125
- const state = this.states.get(id)
126
- if (state === undefined) return null
127
- // Lazy cleanup: purge if the wall-clock elapsed since completion exceeds threshold.
128
- // This avoids scheduling a long-lived setTimeout that would be eagerly fired
129
- // by vi.runAllTimersAsync() in tests.
130
- const completedWall = this.completedWallMs.get(id)
131
- if (completedWall !== undefined && this.wallNow() - completedWall >= this.cleanupAfterMs) {
132
- this.purge(id)
133
- return null
134
- }
135
- return state
136
- }
137
-
138
- list(nodeId?: string): readonly BulkUpdateState[] {
139
- const all = [...this.states.keys()]
140
- .map((id) => this.get(id)) // get() applies lazy-cleanup
141
- .filter((s): s is BulkUpdateState => s !== null)
142
- return nodeId === undefined ? all : all.filter((s) => s.nodeId === nodeId)
143
- }
144
-
145
- cancel(id: string): { cancelled: boolean } {
146
- const state = this.states.get(id)
147
- const flag = this.cancelFlags.get(id)
148
- if (state === undefined || flag === undefined) return { cancelled: false }
149
- // Once restarting, the hub restart is committed — cancel has no effect.
150
- if (state.phase === 'restarting') return { cancelled: false }
151
- // Already completed.
152
- if (state.completedAtMs !== undefined) return { cancelled: false }
153
-
154
- flag.cancelled = true
155
- this.mutate(id, (s) => ({ ...s, cancelled: true }))
156
- return { cancelled: true }
157
- }
158
-
159
- // ── Internal loop ─────────────────────────────────────────────────
160
-
161
- private async runLoop(id: string, cancelFlag: { cancelled: boolean }): Promise<void> {
162
- const initial = this.states.get(id)!
163
-
164
- // ── Phase 1: regular addons ──────────────────────────────────────
165
- this.transitionPhase(id, 'regular')
166
-
167
- for (const item of initial.items.filter((i) => !i.isSystem)) {
168
- if (cancelFlag.cancelled) break
169
- await this.processItem(id, item, false)
170
- }
171
-
172
- // ── Phase 2: system packages (deferRestart: true) ────────────────
173
- if (!cancelFlag.cancelled && initial.items.some((i) => i.isSystem)) {
174
- this.transitionPhase(id, 'system')
175
-
176
- for (const item of initial.items.filter((i) => i.isSystem)) {
177
- if (cancelFlag.cancelled) break
178
- await this.processItem(id, item, true)
179
- }
180
-
181
- // ── Phase 3: single restart ──────────────────────────────────
182
- const anySystemPendingRestart = this.states
183
- .get(id)!
184
- .items.some((i) => i.isSystem && i.status === 'done-pending-restart')
185
-
186
- if (anySystemPendingRestart && !cancelFlag.cancelled) {
187
- this.transitionPhase(id, 'restarting')
188
- try {
189
- await this.deps.restartServer({ confirm: true })
190
- // NOTE: In production, restartServer kills+respawns the hub process.
191
- // Code below this point will not execute in that scenario.
192
- // If the mock/stub returns (e.g. in tests), we fall through to finalizing.
193
- } catch (err) {
194
- // Restart failed but the npm installs already completed. Promote all
195
- // done-pending-restart items to done with a caveat error so the UI
196
- // can inform the user that a manual restart is needed.
197
- this.deps.logger.error('BulkUpdateCoordinator: restart failed', err)
198
- const errMsg = err instanceof Error ? err.message : String(err)
199
- for (const it of this.states.get(id)!.items) {
200
- if (it.status === 'done-pending-restart') {
201
- this.setItemStatus(id, it.name, 'done', {
202
- error: `Restart failed; manual restart required (${errMsg})`,
203
- })
204
- }
205
- }
206
- }
207
- }
208
- }
209
-
210
- // ── Phase 4: finalize ────────────────────────────────────────────
211
- // Reached when:
212
- // a) no system packages at all, OR
213
- // b) restart failed (process continued), OR
214
- // c) cancelled before the restart phase.
215
- this.transitionPhase(id, 'finalizing')
216
- this.completeBulk(id)
217
- }
218
-
219
- private async processItem(id: string, item: BulkUpdateItem, isSystem: boolean): Promise<void> {
220
- this.setItemStatus(id, item.name, 'updating', { startedAtMs: this.now() })
221
- this.mutate(id, (s) => ({ ...s, current: item.name }))
222
- this.emit(this.states.get(id)!)
223
-
224
- try {
225
- if (isSystem) {
226
- await this.deps.updateFrameworkPackage({
227
- packageName: item.name,
228
- version: item.toVersion,
229
- deferRestart: true,
230
- })
231
- this.setItemStatus(id, item.name, 'done-pending-restart', { completedAtMs: this.now() })
232
- } else {
233
- await this.deps.updateAddon({ name: item.name, version: item.toVersion })
234
- this.setItemStatus(id, item.name, 'done', { completedAtMs: this.now() })
235
- }
236
- } catch (err) {
237
- const msg = err instanceof Error ? err.message : String(err)
238
- this.setItemStatus(id, item.name, 'failed', { error: msg, completedAtMs: this.now() })
239
- }
240
-
241
- this.mutate(id, (s) => ({ ...s, current: null }))
242
- this.emit(this.states.get(id)!)
243
- }
244
-
245
- // ── State mutation helpers ────────────────────────────────────────
246
-
247
- private setItemStatus(
248
- id: string,
249
- name: string,
250
- status: BulkUpdateItemStatus,
251
- fields: { error?: string; startedAtMs?: number; completedAtMs?: number } = {},
252
- ): void {
253
- this.mutate(id, (s) => {
254
- const items = s.items.map((it) => (it.name === name ? { ...it, status, ...fields } : it))
255
- // completed = all terminal states: done | done-pending-restart | failed
256
- const completed = items.filter(
257
- (it) =>
258
- it.status === 'done' || it.status === 'done-pending-restart' || it.status === 'failed',
259
- ).length
260
- const failed = items.filter((it) => it.status === 'failed').length
261
- return { ...s, items, completed, failed }
262
- })
263
- }
264
-
265
- private transitionPhase(id: string, phase: BulkUpdatePhase): void {
266
- this.mutate(id, (s) => ({ ...s, phase }))
267
- this.emit(this.states.get(id)!)
268
- }
269
-
270
- private completeBulk(id: string): void {
271
- this.mutate(id, (s) => ({ ...s, completedAtMs: this.now(), current: null }))
272
- this.emit(this.states.get(id)!)
273
-
274
- // Free the nodeId slot so a new bulk for the same node can be started
275
- const nodeId = this.states.get(id)!.nodeId
276
- this.activeNodeIds.delete(nodeId)
277
-
278
- // Record wall-clock completion time for lazy cleanup in `get()`.
279
- // We intentionally avoid scheduling a setTimeout here: a long-lived
280
- // setTimeout (5 min) would be eagerly fired by vi.runAllTimersAsync()
281
- // in tests, causing `get()` to return null immediately after the run.
282
- // Instead, `get()` lazily checks whether the cleanup threshold has
283
- // elapsed using Date.now() — which fake timers DO advance via
284
- // advanceTimersByTimeAsync(), making the cleanup testable without
285
- // a long-running timer.
286
- this.completedWallMs.set(id, this.wallNow())
287
- }
288
-
289
- private purge(id: string): void {
290
- this.states.delete(id)
291
- this.cancelFlags.delete(id)
292
- this.completedWallMs.delete(id)
293
- }
294
-
295
- /** Immutably update the state for the given id. No-op if id is unknown. */
296
- private mutate(id: string, update: (s: BulkUpdateState) => BulkUpdateState): void {
297
- const current = this.states.get(id)
298
- if (current === undefined) return
299
- this.states.set(id, update(current))
300
- }
301
-
302
- private emit(state: BulkUpdateState): void {
303
- this.deps.eventBus.emit(EventCategory.AddonsBulkUpdateProgress, state)
304
- }
305
- }