@hivemagnus/magnus-server 1.1.13 → 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.
- package/.env.example +37 -0
- package/README.md +36 -0
- package/agent-version.json +1 -1
- package/dist/ai/TOOLS.md +1 -1
- package/dist/cli/args.js +3 -1
- package/dist/cli/env-catalog.js +16 -0
- package/dist/cli/migrations.js +22 -0
- package/dist/cli/verify-initialization.js +12 -0
- package/dist/cli.js +28 -0
- package/dist/config/console-path.js +17 -0
- package/dist/config/env.js +32 -5
- package/dist/config/features.js +4 -1
- package/dist/config/oathweave.js +42 -0
- package/dist/config/rehearsal.js +22 -0
- package/dist/db/generated/prisma/internal/class.js +4 -4
- package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
- package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
- package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
- package/dist/db/read-retry.js +5 -1
- package/dist/db/seed.js +8 -0
- package/dist/index.js +28 -37
- package/dist/middleware/auth.js +21 -0
- package/dist/middleware/error-handler.js +10 -10
- package/dist/middleware/oathweave-verifier.js +23 -0
- package/dist/middleware/oathweave.js +115 -0
- package/dist/middleware/rate-limit.js +12 -3
- package/dist/openapi/spec.js +45 -4
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -68
- package/dist/routes/assistant.js +30 -12
- package/dist/routes/cindertrace-response.js +183 -0
- package/dist/routes/console.js +33 -0
- package/dist/routes/cron.js +8 -5
- package/dist/routes/events-stream.js +10 -0
- package/dist/routes/files.js +2 -1
- package/dist/routes/fleet-gateway.js +69 -0
- package/dist/routes/heartbeat.js +1 -6
- package/dist/routes/hermes.js +26 -6
- package/dist/routes/hiveskills.js +116 -0
- package/dist/routes/library.js +3 -2
- package/dist/routes/logs.js +5 -0
- package/dist/routes/metrics.js +4 -3
- package/dist/routes/nodes.js +45 -8
- package/dist/routes/ops.js +24 -6
- package/dist/routes/profiling.js +2 -1
- package/dist/routes/roles.js +17 -9
- package/dist/routes/security-fusion.js +15 -600
- package/dist/routes/stats.js +8 -5
- package/dist/routes/terminal-ticket.js +3 -3
- package/dist/routes/terminal.js +2 -2
- package/dist/routes/webhooks.js +15 -15
- package/dist/sdk/node-api-client.js +23 -1
- package/dist/security/cindertrace-response.js +14 -0
- package/dist/security/native-completion-registry.js +14 -0
- package/dist/server.js +11 -14
- package/dist/services/ai-assistant-service.js +32 -6
- package/dist/services/ai-tools.js +15 -8
- package/dist/services/ardent-scope-enrollment.js +4 -2
- package/dist/services/command-resource-leases.js +2 -4
- package/dist/services/forecast-service.js +23 -8
- package/dist/services/ops-service.js +20 -25
- package/dist/services/prediction-service.js +2 -1
- package/dist/services/resource-enrollment.js +57 -4
- package/dist/services/secret-views.js +72 -0
- package/dist/services/security-export.js +4 -6
- package/dist/services/topology-service.js +2 -1
- package/dist/services/webhook-security.js +10 -1
- package/dist/services/webhook-service.js +14 -6
- package/dist/utils/node-url.js +2 -1
- package/dist/utils/safe-errors.js +130 -0
- package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
- package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
- package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
- package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
- package/package.json +7 -3
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
- package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
- package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- package/prisma/schema.prisma +22 -0
- package/dist/services/security-fusion-actions.js +0 -22
- package/dist/services/security-fusion-evidence-integrity.js +0 -144
- package/dist/services/security-fusion-ingest.js +0 -101
- package/dist/services/security-fusion-outbox.js +0 -185
- package/dist/services/security-fusion-receipt-chain.js +0 -134
- package/dist/services/security-fusion-service.js +0 -751
- package/dist/services/security-fusion-verification.js +0 -50
|
@@ -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
|
-
|
|
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('
|
|
266
|
-
url = origin.origin + '/
|
|
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
|
-
|
|
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
|
|
@@ -1,19 +1,17 @@
|
|
|
1
1
|
import { createHash, createHmac, randomBytes } from 'node:crypto';
|
|
2
2
|
import { getDb } from '../db/index.js';
|
|
3
3
|
import { incMetric, setMetricGauge } from '../routes/metrics.js';
|
|
4
|
-
/** Security Fusion is optional: `off` unless explicitly set to `
|
|
4
|
+
/** Security Fusion is optional: `off` unless explicitly set to `remote`. */
|
|
5
5
|
export function securityFusionMode() {
|
|
6
6
|
const mode = process.env['SECURITY_FUSION_MODE'];
|
|
7
|
-
|
|
7
|
+
if (mode === 'local')
|
|
8
|
+
throw new Error('Embedded Security Fusion is retired. Export its state with the Cindertrace migration tool, verify the import, then configure SECURITY_FUSION_MODE=remote. See docs/CINDERTRACE_MIGRATION.md');
|
|
9
|
+
return mode === 'remote' ? mode : 'off';
|
|
8
10
|
}
|
|
9
11
|
/** Observations are exported to an external security console. */
|
|
10
12
|
export function securityFusionRemote() {
|
|
11
13
|
return securityFusionMode() === 'remote';
|
|
12
14
|
}
|
|
13
|
-
/** Security Fusion cases, evidence and receipts are kept in this server's database. */
|
|
14
|
-
export function securityFusionLocal() {
|
|
15
|
-
return securityFusionMode() === 'local';
|
|
16
|
-
}
|
|
17
15
|
/** Optional external security console URL shown to operators in remote mode. */
|
|
18
16
|
export function securityConsoleUrl() {
|
|
19
17
|
const raw = process.env['MAGNUS_SECURITY_CONSOLE_URL'];
|
|
@@ -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
|
|
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 =
|
|
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
|
-
|
|
181
|
-
|
|
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
|
|
477
|
+
return { success: false, error: clientErrorMessage(err, 'Unknown error') };
|
|
470
478
|
}
|
|
471
479
|
}
|
|
472
480
|
/**
|
package/dist/utils/node-url.js
CHANGED
|
@@ -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
|
-
|
|
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
|