@hivemagnus/magnus-server 2.0.0 → 2.1.0

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.
Files changed (73) hide show
  1. package/.env.example +32 -0
  2. package/README.md +32 -0
  3. package/agent-version.json +1 -1
  4. package/dist/ai/TOOLS.md +1 -1
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/env-catalog.js +13 -0
  7. package/dist/cli/migrations.js +22 -0
  8. package/dist/cli/verify-initialization.js +12 -0
  9. package/dist/cli.js +28 -0
  10. package/dist/config/console-path.js +17 -0
  11. package/dist/config/env.js +28 -0
  12. package/dist/config/features.js +3 -0
  13. package/dist/config/oathweave.js +42 -0
  14. package/dist/config/rehearsal.js +22 -0
  15. package/dist/db/generated/prisma/internal/class.js +4 -4
  16. package/dist/db/generated/prisma/internal/prismaNamespace.js +5 -0
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +5 -0
  18. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  19. package/dist/db/read-retry.js +5 -1
  20. package/dist/db/seed.js +8 -0
  21. package/dist/index.js +27 -21
  22. package/dist/middleware/auth.js +21 -0
  23. package/dist/middleware/error-handler.js +10 -10
  24. package/dist/middleware/oathweave-verifier.js +23 -0
  25. package/dist/middleware/oathweave.js +115 -0
  26. package/dist/middleware/rate-limit.js +12 -3
  27. package/dist/openapi/spec.js +38 -2
  28. package/dist/queue/names.js +9 -0
  29. package/dist/queue/workers.js +9 -3
  30. package/dist/routes/assistant.js +30 -12
  31. package/dist/routes/console.js +33 -0
  32. package/dist/routes/cron.js +8 -5
  33. package/dist/routes/events-stream.js +10 -0
  34. package/dist/routes/files.js +2 -1
  35. package/dist/routes/fleet-gateway.js +69 -0
  36. package/dist/routes/hermes.js +26 -6
  37. package/dist/routes/hiveskills.js +116 -0
  38. package/dist/routes/library.js +3 -2
  39. package/dist/routes/logs.js +5 -0
  40. package/dist/routes/metrics.js +4 -3
  41. package/dist/routes/nodes.js +45 -8
  42. package/dist/routes/ops.js +24 -6
  43. package/dist/routes/profiling.js +2 -1
  44. package/dist/routes/roles.js +17 -9
  45. package/dist/routes/stats.js +8 -5
  46. package/dist/routes/terminal-ticket.js +3 -3
  47. package/dist/routes/terminal.js +2 -2
  48. package/dist/routes/webhooks.js +15 -15
  49. package/dist/sdk/node-api-client.js +11 -1
  50. package/dist/security/native-completion-registry.js +3 -1
  51. package/dist/server.js +8 -0
  52. package/dist/services/ai-assistant-service.js +32 -6
  53. package/dist/services/ai-tools.js +15 -8
  54. package/dist/services/ardent-scope-enrollment.js +4 -2
  55. package/dist/services/forecast-service.js +23 -8
  56. package/dist/services/ops-service.js +20 -25
  57. package/dist/services/prediction-service.js +2 -1
  58. package/dist/services/resource-enrollment.js +57 -4
  59. package/dist/services/secret-views.js +72 -0
  60. package/dist/services/topology-service.js +2 -1
  61. package/dist/services/webhook-security.js +10 -1
  62. package/dist/services/webhook-service.js +14 -6
  63. package/dist/utils/node-url.js +2 -1
  64. package/dist/utils/safe-errors.js +130 -0
  65. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  66. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  67. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  68. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  69. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  70. package/package.json +7 -3
  71. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  72. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  73. package/prisma/schema.prisma +9 -0
@@ -3,7 +3,7 @@ import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
3
3
  import { z } from 'zod';
4
4
  import { getDb } from '../db/index.js';
5
5
  import { HttpError } from '../middleware/error-handler.js';
6
- import { apiDrainAttestationSchema, apiDrainAttestationSigningPayload, canonical, dashboardEnrollmentSecret, drainReceiptSchema, enrollmentSigningPayload } from './resource-enrollment.js';
6
+ import { apiDrainAttestationSchema, apiDrainAttestationSigningPayload, canonical, assertDashboardStillAbsent, dashboardReceiptKey, drainReceiptSchema, enrollmentSigningPayload } from './resource-enrollment.js';
7
7
  import { lockResourceNode, resourceNodeIdentity } from './resource-node-lock.js';
8
8
  import { reconciliationSecret } from './resource-participant-work.js';
9
9
  import { RESULT_SIGNING_SECRET } from '../utils/env.js';
@@ -52,8 +52,10 @@ async function receiptHashes(tx, nodeId, nodeFqdn, epoch, barrierId) {
52
52
  fail();
53
53
  }
54
54
  const { signature, ...unsigned } = receipt;
55
+ if (participant === 'dashboard')
56
+ await assertDashboardStillAbsent(tx, nodeId);
55
57
  const key = participant === 'api' ? reconciliationSecret()
56
- : participant === 'dashboard' ? dashboardEnrollmentSecret() : RESULT_SIGNING_SECRET;
58
+ : participant === 'dashboard' ? dashboardReceiptKey() : RESULT_SIGNING_SECRET;
57
59
  const payload = participant === 'api'
