@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
|
@@ -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',
|
|
@@ -202,6 +203,10 @@ export const UserScalarFieldEnum = {
|
|
|
202
203
|
createdAt: 'createdAt',
|
|
203
204
|
updatedAt: 'updatedAt'
|
|
204
205
|
};
|
|
206
|
+
export const ProductInitializationScalarFieldEnum = {
|
|
207
|
+
id: 'id',
|
|
208
|
+
completedAt: 'completedAt'
|
|
209
|
+
};
|
|
205
210
|
export const NodeScalarFieldEnum = {
|
|
206
211
|
id: 'id',
|
|
207
212
|
name: 'name',
|
|
@@ -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',
|
|
@@ -174,6 +175,10 @@ export const UserScalarFieldEnum = {
|
|
|
174
175
|
createdAt: 'createdAt',
|
|
175
176
|
updatedAt: 'updatedAt'
|
|
176
177
|
};
|
|
178
|
+
export const ProductInitializationScalarFieldEnum = {
|
|
179
|
+
id: 'id',
|
|
180
|
+
completedAt: 'completedAt'
|
|
181
|
+
};
|
|
177
182
|
export const NodeScalarFieldEnum = {
|
|
178
183
|
id: 'id',
|
|
179
184
|
name: 'name',
|
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,6 +12,7 @@ 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();
|
|
@@ -48,24 +50,28 @@ const { writeRuntimeFile } = await import('./utils/runtime-file.js');
|
|
|
48
50
|
const { CONTROL_API_VERSION } = await import('./config/version.js');
|
|
49
51
|
/** Background services shared by the TLS and development listeners. */
|
|
50
52
|
function startBackgroundServices(listenPort) {
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
.
|
|
68
|
-
|
|
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');
|
|
69
75
|
}
|
|
70
76
|
// Relative TLS paths resolve against the working directory; prefer absolute paths.
|
|
71
77
|
const repoRoot = process.cwd();
|
|
@@ -101,7 +107,7 @@ const server = listener.useTls
|
|
|
101
107
|
console.log(`🚀 Magnus server listening on http://${listener.hostname}:${info.port} (development opt-in)`);
|
|
102
108
|
startBackgroundServices(info.port);
|
|
103
109
|
});
|
|
104
|
-
const terminalWss = setupTerminalWebSocket(server);
|
|
110
|
+
const terminalWss = rehearsalEnabled() ? undefined : setupTerminalWebSocket(server);
|
|
105
111
|
// ── Graceful shutdown ──
|
|
106
112
|
let shuttingDown = false;
|
|
107
113
|
async function shutdown(signal) {
|
|
@@ -128,11 +134,11 @@ async function shutdown(signal) {
|
|
|
128
134
|
stopCodexFleetMonitor();
|
|
129
135
|
stopUpdateCheck();
|
|
130
136
|
stopExtensionSourceMonitor();
|
|
131
|
-
for (const client of terminalWss
|
|
137
|
+
for (const client of terminalWss?.clients ?? [])
|
|
132
138
|
client.terminate();
|
|
133
139
|
const httpClosed = new Promise((resolvePromise) => server.close(() => resolvePromise()));
|
|
134
140
|
server.closeAllConnections?.();
|
|
135
|
-
const webSocketsClosed = new Promise((resolvePromise) => terminalWss.close(() => resolvePromise()));
|
|
141
|
+
const webSocketsClosed = terminalWss ? new Promise((resolvePromise) => terminalWss.close(() => resolvePromise())) : Promise.resolve();
|
|
136
142
|
await Promise.all([httpClosed, webSocketsClosed]);
|
|
137
143
|
await closeCommandQueue();
|
|
138
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';
|
|
@@ -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,7 @@ 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.' },
|
|
2706
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.' },
|
|
2707
2741
|
apiSecret: {
|
|
2708
2742
|
type: 'apiKey',
|
|
@@ -2736,6 +2770,8 @@ export function buildOpenApiSpec() {
|
|
|
2736
2770
|
schemas: componentSchemas,
|
|
2737
2771
|
},
|
|
2738
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' } } } },
|
|
2739
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' } } } },
|
|
2740
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' } } } },
|
|
2741
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' } } } },
|
|
@@ -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
|
package/dist/queue/workers.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { ResourceLeaseConflict } from '../services/command-resource-leases.js';
|
|
2
2
|
import { recordMonitorPulse } from '../services/monitor-pulse.js';
|
|
3
3
|
import { Queue, Worker } from 'bullmq';
|
|
4
|
+
import { bullQueueIdentity } from './names.js';
|
|
5
|
+
import { assertLiveExecution } from '../config/rehearsal.js';
|
|
4
6
|
import { addDurableJob } from './requeue.js';
|
|
5
7
|
import { validateRemediationCommandForDispatch } from '../services/remediation-service.js';
|
|
6
8
|
import { reconcileCommands } from '../services/command-lifecycle.js';
|
|
@@ -15,7 +17,7 @@ import { incMetric } from '../routes/metrics.js';
|
|
|
15
17
|
import { verifyCommandResultSignature } from '../security/command-result-signature.js';
|
|
16
18
|
// ─── Command execution queue ───
|
|
17
19
|
const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
|
|
18
|
-
const COMMAND_QUEUE =
|
|
20
|
+
const COMMAND_QUEUE = bullQueueIdentity(REDIS_PREFIX, 'commands');
|
|
19
21
|
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379';
|
|
20
22
|
let _commandQueue = null;
|
|
21
23
|
let _commandWorker = null;
|
|
@@ -25,8 +27,10 @@ const COMMAND_EXECUTION_LEASE_MS = 5 * 60 * 1_000;
|
|
|
25
27
|
const NODE_HEARTBEAT_FRESHNESS_MS = Number(process.env['STALE_NODE_THRESHOLD_MS'] ?? 120_000);
|
|
26
28
|
/** Get the singleton command queue instance. */
|
|
27
29
|
export function getCommandQueue() {
|
|
30
|
+
assertLiveExecution();
|
|
28
31
|
if (!_commandQueue) {
|
|
29
|
-
_commandQueue = new Queue(COMMAND_QUEUE, {
|
|
32
|
+
_commandQueue = new Queue(COMMAND_QUEUE.name, {
|
|
33
|
+
prefix: COMMAND_QUEUE.prefix,
|
|
30
34
|
connection: { url: REDIS_URL },
|
|
31
35
|
defaultJobOptions: {
|
|
32
36
|
attempts: 1,
|
|
@@ -68,9 +72,10 @@ export async function enqueueCommand(commandId) {
|
|
|
68
72
|
}
|
|
69
73
|
/** Start the command execution worker. */
|
|
70
74
|
export function startCommandWorker() {
|
|
75
|
+
assertLiveExecution();
|
|
71
76
|
if (_commandWorker)
|
|
72
77
|
return _commandWorker;
|
|
73
|
-
const worker = new Worker(COMMAND_QUEUE, async (job) => {
|
|
78
|
+
const worker = new Worker(COMMAND_QUEUE.name, async (job) => {
|
|
74
79
|
const db = getDb();
|
|
75
80
|
const { commandId } = job.data;
|
|
76
81
|
const now = new Date();
|
|
@@ -245,6 +250,7 @@ export function startCommandWorker() {
|
|
|
245
250
|
}, {
|
|
246
251
|
connection: { url: REDIS_URL },
|
|
247
252
|
concurrency: 10,
|
|
253
|
+
prefix: COMMAND_QUEUE.prefix,
|
|
248
254
|
});
|
|
249
255
|
worker.on('failed', (job, err) => {
|
|
250
256
|
console.error(`[CommandWorker] Job ${job?.id} failed:`, err.message);
|