@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,433 @@
|
|
|
1
|
+
/** Authoritative one-way drain barrier. Coordination never grants action approval.
|
|
2
|
+
* Public callers can request a drain, but cannot supply participant evidence.
|
|
3
|
+
* Only receipts pulled from fixed authenticated participants are accepted.
|
|
4
|
+
*/
|
|
5
|
+
import { createHash, createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
import { lockResourceNode, resourceNodeIdentity as identity } from './resource-node-lock.js';
|
|
8
|
+
export { lockResourceNode } from './resource-node-lock.js';
|
|
9
|
+
import { dashboardParticipantWork, reconciliationSecret } from './resource-participant-work.js';
|
|
10
|
+
import { getDb } from '../db/index.js';
|
|
11
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
12
|
+
import { getNodeBaseUrl } from '../utils/node-url.js';
|
|
13
|
+
import { getNodeManagerSecret, RESULT_SIGNING_SECRET } from '../utils/env.js';
|
|
14
|
+
export const PARTICIPANTS = ['agent', 'api', 'dashboard', 'scheduler'];
|
|
15
|
+
const HELD = ['reserved', 'running', 'unknown'];
|
|
16
|
+
const digest = (value) => createHash('sha256').update(canonical(value)).digest('hex');
|
|
17
|
+
export function canonical(value) {
|
|
18
|
+
if (Array.isArray(value))
|
|
19
|
+
return '[' + value.map(canonical).join(',') + ']';
|
|
20
|
+
if (value && typeof value === 'object')
|
|
21
|
+
return '{' + Object.entries(value).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)
|
|
22
|
+
.map(([key, item]) => JSON.stringify(key) + ':' + canonical(item)).join(',') + '}';
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
export function enrollmentSigningPayload(value) { return 'magnus.resource-enrollment.v1\n' + canonical(value); }
|
|
26
|
+
function fail(code, message) { throw new HttpError(409, code, message); }
|
|
27
|
+
export async function assertEnrollmentComplete(tx, nodeId, nodeFqdn) {
|
|
28
|
+
const row = await tx.resourceEnrollment.findUnique({ where: { nodeId } });
|
|
29
|
+
if (!row || row.state !== 'enrolled' || row.nodeFqdn !== nodeFqdn)
|
|
30
|
+
fail('RESOURCE_ENROLLMENT_REQUIRED', 'Verified participant drain enrollment is required before resource execution');
|
|
31
|
+
}
|
|
32
|
+
export function dashboardEnrollmentSecret() {
|
|
33
|
+
const key = process.env['RESOURCE_ENROLLMENT_DASHBOARD_SECRET'] ?? '';
|
|
34
|
+
const other = ['API_SECRET', 'NODE_MANAGER_SECRET', 'RESULT_SIGNING_SECRET', 'JWT_SECRET', 'MAGNUS_SECRET',
|
|
35
|
+
'SECURITY_APPROVAL_BRIDGE_TOKEN', 'COMMAND_RECONCILIATION_SECRET', 'RESOURCE_PARTICIPANT_RECONCILIATION_SECRET',
|
|
36
|
+
'CREDENTIAL_ROTATION_KEY', 'AGENT_DISCOVERY_SECRET',
|
|
37
|
+
'SESSION_SECRET', 'AUDIT_HMAC_SECRET', 'LOG_INGEST_SIGNING_SECRET', 'RESOURCE_LEASE_RESULT_SECRET',
|
|
38
|
+
'RESOURCE_ENROLLMENT_NODE_SECRET', 'RESOURCE_LEASE_ADAPTER_SECRET', 'RESULT_SIGNING_PREVIOUS_SECRET',
|
|
39
|
+
'LOG_INGEST_PREVIOUS_SIGNING_SECRET', 'SECURITY_FUSION_RECEIPT_SIGNING_KEY', 'METRICS_TOKEN', 'WEBHOOK_ENCRYPTION_KEY'];
|
|
40
|
+
if (key.length < 32 || other.some(name => process.env[name] === key))
|
|
41
|
+
throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNCONFIGURED', 'Dedicated dashboard participant credential is required');
|
|
42
|
+
return key;
|
|
43
|
+
}
|
|
44
|
+
export function verifyDashboardCredential(token) {
|
|
45
|
+
const expected = dashboardEnrollmentSecret();
|
|
46
|
+
const received = Buffer.from(token);
|
|
47
|
+
const required = Buffer.from(expected);
|
|
48
|
+
if (received.byteLength !== required.byteLength || !timingSafeEqual(received, required))
|
|
49
|
+
throw new HttpError(401, 'UNAUTHORIZED', 'Dashboard participant credential required');
|
|
50
|
+
}
|
|
51
|
+
function statusFields(node, row) {
|
|
52
|
+
return { version: 1, nodeId: node.id, nodeFqdn: node.hostname,
|
|
53
|
+
state: row?.state ?? 'legacy', epoch: row?.epoch ?? 0, barrierId: row?.barrierId ?? null };
|
|
54
|
+
}
|
|
55
|
+
async function participantSnapshot(tx, nodeId, participant) {
|
|
56
|
+
const rows = await tx.resourceParticipantWork.findMany({ where: { nodeId, participant, state: { in: HELD } },
|
|
57
|
+
orderBy: { workId: 'asc' }, select: { workId: true, ownerId: true, state: true, epoch: true, barrierId: true } });
|
|
58
|
+
return { activeCount: rows.filter(row => row.state !== 'unknown').length,
|
|
59
|
+
unknownCount: rows.filter(row => row.state === 'unknown').length, evidenceSha256: digest(rows) };
|
|
60
|
+
}
|
|
61
|
+
export async function enrollmentStatus(nodeId, nonce, participant) {
|
|
62
|
+
return getDb().$transaction(async (tx) => {
|
|
63
|
+
await lockResourceNode(tx, nodeId);
|
|
64
|
+
const { node, row } = await identity(tx, nodeId);
|
|
65
|
+
const ack = await tx.resourceDrainParticipant.findUnique({ where: { nodeId_participant: { nodeId, participant } } });
|
|
66
|
+
const challenge = row?.state === 'draining' && ack?.epoch === row.epoch && ack.barrierId === row.barrierId
|
|
67
|
+
&& ack.challengeExpiresAt.getTime() > Date.now()
|
|
68
|
+
? { challengeId: ack.challengeId, expiresAt: ack.challengeExpiresAt.getTime() } : null;
|
|
69
|
+
const status = { ...statusFields(node, row), nonce };
|
|
70
|
+
return participant === 'agent' ? { ...status, agentChallenge: challenge }
|
|
71
|
+
: participant === 'scheduler' ? { ...status, schedulerChallenge: challenge }
|
|
72
|
+
: { ...status, challenge, ...await participantSnapshot(tx, nodeId, participant) };
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
export async function nodeEnrollmentStatus(nodeFqdn, nonce, participant = 'agent') {
|
|
76
|
+
const nodes = await getDb().node.findMany({ where: { hostname: nodeFqdn }, select: { id: true }, take: 2 });
|
|
77
|
+
if (nodes.length !== 1)
|
|
78
|
+
fail('RESOURCE_TARGET_INVALID', 'Canonical inventory identity required');
|
|
79
|
+
return enrollmentStatus(nodes[0].id, nonce, participant);
|
|
80
|
+
}
|
|
81
|
+
export async function beginEnrollmentDrain(nodeId, actor) {
|
|
82
|
+
return getDb().$transaction(async (tx) => {
|
|
83
|
+
await lockResourceNode(tx, nodeId);
|
|
84
|
+
const { node, row } = await identity(tx, nodeId);
|
|
85
|
+
if (row && row.state !== 'legacy')
|
|
86
|
+
return statusFields(node, row);
|
|
87
|
+
const data = { nodeFqdn: node.hostname, state: 'draining', epoch: (row?.epoch ?? 0) + 1,
|
|
88
|
+
barrierId: randomUUID(), startedBy: actor, startedAt: new Date() };
|
|
89
|
+
const saved = await tx.resourceEnrollment.upsert({ where: { nodeId }, create: { nodeId, ...data }, update: data });
|
|
90
|
+
return statusFields(node, saved);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
export async function inspectEnrollment(nodeId) {
|
|
94
|
+
return getDb().$transaction(async (tx) => {
|
|
95
|
+
await lockResourceNode(tx, nodeId);
|
|
96
|
+
const { node, row } = await identity(tx, nodeId);
|
|
97
|
+
const participants = await tx.resourceDrainParticipant.findMany({ where: { nodeId }, select: {
|
|
98
|
+
participant: true, status: true, epoch: true, challengeExpiresAt: true, evidenceSha256: true, verifiedAt: true
|
|
99
|
+
} });
|
|
100
|
+
return { ...statusFields(node, row), participants, requiredParticipants: PARTICIPANTS,
|
|
101
|
+
unsupportedParticipants: [],
|
|
102
|
+
schedulerUnsettled: await tx.managedCronOccurrence.count({ where: { nodeId, state: { not: 'completed' } } }),
|
|
103
|
+
commandOwners: await tx.commandResourceLease.count({ where: { nodeId, state: { in: HELD } } }),
|
|
104
|
+
api: await participantSnapshot(tx, nodeId, 'api'),
|
|
105
|
+
apiBlockers: row?.state === 'enrolled' || participants.some(p => p.participant === 'api' && p.status === 'verified' && p.epoch === row?.epoch
|
|
106
|
+
&& p.challengeExpiresAt.getTime() > Date.now()) ? [] :
|
|
107
|
+
['preinstrumentation-work-unverified', 'local-descendants-unverified', 'noninventory-bootstrap-unsupported', 'ssh-descendants-unverified'],
|
|
108
|
+
dashboard: await participantSnapshot(tx, nodeId, 'dashboard') };
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
export const drainDescriptorSchema = z.object({ version: z.literal(1), participant: z.enum(PARTICIPANTS),
|
|
112
|
+
nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(),
|
|
113
|
+
barrierId: z.string().guid(), challengeId: z.string().guid(), expiresAt: z.number().int().positive() }).strict();
|
|
114
|
+
export const drainReceiptSchema = drainDescriptorSchema.extend({ status: z.enum(['drained', 'pending']),
|
|
115
|
+
activeCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), unknownCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
116
|
+
evidenceSha256: z.string().regex(/^[a-f0-9]{64}$/), signature: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
117
|
+
const hex = z.string().regex(/^[a-f0-9]{64}$/);
|
|
118
|
+
const schedulerExternalSourceSchema = z.object({ identityVersion: z.literal(2), kind: z.string().regex(/^[A-Z_]{1,80}$/),
|
|
119
|
+
sourceSha256: hex, count: z.number().int().positive().max(1_000_000) }).strict();
|
|
120
|
+
const EXTERNAL_SCHEDULER_KINDS = new Set(['LEGACY_OR_UNSUPPORTED_CRONTAB', 'SYSTEM_ANACRON', 'SYSTEM_CRONTAB',
|
|
121
|
+
'SYSTEM_CRON_SOURCE', 'SYSTEM_TIMER_DEFINITION', 'USER_TIMER_DEFINITION', 'USER_TIMER_OFFLINE_ENABLED',
|
|
122
|
+
'NATIVE_HERMES_RUNTIME_UNINSTRUMENTED', 'NATIVE_HERMES_SCHEDULES', 'NATIVE_HERMES_UNSETTLED_CLAIMS', 'NATIVE_AT_QUEUE']);
|
|
123
|
+
export const schedulerBoundaryReceiptSchema = drainDescriptorSchema.extend({ participant: z.literal('scheduler'),
|
|
124
|
+
scope: z.literal('magnus-managed-scheduler-only'), status: z.enum(['managed-drained', 'pending']),
|
|
125
|
+
activeCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), unknownCount: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER),
|
|
126
|
+
externalBlockers: z.array(schedulerExternalSourceSchema).max(1024), evidenceSha256: hex, signature: hex }).strict();
|
|
127
|
+
export const schedulerBoundaryAttestationSchema = z.object({ version: z.literal(1), scope: z.literal('magnus-managed-scheduler-only'),
|
|
128
|
+
nodeId: z.string().min(1).max(128), nodeFqdn: z.string().min(1).max(253), epoch: z.number().int().positive(),
|
|
129
|
+
barrierId: z.string().guid(), challengeId: z.string().guid(), receiptSha256: hex, externalBlockersSha256: hex,
|
|
130
|
+
reviewSha256: hex, observedAt: z.number().int().positive(),
|
|
131
|
+
approvalRef: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9:._/-]{0,199}$/), signature: hex }).strict();
|
|
132
|
+
export function schedulerBoundaryAttestationSigningPayload(value) {
|
|
133
|
+
return 'magnus.scheduler-boundary-attestation.v1\n' + canonical(value);
|
|
134
|
+
}
|
|
135
|
+
export const apiDrainAttestationSchema = drainDescriptorSchema.extend({
|
|
136
|
+
participant: z.literal('api'), status: z.literal('drained'), activeCount: z.literal(0), unknownCount: z.literal(0),
|
|
137
|
+
evidenceSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
138
|
+
preinstrumentationSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
139
|
+
localDescendantsSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
140
|
+
sshDescendantsSha256: z.string().regex(/^[a-f0-9]{64}$/),
|
|
141
|
+
observedAt: z.number().int().positive(),
|
|
142
|
+
approvalRef: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9:._/-]{0,199}$/),
|
|
143
|
+
signature: z.string().regex(/^[a-f0-9]{64}$/),
|
|
144
|
+
}).strict();
|
|
145
|
+
export function apiDrainAttestationSigningPayload(value) {
|
|
146
|
+
return 'magnus.resource-enrollment-api-attestation.v1\n' + canonical(value);
|
|
147
|
+
}
|
|
148
|
+
/** Bounded response consumption, including dishonest or absent content-length. */
|
|
149
|
+
async function readReceipt(response) {
|
|
150
|
+
if (response.status !== 200 || !response.body) {
|
|
151
|
+
await response.body?.cancel().catch(() => undefined);
|
|
152
|
+
throw new Error('Participant response unavailable');
|
|
153
|
+
}
|
|
154
|
+
const reader = response.body.getReader();
|
|
155
|
+
const chunks = [];
|
|
156
|
+
let size = 0;
|
|
157
|
+
try {
|
|
158
|
+
while (true) {
|
|
159
|
+
const { value, done } = await reader.read();
|
|
160
|
+
if (done)
|
|
161
|
+
break;
|
|
162
|
+
size += value.byteLength;
|
|
163
|
+
if (size > 16_384)
|
|
164
|
+
throw new Error('Participant response exceeded bound');
|
|
165
|
+
chunks.push(value);
|
|
166
|
+
}
|
|
167
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
168
|
+
}
|
|
169
|
+
finally {
|
|
170
|
+
await reader.cancel().catch(() => undefined);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
export async function refreshEnrollmentParticipant(nodeId, participant) {
|
|
174
|
+
// Fixed inventories and origins only: operator input cannot choose an endpoint or key.
|
|
175
|
+
const prepared = await getDb().$transaction(async (tx) => {
|
|
176
|
+
await lockResourceNode(tx, nodeId);
|
|
177
|
+
const { node, row } = await identity(tx, nodeId);
|
|
178
|
+
if (row?.state !== 'draining' || !row.barrierId)
|
|
179
|
+
fail('RESOURCE_DRAIN_REQUIRED', 'Node must be draining');
|
|
180
|
+
const descriptor = { version: 1, participant, nodeId, nodeFqdn: node.hostname, epoch: row.epoch,
|
|
181
|
+
barrierId: row.barrierId, challengeId: randomUUID(), expiresAt: Date.now() + (participant === 'api' ? 300_000 : 30_000) };
|
|
182
|
+
await tx.resourceDrainParticipant.upsert({ where: { nodeId_participant: { nodeId, participant } },
|
|
183
|
+
create: { nodeId, participant, epoch: row.epoch, barrierId: row.barrierId, challengeId: descriptor.challengeId,
|
|
184
|
+
challengeExpiresAt: new Date(descriptor.expiresAt) }, update: { epoch: row.epoch, barrierId: row.barrierId,
|
|
185
|
+
challengeId: descriptor.challengeId, challengeExpiresAt: new Date(descriptor.expiresAt), status: 'pending',
|
|
186
|
+
evidenceSha256: null, receiptJson: null, verifiedAt: null } });
|
|
187
|
+
return { descriptor, baseUrl: getNodeBaseUrl(node.ipAddress, node.port) };
|
|
188
|
+
});
|
|
189
|
+
if (participant === 'api')
|
|
190
|
+
return { participant, status: 'pending', reason: 'signed-reviewed-evidence-required',
|
|
191
|
+
descriptor: prepared.descriptor };
|
|
192
|
+
let receipt;
|
|
193
|
+
try {
|
|
194
|
+
let url;
|
|
195
|
+
let headers;
|
|
196
|
+
let key;
|
|
197
|
+
if (participant === 'agent' || participant === 'scheduler') {
|
|
198
|
+
key = RESULT_SIGNING_SECRET;
|
|
199
|
+
if (key.length < 32)
|
|
200
|
+
throw new Error('Agent receipt credential unavailable');
|
|
201
|
+
if (new URL(prepared.baseUrl).protocol !== 'https:')
|
|
202
|
+
throw new Error('Verified HTTPS agent endpoint required');
|
|
203
|
+
url = prepared.baseUrl.replace(/\/$/, '') + (participant === 'scheduler'
|
|
204
|
+
? '/api/v1/cron/enrollment-drain' : '/api/v1/commands/enrollment-drain');
|
|
205
|
+
headers = { 'x-node-token': getNodeManagerSecret() };
|
|
206
|
+
}
|
|
207
|
+
else {
|
|
208
|
+
key = dashboardEnrollmentSecret();
|
|
209
|
+
const origin = new URL(process.env['DASHBOARD_PUBLIC_ORIGIN'] ?? '');
|
|
210
|
+
if (origin.protocol !== 'https:' || origin.username || origin.password || origin.pathname !== '/' || origin.search || origin.hash)
|
|
211
|
+
throw new Error('Fixed HTTPS dashboard origin unavailable');
|
|
212
|
+
url = origin.origin + '/api/internal/resource-enrollment/drain';
|
|
213
|
+
headers = { 'x-resource-drain-key': key };
|
|
214
|
+
}
|
|
215
|
+
const response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(participant !== 'dashboard' ? 25_000 : 5_000),
|
|
216
|
+
headers: { ...headers, 'content-type': 'application/json' }, body: JSON.stringify(prepared.descriptor) });
|
|
217
|
+
const raw = await readReceipt(response);
|
|
218
|
+
receipt = participant === 'scheduler' ? schedulerBoundaryReceiptSchema.parse(raw) : drainReceiptSchema.parse(raw);
|
|
219
|
+
const { signature, ...unsigned } = receipt;
|
|
220
|
+
const { status: _status, activeCount: _active, unknownCount: _unknown, evidenceSha256: _evidence, ...remaining } = unsigned;
|
|
221
|
+
const descriptor = participant === 'scheduler'
|
|
222
|
+
? (({ scope: _scope, externalBlockers: _externalBlockers, ...value }) => value)(remaining)
|
|
223
|
+
: remaining;
|
|
224
|
+
if (canonical(descriptor) !== canonical(prepared.descriptor) || receipt.expiresAt <= Date.now()
|
|
225
|
+
|| !timingSafeEqual(Buffer.from(signature, 'hex'), createHmac('sha256', key).update(enrollmentSigningPayload(unsigned)).digest()))
|
|
226
|
+
throw new Error('Participant receipt failed exact challenge authentication');
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
// No raw provider error/body is exposed. Outstanding challenge remains pending.
|
|
230
|
+
return { participant, status: 'pending', reason: 'participant-evidence-unavailable' };
|
|
231
|
+
}
|
|
232
|
+
return getDb().$transaction(async (tx) => {
|
|
233
|
+
await lockResourceNode(tx, nodeId);
|
|
234
|
+
const { row } = await identity(tx, nodeId);
|
|
235
|
+
const ack = await tx.resourceDrainParticipant.findUnique({ where: { nodeId_participant: { nodeId, participant } } });
|
|
236
|
+
if (row?.state !== 'draining' || row.epoch !== receipt.epoch || row.barrierId !== receipt.barrierId
|
|
237
|
+
|| ack?.challengeId !== receipt.challengeId || receipt.expiresAt <= Date.now())
|
|
238
|
+
fail('RESOURCE_DRAIN_STALE', 'Participant evidence no longer matches the current drain');
|
|
239
|
+
const central = await participantSnapshot(tx, nodeId, participant);
|
|
240
|
+
const schedulerUnsettled = participant === 'scheduler'
|
|
241
|
+
? await tx.managedCronOccurrence.count({ where: { nodeId, state: { not: 'completed' } } }) : 0;
|
|
242
|
+
const verified = participant !== 'scheduler' && schedulerUnsettled === 0 && receipt.status === 'drained'
|
|
243
|
+
&& receipt.activeCount === 0 && receipt.unknownCount === 0
|
|
244
|
+
&& central.activeCount === 0 && central.unknownCount === 0
|
|
245
|
+
&& (participant !== 'dashboard' || central.evidenceSha256 === receipt.evidenceSha256);
|
|
246
|
+
await tx.resourceDrainParticipant.update({ where: { nodeId_participant: { nodeId, participant } }, data: {
|
|
247
|
+
status: verified ? 'verified' : 'pending', evidenceSha256: receipt.evidenceSha256,
|
|
248
|
+
receiptJson: JSON.stringify(receipt), verifiedAt: verified ? new Date() : null
|
|
249
|
+
} });
|
|
250
|
+
return { participant, status: verified ? 'verified' : 'pending', reason: verified ? 'signed-current-drain'
|
|
251
|
+
: participant === 'scheduler' && receipt.status === 'managed-drained' && !receipt.activeCount && !receipt.unknownCount
|
|
252
|
+
&& !schedulerUnsettled && !central.activeCount && !central.unknownCount ? 'reviewed-boundary-required' : 'unsettled-work' };
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
export async function acceptSchedulerBoundaryAttestation(nodeId, raw) {
|
|
256
|
+
const assertion = schedulerBoundaryAttestationSchema.parse(raw);
|
|
257
|
+
const { signature, ...unsigned } = assertion;
|
|
258
|
+
const expected = createHmac('sha256', reconciliationSecret()).update(schedulerBoundaryAttestationSigningPayload(unsigned)).digest();
|
|
259
|
+
if (!timingSafeEqual(expected, Buffer.from(signature, 'hex')))
|
|
260
|
+
throw new HttpError(403, 'SCHEDULER_BOUNDARY_EVIDENCE_INVALID', 'Scheduler boundary review signature is invalid');
|
|
261
|
+
return getDb().$transaction(async (tx) => {
|
|
262
|
+
await lockResourceNode(tx, nodeId);
|
|
263
|
+
const { node, row } = await identity(tx, nodeId);
|
|
264
|
+
const ack = await tx.resourceDrainParticipant.findUnique({ where: { nodeId_participant: { nodeId, participant: 'scheduler' } } });
|
|
265
|
+
let receipt;
|
|
266
|
+
try {
|
|
267
|
+
receipt = schedulerBoundaryReceiptSchema.parse(JSON.parse(ack?.receiptJson ?? 'null'));
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
fail('SCHEDULER_BOUNDARY_EVIDENCE_INVALID', 'Current signed scheduler receipt is required');
|
|
271
|
+
}
|
|
272
|
+
const { signature: receiptSignature, ...receiptUnsigned } = receipt;
|
|
273
|
+
const signed = createHmac('sha256', RESULT_SIGNING_SECRET).update(enrollmentSigningPayload(receiptUnsigned)).digest();
|
|
274
|
+
const central = await participantSnapshot(tx, nodeId, 'scheduler');
|
|
275
|
+
const externalKeys = receipt.externalBlockers.map(source => source.kind + ':' + source.sourceSha256);
|
|
276
|
+
if (row?.state !== 'draining' || !row.barrierId || nodeId !== assertion.nodeId || node.hostname !== assertion.nodeFqdn
|
|
277
|
+
|| row.epoch !== assertion.epoch || row.barrierId !== assertion.barrierId || ack?.epoch !== row.epoch
|
|
278
|
+
|| ack.barrierId !== row.barrierId || ack.challengeId !== assertion.challengeId
|
|
279
|
+
|| ack.challengeExpiresAt.getTime() <= Date.now() || receipt.expiresAt !== ack.challengeExpiresAt.getTime()
|
|
280
|
+
|| receipt.nodeId !== nodeId || receipt.nodeFqdn !== node.hostname || receipt.epoch !== row.epoch
|
|
281
|
+
|| receipt.barrierId !== row.barrierId || receipt.challengeId !== ack.challengeId
|
|
282
|
+
|| receipt.status !== 'managed-drained' || receipt.activeCount !== 0 || receipt.unknownCount !== 0
|
|
283
|
+
|| !receipt.externalBlockers.every(source => EXTERNAL_SCHEDULER_KINDS.has(source.kind))
|
|
284
|
+
|| new Set(externalKeys).size !== externalKeys.length
|
|
285
|
+
|| assertion.receiptSha256 !== createHash('sha256').update(ack.receiptJson).digest('hex')
|
|
286
|
+
|| assertion.externalBlockersSha256 !== digest(receipt.externalBlockers)
|
|
287
|
+
|| assertion.observedAt < row.startedAt.getTime() || assertion.observedAt < Date.now() - 300_000
|
|
288
|
+
|| assertion.observedAt > Date.now() + 60_000 || !timingSafeEqual(signed, Buffer.from(receiptSignature, 'hex'))
|
|
289
|
+
|| central.activeCount !== 0 || central.unknownCount !== 0
|
|
290
|
+
|| await tx.managedCronOccurrence.count({ where: { nodeId, state: { not: 'completed' } } }))
|
|
291
|
+
fail('SCHEDULER_BOUNDARY_EVIDENCE_INVALID', 'Scheduler review does not bind current fenced work and observed external sources');
|
|
292
|
+
await tx.resourceDrainParticipant.update({ where: { nodeId_participant: { nodeId, participant: 'scheduler' } }, data: {
|
|
293
|
+
status: 'verified', receiptJson: JSON.stringify({ receipt, assertion }), verifiedAt: new Date()
|
|
294
|
+
} });
|
|
295
|
+
return { participant: 'scheduler', status: 'verified', reason: 'signed-reviewed-scheduler-boundary' };
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
export async function acceptApiDrainAttestation(nodeId, raw) {
|
|
299
|
+
const value = apiDrainAttestationSchema.parse(raw);
|
|
300
|
+
const { signature, ...unsigned } = value;
|
|
301
|
+
const expected = createHmac('sha256', reconciliationSecret()).update(apiDrainAttestationSigningPayload(unsigned)).digest();
|
|
302
|
+
if (!timingSafeEqual(expected, Buffer.from(signature, 'hex')))
|
|
303
|
+
throw new HttpError(403, 'RESOURCE_EVIDENCE_INVALID', 'API drain attestation signature is invalid');
|
|
304
|
+
return getDb().$transaction(async (tx) => {
|
|
305
|
+
await lockResourceNode(tx, nodeId);
|
|
306
|
+
const { node, row } = await identity(tx, nodeId);
|
|
307
|
+
const ack = await tx.resourceDrainParticipant.findUnique({ where: { nodeId_participant: { nodeId, participant: 'api' } } });
|
|
308
|
+
const snapshot = await participantSnapshot(tx, nodeId, 'api');
|
|
309
|
+
const hashes = [value.preinstrumentationSha256, value.localDescendantsSha256, value.sshDescendantsSha256];
|
|
310
|
+
const expectedEvidence = digest({ nodeId, nodeFqdn: node.hostname, epoch: value.epoch, barrierId: value.barrierId,
|
|
311
|
+
preinstrumentationSha256: hashes[0], localDescendantsSha256: hashes[1], sshDescendantsSha256: hashes[2] });
|
|
312
|
+
if (row?.state !== 'draining' || !row.barrierId || node.id !== value.nodeId || node.hostname !== value.nodeFqdn
|
|
313
|
+
|| row.epoch !== value.epoch || row.barrierId !== value.barrierId
|
|
314
|
+
|| ack?.challengeId !== value.challengeId || ack.epoch !== value.epoch || ack.barrierId !== value.barrierId
|
|
315
|
+
|| ack.challengeExpiresAt.getTime() !== value.expiresAt || value.expiresAt <= Date.now()
|
|
316
|
+
|| !row.startedAt || value.observedAt < row.startedAt.getTime() || value.observedAt < Date.now() - 300_000
|
|
317
|
+
|| value.observedAt > Date.now() + 60_000 || new Set(hashes).size !== hashes.length
|
|
318
|
+
|| value.evidenceSha256 !== expectedEvidence || snapshot.activeCount !== 0 || snapshot.unknownCount !== 0)
|
|
319
|
+
fail('RESOURCE_EVIDENCE_INVALID', 'API drain evidence does not bind the current barrier and zero work');
|
|
320
|
+
if (ack.status === 'verified') {
|
|
321
|
+
if (ack.receiptJson !== JSON.stringify(value))
|
|
322
|
+
fail('RESOURCE_WORK_CONFLICT', 'API drain challenge already has different evidence');
|
|
323
|
+
return { participant: 'api', status: 'verified', reason: 'signed-reviewed-current-drain' };
|
|
324
|
+
}
|
|
325
|
+
await tx.resourceDrainParticipant.update({ where: { nodeId_participant: { nodeId, participant: 'api' } }, data: {
|
|
326
|
+
status: 'verified', evidenceSha256: value.evidenceSha256, receiptJson: JSON.stringify(value), verifiedAt: new Date(),
|
|
327
|
+
} });
|
|
328
|
+
return { participant: 'api', status: 'verified', reason: 'signed-reviewed-current-drain' };
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
export async function completeEnrollment(nodeId) {
|
|
332
|
+
return getDb().$transaction(async (tx) => {
|
|
333
|
+
await lockResourceNode(tx, nodeId);
|
|
334
|
+
const { node, row } = await identity(tx, nodeId);
|
|
335
|
+
if (row?.state === 'enrolled')
|
|
336
|
+
return statusFields(node, row);
|
|
337
|
+
if (row?.state !== 'draining')
|
|
338
|
+
fail('RESOURCE_DRAIN_REQUIRED', 'Node must be draining');
|
|
339
|
+
const acks = await tx.resourceDrainParticipant.findMany({ where: { nodeId } });
|
|
340
|
+
const missing = PARTICIPANTS.filter(p => !acks.some(a => a.participant === p && a.status === 'verified'
|
|
341
|
+
&& a.epoch === row.epoch && a.barrierId === row.barrierId && a.challengeExpiresAt.getTime() > Date.now()));
|
|
342
|
+
if (missing.length)
|
|
343
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Enrollment pending authoritative agent, API, dashboard and scheduler drain integrations');
|
|
344
|
+
for (const participant of PARTICIPANTS) {
|
|
345
|
+
const ack = acks.find(a => a.participant === participant);
|
|
346
|
+
if (participant === 'scheduler') {
|
|
347
|
+
let reviewed;
|
|
348
|
+
try {
|
|
349
|
+
reviewed = z.object({ receipt: schedulerBoundaryReceiptSchema, assertion: schedulerBoundaryAttestationSchema }).strict()
|
|
350
|
+
.parse(JSON.parse(ack.receiptJson ?? 'null'));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Reviewed scheduler receipt is unavailable');
|
|
354
|
+
}
|
|
355
|
+
const { receipt, assertion } = reviewed;
|
|
356
|
+
const { signature: nodeSignature, ...nodeUnsigned } = receipt;
|
|
357
|
+
const { signature: reviewSignature, ...reviewUnsigned } = assertion;
|
|
358
|
+
const nodeSigned = createHmac('sha256', RESULT_SIGNING_SECRET).update(enrollmentSigningPayload(nodeUnsigned)).digest();
|
|
359
|
+
const reviewSigned = createHmac('sha256', reconciliationSecret()).update(schedulerBoundaryAttestationSigningPayload(reviewUnsigned)).digest();
|
|
360
|
+
const rawReceipt = JSON.stringify(receipt);
|
|
361
|
+
if (receipt.nodeId !== nodeId || receipt.nodeFqdn !== node.hostname || receipt.epoch !== row.epoch
|
|
362
|
+
|| receipt.barrierId !== row.barrierId || receipt.challengeId !== ack.challengeId
|
|
363
|
+
|| receipt.expiresAt !== ack.challengeExpiresAt.getTime() || receipt.status !== 'managed-drained'
|
|
364
|
+
|| receipt.activeCount !== 0 || receipt.unknownCount !== 0 || receipt.evidenceSha256 !== ack.evidenceSha256
|
|
365
|
+
|| !receipt.externalBlockers.every(source => EXTERNAL_SCHEDULER_KINDS.has(source.kind))
|
|
366
|
+
|| assertion.nodeId !== nodeId || assertion.nodeFqdn !== node.hostname || assertion.epoch !== row.epoch
|
|
367
|
+
|| assertion.barrierId !== row.barrierId || assertion.challengeId !== ack.challengeId
|
|
368
|
+
|| assertion.receiptSha256 !== createHash('sha256').update(rawReceipt).digest('hex')
|
|
369
|
+
|| assertion.externalBlockersSha256 !== digest(receipt.externalBlockers)
|
|
370
|
+
|| !timingSafeEqual(nodeSigned, Buffer.from(nodeSignature, 'hex'))
|
|
371
|
+
|| !timingSafeEqual(reviewSigned, Buffer.from(reviewSignature, 'hex')))
|
|
372
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored scheduler boundary failed current reviewed verification');
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
let receipt;
|
|
376
|
+
try {
|
|
377
|
+
receipt = participant === 'api'
|
|
378
|
+
? apiDrainAttestationSchema.parse(JSON.parse(ack.receiptJson ?? 'null'))
|
|
379
|
+
: drainReceiptSchema.parse(JSON.parse(ack.receiptJson ?? 'null'));
|
|
380
|
+
}
|
|
381
|
+
catch {
|
|
382
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored participant receipt is unavailable');
|
|
383
|
+
}
|
|
384
|
+
const { signature, ...unsigned } = receipt;
|
|
385
|
+
const key = participant === 'dashboard' ? dashboardEnrollmentSecret()
|
|
386
|
+
: participant === 'api' ? reconciliationSecret() : RESULT_SIGNING_SECRET;
|
|
387
|
+
const payload = participant === 'api'
|
|
388
|
+
? apiDrainAttestationSigningPayload(unsigned)
|
|
389
|
+
: enrollmentSigningPayload(unsigned);
|
|
390
|
+
const signed = createHmac('sha256', key).update(payload).digest();
|
|
391
|
+
if (receipt.participant !== participant || receipt.nodeId !== nodeId || receipt.nodeFqdn !== node.hostname
|
|
392
|
+
|| receipt.epoch !== row.epoch || receipt.barrierId !== row.barrierId
|
|
393
|
+
|| receipt.challengeId !== ack.challengeId || receipt.expiresAt !== ack.challengeExpiresAt.getTime()
|
|
394
|
+
|| receipt.status !== 'drained' || receipt.activeCount !== 0 || receipt.unknownCount !== 0
|
|
395
|
+
|| receipt.evidenceSha256 !== ack.evidenceSha256
|
|
396
|
+
|| !timingSafeEqual(signed, Buffer.from(signature, 'hex')))
|
|
397
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Stored participant receipt failed exact current drain verification');
|
|
398
|
+
}
|
|
399
|
+
if (await tx.commandResourceLease.count({ where: { nodeId, state: { in: HELD } } })
|
|
400
|
+
|| await tx.resourceParticipantWork.count({ where: { nodeId, state: { in: HELD } } })
|
|
401
|
+
|| await tx.managedCronOccurrence.count({ where: { nodeId, state: { not: 'completed' } } }))
|
|
402
|
+
fail('RESOURCE_DRAIN_INCOMPLETE', 'Owned or unknown work remains');
|
|
403
|
+
const saved = await tx.resourceEnrollment.update({ where: { nodeId }, data: { state: 'enrolled', enrolledAt: new Date() } });
|
|
404
|
+
return statusFields(node, saved);
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
export const dashboardReserveSchema = z.object({ host: z.string().regex(/^[a-zA-Z0-9][a-zA-Z0-9.:-]{0,252}$/),
|
|
408
|
+
workId: z.string().guid(), ownerId: z.string().guid(), kind: z.enum(['terminal-http', 'terminal-ws', 'hermes-http', 'hermes-ws']),
|
|
409
|
+
operationSha256: z.string().regex(/^[a-f0-9]{64}$/), deadline: z.number().int().positive() }).strict();
|
|
410
|
+
export const dashboardStartSchema = z.object({ ownerId: z.string().guid(), epoch: z.number().int().nonnegative(), barrierId: z.string().guid().nullable(),
|
|
411
|
+
operationSha256: z.string().regex(/^[a-f0-9]{64}$/), fence: z.string().regex(/^[1-9][0-9]{0,19}$/),
|
|
412
|
+
deadline: z.number().int().positive(), requestSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
413
|
+
export const dashboardFinishSchema = z.object({ ownerId: z.string().guid(), disposition: z.enum(['not-started', 'remote-ended', 'unknown']),
|
|
414
|
+
evidenceSha256: z.string().regex(/^[a-f0-9]{64}$/) }).strict();
|
|
415
|
+
// Fixed dashboard facade preserves the existing authenticated wire contract.
|
|
416
|
+
export const reserveDashboardWork = (input) => dashboardParticipantWork.reserve(input);
|
|
417
|
+
export const startDashboardWork = (workId, input) => dashboardParticipantWork.start(workId, input);
|
|
418
|
+
export const finishDashboardWork = (workId, input) => dashboardParticipantWork.finish(workId, input);
|
|
419
|
+
/** Deployment preflight only: no enrollment, proof creation, or work admission. */
|
|
420
|
+
export async function dashboardParticipantReadiness() {
|
|
421
|
+
try {
|
|
422
|
+
await getDb().$transaction(async (tx) => {
|
|
423
|
+
await tx.resourceEnrollment.findFirst({ select: { nodeId: true } });
|
|
424
|
+
await tx.resourceDrainParticipant.findFirst({ select: { nodeId: true } });
|
|
425
|
+
await tx.resourceParticipantWork.findFirst({ select: { workId: true } });
|
|
426
|
+
}, { maxWait: 2_000, timeout: 3_000 });
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
throw new HttpError(503, 'RESOURCE_PARTICIPANT_UNAVAILABLE', 'Durable participant schema is not ready');
|
|
430
|
+
}
|
|
431
|
+
return { version: 1, participant: 'dashboard', protocol: 'resource-enrollment-v1', ready: true };
|
|
432
|
+
}
|
|
433
|
+
//# sourceMappingURL=resource-enrollment.js.map
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { HttpError } from '../middleware/error-handler.js';
|
|
2
|
+
function fail(code, message) { throw new HttpError(409, code, message); }
|
|
3
|
+
export async function lockResourceNode(tx, nodeId) {
|
|
4
|
+
await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`command-resource-node:${nodeId}`}, 0))`;
|
|
5
|
+
}
|
|
6
|
+
export async function resourceNodeIdentity(tx, nodeId) {
|
|
7
|
+
const node = await tx.node.findUnique({ where: { id: nodeId } });
|
|
8
|
+
if (!node)
|
|
9
|
+
throw new HttpError(404, 'NODE_NOT_FOUND', 'Inventory node not found');
|
|
10
|
+
const duplicate = await tx.node.count({ where: { hostname: node.hostname } });
|
|
11
|
+
if (duplicate !== 1)
|
|
12
|
+
fail('RESOURCE_TARGET_INVALID', 'Canonical inventory identity is ambiguous');
|
|
13
|
+
const row = await tx.resourceEnrollment.findUnique({ where: { nodeId } });
|
|
14
|
+
if (row && row.nodeFqdn !== node.hostname)
|
|
15
|
+
fail('RESOURCE_TARGET_INVALID', 'Enrollment inventory identity changed');
|
|
16
|
+
return { node, row };
|
|
17
|
+
}
|
|
18
|
+
//# sourceMappingURL=resource-node-lock.js.map
|