58
60
  ? apiDrainAttestationSigningPayload(unsigned)
59
61
  : enrollmentSigningPayload(unsigned);
@@ -1,4 +1,5 @@
1
1
  import { getDb } from '../db/index.js';
2
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
2
3
  function linearRegression(points) {
3
4
  const n = points.length;
4
5
  if (n < 2)
@@ -10,13 +11,17 @@ function linearRegression(points) {
10
11
  const denom = n * sumX2 - sumX ** 2;
11
12
  if (denom === 0)
12
13
  return { slope: 0, intercept: sumY / n, r2: 0 };
13
- const slope = (n * sumXY - sumX * sumY) / denom;
14
- const intercept = (sumY - slope * sumX) / n;
15
14
  // R² for confidence
16
15
  const yMean = sumY / n;
17
16
  const ssTot = points.reduce((a, p) => a + (p.y - yMean) ** 2, 0);
17
+ // Identical samples have no trend. The formula below would still answer with a
18
+ // rounding residue, which reads as a tiny growth rate at full confidence.
18
19
  if (ssTot === 0)
19
- return { slope, intercept, r2: 1 };
20
+ return { slope: 0, intercept: yMean, r2: 1 };
21
+ const slope = (n * sumXY - sumX * sumY) / denom;
22
+ const intercept = (sumY - slope * sumX) / n;
23
+ if (!Number.isFinite(slope))
24
+ return { slope: 0, intercept: yMean, r2: 0 };
20
25
  const ssRes = points.reduce((a, p) => a + (p.y - (slope * p.x + intercept)) ** 2, 0);
21
26
  const r2 = 1 - ssRes / ssTot;
22
27
  return { slope, intercept, r2 };
@@ -26,6 +31,10 @@ const MS_PER_DAY = 86_400_000;
26
31
  const MIN_DATA_POINTS = 3;
27
32
  const MIN_FORECAST_CONFIDENCE = 0.25;
28
33
  const DEFAULT_LOOKBACK_DAYS = 30;
34
+ /** Samples closer together than this are a burst, not a growth trend. */
35
+ const MIN_TREND_SPAN_MS = 60 * 60 * 1000;
36
+ /** A fill date further out than this says nothing useful, and far enough out it is not a valid Date at all. */
37
+ const MAX_FORECAST_DAYS = 36_500;
29
38
  const SYSTEM_MOUNT_PREFIXES = ['/boot', '/snap', '/var/lib/docker/', '/var/lib/containers/'];
30
39
  const READ_ONLY_FILESYSTEMS = new Set(['iso9660', 'squashfs']);
31
40
  function isForecastableVolume(disk) {
@@ -103,15 +112,20 @@ export async function forecastDisk(nodeId, lookbackDays = DEFAULT_LOOKBACK_DAYS)
103
112
  x: (s.timestamp - t0) / MS_PER_DAY,
104
113
  y: s.usedBytes,
105
114
  }));
106
- const reg = linearRegression(points);
115
+ const reg = latest.timestamp - t0 >= MIN_TREND_SPAN_MS
116
+ ? linearRegression(points)
117
+ : { slope: 0, intercept: latest.usedBytes, r2: 0 };
107
118
  const growthRatePerDay = reg.slope; // bytes per day
108
119
  let estimatedFullDate = null;
109
120
  let daysUntilFull = null;
110
121
  if (growthRatePerDay > 0 && reg.r2 >= MIN_FORECAST_CONFIDENCE) {
111
- const availableBytes = totalBytes - latest.usedBytes;
112
- daysUntilFull = Math.ceil(availableBytes / growthRatePerDay);
113
- const fullDate = new Date(Date.now() + daysUntilFull * MS_PER_DAY);
114
- estimatedFullDate = fullDate.toISOString().split('T')[0] ?? null;
122
+ const availableBytes = Math.max(0, totalBytes - latest.usedBytes);
123
+ const days = Math.ceil(availableBytes / growthRatePerDay);
124
+ if (days <= MAX_FORECAST_DAYS) {
125
+ daysUntilFull = days;
126
+ const fullDate = new Date(Date.now() + days * MS_PER_DAY);
127
+ estimatedFullDate = fullDate.toISOString().split('T')[0] ?? null;
128
+ }
115
129
  }
