@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 +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/index.js +4 -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 +3 -3
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:
|
package/agent-version.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"1.0.
|
|
1
|
+
{"version":"1.0.3"}
|
package/dist/cli/env-catalog.js
CHANGED
|
@@ -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 ──
|
package/dist/config/env.js
CHANGED
|
@@ -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:
|
|
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
|
});
|
|
@@ -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,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.
|
|
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.
|
|
105
|
+
"@hivemagnus/magnus-skills": "1.0.3",
|
|
106
106
|
"prisma": "^7.10.1"
|
|
107
107
|
},
|
|
108
108
|
"devDependencies": {
|