@hivemagnus/magnus-server 1.0.2 → 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 CHANGED
@@ -86,6 +86,13 @@ CORS_ORIGIN=https://magnus.example.com:3000
86
86
  # Optional integrations — each is off until configured
87
87
  # ═══════════════════════════════════════════════════════════════════════════
88
88
 
89
+ # ── Interoperating with older clients (optional) ──
90
+ # JSON object mapping legacy /api/v1 path prefixes to current ones. Requests are
91
+ # rewritten before routing, so auth, scopes and audit see the current path.
92
+ # MAGNUS_ROUTE_ALIASES_JSON={"/api/v1/resource-leases/old-name/admission":"/api/v1/resource-leases/admission"}
93
+ # Legacy webhook type names mapped to a current format (generic, slack, discord, mattermost).
94
+ # MAGNUS_WEBHOOK_TYPE_ALIASES=legacy-chat=mattermost
95
+
89
96
  # ── Hive IAM: verify RS256 access tokens issued by Hive IAM ──
90
97
  # When set, JWT_SECRET is no longer required for user tokens.
91
98
  # IAM_CORE_URL=https://iam.example.com
@@ -139,6 +146,9 @@ CORS_ORIGIN=https://magnus.example.com:3000
139
146
  # APPROVAL_SIGNER_PUBLIC_KEY_PATH=/etc/magnus-server/approval-signer.pub
140
147
  # APPROVAL_TELEGRAM_ACTOR_SHA256=
141
148
  # APPROVAL_IPHONE_ACTOR_SHA256=
149
+ # Signature domain prefixes accepted for approval decisions; the first is preferred.
150
+ # Add a legacy signer domain (for example legacy.approval) to interoperate with older signers.
151
+ # MAGNUS_APPROVAL_ASSERTION_DOMAINS=magnus.approval-assertion
142
152
  # Read-only digests for an external observability collector and the fleet-check reporter.
143
153
  # MAGNUS_COLLECTOR_TOKEN_SHA256=
144
154
  # MAGNUS_FLEET_CHECK_TOKEN_SHA256=
package/README.md CHANGED
@@ -132,6 +132,21 @@ Unknown destinations and changed keys fail before authentication. Multiple
132
132
  pins permit a reviewed host-key rotation; remove the old pin afterward.
133
133
  This setting does not enable the retired browser deployment routes.
134
134
 
135
+ ## Interoperating with older components
136
+
137
+ Every switch below is off or set to current behaviour by default. Set them only
138
+ while older clients, signers or scripts still talk to this server.
139
+
140
+ | Variable | Default | Purpose |
141
+ | --- | --- | --- |
142
+ | `MAGNUS_APPROVAL_ASSERTION_DOMAINS` | `magnus.approval-assertion` | Comma-separated signature domain prefixes accepted for signed approval decisions (the signed message starts with `<prefix>.v<N>`). Add a legacy signer domain, for example `magnus.approval-assertion,legacy.approval`. The first entry is preferred. Allowed characters: `a-z`, `0-9`, `.` and `-`. |
143
+ | `MAGNUS_ROUTE_ALIASES_JSON` | unset | JSON object mapping legacy `/api/v1` path prefixes to current ones, for example `{"/api/v1/resource-leases/old-name/admission": "/api/v1/resource-leases/admission"}`. Requests are rewritten before routing, so authentication, endpoint scopes, rate limits and audit see the current path. Aliases cannot chain or contain `.`/`..` segments. |
144
+ | `MAGNUS_WEBHOOK_TYPE_ALIASES` | unset | Comma-separated `legacy=format` pairs, for example `legacy-chat=mattermost`, so webhook endpoints stored or submitted under an older type name keep their delivery format. New and edited endpoints store the current format. |
145
+
146
+ Older Hermes Agent plugins also need `MAGNUS_PLUGIN_API_ENABLED=true`, and an
147
+ older approval bridge needs `MAGNUS_APPROVAL_BRIDGE_CONSUMER`. See the
148
+ [changelog](../../CHANGELOG.md) for renamed variables.
149
+
135
150
  ## Develop
136
151
 
137
152
  From the repository root:
@@ -1 +1 @@
1
- {"version":"1.0.2"}
1
+ {"version":"1.0.3"}
@@ -3,6 +3,8 @@ export const ENV_CATALOG = [
3
3
  // ── Core ──
4
4
  { name: 'NODE_ENV', group: 'core', default: 'development', description: 'development, production or test. Production enforces TLS, DATABASE_URL and strict CORS.' },
5
5
  { name: 'LOG_LEVEL', group: 'core', default: 'info', description: 'debug, info, warn, error or fatal.' },
6
+ { name: 'MAGNUS_ROUTE_ALIASES_JSON', group: 'core', description: 'Optional JSON object mapping legacy /api/v1 path prefixes to current ones, for clients built against older route names. Requests are rewritten before routing, so auth and audit see the current path. Unset means no aliases.' },
7
+ { name: 'MAGNUS_WEBHOOK_TYPE_ALIASES', group: 'core', description: 'Optional comma-separated legacy=format pairs (format: generic, slack, discord or mattermost) so webhook endpoints stored or submitted under an older type name keep their delivery format.' },
6
8
  { name: 'MAGNUS_HOME', group: 'paths', default: '~/.magnus', description: 'Base directory for server-owned data (audit logs, credential rotation state, backups).' },
7
9
  { name: 'MAGNUS_ENV_FILE', group: 'paths', description: 'Env file loaded at startup (set by `--env-file`). Variables already in the environment win.' },
8
10
  { name: 'AUDIT_LOG_DIR', group: 'paths', default: '$MAGNUS_HOME/audit-logs', description: 'Directory for the append-only audit log files.' },
@@ -81,6 +83,7 @@ export const ENV_CATALOG = [
81
83
  { name: 'APPROVAL_SIGNER_PUBLIC_KEY_PEM', group: 'plugins', description: 'Ed25519 public key (PEM) that signs approval decisions. _B64 (DER) and _PATH variants are also accepted.' },
82
84
  { name: 'APPROVAL_TELEGRAM_ACTOR_SHA256', group: 'plugins', description: 'Actor binding digest for decisions from the Telegram channel.' },
83
85
  { name: 'APPROVAL_IPHONE_ACTOR_SHA256', group: 'plugins', description: 'Actor binding digest for decisions from the iPhone channel.' },
86
+ { name: 'MAGNUS_APPROVAL_ASSERTION_DOMAINS', group: 'plugins', default: 'magnus.approval-assertion', description: 'Comma-separated signature domain prefixes accepted for approval decisions (signed message is <prefix>.v<N>). The first is preferred; add a legacy signer domain to keep older signers working.' },
84
87
  { name: 'MAGNUS_COLLECTOR_TOKEN_SHA256', group: 'plugins', description: 'SHA-256 digest of a read-only external observability collector credential.' },
85
88
  { name: 'MAGNUS_FLEET_CHECK_TOKEN_SHA256', group: 'plugins', description: 'SHA-256 digest of the fleet-check reporter credential.' },
86
89
  // ── Hermes registry ──
@@ -1,5 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import path from 'node:path';
3
+ import { parseRouteAliases } from './route-aliases.js';
4
+ import { webhookTypeAliases } from '../services/webhook-types.js';
3
5
  // ─── Shared environment schema — validated with Zod ───
4
6
  const portSchema = z.coerce.number().int().min(1).max(65535);
5
7
  const booleanEnvSchema = z.enum(['true', 'false']).default('false').transform((value) => value === 'true');
@@ -48,6 +50,10 @@ export const envSchema = z.object({
48
50
  APPROVAL_TELEGRAM_ACTOR_SHA256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
49
51
  APPROVAL_IPHONE_ACTOR_SHA256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
50
52
  MAGNUS_APPROVAL_BRIDGE_CONSUMER: z.string().regex(/^[a-z0-9][a-z0-9._-]{0,63}$/i).optional(),
53
+ // Accepted approval-assertion signature domain prefixes (comma-separated); first is preferred.
54
+ MAGNUS_APPROVAL_ASSERTION_DOMAINS: z.string()
55
+ .regex(/^\s*[a-z0-9](?:[a-z0-9.-]{0,98}[a-z0-9])?\s*(?:,\s*[a-z0-9](?:[a-z0-9.-]{0,98}[a-z0-9])?\s*)*$/, 'must list domain prefixes made of a-z, 0-9, "." and "-", separated by commas')
56
+ .optional(),
51
57
  // Security Fusion: off (default) | local (in-process) | remote (external security console).
52
58
  SECURITY_FUSION_MODE: z.enum(['off', 'local', 'remote']).default('off'),
53
59
  SECURITY_FUSION_API_URL: z.string().url().optional(),
@@ -151,7 +157,27 @@ export const envSchema = z.object({
151
157
  HERMES_HIVEMEM_WORKSPACE_ID: z.string().optional(),
152
158
  HERMES_HIVEMEM_DEFAULT_SCOPE: z.enum(['private', 'shared']).optional(),
153
159
  HERMES_OPENAI_RUNTIME: z.enum(['auto', 'codex_app_server']).optional(),
160
+ // Optional legacy-path -> current-path prefix map for older clients (see config/route-aliases.ts).
161
+ MAGNUS_ROUTE_ALIASES_JSON: z.string().optional(),
162
+ // Optional legacy webhook type names: comma-separated legacy=format pairs.
163
+ MAGNUS_WEBHOOK_TYPE_ALIASES: z.string()
164
+ .regex(/^\s*(?:[a-z][a-z0-9_-]{0,31}\s*=\s*(?:generic|slack|discord|mattermost)\s*(?:,\s*|$))*$/, 'must list legacy=format pairs, where format is generic, slack, discord or mattermost')
165
+ .optional(),
154
166
  }).superRefine((env, ctx) => {
167
+ try {
168
+ webhookTypeAliases(env.MAGNUS_WEBHOOK_TYPE_ALIASES);
169
+ }
170
+ catch (error) {
171
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['MAGNUS_WEBHOOK_TYPE_ALIASES'],
172
+ message: error instanceof Error ? error.message : 'MAGNUS_WEBHOOK_TYPE_ALIASES is invalid' });
173
+ }
174
+ try {
175
+ parseRouteAliases(env.MAGNUS_ROUTE_ALIASES_JSON);
176
+ }
177
+ catch (error) {
178
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['MAGNUS_ROUTE_ALIASES_JSON'],
179
+ message: error instanceof Error ? error.message : 'MAGNUS_ROUTE_ALIASES_JSON is invalid' });
180
+ }
155
181
  if (env.HEALTH_SNAPSHOT_FULL_FIDELITY_HOURS > env.HEALTH_SNAPSHOT_RETENTION_DAYS * 24) {
156
182
  ctx.addIssue({
157
183
  code: z.ZodIssueCode.custom,
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Optional request path aliases for clients built against older route names.
3
+ *
4
+ * `MAGNUS_ROUTE_ALIASES_JSON` is a JSON object that maps a legacy path prefix
5
+ * to the current one, for example
6
+ * `{"/api/v1/old/prefix": "/api/v1/new/prefix"}`. A request whose path equals a
7
+ * legacy prefix, or continues it after a `/`, is rewritten before routing, so
8
+ * authentication, endpoint scopes, rate limits and audit all see the current
9
+ * path. Unset (the default) means no aliases.
10
+ */
11
+ const segment = '[A-Za-z0-9._~-]+';
12
+ const aliasPath = new RegExp(`^/api/v1(?:/${segment})+$`);
13
+ function validPath(path) {
14
+ return aliasPath.test(path) && !path.split('/').some(part => part === '.' || part === '..');
15
+ }
16
+ /** Parse and validate the alias map; throws a descriptive error when it is malformed. */
17
+ export function parseRouteAliases(raw) {
18
+ if (raw === undefined || raw.trim() === '')
19
+ return [];
20
+ let value;
21
+ try {
22
+ value = JSON.parse(raw);
23
+ }
24
+ catch {
25
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON must be a JSON object');
26
+ }
27
+ if (!value || typeof value !== 'object' || Array.isArray(value))
28
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON must be a JSON object');
29
+ const entries = Object.entries(value);
30
+ if (entries.length > 64)
31
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON may define at most 64 aliases');
32
+ const aliases = [];
33
+ for (const [from, to] of entries) {
34
+ if (typeof to !== 'string' || !validPath(from) || !validPath(to))
35
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON keys and values must be /api/v1/... paths without "." or ".." segments');
36
+ if (from === to)
37
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON maps a path to itself');
38
+ aliases.push({ from, to });
39
+ }
40
+ for (const { to } of aliases)
41
+ if (aliases.some(alias => to === alias.from || to.startsWith(`${alias.from}/`)))
42
+ throw new Error('MAGNUS_ROUTE_ALIASES_JSON targets must not be aliases themselves');
43
+ // Longest prefix first, so a more specific alias wins.
44
+ return aliases.sort((a, b) => b.from.length - a.from.length);
45
+ }
46
+ /** The current path for `path`, or null when no alias applies. */
47
+ export function resolveRouteAlias(path, aliases) {
48
+ for (const { from, to } of aliases) {
49
+ if (path === from)
50
+ return to;
51
+ if (path.startsWith(`${from}/`))
52
+ return to + path.slice(from.length);
53
+ }
54
+ return null;
55
+ }
56
+ /** Wrap a fetch handler so legacy paths reach their current routes. Returns `fetch` unchanged without aliases. */
57
+ export function withRouteAliases(fetch, aliases) {
58
+ if (!aliases.length)
59
+ return fetch;
60
+ return (request, env, context) => {
61
+ const url = new URL(request.url);
62
+ const target = resolveRouteAlias(url.pathname, aliases);
63
+ if (target === null)
64
+ return fetch(request, env, context);
65
+ url.pathname = target;
66
+ const init = {
67
+ method: request.method, headers: request.headers, redirect: request.redirect, signal: request.signal,
68
+ ...(request.body ? { body: request.body, duplex: 'half' } : {}),
69
+ };
70
+ return fetch(new Request(url, init), env, context);
71
+ };
72
+ }
73
+ //# sourceMappingURL=route-aliases.js.map
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
  });
@@ -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.2",
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,7 +84,7 @@
84
84
  "prepublishOnly": "npm run db:generate && npm run clean && npm run build"
85
85
  },
86
86
  "dependencies": {
87
- "@hivemagnus/magnus-contracts": "1.0.2",
87
+ "@hivemagnus/magnus-contracts": "1.0.3",
88
88
  "@hono/node-server": "^2.1.5",
89
89
  "@hono/swagger-ui": "^0.6.2",
90
90
  "@prisma/adapter-pg": "7.10.1",
@@ -102,7 +102,7 @@
102
102
  "ws": "^8.22.0",
103
103
  "yaml": "2.9.1",
104
104
  "zod": "^4.6.7",
105
- "@hivemagnus/magnus-skills": "1.0.2",
105
+ "@hivemagnus/magnus-skills": "1.0.3",
106
106
  "prisma": "^7.10.1"
107
107
  },
108
108
  "devDependencies": {