@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 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.1"}
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