@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.
- package/{src/agent-status-page.ts → dist/agent-status-page.js} +30 -45
- package/dist/api/addon-upload.js +441 -0
- package/dist/api/addons-custom.router.js +91 -0
- package/dist/api/auth-whoami.js +55 -0
- package/dist/api/bridge-addons.router.js +109 -0
- package/dist/api/capabilities.router.js +229 -0
- package/dist/api/core/addon-settings.router.js +117 -0
- package/dist/api/core/agents.router.js +73 -0
- package/dist/api/core/auth.router.js +286 -0
- package/dist/api/core/bulk-update-coordinator.js +229 -0
- package/dist/api/core/cap-providers.js +1124 -0
- package/dist/api/core/capabilities.router.js +138 -0
- package/dist/api/core/collection-preference.js +17 -0
- package/dist/api/core/event-bus-proxy.router.js +45 -0
- package/dist/api/core/hwaccel.router.js +91 -0
- package/dist/api/core/live-events.router.js +61 -0
- package/dist/api/core/logs.router.js +172 -0
- package/dist/api/core/notifications.router.js +67 -0
- package/dist/api/core/repl.router.js +35 -0
- package/dist/api/core/settings-backend.router.js +121 -0
- package/dist/api/core/stream-probe.router.js +58 -0
- package/dist/api/core/system-events.router.js +100 -0
- package/dist/api/health/health.routes.js +68 -0
- package/{src/api/oauth2/consent-page.ts → dist/api/oauth2/consent-page.js} +11 -20
- package/dist/api/oauth2/oauth2-routes.js +219 -0
- package/dist/api/trpc/cap-mount-helpers.js +194 -0
- package/dist/api/trpc/cap-route-error-formatter.js +133 -0
- package/dist/api/trpc/client-ip.js +147 -0
- package/dist/api/trpc/core-cap-bridge.js +115 -0
- package/dist/api/trpc/generated-cap-mounts.js +388 -0
- package/dist/api/trpc/generated-cap-routers.js +7635 -0
- package/dist/api/trpc/scope-access.js +93 -0
- package/dist/api/trpc/trpc.context.js +184 -0
- package/dist/api/trpc/trpc.middleware.js +139 -0
- package/dist/api/trpc/trpc.router.js +188 -0
- package/dist/auth/session-cookie.js +47 -0
- package/dist/boot/boot-config.js +241 -0
- package/dist/boot/integration-id-backfill.js +76 -0
- package/dist/boot/post-boot.service.js +85 -0
- package/dist/core/addon/addon-call-gateway.js +99 -0
- package/dist/core/addon/addon-package.service.js +1560 -0
- package/dist/core/addon/addon-registry.service.js +2739 -0
- package/{src/core/addon/addon-row-manifest.ts → dist/core/addon/addon-row-manifest.js} +5 -5
- package/dist/core/addon/addon-search.service.js +62 -0
- package/dist/core/addon/addon-settings-provider.js +102 -0
- package/dist/core/addon/addon.tokens.js +5 -0
- package/dist/core/addon-bridge/addon-bridge.service.js +145 -0
- package/dist/core/addon-pages/addon-pages.service.js +107 -0
- package/dist/core/addon-widgets/addon-widgets.service.js +120 -0
- package/dist/core/agent/agent-registry.service.js +477 -0
- package/dist/core/auth/auth.service.js +10 -0
- package/dist/core/capability/capability.service.js +58 -0
- package/dist/core/config/config.schema.js +7 -0
- package/dist/core/config/config.service.js +10 -0
- package/dist/core/events/event-bus.service.js +83 -0
- package/dist/core/feature/feature.service.js +10 -0
- package/dist/core/lifecycle/lifecycle-state-machine.js +6 -0
- package/dist/core/logging/log-ring-buffer.js +6 -0
- package/dist/core/logging/logging.service.js +130 -0
- package/dist/core/logging/scoped-logger.js +6 -0
- package/dist/core/moleculer/cap-call-fn.js +50 -0
- package/dist/core/moleculer/cap-route-authority.js +122 -0
- package/dist/core/moleculer/moleculer.service.js +898 -0
- package/dist/core/network/network-quality.service.js +7 -0
- package/dist/core/notification/notification-wrapper.service.js +33 -0
- package/dist/core/notification/toast-wrapper.service.js +25 -0
- package/dist/core/provider/provider.tokens.js +4 -0
- package/dist/core/repl/repl-engine.service.js +140 -0
- package/dist/core/storage/fs-storage-backend.js +6 -0
- package/dist/core/storage/storage-location-manager.js +6 -0
- package/dist/core/storage/storage.service.js +7 -0
- package/dist/core/streaming/stream-probe.service.js +209 -0
- package/dist/core/topology/topology-emitter.service.js +106 -0
- package/dist/launcher.js +325 -0
- package/dist/main.js +1098 -0
- package/dist/manual-boot.js +227 -0
- package/package.json +5 -1
- package/src/__tests__/addon-install-e2e.test.ts +0 -74
- package/src/__tests__/addon-pages-e2e.test.ts +0 -200
- package/src/__tests__/addon-route-session.test.ts +0 -17
- package/src/__tests__/addon-settings-router.spec.ts +0 -67
- package/src/__tests__/addon-upload.spec.ts +0 -475
- package/src/__tests__/agent-registry.spec.ts +0 -179
- package/src/__tests__/agent-status-page.spec.ts +0 -82
- package/src/__tests__/auth-session-cookie.test.ts +0 -48
- package/src/__tests__/bulk-update-coordinator.spec.ts +0 -303
- package/src/__tests__/cap-ownership-authority.spec.ts +0 -431
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +0 -206
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +0 -37
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +0 -110
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +0 -292
- package/src/__tests__/cap-providers-bulk-update.spec.ts +0 -408
- package/src/__tests__/cap-route-adapter.spec.ts +0 -302
- package/src/__tests__/cap-routers/_meta.spec.ts +0 -199
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +0 -115
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +0 -177
- package/src/__tests__/cap-routers/cap-route-error-formatter.spec.ts +0 -125
- package/src/__tests__/cap-routers/capabilities-node.spec.ts +0 -68
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +0 -137
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +0 -194
- package/src/__tests__/cap-routers/harness.ts +0 -163
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +0 -133
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +0 -64
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +0 -159
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +0 -291
- package/src/__tests__/capability-e2e.test.ts +0 -384
- package/src/__tests__/cli-e2e.test.ts +0 -150
- package/src/__tests__/core-cap-bridge.spec.ts +0 -91
- package/src/__tests__/dev-bootstrap-shm-ring.spec.ts +0 -40
- package/src/__tests__/device-settings-contribution-dispatch.spec.ts +0 -280
- package/src/__tests__/embedded-deps-e2e.test.ts +0 -125
- package/src/__tests__/event-bus-proxy-router.spec.ts +0 -75
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +0 -37
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +0 -37
- package/src/__tests__/fixtures/mock-log-addon.ts +0 -37
- package/src/__tests__/fixtures/mock-storage-addon.ts +0 -40
- package/src/__tests__/framework-allowlist.spec.ts +0 -96
- package/src/__tests__/framework-installer-defer-restart.spec.ts +0 -165
- package/src/__tests__/https-e2e.test.ts +0 -124
- package/src/__tests__/lifecycle-e2e.test.ts +0 -189
- package/src/__tests__/live-events-subscription.spec.ts +0 -149
- package/src/__tests__/moleculer/uds-readiness.spec.ts +0 -150
- package/src/__tests__/moleculer/uds-topology.spec.ts +0 -418
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +0 -383
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +0 -273
- package/src/__tests__/native-cap-route.spec.ts +0 -427
- package/src/__tests__/oauth2-account-linking.spec.ts +0 -867
- package/src/__tests__/post-boot-restart.spec.ts +0 -161
- package/src/__tests__/singleton-contention.test.ts +0 -499
- package/src/__tests__/streaming-diagnostic.test.ts +0 -615
- package/src/__tests__/streaming-scale.test.ts +0 -314
- package/src/__tests__/uds-addon-call-wiring.spec.ts +0 -242
- package/src/__tests__/uds-log-ingest.spec.ts +0 -183
- package/src/api/__tests__/addons-custom.spec.ts +0 -148
- package/src/api/__tests__/capabilities.router.test.ts +0 -56
- package/src/api/addon-upload.ts +0 -529
- package/src/api/addons-custom.router.ts +0 -101
- package/src/api/auth-whoami.ts +0 -101
- package/src/api/bridge-addons.router.ts +0 -122
- package/src/api/capabilities.router.ts +0 -265
- package/src/api/core/__tests__/auth-router-totp.spec.ts +0 -297
- package/src/api/core/__tests__/integration-markers.spec.ts +0 -10
- package/src/api/core/addon-settings.router.ts +0 -127
- package/src/api/core/agents.router.ts +0 -86
- package/src/api/core/auth.router.ts +0 -322
- package/src/api/core/bulk-update-coordinator.ts +0 -305
- package/src/api/core/cap-providers.ts +0 -1339
- package/src/api/core/capabilities.router.ts +0 -149
- package/src/api/core/collection-preference.ts +0 -40
- package/src/api/core/event-bus-proxy.router.ts +0 -45
- package/src/api/core/hwaccel.router.ts +0 -108
- package/src/api/core/live-events.router.ts +0 -67
- package/src/api/core/logs.router.ts +0 -195
- package/src/api/core/notifications.router.ts +0 -66
- package/src/api/core/repl.router.ts +0 -39
- package/src/api/core/settings-backend.router.ts +0 -140
- package/src/api/core/stream-probe.router.ts +0 -57
- package/src/api/core/system-events.router.ts +0 -125
- package/src/api/health/health.routes.ts +0 -117
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +0 -62
- package/src/api/oauth2/oauth2-routes.ts +0 -281
- package/src/api/trpc/__tests__/client-ip.spec.ts +0 -146
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +0 -268
- package/src/api/trpc/__tests__/scope-access.spec.ts +0 -102
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +0 -136
- package/src/api/trpc/cap-mount-helpers.ts +0 -245
- package/src/api/trpc/cap-route-error-formatter.ts +0 -171
- package/src/api/trpc/client-ip.ts +0 -147
- package/src/api/trpc/core-cap-bridge.ts +0 -154
- package/src/api/trpc/generated-cap-mounts.ts +0 -1240
- package/src/api/trpc/generated-cap-routers.ts +0 -11523
- package/src/api/trpc/scope-access.ts +0 -110
- package/src/api/trpc/trpc.context.ts +0 -258
- package/src/api/trpc/trpc.middleware.ts +0 -146
- package/src/api/trpc/trpc.router.ts +0 -389
- package/src/auth/session-cookie.ts +0 -54
- package/src/boot/__tests__/integration-id-backfill.spec.ts +0 -131
- package/src/boot/boot-config.ts +0 -259
- package/src/boot/integration-id-backfill.ts +0 -109
- package/src/boot/post-boot.service.ts +0 -105
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +0 -62
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +0 -62
- package/src/core/addon/addon-call-gateway.ts +0 -171
- package/src/core/addon/addon-package.service.ts +0 -1787
- package/src/core/addon/addon-registry.service.ts +0 -3130
- package/src/core/addon/addon-search.service.ts +0 -91
- package/src/core/addon/addon-settings-provider.ts +0 -220
- package/src/core/addon/addon.tokens.ts +0 -2
- package/src/core/addon-bridge/addon-bridge.service.ts +0 -130
- package/src/core/addon-pages/addon-pages.service.spec.ts +0 -117
- package/src/core/addon-pages/addon-pages.service.ts +0 -82
- package/src/core/addon-widgets/addon-widgets.service.ts +0 -95
- package/src/core/agent/agent-registry.service.ts +0 -529
- package/src/core/auth/auth.service.spec.ts +0 -86
- package/src/core/auth/auth.service.ts +0 -8
- package/src/core/capability/capability.service.ts +0 -66
- package/src/core/config/config.schema.ts +0 -3
- package/src/core/config/config.service.spec.ts +0 -175
- package/src/core/config/config.service.ts +0 -7
- package/src/core/events/event-bus.service.spec.ts +0 -235
- package/src/core/events/event-bus.service.ts +0 -89
- package/src/core/feature/feature.service.spec.ts +0 -99
- package/src/core/feature/feature.service.ts +0 -8
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +0 -166
- package/src/core/lifecycle/lifecycle-state-machine.ts +0 -3
- package/src/core/logging/log-ring-buffer.ts +0 -3
- package/src/core/logging/logging.service.spec.ts +0 -287
- package/src/core/logging/logging.service.ts +0 -143
- package/src/core/logging/scoped-logger.ts +0 -3
- package/src/core/moleculer/cap-call-fn.spec.ts +0 -173
- package/src/core/moleculer/cap-call-fn.ts +0 -107
- package/src/core/moleculer/cap-route-authority.ts +0 -194
- package/src/core/moleculer/moleculer.service.ts +0 -1072
- package/src/core/network/network-quality.service.spec.ts +0 -53
- package/src/core/network/network-quality.service.ts +0 -5
- package/src/core/notification/notification-wrapper.service.ts +0 -34
- package/src/core/notification/toast-wrapper.service.ts +0 -27
- package/src/core/provider/provider.tokens.ts +0 -1
- package/src/core/repl/repl-engine.service.spec.ts +0 -444
- package/src/core/repl/repl-engine.service.ts +0 -155
- package/src/core/storage/fs-storage-backend.spec.ts +0 -70
- package/src/core/storage/fs-storage-backend.ts +0 -3
- package/src/core/storage/storage-location-manager.spec.ts +0 -130
- package/src/core/storage/storage-location-manager.ts +0 -3
- package/src/core/storage/storage.service.spec.ts +0 -73
- package/src/core/storage/storage.service.ts +0 -3
- package/src/core/streaming/stream-probe.service.ts +0 -221
- package/src/core/topology/topology-emitter.service.ts +0 -105
- package/src/launcher.ts +0 -314
- package/src/main.ts +0 -1245
- package/src/manual-boot.ts +0 -301
- package/tsconfig.build.json +0 -8
- package/tsconfig.json +0 -33
- 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
|
-
}
|