@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
@@ -44,6 +44,15 @@ enum UserRole {
44
44
  VIEWER
45
45
  }
46
46
 
47
+ // Product-owned completion receipt. A schema migration alone does not prove that
48
+ // the fresh installation's required base-data transaction committed.
49
+ model ProductInitialization {
50
+ id String @id
51
+ completedAt DateTime @default(now())
52
+
53
+ @@map("product_initializations")
54
+ }
55
+
47
56
  // ─── Nodes ───
48
57
 
49
58
  model Node {
@@ -2183,3 +2192,16 @@ model ManagedCronOccurrence {
2183
2192
  @@index([nodeId, state])
2184
2193
  @@map("managed_cron_occurrences")
2185
2194
  }
2195
+
2196
+ /// Narrow Cindertrace bridge ledger; never replays an uncertain response.
2197
+ model CindertraceResponse {
2198
+ id String @id
2199
+ actionOperation String @unique
2200
+ nodeId String
2201
+ requestSha256 String
2202
+ requestJson String
2203
+ status String @default("claimed")
2204
+ responseJson String?
2205
+ createdAt DateTime @default(now())
2206
+ @@map("cindertrace_responses")
2207
+ }
@@ -1,22 +0,0 @@
1
- export const SECURITY_FUSION_ACTION_TRANSITIONS = {
2
- previewed: ['pending_approval', 'cancelled'],
3
- pending_approval: ['dispatch_pending', 'rejected', 'cancelled'],
4
- dispatch_pending: ['executing', 'verifying', 'succeeded', 'failed'],
5
- executing: ['verifying', 'succeeded', 'failed', 'release_pending'],
6
- verifying: ['succeeded', 'failed'],
7
- succeeded: ['release_pending'],
8
- release_pending: ['releasing', 'released', 'failed'],
9
- releasing: ['released', 'failed'],
10
- rejected: [],
11
- cancelled: [],
12
- failed: [],
13
- released: [],
14
- };
15
- export function canTransitionSecurityFusionAction(from, to) {
16
- return SECURITY_FUSION_ACTION_TRANSITIONS[from].includes(to);
17
- }
18
- export function securityFusionActionSources(to) {
19
- return Object.keys(SECURITY_FUSION_ACTION_TRANSITIONS)
20
- .filter((from) => canTransitionSecurityFusionAction(from, to));
21
- }
22
- //# sourceMappingURL=security-fusion-actions.js.map
@@ -1,144 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { createReadStream } from 'node:fs';
3
- import { lstat, mkdir, rename } from 'node:fs/promises';
4
- import { basename, isAbsolute, join, relative, resolve } from 'node:path';
5
- import { getDb } from '../db/index.js';
6
- import { setMetricGauge } from '../routes/metrics.js';
7
- function evidenceBase() {
8
- return resolve(process.env['SECURITY_FUSION_EVIDENCE_DIR'] ?? './state/security-fusion/evidence');
9
- }
10
- function pathInside(base, candidate) {
11
- const rel = relative(base, resolve(candidate));
12
- return rel.length > 0 && !rel.startsWith('..') && !isAbsolute(rel);
13
- }
14
- async function hashFile(path) {
15
- const hash = createHash('sha256');
16
- await new Promise((resolvePromise, reject) => {
17
- const stream = createReadStream(path);
18
- stream.on('data', (chunk) => hash.update(chunk));
19
- stream.on('error', reject);
20
- stream.on('end', resolvePromise);
21
- });
22
- return hash.digest('hex');
23
- }
24
- export async function ensurePrivateEvidenceDirectory(path) {
25
- await mkdir(path, { recursive: true, mode: 0o700 });
26
- const info = await lstat(path);
27
- const expectedUid = typeof process.geteuid === 'function' ? process.geteuid() : info.uid;
28
- if (!info.isDirectory() || info.isSymbolicLink()
29
- || info.uid !== expectedUid || (info.mode & 0o077) !== 0) {
30
- throw new Error(`Security Fusion evidence directory is unsafe: ${path}`);
31
- }
32
- }
33
- async function quarantineEvidence(row, status, reason, actualBytes) {
34
- const base = evidenceBase();
35
- let storagePath = row.storagePath;
36
- if (status === 'quarantined_hash_mismatch' && pathInside(base, row.storagePath)) {
37
- const quarantineDir = join(base, 'quarantine');
38
- await ensurePrivateEvidenceDirectory(quarantineDir);
39
- const target = join(quarantineDir, `${row.id}.${basename(row.storagePath)}`);
40
- await rename(row.storagePath, target);
41
- storagePath = target;
42
- }
43
- await getDb().securityFusionEvidence.update({
44
- where: { id: row.id },
45
- data: {
46
- byteSize: actualBytes,
47
- integrityStatus: status,
48
- quarantinedAt: new Date(),
49
- quarantineReason: reason,
50
- storagePath,
51
- },
52
- });
53
- }
54
- /**
55
- * Reconcile every retained artifact before byte admission and retention start.
56
- * Missing, unsafe, non-regular, and hash-mismatched evidence remains recorded
57
- * but is quarantined from normal evidence use.
58
- */
59
- export async function reconcileSecurityFusionEvidence() {
60
- const base = evidenceBase();
61
- await ensurePrivateEvidenceDirectory(base);
62
- const mismatchedCases = new Set();
63
- let checked = 0;
64
- let updated = 0;
65
- let quarantined = 0;
66
- let cursor;
67
- while (true) {
68
- const rows = await getDb().securityFusionEvidence.findMany({
69
- select: {
70
- id: true,
71
- caseId: true,
72
- sha256: true,
73
- storagePath: true,
74
- byteSize: true,
75
- integrityStatus: true,
76
- },
77
- orderBy: { id: 'asc' },
78
- take: 250,
79
- ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
80
- });
81
- for (const row of rows) {
82
- checked += 1;
83
- if (row.integrityStatus.startsWith('quarantined_')) {
84
- mismatchedCases.add(row.caseId);
85
- quarantined += 1;
86
- continue;
87
- }
88
- if (!pathInside(base, row.storagePath)) {
89
- mismatchedCases.add(row.caseId);
90
- quarantined += 1;
91
- await quarantineEvidence(row, 'quarantined_unsafe_path', 'storage path is outside the evidence root', row.byteSize);
92
- continue;
93
- }
94
- let info;
95
- try {
96
- info = await lstat(row.storagePath);
97
- }
98
- catch (error) {
99
- if (error.code !== 'ENOENT')
100
- throw error;
101
- mismatchedCases.add(row.caseId);
102
- quarantined += 1;
103
- await quarantineEvidence(row, 'quarantined_missing', 'retained evidence file is missing', 0);
104
- continue;
105
- }
106
- if (!info.isFile() || info.isSymbolicLink()) {
107
- mismatchedCases.add(row.caseId);
108
- quarantined += 1;
109
- await quarantineEvidence(row, 'quarantined_unsafe_file', 'retained evidence is not a regular file', row.byteSize);
110
- continue;
111
- }
112
- const actualBytes = info.size;
113
- const actualSha256 = await hashFile(row.storagePath);
114
- if (actualBytes !== row.byteSize)
115
- mismatchedCases.add(row.caseId);
116
- if (actualSha256 !== row.sha256) {
117
- mismatchedCases.add(row.caseId);
118
- quarantined += 1;
119
- await quarantineEvidence(row, 'quarantined_hash_mismatch', 'retained evidence hash does not match its manifest', actualBytes);
120
- continue;
121
- }
122
- if (actualBytes !== row.byteSize)
123
- updated += 1;
124
- await getDb().securityFusionEvidence.update({
125
- where: { id: row.id },
126
- data: {
127
- byteSize: actualBytes,
128
- integrityStatus: 'verified',
129
- quarantinedAt: null,
130
- quarantineReason: null,
131
- },
132
- });
133
- }
134
- if (rows.length < 250)
135
- break;
136
- cursor = rows.at(-1)?.id;
137
- if (!cursor)
138
- break;
139
- }
140
- setMetricGauge('securityFusionEvidenceReconciliationMismatchedCases', mismatchedCases.size);
141
- setMetricGauge('securityFusionEvidenceQuarantinedArtifacts', quarantined);
142
- return { checked, updated, quarantined, mismatchedCases: mismatchedCases.size };
143
- }
144
- //# sourceMappingURL=security-fusion-evidence-integrity.js.map
@@ -1,101 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { securityFusionSourceSchema } from '@hivemagnus/magnus-contracts';
3
- import { HttpError } from '../middleware/error-handler.js';
4
- export const SECURITY_FUSION_MAX_BATCH_BYTES = 262_144;
5
- export const SECURITY_FUSION_MAX_EVENT_BYTES = 32_768;
6
- export const SECURITY_FUSION_MAX_EVENTS = 100;
7
- export const SECURITY_FUSION_MAX_NESTING_DEPTH = 8;
8
- function sources(value) {
9
- const candidates = Array.isArray(value)
10
- ? value
11
- : typeof value === 'string'
12
- ? value.split(',').map((item) => item.trim()).filter(Boolean)
13
- : [];
14
- const parsed = securityFusionSourceSchema.array().min(1).safeParse(candidates);
15
- if (!parsed.success)
16
- throw new Error('collector allowedSources must contain recognized Security Fusion sources');
17
- return [...new Set(parsed.data)];
18
- }
19
- export function configuredCollectorCredentials(env = process.env) {
20
- const credentials = new Map();
21
- const encoded = env['SECURITY_FUSION_COLLECTOR_CREDENTIALS_JSON'];
22
- if (encoded) {
23
- const value = JSON.parse(encoded);
24
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
25
- throw new Error('SECURITY_FUSION_COLLECTOR_CREDENTIALS_JSON must be an object');
26
- }
27
- for (const [collectorId, raw] of Object.entries(value)) {
28
- if (!/^[a-zA-Z0-9._:-]{1,160}$/u.test(collectorId)) {
29
- throw new Error('collector IDs must be 1-160 safe identifier characters');
30
- }
31
- const config = typeof raw === 'string' ? { secret: raw } : raw;
32
- if (!config || typeof config !== 'object' || typeof config['secret'] !== 'string' || config['secret'].length < 16) {
33
- throw new Error(`collector ${collectorId} must have a secret of at least 16 characters`);
34
- }
35
- credentials.set(collectorId, {
36
- collectorId,
37
- secret: config['secret'],
38
- allowedSources: sources(config['allowedSources'] ?? securityFusionSourceSchema.options),
39
- displayName: typeof config['displayName'] === 'string' ? config['displayName'] : collectorId,
40
- schemaVersion: '1.0',
41
- dataClassification: typeof config['dataClassification'] === 'string'
42
- ? config['dataClassification']
43
- : 'restricted',
44
- });
45
- }
46
- }
47
- const legacySecret = env['SECURITY_FUSION_INGEST_SECRET'];
48
- if (legacySecret) {
49
- const collectorId = env['SECURITY_FUSION_COLLECTOR_ID'] ?? 'magnus-security-collector';
50
- if (!/^[a-zA-Z0-9._:-]{1,160}$/u.test(collectorId) || legacySecret.length < 16) {
51
- throw new Error('legacy collector ID or credential is invalid');
52
- }
53
- if (!credentials.has(collectorId)) {
54
- credentials.set(collectorId, {
55
- collectorId,
56
- secret: legacySecret,
57
- allowedSources: sources(env['SECURITY_FUSION_COLLECTOR_ALLOWED_SOURCES'] ?? securityFusionSourceSchema.options),
58
- displayName: collectorId,
59
- schemaVersion: '1.0',
60
- dataClassification: env['SECURITY_FUSION_DATA_CLASSIFICATION'] ?? 'restricted',
61
- });
62
- }
63
- }
64
- return credentials;
65
- }
66
- export function collectorCredentialFingerprint(secret) {
67
- return createHash('sha256').update(secret).digest('hex').slice(0, 16);
68
- }
69
- function assertDepth(value, depth) {
70
- if (depth > SECURITY_FUSION_MAX_NESTING_DEPTH) {
71
- throw new HttpError(400, 'INGEST_NESTING_TOO_DEEP', 'Event nesting exceeds the permitted depth');
72
- }
73
- if (Array.isArray(value)) {
74
- for (const item of value)
75
- assertDepth(item, depth + 1);
76
- }
77
- else if (value !== null && typeof value === 'object') {
78
- for (const item of Object.values(value))
79
- assertDepth(item, depth + 1);
80
- }
81
- }
82
- export function validateSecurityFusionBatchEnvelope(value, rawBytes) {
83
- if (rawBytes > SECURITY_FUSION_MAX_BATCH_BYTES) {
84
- throw new HttpError(413, 'PAYLOAD_TOO_LARGE', 'Security Fusion batch exceeds the byte limit');
85
- }
86
- if (!value || typeof value !== 'object' || Array.isArray(value))
87
- return;
88
- const events = value['events'];
89
- if (!Array.isArray(events))
90
- return;
91
- if (events.length > SECURITY_FUSION_MAX_EVENTS) {
92
- throw new HttpError(400, 'INGEST_EVENT_LIMIT', `A batch may contain at most ${SECURITY_FUSION_MAX_EVENTS} events`);
93
- }
94
- for (const event of events) {
95
- assertDepth(event, 1);
96
- if (Buffer.byteLength(JSON.stringify(event), 'utf8') > SECURITY_FUSION_MAX_EVENT_BYTES) {
97
- throw new HttpError(400, 'INGEST_EVENT_TOO_LARGE', 'An individual event exceeds the byte limit');
98
- }
99
- }
100
- }
101
- //# sourceMappingURL=security-fusion-ingest.js.map
@@ -1,185 +0,0 @@
1
- import { getDb } from '../db/index.js';
2
- import { enqueueCommand } from '../queue/workers.js';
3
- import { securityFusionActionSources } from './security-fusion-actions.js';
4
- const OUTBOX_LEASE_MS = 30_000;
5
- const OUTBOX_INTERVAL_MS = 2_000;
6
- /**
7
- * Absolute path of an operator-installed containment helper on managed nodes
8
- * (`SECURITY_FUSION_CONTAINMENT_COMMAND`). Magnus does not ship one; without it
9
- * release records fail closed and stay visible for manual handling.
10
- */
11
- function containmentCommand() {
12
- const command = process.env['SECURITY_FUSION_CONTAINMENT_COMMAND']?.trim() ?? '';
13
- if (!/^\/[A-Za-z0-9._/-]{1,255}$/.test(command) || command.includes('..'))
14
- throw new Error('SECURITY_FUSION_CONTAINMENT_COMMAND is not configured');
15
- return command;
16
- }
17
- function containmentArgs(operation) {
18
- if (operation !== 'release')
19
- throw new Error('Embedded containment execute authority is retired');
20
- return ['release'];
21
- }
22
- async function dispatchOutboxRecord(outboxId) {
23
- const db = getDb();
24
- const now = new Date();
25
- const claimed = await db.securityFusionCommandOutbox.updateMany({
26
- where: {
27
- id: outboxId,
28
- operation: 'release',
29
- OR: [
30
- { status: { in: ['pending', 'failed'] }, nextAttemptAt: { lte: now } },
31
- { status: 'dispatching', leaseExpiresAt: { lt: now } },
32
- ],
33
- },
34
- data: {
35
- status: 'dispatching',
36
- leaseExpiresAt: new Date(now.getTime() + OUTBOX_LEASE_MS),
37
- attempts: { increment: 1 },
38
- lastError: null,
39
- },
40
- });
41
- if (claimed.count !== 1)
42
- return false;
43
- const outbox = await db.securityFusionCommandOutbox.findUnique({ where: { id: outboxId } });
44
- const action = outbox
45
- ? await db.securityFusionAction.findUnique({ where: { id: outbox.actionId } })
46
- : null;
47
- if (!outbox || !action)
48
- throw new Error('Security Fusion outbox references a missing action');
49
- const node = await db.node.findUnique({ where: { id: action.nodeId } });
50
- if (!node)
51
- throw new Error('Security Fusion containment target no longer exists');
52
- const command = containmentCommand();
53
- const args = containmentArgs(outbox.operation);
54
- const idempotencyKey = `security-fusion:${action.id}:${node.id}:${outbox.operation}`;
55
- const remote = await db.remoteCommand.upsert({
56
- where: { idempotencyKey },
57
- create: {
58
- nodeId: node.id,
59
- command,
60
- args: JSON.stringify(args),
61
- environment: '{}',
62
- timeoutSeconds: 120,
63
- status: 'QUEUED',
64
- riskLevel: 'high',
65
- idempotencyKey,
66
- correlationId: outbox.id,
67
- createdBy: outbox.actorId,
68
- },
69
- update: {},
70
- });
71
- await db.securityFusionCommandOutbox.update({
72
- where: { id: outbox.id },
73
- data: { remoteCommandId: remote.id },
74
- });
75
- await enqueueCommand(remote.id);
76
- await db.$transaction([
77
- db.securityFusionCommandOutbox.update({
78
- where: { id: outbox.id },
79
- data: {
80
- status: 'dispatched',
81
- leaseExpiresAt: null,
82
- lastError: null,
83
- dispatchedAt: new Date(),
84
- },
85
- }),
86
- db.securityFusionAction.updateMany({
87
- where: {
88
- id: action.id,
89
- status: {
90
- in: securityFusionActionSources('releasing'),
91
- },
92
- },
93
- data: {
94
- status: 'releasing',
95
- resultJson: JSON.stringify({
96
- commandId: remote.id,
97
- outboxId: outbox.id,
98
- operation: outbox.operation,
99
- }),
100
- },
101
- }),
102
- ]);
103
- return true;
104
- }
105
- export async function dispatchSecurityFusionOutbox() {
106
- const db = getDb();
107
- const now = new Date();
108
- // Initial containment belongs exclusively to the standalone Security Fusion
109
- // service. Keep only the independent emergency release path in this embedded
110
- // compatibility service, and quarantine any row old code could retry.
111
- await db.securityFusionCommandOutbox.updateMany({
112
- where: {
113
- operation: 'execute',
114
- status: { in: ['pending', 'failed'] },
115
- },
116
- data: {
117
- status: 'blocked',
118
- leaseExpiresAt: null,
119
- lastError: 'Embedded execute quarantined; standalone Security Fusion approval is required',
120
- },
121
- });
122
- await db.securityFusionCommandOutbox.updateMany({
123
- where: {
124
- operation: 'execute',
125
- status: { in: ['dispatching', 'unknown'] },
126
- },
127
- data: {
128
- status: 'blocked',
129
- leaseExpiresAt: null,
130
- lastError: 'Embedded execute quarantined; execution may have occurred; do not retry',
131
- },
132
- });
133
- const records = await db.securityFusionCommandOutbox.findMany({
134
- where: {
135
- operation: 'release',
136
- OR: [
137
- { status: { in: ['pending', 'failed'] }, nextAttemptAt: { lte: now } },
138
- { status: 'dispatching', leaseExpiresAt: { lt: now } },
139
- ],
140
- },
141
- orderBy: { createdAt: 'asc' },
142
- take: 20,
143
- });
144
- let dispatched = 0;
145
- for (const record of records) {
146
- try {
147
- if (await dispatchOutboxRecord(record.id))
148
- dispatched += 1;
149
- }
150
- catch (error) {
151
- const message = error instanceof Error ? error.message : String(error);
152
- const retryDelay = Math.min(60_000, 1_000 * (2 ** Math.min(record.attempts, 6)));
153
- await db.securityFusionCommandOutbox.updateMany({
154
- where: { id: record.id, status: 'dispatching' },
155
- data: {
156
- status: 'failed',
157
- leaseExpiresAt: null,
158
- lastError: message.slice(0, 1000),
159
- nextAttemptAt: new Date(Date.now() + retryDelay),
160
- },
161
- });
162
- console.error(`[security-fusion] outbox dispatch failed for ${record.id}: ${message}`);
163
- }
164
- }
165
- return dispatched;
166
- }
167
- let dispatcherTimer = null;
168
- export function startSecurityFusionOutboxDispatcher() {
169
- if (dispatcherTimer)
170
- return;
171
- const dispatch = () => {
172
- void dispatchSecurityFusionOutbox().catch((error) => {
173
- console.error('[security-fusion] outbox scan failed', error instanceof Error ? error.message : String(error));
174
- });
175
- };
176
- dispatch();
177
- dispatcherTimer = setInterval(dispatch, OUTBOX_INTERVAL_MS);
178
- dispatcherTimer.unref();
179
- }
180
- export function stopSecurityFusionOutboxDispatcher() {
181
- if (dispatcherTimer)
182
- clearInterval(dispatcherTimer);
183
- dispatcherTimer = null;
184
- }
185
- //# sourceMappingURL=security-fusion-outbox.js.map
@@ -1,134 +0,0 @@
1
- import { createHmac, createHash } from 'node:crypto';
2
- import { appendFile, chmod, lstat, mkdir, open } from 'node:fs/promises';
3
- import { dirname, resolve } from 'node:path';
4
- import { getDb } from '../db/index.js';
5
- function canonicalJson(value) {
6
- if (Array.isArray(value))
7
- return `[${value.map(canonicalJson).join(',')}]`;
8
- if (value && typeof value === 'object') {
9
- return `{${Object.entries(value)
10
- .sort(([a], [b]) => a.localeCompare(b))
11
- .map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`).join(',')}}`;
12
- }
13
- return JSON.stringify(value);
14
- }
15
- export function deletionReceiptHash(input, previousReceiptSha256, chainSequence) {
16
- return createHash('sha256').update(canonicalJson({
17
- resourceType: input.resourceType,
18
- resourceIdHash: input.resourceIdHash,
19
- reason: input.reason,
20
- outcome: input.outcome,
21
- bytesDeleted: input.bytesDeleted,
22
- detail: JSON.parse(input.detailJson),
23
- deletedAt: input.deletedAt.toISOString(),
24
- previousReceiptSha256,
25
- chainSequence,
26
- chainVersion: 2,
27
- })).digest('hex');
28
- }
29
- /**
30
- * Append receipts while holding the singleton PostgreSQL chain-head row lock.
31
- * This serializes all writers across Control API processes.
32
- */
33
- export async function appendDeletionReceipts(tx, inputs) {
34
- const [head] = await tx.$queryRaw `
35
- SELECT "headSha256", "chainSequence"
36
- FROM "security_fusion_receipt_chain_heads"
37
- WHERE "id" = 'security-fusion'
38
- FOR UPDATE
39
- `;
40
- if (!head)
41
- throw new Error('Security Fusion receipt chain head is missing');
42
- let previousReceiptSha256 = head.headSha256;
43
- let chainSequence = head.chainSequence;
44
- for (const input of inputs) {
45
- chainSequence += 1;
46
- const receiptSha256 = deletionReceiptHash(input, previousReceiptSha256, chainSequence);
47
- await tx.securityFusionDeletionReceipt.create({
48
- data: {
49
- ...input,
50
- receiptSha256,
51
- previousReceiptSha256,
52
- chainSequence,
53
- chainVersion: 2,
54
- },
55
- });
56
- previousReceiptSha256 = receiptSha256;
57
- }
58
- if (inputs.length > 0) {
59
- await tx.securityFusionReceiptChainHead.update({
60
- where: { id: 'security-fusion' },
61
- data: { headSha256: previousReceiptSha256, chainSequence },
62
- });
63
- }
64
- return { headSha256: previousReceiptSha256, chainSequence };
65
- }
66
- function chainHeadExportPath() {
67
- return resolve(process.env['SECURITY_FUSION_RECEIPT_CHAIN_EXPORT']
68
- ?? './state/security-fusion/receipt-chain-heads.jsonl');
69
- }
70
- /** Durably export the current chain head outside the mutable database. */
71
- export async function exportReceiptChainHead(head) {
72
- const current = head ?? await getDb().securityFusionReceiptChainHead.findUnique({
73
- where: { id: 'security-fusion' },
74
- select: { headSha256: true, chainSequence: true },
75
- });
76
- if (!current)
77
- throw new Error('Security Fusion receipt chain head is missing');
78
- const path = chainHeadExportPath();
79
- const directory = dirname(path);
80
- await mkdir(directory, { recursive: true, mode: 0o700 });
81
- const expectedUid = typeof process.geteuid === 'function' ? process.geteuid() : null;
82
- const directoryInfo = await lstat(directory);
83
- if (!directoryInfo.isDirectory() || directoryInfo.isSymbolicLink()
84
- || (expectedUid !== null && directoryInfo.uid !== expectedUid)) {
85
- throw new Error('Security Fusion receipt chain export directory is unsafe');
86
- }
87
- await chmod(directory, 0o700);
88
- try {
89
- const info = await lstat(path);
90
- if (!info.isFile() || info.isSymbolicLink()
91
- || (expectedUid !== null && info.uid !== expectedUid)
92
- || (info.mode & 0o777) !== 0o600) {
93
- throw new Error('Security Fusion receipt chain export must be a mode-0600 regular file');
94
- }
95
- }
96
- catch (error) {
97
- if (error.code !== 'ENOENT')
98
- throw error;
99
- }
100
- const exportedAt = new Date().toISOString();
101
- const signingKey = process.env['SECURITY_FUSION_RECEIPT_SIGNING_KEY'];
102
- if (!signingKey || signingKey.length < 32) {
103
- throw new Error('SECURITY_FUSION_RECEIPT_SIGNING_KEY must contain at least 32 characters');
104
- }
105
- const signedPayload = canonicalJson({
106
- chain: 'security-fusion',
107
- chainSequence: current.chainSequence,
108
- headSha256: current.headSha256,
109
- exportedAt,
110
- });
111
- const record = {
112
- chain: 'security-fusion',
113
- chainSequence: current.chainSequence,
114
- headSha256: current.headSha256,
115
- exportedAt,
116
- signatureHmacSha256: createHmac('sha256', signingKey).update(signedPayload).digest('hex'),
117
- };
118
- const handle = await open(path, 'a', 0o600);
119
- try {
120
- await appendFile(handle, `${JSON.stringify(record)}\n`, 'utf8');
121
- await handle.sync();
122
- }
123
- finally {
124
- await handle.close();
125
- }
126
- const directoryHandle = await open(directory, 'r');
127
- try {
128
- await directoryHandle.sync();
129
- }
130
- finally {
131
- await directoryHandle.close();
132
- }
133
- }
134
- //# sourceMappingURL=security-fusion-receipt-chain.js.map