@hivemagnus/magnus-server 0.0.0-stage → 1.0.2
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 +171 -0
- package/LICENSE +202 -0
- package/README.md +169 -2
- package/agent-version.json +1 -0
- package/dist/ai/HEARTBEAT.md +56 -0
- package/dist/ai/IDENTITY.md +54 -0
- package/dist/ai/MEMORY.md +40 -0
- package/dist/ai/TOOLS.md +538 -0
- package/dist/ai/USER.md +32 -0
- package/dist/cli/args.js +62 -0
- package/dist/cli/env-catalog.js +131 -0
- package/dist/cli/preflight.js +89 -0
- package/dist/cli.js +175 -0
- package/dist/config/cors.js +16 -0
- package/dist/config/env.js +392 -0
- package/dist/config/features.js +16 -0
- package/dist/config/index.js +3 -0
- package/dist/config/listener-security.js +38 -0
- package/dist/config/version.js +31 -0
- package/dist/db/client.js +44 -0
- package/dist/db/generated/prisma/browser.js +18 -0
- package/dist/db/generated/prisma/client.js +35 -0
- package/dist/db/generated/prisma/commonInputTypes.js +11 -0
- package/dist/db/generated/prisma/enums.js +42 -0
- package/dist/db/generated/prisma/internal/class.js +50 -0
- package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
- package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
- package/dist/db/generated/prisma/models/A2ATask.js +2 -0
- package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
- package/dist/db/generated/prisma/models/Agent.js +2 -0
- package/dist/db/generated/prisma/models/AiConversation.js +2 -0
- package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
- package/dist/db/generated/prisma/models/AiMemory.js +2 -0
- package/dist/db/generated/prisma/models/AiMessage.js +2 -0
- package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
- package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
- package/dist/db/generated/prisma/models/AlertRule.js +2 -0
- package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
- package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
- package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
- package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
- package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/CommandResult.js +2 -0
- package/dist/db/generated/prisma/models/CronJob.js +2 -0
- package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
- package/dist/db/generated/prisma/models/DeployJob.js +2 -0
- package/dist/db/generated/prisma/models/DriftScan.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
- package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
- package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
- package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
- package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
- package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
- package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesHook.js +2 -0
- package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
- package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
- package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
- package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
- package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
- package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
- package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
- package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
- package/dist/db/generated/prisma/models/HermesRole.js +2 -0
- package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
- package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
- package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
- package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
- package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
- package/dist/db/generated/prisma/models/LogEntry.js +2 -0
- package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
- package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
- package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
- package/dist/db/generated/prisma/models/Node.js +2 -0
- package/dist/db/generated/prisma/models/Notification.js +2 -0
- package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
- package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
- package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
- package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
- package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
- package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
- package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
- package/dist/db/generated/prisma/models/Role.js +2 -0
- package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
- package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
- package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
- package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
- package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
- package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
- package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
- package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
- package/dist/db/generated/prisma/models/SkillTag.js +2 -0
- package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
- package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
- package/dist/db/generated/prisma/models/User.js +2 -0
- package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
- package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
- package/dist/db/generated/prisma/models.js +2 -0
- package/dist/db/index.js +3 -0
- package/dist/db/read-retry.js +35 -0
- package/dist/db/seed.js +348 -0
- package/dist/index.js +162 -0
- package/dist/middleware/auth.js +361 -0
- package/dist/middleware/body-limit.js +14 -0
- package/dist/middleware/correlation-id.js +13 -0
- package/dist/middleware/error-handler.js +28 -0
- package/dist/middleware/iam-token.js +21 -0
- package/dist/middleware/rate-limit.js +78 -0
- package/dist/middleware/request-logger.js +85 -0
- package/dist/middleware/security-headers.js +23 -0
- package/dist/middleware/tracing.js +120 -0
- package/dist/middleware/user-status.js +33 -0
- package/dist/openapi/spec.js +2656 -0
- package/dist/queue/redis.js +20 -0
- package/dist/queue/requeue.js +27 -0
- package/dist/queue/workers.js +342 -0
- package/dist/routes/ai-schedule.js +73 -0
- package/dist/routes/alerts.js +79 -0
- package/dist/routes/anomalies.js +101 -0
- package/dist/routes/ardent.js +82 -0
- package/dist/routes/assistant.js +417 -0
- package/dist/routes/audit.js +138 -0
- package/dist/routes/codex.js +65 -0
- package/dist/routes/commands.js +226 -0
- package/dist/routes/credentials.js +58 -0
- package/dist/routes/cron.js +292 -0
- package/dist/routes/dependencies.js +49 -0
- package/dist/routes/deploy.js +18 -0
- package/dist/routes/discovery.js +168 -0
- package/dist/routes/docs.js +24 -0
- package/dist/routes/drift.js +126 -0
- package/dist/routes/events-stream.js +87 -0
- package/dist/routes/files.js +214 -0
- package/dist/routes/fleet-checks.js +21 -0
- package/dist/routes/health.js +133 -0
- package/dist/routes/heartbeat.js +545 -0
- package/dist/routes/hermes-registry.js +278 -0
- package/dist/routes/hermes.js +61 -0
- package/dist/routes/library.js +402 -0
- package/dist/routes/logs.js +172 -0
- package/dist/routes/managed-cron.js +23 -0
- package/dist/routes/mcp.js +39 -0
- package/dist/routes/metrics.js +187 -0
- package/dist/routes/nodes.js +951 -0
- package/dist/routes/notifications.js +130 -0
- package/dist/routes/ops.js +258 -0
- package/dist/routes/orchestra.js +430 -0
- package/dist/routes/predictions.js +23 -0
- package/dist/routes/profiling.js +68 -0
- package/dist/routes/quick-command.js +72 -0
- package/dist/routes/remediation.js +111 -0
- package/dist/routes/resource-leases.js +297 -0
- package/dist/routes/roles.js +370 -0
- package/dist/routes/rollout.js +124 -0
- package/dist/routes/scan.js +116 -0
- package/dist/routes/security-backup.js +58 -0
- package/dist/routes/security-fusion.js +605 -0
- package/dist/routes/security-integration.js +71 -0
- package/dist/routes/self-update.js +129 -0
- package/dist/routes/shared-resource-leases.js +78 -0
- package/dist/routes/skills.js +485 -0
- package/dist/routes/stats.js +96 -0
- package/dist/routes/team-approvals.js +23 -0
- package/dist/routes/terminal-ticket.js +15 -0
- package/dist/routes/terminal.js +17 -0
- package/dist/routes/topology.js +37 -0
- package/dist/routes/users.js +219 -0
- package/dist/routes/webhooks.js +137 -0
- package/dist/schemas/command-reconciliation.js +14 -0
- package/dist/schemas/commands.js +51 -0
- package/dist/schemas/cron.js +33 -0
- package/dist/schemas/deploy.js +20 -0
- package/dist/schemas/files.js +14 -0
- package/dist/schemas/hermes.js +69 -0
- package/dist/schemas/index.js +11 -0
- package/dist/schemas/library.js +48 -0
- package/dist/schemas/logs.js +35 -0
- package/dist/schemas/managed-cron.js +127 -0
- package/dist/schemas/node-responses.js +87 -0
- package/dist/schemas/node-session-receipt.js +84 -0
- package/dist/schemas/nodes.js +36 -0
- package/dist/schemas/orchestra.js +118 -0
- package/dist/schemas/resource-participant-reconciliation.js +99 -0
- package/dist/schemas/roles.js +54 -0
- package/dist/schemas/shared-resource-lease.js +132 -0
- package/dist/schemas/skills.js +44 -0
- package/dist/schemas/users.js +28 -0
- package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
- package/dist/sdk/index.js +2 -0
- package/dist/sdk/node-api-client.js +474 -0
- package/dist/security/approval-assertion.js +66 -0
- package/dist/security/command-completion-registry.js +35 -0
- package/dist/security/command-request.js +12 -0
- package/dist/security/command-result-signature.js +34 -0
- package/dist/security/managed-http.js +45 -0
- package/dist/security/native-completion-registry.js +44 -0
- package/dist/server.js +178 -0
- package/dist/services/admission-fence.js +71 -0
- package/dist/services/ai-assistant-service.js +2523 -0
- package/dist/services/ai-correlation-service.js +225 -0
- package/dist/services/ai-export-service.js +141 -0
- package/dist/services/ai-insights-service.js +219 -0
- package/dist/services/ai-schedule-service.js +145 -0
- package/dist/services/ai-scratchpad-service.js +222 -0
- package/dist/services/ai-tools.js +1343 -0
- package/dist/services/alert-service.js +313 -0
- package/dist/services/anomaly-detector.js +419 -0
- package/dist/services/api-local-work.js +79 -0
- package/dist/services/application-probes.js +309 -0
- package/dist/services/ardent-scope-enrollment.js +123 -0
- package/dist/services/ardent-service.js +86 -0
- package/dist/services/audit-service.js +308 -0
- package/dist/services/codex-service.js +209 -0
- package/dist/services/command-lifecycle.js +60 -0
- package/dist/services/command-resource-leases.js +305 -0
- package/dist/services/control-host-node.js +242 -0
- package/dist/services/credential-rotation-service.js +294 -0
- package/dist/services/dependency-service.js +145 -0
- package/dist/services/disposable-recovery-runner.js +595 -0
- package/dist/services/docker-group-health.js +121 -0
- package/dist/services/engineering-assurance.js +492 -0
- package/dist/services/engineering-evidence-ledger.js +94 -0
- package/dist/services/engineering-metric-evidence.js +26 -0
- package/dist/services/event-bus.js +227 -0
- package/dist/services/fleet-check-log.js +102 -0
- package/dist/services/forecast-service.js +154 -0
- package/dist/services/health-retention.js +175 -0
- package/dist/services/heartbeat-stream.js +63 -0
- package/dist/services/hermes-catalog-import.js +62 -0
- package/dist/services/hermes-cli-parser.js +671 -0
- package/dist/services/hermes-core-rollout.js +90 -0
- package/dist/services/hermes-dashboard-endpoint.js +13 -0
- package/dist/services/hermes-plugin-source.js +149 -0
- package/dist/services/hermes-registry-service.js +258 -0
- package/dist/services/hermes-service.js +566 -0
- package/dist/services/hivemem-client.js +85 -0
- package/dist/services/host-local-writer.js +82 -0
- package/dist/services/incident-evidence.js +110 -0
- package/dist/services/incident-lessons.js +186 -0
- package/dist/services/llm-providers/anthropic.js +143 -0
- package/dist/services/llm-providers/index.js +7 -0
- package/dist/services/llm-providers/llama-cpp.js +237 -0
- package/dist/services/llm-providers/ollama.js +292 -0
- package/dist/services/llm-providers/openai-compatible.js +200 -0
- package/dist/services/llm-providers/provider-error.js +36 -0
- package/dist/services/llm-providers/registry.js +45 -0
- package/dist/services/llm-providers/types.js +2 -0
- package/dist/services/log-emitter.js +19 -0
- package/dist/services/log-retention.js +66 -0
- package/dist/services/maintenance-service.js +29 -0
- package/dist/services/managed-cron.js +493 -0
- package/dist/services/managed-ssh-admission.js +68 -0
- package/dist/services/monitor-pulse.js +10 -0
- package/dist/services/node-issue-monitor.js +71 -0
- package/dist/services/node-service.js +35 -0
- package/dist/services/notification-retention.js +51 -0
- package/dist/services/notification-service.js +94 -0
- package/dist/services/operational-observations.js +112 -0
- package/dist/services/operations-config.js +67 -0
- package/dist/services/ops-service.js +491 -0
- package/dist/services/orchestra-extras-service.js +370 -0
- package/dist/services/orchestra-resources-service.js +530 -0
- package/dist/services/os-scheduler-writer.js +73 -0
- package/dist/services/password-service.js +22 -0
- package/dist/services/prediction-service.js +284 -0
- package/dist/services/recovery-exercises.js +682 -0
- package/dist/services/remediation-service.js +325 -0
- package/dist/services/resource-enrollment.js +433 -0
- package/dist/services/resource-node-lock.js +18 -0
- package/dist/services/resource-participant-work.js +302 -0
- package/dist/services/role-service.js +388 -0
- package/dist/services/rollout-service.js +440 -0
- package/dist/services/security-export.js +185 -0
- package/dist/services/security-fusion-actions.js +22 -0
- package/dist/services/security-fusion-approvals.js +83 -0
- package/dist/services/security-fusion-evidence-integrity.js +144 -0
- package/dist/services/security-fusion-ingest.js +101 -0
- package/dist/services/security-fusion-outbox.js +185 -0
- package/dist/services/security-fusion-receipt-chain.js +134 -0
- package/dist/services/security-fusion-service.js +751 -0
- package/dist/services/security-fusion-verification.js +50 -0
- package/dist/services/self-update-service.js +200 -0
- package/dist/services/shared-resource-leases.js +413 -0
- package/dist/services/skill-publication-bundle.js +90 -0
- package/dist/services/skill-publication-settlement.js +36 -0
- package/dist/services/skill-publication-state.js +40 -0
- package/dist/services/skill-registry-service.js +327 -0
- package/dist/services/skill-service.js +148 -0
- package/dist/services/ssh-exec-service.js +139 -0
- package/dist/services/ssh-host-verification.js +27 -0
- package/dist/services/tailscale-node-key-service.js +75 -0
- package/dist/services/team-approvals.js +118 -0
- package/dist/services/topology-service.js +138 -0
- package/dist/services/transfer-authority-snapshot.js +151 -0
- package/dist/services/webhook-credential-migration.js +97 -0
- package/dist/services/webhook-security.js +242 -0
- package/dist/services/webhook-service.js +494 -0
- package/dist/types/auth.js +3 -0
- package/dist/types/index.js +3 -0
- package/dist/types/library.js +3 -0
- package/dist/types/notifications.js +3 -0
- package/dist/types/roles.js +3 -0
- package/dist/types/shared-contracts.js +3 -0
- package/dist/utils/env.js +141 -0
- package/dist/utils/heartbeat-peer.js +19 -0
- package/dist/utils/idempotency.js +45 -0
- package/dist/utils/ip.js +25 -0
- package/dist/utils/json-fields.js +94 -0
- package/dist/utils/local-tls.js +39 -0
- package/dist/utils/magnus-home.js +15 -0
- package/dist/utils/node-url.js +59 -0
- package/dist/utils/redact.js +26 -0
- package/dist/utils/safe-equal.js +13 -0
- package/dist/utils/sentinel-nodes.js +16 -0
- package/dist/utils/validate.js +41 -0
- package/package.json +119 -4
- package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +2185 -0
- package/prisma.config.ts +14 -0
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import { recordMonitorPulse } from './monitor-pulse.js';
|
|
2
|
+
import { redactUnknown } from '../utils/redact.js';
|
|
3
|
+
import { createHmac, randomBytes, createCipheriv } from 'node:crypto';
|
|
4
|
+
import { nanoid } from 'nanoid';
|
|
5
|
+
import { Queue, Worker } from 'bullmq';
|
|
6
|
+
import { addDurableJob } from '../queue/requeue.js';
|
|
7
|
+
import { getDb } from '../db/index.js';
|
|
8
|
+
import { isEncryptedWebhookCredential, decryptWebhookCredential, encryptWebhookCredential, postWebhook, validateWebhookTarget, } from './webhook-security.js';
|
|
9
|
+
// ─── Webhook Service — dispatch, deliver, retry ───
|
|
10
|
+
const REDIS_URL = process.env['REDIS_URL'] ?? 'redis://127.0.0.1:6379';
|
|
11
|
+
const REDIS_PREFIX = process.env['REDIS_PREFIX'] ?? 'magnus-';
|
|
12
|
+
const WEBHOOK_QUEUE = `${REDIS_PREFIX}webhooks`;
|
|
13
|
+
const PROTECTED_HEADERS = new Set([
|
|
14
|
+
'connection', 'content-length', 'host', 'transfer-encoding', 'user-agent',
|
|
15
|
+
'x-magnus-delivery', 'x-magnus-encrypted', 'x-magnus-event', 'x-magnus-signature',
|
|
16
|
+
]);
|
|
17
|
+
let dispatchTimer;
|
|
18
|
+
let dispatchBusy = false;
|
|
19
|
+
let _webhookQueue = null;
|
|
20
|
+
let _webhookWorker = null;
|
|
21
|
+
/** Get the singleton webhook queue instance. */
|
|
22
|
+
export function getWebhookQueue() {
|
|
23
|
+
if (!_webhookQueue) {
|
|
24
|
+
_webhookQueue = new Queue(WEBHOOK_QUEUE, { connection: { url: REDIS_URL } });
|
|
25
|
+
}
|
|
26
|
+
return _webhookQueue;
|
|
27
|
+
}
|
|
28
|
+
export async function closeWebhookQueue() {
|
|
29
|
+
if (dispatchTimer)
|
|
30
|
+
clearInterval(dispatchTimer);
|
|
31
|
+
dispatchTimer = undefined;
|
|
32
|
+
if (_webhookWorker) {
|
|
33
|
+
await _webhookWorker.close();
|
|
34
|
+
_webhookWorker = null;
|
|
35
|
+
}
|
|
36
|
+
if (_webhookQueue) {
|
|
37
|
+
await _webhookQueue.close();
|
|
38
|
+
_webhookQueue = null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function createWebhookJobPayload(endpointId) {
|
|
42
|
+
return { endpointId };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Dispatch a webhook event to all matching active endpoints.
|
|
46
|
+
* Enqueues deliveries to BullMQ for async processing with retries.
|
|
47
|
+
*/
|
|
48
|
+
/** Called in the same transaction as the notification. These pending rows are
|
|
49
|
+
* the delivery outbox, so a crash before Redis insertion cannot lose an alert. */
|
|
50
|
+
export async function createWebhookDeliveries(tx, event, payload) {
|
|
51
|
+
const endpoints = await tx.webhookEndpoint.findMany({ where: { active: true } });
|
|
52
|
+
for (const ep of endpoints) {
|
|
53
|
+
const events = JSON.parse(ep.events || '[]');
|
|
54
|
+
if (events.length && !events.some(pattern => eventMatches(event, pattern)))
|
|
55
|
+
continue;
|
|
56
|
+
await tx.webhookDelivery.create({ data: { id: nanoid(), endpointId: ep.id, event,
|
|
57
|
+
payload: JSON.stringify(payload), status: 'pending' } });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export async function reconcileWebhookDeliveries() {
|
|
61
|
+
const pending = await getDb().webhookDelivery.findMany({
|
|
62
|
+
where: { status: 'pending', attempts: 0, endpoint: { active: true } },
|
|
63
|
+
include: { endpoint: true }, orderBy: { createdAt: 'asc' }, take: 1000,
|
|
64
|
+
});
|
|
65
|
+
for (const delivery of pending) {
|
|
66
|
+
// Only a completed job (e.g. skipped while its endpoint was inactive) is
|
|
67
|
+
// replaced; failed jobs are finalized by the worker's failure bookkeeping.
|
|
68
|
+
await addDurableJob(getWebhookQueue(), 'deliver', createWebhookJobPayload(delivery.endpointId), {
|
|
69
|
+
jobId: delivery.id,
|
|
70
|
+
attempts: delivery.endpoint.type === 'mattermost' ? 1 : Math.max(1, Math.min(11, delivery.endpoint.retries + 1)),
|
|
71
|
+
backoff: { type: 'exponential', delay: 1000 },
|
|
72
|
+
removeOnComplete: { age: 86400, count: 10000 }, removeOnFail: { age: 604800, count: 10000 },
|
|
73
|
+
}, ['completed']);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
export async function dispatchWebhook(event, payload) {
|
|
77
|
+
await getDb().$transaction(tx => createWebhookDeliveries(tx, event, payload));
|
|
78
|
+
// The periodic dispatcher owns recovery; callers only depend on durable storage.
|
|
79
|
+
}
|
|
80
|
+
/** Check if an event string matches a pattern (supports * wildcard). */
|
|
81
|
+
function eventMatches(event, pattern) {
|
|
82
|
+
if (pattern === '*')
|
|
83
|
+
return true;
|
|
84
|
+
if (pattern === event)
|
|
85
|
+
return true;
|
|
86
|
+
// Support prefix wildcards: "node.*" matches "node.offline"
|
|
87
|
+
if (pattern.endsWith('.*')) {
|
|
88
|
+
const prefix = pattern.slice(0, -2);
|
|
89
|
+
return event.startsWith(prefix + '.');
|
|
90
|
+
}
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
/** Sign a payload with HMAC-SHA256. */
|
|
94
|
+
function signPayload(payload, secret) {
|
|
95
|
+
return createHmac('sha256', secret).update(payload).digest('hex');
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Encrypt a payload using AES-256-GCM.
|
|
99
|
+
* Returns Base64 envelope: iv (12B) + ciphertext + authTag (16B).
|
|
100
|
+
* The recipient derives the 256-bit key from the shared secret via SHA-256.
|
|
101
|
+
*/
|
|
102
|
+
function encryptPayload(payload, secret) {
|
|
103
|
+
const key = Buffer.from(createHmac('sha256', 'magnus-webhook-enc').update(secret).digest());
|
|
104
|
+
const iv = randomBytes(12);
|
|
105
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
106
|
+
const encrypted = Buffer.concat([cipher.update(payload, 'utf8'), cipher.final()]);
|
|
107
|
+
const authTag = cipher.getAuthTag();
|
|
108
|
+
return Buffer.concat([iv, encrypted, authTag]).toString('base64');
|
|
109
|
+
}
|
|
110
|
+
function parseStoredHeaders(endpointId, stored) {
|
|
111
|
+
const plaintext = decryptWebhookCredential(stored, endpointId, 'headers');
|
|
112
|
+
let parsed;
|
|
113
|
+
try {
|
|
114
|
+
parsed = JSON.parse(plaintext);
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
throw new Error('Webhook headers contain invalid encrypted JSON');
|
|
118
|
+
}
|
|
119
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
120
|
+
throw new Error('Webhook headers must decrypt to an object');
|
|
121
|
+
}
|
|
122
|
+
const headers = {};
|
|
123
|
+
for (const [rawName, rawValue] of Object.entries(parsed)) {
|
|
124
|
+
const name = rawName.trim().toLowerCase();
|
|
125
|
+
if (!name || !/^[!#$%&'*+.^_`|~0-9a-z-]+$/.test(name) || PROTECTED_HEADERS.has(name)) {
|
|
126
|
+
throw new Error('Webhook headers contain a forbidden header name');
|
|
127
|
+
}
|
|
128
|
+
if (typeof rawValue !== 'string' || rawValue.length > 8_192) {
|
|
129
|
+
throw new Error('Webhook headers contain an invalid value');
|
|
130
|
+
}
|
|
131
|
+
headers[name] = rawValue;
|
|
132
|
+
}
|
|
133
|
+
if (Object.keys(headers).length > 32)
|
|
134
|
+
throw new Error('Webhook headers exceed the configured limit');
|
|
135
|
+
return headers;
|
|
136
|
+
}
|
|
137
|
+
function encryptedSecret(endpoint) {
|
|
138
|
+
return endpoint.secret === null
|
|
139
|
+
? null
|
|
140
|
+
: decryptWebhookCredential(endpoint.secret, endpoint.id, 'secret');
|
|
141
|
+
}
|
|
142
|
+
export function redactWebhookEndpoint(endpoint) {
|
|
143
|
+
const secret = encryptedSecret(endpoint);
|
|
144
|
+
const headers = parseStoredHeaders(endpoint.id, endpoint.headers);
|
|
145
|
+
const safe = { ...endpoint };
|
|
146
|
+
delete safe.secret;
|
|
147
|
+
delete safe.headers;
|
|
148
|
+
return {
|
|
149
|
+
...safe,
|
|
150
|
+
...(endpoint.url ? { url: new URL(webhookUrl(endpoint)).origin, urlConfigured: true } : {}),
|
|
151
|
+
secretConfigured: Boolean(secret),
|
|
152
|
+
headersConfigured: Object.keys(headers).length > 0,
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
function normalizeHeaders(headers) {
|
|
156
|
+
const temporaryEnvelope = encryptWebhookCredential(JSON.stringify(headers), 'validation', 'headers');
|
|
157
|
+
return parseStoredHeaders('validation', temporaryEnvelope);
|
|
158
|
+
}
|
|
159
|
+
function normalizeDeliveryOptions(retries = 3, timeoutMs = 10_000) {
|
|
160
|
+
if (!Number.isInteger(retries) || retries < 0 || retries > 10) {
|
|
161
|
+
throw new Error('Webhook retries must be an integer between 0 and 10');
|
|
162
|
+
}
|
|
163
|
+
if (!Number.isInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
|
|
164
|
+
throw new Error('Webhook timeoutMs must be an integer between 1000 and 60000');
|
|
165
|
+
}
|
|
166
|
+
return { retries, timeoutMs };
|
|
167
|
+
}
|
|
168
|
+
/** Format payload for Slack/Discord integrations. */
|
|
169
|
+
export function formatPayload(type, event, payload) {
|
|
170
|
+
const data = JSON.parse(payload);
|
|
171
|
+
// Mattermost-compatible incoming webhook: redacted text, delivered at most once.
|
|
172
|
+
if (type === 'mattermost') {
|
|
173
|
+
const clean = (value) => String(redactUnknown(value) ?? '').replace(/@[a-z0-9_]+/gi, '[mention]').replace(/(?:https?:\/\/)[^\s]+/g, '[link omitted]').replace(/(?:password|token|secret|authorization)\s*[:=]\s*\S+/gi, '[credential omitted]').slice(0, 2000);
|
|
174
|
+
const origin = process.env['DASHBOARD_PUBLIC_ORIGIN'];
|
|
175
|
+
let link = '';
|
|
176
|
+
if (origin) {
|
|
177
|
+
try {
|
|
178
|
+
const parsed = new URL(origin);
|
|
179
|
+
if (parsed.protocol === 'https:' && !parsed.username && !parsed.password)
|
|
180
|
+
link = `${parsed.origin}/incidents`;
|
|
181
|
+
}
|
|
182
|
+
catch { /* no link */ }
|
|
183
|
+
}
|
|
184
|
+
return { body: JSON.stringify({ text: `[Magnus] ${clean(event)} · ${clean(data.severity)}\n${clean(data.title)}\n${clean(data.message)}\nObserved: ${clean(data.createdAt ?? data.timestamp)}${link ? `\nReview: ${link}` : ''}` }), contentType: 'application/json' };
|
|
185
|
+
}
|
|
186
|
+
if (type === 'slack') {
|
|
187
|
+
return {
|
|
188
|
+
body: JSON.stringify({
|
|
189
|
+
text: `*[Magnus]* ${event}`,
|
|
190
|
+
blocks: [
|
|
191
|
+
{
|
|
192
|
+
type: 'section',
|
|
193
|
+
text: {
|
|
194
|
+
type: 'mrkdwn',
|
|
195
|
+
text: `*${event}*\n${data.title ?? data.message ?? JSON.stringify(data)}`,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
}),
|
|
200
|
+
contentType: 'application/json',
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
if (type === 'discord') {
|
|
204
|
+
return {
|
|
205
|
+
body: JSON.stringify({
|
|
206
|
+
content: `**[Magnus]** ${event}`,
|
|
207
|
+
embeds: [
|
|
208
|
+
{
|
|
209
|
+
title: data.title ?? event,
|
|
210
|
+
description: data.message ?? JSON.stringify(data),
|
|
211
|
+
color: data.severity === 'critical' ? 0xff0000 : data.severity === 'warning' ? 0xffaa00 : 0x00e5ff,
|
|
212
|
+
},
|
|
213
|
+
],
|
|
214
|
+
}),
|
|
215
|
+
contentType: 'application/json',
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
// Generic
|
|
219
|
+
return {
|
|
220
|
+
body: payload,
|
|
221
|
+
contentType: 'application/json',
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
/** Persist the non-replayable attempt before any Mattermost POST. A stalled
|
|
225
|
+
* queue job after process loss cannot post the same delivery a second time. */
|
|
226
|
+
export async function claimMattermostAttempt(deliveryId) {
|
|
227
|
+
const claimed = await getDb().webhookDelivery.updateMany({ where: { id: deliveryId, status: 'pending' }, data: { status: 'sending', attempts: 1 } });
|
|
228
|
+
return claimed.count === 1;
|
|
229
|
+
}
|
|
230
|
+
/** Start the webhook delivery worker. */
|
|
231
|
+
export function startWebhookWorker() {
|
|
232
|
+
if (_webhookWorker)
|
|
233
|
+
return _webhookWorker;
|
|
234
|
+
// Force the dedicated at-rest key to be present before accepting queued work.
|
|
235
|
+
encryptWebhookCredential('', 'startup-check', 'secret');
|
|
236
|
+
const worker = new Worker(WEBHOOK_QUEUE, async (job) => {
|
|
237
|
+
const db = getDb();
|
|
238
|
+
const deliveryId = job.id;
|
|
239
|
+
if (!deliveryId)
|
|
240
|
+
throw new Error('Webhook delivery job is missing its delivery ID');
|
|
241
|
+
const delivery = await db.webhookDelivery.findUnique({
|
|
242
|
+
where: { id: deliveryId },
|
|
243
|
+
include: { endpoint: true },
|
|
244
|
+
});
|
|
245
|
+
if (!delivery)
|
|
246
|
+
return;
|
|
247
|
+
if (delivery.endpointId !== job.data.endpointId) {
|
|
248
|
+
throw new Error('Webhook delivery endpoint binding mismatch');
|
|
249
|
+
}
|
|
250
|
+
const endpoint = delivery.endpoint;
|
|
251
|
+
if (!endpoint.active || delivery.status === 'success')
|
|
252
|
+
return;
|
|
253
|
+
const secret = encryptedSecret(endpoint);
|
|
254
|
+
const extraHeaders = parseStoredHeaders(endpoint.id, endpoint.headers);
|
|
255
|
+
const shouldEncrypt = extraHeaders['x-magnus-encrypt'] === 'true';
|
|
256
|
+
delete extraHeaders['x-magnus-encrypt'];
|
|
257
|
+
const { body: rawBody, contentType } = formatPayload(endpoint.type, delivery.event, delivery.payload);
|
|
258
|
+
// Optionally encrypt the payload with AES-256-GCM
|
|
259
|
+
const encrypted = shouldEncrypt && secret !== null && endpoint.type === 'generic';
|
|
260
|
+
const body = encrypted ? encryptPayload(rawBody, secret) : rawBody;
|
|
261
|
+
const headers = {
|
|
262
|
+
...extraHeaders,
|
|
263
|
+
'Content-Type': encrypted ? 'application/octet-stream' : contentType,
|
|
264
|
+
'User-Agent': 'Magnus-Webhook/1.0',
|
|
265
|
+
'X-Magnus-Event': delivery.event,
|
|
266
|
+
'X-Magnus-Delivery': deliveryId,
|
|
267
|
+
...(encrypted && { 'X-Magnus-Encrypted': 'aes-256-gcm' }),
|
|
268
|
+
};
|
|
269
|
+
if (secret) {
|
|
270
|
+
headers['X-Magnus-Signature'] = `sha256=${signPayload(body, secret)}`;
|
|
271
|
+
}
|
|
272
|
+
if (endpoint.type === 'mattermost' && !(await claimMattermostAttempt(deliveryId)))
|
|
273
|
+
return;
|
|
274
|
+
try {
|
|
275
|
+
const res = await postWebhook(webhookUrl(endpoint), { headers, body, timeoutMs: endpoint.timeoutMs });
|
|
276
|
+
const attempt = job.attemptsMade + 1;
|
|
277
|
+
await db.webhookDelivery.update({
|
|
278
|
+
where: { id: deliveryId },
|
|
279
|
+
data: {
|
|
280
|
+
status: res.ok ? 'success' : 'failed',
|
|
281
|
+
httpStatus: res.status,
|
|
282
|
+
response: res.responseBody,
|
|
283
|
+
attempts: attempt,
|
|
284
|
+
deliveredAt: res.ok ? new Date() : undefined,
|
|
285
|
+
error: res.ok ? null : `HTTP ${res.status}`,
|
|
286
|
+
},
|
|
287
|
+
});
|
|
288
|
+
if (!res.ok)
|
|
289
|
+
throw new Error(`Webhook delivery failed with HTTP ${res.status}`);
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
const errorMsg = err instanceof Error ? err.message : 'Unknown error';
|
|
293
|
+
await db.webhookDelivery.update({
|
|
294
|
+
where: { id: deliveryId },
|
|
295
|
+
data: {
|
|
296
|
+
status: 'failed',
|
|
297
|
+
attempts: job.attemptsMade + 1,
|
|
298
|
+
error: errorMsg,
|
|
299
|
+
},
|
|
300
|
+
});
|
|
301
|
+
throw err;
|
|
302
|
+
}
|
|
303
|
+
}, {
|
|
304
|
+
connection: { url: REDIS_URL },
|
|
305
|
+
concurrency: 5,
|
|
306
|
+
});
|
|
307
|
+
worker.on('failed', (job, err) => {
|
|
308
|
+
console.error(`[webhook-worker] Job ${job?.id} failed: ${err.message}`);
|
|
309
|
+
if (job?.id && job.attemptsMade >= (job.opts.attempts ?? 1)) {
|
|
310
|
+
void getDb().webhookDelivery.updateMany({ where: { id: job.id, status: 'pending' },
|
|
311
|
+
data: { status: 'failed', error: 'Delivery validation or queue attempt failed', attempts: job.attemptsMade },
|
|
312
|
+
}).catch(() => console.error('[webhook] failed-attempt bookkeeping pending'));
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
const dispatch = async () => {
|
|
316
|
+
if (dispatchBusy)
|
|
317
|
+
return;
|
|
318
|
+
dispatchBusy = true;
|
|
319
|
+
try {
|
|
320
|
+
await reconcileWebhookDeliveries();
|
|
321
|
+
recordMonitorPulse('webhooks');
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
console.error('[webhook] durable dispatch pending');
|
|
325
|
+
}
|
|
326
|
+
finally {
|
|
327
|
+
dispatchBusy = false;
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
void dispatch();
|
|
331
|
+
dispatchTimer = setInterval(() => void dispatch(), 2000);
|
|
332
|
+
dispatchTimer.unref();
|
|
333
|
+
_webhookWorker = worker;
|
|
334
|
+
return worker;
|
|
335
|
+
}
|
|
336
|
+
export function webhookUrl(endpoint) {
|
|
337
|
+
return isEncryptedWebhookCredential(endpoint.url) ? decryptWebhookCredential(endpoint.url, endpoint.id, 'url') : endpoint.url;
|
|
338
|
+
}
|
|
339
|
+
// ─── CRUD helpers ───
|
|
340
|
+
export async function listWebhookEndpoints() {
|
|
341
|
+
const db = getDb();
|
|
342
|
+
const endpoints = await db.webhookEndpoint.findMany({
|
|
343
|
+
orderBy: { createdAt: 'desc' },
|
|
344
|
+
include: { _count: { select: { deliveries: true } } },
|
|
345
|
+
});
|
|
346
|
+
return endpoints.map(redactWebhookEndpoint);
|
|
347
|
+
}
|
|
348
|
+
export async function getWebhookEndpoint(id) {
|
|
349
|
+
const db = getDb();
|
|
350
|
+
const endpoint = await db.webhookEndpoint.findUnique({
|
|
351
|
+
where: { id },
|
|
352
|
+
include: { _count: { select: { deliveries: true } } },
|
|
353
|
+
});
|
|
354
|
+
return endpoint ? redactWebhookEndpoint(endpoint) : null;
|
|
355
|
+
}
|
|
356
|
+
export async function createWebhookEndpoint(data) {
|
|
357
|
+
const db = getDb();
|
|
358
|
+
const id = nanoid();
|
|
359
|
+
if (!['generic', 'slack', 'discord', 'mattermost'].includes(data.type ?? 'generic'))
|
|
360
|
+
throw new Error('Unsupported webhook format');
|
|
361
|
+
await validateWebhookTarget(data.url);
|
|
362
|
+
const headers = normalizeHeaders(data.headers ?? {});
|
|
363
|
+
const { retries, timeoutMs } = normalizeDeliveryOptions(data.retries, data.timeoutMs);
|
|
364
|
+
const endpoint = await db.webhookEndpoint.create({
|
|
365
|
+
data: {
|
|
366
|
+
id,
|
|
367
|
+
name: data.name,
|
|
368
|
+
url: encryptWebhookCredential(data.url, id, 'url'),
|
|
369
|
+
type: data.type ?? 'generic',
|
|
370
|
+
secret: data.secret === undefined ? null : encryptWebhookCredential(data.secret, id, 'secret'),
|
|
371
|
+
events: JSON.stringify(data.events ?? []),
|
|
372
|
+
headers: encryptWebhookCredential(JSON.stringify(headers), id, 'headers'),
|
|
373
|
+
retries,
|
|
374
|
+
timeoutMs,
|
|
375
|
+
createdBy: data.createdBy ?? null,
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
return redactWebhookEndpoint(endpoint);
|
|
379
|
+
}
|
|
380
|
+
export async function updateWebhookEndpoint(id, data) {
|
|
381
|
+
const db = getDb();
|
|
382
|
+
const current = await db.webhookEndpoint.findUnique({ where: { id } });
|
|
383
|
+
if (!current)
|
|
384
|
+
return null;
|
|
385
|
+
const updateData = {};
|
|
386
|
+
if (data.name !== undefined)
|
|
387
|
+
updateData['name'] = data.name;
|
|
388
|
+
if (data.url !== undefined) {
|
|
389
|
+
await validateWebhookTarget(data.url);
|
|
390
|
+
updateData['url'] = encryptWebhookCredential(data.url, id, 'url');
|
|
391
|
+
}
|
|
392
|
+
if (data.type !== undefined) {
|
|
393
|
+
if (!['generic', 'slack', 'discord', 'mattermost'].includes(data.type))
|
|
394
|
+
throw new Error('Unsupported webhook format');
|
|
395
|
+
updateData['type'] = data.type;
|
|
396
|
+
}
|
|
397
|
+
if (data.secret !== undefined) {
|
|
398
|
+
updateData['secret'] = data.secret === null ? null : encryptWebhookCredential(data.secret, id, 'secret');
|
|
399
|
+
}
|
|
400
|
+
if (data.events !== undefined)
|
|
401
|
+
updateData['events'] = JSON.stringify(data.events);
|
|
402
|
+
if (data.headers !== undefined) {
|
|
403
|
+
const headers = normalizeHeaders(data.headers);
|
|
404
|
+
updateData['headers'] = encryptWebhookCredential(JSON.stringify(headers), id, 'headers');
|
|
405
|
+
}
|
|
406
|
+
if (data.active !== undefined)
|
|
407
|
+
updateData['active'] = data.active;
|
|
408
|
+
if (data.retries !== undefined || data.timeoutMs !== undefined) {
|
|
409
|
+
const options = normalizeDeliveryOptions(data.retries ?? current.retries, data.timeoutMs ?? current.timeoutMs);
|
|
410
|
+
updateData['retries'] = options.retries;
|
|
411
|
+
updateData['timeoutMs'] = options.timeoutMs;
|
|
412
|
+
}
|
|
413
|
+
const endpoint = await db.webhookEndpoint.update({ where: { id }, data: updateData });
|
|
414
|
+
return redactWebhookEndpoint(endpoint);
|
|
415
|
+
}
|
|
416
|
+
export async function deleteWebhookEndpoint(id) {
|
|
417
|
+
const db = getDb();
|
|
418
|
+
await db.webhookEndpoint.delete({ where: { id } });
|
|
419
|
+
}
|
|
420
|
+
export async function listDeliveries(endpointId, page = 1, pageSize = 20) {
|
|
421
|
+
const db = getDb();
|
|
422
|
+
const skip = (page - 1) * pageSize;
|
|
423
|
+
const [deliveries, total] = await Promise.all([
|
|
424
|
+
db.webhookDelivery.findMany({
|
|
425
|
+
where: { endpointId },
|
|
426
|
+
orderBy: { createdAt: 'desc' },
|
|
427
|
+
skip,
|
|
428
|
+
take: pageSize,
|
|
429
|
+
}),
|
|
430
|
+
db.webhookDelivery.count({ where: { endpointId } }),
|
|
431
|
+
]);
|
|
432
|
+
return { deliveries, total };
|
|
433
|
+
}
|
|
434
|
+
export async function testWebhookEndpoint(id) {
|
|
435
|
+
const db = getDb();
|
|
436
|
+
const endpoint = await db.webhookEndpoint.findUnique({ where: { id } });
|
|
437
|
+
if (!endpoint)
|
|
438
|
+
return { success: false, error: 'Endpoint not found' };
|
|
439
|
+
try {
|
|
440
|
+
const testPayload = {
|
|
441
|
+
event: 'webhook.test',
|
|
442
|
+
message: 'This is a test webhook delivery from Magnus',
|
|
443
|
+
timestamp: new Date().toISOString(),
|
|
444
|
+
};
|
|
445
|
+
const { body: rawBody, contentType } = formatPayload(endpoint.type, 'webhook.test', JSON.stringify(testPayload));
|
|
446
|
+
const secret = encryptedSecret(endpoint);
|
|
447
|
+
const extraHeaders = parseStoredHeaders(endpoint.id, endpoint.headers);
|
|
448
|
+
const shouldEncrypt = extraHeaders['x-magnus-encrypt'] === 'true';
|
|
449
|
+
delete extraHeaders['x-magnus-encrypt'];
|
|
450
|
+
const encrypted = shouldEncrypt && secret !== null && endpoint.type === 'generic';
|
|
451
|
+
const body = encrypted ? encryptPayload(rawBody, secret) : rawBody;
|
|
452
|
+
const headers = {
|
|
453
|
+
...extraHeaders,
|
|
454
|
+
'Content-Type': encrypted ? 'application/octet-stream' : contentType,
|
|
455
|
+
'User-Agent': 'Magnus-Webhook/1.0',
|
|
456
|
+
'X-Magnus-Event': 'webhook.test',
|
|
457
|
+
...(encrypted && { 'X-Magnus-Encrypted': 'aes-256-gcm' }),
|
|
458
|
+
};
|
|
459
|
+
if (secret) {
|
|
460
|
+
headers['X-Magnus-Signature'] = `sha256=${signPayload(body, secret)}`;
|
|
461
|
+
}
|
|
462
|
+
const res = await postWebhook(webhookUrl(endpoint), { headers, body, timeoutMs: endpoint.timeoutMs });
|
|
463
|
+
return { success: res.ok, error: res.ok ? undefined : `HTTP ${res.status}` };
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
return { success: false, error: err instanceof Error ? err.message : 'Unknown error' };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Redeliver a failed webhook delivery by re-enqueuing it.
|
|
471
|
+
* Returns the new delivery ID, or null if the original delivery was not found.
|
|
472
|
+
*/
|
|
473
|
+
export async function redeliverWebhookDelivery(endpointId, deliveryId) {
|
|
474
|
+
const db = getDb();
|
|
475
|
+
const delivery = await db.webhookDelivery.findUnique({
|
|
476
|
+
where: { id: deliveryId },
|
|
477
|
+
include: { endpoint: true },
|
|
478
|
+
});
|
|
479
|
+
if (!delivery || delivery.endpointId !== endpointId)
|
|
480
|
+
return null;
|
|
481
|
+
const ep = delivery.endpoint;
|
|
482
|
+
// Create a new delivery record for the redeliver attempt
|
|
483
|
+
const newDelivery = await db.webhookDelivery.create({
|
|
484
|
+
data: {
|
|
485
|
+
id: nanoid(),
|
|
486
|
+
endpointId: ep.id,
|
|
487
|
+
event: delivery.event,
|
|
488
|
+
payload: delivery.payload,
|
|
489
|
+
status: 'pending',
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
return newDelivery.id;
|
|
493
|
+
}
|
|
494
|
+
//# sourceMappingURL=webhook-service.js.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Centralized, validated environment secrets for control-api.
|
|
3
|
+
*
|
|
4
|
+
* Import secrets from this module instead of reading process.env directly.
|
|
5
|
+
* Secrets are read from process.env at import time. Ensure dotenv has loaded
|
|
6
|
+
* before any module that imports this is evaluated (use dynamic imports in
|
|
7
|
+
* the entry point — index.ts already does this).
|
|
8
|
+
*
|
|
9
|
+
* At startup, `validateSecrets()` performs both fail-fast missing-secret
|
|
10
|
+
* checks AND full Zod schema validation via `config/env.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { loadEnv } from '../config/env.js';
|
|
13
|
+
import { resolveCredentialSecret } from '../services/credential-rotation-service.js';
|
|
14
|
+
// ── Exported resolved secrets ──
|
|
15
|
+
// JWT_SECRET kept for backward-compat with non-IAM environments.
|
|
16
|
+
// When IAM_CORE_URL is set, RS256 tokens from IAM Core are used instead.
|
|
17
|
+
export const JWT_SECRET = process.env['JWT_SECRET'] ?? '';
|
|
18
|
+
export const API_SECRET = process.env['API_SECRET'] ?? '';
|
|
19
|
+
export const NODE_MANAGER_SECRET = process.env['NODE_MANAGER_SECRET'] ?? '';
|
|
20
|
+
export const CONTROL_API_URL = process.env['CONTROL_API_URL'] ?? 'https://localhost:4000';
|
|
21
|
+
export const ENABLE_AGENT_DISCOVERY = (process.env['ENABLE_AGENT_DISCOVERY'] ?? 'false').toLowerCase() === 'true';
|
|
22
|
+
export const AGENT_DISCOVERY_SECRET = process.env['AGENT_DISCOVERY_SECRET'] ?? '';
|
|
23
|
+
/** SHA-256 digest of an optional external collector's read-only bearer token. */
|
|
24
|
+
export const MAGNUS_COLLECTOR_TOKEN_SHA256 = process.env['MAGNUS_COLLECTOR_TOKEN_SHA256'] ?? '';
|
|
25
|
+
/** Dedicated audit-chain HMAC key; deliberately has no master-secret fallback. */
|
|
26
|
+
export const AUDIT_HMAC_SECRET = process.env['AUDIT_HMAC_SECRET'] ?? '';
|
|
27
|
+
/** IAM Core API URL — when set, enables RS256 JWT verification via IAM. */
|
|
28
|
+
export const IAM_CORE_URL = process.env['IAM_CORE_URL'] ?? '';
|
|
29
|
+
/** Exact registered claims required for every IAM access token. */
|
|
30
|
+
export const IAM_TOKEN_ISSUER = process.env['IAM_TOKEN_ISSUER'] ?? '';
|
|
31
|
+
export const IAM_TOKEN_AUDIENCE = process.env['IAM_TOKEN_AUDIENCE'] ?? '';
|
|
32
|
+
/** HMAC secret for verifying agent command result signatures. */
|
|
33
|
+
export const RESULT_SIGNING_SECRET = process.env['RESULT_SIGNING_SECRET'] ?? '';
|
|
34
|
+
/** Optional Control API-only authority for reviewed participant reconciliation. */
|
|
35
|
+
export const RESOURCE_PARTICIPANT_RECONCILIATION_SECRET = process.env['RESOURCE_PARTICIPANT_RECONCILIATION_SECRET'] ?? '';
|
|
36
|
+
/** Optional bounded rollout key; remove after every active agent proves the new signer. */
|
|
37
|
+
export const RESULT_SIGNING_PREVIOUS_SECRET = process.env['RESULT_SIGNING_PREVIOUS_SECRET'] ?? '';
|
|
38
|
+
export const LOG_INGEST_SIGNING_SECRET = process.env['LOG_INGEST_SIGNING_SECRET'] ?? '';
|
|
39
|
+
export const LOG_INGEST_PREVIOUS_SIGNING_SECRET = process.env['LOG_INGEST_PREVIOUS_SIGNING_SECRET'] ?? '';
|
|
40
|
+
/** Dedicated encrypted-rotation store key. */
|
|
41
|
+
export const CREDENTIAL_ROTATION_KEY = process.env['CREDENTIAL_ROTATION_KEY'] ?? '';
|
|
42
|
+
/**
|
|
43
|
+
* Optional Hermes Agent plugin API. When disabled (the default) plugin
|
|
44
|
+
* credentials are ignored and the plugin-scoped routes are unreachable.
|
|
45
|
+
*/
|
|
46
|
+
export const MAGNUS_PLUGIN_API_ENABLED = (process.env['MAGNUS_PLUGIN_API_ENABLED'] ?? 'false').toLowerCase() === 'true';
|
|
47
|
+
/** Per-installation SHA-256 digests for read-only Magnus plugins. */
|
|
48
|
+
export const MAGNUS_PLUGIN_READ_TOKEN_DIGESTS_JSON = process.env['MAGNUS_PLUGIN_READ_TOKEN_DIGESTS_JSON'] ?? '{}';
|
|
49
|
+
/** Server-local plugin operator token digest; keep it off every agent host. */
|
|
50
|
+
export const MAGNUS_PLUGIN_OPERATOR_TOKEN_SHA256 = process.env['MAGNUS_PLUGIN_OPERATOR_TOKEN_SHA256'] ?? '';
|
|
51
|
+
/** Plugin read consumer allowed to relay signed team-approval decisions (optional). */
|
|
52
|
+
export const MAGNUS_APPROVAL_BRIDGE_CONSUMER = process.env['MAGNUS_APPROVAL_BRIDGE_CONSUMER'] ?? '';
|
|
53
|
+
/** Purpose-scoped bearer accepted only by the shared-resource lease API. */
|
|
54
|
+
export const MAGNUS_SHARED_RESOURCE_TOKEN_SHA256 = process.env['MAGNUS_SHARED_RESOURCE_TOKEN_SHA256'] ?? '';
|
|
55
|
+
/** Preferred per-client shared-resource bearer digests for exact actor ownership. */
|
|
56
|
+
export const MAGNUS_SHARED_RESOURCE_TOKEN_DIGESTS_JSON = process.env['MAGNUS_SHARED_RESOURCE_TOKEN_DIGESTS_JSON'] ?? '{}';
|
|
57
|
+
export const MAGNUS_FLEET_CHECK_TOKEN_SHA256 = process.env['MAGNUS_FLEET_CHECK_TOKEN_SHA256'] ?? '';
|
|
58
|
+
export const SECURITY_FUSION_RECEIPT_SIGNING_KEY = process.env['SECURITY_FUSION_RECEIPT_SIGNING_KEY'] ?? '';
|
|
59
|
+
/** Security Fusion mode: `off` (default), `local` (in-process) or `remote` (external console). */
|
|
60
|
+
export const SECURITY_FUSION_MODE = process.env['SECURITY_FUSION_MODE'] ?? 'off';
|
|
61
|
+
export const METRICS_TOKEN = process.env['METRICS_TOKEN'] ?? '';
|
|
62
|
+
export const WEBHOOK_ENCRYPTION_KEY = process.env['WEBHOOK_ENCRYPTION_KEY'] ?? '';
|
|
63
|
+
let validatedEnv = null;
|
|
64
|
+
/** Return the single validated runtime configuration consumed by services. */
|
|
65
|
+
export function getValidatedEnv() {
|
|
66
|
+
validatedEnv ??= loadEnv();
|
|
67
|
+
return validatedEnv;
|
|
68
|
+
}
|
|
69
|
+
/** Active node-manager secret, including durable rotations. */
|
|
70
|
+
export function getNodeManagerSecret() {
|
|
71
|
+
return resolveCredentialSecret('NODE_MANAGER_SECRET', NODE_MANAGER_SECRET);
|
|
72
|
+
}
|
|
73
|
+
// ── Startup validation ──
|
|
74
|
+
/**
|
|
75
|
+
* Return a description of every missing or too-short required secret.
|
|
76
|
+
* Optional integrations only contribute when their feature is enabled.
|
|
77
|
+
*/
|
|
78
|
+
export function listMissingSecrets() {
|
|
79
|
+
const missing = [];
|
|
80
|
+
const usingIam = !!IAM_CORE_URL;
|
|
81
|
+
if (!usingIam && JWT_SECRET.length < 32)
|
|
82
|
+
missing.push('JWT_SECRET (dedicated, min 32 chars)');
|
|
83
|
+
if (usingIam && !IAM_TOKEN_ISSUER)
|
|
84
|
+
missing.push('IAM_TOKEN_ISSUER');
|
|
85
|
+
if (usingIam && !IAM_TOKEN_AUDIENCE)
|
|
86
|
+
missing.push('IAM_TOKEN_AUDIENCE');
|
|
87
|
+
if (API_SECRET.length < 32)
|
|
88
|
+
missing.push('API_SECRET (dedicated, min 32 chars)');
|
|
89
|
+
if (NODE_MANAGER_SECRET.length < 32)
|
|
90
|
+
missing.push('NODE_MANAGER_SECRET (dedicated, min 32 chars)');
|
|
91
|
+
if (AUDIT_HMAC_SECRET.length < 32)
|
|
92
|
+
missing.push('AUDIT_HMAC_SECRET (dedicated, min 32 chars)');
|
|
93
|
+
if (RESULT_SIGNING_SECRET.length < 32)
|
|
94
|
+
missing.push('RESULT_SIGNING_SECRET (dedicated, min 32 chars)');
|
|
95
|
+
if (LOG_INGEST_SIGNING_SECRET.length < 32)
|
|
96
|
+
missing.push('LOG_INGEST_SIGNING_SECRET (dedicated, min 32 chars)');
|
|
97
|
+
if (CREDENTIAL_ROTATION_KEY.length < 32)
|
|
98
|
+
missing.push('CREDENTIAL_ROTATION_KEY (dedicated, min 32 chars)');
|
|
99
|
+
if (METRICS_TOKEN.length < 32)
|
|
100
|
+
missing.push('METRICS_TOKEN (dedicated, min 32 chars)');
|
|
101
|
+
if (WEBHOOK_ENCRYPTION_KEY.length < 32)
|
|
102
|
+
missing.push('WEBHOOK_ENCRYPTION_KEY (dedicated, min 32 chars)');
|
|
103
|
+
if (SECURITY_FUSION_MODE === 'local' && SECURITY_FUSION_RECEIPT_SIGNING_KEY.length < 32) {
|
|
104
|
+
missing.push('SECURITY_FUSION_RECEIPT_SIGNING_KEY (dedicated, min 32 chars; required when SECURITY_FUSION_MODE=local)');
|
|
105
|
+
}
|
|
106
|
+
if (MAGNUS_PLUGIN_API_ENABLED && MAGNUS_PLUGIN_READ_TOKEN_DIGESTS_JSON === '{}') {
|
|
107
|
+
missing.push('MAGNUS_PLUGIN_READ_TOKEN_DIGESTS_JSON (per-installation read credentials; required when MAGNUS_PLUGIN_API_ENABLED=true)');
|
|
108
|
+
}
|
|
109
|
+
if (MAGNUS_PLUGIN_API_ENABLED && !/^[a-f0-9]{64}$/.test(MAGNUS_PLUGIN_OPERATOR_TOKEN_SHA256)) {
|
|
110
|
+
missing.push('MAGNUS_PLUGIN_OPERATOR_TOKEN_SHA256 (server-local operator credential digest; required when MAGNUS_PLUGIN_API_ENABLED=true)');
|
|
111
|
+
}
|
|
112
|
+
if (process.env['NODE_ENV'] === 'production' && !process.env['DATABASE_URL']) {
|
|
113
|
+
missing.push('DATABASE_URL (PostgreSQL connection string; required in production)');
|
|
114
|
+
}
|
|
115
|
+
return missing;
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* Fail fast if required secrets are missing, then run full Zod validation.
|
|
119
|
+
* Call once at startup, after dotenv has loaded.
|
|
120
|
+
* Skipped when NODE_ENV === 'test'.
|
|
121
|
+
*/
|
|
122
|
+
export function validateSecrets() {
|
|
123
|
+
if (process.env['NODE_ENV'] === 'test')
|
|
124
|
+
return;
|
|
125
|
+
const missing = listMissingSecrets();
|
|
126
|
+
if (missing.length > 0) {
|
|
127
|
+
const hint = 'Set independent purpose-specific credentials of at least 32 characters; MAGNUS_SECRET is retired';
|
|
128
|
+
console.error(`\n❌ Missing required secrets: ${missing.join(', ')}\n ${hint}\n`);
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
// Full Zod schema validation — catches type errors, invalid ports, etc.
|
|
132
|
+
try {
|
|
133
|
+
getValidatedEnv();
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
137
|
+
console.error(`\n❌ Environment validation failed:\n${message}\n`);
|
|
138
|
+
process.exit(1);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=env.js.map
|