@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,472 @@
1
+ import type { FastifyInstance, FastifyReply } from 'fastify'
2
+ import * as fs from 'node:fs'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import { execFileSync } from 'node:child_process'
6
+ import type { IScopedLogger, TokenScope } from '@camstack/types'
7
+ import { checkScopeAccess } from './trpc/scope-access.js'
8
+ import type { AddonBridgeService } from '../core/addon-bridge/addon-bridge.service'
9
+ import type { AddonRegistryService } from '../core/addon/addon-registry.service'
10
+ import type { AuthService } from '../core/auth/auth.service'
11
+ import type { MoleculerService } from '../core/moleculer/moleculer.service'
12
+
13
+ interface ScopedTokenLike {
14
+ readonly scopes: readonly TokenScope[]
15
+ }
16
+ interface UserManagementLike {
17
+ validateScopedToken(input: { token: string }): Promise<ScopedTokenLike | null>
18
+ }
19
+
20
+ /**
21
+ * Validate a `cst_*` scoped token via the `user-management` cap singleton.
22
+ *
23
+ * The local `AuthService.validateScopedToken` indirection is unused (the
24
+ * `setScopedTokenManager` wire-up was never invoked), so we go straight
25
+ * to the cap registry — same path the generic addon-route handler in
26
+ * `main.ts` uses (the one that actually works end-to-end).
27
+ *
28
+ * Returns null when the singleton isn't mounted yet (boot race) or the
29
+ * token doesn't validate. Caller treats both as auth failure.
30
+ */
31
+ async function validateScopedTokenViaCap(
32
+ addonRegistry: AddonRegistryService,
33
+ token: string,
34
+ ): Promise<ScopedTokenLike | null> {
35
+ const capRegistry = addonRegistry.getCapabilityRegistry()
36
+ const userMgmt = capRegistry.getSingleton('user-management') as UserManagementLike | undefined
37
+ if (!userMgmt) return null
38
+ return userMgmt.validateScopedToken({ token })
39
+ }
40
+
41
+ /**
42
+ * REST endpoint `/api/addons/upload` shares the scope-gate of the
43
+ * `addons.installPackage` cap method (semantically equivalent — both
44
+ * install a tarball under the system-scope `addons` cap with `create`
45
+ * access). Reuse the shared scope matcher so the gate stays in sync
46
+ * with the tRPC middleware — no parallel REST-only ACL.
47
+ *
48
+ * Why not `addons.upload`? There is no `upload` method on the cap
49
+ * definition (this endpoint is Fastify-only), so `METHOD_ACCESS_MAP`
50
+ * has no row for it and `checkScopeAccess` falls through to deny.
51
+ */
52
+ const UPLOAD_TRPC_PATH = 'addons.installPackage'
53
+ function isUploadAllowed(scoped: ScopedTokenLike): boolean {
54
+ return checkScopeAccess(scoped.scopes, UPLOAD_TRPC_PATH).ok
55
+ }
56
+
57
+ const TARBALL_EXTENSIONS = ['.tgz', '.tar.gz']
58
+ const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
59
+ const AGENT_DEPLOY_TIMEOUT_MS = 60_000
60
+
61
+ interface TarballManifest {
62
+ readonly name: string
63
+ readonly version: string
64
+ }
65
+
66
+ interface AgentDeployResponse {
67
+ readonly success: boolean
68
+ readonly addonId: string
69
+ readonly path?: string
70
+ }
71
+
72
+ function isTarball(filename: string): boolean {
73
+ return TARBALL_EXTENSIONS.some(ext => filename.endsWith(ext))
74
+ }
75
+
76
+ /**
77
+ * Validate an uploaded tarball by extracting its `package.json` and
78
+ * checking it declares `name` + `version`. Runs BEFORE the hub/agent
79
+ * branch so broken archives are rejected at the gateway instead of
80
+ * failing mid-extraction on the agent (which has no clean rollback).
81
+ *
82
+ * The routine writes the buffer to a scratch path and invokes `tar` with
83
+ * `-xzO` to stream-extract just `package/package.json` to stdout. No full
84
+ * unpack is needed — npm-pack layout always puts the manifest at that
85
+ * fixed inner path. Returns null (not throw) on malformed archives so
86
+ * the caller can surface a precise 400 response.
87
+ */
88
+ function validateTarball(buffer: Buffer, filename: string): TarballManifest | null {
89
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'camstack-tarball-check-'))
90
+ const tgzPath = path.join(tmpDir, filename)
91
+ try {
92
+ fs.writeFileSync(tgzPath, buffer)
93
+ const output = execFileSync('tar', ['-xzO', '-f', tgzPath, 'package/package.json'], {
94
+ timeout: 5_000,
95
+ stdio: ['ignore', 'pipe', 'ignore'],
96
+ })
97
+ const parsed: unknown = JSON.parse(output.toString('utf8'))
98
+ if (!parsed || typeof parsed !== 'object') return null
99
+ const pkg = parsed as Record<string, unknown>
100
+ if (typeof pkg['name'] !== 'string' || typeof pkg['version'] !== 'string') return null
101
+ return { name: pkg['name'], version: pkg['version'] }
102
+ } catch {
103
+ return null
104
+ } finally {
105
+ fs.rmSync(tmpDir, { recursive: true, force: true })
106
+ }
107
+ }
108
+
109
+ export async function registerAddonUploadRoute(
110
+ fastify: FastifyInstance,
111
+ addonBridge: AddonBridgeService,
112
+ authService: AuthService,
113
+ moleculer: MoleculerService,
114
+ addonRegistry: AddonRegistryService,
115
+ logger: IScopedLogger,
116
+ ): Promise<void> {
117
+ await fastify.register(import('@fastify/multipart'), {
118
+ limits: { fileSize: MAX_UPLOAD_BYTES },
119
+ })
120
+
121
+ fastify.post('/api/addons/upload', async (request, reply) => {
122
+ const authHeader = request.headers.authorization
123
+ if (!authHeader) {
124
+ return reply.status(401).send({ error: 'Unauthorized' })
125
+ }
126
+
127
+ // Auth chain: JWT (isAdmin) OR scoped token whose scopes grant
128
+ // `create` access on the `addons` capability. The scoped path is the
129
+ // CLI's `camstack login` flow — fetches a long-lived upload-scoped
130
+ // token so headless deploys don't need an admin password on disk.
131
+ const token = authHeader.replace('Bearer ', '')
132
+ let authOk = false
133
+ let authReason: string | undefined
134
+ // Try JWT first — fastest path + carries isAdmin flag directly.
135
+ try {
136
+ const payload = authService.verifyToken(token)
137
+ if (payload.isAdmin) {
138
+ authOk = true
139
+ } else {
140
+ authReason = 'JWT is not admin'
141
+ }
142
+ } catch {
143
+ // Not a JWT (or invalid signature) — fall through to scoped-token path.
144
+ }
145
+ if (!authOk) {
146
+ // `cst_*` scoped tokens — only the cap-registry singleton actually
147
+ // validates; the local AuthService bridge was never wired. See main.ts:652.
148
+ try {
149
+ const record = await validateScopedTokenViaCap(addonRegistry, token)
150
+ if (!record) {
151
+ authReason = authReason ?? 'token not recognised'
152
+ } else if (isUploadAllowed(record)) {
153
+ authOk = true
154
+ } else {
155
+ authReason = `scoped token lacks create access on '${UPLOAD_TRPC_PATH}'`
156
+ }
157
+ } catch (err) {
158
+ authReason = `scoped token validation failed: ${err instanceof Error ? err.message : String(err)}`
159
+ }
160
+ }
161
+ if (!authOk) {
162
+ return reply.status(403).send({ error: `Forbidden: ${authReason ?? 'admin or upload-scoped token required'}` })
163
+ }
164
+
165
+ const data = await request.file()
166
+ if (!data) {
167
+ return reply.status(400).send({ error: 'No file uploaded' })
168
+ }
169
+ if (!isTarball(data.filename)) {
170
+ return reply.status(400).send({ error: 'File must be a .tgz or .tar.gz archive' })
171
+ }
172
+
173
+ // `nodeId` and `addonId` come through as multipart text fields.
174
+ // `data.fields[X].value` is the parsed string; we narrow defensively
175
+ // because the multipart plugin types it as `unknown`.
176
+ const nodeIdField = data.fields['nodeId']
177
+ const addonIdField = data.fields['addonId']
178
+ const nodeId = typeof nodeIdField === 'object' && nodeIdField !== null && 'value' in nodeIdField && typeof nodeIdField.value === 'string'
179
+ ? nodeIdField.value
180
+ : null
181
+ const addonIdHint = typeof addonIdField === 'object' && addonIdField !== null && 'value' in addonIdField && typeof addonIdField.value === 'string'
182
+ ? addonIdField.value
183
+ : null
184
+
185
+ const buffer = await data.toBuffer()
186
+
187
+ // Gate: reject archives that don't expose a parseable package.json with
188
+ // name + version. The hub installer did this implicitly via npm; the
189
+ // agent path would otherwise fail mid-extraction with no clean rollback.
190
+ const manifest = validateTarball(buffer, data.filename)
191
+ if (!manifest) {
192
+ return reply.status(400).send({ error: 'Tarball missing or malformed package/package.json (name + version required)' })
193
+ }
194
+
195
+ // Branch by deployment target. `nodeId === 'hub'` (or absent) installs on
196
+ // the hub AND broadcasts to every connected agent that can run any of
197
+ // the package's addons (i.e. anything not marked hub-only). Any other
198
+ // explicit `nodeId` value routes only to that agent via `$agent.deploy`.
199
+ if (!nodeId || nodeId === 'hub') {
200
+ return installToHub(reply, addonBridge, addonRegistry, moleculer, logger, data.filename, buffer)
201
+ }
202
+ const agentAddonId = addonIdHint ?? manifest.name
203
+ return deployToAgent(reply, moleculer, nodeId, agentAddonId, buffer)
204
+ })
205
+ }
206
+
207
+ interface AddonExecutionLike {
208
+ readonly placement?: 'hub-only' | 'agent-only' | 'any-node'
209
+ }
210
+ interface CamstackAddonDeclLike {
211
+ readonly id?: unknown
212
+ readonly execution?: AddonExecutionLike
213
+ }
214
+
215
+ /**
216
+ * Read the on-disk package.json and decide whether the package contains
217
+ * anything that needs to land on agents. We deliberately read from disk
218
+ * (rather than `addonRegistry.listAddons()`) so the function is safe to
219
+ * call before `loadNewAddons()` and so `agent-only` addons — which the
220
+ * hub registry intentionally skips — still show up here.
221
+ *
222
+ * Returns `true` if at least one addon's `execution.placement` is anything
223
+ * other than `'hub-only'`. Absence of `execution` defaults to hub-only so
224
+ * legacy packages don't suddenly broadcast cluster-wide.
225
+ */
226
+ function packageHasAgentDeployable(addonsDir: string, packageName: string): boolean {
227
+ try {
228
+ const pkgPath = path.join(addonsDir, packageName, 'package.json')
229
+ const parsed: unknown = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'))
230
+ if (parsed === null || typeof parsed !== 'object') return false
231
+ const camstack = (parsed as { camstack?: { addons?: readonly CamstackAddonDeclLike[] } }).camstack
232
+ const addons = camstack?.addons ?? []
233
+ return addons.some((a) => {
234
+ const placement = a.execution?.placement ?? 'hub-only'
235
+ return placement === 'agent-only' || placement === 'any-node'
236
+ })
237
+ } catch {
238
+ return false
239
+ }
240
+ }
241
+
242
+ interface AgentDeployResult {
243
+ readonly nodeId: string
244
+ readonly success: boolean
245
+ readonly loaded?: readonly string[]
246
+ readonly error?: string
247
+ }
248
+
249
+ interface MoleculerBrokerLike {
250
+ call(action: string, params: Record<string, unknown>, options: { nodeID: string; timeout: number }): Promise<unknown>
251
+ registry?: {
252
+ getNodeList?: (opts: { onlyAvailable: boolean }) => readonly { id: string }[]
253
+ }
254
+ }
255
+
256
+ /**
257
+ * Push `buffer` (a tarball) to every connected non-hub node and trigger a
258
+ * `$agent.reload` so the freshly extracted dist is picked up immediately
259
+ * without an agent restart. Agents that fail are reported per-node — one
260
+ * unreachable agent must not block the others or the hub install.
261
+ */
262
+ async function propagateToAgents(
263
+ moleculer: MoleculerService,
264
+ logger: IScopedLogger,
265
+ packageName: string,
266
+ buffer: Buffer,
267
+ ): Promise<readonly AgentDeployResult[]> {
268
+ const broker = moleculer.broker as unknown as MoleculerBrokerLike
269
+ const nodes = broker.registry?.getNodeList?.({ onlyAvailable: true }) ?? []
270
+ // Moleculer reports both top-level nodes (`hub`, `dev-agent-0`, …) AND
271
+ // their child per-addon runner processes (`hub/detection-pipeline`, `dev-agent-0/foo`).
272
+ // Only the top-level agent nodes have the `$agent` service — calling
273
+ // `$agent.deploy` on a child would hang until timeout. The hub itself
274
+ // is excluded (it was already installed via `installToHub`).
275
+ const allNodeIds = nodes.map((n) => n.id)
276
+ const agentNodeIds = allNodeIds.filter((id) => id !== 'hub' && !id.includes('/'))
277
+ logger.info('propagate: enumerated broker nodes', {
278
+ meta: {
279
+ packageName,
280
+ allNodeIds,
281
+ targetAgents: agentNodeIds,
282
+ },
283
+ })
284
+ if (agentNodeIds.length === 0) return []
285
+ const results: AgentDeployResult[] = []
286
+ for (const nodeId of agentNodeIds) {
287
+ try {
288
+ const deployRaw = await broker.call(
289
+ '$agent.deploy',
290
+ { addonId: packageName, bundle: buffer },
291
+ { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
292
+ )
293
+ if (!isAgentDeployResponse(deployRaw)) {
294
+ results.push({ nodeId, success: false, error: 'malformed deploy response' })
295
+ continue
296
+ }
297
+ const reloadRaw = await broker.call(
298
+ '$agent.reload',
299
+ {},
300
+ { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
301
+ )
302
+ const reloaded =
303
+ reloadRaw !== null &&
304
+ typeof reloadRaw === 'object' &&
305
+ 'loaded' in (reloadRaw as Record<string, unknown>)
306
+ ? ((reloadRaw as { loaded: readonly string[] }).loaded)
307
+ : []
308
+ results.push({ nodeId, success: true, loaded: reloaded })
309
+ } catch (err: unknown) {
310
+ results.push({
311
+ nodeId,
312
+ success: false,
313
+ error: err instanceof Error ? err.message : String(err),
314
+ })
315
+ }
316
+ }
317
+ return results
318
+ }
319
+
320
+ /**
321
+ * Hub install path: write the tgz, refresh the manifest list, then drive the
322
+ * registry through (a) `loadNewAddons` for brand-new addonIds and (b)
323
+ * `restartAddon` for each addonId already tied to this package. The restart
324
+ * branch is the hot-update story — group-hosted addons re-fork their child
325
+ * process (fresh dist code is loaded) and in-process addons re-init in place.
326
+ * Without this the CLI push was write-to-disk-only and required a server
327
+ * restart to actually run the new code.
328
+ */
329
+ async function installToHub(
330
+ reply: FastifyReply,
331
+ addonBridge: AddonBridgeService,
332
+ addonRegistry: AddonRegistryService,
333
+ moleculer: MoleculerService,
334
+ logger: IScopedLogger,
335
+ filename: string,
336
+ buffer: Buffer,
337
+ ): Promise<void> {
338
+ const tmpDir = path.join(os.tmpdir(), `camstack-addon-upload-${Date.now()}`)
339
+ fs.mkdirSync(tmpDir, { recursive: true })
340
+ const tgzPath = path.join(tmpDir, filename)
341
+ fs.writeFileSync(tgzPath, buffer)
342
+
343
+ try {
344
+ const installer = addonBridge.getInstaller()
345
+ if (!installer) {
346
+ return reply.status(500).send({ error: 'Addon installer not available' })
347
+ }
348
+
349
+ // Snapshot addonIds tied to this package BEFORE installation. Used after
350
+ // reload to distinguish "new" vs "updated". installFromTgz only writes
351
+ // to disk; `addonRegistry.listAddons()` reflects in-memory state, so the
352
+ // order (snapshot first vs install first) doesn't matter here — kept
353
+ // pre-install for readability.
354
+ const result = await installer.installFromTgz(tgzPath)
355
+ const preInstallAddonIds = addonRegistry
356
+ .listAddons()
357
+ .filter((row) => row.manifest.packageName === result.name)
358
+ .map((row) => row.manifest.id)
359
+
360
+ const addonsDir = path.resolve(process.env['CAMSTACK_DATA'] ?? 'camstack-data', 'addons')
361
+ fs.writeFileSync(path.join(addonsDir, result.name, '.install-source'), 'upload')
362
+
363
+ // `addonRegistry.loadNewAddons()` already runs its own fresh filesystem
364
+ // scan, diff'ing against existing `addonEntries` — it updates metadata
365
+ // for known addons without touching their live instances + initializes
366
+ // only entries it hasn't seen before. The previous `addonBridge.
367
+ // reloadPackages()` call here was redundant AND destructive: it built
368
+ // a brand-new `AddonLoader` and replaced the global, which knocked
369
+ // every isolated group-runner's IPC channel offline (visible as
370
+ // `Group-runner hub/<X>-isolated disconnected` for 6+ addons after
371
+ // a single `camstack deploy`). `loadNewAddons` alone is enough.
372
+ const loadResult = await addonRegistry.loadNewAddons()
373
+
374
+ // Hot-update + cluster propagation are FIRE-AND-FORGET. The upload
375
+ // route must respond quickly so the CLI / UI sees confirmation; the
376
+ // capability re-registration after a forked group-runner respawn can
377
+ // legitimately take 30s–several minutes (Python pool warmup, native
378
+ // module init, broken external deps) and we don't want operator
379
+ // tooling to hang waiting for it. `restartAddon` and
380
+ // `propagateToAgents` each log their own progress; operators monitor
381
+ // status through the topology cap or the addons UI.
382
+ const propagatable = packageHasAgentDeployable(addonsDir, result.name)
383
+ logger.info('hub install OK', {
384
+ meta: {
385
+ packageName: result.name,
386
+ packageVersion: result.version,
387
+ loaded: loadResult.loaded,
388
+ restartTargets: preInstallAddonIds,
389
+ propagatable,
390
+ },
391
+ })
392
+ for (const id of preInstallAddonIds) {
393
+ void addonRegistry.restartAddon(id).then((r) => {
394
+ if (!r.success) {
395
+ logger.warn('background restart failed', { tags: { addonId: id }, meta: { error: r.error ?? 'unknown' } })
396
+ } else {
397
+ logger.info('background restart OK', { tags: { addonId: id } })
398
+ }
399
+ })
400
+ }
401
+ if (propagatable) {
402
+ void propagateToAgents(moleculer, logger, result.name, buffer).then((agentResults) => {
403
+ logger.info('propagation done', { meta: { packageName: result.name, agents: agentResults } })
404
+ })
405
+ }
406
+
407
+ return reply.send({
408
+ success: true,
409
+ target: 'hub',
410
+ packageName: result.name,
411
+ version: result.version,
412
+ loaded: loadResult.loaded,
413
+ restartingInBackground: preInstallAddonIds,
414
+ propagatingToAgentsInBackground: propagatable,
415
+ failures: loadResult.failed,
416
+ })
417
+ } finally {
418
+ fs.rmSync(tmpDir, { recursive: true, force: true })
419
+ }
420
+ }
421
+
422
+ function isAgentDeployResponse(value: unknown): value is AgentDeployResponse {
423
+ if (value === null || typeof value !== 'object') return false
424
+ const v = value as { success?: unknown; addonId?: unknown; path?: unknown }
425
+ if (typeof v.success !== 'boolean') return false
426
+ if (typeof v.addonId !== 'string') return false
427
+ if (v.path !== undefined && typeof v.path !== 'string') return false
428
+ return true
429
+ }
430
+
431
+ /**
432
+ * Typed view over Moleculer's `ServiceBroker.call`. The library's published
433
+ * types resolve to `any`, which the typed-eslint ruleset rejects across this
434
+ * file. Casting the broker once at the boundary lets every downstream call
435
+ * site read as a normal typed `Promise<unknown>`.
436
+ */
437
+ interface TypedBrokerCall {
438
+ call(
439
+ action: string,
440
+ params: Record<string, unknown>,
441
+ options: { nodeID: string; timeout: number },
442
+ ): Promise<unknown>
443
+ }
444
+
445
+ async function deployToAgent(
446
+ reply: FastifyReply,
447
+ moleculer: MoleculerService,
448
+ nodeId: string,
449
+ addonId: string,
450
+ buffer: Buffer,
451
+ ): Promise<void> {
452
+ try {
453
+ const broker = moleculer.broker as unknown as TypedBrokerCall
454
+ const raw = await broker.call(
455
+ '$agent.deploy',
456
+ { addonId, bundle: buffer },
457
+ { nodeID: nodeId, timeout: AGENT_DEPLOY_TIMEOUT_MS },
458
+ )
459
+ if (!isAgentDeployResponse(raw)) {
460
+ return reply.status(502).send({ error: 'Agent deploy returned malformed response' })
461
+ }
462
+ return reply.send({
463
+ success: true,
464
+ target: nodeId,
465
+ addonId: raw.addonId,
466
+ path: raw.path,
467
+ })
468
+ } catch (err: unknown) {
469
+ const msg = err instanceof Error ? err.message : String(err)
470
+ return reply.status(502).send({ error: `Agent deploy failed: ${msg}` })
471
+ }
472
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * `api.addons.custom` — generic dispatcher for addon-defined custom actions.
3
+ *
4
+ * Task 7.2 of the device-proxy redesign. Addons declare a catalog of
5
+ * custom actions at boot via `AddonInitResult.customActions` + a single
6
+ * `handleCustomAction(action, input)` handler. The catalog is registered
7
+ * with a per-process `CustomActionRegistry` (Task 7.1). This endpoint is
8
+ * the single tRPC entry point that resolves an `(addonId, action)` pair,
9
+ * validates input + output against the action's Zod schemas, enforces the
10
+ * action's declared auth level, and dispatches to the addon handler.
11
+ *
12
+ * The factory returns a record of procedures (not a router) so the caller
13
+ * can spread it into the existing `addons` namespace:
14
+ *
15
+ * trpcRouter({
16
+ * ...existingAddonsProcedures,
17
+ * ...createAddonsCustomProcedures({ getCustomActionRegistry: ... }),
18
+ * })
19
+ *
20
+ * This avoids `mergeRouters` (which requires sharing the `t` instance
21
+ * across modules) while still mounting the procedure at `api.addons.custom`.
22
+ */
23
+ import { z } from 'zod'
24
+ import { TRPCError } from '@trpc/server'
25
+ import type { CustomActionRegistry } from '@camstack/kernel'
26
+ import type { CapabilityMethodAuth } from '@camstack/types'
27
+ import type { TrpcContext } from './trpc/trpc.context.js'
28
+ import { protectedProcedure } from './trpc/trpc.middleware.js'
29
+
30
+ export interface AddonsCustomDeps {
31
+ readonly getCustomActionRegistry: () => CustomActionRegistry
32
+ }
33
+
34
+ /**
35
+ * Build the procedure record for the `custom` endpoint.
36
+ *
37
+ * The OUTER procedure is `protectedProcedure` — every caller must be
38
+ * authenticated. The INNER per-action auth declared in `spec.auth` is
39
+ * enforced manually by `ensureAuth` because the auth level is not known
40
+ * until after the registry lookup.
41
+ */
42
+ export function createAddonsCustomProcedures(deps: AddonsCustomDeps) {
43
+ return {
44
+ custom: protectedProcedure
45
+ .input(z.object({
46
+ addonId: z.string().min(1),
47
+ action: z.string().min(1),
48
+ input: z.unknown(),
49
+ }))
50
+ .output(z.unknown())
51
+ .mutation(async ({ input, ctx }) => {
52
+ const registry = deps.getCustomActionRegistry()
53
+ const entry = registry.resolve(input.addonId, input.action)
54
+ if (!entry) {
55
+ throw new TRPCError({
56
+ code: 'NOT_FOUND',
57
+ message: `addon '${input.addonId}' has no custom action '${input.action}'`,
58
+ })
59
+ }
60
+
61
+ // Per-action authorization. The outer procedure already requires
62
+ // authentication; here we additionally enforce the declared role
63
+ // when it's stricter than 'protected'.
64
+ ensureAuth(ctx, entry.spec.auth)
65
+
66
+ // Validate input against the action's declared Zod schema.
67
+ const parsedInput = entry.spec.input.parse(input.input)
68
+
69
+
70
+ // Dispatch through the addon handler.
71
+ const result = await entry.handler(parsedInput)
72
+
73
+ // Validate the addon's output. Crash-early on misbehaving addons.
74
+ return entry.spec.output.parse(result)
75
+ }),
76
+ } as const
77
+ }
78
+
79
+ /**
80
+ * Enforce the action's declared auth level.
81
+ *
82
+ * Mirrors the role checks performed by `protectedProcedure` and
83
+ * `adminProcedure` in trpc.middleware.ts:
84
+ * - public: no auth
85
+ * - protected: any authenticated user
86
+ * - admin: isAdmin only (scoped tokens bounce)
87
+ */
88
+ function ensureAuth(ctx: TrpcContext, level: CapabilityMethodAuth): void {
89
+ if (level === 'public') return
90
+ if (!ctx.user) {
91
+ throw new TRPCError({ code: 'UNAUTHORIZED' })
92
+ }
93
+ if (level === 'protected') return
94
+ if (level === 'admin') {
95
+ if (!ctx.user.isAdmin) {
96
+ throw new TRPCError({ code: 'FORBIDDEN', message: 'custom action requires admin' })
97
+ }
98
+ return
99
+ }
100
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * `GET /api/auth/whoami` — validate the caller's token and surface the
3
+ * canonical identity + scope summary the server sees.
4
+ *
5
+ * Designed for the CLI `camstack whoami` command, which used to trust
6
+ * the local cache blindly. Hitting this endpoint catches:
7
+ * • revocation (token was deleted server-side after the local cache)
8
+ * • expiry
9
+ * • orphan-cleanup (e.g. owner deleted, see local-auth boot migration)
10
+ *
11
+ * Accepts both JWT and `cst_*` scoped tokens. Returns 401 on auth fail;
12
+ * 200 with the resolved identity otherwise. We intentionally do NOT
13
+ * expose `tokenHash` or the raw token — only `tokenPrefix` + scopes for
14
+ * the client to confirm the cached blob matches what the server has.
15
+ */
16
+ import type { FastifyInstance } from 'fastify'
17
+ import type { AuthService } from '../core/auth/auth.service'
18
+ import type { AddonRegistryService } from '../core/addon/addon-registry.service'
19
+
20
+ interface ScopedTokenLike {
21
+ readonly id: string
22
+ readonly userId: string
23
+ readonly name: string
24
+ readonly tokenPrefix: string
25
+ readonly scopes: readonly { type: string; target: string }[]
26
+ readonly expiresAt?: number | null
27
+ readonly lastUsedAt?: number | null
28
+ readonly createdAt: number
29
+ }
30
+ interface UserManagementLike {
31
+ validateScopedToken(input: { token: string }): Promise<ScopedTokenLike | null>
32
+ }
33
+
34
+ interface WhoamiOk {
35
+ readonly ok: true
36
+ readonly kind: 'jwt' | 'scoped'
37
+ readonly userId: string
38
+ readonly username: string
39
+ readonly isAdmin?: boolean
40
+ readonly tokenPrefix?: string
41
+ readonly scopes?: readonly { type: string; target: string }[]
42
+ readonly expiresAt?: number | null
43
+ readonly createdAt?: number
44
+ }
45
+
46
+ export async function registerAuthWhoamiRoute(
47
+ fastify: FastifyInstance,
48
+ authService: AuthService,
49
+ addonRegistry: AddonRegistryService,
50
+ ): Promise<void> {
51
+ fastify.get('/api/auth/whoami', async (request, reply) => {
52
+ const authHeader = request.headers.authorization
53
+ if (!authHeader) {
54
+ return reply.status(401).send({ ok: false, error: 'No Authorization header' })
55
+ }
56
+ const token = authHeader.replace('Bearer ', '')
57
+
58
+ // JWT fast path — same logic as addon-upload auth chain.
59
+ try {
60
+ const payload = authService.verifyToken(token)
61
+ const ok: WhoamiOk = {
62
+ ok: true,
63
+ kind: 'jwt',
64
+ userId: payload.userId ?? payload.keyId ?? 'unknown',
65
+ username: payload.username ?? 'unknown',
66
+ isAdmin: payload.isAdmin,
67
+ }
68
+ return reply.send(ok)
69
+ } catch {
70
+ // Not a JWT — try scoped token via cap registry.
71
+ }
72
+
73
+ try {
74
+ const capRegistry = addonRegistry.getCapabilityRegistry()
75
+ const userMgmt = capRegistry.getSingleton('user-management') as UserManagementLike | undefined
76
+ if (!userMgmt) {
77
+ return reply.status(503).send({ ok: false, error: 'user-management cap not mounted' })
78
+ }
79
+ const record = await userMgmt.validateScopedToken({ token })
80
+ if (!record) {
81
+ return reply.status(401).send({ ok: false, error: 'Token not recognised (revoked, expired, or never issued)' })
82
+ }
83
+ const ok: WhoamiOk = {
84
+ ok: true,
85
+ kind: 'scoped',
86
+ userId: record.userId,
87
+ username: record.userId,
88
+ tokenPrefix: record.tokenPrefix,
89
+ scopes: record.scopes,
90
+ expiresAt: record.expiresAt ?? null,
91
+ createdAt: record.createdAt,
92
+ }
93
+ return reply.send(ok)
94
+ } catch (err) {
95
+ const msg = err instanceof Error ? err.message : String(err)
96
+ return reply.status(500).send({ ok: false, error: `Validation failed: ${msg}` })
97
+ }
98
+ })
99
+ }