116
130
  forecasts.push({
117
131
  mountPoint,
@@ -129,6 +143,7 @@ export async function forecastDisk(nodeId, lookbackDays = DEFAULT_LOOKBACK_DAYS)
129
143
  export async function forecastAtRisk(daysThreshold = 30) {
130
144
  const db = getDb();
131
145
  const nodes = await db.node.findMany({
146
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
132
147
  select: { id: true, hostname: true },
133
148
  });
134
149
  const results = [];
@@ -9,6 +9,8 @@ import { getRateLimitHealth } from '../middleware/rate-limit.js';
9
9
  import { listHermesInstallations } from './hermes-service.js';
10
10
  import { getHealthRetentionCutoffs, getHealthRetentionPolicy, getRetentionPolicyReadiness } from './health-retention.js';
11
11
  import { getNotificationRetentionPolicy } from './notification-retention.js';
12
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
13
+ import { databaseFailure, describeTransportFailure } from '../utils/safe-errors.js';
12
14
  const STALE_THRESHOLD_MS = Number(process.env['STALE_NODE_THRESHOLD_MS'] ?? 120_000);
13
15
  function fingerprint(value) {
14
16
  if (!value)
@@ -24,26 +26,10 @@ async function timed(fn) {
24
26
  catch (err) {
25
27
  return {
26
28
  latencyMs: Math.round((performance.now() - started) * 100) / 100,
27
- error: err instanceof Error ? err.message : String(err),
29
+ error: databaseFailure(err)?.message ?? describeTransportFailure(err),
28
30
  };
29
31
  }
30
32
  }
31
- function describeDependencyError(error) {
32
- if (!(error instanceof Error))
33
- return String(error);
34
- const cause = error.cause;
35
- if (cause && typeof cause === 'object') {
36
- const code = 'code' in cause && typeof cause.code === 'string' ? cause.code : undefined;
37
- const message = 'message' in cause && typeof cause.message === 'string' ? cause.message : undefined;
38
- if (code && message)
39
- return `${error.message} (${code}: ${message})`;
40
- if (code)
41
- return `${error.message} (${code})`;
42
- if (message)
43
- return `${error.message} (${message})`;
44
- }
45
- return error.message;
46
- }
47
33
  export function resolveRouterHealthUrl(routerUrl, explicitHealthUrl) {
48
34
  if (explicitHealthUrl)
49
35
  return explicitHealthUrl;
@@ -70,7 +56,7 @@ export async function checkHttp(name, url, timeoutMs = 2500) {
70
56
  return {
71
57
  name,
72
58
  status: 'warn',
73
- message: describeDependencyError(error),
59
+ message: describeTransportFailure(error),
74
60
  latencyMs: Math.round((performance.now() - started) * 100) / 100,
75
61
  };
76
62
  }
@@ -143,10 +129,17 @@ export async function getDependencyHealth() {
143
129
  const status = failCount > 0 ? 'unhealthy' : warnCount > 0 ? 'degraded' : 'healthy';
144
130
  return { status, checks, generatedAt: new Date().toISOString() };
145
131
  }
146
- export async function getFleetReadiness() {
132
+ /**
133
+ * Fleet readiness. Secret fingerprints are opt-in, and only an ADMIN response
134
+ * asks for them: every other caller, including assistant tools and MCP, sees
135
+ * whether a node's credential is current without the value that identifies it.
136
+ */
137
+ export async function getFleetReadiness(options = {}) {
138
+ const includeSecretFingerprints = options.includeSecretFingerprints === true;
147
139
  const db = getDb();
148
140
  const [nodes, latestHealthSnapshots] = await withReadRetry('fleet readiness', () => Promise.all([
149
141
  db.node.findMany({
142
+ where: { id: { notIn: SENTINEL_NODE_IDS } },
150
143
  orderBy: { name: 'asc' },
151
144
  include: {
152
145
  agent: true,
@@ -171,10 +164,12 @@ export async function getFleetReadiness() {
171
164
  return {
172
165
  generatedAt: new Date().toISOString(),
173
166
  thresholds: { staleNodeMs: STALE_THRESHOLD_MS },
174
- expectedFingerprints: {
175
- nodeManagerSecret: fingerprint(getNodeManagerSecret()),
176
- apiSecret: fingerprint(API_SECRET),
177
- },
167
+ ...(includeSecretFingerprints ? {
168
+ expectedFingerprints: {
169
+ nodeManagerSecret: fingerprint(getNodeManagerSecret()),
170
+ apiSecret: fingerprint(API_SECRET),
171
+ },
172
+ } : {}),
178
173
  nodes: nodes.map((node) => {
179
174
  const heartbeatAgeMs = node.lastHeartbeatAt ? now - node.lastHeartbeatAt.getTime() : null;
180
175
  const stale = heartbeatAgeMs === null || heartbeatAgeMs > STALE_THRESHOLD_MS;
@@ -217,9 +212,9 @@ export async function getFleetReadiness() {
217
212
  lastDetectedAt: hermes.lastDetectedAt?.toISOString() ?? null,
218
213
  } : null,
219
214
  secretFingerprint: {
220
- expectedNodeManagerSecret,
221
- observedNodeManagerSecret,
215
+ ...(includeSecretFingerprints ? { expectedNodeManagerSecret, observedNodeManagerSecret } : {}),
222
216
  match: observedNodeManagerSecret === null ? 'unknown' : observedNodeManagerSecret === expectedNodeManagerSecret ? 'match' : 'mismatch',
217
+ state: typeof metadata['nodeManagerSecretState'] === 'string' ? metadata['nodeManagerSecretState'] : 'unknown',
223
218
  },
224
219
  };
225
220
  }),
@@ -8,6 +8,7 @@
8
8
  * Run after each heartbeat, alongside the existing z-score anomaly detection.
9
9
  */
10
10
  import { getDb } from '../db/index.js';
11
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
11
12
  import { createNotification } from './notification-service.js';
12
13
  import { eventBus, EventChannels } from './event-bus.js';
13
14
  // ── Configuration ──
@@ -249,7 +250,7 @@ export async function getNodePredictions(nodeId) {
249
250
  export async function getFleetPredictions() {
250
251
  const db = getDb();
251
252
  const nodes = await db.node.findMany({
252
- where: { status: 'ONLINE' },
253
+ where: { id: { notIn: SENTINEL_NODE_IDS }, status: 'ONLINE' },
253
254
  select: { id: true, name: true, hostname: true },
254
255
  });
255
256
  const atRisk = [];
@@ -45,6 +45,26 @@ export function dashboardEnrollmentSecret() {
45
45
  throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNCONFIGURED', 'Dedicated dashboard participant credential is required');
46
46
  return key;
47
47
  }
48
+ /** Who answers console drain challenges. Anything but an explicit `absent` needs a real gateway. */
49
+ export function dashboardParticipantAbsent() {
50
+ return process.env['RESOURCE_ENROLLMENT_DASHBOARD_PARTICIPANT'] === 'absent';
51
+ }
52
+ /**
53
+ * The key that signs stored console evidence. In absent mode no participant exists to hold a shared
54
+ * key, so this server signs its own finding. Changing mode invalidates evidence from the other mode.
55
+ */
56
+ export function dashboardReceiptKey() {
57
+ if (!dashboardParticipantAbsent())
58
+ return dashboardEnrollmentSecret();
59
+ if (RESULT_SIGNING_SECRET.length < 32)
60
+ throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNCONFIGURED', 'Result signing credential is required');
61
+ return RESULT_SIGNING_SECRET;
62
+ }
63
+ /** Absent mode holds only while no console work was ever recorded on this node, in any state. */
64
+ export async function assertDashboardStillAbsent(tx, nodeId) {
65
+ if (dashboardParticipantAbsent() && await tx.resourceParticipantWork.count({ where: { nodeId, participant: 'dashboard' } }))
66
+ fail('RESOURCE_DRAIN_INCOMPLETE', 'Console work exists on this node, so the console participant cannot be declared absent');
67
+ }
48
68
  export function verifyDashboardCredential(token) {
49
69
  const expected = dashboardEnrollmentSecret();
50
70
  const received = Buffer.from(token);
@@ -243,6 +263,8 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
243
263
  if (participant === 'api')
244
264
  return { participant, status: 'pending', reason: 'signed-reviewed-evidence-required',
245
265
  descriptor: prepared.descriptor };
266
+ if (participant === 'dashboard' && dashboardParticipantAbsent())
267
+ return recordAbsentDashboard(nodeId, prepared.descriptor);
246
268
  let receipt;
247
269
  try {
248
270
  let url;
@@ -260,10 +282,16 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
260
282
  }
261
283
  else {
262
284
  key = dashboardEnrollmentSecret();
263
- const origin = new URL(process.env['DASHBOARD_PUBLIC_ORIGIN'] ?? '');
285
+ let origin;
286
+ try {
287
+ origin = new URL(process.env['FLEET_GATEWAY_URL'] ?? '');
288
+ }
289
+ catch {
290
+ throw new ParticipantEvidenceError('gateway-origin-unavailable');
291
+ }
264
292
  if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
265
- throw new ParticipantEvidenceError('dashboard-origin-unavailable');
266
- url = origin.origin + '/api/internal/resource-enrollment/drain';
293
+ throw new ParticipantEvidenceError('gateway-origin-unavailable');
294
+ url = origin.origin + '/internal/resource-enrollment/drain';
267
295
  headers = { 'x-resource-drain-key': key };
268
296
  }
269
297
  const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(participant !== 'dashboard' ? 25_000 : 5_000),
@@ -316,6 +344,29 @@ export async function refreshEnrollmentParticipant(nodeId, participant) {
316
344
  && !schedulerUnsettled && !central.activeCount && !central.unknownCount ? 'reviewed-boundary-required' : 'unsettled-work' };
317
345
  });
318
346
  }
347
+ /** No participant exists to ask, so this server records what its own ledger shows, or refuses. */
348
+ async function recordAbsentDashboard(nodeId, descriptor) {
349
+ const key = dashboardReceiptKey();
350
+ return getDb().$transaction(async (tx) => {
351
+ await lockResourceNode(tx, nodeId);
352
+ const { row } = await identity(tx, nodeId);
353
+ const ack = await tx.resourceDrainParticipant.findUnique({ where: { nodeId_participant: { nodeId, participant: 'dashboard' } } });
354
+ if (row?.state !== 'draining' || row.epoch !== descriptor.epoch || row.barrierId !== descriptor.barrierId || ack?.challengeId !== descriptor.challengeId)
355
+ fail('RESOURCE_DRAIN_STALE', 'Participant evidence no longer matches the current drain');
356
+ if (await tx.resourceParticipantWork.count({ where: { nodeId, participant: 'dashboard' } })) {
357
+ failures.set(`${nodeId}\ndashboard`, { participant: 'dashboard', cause: 'dashboard-work-exists', agentCode: null, agentReason: null, at: new Date().toISOString() });
358
+ return { participant: 'dashboard', status: 'pending', reason: 'dashboard-work-exists' };
359
+ }
360
+ failures.delete(`${nodeId}\ndashboard`);
361
+ const { evidenceSha256 } = await participantSnapshot(tx, nodeId, 'dashboard');
362
+ const unsigned = { ...descriptor, status: 'drained', activeCount: 0, unknownCount: 0, evidenceSha256 };
363
+ const receipt = { ...unsigned, signature: createHmac('sha256', key).update(enrollmentSigningPayload(unsigned)).digest('hex') };
364
+ await tx.resourceDrainParticipant.update({ where: { nodeId_participant: { nodeId, participant: 'dashboard' } }, data: {
365
+ status: 'verified', evidenceSha256, receiptJson: JSON.stringify(receipt), verifiedAt: new Date()
366
+ } });
367
+ return { participant: 'dashboard', status: 'verified', reason: 'declared-absent-no-work' };
368
+ });
369
+ }
319
370
  export async function acceptSchedulerBoundaryAttestation(nodeId, raw) {
320
371
  const assertion = schedulerBoundaryAttestationSchema.parse(raw);
321
372
  const { signature, ...unsigned } = assertion;
@@ -449,7 +500,9 @@ export async function completeEnrollment(nodeId) {
449
500
  fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored participant receipt is unavailable');
450
501
  }
451
502
  const { signature, ...unsigned } = receipt;
452
- const key = participant === 'dashboard' ? dashboardEnrollmentSecret()
503
+ if (participant === 'dashboard')
504
+ await assertDashboardStillAbsent(tx, nodeId);
505
+ const key = participant === 'dashboard' ? dashboardReceiptKey()
453
506
  : participant === 'api' ? reconciliationSecret() : RESULT_SIGNING_SECRET;
454
507
  const payload = participant === 'api'
455
508
  ? apiDrainAttestationSigningPayload(unsigned)
@@ -0,0 +1,72 @@
1
+ /**
2
+ * What each role may see of values written to hosts and of secret fingerprints.
3
+ * Below ADMIN a response names environment keys but never carries their values,
4
+ * and reports a credential's state but never its fingerprint.
5
+ */
6
+ function parseRecord(raw) {
7
+ try {
8
+ const value = JSON.parse(raw);
9
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : {};
10
+ }
11
+ catch {
12
+ return {};
13
+ }
14
+ }
15
+ export function environmentKeys(raw) {
16
+ return Object.keys(parseRecord(raw)).sort();
17
+ }
18
+ /**
19
+ * Apply a non-admin's edit to stored environment values. Such a client only ever
20
+ * saw key names, so a key it left out or left blank means "unchanged", not
21
+ * "delete". Sending back a redacted response therefore cannot wipe a value, and
22
+ * removing a key takes an ADMIN, who can see what is being removed.
23
+ */
24
+ export function mergeEnvironmentEdit(existing, incoming) {
25
+ const merged = {};
26
+ for (const [key, value] of Object.entries(parseRecord(existing))) {
27
+ if (typeof value === 'string')
28
+ merged[key] = value;
29
+ }
30
+ for (const [key, value] of Object.entries(incoming)) {
31
+ if (value === '' && key in merged)
32
+ continue;
33
+ merged[key] = value;
34
+ }
35
+ return merged;
36
+ }
37
+ const FINGERPRINT_METADATA_KEYS = ['nodeManagerSecretFingerprint'];
38
+ /** Node metadata as a JSON string, with secret fingerprints removed below ADMIN. */
39
+ export function nodeMetadataForRole(metadata, role) {
40
+ if (role === 'ADMIN')
41
+ return metadata;
42
+ if (!FINGERPRINT_METADATA_KEYS.some((key) => metadata.includes(key)))
43
+ return metadata;
44
+ const visible = parseRecord(metadata);
45
+ for (const key of FINGERPRINT_METADATA_KEYS)
46
+ delete visible[key];
47
+ return JSON.stringify(visible);
48
+ }
49
+ /** A node row for a response: never its token, and metadata fit for the role. */
50
+ export function nodeForRole(node, role) {
51
+ const { token: _token, ...safe } = node;
52
+ return { ...safe, metadata: nodeMetadataForRole(node.metadata, role) };
53
+ }
54
+ /** A cron job for a response: environment values for ADMIN, key names for everyone. */
55
+ export function cronJobForRole(job, role) {
56
+ const { environment, ...rest } = job;
57
+ return {
58
+ ...rest,
59
+ ...(role === 'ADMIN' ? { environment } : {}),
60
+ environmentKeys: environmentKeys(environment),
61
+ };
62
+ }
63
+ /** A Hermes profile for a response: whether a key is set, and its fingerprint only for ADMIN. */
64
+ export function hermesProfileForRole(profile, role) {
65
+ const { apiServerKeyFingerprint, ...rest } = profile;
66
+ return {
67
+ ...rest,
68
+ ...(role === 'ADMIN' ? { apiServerKeyFingerprint: apiServerKeyFingerprint ?? null } : {}),
69
+ apiServerKeyConfigured: Boolean(apiServerKeyFingerprint),
70
+ };
71
+ }
72
+ //# sourceMappingURL=secret-views.js.map
@@ -8,6 +8,7 @@
8
8
  * - Gateway detection (subnet with most cross-subnet connections)
9
9
  */
10
10
  import { getDb } from '../db/index.js';
11
+ import { SENTINEL_NODE_IDS } from '../utils/sentinel-nodes.js';
11
12
  import { connect } from 'node:net';
12
13
  // ── Helpers ──
13
14
  function getSubnet(ip) {
@@ -61,7 +62,7 @@ function probeNode(host, port, timeoutMs = 3000) {
61
62
  export async function scanTopology(probeTimeoutMs = 3000) {
62
63
  const db = getDb();
63
64
  const nodes = await db.node.findMany({
64
- where: { status: { not: 'DECOMMISSIONED' } },
65
+ where: { id: { notIn: SENTINEL_NODE_IDS }, status: { not: 'DECOMMISSIONED' } },
65
66
  select: { id: true, name: true, hostname: true, ipAddress: true, port: true, status: true, lastHeartbeatAt: true },
66
67
  });
67
68
  // Probe all nodes concurrently
@@ -107,6 +107,15 @@ function tailnetPins(origin) {
107
107
  const pins = entries[origin];
108
108
  return pins ? new Set(pins) : undefined;
109
109
  }
110
+ /** The one refusal that is safe and useful to explain: it names a setting, and no address or allowlist entry. */
111
+ export class WebhookOriginNotAllowedError extends Error {
112
+ constructor(allowlistIsEmpty) {
113
+ super(allowlistIsEmpty
114
+ ? 'Webhook target origin is not allowlisted: no webhook can be created until WEBHOOK_ALLOWED_ORIGINS is set on the control API. Set it to a comma-separated list of exact HTTPS origins, such as https://hooks.example.com, and restart the server.'
115
+ : 'Webhook target origin is not allowlisted. Add its exact HTTPS origin to WEBHOOK_ALLOWED_ORIGINS on the control API and restart the server.');
116
+ this.name = 'WebhookOriginNotAllowedError';
117
+ }
118
+ }
110
119
  export function createPinnedWebhookLookup(target) {
111
120
  return (_hostname, options, callback) => {
112
121
  if (options.all) {
@@ -134,7 +143,7 @@ async function resolveWebhookTarget(rawUrl, options = {}) {
134
143
  url.hostname = hostname;
135
144
  const allowlist = allowedOrigins(options.allowedOriginsRaw);
136
145
  if (!allowlist.has(canonicalOrigin(url))) {
137
- throw new Error('Webhook target origin is not allowlisted');
146
+ throw new WebhookOriginNotAllowedError(allowlist.size === 0);
138
147
  }
139
148
  const pins = tailnetPins(canonicalOrigin(url));
140
149
  let addresses;
@@ -1,6 +1,10 @@
1
+ import { consolePagePath } from '../config/console-path.js';
2
+ import { bullQueueIdentity } from '../queue/names.js';
3
+ import { assertLiveExecution } from '../config/rehearsal.js';
1
4
  import { recordMonitorPulse } from './monitor-pulse.js';
2
5
  import { WEBHOOK_FORMATS, webhookFormat } from './webhook-types.js';
3
6
  import { redactUnknown } from '../utils/redact.js';
7
+ import { clientErrorMessage } from '../utils/safe-errors.js';
4
8
  import { createHmac, randomBytes, createCipheriv } from 'node:crypto';
5
9
  import { nanoid } from 'nanoid';
6
10
  import { Queue, Worker } from 'bullmq';
@@ -10,7 +14,7 @@ import { isEncryptedWebhookCredential, decryptWebhookCredential, encryptWebhookC
10
14
  // ─── Webhook Service — dispatch, deliver, retry ───
11
15
  const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379';
12
16
  const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
13
- const WEBHOOK_QUEUE = `${REDIS_PREFIX}webhooks`;
17
+ const WEBHOOK_QUEUE = bullQueueIdentity(REDIS_PREFIX, 'webhooks');
14
18
  const PROTECTED_HEADERS = new Set([
15
19
  'connection', 'content-length', 'host', 'transfer-encoding', 'user-agent',
16
20
  'x-magnus-delivery', 'x-magnus-encrypted', 'x-magnus-event', 'x-magnus-signature',
@@ -21,8 +25,9 @@ let _webhookQueue = null;
21
25
  let _webhookWorker = null;
22
26
  /** Get the singleton webhook queue instance. */
23
27
  export function getWebhookQueue() {
28
+ assertLiveExecution();
24
29
  if (!_webhookQueue) {
25
- _webhookQueue = new Queue(WEBHOOK_QUEUE, { connection: { url: REDIS_URL } });
30
+ _webhookQueue = new Queue(WEBHOOK_QUEUE.name, { prefix: WEBHOOK_QUEUE.prefix, connection: { url: REDIS_URL } });
26
31
  }
27
32
  return _webhookQueue;
28
33
  }
@@ -177,8 +182,9 @@ export function formatPayload(type, event, payload) {
177
182
  if (origin) {
178
183
  try {
179
184
  const parsed = new URL(origin);
180
- if (parsed.protocol === 'https:' && !parsed.username && !parsed.password)
181
- link = `${parsed.origin}/incidents`;
185
+ const page = consolePagePath('/incidents');
186
+ if (page && parsed.protocol === 'https:' && !parsed.username && !parsed.password)
187
+ link = `${parsed.origin}${page}`;
182
188
  }
183
189
  catch { /* no link */ }
184
190
  }
@@ -230,11 +236,12 @@ export async function claimMattermostAttempt(deliveryId) {
230
236
  }
231
237
  /** Start the webhook delivery worker. */
232
238
  export function startWebhookWorker() {
239
+ assertLiveExecution();
233
240
  if (_webhookWorker)
234
241
  return _webhookWorker;
235
242
  // Force the dedicated at-rest key to be present before accepting queued work.
236
243
  encryptWebhookCredential('', 'startup-check', 'secret');
237
- const worker = new Worker(WEBHOOK_QUEUE, async (job) => {
244
+ const worker = new Worker(WEBHOOK_QUEUE.name, async (job) => {
238
245
  const db = getDb();
239
246
  const deliveryId = job.id;
240
247
  if (!deliveryId)
@@ -304,6 +311,7 @@ export function startWebhookWorker() {
304
311
  }, {
305
312
  connection: { url: REDIS_URL },
306
313
  concurrency: 5,
314
+ prefix: WEBHOOK_QUEUE.prefix,
307
315
  });
308
316
  worker.on('failed', (job, err) => {
309
317
  console.error(`[webhook-worker] Job ${job?.id} failed: ${err.message}`);
@@ -466,7 +474,7 @@ export async function testWebhookEndpoint(id) {
466
474
  return { success: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };
467
475
  }
468
476
  catch (err) {
469
- return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
477
+ return { success: false, error: clientErrorMessage(err, 'Unknown error') };
470
478
  }
471
479
  }
472
480
  /**
@@ -9,7 +9,8 @@ import { isIP } from 'node:net';
9
9
  export function getNodeBaseUrl(ipAddress, port) {
10
10
  const useTls = process.env['TLS_CERT_PATH'] && process.env['TLS_KEY_PATH'];
11
11
  const protocol = useTls ? 'https' : 'http';
12
- return `${protocol}://${ipAddress}:${port}`;
12
+ const host = ipAddress.includes(':') && !ipAddress.startsWith('[') ? `[${ipAddress}]` : ipAddress;
13
+ return `${protocol}://${host}:${port}`;
13
14
  }
14
15
  const DNS_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
15
16
  /**
@@ -0,0 +1,130 @@
1
+ /**
2
+ * Turns database and transport failures into text that is safe to send to a client.
3
+ *
4
+ * Prisma messages quote the failing call, its arguments, constraint names and the
5
+ * database address. Socket errors quote the peer's address, and `fetch` reports
6
+ * only "fetch failed". None of that belongs in a response, so the text here comes
7
+ * from fixed tables and never from the error's own message.
8
+ */
9
+ const INVALID = { status: 400, code: 'VALIDATION_ERROR', message: 'The request contains an invalid value.' };
10
+ const NOT_FOUND = { status: 404, code: 'NOT_FOUND', message: 'The requested record was not found.' };
11
+ const IN_USE = { status: 409, code: 'CONFLICT', message: 'The change refers to a record that does not exist or is still in use.' };
12
+ const UNAVAILABLE = { status: 503, code: 'DATABASE_UNAVAILABLE', message: 'The database is temporarily unavailable. Try again shortly.' };
13
+ const UNEXPECTED = { status: 500, code: 'INTERNAL_ERROR', message: 'An unexpected error occurred' };
14
+ const DATABASE_FAILURES = {
15
+ P2000: INVALID, P2005: INVALID, P2006: INVALID, P2007: INVALID, P2011: INVALID, P2012: INVALID, P2019: INVALID, P2020: INVALID, P2023: INVALID,
16
+ P2001: NOT_FOUND, P2015: NOT_FOUND, P2018: NOT_FOUND, P2025: NOT_FOUND,
17
+ P2003: IN_USE, P2014: IN_USE,
18
+ P2024: UNAVAILABLE, P2034: UNAVAILABLE, P2037: UNAVAILABLE,
19
+ };
20
+ const PRISMA_ERROR_NAME = /^PrismaClient(?:KnownRequest|UnknownRequest|Initialization|Validation|RustPanic)Error$|^DriverAdapterError$/u;
21
+ /** Prisma text that reached a message through a wrapper, such as a route catching "not found". */
22
+ const PRISMA_TEXT = /Invalid `[^`]+` invocation|PrismaClient|Unique constraint failed|Foreign key constraint|Can't reach database server|required but not found/iu;
23
+ function errorProperty(error, key) {
24
+ return error && typeof error === 'object' && key in error ? error[key] : undefined;
25
+ }
26
+ function isPrismaError(error) {
27
+ const code = errorProperty(error, 'code');
28
+ return (typeof code === 'string' && /^P\d{4}$/u.test(code))
29
+ || (typeof errorProperty(error, 'name') === 'string' && PRISMA_ERROR_NAME.test(errorProperty(error, 'name')));
30
+ }
31
+ const words = (name) => name.replace(/(?<=[a-z])(?=[A-Z])/gu, ' ').toLowerCase();
32
+ /** `HermesCredentialPool` becomes `hermes credential pool`. */
33
+ function modelLabel(error) {
34
+ const name = errorProperty(errorProperty(error, 'meta'), 'modelName');
35
+ return typeof name === 'string' && /^[A-Za-z]+$/u.test(name)
36
+ ? words(name)
37
+ : null;
38
+ }
39
+ /**
40
+ * The columns of a violated unique constraint. The PostgreSQL adapter reports only the
41
+ * index, named `<table>_<columns>_key`. Other paths report the columns themselves.
42
+ */
43
+ function constraintFields(error) {
44
+ const meta = errorProperty(error, 'meta');
45
+ const cause = errorProperty(errorProperty(meta, 'driverAdapterError'), 'cause');
46
+ const constraint = errorProperty(cause, 'constraint');
47
+ const listed = errorProperty(constraint, 'fields') ?? errorProperty(meta, 'target');
48
+ if (Array.isArray(listed))
49
+ return listed.filter((field) => typeof field === 'string' && /^[A-Za-z_]+$/u.test(field));
50
+ const index = errorProperty(constraint, 'index');
51
+ const table = errorProperty(cause, 'table');
52
+ if (typeof index !== 'string' || typeof table !== 'string' || !index.startsWith(`${table}_`) || !index.endsWith('_key'))
53
+ return [];
54
+ const columns = index.slice(table.length + 1, -'_key'.length);
55
+ return /^[A-Za-z]+(?:_[A-Za-z]+)*$/u.test(columns) ? columns.split('_') : [];
56
+ }
57
+ function duplicateMessage(error) {
58
+ const label = modelLabel(error);
59
+ const fields = constraintFields(error).map(words);
60
+ const subject = label ? `A ${label}` : 'A record';
61
+ return fields.length > 0 ? `${subject} with this ${fields.join(' and ')} already exists.` : `${subject} with the same unique value already exists.`;
62
+ }
63
+ /** The client's view of a database error, or null for any other error. */
64
+ export function databaseFailure(error) {
65
+ if (!isPrismaError(error))
66
+ return null;
67
+ const name = errorProperty(error, 'name');
68
+ if (name === 'PrismaClientValidationError')
69
+ return INVALID;
70
+ if (name === 'PrismaClientInitializationError')
71
+ return UNAVAILABLE;
72
+ const code = errorProperty(error, 'code');
73
+ if (typeof code !== 'string')
74
+ return UNEXPECTED;
75
+ if (code === 'P2002')
76
+ return { status: 409, code: 'CONFLICT', message: duplicateMessage(error) };
77
+ if (code.startsWith('P1'))
78
+ return UNAVAILABLE;
79
+ return DATABASE_FAILURES[code] ?? UNEXPECTED;
80
+ }
81
+ export function containsDatabaseText(message) {
82
+ return PRISMA_TEXT.test(message);
83
+ }
84
+ /** An error's own message for a client, unless it is, or quotes, a database error. */
85
+ export function clientErrorMessage(error, fallback) {
86
+ const failure = databaseFailure(error);
87
+ if (failure)
88
+ return failure.message;
89
+ const message = error instanceof Error ? error.message : '';
90
+ return message && !containsDatabaseText(message) ? message : fallback;
91
+ }
92
+ const TLS_VERIFICATION = 'its TLS certificate could not be verified';
93
+ const TRANSPORT_FAILURES = {
94
+ ECONNREFUSED: 'the connection was refused',
95
+ ECONNRESET: 'the connection was closed unexpectedly',
96
+ EPIPE: 'the connection was closed unexpectedly',
97
+ UND_ERR_SOCKET: 'the connection was closed unexpectedly',
98
+ ETIMEDOUT: 'the connection timed out',
99
+ UND_ERR_CONNECT_TIMEOUT: 'the connection timed out',
100
+ UND_ERR_HEADERS_TIMEOUT: 'the response timed out',
101
+ ENOTFOUND: 'the address could not be resolved',
102
+ EAI_AGAIN: 'the address could not be resolved',
103
+ EHOSTUNREACH: 'there is no network route to it',
104
+ ENETUNREACH: 'there is no network route to it',
105
+ EPROTO: 'the TLS handshake failed',
106
+ ERR_SSL_WRONG_VERSION_NUMBER: 'the TLS handshake failed',
107
+ CERT_HAS_EXPIRED: 'its TLS certificate has expired',
108
+ DEPTH_ZERO_SELF_SIGNED_CERT: TLS_VERIFICATION,
109
+ SELF_SIGNED_CERT_IN_CHAIN: TLS_VERIFICATION,
110
+ UNABLE_TO_VERIFY_LEAF_SIGNATURE: TLS_VERIFICATION,
111
+ UNABLE_TO_GET_ISSUER_CERT_LOCALLY: TLS_VERIFICATION,
112
+ ERR_TLS_CERT_ALTNAME_INVALID: TLS_VERIFICATION,
113
+ EACCES: 'permission was denied',
114
+ ENOENT: 'the path does not exist',
115
+ };
116
+ /** Why a connection failed, in words that name no host, port, path or URL. */
117
+ export function describeTransportFailure(error) {
118
+ let current = error;
119
+ for (let depth = 0; current && depth < 4; depth += 1) {
120
+ const name = errorProperty(current, 'name');
121
+ if (name === 'TimeoutError' || name === 'AbortError')
122
+ return 'the request timed out';
123
+ const code = errorProperty(current, 'code');
124
+ if (typeof code === 'string' && TRANSPORT_FAILURES[code])
125
+ return `${TRANSPORT_FAILURES[code]} (${code})`;
126
+ current = errorProperty(current, 'cause');
127
+ }
128
+ return 'the request failed';
129
+ }
130
+ //# sourceMappingURL=safe-errors.js.map