@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.
Files changed (89) hide show
  1. package/.env.example +37 -0
  2. package/README.md +36 -0
  3. package/agent-version.json +1 -1
  4. package/dist/ai/TOOLS.md +1 -1
  5. package/dist/cli/args.js +3 -1
  6. package/dist/cli/env-catalog.js +16 -0
  7. package/dist/cli/migrations.js +22 -0
  8. package/dist/cli/verify-initialization.js +12 -0
  9. package/dist/cli.js +28 -0
  10. package/dist/config/console-path.js +17 -0
  11. package/dist/config/env.js +32 -5
  12. package/dist/config/features.js +4 -1
  13. package/dist/config/oathweave.js +42 -0
  14. package/dist/config/rehearsal.js +22 -0
  15. package/dist/db/generated/prisma/internal/class.js +4 -4
  16. package/dist/db/generated/prisma/internal/prismaNamespace.js +17 -1
  17. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +17 -1
  18. package/dist/db/generated/prisma/models/CindertraceResponse.js +2 -0
  19. package/dist/db/generated/prisma/models/ProductInitialization.js +2 -0
  20. package/dist/db/read-retry.js +5 -1
  21. package/dist/db/seed.js +8 -0
  22. package/dist/index.js +28 -37
  23. package/dist/middleware/auth.js +21 -0
  24. package/dist/middleware/error-handler.js +10 -10
  25. package/dist/middleware/oathweave-verifier.js +23 -0
  26. package/dist/middleware/oathweave.js +115 -0
  27. package/dist/middleware/rate-limit.js +12 -3
  28. package/dist/openapi/spec.js +45 -4
  29. package/dist/queue/names.js +9 -0
  30. package/dist/queue/workers.js +9 -68
  31. package/dist/routes/assistant.js +30 -12
  32. package/dist/routes/cindertrace-response.js +183 -0
  33. package/dist/routes/console.js +33 -0
  34. package/dist/routes/cron.js +8 -5
  35. package/dist/routes/events-stream.js +10 -0
  36. package/dist/routes/files.js +2 -1
  37. package/dist/routes/fleet-gateway.js +69 -0
  38. package/dist/routes/heartbeat.js +1 -6
  39. package/dist/routes/hermes.js +26 -6
  40. package/dist/routes/hiveskills.js +116 -0
  41. package/dist/routes/library.js +3 -2
  42. package/dist/routes/logs.js +5 -0
  43. package/dist/routes/metrics.js +4 -3
  44. package/dist/routes/nodes.js +45 -8
  45. package/dist/routes/ops.js +24 -6
  46. package/dist/routes/profiling.js +2 -1
  47. package/dist/routes/roles.js +17 -9
  48. package/dist/routes/security-fusion.js +15 -600
  49. package/dist/routes/stats.js +8 -5
  50. package/dist/routes/terminal-ticket.js +3 -3
  51. package/dist/routes/terminal.js +2 -2
  52. package/dist/routes/webhooks.js +15 -15
  53. package/dist/sdk/node-api-client.js +23 -1
  54. package/dist/security/cindertrace-response.js +14 -0
  55. package/dist/security/native-completion-registry.js +14 -0
  56. package/dist/server.js +11 -14
  57. package/dist/services/ai-assistant-service.js +32 -6
  58. package/dist/services/ai-tools.js +15 -8
  59. package/dist/services/ardent-scope-enrollment.js +4 -2
  60. package/dist/services/command-resource-leases.js +2 -4
  61. package/dist/services/forecast-service.js +23 -8
  62. package/dist/services/ops-service.js +20 -25
  63. package/dist/services/prediction-service.js +2 -1
  64. package/dist/services/resource-enrollment.js +57 -4
  65. package/dist/services/secret-views.js +72 -0
  66. package/dist/services/security-export.js +4 -6
  67. package/dist/services/topology-service.js +2 -1
  68. package/dist/services/webhook-security.js +10 -1
  69. package/dist/services/webhook-service.js +14 -6
  70. package/dist/utils/node-url.js +2 -1
  71. package/dist/utils/safe-errors.js +130 -0
  72. package/node_modules/@hivemagnus/magnus-oathweave/LICENSE +202 -0
  73. package/node_modules/@hivemagnus/magnus-oathweave/README.md +20 -0
  74. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.d.ts +61 -0
  75. package/node_modules/@hivemagnus/magnus-oathweave/dist/index.js +148 -0
  76. package/node_modules/@hivemagnus/magnus-oathweave/package.json +54 -0
  77. package/package.json +7 -3
  78. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration-impact.json +128 -0
  79. package/prisma/migrations/20261103000000_cindertrace_response_bridge/migration.sql +6 -0
  80. package/prisma/migrations/20261105010000_base_initialization/magnus-impact.json +1 -0
  81. package/prisma/migrations/20261105010000_base_initialization/migration.sql +5 -0
  82. package/prisma/schema.prisma +22 -0
  83. package/dist/services/security-fusion-actions.js +0 -22
  84. package/dist/services/security-fusion-evidence-integrity.js +0 -144
  85. package/dist/services/security-fusion-ingest.js +0 -101
  86. package/dist/services/security-fusion-outbox.js +0 -185
  87. package/dist/services/security-fusion-receipt-chain.js +0 -134
  88. package/dist/services/security-fusion-service.js +0 -751
  89. 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'
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=CindertraceResponse.js.map
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=ProductInitialization.js.map
@@ -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
- throw new Error(`${label} failed after ${attempts} read attempts: ${message}`);
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 { securityFusionLocal, securityFusionMode, startSecurityExporter, stopSecurityExporter } = await import('./services/security-export.js');
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
- writeRuntimeFile({ version: CONTROL_API_VERSION, port: listenPort, host: listener.hostname });
61
- startNodeIssueMonitor();
62
- startUpgradeReadinessMonitor();
63
- startApplicationProbes();
64
- startLogRetention();
65
- startHealthRetention();
66
- startNotificationRetention();
67
- if (securityFusionLocal()) {
68
- startSecurityFusionRetention();
69
- startSecurityFusionOutboxDispatcher();
70
- }
71
- startSecurityExporter();
72
- startCommandWorker();
73
- startRolloutReconciler();
74
- startWebhookWorker();
75
- startCodexFleetMonitor();
76
- startUpdateCheck();
77
- if (features.hermesRegistry) {
78
- startExtensionSourceMonitor();
79
- void importPackageSkills().then(result => console.log(`[hermes-registry] catalog import: ${JSON.stringify(result)}`))
80
- .catch(error => console.error('[hermes-registry] catalog import failed', error));
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.clients)
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();
@@ -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 status = 'status' in err ? err.status : 500;
5
- const code = 'code' in err ? err.code : 'INTERNAL_ERROR';
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
- console.error(`[ERROR] ${c.req.method} ${c.req.url} [${code}] ${redactSecretText(err.message)}`);
9
- const body = {
10
- success: false,
11
- error: {
12
- code: String(code),
13
- message: redactSecretText(err.message || 'An unexpected error occurred'),
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 client IP address. Falls through (allows) if Redis is unavailable.
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 ip = heartbeatPeerAddress(c) || 'unknown-peer';
36
- const key = `${REDIS_PREFIX}${prefix}:${ip}`;
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 {
@@ -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
- keyFingerprint: { type: 'string', nullable: true },
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