@camstack/server 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +17 -0
- package/package.json +55 -0
- package/src/__tests__/addon-install-e2e.test.ts +75 -0
- package/src/__tests__/addon-pages-e2e.test.ts +178 -0
- package/src/__tests__/addon-route-session.test.ts +17 -0
- package/src/__tests__/addon-settings-router.spec.ts +62 -0
- package/src/__tests__/addon-upload.spec.ts +355 -0
- package/src/__tests__/agent-registry.spec.ts +162 -0
- package/src/__tests__/agent-status-page.spec.ts +84 -0
- package/src/__tests__/auth-session-cookie.test.ts +21 -0
- package/src/__tests__/cap-providers/cap-usage-graph.spec.ts +23 -0
- package/src/__tests__/cap-providers/compute-topology-categories.spec.ts +64 -0
- package/src/__tests__/cap-routers/_meta.spec.ts +200 -0
- package/src/__tests__/cap-routers/addon-settings.router.spec.ts +106 -0
- package/src/__tests__/cap-routers/device-manager-aggregate.router.spec.ts +142 -0
- package/src/__tests__/cap-routers/harness.ts +159 -0
- package/src/__tests__/cap-routers/metrics-provider.router.spec.ts +119 -0
- package/src/__tests__/cap-routers/null-provider-guard.spec.ts +66 -0
- package/src/__tests__/cap-routers/pipeline-executor.router.spec.ts +135 -0
- package/src/__tests__/cap-routers/settings-store.router.spec.ts +247 -0
- package/src/__tests__/capability-e2e.test.ts +386 -0
- package/src/__tests__/cli-e2e.test.ts +129 -0
- package/src/__tests__/core-cap-bridge.spec.ts +89 -0
- package/src/__tests__/embedded-deps-e2e.test.ts +109 -0
- package/src/__tests__/event-bus-proxy-router.spec.ts +72 -0
- package/src/__tests__/fixtures/mock-analysis-addon-a.ts +37 -0
- package/src/__tests__/fixtures/mock-analysis-addon-b.ts +37 -0
- package/src/__tests__/fixtures/mock-log-addon.ts +37 -0
- package/src/__tests__/fixtures/mock-storage-addon.ts +40 -0
- package/src/__tests__/framework-allowlist.spec.ts +95 -0
- package/src/__tests__/https-e2e.test.ts +118 -0
- package/src/__tests__/lifecycle-e2e.test.ts +140 -0
- package/src/__tests__/live-events-subscription.spec.ts +150 -0
- package/src/__tests__/moleculer-register-node-idempotency.spec.ts +229 -0
- package/src/__tests__/oauth2-account-linking.spec.ts +736 -0
- package/src/__tests__/post-boot-restart.spec.ts +161 -0
- package/src/__tests__/singleton-contention.test.ts +487 -0
- package/src/__tests__/streaming-diagnostic.test.ts +512 -0
- package/src/__tests__/streaming-scale.test.ts +280 -0
- package/src/agent-status-page.ts +121 -0
- package/src/api/__tests__/addons-custom.spec.ts +134 -0
- package/src/api/__tests__/capabilities.router.test.ts +47 -0
- package/src/api/addon-upload.ts +472 -0
- package/src/api/addons-custom.router.ts +100 -0
- package/src/api/auth-whoami.ts +99 -0
- package/src/api/bridge-addons.router.ts +120 -0
- package/src/api/capabilities.router.ts +226 -0
- package/src/api/core/__tests__/auth-router-totp.spec.ts +256 -0
- package/src/api/core/addon-settings.router.ts +124 -0
- package/src/api/core/agents.router.ts +87 -0
- package/src/api/core/auth.router.ts +303 -0
- package/src/api/core/cap-providers.ts +993 -0
- package/src/api/core/capabilities.router.ts +119 -0
- package/src/api/core/collection-preference.ts +40 -0
- package/src/api/core/event-bus-proxy.router.ts +45 -0
- package/src/api/core/hwaccel.router.ts +81 -0
- package/src/api/core/live-events.router.ts +60 -0
- package/src/api/core/logs.router.ts +162 -0
- package/src/api/core/notifications.router.ts +65 -0
- package/src/api/core/repl.router.ts +41 -0
- package/src/api/core/settings-backend.router.ts +142 -0
- package/src/api/core/stream-probe.router.ts +57 -0
- package/src/api/core/system-events.router.ts +116 -0
- package/src/api/health/health.routes.ts +123 -0
- package/src/api/oauth2/__tests__/oauth2-routes.spec.ts +52 -0
- package/src/api/oauth2/consent-page.ts +42 -0
- package/src/api/oauth2/oauth2-routes.ts +248 -0
- package/src/api/trpc/__tests__/scope-access-device.spec.ts +223 -0
- package/src/api/trpc/__tests__/scope-access.spec.ts +107 -0
- package/src/api/trpc/cap-mount-helpers.ts +225 -0
- package/src/api/trpc/core-cap-bridge.ts +152 -0
- package/src/api/trpc/generated-cap-mounts.ts +707 -0
- package/src/api/trpc/generated-cap-routers.ts +6340 -0
- package/src/api/trpc/scope-access.ts +110 -0
- package/src/api/trpc/trpc.context.ts +255 -0
- package/src/api/trpc/trpc.middleware.ts +140 -0
- package/src/api/trpc/trpc.router.ts +275 -0
- package/src/auth/session-cookie.ts +44 -0
- package/src/boot/boot-config.ts +278 -0
- package/src/boot/post-boot.service.ts +103 -0
- package/src/core/addon/__tests__/addon-registry-capability.test.ts +53 -0
- package/src/core/addon/addon-package.service.ts +1684 -0
- package/src/core/addon/addon-registry.service.ts +2926 -0
- package/src/core/addon/addon-search.service.ts +90 -0
- package/src/core/addon/addon-settings-provider.ts +276 -0
- package/src/core/addon/addon.tokens.ts +2 -0
- package/src/core/addon-bridge/addon-bridge.service.ts +125 -0
- package/src/core/addon-pages/addon-pages.service.spec.ts +117 -0
- package/src/core/addon-pages/addon-pages.service.ts +80 -0
- package/src/core/addon-widgets/addon-widgets.service.ts +92 -0
- package/src/core/agent/agent-registry.service.ts +507 -0
- package/src/core/auth/auth.service.spec.ts +88 -0
- package/src/core/auth/auth.service.ts +8 -0
- package/src/core/capability/capability.service.ts +57 -0
- package/src/core/config/config.schema.ts +3 -0
- package/src/core/config/config.service.spec.ts +175 -0
- package/src/core/config/config.service.ts +7 -0
- package/src/core/events/event-bus.service.spec.ts +212 -0
- package/src/core/events/event-bus.service.ts +85 -0
- package/src/core/feature/feature.service.spec.ts +96 -0
- package/src/core/feature/feature.service.ts +8 -0
- package/src/core/lifecycle/lifecycle-state-machine.spec.ts +168 -0
- package/src/core/lifecycle/lifecycle-state-machine.ts +3 -0
- package/src/core/logging/log-ring-buffer.ts +3 -0
- package/src/core/logging/logging.service.spec.ts +247 -0
- package/src/core/logging/logging.service.ts +129 -0
- package/src/core/logging/scoped-logger.ts +3 -0
- package/src/core/moleculer/moleculer.service.ts +612 -0
- package/src/core/network/network-quality.service.spec.ts +47 -0
- package/src/core/network/network-quality.service.ts +5 -0
- package/src/core/notification/notification-wrapper.service.ts +36 -0
- package/src/core/notification/toast-wrapper.service.ts +31 -0
- package/src/core/provider/provider.tokens.ts +1 -0
- package/src/core/repl/repl-engine.service.spec.ts +417 -0
- package/src/core/repl/repl-engine.service.ts +156 -0
- package/src/core/storage/fs-storage-backend.spec.ts +70 -0
- package/src/core/storage/fs-storage-backend.ts +3 -0
- package/src/core/storage/settings-store.spec.ts +213 -0
- package/src/core/storage/settings-store.ts +2 -0
- package/src/core/storage/sql-schema.spec.ts +140 -0
- package/src/core/storage/sql-schema.ts +3 -0
- package/src/core/storage/storage-location-manager.spec.ts +121 -0
- package/src/core/storage/storage-location-manager.ts +3 -0
- package/src/core/storage/storage.service.spec.ts +73 -0
- package/src/core/storage/storage.service.ts +3 -0
- package/src/core/streaming/stream-probe.service.ts +212 -0
- package/src/core/topology/topology-emitter.service.ts +101 -0
- package/src/launcher.ts +309 -0
- package/src/main.ts +1049 -0
- package/src/manual-boot.ts +322 -0
- package/tsconfig.build.json +8 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +26 -0
|
@@ -0,0 +1,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
|
+
}
|