@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.
- package/.env.example +32 -0
- package/README.md +32 -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 +13 -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 +28 -0
- package/dist/config/features.js +3 -0
- 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 +5 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +5 -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 +27 -21
- 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 +38 -2
- package/dist/queue/names.js +9 -0
- package/dist/queue/workers.js +9 -3
- package/dist/routes/assistant.js +30 -12
- 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/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/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 +11 -1
- package/dist/security/native-completion-registry.js +3 -1
- package/dist/server.js +8 -0
- 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/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/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/20261105010000_base_initialization/magnus-impact.json +1 -0
- package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
- 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,
|
|
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' ?
|
|
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 =
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|