@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.
@@ -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.9.1
40
- * Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
39
+ * Prisma Client JS version: 7.10.1
40
+ * Query Engine version: d1b3f377ca835d73fd50954a5e22128b9ebdd6b8
41
41
  */
42
42
  export const prismaVersion = {
43
- client: "7.9.1",
44
- engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
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: app.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: app.fetch, port, hostname: listener.hostname }, (info) => {
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(_request, _options, response) {
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
- prefixUrl: `${options.baseUrl}/api/v1`,
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
- valid = key.asymmetricKeyType === 'ed25519' && verify(null, Buffer.from(`magnus.approval-assertion.v${payload.version}\n` + canonical), key, Buffer.from(signature, 'base64url'));
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
- if (!['generic', 'slack', 'discord', 'mattermost'].includes(data.type ?? 'generic'))
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: data.type ?? 'generic',
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
- if (!['generic', 'slack', 'discord', 'mattermost'].includes(data.type))
395
+ const type = webhookFormat(data.type);
396
+ if (!WEBHOOK_FORMATS.includes(type))
394
397
  throw new Error('Unsupported webhook format');
395
- updateData['type'] = data.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.1",
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.1",
87
+ "@hivemagnus/magnus-contracts": "1.0.3",
88
88
  "@hono/node-server": "^2.1.5",
89
- "@hono/swagger-ui": "^0.5.3",
90
- "@prisma/adapter-pg": "7.8.0",
91
- "@prisma/client": "^7.9.1",
92
- "argon2": "^0.44.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": "^17.3.1",
95
- "hono": "4.13.13",
94
+ "dotenv": "^18.0.9",
95
+ "hono": "4.13.14",
96
96
  "ioredis": "^5.4.0",
97
- "jose": "^6.0.0",
98
- "ky": "^1.7.5",
97
+ "jose": "^6.2.13",
98
+ "ky": "^2.2.0",
99
99
  "nanoid": "^5.0.0",
100
- "pg": "^8.22.0",
100
+ "pg": "^8.23.1",
101
101
  "ssh2": "^1.17.0",
102
- "ws": "^8.19.0",
102
+ "ws": "^8.22.0",
103
103
  "yaml": "2.9.1",
104
104
  "zod": "^4.6.7",
105
- "@hivemagnus/magnus-skills": "1.0.1",
106
- "prisma": "^7.9.1"
105
+ "@hivemagnus/magnus-skills": "1.0.3",
106
+ "prisma": "^7.10.1"
107
107
  },
108
108
  "devDependencies": {
109
- "@types/node": "^22.10.0",
110
- "@types/pg": "^8.20.0",
111
- "@types/ssh2": "^1.15.5",
112
- "@types/ws": "^8.18.1",
113
- "tsx": "^4.19.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
  },