@hivemagnus/magnus-server 1.0.1 → 1.0.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 +10 -0
- package/README.md +15 -0
- package/agent-version.json +1 -1
- package/dist/cli/env-catalog.js +3 -0
- package/dist/config/env.js +26 -0
- package/dist/config/route-aliases.js +73 -0
- package/dist/db/generated/prisma/internal/class.js +3 -3
- package/dist/db/generated/prisma/internal/prismaNamespace.js +4 -4
- package/dist/index.js +4 -2
- package/dist/sdk/node-api-client.js +2 -2
- package/dist/security/approval-assertion.js +23 -1
- package/dist/services/webhook-service.js +13 -10
- package/dist/services/webhook-types.js +29 -0
- package/package.json +19 -19
|
@@ -36,12 +36,12 @@ export const Sql = runtime.Sql;
|
|
|
36
36
|
export const Decimal = runtime.Decimal;
|
|
37
37
|
export const getExtensionContext = runtime.Extensions.getExtensionContext;
|
|
38
38
|
/**
|
|
39
|
-
* Prisma Client JS version: 7.
|
|
40
|
-
* Query Engine version:
|
|
39
|
+
* Prisma Client JS version: 7.10.1
|
|
40
|
+
* Query Engine version: d1b3f377ca835d73fd50954a5e22128b9ebdd6b8
|
|
41
41
|
*/
|
|
42
42
|
export const prismaVersion = {
|
|
43
|
-
client: "7.
|
|
44
|
-
engine: "
|
|
43
|
+
client: "7.10.1",
|
|
44
|
+
engine: "d1b3f377ca835d73fd50954a5e22128b9ebdd6b8"
|
|
45
45
|
};
|
|
46
46
|
export const NullTypes = {
|
|
47
47
|
DbNull: runtime.NullTypes.DbNull,
|
package/dist/index.js
CHANGED
|
@@ -18,6 +18,8 @@ const { securityFusionLocal, securityFusionMode, startSecurityExporter, stopSecu
|
|
|
18
18
|
const { featureFlags } = await import('./config/features.js');
|
|
19
19
|
const { serve } = await import('@hono/node-server');
|
|
20
20
|
const { app } = await import('./server.js');
|
|
21
|
+
const { parseRouteAliases, withRouteAliases } = await import('./config/route-aliases.js');
|
|
22
|
+
const handler = withRouteAliases(app.fetch, parseRouteAliases(process.env['MAGNUS_ROUTE_ALIASES_JSON']));
|
|
21
23
|
const { startRolloutReconciler, stopRolloutReconciler } = await import('./services/rollout-service.js');
|
|
22
24
|
const { startApplicationProbes, stopApplicationProbes } = await import('./services/application-probes.js');
|
|
23
25
|
const { startNodeIssueMonitor } = await import('./services/node-issue-monitor.js');
|
|
@@ -89,7 +91,7 @@ const listener = resolveListenerSecurity({
|
|
|
89
91
|
});
|
|
90
92
|
const server = listener.useTls
|
|
91
93
|
? serve({
|
|
92
|
-
fetch:
|
|
94
|
+
fetch: handler,
|
|
93
95
|
port,
|
|
94
96
|
hostname: listener.hostname,
|
|
95
97
|
createServer,
|
|
@@ -101,7 +103,7 @@ const server = listener.useTls
|
|
|
101
103
|
console.log(`🚀 Magnus server listening on https://${listener.hostname}:${info.port}`);
|
|
102
104
|
startBackgroundServices();
|
|
103
105
|
})
|
|
104
|
-
: serve({ fetch:
|
|
106
|
+
: serve({ fetch: handler, port, hostname: listener.hostname }, (info) => {
|
|
105
107
|
console.log(`🚀 Magnus server listening on http://${listener.hostname}:${info.port} (development opt-in)`);
|
|
106
108
|
startBackgroundServices();
|
|
107
109
|
});
|
|
@@ -44,7 +44,7 @@ function verifiedReconciliation(raw, commandId, expected, fresh, nodeToken) {
|
|
|
44
44
|
* Surface that as a non-retryable "feature disabled on this node" error.
|
|
45
45
|
*/
|
|
46
46
|
export const AGENT_NOT_PRIVILEGED = 'AGENT_NOT_PRIVILEGED';
|
|
47
|
-
async function rejectUnprivilegedAgent(
|
|
47
|
+
async function rejectUnprivilegedAgent({ response }) {
|
|
48
48
|
if (response.status !== 403)
|
|
49
49
|
return;
|
|
50
50
|
let body;
|
|
@@ -76,7 +76,7 @@ export class NodeApiClient {
|
|
|
76
76
|
actorId: options.resourceActorId ?? 'system:control-api', baseUrl: options.baseUrl,
|
|
77
77
|
...(options.resourceScope ? { scope: options.resourceScope } : {}) };
|
|
78
78
|
this.api = ky.create({
|
|
79
|
-
|
|
79
|
+
prefix: `${options.baseUrl}/api/v1`,
|
|
80
80
|
timeout: options.timeout ?? 15_000,
|
|
81
81
|
headers: {
|
|
82
82
|
'x-node-token': options.nodeToken,
|
|
@@ -2,6 +2,26 @@ import { createHash, createPublicKey, timingSafeEqual, verify } from 'node:crypt
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { z } from 'zod';
|
|
4
4
|
import { HttpError } from '../middleware/error-handler.js';
|
|
5
|
+
/** Signature domain prefix for approval assertions; the signed message is `<prefix>.v<version>\n<canonical payload>`. */
|
|
6
|
+
export const DEFAULT_APPROVAL_ASSERTION_DOMAIN = 'magnus.approval-assertion';
|
|
7
|
+
export const approvalAssertionDomainPattern = /^[a-z0-9](?:[a-z0-9.-]{0,98}[a-z0-9])?$/;
|
|
8
|
+
/**
|
|
9
|
+
* Accepted signature domain prefixes, in preference order. The first one is the
|
|
10
|
+
* domain this server would sign with. `MAGNUS_APPROVAL_ASSERTION_DOMAINS`
|
|
11
|
+
* (comma-separated) lets older signers that use another prefix keep working.
|
|
12
|
+
*/
|
|
13
|
+
export function approvalAssertionDomains(value = process.env['MAGNUS_APPROVAL_ASSERTION_DOMAINS']) {
|
|
14
|
+
if (value === undefined || value.trim() === '')
|
|
15
|
+
return [DEFAULT_APPROVAL_ASSERTION_DOMAIN];
|
|
16
|
+
const domains = value.split(',').map(item => item.trim()).filter(Boolean);
|
|
17
|
+
if (!domains.length || domains.some(domain => !approvalAssertionDomainPattern.test(domain)))
|
|
18
|
+
throw new Error('MAGNUS_APPROVAL_ASSERTION_DOMAINS must list domain prefixes made of a-z, 0-9, "." and "-"');
|
|
19
|
+
return [...new Set(domains)];
|
|
20
|
+
}
|
|
21
|
+
/** The exact bytes signed for an approval payload under a domain prefix. */
|
|
22
|
+
export function approvalSigningMessage(payload, domain = approvalAssertionDomains()[0]) {
|
|
23
|
+
return Buffer.from(`${domain}.v${payload.version}\n` + approvalPayloadCanonical(payload));
|
|
24
|
+
}
|
|
5
25
|
const identifier = z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9:._-]{0,159}$/);
|
|
6
26
|
const digest = z.string().regex(/^[a-f0-9]{64}$/);
|
|
7
27
|
const payloadFields = {
|
|
@@ -49,7 +69,9 @@ export function verifyApprovalAssertion(raw) {
|
|
|
49
69
|
const key = createPublicKey(inline ?? (encoded
|
|
50
70
|
? { key: Buffer.from(encoded, 'base64'), format: 'der', type: 'spki' }
|
|
51
71
|
: readFileSync(path, 'utf8')));
|
|
52
|
-
|
|
72
|
+
const signatureBytes = Buffer.from(signature, 'base64url');
|
|
73
|
+
valid = key.asymmetricKeyType === 'ed25519' && approvalAssertionDomains()
|
|
74
|
+
.some(domain => verify(null, approvalSigningMessage(payload, domain), key, signatureBytes));
|
|
53
75
|
}
|
|
54
76
|
catch {
|
|
55
77
|
valid = false;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { recordMonitorPulse } from './monitor-pulse.js';
|
|
2
|
+
import { WEBHOOK_FORMATS, webhookFormat } from './webhook-types.js';
|
|
2
3
|
import { redactUnknown } from '../utils/redact.js';
|
|
3
4
|
import { createHmac, randomBytes, createCipheriv } from 'node:crypto';
|
|
4
5
|
import { nanoid } from 'nanoid';
|
|
@@ -67,7 +68,7 @@ export async function reconcileWebhookDeliveries() {
|
|
|
67
68
|
// replaced; failed jobs are finalized by the worker's failure bookkeeping.
|
|
68
69
|
await addDurableJob(getWebhookQueue(), 'deliver', createWebhookJobPayload(delivery.endpointId), {
|
|
69
70
|
jobId: delivery.id,
|
|
70
|
-
attempts: delivery.endpoint.type === 'mattermost' ? 1 : Math.max(1, Math.min(11, delivery.endpoint.retries + 1)),
|
|
71
|
+
attempts: webhookFormat(delivery.endpoint.type) === 'mattermost' ? 1 : Math.max(1, Math.min(11, delivery.endpoint.retries + 1)),
|
|
71
72
|
backoff: { type: 'exponential', delay: 1000 },
|
|
72
73
|
removeOnComplete: { age: 86400, count: 10000 }, removeOnFail: { age: 604800, count: 10000 },
|
|
73
74
|
}, ['completed']);
|
|
@@ -254,9 +255,9 @@ export function startWebhookWorker() {
|
|
|
254
255
|
const extraHeaders = parseStoredHeaders(endpoint.id, endpoint.headers);
|
|
255
256
|
const shouldEncrypt = extraHeaders['x-magnus-encrypt'] === 'true';
|
|
256
257
|
delete extraHeaders['x-magnus-encrypt'];
|
|
257
|
-
const { body: rawBody, contentType } = formatPayload(endpoint.type, delivery.event, delivery.payload);
|
|
258
|
+
const { body: rawBody, contentType } = formatPayload(webhookFormat(endpoint.type), delivery.event, delivery.payload);
|
|
258
259
|
// Optionally encrypt the payload with AES-256-GCM
|
|
259
|
-
const encrypted = shouldEncrypt && secret !== null && endpoint.type === 'generic';
|
|
260
|
+
const encrypted = shouldEncrypt && secret !== null && webhookFormat(endpoint.type) === 'generic';
|
|
260
261
|
const body = encrypted ? encryptPayload(rawBody, secret) : rawBody;
|
|
261
262
|
const headers = {
|
|
262
263
|
...extraHeaders,
|
|
@@ -269,7 +270,7 @@ export function startWebhookWorker() {
|
|
|
269
270
|
if (secret) {
|
|
270
271
|
headers['X-Magnus-Signature'] = `sha256=${signPayload(body, secret)}`;
|
|
271
272
|
}
|
|
272
|
-
if (endpoint.type === 'mattermost' && !(await claimMattermostAttempt(deliveryId)))
|
|
273
|
+
if (webhookFormat(endpoint.type) === 'mattermost' && !(await claimMattermostAttempt(deliveryId)))
|
|
273
274
|
return;
|
|
274
275
|
try {
|
|
275
276
|
const res = await postWebhook(webhookUrl(endpoint), { headers, body, timeoutMs: endpoint.timeoutMs });
|
|
@@ -356,7 +357,8 @@ export async function getWebhookEndpoint(id) {
|
|
|
356
357
|
export async function createWebhookEndpoint(data) {
|
|
357
358
|
const db = getDb();
|
|
358
359
|
const id = nanoid();
|
|
359
|
-
|
|
360
|
+
const type = webhookFormat(data.type ?? 'generic');
|
|
361
|
+
if (!WEBHOOK_FORMATS.includes(type))
|
|
360
362
|
throw new Error('Unsupported webhook format');
|
|
361
363
|
await validateWebhookTarget(data.url);
|
|
362
364
|
const headers = normalizeHeaders(data.headers ?? {});
|
|
@@ -366,7 +368,7 @@ export async function createWebhookEndpoint(data) {
|
|
|
366
368
|
id,
|
|
367
369
|
name: data.name,
|
|
368
370
|
url: encryptWebhookCredential(data.url, id, 'url'),
|
|
369
|
-
type
|
|
371
|
+
type,
|
|
370
372
|
secret: data.secret === undefined ? null : encryptWebhookCredential(data.secret, id, 'secret'),
|
|
371
373
|
events: JSON.stringify(data.events ?? []),
|
|
372
374
|
headers: encryptWebhookCredential(JSON.stringify(headers), id, 'headers'),
|
|
@@ -390,9 +392,10 @@ export async function updateWebhookEndpoint(id, data) {
|
|
|
390
392
|
updateData['url'] = encryptWebhookCredential(data.url, id, 'url');
|
|
391
393
|
}
|
|
392
394
|
if (data.type !== undefined) {
|
|
393
|
-
|
|
395
|
+
const type = webhookFormat(data.type);
|
|
396
|
+
if (!WEBHOOK_FORMATS.includes(type))
|
|
394
397
|
throw new Error('Unsupported webhook format');
|
|
395
|
-
updateData['type'] =
|
|
398
|
+
updateData['type'] = type;
|
|
396
399
|
}
|
|
397
400
|
if (data.secret !== undefined) {
|
|
398
401
|
updateData['secret'] = data.secret === null ? null : encryptWebhookCredential(data.secret, id, 'secret');
|
|
@@ -442,12 +445,12 @@ export async function testWebhookEndpoint(id) {
|
|
|
442
445
|
message: 'This is a test webhook delivery from Magnus',
|
|
443
446
|
timestamp: new Date().toISOString(),
|
|
444
447
|
};
|
|
445
|
-
const { body: rawBody, contentType } = formatPayload(endpoint.type, 'webhook.test', JSON.stringify(testPayload));
|
|
448
|
+
const { body: rawBody, contentType } = formatPayload(webhookFormat(endpoint.type), 'webhook.test', JSON.stringify(testPayload));
|
|
446
449
|
const secret = encryptedSecret(endpoint);
|
|
447
450
|
const extraHeaders = parseStoredHeaders(endpoint.id, endpoint.headers);
|
|
448
451
|
const shouldEncrypt = extraHeaders['x-magnus-encrypt'] === 'true';
|
|
449
452
|
delete extraHeaders['x-magnus-encrypt'];
|
|
450
|
-
const encrypted = shouldEncrypt && secret !== null && endpoint.type === 'generic';
|
|
453
|
+
const encrypted = shouldEncrypt && secret !== null && webhookFormat(endpoint.type) === 'generic';
|
|
451
454
|
const body = encrypted ? encryptPayload(rawBody, secret) : rawBody;
|
|
452
455
|
const headers = {
|
|
453
456
|
...extraHeaders,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export const WEBHOOK_FORMATS = ['generic', 'slack', 'discord', 'mattermost'];
|
|
2
|
+
/**
|
|
3
|
+
* Legacy webhook type names mapped to current formats, from
|
|
4
|
+
* `MAGNUS_WEBHOOK_TYPE_ALIASES` (comma-separated `legacy=format` pairs). Lets
|
|
5
|
+
* endpoints stored or submitted under an older type name keep their format.
|
|
6
|
+
*/
|
|
7
|
+
export function webhookTypeAliases(raw = process.env['MAGNUS_WEBHOOK_TYPE_ALIASES']) {
|
|
8
|
+
if (raw === undefined || raw.trim() === '')
|
|
9
|
+
return {};
|
|
10
|
+
const aliases = {};
|
|
11
|
+
for (const pair of raw.split(',').map(item => item.trim()).filter(Boolean)) {
|
|
12
|
+
const [from, to, extra] = pair.split('=').map(item => item.trim());
|
|
13
|
+
if (extra !== undefined || !from || !to || !/^[a-z][a-z0-9_-]{0,31}$/.test(from)
|
|
14
|
+
|| WEBHOOK_FORMATS.includes(from) || !WEBHOOK_FORMATS.includes(to))
|
|
15
|
+
throw new Error('MAGNUS_WEBHOOK_TYPE_ALIASES must list legacy=format pairs, where format is generic, slack, discord or mattermost');
|
|
16
|
+
aliases[from] = to;
|
|
17
|
+
}
|
|
18
|
+
return aliases;
|
|
19
|
+
}
|
|
20
|
+
/** The current delivery format for a stored or submitted webhook type. */
|
|
21
|
+
export function webhookFormat(type) {
|
|
22
|
+
let aliases = {};
|
|
23
|
+
try {
|
|
24
|
+
aliases = webhookTypeAliases();
|
|
25
|
+
}
|
|
26
|
+
catch { /* Startup validation reports a malformed list. */ }
|
|
27
|
+
return Object.hasOwn(aliases, type) ? aliases[type] : type;
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=webhook-types.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hivemagnus/magnus-server",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "Magnus server: the fleet control plane API (Hono, PostgreSQL, Redis) for nodes running magnus-agent, with optional Hive IAM, Hive Router, HiveMem, Hermes Agent and external security console integrations.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"magnus",
|
|
@@ -84,33 +84,33 @@
|
|
|
84
84
|
"prepublishOnly": "npm run db:generate && npm run clean && npm run build"
|
|
85
85
|
},
|
|
86
86
|
"dependencies": {
|
|
87
|
-
"@hivemagnus/magnus-contracts": "1.0.
|
|
87
|
+
"@hivemagnus/magnus-contracts": "1.0.3",
|
|
88
88
|
"@hono/node-server": "^2.1.5",
|
|
89
|
-
"@hono/swagger-ui": "^0.
|
|
90
|
-
"@prisma/adapter-pg": "7.
|
|
91
|
-
"@prisma/client": "^7.
|
|
92
|
-
"argon2": "^0.
|
|
89
|
+
"@hono/swagger-ui": "^0.6.2",
|
|
90
|
+
"@prisma/adapter-pg": "7.10.1",
|
|
91
|
+
"@prisma/client": "^7.10.1",
|
|
92
|
+
"argon2": "^0.45.1",
|
|
93
93
|
"bullmq": "^5.30.0",
|
|
94
|
-
"dotenv": "^
|
|
95
|
-
"hono": "4.13.
|
|
94
|
+
"dotenv": "^18.0.9",
|
|
95
|
+
"hono": "4.13.14",
|
|
96
96
|
"ioredis": "^5.4.0",
|
|
97
|
-
"jose": "^6.
|
|
98
|
-
"ky": "^
|
|
97
|
+
"jose": "^6.2.13",
|
|
98
|
+
"ky": "^2.2.0",
|
|
99
99
|
"nanoid": "^5.0.0",
|
|
100
|
-
"pg": "^8.
|
|
100
|
+
"pg": "^8.23.1",
|
|
101
101
|
"ssh2": "^1.17.0",
|
|
102
|
-
"ws": "^8.
|
|
102
|
+
"ws": "^8.22.0",
|
|
103
103
|
"yaml": "2.9.1",
|
|
104
104
|
"zod": "^4.6.7",
|
|
105
|
-
"@hivemagnus/magnus-skills": "1.0.
|
|
106
|
-
"prisma": "^7.
|
|
105
|
+
"@hivemagnus/magnus-skills": "1.0.3",
|
|
106
|
+
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|
|
109
|
-
"@types/node": "^22.
|
|
110
|
-
"@types/pg": "^8.
|
|
111
|
-
"@types/ssh2": "^1.15.
|
|
112
|
-
"@types/ws": "^8.18.
|
|
113
|
-
"tsx": "^4.
|
|
109
|
+
"@types/node": "^22.20.5",
|
|
110
|
+
"@types/pg": "^8.23.1",
|
|
111
|
+
"@types/ssh2": "^1.15.6",
|
|
112
|
+
"@types/ws": "^8.18.2",
|
|
113
|
+
"tsx": "^4.23.15",
|
|
114
114
|
"typescript": "^5.8.3",
|
|
115
115
|
"vitest": "^4.1.12"
|
|
116
116
|
},
|