@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
|
@@ -69,6 +69,7 @@ export const AnyNull = runtime.AnyNull;
|
|
|
69
69
|
export const ModelName = {
|
|
70
70
|
MagnusReleaseCanary: 'MagnusReleaseCanary',
|
|
71
71
|
User: 'User',
|
|
72
|
+
ProductInitialization: 'ProductInitialization',
|
|
72
73
|
Node: 'Node',
|
|
73
74
|
ArdentInstallation: 'ArdentInstallation',
|
|
74
75
|
ArdentProfile: 'ArdentProfile',
|
|
@@ -172,7 +173,8 @@ export const ModelName = {
|
|
|
172
173
|
ResourceDrainParticipant: 'ResourceDrainParticipant',
|
|
173
174
|
ResourceParticipantWork: 'ResourceParticipantWork',
|
|
174
175
|
ManagedCronDefinition: 'ManagedCronDefinition',
|
|
175
|
-
ManagedCronOccurrence: 'ManagedCronOccurrence'
|
|
176
|
+
ManagedCronOccurrence: 'ManagedCronOccurrence',
|
|
177
|
+
CindertraceResponse: 'CindertraceResponse'
|
|
176
178
|
};
|
|
177
179
|
/**
|
|
178
180
|
* Enums
|
|
@@ -201,6 +203,10 @@ export const UserScalarFieldEnum = {
|
|
|
201
203
|
createdAt: 'createdAt',
|
|
202
204
|
updatedAt: 'updatedAt'
|
|
203
205
|
};
|
|
206
|
+
export const ProductInitializationScalarFieldEnum = {
|
|
207
|
+
id: 'id',
|
|
208
|
+
completedAt: 'completedAt'
|
|
209
|
+
};
|
|
204
210
|
export const NodeScalarFieldEnum = {
|
|
205
211
|
id: 'id',
|
|
206
212
|
name: 'name',
|
|
@@ -1582,6 +1588,16 @@ export const ManagedCronOccurrenceScalarFieldEnum = {
|
|
|
1582
1588
|
admissionJson: 'admissionJson',
|
|
1583
1589
|
createdAt: 'createdAt'
|
|
1584
1590
|
};
|
|
1591
|
+
export const CindertraceResponseScalarFieldEnum = {
|
|
1592
|
+
id: 'id',
|
|
1593
|
+
actionOperation: 'actionOperation',
|
|
1594
|
+
nodeId: 'nodeId',
|
|
1595
|
+
requestSha256: 'requestSha256',
|
|
1596
|
+
requestJson: 'requestJson',
|
|
1597
|
+
status: 'status',
|
|
1598
|
+
responseJson: 'responseJson',
|
|
1599
|
+
createdAt: 'createdAt'
|
|
1600
|
+
};
|
|
1585
1601
|
export const SortOrder = {
|
|
1586
1602
|
asc: 'asc',
|
|
1587
1603
|
desc: 'desc'
|
|
@@ -41,6 +41,7 @@ export const AnyNull = runtime.AnyNull;
|
|
|
41
41
|
export const ModelName = {
|
|
42
42
|
MagnusReleaseCanary: 'MagnusReleaseCanary',
|
|
43
43
|
User: 'User',
|
|
44
|
+
ProductInitialization: 'ProductInitialization',
|
|
44
45
|
Node: 'Node',
|
|
45
46
|
ArdentInstallation: 'ArdentInstallation',
|
|
46
47
|
ArdentProfile: 'ArdentProfile',
|
|
@@ -144,7 +145,8 @@ export const ModelName = {
|
|
|
144
145
|
ResourceDrainParticipant: 'ResourceDrainParticipant',
|
|
145
146
|
ResourceParticipantWork: 'ResourceParticipantWork',
|
|
146
147
|
ManagedCronDefinition: 'ManagedCronDefinition',
|
|
147
|
-
ManagedCronOccurrence: 'ManagedCronOccurrence'
|
|
148
|
+
ManagedCronOccurrence: 'ManagedCronOccurrence',
|
|
149
|
+
CindertraceResponse: 'CindertraceResponse'
|
|
148
150
|
};
|
|
149
151
|
/*
|
|
150
152
|
* Enums
|
|
@@ -173,6 +175,10 @@ export const UserScalarFieldEnum = {
|
|
|
173
175
|
createdAt: 'createdAt',
|
|
174
176
|
updatedAt: 'updatedAt'
|
|
175
177
|
};
|
|
178
|
+
export const ProductInitializationScalarFieldEnum = {
|
|
179
|
+
id: 'id',
|
|
180
|
+
completedAt: 'completedAt'
|
|
181
|
+
};
|
|
176
182
|
export const NodeScalarFieldEnum = {
|
|
177
183
|
id: 'id',
|
|
178
184
|
name: 'name',
|
|
@@ -1554,6 +1560,16 @@ export const ManagedCronOccurrenceScalarFieldEnum = {
|
|
|
1554
1560
|
admissionJson: 'admissionJson',
|
|
1555
1561
|
createdAt: 'createdAt'
|
|
1556
1562
|
};
|
|
1563
|
+
export const CindertraceResponseScalarFieldEnum = {
|
|
1564
|
+
id: 'id',
|
|
1565
|
+
actionOperation: 'actionOperation',
|
|
1566
|
+
nodeId: 'nodeId',
|
|
1567
|
+
requestSha256: 'requestSha256',
|
|
1568
|
+
requestJson: 'requestJson',
|
|
1569
|
+
status: 'status',
|
|
1570
|
+
responseJson: 'responseJson',
|
|
1571
|
+
createdAt: 'createdAt'
|
|
1572
|
+
};
|
|
1557
1573
|
export const SortOrder = {
|
|
1558
1574
|
asc: 'asc',
|
|
1559
1575
|
desc: 'desc'
|
package/dist/db/read-retry.js
CHANGED
|
@@ -29,7 +29,11 @@ export async function withReadRetry(label, fn, opts = {}) {
|
|
|
29
29
|
await sleep(baseDelayMs * attempt);
|
|
30
30
|
}
|
|
31
31
|
}
|
|
32
|
+
// An error that retrying cannot help is not an outage: keep it, so its handler can classify it.
|
|
33
|
+
if (!isTransientReadError(lastErr))
|
|
34
|
+
throw lastErr;
|
|
32
35
|
const message = lastErr instanceof Error ? lastErr.message : String(lastErr);
|
|
33
|
-
|
|
36
|
+
console.error(`[db] ${label} failed after ${attempts} read attempts: ${message}`);
|
|
37
|
+
throw Object.assign(new Error('The database is temporarily unavailable. Try again shortly.'), { status: 503, code: 'DATABASE_UNAVAILABLE' });
|
|
34
38
|
}
|
|
35
39
|
//# sourceMappingURL=read-retry.js.map
|
package/dist/db/seed.js
CHANGED
|
@@ -18,6 +18,14 @@ async function hashPassword(password) {
|
|
|
18
18
|
}
|
|
19
19
|
/** Idempotently create the system actor, sentinel nodes and library content. */
|
|
20
20
|
export async function seedDatabase(prisma, log = console.log) {
|
|
21
|
+
await prisma.$transaction(async (transaction) => {
|
|
22
|
+
await seedBaseData(transaction, log);
|
|
23
|
+
await transaction.productInitialization.upsert({
|
|
24
|
+
where: { id: 'base-data-v1' }, update: {}, create: { id: 'base-data-v1' },
|
|
25
|
+
});
|
|
26
|
+
}, { timeout: 60_000 });
|
|
27
|
+
}
|
|
28
|
+
async function seedBaseData(prisma, log) {
|
|
21
29
|
log('🌱 Seeding database…');
|
|
22
30
|
// ── System user (used by API-secret / agent auth) ──
|
|
23
31
|
let systemUser = await prisma.user.findUnique({ where: { id: 'system' } });
|
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'url';
|
|
|
4
4
|
import { readFileSync, existsSync } from 'node:fs';
|
|
5
5
|
import { createServer } from 'node:https';
|
|
6
6
|
import { resolveListenerSecurity } from './config/listener-security.js';
|
|
7
|
+
import { rehearsalEnabled, startLiveBackgroundServices } from './config/rehearsal.js';
|
|
7
8
|
// Load the env file before any other imports read process.env. The CLI passes
|
|
8
9
|
// `--env-file` through MAGNUS_ENV_FILE; otherwise the package-level .env is used
|
|
9
10
|
// when present. Variables already set in the environment always win.
|
|
@@ -11,10 +12,11 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
11
12
|
const envFile = process.env['MAGNUS_ENV_FILE'] ? resolve(process.env['MAGNUS_ENV_FILE']) : resolve(__dirname, '../.env');
|
|
12
13
|
if (existsSync(envFile))
|
|
13
14
|
config({ path: envFile, quiet: true });
|
|
15
|
+
rehearsalEnabled(); // Invalid safety flags fail before opening any listener.
|
|
14
16
|
// Validate required secrets before starting anything
|
|
15
17
|
const { validateSecrets } = await import('./utils/env.js');
|
|
16
18
|
validateSecrets();
|
|
17
|
-
const {
|
|
19
|
+
const { securityFusionMode, startSecurityExporter, stopSecurityExporter } = await import('./services/security-export.js');
|
|
18
20
|
const { featureFlags } = await import('./config/features.js');
|
|
19
21
|
const { serve } = await import('@hono/node-server');
|
|
20
22
|
const { app } = await import('./server.js');
|
|
@@ -30,9 +32,6 @@ const { stopLogRetention } = await import('./services/log-retention.js');
|
|
|
30
32
|
const { startHealthRetention } = await import('./services/health-retention.js');
|
|
31
33
|
const { stopHealthRetention } = await import('./services/health-retention.js');
|
|
32
34
|
const { startNotificationRetention, stopNotificationRetention } = await import('./services/notification-retention.js');
|
|
33
|
-
const { startSecurityFusionRetention, stopSecurityFusionRetention } = await import('./services/security-fusion-service.js');
|
|
34
|
-
const { reconcileSecurityFusionEvidence } = await import('./services/security-fusion-evidence-integrity.js');
|
|
35
|
-
const { startSecurityFusionOutboxDispatcher, stopSecurityFusionOutboxDispatcher, } = await import('./services/security-fusion-outbox.js');
|
|
36
35
|
const { startCommandWorker, closeCommandQueue } = await import('./queue/workers.js');
|
|
37
36
|
const { startUpdateCheck, stopUpdateCheck } = await import('./services/update-check-service.js');
|
|
38
37
|
const { startWebhookWorker, closeWebhookQueue } = await import('./services/webhook-service.js');
|
|
@@ -47,38 +46,32 @@ const port = Number(process.env['CONTROL_API_PORT'] ?? 4000);
|
|
|
47
46
|
const features = featureFlags();
|
|
48
47
|
console.log(`[startup] security-fusion=${securityFusionMode()} hermes-registry=${features.hermesRegistry ? 'on' : 'off'} `
|
|
49
48
|
+ `plugin-api=${features.pluginApi ? 'on' : 'off'} iam=${features.iam ? 'on' : 'off'}`);
|
|
50
|
-
if (securityFusionLocal()) {
|
|
51
|
-
const evidenceReconciliation = await reconcileSecurityFusionEvidence();
|
|
52
|
-
console.log(`[security-fusion] evidence reconciliation checked=${evidenceReconciliation.checked} `
|
|
53
|
-
+ `updated=${evidenceReconciliation.updated} quarantined=${evidenceReconciliation.quarantined} `
|
|
54
|
-
+ `mismatched_cases=${evidenceReconciliation.mismatchedCases}`);
|
|
55
|
-
}
|
|
56
49
|
const { writeRuntimeFile } = await import('./utils/runtime-file.js');
|
|
57
50
|
const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
58
51
|
/** Background services shared by the TLS and development listeners. */
|
|
59
52
|
function startBackgroundServices(listenPort) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
53
|
+
const started = startLiveBackgroundServices(() => {
|
|
54
|
+
writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
|
|
55
|
+
startNodeIssueMonitor();
|
|
56
|
+
startUpgradeReadinessMonitor();
|
|
57
|
+
startApplicationProbes();
|
|
58
|
+
startLogRetention();
|
|
59
|
+
startHealthRetention();
|
|
60
|
+
startNotificationRetention();
|
|
61
|
+
startSecurityExporter();
|
|
62
|
+
startCommandWorker();
|
|
63
|
+
startRolloutReconciler();
|
|
64
|
+
startWebhookWorker();
|
|
65
|
+
startCodexFleetMonitor();
|
|
66
|
+
startUpdateCheck();
|
|
67
|
+
if (features.hermesRegistry) {
|
|
68
|
+
startExtensionSourceMonitor();
|
|
69
|
+
void importPackageSkills().then(result => console.log(`[hermes-registry] catalog import: ${JSON.stringify(result)}`))
|
|
70
|
+
.catch(error => console.error('[hermes-registry] catalog import failed', error));
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
if (!started)
|
|
74
|
+
console.log('[startup] isolated rehearsal: background consumers and queue activation disabled');
|
|
82
75
|
}
|
|
83
76
|
// Relative TLS paths resolve against the working directory; prefer absolute paths.
|
|
84
77
|
const repoRoot = process.cwd();
|
|
@@ -114,7 +107,7 @@ const server = listener.useTls
|
|
|
114
107
|
console.log(`🚀 Magnus server listening on http://${listener.hostname}:${info.port} (development opt-in)`);
|
|
115
108
|
startBackgroundServices(info.port);
|
|
116
109
|
});
|
|
117
|
-
const terminalWss = setupTerminalWebSocket(server);
|
|
110
|
+
const terminalWss = rehearsalEnabled() ? undefined : setupTerminalWebSocket(server);
|
|
118
111
|
// ── Graceful shutdown ──
|
|
119
112
|
let shuttingDown = false;
|
|
120
113
|
async function shutdown(signal) {
|
|
@@ -137,17 +130,15 @@ async function shutdown(signal) {
|
|
|
137
130
|
stopLogRetention();
|
|
138
131
|
stopHealthRetention();
|
|
139
132
|
stopNotificationRetention();
|
|
140
|
-
stopSecurityFusionRetention();
|
|
141
133
|
stopSecurityExporter();
|
|
142
|
-
stopSecurityFusionOutboxDispatcher();
|
|
143
134
|
stopCodexFleetMonitor();
|
|
144
135
|
stopUpdateCheck();
|
|
145
136
|
stopExtensionSourceMonitor();
|
|
146
|
-
for (const client of terminalWss
|
|
137
|
+
for (const client of terminalWss?.clients ?? [])
|
|
147
138
|
client.terminate();
|
|
148
139
|
const httpClosed = new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
149
140
|
server.closeAllConnections?.();
|
|
150
|
-
const webSocketsClosed = new Promise((resolvePromise) => terminalWss.close(() => resolvePromise()));
|
|
141
|
+
const webSocketsClosed = terminalWss ? new Promise((resolvePromise) => terminalWss.close(() => resolvePromise())) : Promise.resolve();
|
|
151
142
|
await Promise.all([httpClosed, webSocketsClosed]);
|
|
152
143
|
await closeCommandQueue();
|
|
153
144
|
await closeWebhookQueue();
|
package/dist/middleware/auth.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* happens in Hive IAM itself.
|
|
7
7
|
*
|
|
8
8
|
* If IAM_CORE_URL is not set, local Magnus HS256/API-secret auth remains active.
|
|
9
|
+
*
|
|
10
|
+
* When OATHWEAVE_ISSUER is configured instead, RS256 bearer tokens are Oathweave
|
|
11
|
+
* resource tokens (see oathweave.ts). The two RS256 verifiers refuse to coexist.
|
|
9
12
|
*/
|
|
10
13
|
import { createHash } from 'node:crypto';
|
|
11
14
|
import * as jose from 'jose';
|
|
@@ -15,6 +18,7 @@ import { verifyWithRotation } from '../services/credential-rotation-service.js';
|
|
|
15
18
|
import { verifyIamAccessToken } from './iam-token.js';
|
|
16
19
|
import { getCurrentUserStatus } from './user-status.js';
|
|
17
20
|
import { safeEqual } from '../utils/safe-equal.js';
|
|
21
|
+
import { OathweaveAccessDenied, oathweaveAuthentication } from './oathweave.js';
|
|
18
22
|
/** Constant-time string comparison (re-exported for existing callers). */
|
|
19
23
|
export { safeEqual };
|
|
20
24
|
let cachedPublicKey = null;
|
|
@@ -260,6 +264,23 @@ export async function authMiddleware(c, next) {
|
|
|
260
264
|
return;
|
|
261
265
|
}
|
|
262
266
|
}
|
|
267
|
+
// ── Oathweave resource tokens: RS256 ──────────────────
|
|
268
|
+
// A token this verifier claims is decided here. Failure never falls through.
|
|
269
|
+
let oathweaveUser;
|
|
270
|
+
try {
|
|
271
|
+
oathweaveUser = await oathweaveAuthentication(token);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
if (error instanceof OathweaveAccessDenied) {
|
|
275
|
+
throw new HttpError(403, 'FORBIDDEN', 'This identity has no linked Magnus account or permission');
|
|
276
|
+
}
|
|
277
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Invalid or inactive identity session');
|
|
278
|
+
}
|
|
279
|
+
if (oathweaveUser) {
|
|
280
|
+
c.set('user', oathweaveUser);
|
|
281
|
+
await next();
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
263
284
|
// ── External Hive IAM verification mode: RS256 ────────
|
|
264
285
|
if (IAM_CORE_URL) {
|
|
265
286
|
let payload;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
1
|
import { redactSecretText } from '../utils/redact.js';
|
|
2
|
+
import { containsDatabaseText, databaseFailure } from '../utils/safe-errors.js';
|
|
2
3
|
/** Global error handler for Hono — returns structured ApiError JSON. */
|
|
3
4
|
export function errorHandler(err, c) {
|
|
4
|
-
const
|
|
5
|
-
const
|
|
5
|
+
const database = databaseFailure(err);
|
|
6
|
+
const status = database?.status ?? ('status' in err ? err.status : 500);
|
|
7
|
+
const code = database?.code ?? ('code' in err ? err.code : 'INTERNAL_ERROR');
|
|
6
8
|
// Log only the error message and code — never the full error object,
|
|
7
9
|
// which may contain request bodies, tokens, or other secrets.
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
},
|
|
15
|
-
};
|
|
10
|
+
const loggedCode = 'code' in err ? err.code : code;
|
|
11
|
+
console.error(`[ERROR] ${c.req.method} ${c.req.url} [${loggedCode}] ${redactSecretText(err.message)}`);
|
|
12
|
+
// Database text quotes queries, arguments and addresses, so it stays in the log.
|
|
13
|
+
const message = database?.message
|
|
14
|
+
?? (containsDatabaseText(err.message) ? 'An unexpected error occurred' : redactSecretText(err.message || 'An unexpected error occurred'));
|
|
15
|
+
const body = { success: false, error: { code: String(code), message } };
|
|
16
16
|
return c.json(body, status);
|
|
17
17
|
}
|
|
18
18
|
/** Throw an HTTP error with a code. */
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { createOathweaveVerifier, OATHWEAVE_AUDIENCE, } from '@hivemagnus/magnus-oathweave';
|
|
2
|
+
export { createOathweaveVerifier, OATHWEAVE_SESSION_CACHE_TTL_MS, OATHWEAVE_STREAM_RECHECK_MS, } from '@hivemagnus/magnus-oathweave';
|
|
3
|
+
let verifier;
|
|
4
|
+
/** Whether this server accepts Oathweave tokens. A partial configuration counts, and then fails closed. */
|
|
5
|
+
export function oathweaveConfigured() {
|
|
6
|
+
return Boolean(process.env['OATHWEAVE_ISSUER'] || process.env['OATHWEAVE_JWKS_URL'] || process.env['OATHWEAVE_USERINFO_URL']);
|
|
7
|
+
}
|
|
8
|
+
export function configuredOathweaveVerifier() {
|
|
9
|
+
if (verifier)
|
|
10
|
+
return verifier;
|
|
11
|
+
const issuer = process.env['OATHWEAVE_ISSUER'];
|
|
12
|
+
const jwksUrl = process.env['OATHWEAVE_JWKS_URL'];
|
|
13
|
+
const userinfoUrl = process.env['OATHWEAVE_USERINFO_URL'];
|
|
14
|
+
if (!issuer || !jwksUrl || !userinfoUrl)
|
|
15
|
+
throw new Error('Oathweave configuration is incomplete');
|
|
16
|
+
verifier = createOathweaveVerifier({ issuer, jwksUrl, userinfoUrl, audience: OATHWEAVE_AUDIENCE });
|
|
17
|
+
return verifier;
|
|
18
|
+
}
|
|
19
|
+
/** Drop the memoized verifier so a test can reconfigure the environment. */
|
|
20
|
+
export function resetOathweaveVerifier() {
|
|
21
|
+
verifier = undefined;
|
|
22
|
+
}
|
|
23
|
+
//# sourceMappingURL=oathweave-verifier.js.map
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { decodeProtectedHeader } from 'jose';
|
|
3
|
+
import { parseOathweaveLinks } from '../config/oathweave.js';
|
|
4
|
+
import { configuredOathweaveVerifier, OATHWEAVE_STREAM_RECHECK_MS, oathweaveConfigured, } from './oathweave-verifier.js';
|
|
5
|
+
/** Grants the web terminal, only to a person whose account link is ADMIN. No other route reads it. */
|
|
6
|
+
export const OATHWEAVE_TERMINAL_PERMISSION = 'magnus:terminal';
|
|
7
|
+
/** The token proved who the caller is, but that person has no access to Magnus. */
|
|
8
|
+
export class OathweaveAccessDenied extends Error {
|
|
9
|
+
}
|
|
10
|
+
let parsedLinks;
|
|
11
|
+
function identityLinks(raw) {
|
|
12
|
+
if (parsedLinks?.raw !== raw)
|
|
13
|
+
parsedLinks = { raw, links: parseOathweaveLinks(raw) };
|
|
14
|
+
return parsedLinks.links;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Map a verified identity through its one explicit account link. The link's role
|
|
18
|
+
* is a ceiling and the token's permissions grant within it, so neither alone
|
|
19
|
+
* confers privilege. IAM role names and email addresses are never consulted.
|
|
20
|
+
*/
|
|
21
|
+
export function linkedMagnusIdentity(identity, raw = process.env['MAGNUS_OATHWEAVE_IDENTITIES_JSON'] ?? '[]') {
|
|
22
|
+
let links;
|
|
23
|
+
try {
|
|
24
|
+
links = identityLinks(raw);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
throw new OathweaveAccessDenied('Identity links are unreadable');
|
|
28
|
+
}
|
|
29
|
+
const matches = links.filter((link) => link.issuer === identity.issuer && link.subject === identity.subject);
|
|
30
|
+
if (matches.length !== 1)
|
|
31
|
+
throw new OathweaveAccessDenied('An explicit unique account link is required');
|
|
32
|
+
const link = matches[0];
|
|
33
|
+
let role;
|
|
34
|
+
if (link.role === 'ADMIN' && identity.permissions.includes('magnus:admin'))
|
|
35
|
+
role = 'ADMIN';
|
|
36
|
+
else if (link.role !== 'VIEWER' && identity.permissions.includes('magnus:operate'))
|
|
37
|
+
role = 'OPERATOR';
|
|
38
|
+
else if (identity.permissions.includes('magnus:view'))
|
|
39
|
+
role = 'VIEWER';
|
|
40
|
+
else
|
|
41
|
+
throw new OathweaveAccessDenied('Magnus permission required');
|
|
42
|
+
// `email` is an audit actor label across this server (often read as
|
|
43
|
+
// `user.email ?? user.userId`, and `??` keeps a blank), so it must not be empty.
|
|
44
|
+
return {
|
|
45
|
+
userId: link.localUserId, email: link.localUserId, role, iat: identity.issuedAt, exp: identity.expiresAt,
|
|
46
|
+
// The fleet gateway applies the same rule, and also requires recent identity verification.
|
|
47
|
+
terminal: link.role === 'ADMIN' && identity.permissions.includes(OATHWEAVE_TERMINAL_PERMISSION),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
function isOathweaveToken(token) {
|
|
51
|
+
if (!oathweaveConfigured())
|
|
52
|
+
return false;
|
|
53
|
+
try {
|
|
54
|
+
return decodeProtectedHeader(token).alg === 'RS256';
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Authenticate an Oathweave bearer token. Undefined means the token is not this
|
|
62
|
+
* verifier's to judge (Oathweave is off, or it is not an RS256 JWT). Any throw is
|
|
63
|
+
* final: the caller must reject the request rather than try another verifier.
|
|
64
|
+
*/
|
|
65
|
+
export async function oathweaveAuthentication(token) {
|
|
66
|
+
if (!isOathweaveToken(token))
|
|
67
|
+
return undefined;
|
|
68
|
+
return linkedMagnusIdentity(await configuredOathweaveVerifier()(token));
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* A stable rate-limit identity for a fully verified token, or undefined. Rate
|
|
72
|
+
* limiters run before route authentication, so they cannot read the context user
|
|
73
|
+
* and must not trust an unverified `sub` a caller could rotate. The answer comes
|
|
74
|
+
* from the same session cache authentication uses, so it adds no identity traffic.
|
|
75
|
+
*/
|
|
76
|
+
export async function oathweaveRateLimitSubject(token) {
|
|
77
|
+
if (!isOathweaveToken(token))
|
|
78
|
+
return undefined;
|
|
79
|
+
try {
|
|
80
|
+
const identity = await configuredOathweaveVerifier()(token);
|
|
81
|
+
return createHash('sha256').update(JSON.stringify([identity.issuer, identity.subject])).digest('hex');
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/** How often an open stream re-checks its token. See OATHWEAVE_SESSION_CACHE_TTL_MS. */
|
|
88
|
+
export { OATHWEAVE_STREAM_RECHECK_MS };
|
|
89
|
+
/**
|
|
90
|
+
* Authentication runs once when a stream opens, so a long-lived stream would
|
|
91
|
+
* otherwise outlive logout and token expiry. Calls onRevoked once the bearer
|
|
92
|
+
* token stops authenticating; the browser reconnects with a fresh token.
|
|
93
|
+
* Returns a function that stops watching. Other credentials are not watched.
|
|
94
|
+
*/
|
|
95
|
+
export function watchOathweaveStream(authorization, onRevoked) {
|
|
96
|
+
const token = authorization?.startsWith('Bearer ') ? authorization.slice(7) : '';
|
|
97
|
+
if (!isOathweaveToken(token))
|
|
98
|
+
return () => undefined;
|
|
99
|
+
let stopped = false;
|
|
100
|
+
const stop = () => {
|
|
101
|
+
stopped = true;
|
|
102
|
+
clearInterval(timer);
|
|
103
|
+
};
|
|
104
|
+
const timer = setInterval(() => {
|
|
105
|
+
oathweaveAuthentication(token).catch(() => {
|
|
106
|
+
if (stopped)
|
|
107
|
+
return;
|
|
108
|
+
stop();
|
|
109
|
+
onRevoked();
|
|
110
|
+
});
|
|
111
|
+
}, OATHWEAVE_STREAM_RECHECK_MS);
|
|
112
|
+
timer.unref();
|
|
113
|
+
return stop;
|
|
114
|
+
}
|
|
115
|
+
//# sourceMappingURL=oathweave.js.map
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Redis } from 'ioredis';
|
|
2
2
|
import { incMetric } from '../routes/metrics.js';
|
|
3
3
|
import { heartbeatPeerAddress } from '../utils/heartbeat-peer.js';
|
|
4
|
+
import { oathweaveRateLimitSubject } from './oathweave.js';
|
|
4
5
|
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379';
|
|
5
6
|
const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
|
|
6
7
|
let _redis = null;
|
|
@@ -17,7 +18,8 @@ function getRedis() {
|
|
|
17
18
|
}
|
|
18
19
|
/**
|
|
19
20
|
* Sliding-window rate limiter backed by Redis.
|
|
20
|
-
* Keyed by
|
|
21
|
+
* Keyed by the verified subject of an Oathweave token, otherwise by client IP
|
|
22
|
+
* address. Falls through (allows) if Redis is unavailable.
|
|
21
23
|
*
|
|
22
24
|
* @param opts.windowMs Time window in milliseconds (default: 60_000 = 1 minute)
|
|
23
25
|
* @param opts.max Maximum requests per window (default: 100)
|
|
@@ -32,8 +34,15 @@ export function rateLimitMiddleware(opts = {}) {
|
|
|
32
34
|
// forwarding header must not let login or mutation clients rotate their
|
|
33
35
|
// rate-limit identity. The helper prefers the Node socket peer and only
|
|
34
36
|
// falls back to forwarded metadata in adapter-free/test contexts.
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
+
const authorization = c.req.header('Authorization');
|
|
38
|
+
const subject = authorization?.startsWith('Bearer ')
|
|
39
|
+
? await oathweaveRateLimitSubject(authorization.slice(7))
|
|
40
|
+
: undefined;
|
|
41
|
+
// Behind a same-origin ingress every browser shares one peer address, so a
|
|
42
|
+
// verified subject gets its own bucket. Anything else, including a token
|
|
43
|
+
// that failed verification, stays on the peer bucket.
|
|
44
|
+
const identity = subject ? `subject:${subject}` : heartbeatPeerAddress(c) || 'unknown-peer';
|
|
45
|
+
const key = `${REDIS_PREFIX}${prefix}:${identity}`;
|
|
37
46
|
const now = Date.now();
|
|
38
47
|
const windowStart = now - windowMs;
|
|
39
48
|
try {
|
package/dist/openapi/spec.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { skillJobRequest } from '../routes/hiveskills.js';
|
|
1
2
|
import { z as zSchema } from 'zod';
|
|
2
3
|
import { dashboardReserveSchema, dashboardStartSchema, dashboardFinishSchema, apiDrainAttestationSchema, schedulerBoundaryAttestationSchema } from '../services/resource-enrollment.js';
|
|
3
4
|
import { ardentScopeActivationSchema } from '../services/ardent-scope-enrollment.js';
|
|
@@ -5,7 +6,7 @@ import { participantWorkReconciliationSchema, historicalParticipantWorkReconcili
|
|
|
5
6
|
import { sharedResourceLeaseIdentitySchema, sharedResourceLeaseViewSchema, sharedResourceReconciliationSchema, sharedResourceReserveSchema, sharedResourceTerminalEvidenceSchema, } from '../schemas/shared-resource-lease.js';
|
|
6
7
|
import { managedCronRegistrationSchema, schedulerSourceSettlementSchema } from '../schemas/managed-cron.js';
|
|
7
8
|
import { legacyAdmissionRequestSchema, resourceClaimSchema } from '../security/managed-http.js';
|
|
8
|
-
import { magnusObservationSchema } from '@hivemagnus/magnus-contracts';
|
|
9
|
+
import { magnusObservationSchema, responseRequestSchema } from '@hivemagnus/magnus-contracts';
|
|
9
10
|
import { maintenanceSchema } from '../services/maintenance-service.js';
|
|
10
11
|
import { rolloutInputSchema } from '../services/rollout-service.js';
|
|
11
12
|
import { lessonSchema } from '../services/incident-lessons.js';
|
|
@@ -217,6 +218,7 @@ const componentSchemas = {
|
|
|
217
218
|
createdAt: { type: 'string', format: 'date-time' },
|
|
218
219
|
updatedAt: { type: 'string', format: 'date-time' },
|
|
219
220
|
agent: { type: 'object', nullable: true, properties: { version: { type: 'string' }, status: { type: 'string' } } },
|
|
221
|
+
health: { type: 'object', nullable: true, description: 'GET /nodes only: headline figures of the newest snapshot, null until the node reports one', properties: { timestamp: { type: 'string', format: 'date-time' }, cpuUsagePercent: { type: 'number' }, cpuCores: { type: 'integer' }, memoryTotalBytes: { type: 'number' }, memoryUsedBytes: { type: 'number' }, memoryPercent: { type: 'number' }, swapTotalBytes: { type: 'number' }, swapUsedBytes: { type: 'number' }, disks: { type: 'string', description: 'JSON-encoded disk array' } } },
|
|
220
222
|
},
|
|
221
223
|
},
|
|
222
224
|
User: {
|
|
@@ -655,7 +657,7 @@ const paths = {
|
|
|
655
657
|
get: {
|
|
656
658
|
tags: ['Nodes'],
|
|
657
659
|
summary: 'List nodes',
|
|
658
|
-
description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering. Each node has `upgradeReadiness`: `status` (ok, not-ready, or unknown for agents that do not report it) and `problems` with a code, a detail, a cause and a remedy, such as an enrollment stuck draining, a marker the server does not share, a fence ahead of the server, an unresolved ledger, low disk or missing npm.',
|
|
660
|
+
description: 'Returns all fleet nodes with agent info and health metadata. Supports pagination and status filtering. Each node has `upgradeReadiness`: `status` (ok, not-ready, or unknown for agents that do not report it) and `problems` with a code, a detail, a cause and a remedy, such as an enrollment stuck draining, a marker the server does not share, a fence ahead of the server, an unresolved ledger, low disk or missing npm. Each node also has `health`, the headline figures of its newest snapshot, or null when it has none, so a list needs no request per row.',
|
|
659
661
|
security: apiSecretAuth,
|
|
660
662
|
parameters: [
|
|
661
663
|
qp('page', 'Page number (1-based)', { type: 'integer' }),
|
|
@@ -2106,6 +2108,36 @@ const paths = {
|
|
|
2106
2108
|
},
|
|
2107
2109
|
},
|
|
2108
2110
|
// ──────────────── Stats ────────────────
|
|
2111
|
+
'/console/session': {
|
|
2112
|
+
get: {
|
|
2113
|
+
tags: ['Stats'],
|
|
2114
|
+
summary: 'Role and capabilities of the signed-in browser console user',
|
|
2115
|
+
security: [{ bearerAuth: [] }, { apiSecret: [] }],
|
|
2116
|
+
responses: {
|
|
2117
|
+
'200': success('Mapped role and console capabilities', {
|
|
2118
|
+
type: 'object',
|
|
2119
|
+
required: ['userId', 'role', 'capabilities'],
|
|
2120
|
+
properties: {
|
|
2121
|
+
userId: { type: 'string' },
|
|
2122
|
+
role: { type: 'string', enum: ['ADMIN', 'OPERATOR', 'VIEWER'] },
|
|
2123
|
+
capabilities: {
|
|
2124
|
+
type: 'object',
|
|
2125
|
+
required: ['hermes', 'terminal', 'agentVersion', 'magnusVersion', 'securityConsoleUrl'],
|
|
2126
|
+
properties: {
|
|
2127
|
+
hermes: { type: 'boolean' },
|
|
2128
|
+
terminal: { type: 'boolean', description: 'The fleet gateway is deployed and this person may use it.' },
|
|
2129
|
+
agentVersion: { type: 'string' },
|
|
2130
|
+
magnusVersion: { type: 'string' },
|
|
2131
|
+
securityConsoleUrl: { type: ['string', 'null'] },
|
|
2132
|
+
},
|
|
2133
|
+
},
|
|
2134
|
+
},
|
|
2135
|
+
}),
|
|
2136
|
+
'401': err401,
|
|
2137
|
+
'403': err403,
|
|
2138
|
+
},
|
|
2139
|
+
},
|
|
2140
|
+
},
|
|
2109
2141
|
'/stats/overview': {
|
|
2110
2142
|
get: {
|
|
2111
2143
|
tags: ['Stats'],
|
|
@@ -2387,7 +2419,8 @@ const paths = {
|
|
|
2387
2419
|
enabled: { type: 'boolean' },
|
|
2388
2420
|
host: { type: 'string' },
|
|
2389
2421
|
port: { type: 'integer', nullable: true },
|
|
2390
|
-
|
|
2422
|
+
keyConfigured: { type: 'boolean' },
|
|
2423
|
+
keyFingerprint: { type: 'string', nullable: true, description: 'Present for ADMIN only.' },
|
|
2391
2424
|
lastSyncedAt: { type: 'string', format: 'date-time', nullable: true },
|
|
2392
2425
|
},
|
|
2393
2426
|
}),
|
|
@@ -2703,6 +2736,8 @@ export function buildOpenApiSpec() {
|
|
|
2703
2736
|
tags,
|
|
2704
2737
|
components: {
|
|
2705
2738
|
securitySchemes: {
|
|
2739
|
+
skillsExecutorBearer: { type: 'http', scheme: 'bearer', bearerFormat: 'opaque', description: 'Dedicated endpoint-scoped registry credential; only configured targets and exact native sync operations, never general commands.' },
|
|
2740
|
+
cindertraceSignature: { type: 'apiKey', in: 'header', name: 'x-cindertrace-signature', description: 'Dedicated response HMAC bound to method, path, exact body, x-cindertrace-time and x-cindertrace-nonce. Responses are signed against the same request binding.' },
|
|
2706
2741
|
apiSecret: {
|
|
2707
2742
|
type: 'apiKey',
|
|
2708
2743
|
in: 'header',
|
|
@@ -2734,7 +2769,13 @@ export function buildOpenApiSpec() {
|
|
|
2734
2769
|
},
|
|
2735
2770
|
schemas: componentSchemas,
|
|
2736
2771
|
},
|
|
2737
|
-
paths,
|
|
2772
|
+
paths: { ...paths,
|
|
2773
|
+
'/api/v1/hiveskills/targets/{target}': { get: { summary: 'Verify exact enrolled native target and immutable client without dispatch', tags: ['Skills'], security: [{ skillsExecutorBearer: [] }], parameters: [{ name: 'target', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Read-only enrolled target posture; ready false retains operator review' }, 401: { description: 'Dedicated executor credential required' }, 403: { description: 'Target outside exact registry grant' } } } },
|
|
2774
|
+
'/api/v1/hiveskills/jobs/{id}': { post: { summary: 'Execute one exact approved HiveSkills publication through enrolled native admission', tags: ['Skills'], requestBody: { required: true, content: { 'application/json': { schema: zSchema.toJSONSchema(skillJobRequest) } } }, security: [{ skillsExecutorBearer: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }], responses: { 200: { description: 'Bounded durable outcome; admitted operations are never replayed' }, 409: { description: 'Publication conflict, not admitted or uncertain' } } }, get: { summary: 'Reconcile only the original signed HiveSkills operation receipt', tags: ['Skills'], security: [{ skillsExecutorBearer: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, { name: 'target', in: 'query', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Existing durable outcome' } } } },
|
|
2775
|
+
'/api/v1/security-integration/response/execute': { post: { summary: 'Execute an exact Cindertrace-approved response through managed node admission', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], requestBody: { required: true, content: { 'application/json': { schema: zSchema.toJSONSchema(responseRequestSchema) } } }, responses: { 200: { description: 'Signed completion receipt' }, 409: { description: 'Expired, already claimed, blocked, or uncertain execution' } } } },
|
|
2776
|
+
'/api/v1/security-integration/response/{id}': { get: { summary: 'Read an existing Cindertrace execution without replay', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Signed existing execution status' } } } },
|
|
2777
|
+
'/api/v1/security-integration/response/targets/{id}/health': { get: { summary: 'Read fresh target health for Cindertrace', tags: ['Cindertrace'], security: [{ cindertraceSignature: [] }], parameters: [{ name: 'id', in: 'path', required: true, schema: { type: 'string' } }], responses: { 200: { description: 'Signed minimal health snapshot' } } } },
|
|
2778
|
+
},
|
|
2738
2779
|
};
|
|
2739
2780
|
}
|
|
2740
2781
|
//# sourceMappingURL=spec.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** BullMQ rejects colons in queue names. Split the legacy logical name without
|
|
2
|
+
* changing a single Redis key: default "bull:<logical>:<suffix>" stays identical. */
|
|
3
|
+
export function bullQueueIdentity(namespace, queue) {
|
|
4
|
+
const logical = namespace + queue, index = logical.lastIndexOf(':');
|
|
5
|
+
return index < 0
|
|
6
|
+
? { name: logical, prefix: 'bull' }
|
|
7
|
+
: { name: logical.slice(index + 1), prefix: 'bull:' + logical.slice(0, index) };
|
|
8
|
+
}
|
|
9
|
+
//# sourceMappingURL=names.js.map
|