@hivemagnus/magnus-server 1.1.13 → 2.1.0
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 +37 -0
- package/README.md +36 -0
- package/agent-version.json +1 -1
- package/dist/ai/TOOLS.md +1 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/env-catalog.js +16 -0
- package/dist/cli/migrations.js +22 -0
- package/dist/cli/verify-initialization.js +12 -0
- package/dist/cli.js +28 -0
- package/dist/config/console-path.js +17 -0
- package/dist/config/env.js +32 -5
- package/dist/config/features.js +4 -1
- package/dist/config/oathweave.js +42 -0
- package/dist/config/rehearsal.js +22 -0
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
- package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
- package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
- package/dist/db/read-retry.js +5 -1
- package/dist/db/seed.js +8 -0
- package/dist/index.js +28 -37
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/error-handler.js +10 -10
- package/dist/middleware/oathweave-verifier.js +23 -0
- package/dist/middleware/oathweave.js +115 -0
- package/dist/middleware/rate-limit.js +12 -3
- package/dist/openapi/spec.js +45 -4
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -68
- package/dist/routes/assistant.js +30 -12
- package/dist/routes/cindertrace-response.js +183 -0
- package/dist/routes/console.js +33 -0
- package/dist/routes/cron.js +8 -5
- package/dist/routes/events-stream.js +10 -0
- package/dist/routes/files.js +2 -1
- package/dist/routes/fleet-gateway.js +69 -0
- package/dist/routes/heartbeat.js +1 -6
- package/dist/routes/hermes.js +26 -6
- package/dist/routes/hiveskills.js +116 -0
- package/dist/routes/library.js +3 -2
- package/dist/routes/logs.js +5 -0
- package/dist/routes/metrics.js +4 -3
- package/dist/routes/nodes.js +45 -8
- package/dist/routes/ops.js +24 -6
- package/dist/routes/profiling.js +2 -1
- package/dist/routes/roles.js +17 -9
- package/dist/routes/security-fusion.js +15 -600
- package/dist/routes/stats.js +8 -5
- package/dist/routes/terminal-ticket.js +3 -3
- package/dist/routes/terminal.js +2 -2
- package/dist/routes/webhooks.js +15 -15
- package/dist/sdk/node-api-client.js +23 -1
- package/dist/security/cindertrace-response.js +14 -0
- package/dist/security/native-completion-registry.js +14 -0
- package/dist/server.js +11 -14
- package/dist/services/ai-assistant-service.js +32 -6
- package/dist/services/ai-tools.js +15 -8
- package/dist/services/ardent-scope-enrollment.js +4 -2
- package/dist/services/command-resource-leases.js +2 -4
- package/dist/services/forecast-service.js +23 -8
- package/dist/services/ops-service.js +20 -25
- package/dist/services/prediction-service.js +2 -1
- package/dist/services/resource-enrollment.js +57 -4
- package/dist/services/secret-views.js +72 -0
- package/dist/services/security-export.js +4 -6
- package/dist/services/topology-service.js +2 -1
- package/dist/services/webhook-security.js +10 -1
- package/dist/services/webhook-service.js +14 -6
- package/dist/utils/node-url.js +2 -1
- package/dist/utils/safe-errors.js +130 -0
- package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
- package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
- package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
- package/package.json +7 -3
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
- package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +22 -0
- package/dist/services/security-fusion-actions.js +0 -22
- package/dist/services/security-fusion-evidence-integrity.js +0 -144
- package/dist/services/security-fusion-ingest.js +0 -101
- package/dist/services/security-fusion-outbox.js +0 -185
- package/dist/services/security-fusion-receipt-chain.js +0 -134
- package/dist/services/security-fusion-service.js +0 -751
- package/dist/services/security-fusion-verification.js +0 -50
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { getDb } from '../db/index.js';
|
|
5
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
6
|
+
import { parseBody } from '../utils/validate.js';
|
|
7
|
+
import { safeEqual } from '../utils/safe-equal.js';
|
|
8
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
9
|
+
/**
|
|
10
|
+
* Machine routes for the fleet gateway, the separate service that holds the restricted SSH key.
|
|
11
|
+
* The gateway asks what a node is and records what happened. Nothing here dials a node.
|
|
12
|
+
*/
|
|
13
|
+
export const fleetGatewayRouter = new Hono();
|
|
14
|
+
async function requireGatewayCredential(c, next) {
|
|
15
|
+
const expected = process.env['FLEET_GATEWAY_TOKEN_SHA256'] ?? '';
|
|
16
|
+
const token = c.req.header('x-fleet-gateway-token') ?? '';
|
|
17
|
+
if (!expected || !token || !safeEqual(createHash('sha256').update(token).digest('hex'), expected))
|
|
18
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Fleet gateway credential required');
|
|
19
|
+
await next();
|
|
20
|
+
}
|
|
21
|
+
fleetGatewayRouter.use('*', requireGatewayCredential);
|
|
22
|
+
const nodeId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/);
|
|
23
|
+
/** POST /fleet-gateway/target: inventory facts for one node. The gateway applies its own host-key approval. */
|
|
24
|
+
fleetGatewayRouter.post('/target', async (c) => {
|
|
25
|
+
const body = await parseBody(c, z.object({ nodeId }).strict());
|
|
26
|
+
const node = await getDb().node.findUnique({
|
|
27
|
+
where: { id: body.nodeId },
|
|
28
|
+
select: { id: true, hostname: true, ipAddress: true, status: true },
|
|
29
|
+
});
|
|
30
|
+
if (!node)
|
|
31
|
+
throw new HttpError(404, 'NODE_NOT_FOUND', 'Node not found');
|
|
32
|
+
c.header('Cache-Control', 'no-store');
|
|
33
|
+
return c.json({ success: true, data: { nodeId: node.id, hostname: node.hostname, ipAddress: node.ipAddress, status: node.status } });
|
|
34
|
+
});
|
|
35
|
+
const auditSchema = z.object({
|
|
36
|
+
action: z.enum(['terminal.session.open', 'terminal.session.close', 'terminal.session.refuse', 'terminal.host-key.approve', 'terminal.host-key.remove']),
|
|
37
|
+
subject: z.string().min(1).max(256),
|
|
38
|
+
actorId: z.string().min(1).max(256),
|
|
39
|
+
nodeId: nodeId.nullable(),
|
|
40
|
+
sessionId: z.string().regex(/^ts_[A-Za-z0-9_-]{43}$/).nullable(),
|
|
41
|
+
outcome: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9:._/-]{0,199}$/),
|
|
42
|
+
at: z.iso.datetime(),
|
|
43
|
+
durationMs: z.number().int().nonnegative().nullable(),
|
|
44
|
+
ipAddress: z.union([z.ipv4(), z.ipv6()]).nullable(),
|
|
45
|
+
fingerprint: z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/).nullable(),
|
|
46
|
+
}).strict();
|
|
47
|
+
/** POST /fleet-gateway/audit: terminal metadata. The schema has no field for terminal input or output. */
|
|
48
|
+
fleetGatewayRouter.post('/audit', async (c) => {
|
|
49
|
+
const event = await parseBody(c, auditSchema);
|
|
50
|
+
await logAuditEvent({
|
|
51
|
+
action: event.action,
|
|
52
|
+
actorId: event.actorId,
|
|
53
|
+
actorType: 'user',
|
|
54
|
+
targetType: 'node',
|
|
55
|
+
targetId: event.nodeId ?? 'unspecified',
|
|
56
|
+
ipAddress: event.ipAddress,
|
|
57
|
+
details: {
|
|
58
|
+
subject: event.subject,
|
|
59
|
+
sessionHandle: event.sessionId,
|
|
60
|
+
outcome: event.outcome,
|
|
61
|
+
observedAt: event.at,
|
|
62
|
+
durationMs: event.durationMs,
|
|
63
|
+
hostKeyFingerprint: event.fingerprint,
|
|
64
|
+
},
|
|
65
|
+
});
|
|
66
|
+
c.header('Cache-Control', 'no-store');
|
|
67
|
+
return c.json({ success: true, data: { recorded: true } });
|
|
68
|
+
});
|
|
69
|
+
//# sourceMappingURL=fleet-gateway.js.map
|
package/dist/routes/heartbeat.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { enqueueSecurityObservations
|
|
1
|
+
import { enqueueSecurityObservations } from '../services/security-export.js';
|
|
2
2
|
import { warnOnAgentVersionMismatch } from '../services/agent-version-warnings.js';
|
|
3
3
|
import { recordSelfUpgradeReport } from '../services/agent-upgrade-service.js';
|
|
4
4
|
import { Hono } from 'hono';
|
|
@@ -22,7 +22,6 @@ import { heartbeatHealthSchema } from '../schemas/node-responses.js';
|
|
|
22
22
|
import { normalizeIpAddress, isLoopbackOrUnroutableIp, isUsableNodeIp } from '../utils/ip.js';
|
|
23
23
|
import { AGENT_VERSION } from '../config/version.js';
|
|
24
24
|
import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
|
|
25
|
-
import { ingestMagnusServiceHealth } from '../services/security-fusion-service.js';
|
|
26
25
|
import { deriveAgentCertificateHostname, selectAgentEndpointHostname, verifyAgentCertificateHostname, } from '../utils/node-url.js';
|
|
27
26
|
import { heartbeatPeerAddress } from '../utils/heartbeat-peer.js';
|
|
28
27
|
import { authMiddleware } from '../middleware/auth.js';
|
|
@@ -490,10 +489,6 @@ heartbeatRouter.post('/', async (c) => {
|
|
|
490
489
|
await enqueueSecurityObservations(tx, nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? []), snapshot.id, snapshot.timestamp);
|
|
491
490
|
return snapshot;
|
|
492
491
|
});
|
|
493
|
-
if (securityFusionLocal())
|
|
494
|
-
void ingestMagnusServiceHealth(nodeId, existing?.name ?? fqdn ?? nodeId, (h.services ?? [])).catch((err) => {
|
|
495
|
-
console.error(`[security-fusion] heartbeat conversion failed for ${nodeId}:`, err instanceof Error ? err.message : err);
|
|
496
|
-
});
|
|
497
492
|
// Evaluate health against alert rules (fire-and-forget)
|
|
498
493
|
const alertNodeName = existing?.name ?? fqdn ?? nodeId;
|
|
499
494
|
evaluateHealth(nodeId, alertNodeName, {
|
package/dist/routes/hermes.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
-
import { authMiddleware } from '../middleware/auth.js';
|
|
2
|
+
import { authMiddleware, getUser } from '../middleware/auth.js';
|
|
3
|
+
import { hermesProfileForRole } from '../services/secret-views.js';
|
|
3
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
4
5
|
import { hermesJobListQuerySchema } from '../schemas/hermes.js';
|
|
5
6
|
import { getHermesApiServerStatus, getHermesInstallation, getHermesJob, listHermesInstallations, listHermesJobs, listHermesProfiles, } from '../services/hermes-service.js';
|
|
@@ -13,21 +14,40 @@ function requiredParam(c, name) {
|
|
|
13
14
|
}
|
|
14
15
|
// Read-only inventory/status endpoints remain available because they use the
|
|
15
16
|
// Control database and never open SSH connections.
|
|
16
|
-
|
|
17
|
+
function installationForRole(installation, role) {
|
|
18
|
+
return installation.profiles
|
|
19
|
+
? { ...installation, profiles: installation.profiles.map((profile) => hermesProfileForRole(profile, role)) }
|
|
20
|
+
: installation;
|
|
21
|
+
}
|
|
22
|
+
hermesRouter.get('/installations', async (c) => {
|
|
23
|
+
const role = getUser(c).role;
|
|
24
|
+
return c.json({ success: true, data: (await listHermesInstallations()).map((installation) => installationForRole(installation, role)) });
|
|
25
|
+
});
|
|
17
26
|
hermesRouter.get('/installations/:id', async (c) => {
|
|
18
27
|
const data = await getHermesInstallation(requiredParam(c, 'id'));
|
|
19
28
|
if (!data)
|
|
20
29
|
throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
|
|
21
|
-
return c.json({ success: true, data });
|
|
30
|
+
return c.json({ success: true, data: installationForRole(data, getUser(c).role) });
|
|
22
31
|
});
|
|
23
32
|
hermesRouter.get('/installations/:id/profiles', async (c) => {
|
|
24
33
|
const installation = await getHermesInstallation(requiredParam(c, 'id'));
|
|
25
34
|
if (!installation)
|
|
26
35
|
throw new HttpError(404, 'NOT_FOUND', 'Hermes installation not found');
|
|
27
|
-
|
|
36
|
+
const role = getUser(c).role;
|
|
37
|
+
return c.json({ success: true, data: (await listHermesProfiles(installation.id)).map((profile) => hermesProfileForRole(profile, role)) });
|
|
38
|
+
});
|
|
39
|
+
hermesRouter.get('/profiles', async (c) => {
|
|
40
|
+
const role = getUser(c).role;
|
|
41
|
+
return c.json({ success: true, data: (await listHermesProfiles()).map((profile) => hermesProfileForRole(profile, role)) });
|
|
42
|
+
});
|
|
43
|
+
hermesRouter.get('/profiles/:id/api-server/status', async (c) => {
|
|
44
|
+
const { keyFingerprint, ...status } = await getHermesApiServerStatus(requiredParam(c, 'id'));
|
|
45
|
+
return c.json({ success: true, data: {
|
|
46
|
+
...status,
|
|
47
|
+
...(getUser(c).role === 'ADMIN' ? { keyFingerprint } : {}),
|
|
48
|
+
keyConfigured: Boolean(keyFingerprint),
|
|
49
|
+
} });
|
|
28
50
|
});
|
|
29
|
-
hermesRouter.get('/profiles', async (c) => c.json({ success: true, data: await listHermesProfiles() }));
|
|
30
|
-
hermesRouter.get('/profiles/:id/api-server/status', async (c) => c.json({ success: true, data: await getHermesApiServerStatus(requiredParam(c, 'id')) }));
|
|
31
51
|
hermesRouter.get('/jobs', async (c) => {
|
|
32
52
|
const parsed = hermesJobListQuerySchema.safeParse({
|
|
33
53
|
status: c.req.query('status'),
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { Hono } from 'hono';
|
|
3
|
+
import { z } from 'zod';
|
|
4
|
+
import { HiveSkillsTargetPostureSchema, HiveSkillsPublicationSchema } from '@hivemagnus/magnus-contracts';
|
|
5
|
+
import { getDb } from '../db/index.js';
|
|
6
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
7
|
+
import { safeEqual } from '../utils/safe-equal.js';
|
|
8
|
+
import { getNodeManagerSecret } from '../utils/env.js';
|
|
9
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
10
|
+
import { NodeApiClient } from '../sdk/node-api-client.js';
|
|
11
|
+
import { logAuditEvent } from '../services/audit-service.js';
|
|
12
|
+
import { httpIdentity } from '../security/managed-http.js';
|
|
13
|
+
const slug = z.string().regex(/^[a-z0-9][a-z0-9-]{1,62}$/);
|
|
14
|
+
export const skillsExecutorConfig = z.record(slug, z.object({ tokenSha256: z.string().regex(/^[a-f0-9]{64}$/), targets: z.record(slug, z.object({ nodeId: z.string().min(1).max(128), agentTarget: slug }).strict()) }).strict());
|
|
15
|
+
export const skillJobRequest = z.object({ target: slug, publicationSha256: z.string().regex(/^[a-f0-9]{64}$/), manifest: HiveSkillsPublicationSchema, approvedBy: z.string().min(1).max(200) }).strict();
|
|
16
|
+
export const hiveskillsRouter = new Hono();
|
|
17
|
+
function authorize(header) {
|
|
18
|
+
let raw;
|
|
19
|
+
try {
|
|
20
|
+
raw = JSON.parse(process.env['MAGNUS_HIVESKILLS_DEPLOYMENTS_JSON'] ?? '{}');
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
|
|
24
|
+
}
|
|
25
|
+
const configured = skillsExecutorConfig.safeParse(raw);
|
|
26
|
+
if (!configured.success)
|
|
27
|
+
throw new HttpError(503, 'HIVESKILLS_UNCONFIGURED', 'Invalid Skills executor configuration');
|
|
28
|
+
if (!header?.startsWith('Bearer ') || header.length < 39 || /[\r\n]/.test(header))
|
|
29
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
|
|
30
|
+
const digest = createHash('sha256').update(header.slice(7)).digest('hex');
|
|
31
|
+
for (const [registryId, entry] of Object.entries(configured.data))
|
|
32
|
+
if (safeEqual(entry.tokenSha256, digest))
|
|
33
|
+
return { registryId, ...entry };
|
|
34
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Endpoint-scoped Skills credential required');
|
|
35
|
+
}
|
|
36
|
+
function commandId(registryId, id) { if (!z.uuid().safeParse(id).success)
|
|
37
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Job UUID required'); return 'hiveskills-' + createHash('sha256').update(registryId + ':' + id).digest('hex'); }
|
|
38
|
+
async function targetClient(registry, target) {
|
|
39
|
+
const binding = registry.targets[target];
|
|
40
|
+
if (!binding)
|
|
41
|
+
throw new HttpError(403, 'FORBIDDEN', 'Target is outside this registry executor grant');
|
|
42
|
+
const node = await getDb().node.findUnique({ where: { id: binding.nodeId } });
|
|
43
|
+
if (!node || node.status === 'DECOMMISSIONED')
|
|
44
|
+
throw new HttpError(409, 'TARGET_UNAVAILABLE', 'Configured target is unavailable');
|
|
45
|
+
return { binding, node, client: new NodeApiClient({ baseUrl: getNodeBaseUrl(node.ipAddress, node.port), resourceNodeId: node.id, resourceActorId: 'service:hiveskills:' + registry.registryId, nodeToken: getNodeManagerSecret() }) };
|
|
46
|
+
}
|
|
47
|
+
async function observed(registry, target, id) {
|
|
48
|
+
const { node, client } = await targetClient(registry, target), jobId = commandId(registry.registryId, id);
|
|
49
|
+
const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
50
|
+
if (!row)
|
|
51
|
+
throw new HttpError(404, 'NOT_FOUND', 'Job has not been admitted');
|
|
52
|
+
if (row.nodeId !== node.id || row.createdBy !== 'service:hiveskills:' + registry.registryId)
|
|
53
|
+
throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Job belongs to another target');
|
|
54
|
+
const lease = await getDb().commandResourceLease.findUnique({ where: { commandId: jobId } });
|
|
55
|
+
if (!lease)
|
|
56
|
+
return { jobId, status: 'unknown', result: 'Missing durable ownership; review required' };
|
|
57
|
+
try {
|
|
58
|
+
const receipt = await client.getCommandReceipt(jobId, { nodeFqdn: node.hostname, requestSha256: lease.requestSha256 });
|
|
59
|
+
if (receipt.state === 'completed')
|
|
60
|
+
return { jobId, status: receipt.result.exitCode === 0 ? 'completed' : 'failed', result: receipt.result.exitCode === 0 ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' };
|
|
61
|
+
return { jobId, status: receipt.state === 'running' ? 'running' : 'unknown', result: 'Original operation retained; never replayed' };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return { jobId, status: 'unknown', result: 'Receipt unavailable; original operation retained' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
hiveskillsRouter.get('/targets/:target', async (c) => {
|
|
68
|
+
const registry = authorize(c.req.header('authorization')), target = slug.safeParse(c.req.param('target'));
|
|
69
|
+
if (!target.success)
|
|
70
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact logical target required');
|
|
71
|
+
const { binding, node, client } = await targetClient(registry, target.data), enrollment = await getDb().resourceEnrollment.findUnique({ where: { nodeId: node.id } });
|
|
72
|
+
const base = { registryId: registry.registryId, target: target.data, nodeId: node.id, agentTarget: binding.agentTarget };
|
|
73
|
+
if (enrollment?.state !== 'enrolled' || enrollment.nodeFqdn !== node.hostname || node.status !== 'ONLINE' || !node.lastHeartbeatAt || Date.now() - node.lastHeartbeatAt.getTime() > 120000 || await getDb().commandResourceLease.count({ where: { nodeId: node.id, state: { in: ['reserved', 'running', 'unknown'] } } }))
|
|
74
|
+
return c.json({ ...base, ready: false, reason: 'Node enrollment, health or retained ownership requires operator review' });
|
|
75
|
+
try {
|
|
76
|
+
const reply = await client.hiveSkillsTargetPosture(binding.agentTarget, registry.registryId), posture = HiveSkillsTargetPostureSchema.parse(reply.data);
|
|
77
|
+
if (!reply.success || posture.registryId !== registry.registryId || posture.target !== binding.agentTarget)
|
|
78
|
+
throw new Error('Native target binding differs');
|
|
79
|
+
return c.json({ ...base, ready: posture.ready, reason: posture.reason, configurationSha256: posture.configurationSha256, clientSha256: posture.clientSha256 });
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return c.json({ ...base, ready: false, reason: 'Native target posture unavailable' });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
hiveskillsRouter.get('/jobs/:id', async (c) => { const registry = authorize(c.req.header('authorization')); const target = slug.safeParse(c.req.query('target')); if (!target.success)
|
|
86
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact target required'); return c.json(await observed(registry, target.data, c.req.param('id'))); });
|
|
87
|
+
hiveskillsRouter.post('/jobs/:id', async (c) => {
|
|
88
|
+
const registry = authorize(c.req.header('authorization')), parsed = skillJobRequest.safeParse(await c.req.json().catch(() => null));
|
|
89
|
+
if (!parsed.success)
|
|
90
|
+
throw new HttpError(400, 'VALIDATION_ERROR', 'Exact approved Skills publication required');
|
|
91
|
+
const body = parsed.data, jobId = commandId(registry.registryId, c.req.param('id'));
|
|
92
|
+
if (createHash('sha256').update(JSON.stringify(body.manifest)).digest('hex') !== body.publicationSha256)
|
|
93
|
+
throw new HttpError(409, 'PUBLICATION_CONFLICT', 'Publication digest differs');
|
|
94
|
+
const { binding, node, client } = await targetClient(registry, body.target);
|
|
95
|
+
const request = { target: binding.agentTarget, registryId: registry.registryId, publicationSha256: body.publicationSha256, manifest: body.manifest };
|
|
96
|
+
const prior = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
97
|
+
if (prior) {
|
|
98
|
+
const identity = httpIdentity(jobId, 'POST', '/hiveskills/sync', JSON.stringify(request), 120);
|
|
99
|
+
if (prior.nodeId !== node.id || prior.createdBy !== 'service:hiveskills:' + registry.registryId || prior.command !== identity.command || prior.args !== JSON.stringify(identity.args))
|
|
100
|
+
throw new HttpError(409, 'JOB_BINDING_CONFLICT', 'Existing job has another target or publication');
|
|
101
|
+
return c.json(await observed(registry, body.target, c.req.param('id')));
|
|
102
|
+
}
|
|
103
|
+
await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, outcome: 'requested' } });
|
|
104
|
+
try {
|
|
105
|
+
const result = await client.syncHiveSkills(request, jobId);
|
|
106
|
+
await logAuditEvent({ action: 'skill.push', actorId: 'service:hiveskills:' + registry.registryId, targetType: 'node', targetId: node.id, details: { jobId, target: body.target, publicationSha256: body.publicationSha256, approvedBy: body.approvedBy, commandSha256: result.evidence.commandSha256 } });
|
|
107
|
+
return c.json({ jobId, status: result.response.success ? 'completed' : 'failed', result: result.response.success ? 'Exact approved publication synchronized' : 'Node completed with a bounded failure' });
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
const row = await getDb().remoteCommand.findUnique({ where: { id: jobId } });
|
|
111
|
+
if (!row)
|
|
112
|
+
return c.json({ jobId, status: 'not_admitted', result: 'No operation was admitted; resolve target readiness and prepare a new preview' });
|
|
113
|
+
return c.json(await observed(registry, body.target, c.req.param('id')));
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
//# sourceMappingURL=hiveskills.js.map
|
package/dist/routes/library.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { clientErrorMessage } from '../utils/safe-errors.js';
|
|
1
2
|
import { Hono } from 'hono';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
@@ -373,7 +374,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
373
374
|
nodeId: node.id,
|
|
374
375
|
nodeName: node.name,
|
|
375
376
|
status: 'failed',
|
|
376
|
-
error: err
|
|
377
|
+
error: clientErrorMessage(err, 'Unknown error'),
|
|
377
378
|
};
|
|
378
379
|
}
|
|
379
380
|
}));
|
|
@@ -383,7 +384,7 @@ libraryRouter.post('/deploy', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
383
384
|
nodeId: 'unknown',
|
|
384
385
|
nodeName: 'unknown',
|
|
385
386
|
status: 'failed',
|
|
386
|
-
error: r.reason
|
|
387
|
+
error: clientErrorMessage(r.reason, 'Unknown error'),
|
|
387
388
|
});
|
|
388
389
|
await logAuditEvent({
|
|
389
390
|
action: 'library.deploy',
|
package/dist/routes/logs.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
3
3
|
import { streamSSE } from 'hono/streaming';
|
|
4
|
+
import { watchOathweaveStream } from '../middleware/oathweave.js';
|
|
4
5
|
import { getDb } from '../db/index.js';
|
|
5
6
|
import { authMiddleware } from '../middleware/auth.js';
|
|
6
7
|
import { sanitizeDetails } from '../services/audit-service.js';
|
|
@@ -157,9 +158,13 @@ logsRouter.get('/stream', (c) => {
|
|
|
157
158
|
id: String(eventId++),
|
|
158
159
|
});
|
|
159
160
|
});
|
|
161
|
+
const stopWatching = watchOathweaveStream(c.req.header('Authorization'), () => {
|
|
162
|
+
void stream.close();
|
|
163
|
+
});
|
|
160
164
|
// Wait until the client disconnects
|
|
161
165
|
stream.onAbort(() => {
|
|
162
166
|
clearInterval(heartbeatTimer);
|
|
167
|
+
stopWatching();
|
|
163
168
|
unsubscribe();
|
|
164
169
|
});
|
|
165
170
|
// Keep the stream open — the onAbort callback will clean up
|
package/dist/routes/metrics.js
CHANGED
|
@@ -3,6 +3,7 @@ import { getDb } from '../db/index.js';
|
|
|
3
3
|
import { getRedis } from '../queue/redis.js';
|
|
4
4
|
import { getHeartbeatStreamStats } from '../services/heartbeat-stream.js';
|
|
5
5
|
import { safeEqual } from '../utils/safe-equal.js';
|
|
6
|
+
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
6
7
|
export const metricsRouter = new Hono();
|
|
7
8
|
// ── In-process counters ──
|
|
8
9
|
const counters = {
|
|
@@ -115,10 +116,10 @@ metricsRouter.get('/', async (c) => {
|
|
|
115
116
|
try {
|
|
116
117
|
const db = getDb();
|
|
117
118
|
const [nodeCount, onlineNodes, pendingCommands, staleNodes, versions, oldestQueued] = await Promise.all([
|
|
118
|
-
db.node.count(),
|
|
119
|
-
db.node.count({ where: { status: 'ONLINE' } }),
|
|
119
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS } } }),
|
|
120
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' } }),
|
|
120
121
|
db.remoteCommand.count({ where: { status: { in: ['PENDING', 'QUEUED'] } } }),
|
|
121
|
-
db.node.count({ where: { lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
|
|
122
|
+
db.node.count({ where: { id: { notIn: SENTINEL_NODE_IDS }, lastHeartbeatAt: { lt: new Date(Date.now() - 120_000) } } }),
|
|
122
123
|
db.agent.groupBy({ by: ['version'], _count: true }),
|
|
123
124
|
db.remoteCommand.findFirst({ where: { status: { in: ['PENDING', 'QUEUED'] } }, orderBy: { createdAt: 'asc' }, select: { createdAt: true } }),
|
|
124
125
|
]);
|
package/dist/routes/nodes.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
import { nodeForRole } from '../services/secret-views.js';
|
|
2
3
|
import { nanoid } from 'nanoid';
|
|
3
4
|
import { AGENT_VERSION } from '../config/index.js';
|
|
4
|
-
import { getDb } from '../db/index.js';
|
|
5
|
+
import { getDb, Prisma } from '../db/index.js';
|
|
5
6
|
import { promises as dns } from 'node:dns';
|
|
6
7
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
7
8
|
import { logAuditEvent } from '../services/audit-service.js';
|
|
@@ -79,6 +80,36 @@ async function tryResolveHostnameToIp(hostname) {
|
|
|
79
80
|
return null;
|
|
80
81
|
}
|
|
81
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The latest snapshot of each node, in one statement, for what a list row shows. An
|
|
85
|
+
* indexed lookup per node avoids hydrating retained history, which a Prisma relation
|
|
86
|
+
* with `take: 1` would load. A node with no snapshot is simply absent.
|
|
87
|
+
*/
|
|
88
|
+
async function latestHealthSummaries(nodeIds) {
|
|
89
|
+
if (nodeIds.length === 0)
|
|
90
|
+
return new Map();
|
|
91
|
+
const rows = await getDb().$queryRaw `
|
|
92
|
+
SELECT snapshot."nodeId", snapshot."timestamp", snapshot."cpuUsagePercent", snapshot."cpuCores",
|
|
93
|
+
snapshot."memoryTotalBytes", snapshot."memoryUsedBytes", snapshot."memoryPercent",
|
|
94
|
+
snapshot."swapTotalBytes", snapshot."swapUsedBytes", snapshot."disks"
|
|
95
|
+
FROM "nodes" AS node
|
|
96
|
+
INNER JOIN "health_snapshots" AS snapshot ON snapshot."id" = (
|
|
97
|
+
SELECT candidate."id"
|
|
98
|
+
FROM "health_snapshots" AS candidate
|
|
99
|
+
WHERE candidate."nodeId" = node."id"
|
|
100
|
+
ORDER BY candidate."timestamp" DESC
|
|
101
|
+
LIMIT 1
|
|
102
|
+
)
|
|
103
|
+
WHERE node."id" IN (${Prisma.join(nodeIds)})
|
|
104
|
+
`;
|
|
105
|
+
return new Map(rows.map(({ nodeId, memoryTotalBytes, memoryUsedBytes, swapTotalBytes, swapUsedBytes, ...row }) => [nodeId, {
|
|
106
|
+
...row,
|
|
107
|
+
memoryTotalBytes: Number(memoryTotalBytes),
|
|
108
|
+
memoryUsedBytes: Number(memoryUsedBytes),
|
|
109
|
+
swapTotalBytes: Number(swapTotalBytes),
|
|
110
|
+
swapUsedBytes: Number(swapUsedBytes),
|
|
111
|
+
}]));
|
|
112
|
+
}
|
|
82
113
|
export const nodesRouter = new Hono();
|
|
83
114
|
// All routes require auth
|
|
84
115
|
nodesRouter.use('*', authMiddleware);
|
|
@@ -116,11 +147,15 @@ nodesRouter.get('/', async (c) => {
|
|
|
116
147
|
db.node.count({ where }),
|
|
117
148
|
]));
|
|
118
149
|
// Strip token from API response — never expose node secrets to the frontend
|
|
119
|
-
const
|
|
120
|
-
const
|
|
150
|
+
const viewer = getUser(c).role;
|
|
151
|
+
const safeNodes = nodes.map((node) => nodeForRole(node, viewer));
|
|
152
|
+
const [readiness, health] = await Promise.all([
|
|
153
|
+
readinessOfNodes(safeNodes),
|
|
154
|
+
withReadRetry('nodes list health', () => latestHealthSummaries(safeNodes.map((node) => node.id))),
|
|
155
|
+
]);
|
|
121
156
|
return c.json({
|
|
122
157
|
success: true,
|
|
123
|
-
data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null })),
|
|
158
|
+
data: safeNodes.map(node => ({ ...node, upgradeReadiness: readiness.get(node.id) ?? null, health: health.get(node.id) ?? null })),
|
|
124
159
|
meta: { total, page, pageSize, hasMore: page * pageSize < total },
|
|
125
160
|
});
|
|
126
161
|
});
|
|
@@ -495,7 +530,7 @@ nodesRouter.get('/:nodeId', async (c) => {
|
|
|
495
530
|
});
|
|
496
531
|
if (!node)
|
|
497
532
|
throw new HttpError(404, 'NOT_FOUND', 'Node not found');
|
|
498
|
-
const
|
|
533
|
+
const safeNode = nodeForRole(node, getUser(c).role);
|
|
499
534
|
const readiness = await readinessOfNodes([safeNode]);
|
|
500
535
|
return c.json({ success: true, data: { ...safeNode, upgradeReadiness: readiness.get(safeNode.id) ?? null } });
|
|
501
536
|
});
|
|
@@ -535,6 +570,8 @@ nodesRouter.post('/register', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
535
570
|
targetId: node.id,
|
|
536
571
|
details: { name: body.name, hostname: body.hostname },
|
|
537
572
|
});
|
|
573
|
+
// The only response that ever carries this token: no cache may keep it.
|
|
574
|
+
c.header('Cache-Control', 'no-store');
|
|
538
575
|
return c.json({
|
|
539
576
|
success: true,
|
|
540
577
|
data: {
|
|
@@ -570,7 +607,7 @@ nodesRouter.patch('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
570
607
|
throw new HttpError(404, 'NOT_FOUND', 'Node not found');
|
|
571
608
|
throw err;
|
|
572
609
|
});
|
|
573
|
-
const
|
|
610
|
+
const safeNode = nodeForRole(node, getUser(c).role);
|
|
574
611
|
await logAuditEvent({
|
|
575
612
|
action: 'node.update',
|
|
576
613
|
actorId: user.userId,
|
|
@@ -621,7 +658,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
|
|
|
621
658
|
const ipChanged = resolvedIp !== node.ipAddress;
|
|
622
659
|
const hostnameChanged = targetHostname !== node.hostname;
|
|
623
660
|
if (!ipChanged && !hostnameChanged) {
|
|
624
|
-
const
|
|
661
|
+
const safeNode = nodeForRole(node, getUser(c).role);
|
|
625
662
|
return c.json({
|
|
626
663
|
success: true,
|
|
627
664
|
data: {
|
|
@@ -651,7 +688,7 @@ nodesRouter.post('/:nodeId/repair-address', requireRole('ADMIN', 'OPERATOR'), as
|
|
|
651
688
|
source,
|
|
652
689
|
},
|
|
653
690
|
});
|
|
654
|
-
const
|
|
691
|
+
const safeNode = nodeForRole(updated, getUser(c).role);
|
|
655
692
|
return c.json({
|
|
656
693
|
success: true,
|
|
657
694
|
data: {
|
package/dist/routes/ops.js
CHANGED
|
@@ -2,6 +2,8 @@ import { applicationProbeContractDigest, getApplicationProbeCollectorStatus } fr
|
|
|
2
2
|
import { lessonSchema, publishLesson, lessonSuggestions, supersedeLesson } from '../services/incident-lessons.js';
|
|
3
3
|
import { exportIncidentEvidence } from '../services/incident-evidence.js';
|
|
4
4
|
import { getNodeManagerSecret } from '../utils/env.js';
|
|
5
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
6
|
+
import { describeTransportFailure } from '../utils/safe-errors.js';
|
|
5
7
|
import { Hono } from 'hono';
|
|
6
8
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
7
9
|
import { HttpError } from '../middleware/error-handler.js';
|
|
@@ -20,7 +22,8 @@ import { loadRecoveryExerciseAssessmentEvidence } from '../services/recovery-exe
|
|
|
20
22
|
export const opsRouter = new Hono();
|
|
21
23
|
opsRouter.use('*', authMiddleware);
|
|
22
24
|
opsRouter.get('/dependency-health', async (c) => c.json({ success: true, data: await getDependencyHealth() }));
|
|
23
|
-
opsRouter.get('/readiness', async (c) => c.json({ success: true,
|
|
25
|
+
opsRouter.get('/readiness', async (c) => c.json({ success: true,
|
|
26
|
+
data: await getFleetReadiness({ includeSecretFingerprints: getUser(c).role === 'ADMIN' }) }));
|
|
24
27
|
opsRouter.get('/timeline', async (c) => {
|
|
25
28
|
const limit = Math.min(Number(c.req.query('limit') ?? '80'), 200);
|
|
26
29
|
return c.json({ success: true, data: await getFleetTimeline(limit) });
|
|
@@ -212,15 +215,30 @@ opsRouter.post('/maintenance/:id/cancel', requireRole('ADMIN', 'OPERATOR'), asyn
|
|
|
212
215
|
});
|
|
213
216
|
opsRouter.get('/incidents/:id/evidence', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await exportIncidentEvidence(c.req.param('id')) }));
|
|
214
217
|
opsRouter.get('/storage-evidence/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
215
|
-
const
|
|
218
|
+
const nodeId = c.req.param('nodeId');
|
|
219
|
+
const node = await getDb().node.findUnique({ where: { id: nodeId }, select: { name: true, ipAddress: true, port: true } });
|
|
216
220
|
const mount = c.req.query('mount');
|
|
217
221
|
if (!node || !mount || mount.length > 4096)
|
|
218
222
|
return c.json({ success: false, error: { message: 'Valid node and mount required' } }, 400);
|
|
219
|
-
const
|
|
220
|
-
|
|
223
|
+
const audit = (success) => logAuditEvent({ action: 'storage.investigate', actorId: getUser(c).userId, targetType: 'node', targetId: nodeId, details: { operation: 'storage-evidence', readOnly: true, success } });
|
|
224
|
+
let response;
|
|
225
|
+
try {
|
|
226
|
+
response = await fetch(`${getNodeBaseUrl(node.ipAddress, node.port)}/api/v1/storage-evidence?${new URLSearchParams({ mount })}`, { headers: { 'x-node-token': getNodeManagerSecret() }, signal: AbortSignal.timeout(15000), redirect: 'error' });
|
|
227
|
+
}
|
|
228
|
+
catch (error) {
|
|
229
|
+
await audit(false);
|
|
230
|
+
throw new HttpError(502, 'NODE_UNREACHABLE', `Could not reach the agent on ${node.name}: ${describeTransportFailure(error)}. Check that the agent is running and that this server can reach its port.`);
|
|
231
|
+
}
|
|
221
232
|
const { boundedJson } = await import('../services/application-probes.js');
|
|
222
|
-
|
|
223
|
-
|
|
233
|
+
let body;
|
|
234
|
+
try {
|
|
235
|
+
body = await boundedJson(response);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
await audit(false);
|
|
239
|
+
throw new HttpError(502, 'NODE_BAD_RESPONSE', `The agent on ${node.name} answered HTTP ${response.status} with no readable storage report. Check that it is running a version that supports storage evidence.`);
|
|
240
|
+
}
|
|
241
|
+
await audit(response.ok);
|
|
224
242
|
return c.json(body, response.ok ? 200 : 502);
|
|
225
243
|
});
|
|
226
244
|
opsRouter.get('/lessons', requireRole('ADMIN', 'OPERATOR'), async (c) => c.json({ success: true, data: await lessonSuggestions((c.req.query('query') ?? '').slice(0, 500)) }));
|
package/dist/routes/profiling.js
CHANGED
|
@@ -3,6 +3,7 @@ import { authMiddleware, requireRole } from '../middleware/auth.js';
|
|
|
3
3
|
import { getDb } from '../db/index.js';
|
|
4
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
5
5
|
import { getNodeManagerSecret } from '../utils/env.js';
|
|
6
|
+
import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
|
|
6
7
|
export const profilingRouter = new Hono();
|
|
7
8
|
profilingRouter.use('*', authMiddleware);
|
|
8
9
|
/**
|
|
@@ -49,7 +50,7 @@ profilingRouter.get('/:nodeId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
49
50
|
profilingRouter.get('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
50
51
|
const db = getDb();
|
|
51
52
|
const nodes = await db.node.findMany({
|
|
52
|
-
where: { status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
|
|
53
|
+
where: { id: { notIn: SENTINEL_NODE_IDS }, status: { notIn: ['DECOMMISSIONED', 'OFFLINE'] } },
|
|
53
54
|
select: { id: true, name: true, ipAddress: true, port: true },
|
|
54
55
|
});
|
|
55
56
|
const results = await Promise.allSettled(nodes.map(async (node) => {
|
package/dist/routes/roles.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
|
+
import { mergeEnvironmentEdit } from '../services/secret-views.js';
|
|
2
3
|
import { getDb } from '../db/index.js';
|
|
3
4
|
import { authMiddleware, getUser, requireRole } from '../middleware/auth.js';
|
|
4
5
|
import { logAuditEvent } from '../services/audit-service.js';
|
|
@@ -11,17 +12,23 @@ import { serializeStringArray, serializeRecord, parseStringArray, parseStringRec
|
|
|
11
12
|
export const rolesRouter = new Hono();
|
|
12
13
|
rolesRouter.use('*', authMiddleware);
|
|
13
14
|
/** Transform a DB role record into a clean API response. */
|
|
14
|
-
function formatRole(role) {
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
function formatRole(role, viewer) {
|
|
16
|
+
// Values in envConfig are written to hosts and may be secrets: below ADMIN a
|
|
17
|
+
// role shows its key names only.
|
|
18
|
+
const { envConfig: rawEnvConfig, ...rest } = role;
|
|
19
|
+
const envConfig = parseStringRecord(rawEnvConfig);
|
|
20
|
+
const formatted = {
|
|
21
|
+
...rest,
|
|
17
22
|
libraryFileIds: parseStringArray(role.libraryFileIds),
|
|
18
23
|
skillNames: parseStringArray(role.skillNames),
|
|
19
24
|
aptPackages: parseStringArray(role.aptPackages),
|
|
20
25
|
npmPackages: parseStringArray(role.npmPackages),
|
|
21
|
-
envConfig:
|
|
26
|
+
...(viewer === 'ADMIN' ? { envConfig } : {}),
|
|
27
|
+
envConfigKeys: Object.keys(envConfig).sort(),
|
|
22
28
|
dockerVolumes: parseStringArray(role.dockerVolumes),
|
|
23
29
|
firewallAllowRules: parseStringArray(role.firewallAllowRules),
|
|
24
30
|
};
|
|
31
|
+
return formatted;
|
|
25
32
|
}
|
|
26
33
|
/** Transform a DB role application record. */
|
|
27
34
|
function formatApplication(app) {
|
|
@@ -39,7 +46,8 @@ rolesRouter.get('/', async (c) => {
|
|
|
39
46
|
_count: { select: { nodes: true } },
|
|
40
47
|
},
|
|
41
48
|
});
|
|
42
|
-
const
|
|
49
|
+
const viewer = getUser(c).role;
|
|
50
|
+
const data = roles.map((r) => formatRole(r, viewer));
|
|
43
51
|
return c.json({ success: true, data });
|
|
44
52
|
});
|
|
45
53
|
// ─── Get single role ───
|
|
@@ -55,7 +63,7 @@ rolesRouter.get('/:roleId', async (c) => {
|
|
|
55
63
|
});
|
|
56
64
|
if (!role)
|
|
57
65
|
throw new HttpError(404, 'NOT_FOUND', 'Role not found');
|
|
58
|
-
return c.json({ success: true, data: formatRole(role) });
|
|
66
|
+
return c.json({ success: true, data: formatRole(role, getUser(c).role) });
|
|
59
67
|
});
|
|
60
68
|
// ─── Create role ───
|
|
61
69
|
rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -105,7 +113,7 @@ rolesRouter.post('/', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
105
113
|
targetId: role.id,
|
|
106
114
|
details: { name: role.name, status: role.status },
|
|
107
115
|
});
|
|
108
|
-
return c.json({ success: true, data: formatRole(role) }, 201);
|
|
116
|
+
return c.json({ success: true, data: formatRole(role, user.role) }, 201);
|
|
109
117
|
});
|
|
110
118
|
// ─── Update role ───
|
|
111
119
|
rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
@@ -131,7 +139,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
131
139
|
...(body.skillNames !== undefined && { skillNames: serializeStringArray(body.skillNames) }),
|
|
132
140
|
...(body.aptPackages !== undefined && { aptPackages: serializeStringArray(body.aptPackages) }),
|
|
133
141
|
...(body.npmPackages !== undefined && { npmPackages: serializeStringArray(body.npmPackages) }),
|
|
134
|
-
...(body.envConfig !== undefined && { envConfig: serializeRecord(body.envConfig) }),
|
|
142
|
+
...(body.envConfig !== undefined && { envConfig: serializeRecord(user.role === 'ADMIN' ? body.envConfig : mergeEnvironmentEdit(existing.envConfig, body.envConfig)) }),
|
|
135
143
|
...(body.dockerEnabled !== undefined && { dockerEnabled: body.dockerEnabled }),
|
|
136
144
|
...(body.dockerNonRoot !== undefined && { dockerNonRoot: body.dockerNonRoot }),
|
|
137
145
|
...(body.dockerImage !== undefined && { dockerImage: body.dockerImage }),
|
|
@@ -149,7 +157,7 @@ rolesRouter.patch('/:roleId', requireRole('ADMIN', 'OPERATOR'), async (c) => {
|
|
|
149
157
|
targetId: roleId,
|
|
150
158
|
details: { name: body.name, status: body.status },
|
|
151
159
|
});
|
|
152
|
-
return c.json({ success: true, data: formatRole(role) });
|
|
160
|
+
return c.json({ success: true, data: formatRole(role, user.role) });
|
|
153
161
|
});
|
|
154
162
|
// ─── Delete role ───
|
|
155
163
|
rolesRouter.delete('/:roleId', requireRole('ADMIN'), async (c) => {
|