@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.
Files changed (356) hide show
  1. package/.env.example +171 -0
  2. package/LICENSE +202 -0
  3. package/README.md +169 -2
  4. package/agent-version.json +1 -0
  5. package/dist/ai/HEARTBEAT.md +56 -0
  6. package/dist/ai/IDENTITY.md +54 -0
  7. package/dist/ai/MEMORY.md +40 -0
  8. package/dist/ai/TOOLS.md +538 -0
  9. package/dist/ai/USER.md +32 -0
  10. package/dist/cli/args.js +62 -0
  11. package/dist/cli/env-catalog.js +131 -0
  12. package/dist/cli/preflight.js +89 -0
  13. package/dist/cli.js +175 -0
  14. package/dist/config/cors.js +16 -0
  15. package/dist/config/env.js +392 -0
  16. package/dist/config/features.js +16 -0
  17. package/dist/config/index.js +3 -0
  18. package/dist/config/listener-security.js +38 -0
  19. package/dist/config/version.js +31 -0
  20. package/dist/db/client.js +44 -0
  21. package/dist/db/generated/prisma/browser.js +18 -0
  22. package/dist/db/generated/prisma/client.js +35 -0
  23. package/dist/db/generated/prisma/commonInputTypes.js +11 -0
  24. package/dist/db/generated/prisma/enums.js +42 -0
  25. package/dist/db/generated/prisma/internal/class.js +50 -0
  26. package/dist/db/generated/prisma/internal/prismaNamespace.js +1598 -0
  27. package/dist/db/generated/prisma/internal/prismaNamespaceBrowser.js +1569 -0
  28. package/dist/db/generated/prisma/models/A2ATask.js +2 -0
  29. package/dist/db/generated/prisma/models/AdmissionFence.js +2 -0
  30. package/dist/db/generated/prisma/models/Agent.js +2 -0
  31. package/dist/db/generated/prisma/models/AiConversation.js +2 -0
  32. package/dist/db/generated/prisma/models/AiDashboard.js +2 -0
  33. package/dist/db/generated/prisma/models/AiMemory.js +2 -0
  34. package/dist/db/generated/prisma/models/AiMessage.js +2 -0
  35. package/dist/db/generated/prisma/models/AiScheduledOp.js +2 -0
  36. package/dist/db/generated/prisma/models/AlertConditionState.js +2 -0
  37. package/dist/db/generated/prisma/models/AlertRule.js +2 -0
  38. package/dist/db/generated/prisma/models/AlertViolation.js +2 -0
  39. package/dist/db/generated/prisma/models/AnomalyEvent.js +2 -0
  40. package/dist/db/generated/prisma/models/ApplicationObservation.js +2 -0
  41. package/dist/db/generated/prisma/models/ArdentInstallation.js +2 -0
  42. package/dist/db/generated/prisma/models/ArdentProfile.js +2 -0
  43. package/dist/db/generated/prisma/models/ArdentScopeEnrollment.js +2 -0
  44. package/dist/db/generated/prisma/models/CommandResourceFence.js +2 -0
  45. package/dist/db/generated/prisma/models/CommandResourceLease.js +2 -0
  46. package/dist/db/generated/prisma/models/CommandResult.js +2 -0
  47. package/dist/db/generated/prisma/models/CronJob.js +2 -0
  48. package/dist/db/generated/prisma/models/CronJobExecution.js +2 -0
  49. package/dist/db/generated/prisma/models/DeployJob.js +2 -0
  50. package/dist/db/generated/prisma/models/DriftScan.js +2 -0
  51. package/dist/db/generated/prisma/models/EngineeringMetricSample.js +2 -0
  52. package/dist/db/generated/prisma/models/EngineeringRecoveryExercise.js +2 -0
  53. package/dist/db/generated/prisma/models/ExecutionRun.js +2 -0
  54. package/dist/db/generated/prisma/models/FilePatchTask.js +2 -0
  55. package/dist/db/generated/prisma/models/GpuSnapshot.js +2 -0
  56. package/dist/db/generated/prisma/models/HealthSnapshot.js +2 -0
  57. package/dist/db/generated/prisma/models/HermesCoreRollout.js +2 -0
  58. package/dist/db/generated/prisma/models/HermesCoreRolloutHost.js +2 -0
  59. package/dist/db/generated/prisma/models/HermesCredentialPool.js +2 -0
  60. package/dist/db/generated/prisma/models/HermesCredentialPoolAttachment.js +2 -0
  61. package/dist/db/generated/prisma/models/HermesCronJob.js +2 -0
  62. package/dist/db/generated/prisma/models/HermesHook.js +2 -0
  63. package/dist/db/generated/prisma/models/HermesInstallation.js +2 -0
  64. package/dist/db/generated/prisma/models/HermesMcpServer.js +2 -0
  65. package/dist/db/generated/prisma/models/HermesMcpServerAttachment.js +2 -0
  66. package/dist/db/generated/prisma/models/HermesMessagingBinding.js +2 -0
  67. package/dist/db/generated/prisma/models/HermesOperationJob.js +2 -0
  68. package/dist/db/generated/prisma/models/HermesPluginInstall.js +2 -0
  69. package/dist/db/generated/prisma/models/HermesProfile.js +2 -0
  70. package/dist/db/generated/prisma/models/HermesProviderConfig.js +2 -0
  71. package/dist/db/generated/prisma/models/HermesRegistryClient.js +2 -0
  72. package/dist/db/generated/prisma/models/HermesRegistryInventory.js +2 -0
  73. package/dist/db/generated/prisma/models/HermesRegistryReceipt.js +2 -0
  74. package/dist/db/generated/prisma/models/HermesRegistryRevision.js +2 -0
  75. package/dist/db/generated/prisma/models/HermesRegistryTarget.js +2 -0
  76. package/dist/db/generated/prisma/models/HermesRole.js +2 -0
  77. package/dist/db/generated/prisma/models/HermesRoleApplication.js +2 -0
  78. package/dist/db/generated/prisma/models/HermesSkillAssignment.js +2 -0
  79. package/dist/db/generated/prisma/models/HermesToolsetPolicy.js +2 -0
  80. package/dist/db/generated/prisma/models/IncidentLesson.js +2 -0
  81. package/dist/db/generated/prisma/models/LibraryCategory.js +2 -0
  82. package/dist/db/generated/prisma/models/LibraryFile.js +2 -0
  83. package/dist/db/generated/prisma/models/LogEntry.js +2 -0
  84. package/dist/db/generated/prisma/models/MagnusReleaseCanary.js +2 -0
  85. package/dist/db/generated/prisma/models/MaintenanceWindow.js +2 -0
  86. package/dist/db/generated/prisma/models/ManagedCronDefinition.js +2 -0
  87. package/dist/db/generated/prisma/models/ManagedCronOccurrence.js +2 -0
  88. package/dist/db/generated/prisma/models/Node.js +2 -0
  89. package/dist/db/generated/prisma/models/Notification.js +2 -0
  90. package/dist/db/generated/prisma/models/NotificationPreference.js +2 -0
  91. package/dist/db/generated/prisma/models/RemediationExecution.js +2 -0
  92. package/dist/db/generated/prisma/models/RemediationRule.js +2 -0
  93. package/dist/db/generated/prisma/models/RemoteCommand.js +2 -0
  94. package/dist/db/generated/prisma/models/ResourceDrainParticipant.js +2 -0
  95. package/dist/db/generated/prisma/models/ResourceEnrollment.js +2 -0
  96. package/dist/db/generated/prisma/models/ResourceParticipantWork.js +2 -0
  97. package/dist/db/generated/prisma/models/Role.js +2 -0
  98. package/dist/db/generated/prisma/models/RoleApplication.js +2 -0
  99. package/dist/db/generated/prisma/models/RolloutTarget.js +2 -0
  100. package/dist/db/generated/prisma/models/SecurityExportOutbox.js +2 -0
  101. package/dist/db/generated/prisma/models/SecurityFusionAction.js +2 -0
  102. package/dist/db/generated/prisma/models/SecurityFusionActionApproval.js +2 -0
  103. package/dist/db/generated/prisma/models/SecurityFusionCase.js +2 -0
  104. package/dist/db/generated/prisma/models/SecurityFusionCaseActivity.js +2 -0
  105. package/dist/db/generated/prisma/models/SecurityFusionCaseEntity.js +2 -0
  106. package/dist/db/generated/prisma/models/SecurityFusionCaseEvent.js +2 -0
  107. package/dist/db/generated/prisma/models/SecurityFusionCaseRelationship.js +2 -0
  108. package/dist/db/generated/prisma/models/SecurityFusionCollector.js +2 -0
  109. package/dist/db/generated/prisma/models/SecurityFusionCommandOutbox.js +2 -0
  110. package/dist/db/generated/prisma/models/SecurityFusionDeletionReceipt.js +2 -0
  111. package/dist/db/generated/prisma/models/SecurityFusionDelivery.js +2 -0
  112. package/dist/db/generated/prisma/models/SecurityFusionEntity.js +2 -0
  113. package/dist/db/generated/prisma/models/SecurityFusionEntityAlias.js +2 -0
  114. package/dist/db/generated/prisma/models/SecurityFusionEvent.js +2 -0
  115. package/dist/db/generated/prisma/models/SecurityFusionEventEntity.js +2 -0
  116. package/dist/db/generated/prisma/models/SecurityFusionEvidence.js +2 -0
  117. package/dist/db/generated/prisma/models/SecurityFusionInvestigation.js +2 -0
  118. package/dist/db/generated/prisma/models/SecurityFusionNonce.js +2 -0
  119. package/dist/db/generated/prisma/models/SecurityFusionReceiptChainHead.js +2 -0
  120. package/dist/db/generated/prisma/models/SecurityFusionRuleVersion.js +2 -0
  121. package/dist/db/generated/prisma/models/SecurityFusionSourceState.js +2 -0
  122. package/dist/db/generated/prisma/models/SecurityServiceState.js +2 -0
  123. package/dist/db/generated/prisma/models/SharedResourceFence.js +2 -0
  124. package/dist/db/generated/prisma/models/SharedResourceLease.js +2 -0
  125. package/dist/db/generated/prisma/models/SharedResourceLeaseResource.js +2 -0
  126. package/dist/db/generated/prisma/models/SkillInstallation.js +2 -0
  127. package/dist/db/generated/prisma/models/SkillRegistryEntry.js +2 -0
  128. package/dist/db/generated/prisma/models/SkillTag.js +2 -0
  129. package/dist/db/generated/prisma/models/SkillVersion.js +2 -0
  130. package/dist/db/generated/prisma/models/TeamApprovalDecision.js +2 -0
  131. package/dist/db/generated/prisma/models/User.js +2 -0
  132. package/dist/db/generated/prisma/models/WebhookDelivery.js +2 -0
  133. package/dist/db/generated/prisma/models/WebhookEndpoint.js +2 -0
  134. package/dist/db/generated/prisma/models.js +2 -0
  135. package/dist/db/index.js +3 -0
  136. package/dist/db/read-retry.js +35 -0
  137. package/dist/db/seed.js +348 -0
  138. package/dist/index.js +162 -0
  139. package/dist/middleware/auth.js +361 -0
  140. package/dist/middleware/body-limit.js +14 -0
  141. package/dist/middleware/correlation-id.js +13 -0
  142. package/dist/middleware/error-handler.js +28 -0
  143. package/dist/middleware/iam-token.js +21 -0
  144. package/dist/middleware/rate-limit.js +78 -0
  145. package/dist/middleware/request-logger.js +85 -0
  146. package/dist/middleware/security-headers.js +23 -0
  147. package/dist/middleware/tracing.js +120 -0
  148. package/dist/middleware/user-status.js +33 -0
  149. package/dist/openapi/spec.js +2656 -0
  150. package/dist/queue/redis.js +20 -0
  151. package/dist/queue/requeue.js +27 -0
  152. package/dist/queue/workers.js +342 -0
  153. package/dist/routes/ai-schedule.js +73 -0
  154. package/dist/routes/alerts.js +79 -0
  155. package/dist/routes/anomalies.js +101 -0
  156. package/dist/routes/ardent.js +82 -0
  157. package/dist/routes/assistant.js +417 -0
  158. package/dist/routes/audit.js +138 -0
  159. package/dist/routes/codex.js +65 -0
  160. package/dist/routes/commands.js +226 -0
  161. package/dist/routes/credentials.js +58 -0
  162. package/dist/routes/cron.js +292 -0
  163. package/dist/routes/dependencies.js +49 -0
  164. package/dist/routes/deploy.js +18 -0
  165. package/dist/routes/discovery.js +168 -0
  166. package/dist/routes/docs.js +24 -0
  167. package/dist/routes/drift.js +126 -0
  168. package/dist/routes/events-stream.js +87 -0
  169. package/dist/routes/files.js +214 -0
  170. package/dist/routes/fleet-checks.js +21 -0
  171. package/dist/routes/health.js +133 -0
  172. package/dist/routes/heartbeat.js +545 -0
  173. package/dist/routes/hermes-registry.js +278 -0
  174. package/dist/routes/hermes.js +61 -0
  175. package/dist/routes/library.js +402 -0
  176. package/dist/routes/logs.js +172 -0
  177. package/dist/routes/managed-cron.js +23 -0
  178. package/dist/routes/mcp.js +39 -0
  179. package/dist/routes/metrics.js +187 -0
  180. package/dist/routes/nodes.js +951 -0
  181. package/dist/routes/notifications.js +130 -0
  182. package/dist/routes/ops.js +258 -0
  183. package/dist/routes/orchestra.js +430 -0
  184. package/dist/routes/predictions.js +23 -0
  185. package/dist/routes/profiling.js +68 -0
  186. package/dist/routes/quick-command.js +72 -0
  187. package/dist/routes/remediation.js +111 -0
  188. package/dist/routes/resource-leases.js +297 -0
  189. package/dist/routes/roles.js +370 -0
  190. package/dist/routes/rollout.js +124 -0
  191. package/dist/routes/scan.js +116 -0
  192. package/dist/routes/security-backup.js +58 -0
  193. package/dist/routes/security-fusion.js +605 -0
  194. package/dist/routes/security-integration.js +71 -0
  195. package/dist/routes/self-update.js +129 -0
  196. package/dist/routes/shared-resource-leases.js +78 -0
  197. package/dist/routes/skills.js +485 -0
  198. package/dist/routes/stats.js +96 -0
  199. package/dist/routes/team-approvals.js +23 -0
  200. package/dist/routes/terminal-ticket.js +15 -0
  201. package/dist/routes/terminal.js +17 -0
  202. package/dist/routes/topology.js +37 -0
  203. package/dist/routes/users.js +219 -0
  204. package/dist/routes/webhooks.js +137 -0
  205. package/dist/schemas/command-reconciliation.js +14 -0
  206. package/dist/schemas/commands.js +51 -0
  207. package/dist/schemas/cron.js +33 -0
  208. package/dist/schemas/deploy.js +20 -0
  209. package/dist/schemas/files.js +14 -0
  210. package/dist/schemas/hermes.js +69 -0
  211. package/dist/schemas/index.js +11 -0
  212. package/dist/schemas/library.js +48 -0
  213. package/dist/schemas/logs.js +35 -0
  214. package/dist/schemas/managed-cron.js +127 -0
  215. package/dist/schemas/node-responses.js +87 -0
  216. package/dist/schemas/node-session-receipt.js +84 -0
  217. package/dist/schemas/nodes.js +36 -0
  218. package/dist/schemas/orchestra.js +118 -0
  219. package/dist/schemas/resource-participant-reconciliation.js +99 -0
  220. package/dist/schemas/roles.js +54 -0
  221. package/dist/schemas/shared-resource-lease.js +132 -0
  222. package/dist/schemas/skills.js +44 -0
  223. package/dist/schemas/users.js +28 -0
  224. package/dist/scripts/run-disposable-recovery-exercise.js +39 -0
  225. package/dist/sdk/index.js +2 -0
  226. package/dist/sdk/node-api-client.js +474 -0
  227. package/dist/security/approval-assertion.js +66 -0
  228. package/dist/security/command-completion-registry.js +35 -0
  229. package/dist/security/command-request.js +12 -0
  230. package/dist/security/command-result-signature.js +34 -0
  231. package/dist/security/managed-http.js +45 -0
  232. package/dist/security/native-completion-registry.js +44 -0
  233. package/dist/server.js +178 -0
  234. package/dist/services/admission-fence.js +71 -0
  235. package/dist/services/ai-assistant-service.js +2523 -0
  236. package/dist/services/ai-correlation-service.js +225 -0
  237. package/dist/services/ai-export-service.js +141 -0
  238. package/dist/services/ai-insights-service.js +219 -0
  239. package/dist/services/ai-schedule-service.js +145 -0
  240. package/dist/services/ai-scratchpad-service.js +222 -0
  241. package/dist/services/ai-tools.js +1343 -0
  242. package/dist/services/alert-service.js +313 -0
  243. package/dist/services/anomaly-detector.js +419 -0
  244. package/dist/services/api-local-work.js +79 -0
  245. package/dist/services/application-probes.js +309 -0
  246. package/dist/services/ardent-scope-enrollment.js +123 -0
  247. package/dist/services/ardent-service.js +86 -0
  248. package/dist/services/audit-service.js +308 -0
  249. package/dist/services/codex-service.js +209 -0
  250. package/dist/services/command-lifecycle.js +60 -0
  251. package/dist/services/command-resource-leases.js +305 -0
  252. package/dist/services/control-host-node.js +242 -0
  253. package/dist/services/credential-rotation-service.js +294 -0
  254. package/dist/services/dependency-service.js +145 -0
  255. package/dist/services/disposable-recovery-runner.js +595 -0
  256. package/dist/services/docker-group-health.js +121 -0
  257. package/dist/services/engineering-assurance.js +492 -0
  258. package/dist/services/engineering-evidence-ledger.js +94 -0
  259. package/dist/services/engineering-metric-evidence.js +26 -0
  260. package/dist/services/event-bus.js +227 -0
  261. package/dist/services/fleet-check-log.js +102 -0
  262. package/dist/services/forecast-service.js +154 -0
  263. package/dist/services/health-retention.js +175 -0
  264. package/dist/services/heartbeat-stream.js +63 -0
  265. package/dist/services/hermes-catalog-import.js +62 -0
  266. package/dist/services/hermes-cli-parser.js +671 -0
  267. package/dist/services/hermes-core-rollout.js +90 -0
  268. package/dist/services/hermes-dashboard-endpoint.js +13 -0
  269. package/dist/services/hermes-plugin-source.js +149 -0
  270. package/dist/services/hermes-registry-service.js +258 -0
  271. package/dist/services/hermes-service.js +566 -0
  272. package/dist/services/hivemem-client.js +85 -0
  273. package/dist/services/host-local-writer.js +82 -0
  274. package/dist/services/incident-evidence.js +110 -0
  275. package/dist/services/incident-lessons.js +186 -0
  276. package/dist/services/llm-providers/anthropic.js +143 -0
  277. package/dist/services/llm-providers/index.js +7 -0
  278. package/dist/services/llm-providers/llama-cpp.js +237 -0
  279. package/dist/services/llm-providers/ollama.js +292 -0
  280. package/dist/services/llm-providers/openai-compatible.js +200 -0
  281. package/dist/services/llm-providers/provider-error.js +36 -0
  282. package/dist/services/llm-providers/registry.js +45 -0
  283. package/dist/services/llm-providers/types.js +2 -0
  284. package/dist/services/log-emitter.js +19 -0
  285. package/dist/services/log-retention.js +66 -0
  286. package/dist/services/maintenance-service.js +29 -0
  287. package/dist/services/managed-cron.js +493 -0
  288. package/dist/services/managed-ssh-admission.js +68 -0
  289. package/dist/services/monitor-pulse.js +10 -0
  290. package/dist/services/node-issue-monitor.js +71 -0
  291. package/dist/services/node-service.js +35 -0
  292. package/dist/services/notification-retention.js +51 -0
  293. package/dist/services/notification-service.js +94 -0
  294. package/dist/services/operational-observations.js +112 -0
  295. package/dist/services/operations-config.js +67 -0
  296. package/dist/services/ops-service.js +491 -0
  297. package/dist/services/orchestra-extras-service.js +370 -0
  298. package/dist/services/orchestra-resources-service.js +530 -0
  299. package/dist/services/os-scheduler-writer.js +73 -0
  300. package/dist/services/password-service.js +22 -0
  301. package/dist/services/prediction-service.js +284 -0
  302. package/dist/services/recovery-exercises.js +682 -0
  303. package/dist/services/remediation-service.js +325 -0
  304. package/dist/services/resource-enrollment.js +433 -0
  305. package/dist/services/resource-node-lock.js +18 -0
  306. package/dist/services/resource-participant-work.js +302 -0
  307. package/dist/services/role-service.js +388 -0
  308. package/dist/services/rollout-service.js +440 -0
  309. package/dist/services/security-export.js +185 -0
  310. package/dist/services/security-fusion-actions.js +22 -0
  311. package/dist/services/security-fusion-approvals.js +83 -0
  312. package/dist/services/security-fusion-evidence-integrity.js +144 -0
  313. package/dist/services/security-fusion-ingest.js +101 -0
  314. package/dist/services/security-fusion-outbox.js +185 -0
  315. package/dist/services/security-fusion-receipt-chain.js +134 -0
  316. package/dist/services/security-fusion-service.js +751 -0
  317. package/dist/services/security-fusion-verification.js +50 -0
  318. package/dist/services/self-update-service.js +200 -0
  319. package/dist/services/shared-resource-leases.js +413 -0
  320. package/dist/services/skill-publication-bundle.js +90 -0
  321. package/dist/services/skill-publication-settlement.js +36 -0
  322. package/dist/services/skill-publication-state.js +40 -0
  323. package/dist/services/skill-registry-service.js +327 -0
  324. package/dist/services/skill-service.js +148 -0
  325. package/dist/services/ssh-exec-service.js +139 -0
  326. package/dist/services/ssh-host-verification.js +27 -0
  327. package/dist/services/tailscale-node-key-service.js +75 -0
  328. package/dist/services/team-approvals.js +118 -0
  329. package/dist/services/topology-service.js +138 -0
  330. package/dist/services/transfer-authority-snapshot.js +151 -0
  331. package/dist/services/webhook-credential-migration.js +97 -0
  332. package/dist/services/webhook-security.js +242 -0
  333. package/dist/services/webhook-service.js +494 -0
  334. package/dist/types/auth.js +3 -0
  335. package/dist/types/index.js +3 -0
  336. package/dist/types/library.js +3 -0
  337. package/dist/types/notifications.js +3 -0
  338. package/dist/types/roles.js +3 -0
  339. package/dist/types/shared-contracts.js +3 -0
  340. package/dist/utils/env.js +141 -0
  341. package/dist/utils/heartbeat-peer.js +19 -0
  342. package/dist/utils/idempotency.js +45 -0
  343. package/dist/utils/ip.js +25 -0
  344. package/dist/utils/json-fields.js +94 -0
  345. package/dist/utils/local-tls.js +39 -0
  346. package/dist/utils/magnus-home.js +15 -0
  347. package/dist/utils/node-url.js +59 -0
  348. package/dist/utils/redact.js +26 -0
  349. package/dist/utils/safe-equal.js +13 -0
  350. package/dist/utils/sentinel-nodes.js +16 -0
  351. package/dist/utils/validate.js +41 -0
  352. package/package.json +119 -4
  353. package/prisma/migrations/20261031000000_baseline/migration.sql +2935 -0
  354. package/prisma/migrations/migration_lock.toml +3 -0
  355. package/prisma/schema.prisma +2185 -0
  356. package/prisma.config.ts +14 -0
@@ -0,0 +1,139 @@
1
+ import { withApiSshOwnership } from './managed-ssh-admission.js';
2
+ import { Client } from 'ssh2';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+ import { sshHostVerification } from './ssh-host-verification.js';
6
+ /** Channel closure is transport evidence, not a remote execution result. */
7
+ export class SshCommandOutcomeUnknownError extends Error {
8
+ code = 'SSH_COMMAND_OUTCOME_UNKNOWN';
9
+ outcome = 'unknown';
10
+ retryable = false;
11
+ constructor() {
12
+ super('SSH command closed without a valid exit status; outcome unknown. Reconcile remote effects before retrying.');
13
+ this.name = 'SshCommandOutcomeUnknownError';
14
+ }
15
+ }
16
+ /** A real SSH exit-status frame is an unsigned 32-bit integer. */
17
+ export function requireSshExitStatus(value) {
18
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 0xffffffff)
19
+ throw new SshCommandOutcomeUnknownError();
20
+ return value;
21
+ }
22
+ const SENSITIVE_LINE_PATTERNS = [
23
+ /((?:api|access|refresh|bearer|jwt|node|iam|deploy|client)?[_-]?(?:key|token|secret|password)\s*[=:]\s*)[^\s'"`]+/gi,
24
+ /(Authorization:\s*Bearer\s+)[^\s'"`]+/gi,
25
+ /(-----BEGIN [A-Z ]*PRIVATE KEY-----)[\s\S]*?(-----END [A-Z ]*PRIVATE KEY-----)/g,
26
+ ];
27
+ export function redactSensitiveText(value) {
28
+ let redacted = value;
29
+ for (const pattern of SENSITIVE_LINE_PATTERNS) {
30
+ redacted = redacted.replace(pattern, (_match, prefix, suffix) => {
31
+ if (suffix)
32
+ return `${prefix}\n[REDACTED]\n${suffix}`;
33
+ return `${prefix}[REDACTED]`;
34
+ });
35
+ }
36
+ return redacted;
37
+ }
38
+ export function escapeForSingleQuotes(value) {
39
+ return value.replace(/'/g, "'\\''");
40
+ }
41
+ export function resolveDeploySshCredentials() {
42
+ const username = process.env['SSH_USERNAME'];
43
+ const password = process.env['SSH_PASSWORD'] ?? '';
44
+ const privateKeyPath = process.env['SSH_PRIVATE_KEY_PATH'];
45
+ if (!username) {
46
+ throw new Error('SSH_USERNAME must be set in the server environment');
47
+ }
48
+ let privateKey;
49
+ if (privateKeyPath) {
50
+ const resolvedPath = resolve(privateKeyPath);
51
+ if (!existsSync(resolvedPath)) {
52
+ throw new Error(`SSH_PRIVATE_KEY_PATH does not exist: ${resolvedPath}`);
53
+ }
54
+ privateKey = readFileSync(resolvedPath, 'utf-8');
55
+ }
56
+ if (!password && !privateKey) {
57
+ throw new Error('SSH_PRIVATE_KEY_PATH or SSH_PASSWORD must be set in the server environment');
58
+ }
59
+ return { username, password, privateKey };
60
+ }
61
+ export function execRemote(conn, cmd, opts = {}) {
62
+ const maxOutputBytes = opts.maxOutputBytes ?? 256_000;
63
+ return new Promise((resolvePromise, reject) => {
64
+ conn.exec(cmd, (err, stream) => {
65
+ if (err) {
66
+ reject(err);
67
+ return;
68
+ }
69
+ let stdout = '';
70
+ const append = (chunk) => {
71
+ if (stdout.length < maxOutputBytes) {
72
+ stdout += chunk.toString();
73
+ if (stdout.length > maxOutputBytes) {
74
+ stdout = `${stdout.slice(0, maxOutputBytes)}\n[output truncated]`;
75
+ }
76
+ }
77
+ };
78
+ stream.on('data', append);
79
+ stream.stderr.on('data', append);
80
+ stream.on('close', (code) => {
81
+ // SSH exit-status is an unsigned 32-bit integer. A signal-only close or
82
+ // disconnected channel may supply null/undefined; neither proves success
83
+ // or even that the remote process stopped. Keep private output off errors.
84
+ let exitCode;
85
+ try {
86
+ exitCode = requireSshExitStatus(code);
87
+ }
88
+ catch (error) {
89
+ reject(error);
90
+ return;
91
+ }
92
+ resolvePromise({
93
+ code: exitCode,
94
+ stdout,
95
+ redactedStdout: redactSensitiveText(stdout),
96
+ });
97
+ });
98
+ stream.on('error', reject);
99
+ });
100
+ });
101
+ }
102
+ export async function runRemoteCommand(target, command, opts = {}) {
103
+ return withApiSshOwnership(target.hostname, 'ssh-command', () => new Promise((resolvePromise, reject) => {
104
+ const conn = new Client();
105
+ let settled = false;
106
+ const finish = (fn) => {
107
+ if (settled)
108
+ return;
109
+ settled = true;
110
+ fn();
111
+ };
112
+ conn.on('error', (err) => {
113
+ finish(() => reject(err));
114
+ });
115
+ conn.on('ready', () => {
116
+ void (async () => {
117
+ try {
118
+ const result = await execRemote(conn, command, { maxOutputBytes: opts.maxOutputBytes });
119
+ finish(() => resolvePromise(result));
120
+ }
121
+ catch (err) {
122
+ finish(() => reject(err));
123
+ }
124
+ finally {
125
+ conn.end();
126
+ }
127
+ })();
128
+ });
129
+ conn.connect({
130
+ ...sshHostVerification(target.hostname, target.port ?? 22),
131
+ host: target.hostname,
132
+ port: target.port ?? 22,
133
+ username: target.username,
134
+ ...(target.privateKey ? { privateKey: target.privateKey } : { password: target.password ?? '' }),
135
+ readyTimeout: opts.readyTimeoutMs ?? 30_000,
136
+ });
137
+ }));
138
+ }
139
+ //# sourceMappingURL=ssh-exec-service.js.map
@@ -0,0 +1,27 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFileSync } from 'node:fs';
3
+ import { z } from 'zod';
4
+ const pinsSchema = z.record(z.string(), z.array(z.string().regex(/^SHA256:[A-Za-z0-9+/]{43}$/)).min(1));
5
+ /** A trusted pin must exist before SSH can send any authentication credential. */
6
+ export function sshHostVerification(hostname, port = 22) {
7
+ const file = process.env['SSH_HOST_KEY_PINS_FILE'];
8
+ if (!file)
9
+ throw new Error('SSH_HOST_KEY_PINS_FILE is required for SSH operations');
10
+ let pins;
11
+ try {
12
+ pins = pinsSchema.parse(JSON.parse(readFileSync(file, 'utf8')));
13
+ }
14
+ catch {
15
+ throw new Error('SSH host-key pin configuration is unreadable or invalid');
16
+ }
17
+ const accepted = pins[`${hostname.toLowerCase()}:${port}`];
18
+ if (!accepted?.length)
19
+ throw new Error('No trusted SSH host-key pin for this destination');
20
+ return {
21
+ hostVerifier: (key) => {
22
+ const fingerprint = `SHA256:${createHash('sha256').update(key).digest('base64').replace(/=+$/, '')}`;
23
+ return accepted.includes(fingerprint);
24
+ },
25
+ };
26
+ }
27
+ //# sourceMappingURL=ssh-host-verification.js.map
@@ -0,0 +1,75 @@
1
+ export const TAILSCALE_RENEWAL_WARNING_DAYS = 30;
2
+ const DAY_MS = 24 * 60 * 60 * 1000;
3
+ function parseDetails(value) {
4
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
5
+ return value;
6
+ }
7
+ if (typeof value !== 'string')
8
+ return null;
9
+ try {
10
+ const parsed = JSON.parse(value);
11
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
12
+ ? parsed
13
+ : null;
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ }
19
+ export function parseTailscaleNodeKeyPosture(serializedServices, nowMs = Date.now(), warningDays = TAILSCALE_RENEWAL_WARNING_DAYS) {
20
+ let services;
21
+ try {
22
+ const parsed = JSON.parse(serializedServices);
23
+ if (!Array.isArray(parsed))
24
+ return null;
25
+ services = parsed;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ const service = services.find((item) => item?.name === 'tailscale-node-key');
31
+ const details = parseDetails(service?.details);
32
+ if (!service || typeof service.status !== 'string' || !details)
33
+ return null;
34
+ const backendState = details['backendState'];
35
+ const expiryEnabled = details['expiryEnabled'];
36
+ const rawExpiry = details['keyExpiry'];
37
+ if (typeof backendState !== 'string' || typeof expiryEnabled !== 'boolean')
38
+ return null;
39
+ if (!(rawExpiry === null || typeof rawExpiry === 'string'))
40
+ return null;
41
+ const dnsName = typeof details['dnsName'] === 'string' ? details['dnsName'] : null;
42
+ const online = typeof details['online'] === 'boolean' ? details['online'] : null;
43
+ const expiryMs = typeof rawExpiry === 'string' ? Date.parse(rawExpiry) : Number.NaN;
44
+ const hasValidExpiry = Number.isFinite(expiryMs);
45
+ if (expiryEnabled && !hasValidExpiry)
46
+ return null;
47
+ const daysRemaining = expiryEnabled && hasValidExpiry
48
+ ? Math.ceil((expiryMs - nowMs) / DAY_MS)
49
+ : null;
50
+ const expired = expiryEnabled && hasValidExpiry && expiryMs <= nowMs;
51
+ const requiresRenewal = expired || (expiryEnabled && daysRemaining !== null && daysRemaining <= warningDays);
52
+ return {
53
+ status: service.status,
54
+ backendState,
55
+ dnsName,
56
+ online,
57
+ keyExpiry: hasValidExpiry ? rawExpiry : null,
58
+ daysRemaining,
59
+ expiryEnabled,
60
+ expired,
61
+ requiresRenewal,
62
+ };
63
+ }
64
+ export function summarizeTailscaleNodeKeys(nodes, warningDays = TAILSCALE_RENEWAL_WARNING_DAYS) {
65
+ return {
66
+ total: nodes.length,
67
+ reporting: nodes.filter((node) => node.tailscale !== null).length,
68
+ expiryEnabled: nodes.filter((node) => node.tailscale?.expiryEnabled).length,
69
+ expiringSoon: nodes.filter((node) => node.tailscale?.requiresRenewal && !node.tailscale.expired).length,
70
+ expired: nodes.filter((node) => node.tailscale?.expired).length,
71
+ warningDays,
72
+ nodes,
73
+ };
74
+ }
75
+ //# sourceMappingURL=tailscale-node-key-service.js.map
@@ -0,0 +1,118 @@
1
+ import { z } from 'zod';
2
+ import { getDb } from '../db/index.js';
3
+ import { HttpError } from '../middleware/error-handler.js';
4
+ import { approvalDigest, requireFreshApproval, verifyApprovalAssertion } from '../security/approval-assertion.js';
5
+ import { forwardSecurityFusionApproval, getSecurityFusionApprovalPlan, listSecurityFusionApprovalPlans } from './security-fusion-approvals.js';
6
+ const planSchema = z.object({
7
+ runbookVersion: z.enum(['diagnostics.v1', 'restart-service.v1']), nodeId: z.string().min(1).max(160),
8
+ applicationId: z.string().min(1).max(160).optional(), command: z.string().min(1).max(500),
9
+ args: z.array(z.string().max(500)).max(16), expiresAt: z.string().datetime(), timeoutSeconds: z.number().int().min(1).max(3600),
10
+ catalogRunbookId: z.string().min(1).max(160).optional(), catalogRunbookVersion: z.string().min(1).max(160).optional(),
11
+ catalogPlanSha256: z.string().regex(/^[a-f0-9]{64}$/).optional(),
12
+ }).strict().superRefine((value, ctx) => {
13
+ const catalogFields = [value.catalogRunbookId, value.catalogRunbookVersion, value.catalogPlanSha256];
14
+ if (catalogFields.some(Boolean) && !catalogFields.every(Boolean))
15
+ ctx.addIssue({ code: 'custom', message: 'Catalog plan identity must be complete', path: ['catalogPlanSha256'] });
16
+ });
17
+ function remediationId(planId) {
18
+ const match = /^remediation:([a-zA-Z0-9][a-zA-Z0-9_-]{0,127})$/.exec(planId);
19
+ if (!match)
20
+ throw new HttpError(400, 'PLAN_TYPE_UNAVAILABLE', 'Select an existing versioned Magnus remediation plan; containment belongs to Security Fusion');
21
+ return match[1];
22
+ }
23
+ export function describeRemediationPlan(row) {
24
+ let raw;
25
+ try {
26
+ raw = JSON.parse(row.input);
27
+ }
28
+ catch {
29
+ throw new HttpError(409, 'INVALID_PLAN', 'Stored remediation plan is invalid');
30
+ }
31
+ const parsed = planSchema.safeParse(raw);
32
+ if (!parsed.success || parsed.data.nodeId !== row.nodeId)
33
+ throw new HttpError(409, 'INVALID_PLAN', 'Stored remediation plan is invalid');
34
+ const p = parsed.data;
35
+ const view = {
36
+ plan_id: `remediation:${row.id}`, revision: 1, action: 'remediation.runbook', targets: [p.nodeId],
37
+ steps: [`Runbook: ${p.runbookVersion}`, `Command: ${JSON.stringify([p.command, ...p.args])}`,
38
+ `Timeout: ${p.timeoutSeconds} seconds`, ...(p.applicationId ? [`Application: ${p.applicationId}`] : []),
39
+ ...(p.catalogPlanSha256 ? [`Catalog: ${p.catalogRunbookId}@${p.catalogRunbookVersion}`, `Catalog plan: ${p.catalogPlanSha256}`] : [])],
40
+ risk: p.runbookVersion === 'diagnostics.v1' ? 'Read-only host uptime diagnostic.' : 'Service restart interrupts availability and can lose in-memory work.',
41
+ rollback: p.runbookVersion === 'diagnostics.v1' ? 'No target state change; retain the result.' : 'A restart cannot be undone. Verify service/application health; any further recovery needs a new approved plan.',
42
+ evidence: [`anomaly:${row.anomalyEventId}`], expires_at: Date.parse(p.expiresAt),
43
+ };
44
+ return { ...view, plan_sha256: approvalDigest(view) };
45
+ }
46
+ /** Bounded discovery exposes only validated, unexpired backend-owned pending plans. */
47
+ export async function listTeamApprovalPlans() {
48
+ const rows = await getDb().remediationExecution.findMany({
49
+ where: { status: 'pending_approval', triggeredAt: { gte: new Date(Date.now() - 24 * 3600_000) } },
50
+ orderBy: { triggeredAt: 'desc' }, take: 100,
51
+ });
52
+ const plans = [];
53
+ for (const row of rows) {
54
+ try {
55
+ const plan = describeRemediationPlan(row);
56
+ if (plan.expires_at > Date.now())
57
+ plans.push(plan);
58
+ }
59
+ catch (error) {
60
+ if (!(error instanceof HttpError))
61
+ throw error;
62
+ }
63
+ if (plans.length === 20)
64
+ break;
65
+ }
66
+ const containment = await listSecurityFusionApprovalPlans();
67
+ return { plans: [...plans, ...containment].sort((a, b) => Number(a.expires_at) - Number(b.expires_at)).slice(0, 20) };
68
+ }
69
+ export async function getTeamApprovalPlan(planId) {
70
+ if (planId.startsWith('containment:'))
71
+ return getSecurityFusionApprovalPlan(planId);
72
+ const row = await getDb().remediationExecution.findUnique({ where: { id: remediationId(planId) } });
73
+ if (!row)
74
+ throw new HttpError(404, 'NOT_FOUND', 'Remediation plan not found');
75
+ if (row.status !== 'pending_approval')
76
+ throw new HttpError(409, 'PLAN_ALREADY_HANDLED', 'Plan is no longer awaiting permission');
77
+ const plan = describeRemediationPlan(row);
78
+ if (plan.expires_at <= Date.now())
79
+ throw new HttpError(409, 'PLAN_EXPIRED', 'Create a fresh remediation proposal');
80
+ return plan;
81
+ }
82
+ export async function recordTeamApproval(raw) {
83
+ const { payload: p, assertionSha256 } = verifyApprovalAssertion(raw);
84
+ if (p.proposal_id.startsWith('containment:'))
85
+ return forwardSecurityFusionApproval(raw);
86
+ const id = remediationId(p.proposal_id);
87
+ return getDb().$transaction(async (tx) => {
88
+ // Locks are scoped to immutable proposal identity and survive API replicas.
89
+ await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`team-approval:${p.proposal_id}`}, 0))`;
90
+ const prior = await tx.teamApprovalDecision.findUnique({ where: { proposalId: p.proposal_id } });
91
+ if (prior) {
92
+ if (prior.decisionId !== p.decision_id || prior.assertionSha256 !== assertionSha256)
93
+ throw new HttpError(409, 'APPROVAL_CONFLICT', 'Proposal already has a different decision');
94
+ return { decision_id: prior.decisionId, proposal_id: prior.proposalId, decision: prior.decision, execution_id: prior.executionId, accepted: true, replay: true };
95
+ }
96
+ requireFreshApproval(p);
97
+ const row = await tx.remediationExecution.findUnique({ where: { id } });
98
+ if (!row || row.status !== 'pending_approval')
99
+ throw new HttpError(409, 'PLAN_ALREADY_HANDLED', 'Plan is not awaiting permission');
100
+ const plan = describeRemediationPlan(row);
101
+ if (plan.revision !== p.proposal_revision || plan.plan_sha256 !== p.plan_sha256 || p.expires_at > plan.expires_at)
102
+ throw new HttpError(409, 'APPROVAL_PLAN_CHANGED', 'Telegram permission does not match the current immutable plan');
103
+ const actor = `telegram:${p.actor_binding_sha256}`;
104
+ const changed = await tx.remediationExecution.updateMany({
105
+ where: { id, status: 'pending_approval', input: row.input },
106
+ data: p.decision === 'approve' ? { status: 'approving', output: JSON.stringify({ approvedBy: actor,
107
+ telegramDecisionId: p.decision_id, telegramPlanSha256: p.plan_sha256, telegramExpiresAt: p.expires_at,
108
+ telegramExecutionId: p.execution_id }) } : { status: 'skipped', completedAt: new Date(), output: 'Operator rejected the exact proposal through Telegram' },
109
+ });
110
+ if (changed.count !== 1)
111
+ throw new HttpError(409, 'APPROVAL_PLAN_CHANGED', 'Plan changed before permission could be recorded');
112
+ await tx.teamApprovalDecision.create({ data: { decisionId: p.decision_id, proposalId: p.proposal_id,
113
+ proposalRevision: p.proposal_revision, planSha256: p.plan_sha256, decision: p.decision, assertionSha256,
114
+ actorBindingSha256: p.actor_binding_sha256, executionId: p.execution_id, decidedAt: new Date(p.decided_at), expiresAt: new Date(p.expires_at) } });
115
+ return { decision_id: p.decision_id, proposal_id: p.proposal_id, decision: p.decision, execution_id: p.execution_id, accepted: true, replay: false };
116
+ });
117
+ }
118
+ //# sourceMappingURL=team-approvals.js.map
@@ -0,0 +1,138 @@
1
+ /**
2
+ * topology-service.ts — Fleet network topology autodiscovery.
3
+ *
4
+ * Builds a topology map from registered nodes:
5
+ * - Subnet grouping (by /24 CIDR)
6
+ * - Node reachability (TCP probe on agent port)
7
+ * - Latency measurement between control-api and each node
8
+ * - Gateway detection (subnet with most cross-subnet connections)
9
+ */
10
+ import { getDb } from '../db/index.js';
11
+ import { connect } from 'node:net';
12
+ // ── Helpers ──
13
+ function getSubnet(ip) {
14
+ const parts = ip.split('.');
15
+ if (parts.length !== 4)
16
+ return 'unknown/24';
17
+ return `${parts[0]}.${parts[1]}.${parts[2]}.0/24`;
18
+ }
19
+ /**
20
+ * TCP probe — attempt to connect to host:port with timeout.
21
+ * Returns latency in ms on success, null on failure.
22
+ */
23
+ function probeNode(host, port, timeoutMs = 3000) {
24
+ return new Promise((resolve) => {
25
+ const start = performance.now();
26
+ let resolved = false;
27
+ const socket = connect({ host, port, timeout: timeoutMs }, () => {
28
+ if (!resolved) {
29
+ resolved = true;
30
+ const latency = Math.round(performance.now() - start);
31
+ socket.destroy();
32
+ resolve(latency);
33
+ }
34
+ });
35
+ socket.on('error', () => {
36
+ if (!resolved) {
37
+ resolved = true;
38
+ resolve(null);
39
+ }
40
+ });
41
+ socket.on('timeout', () => {
42
+ if (!resolved) {
43
+ resolved = true;
44
+ socket.destroy();
45
+ resolve(null);
46
+ }
47
+ });
48
+ setTimeout(() => {
49
+ if (!resolved) {
50
+ resolved = true;
51
+ socket.destroy();
52
+ resolve(null);
53
+ }
54
+ }, timeoutMs);
55
+ });
56
+ }
57
+ // ── Core ──
58
+ /**
59
+ * Scan all registered nodes and build a topology map.
60
+ */
61
+ export async function scanTopology(probeTimeoutMs = 3000) {
62
+ const db = getDb();
63
+ const nodes = await db.node.findMany({
64
+ where: { status: { not: 'DECOMMISSIONED' } },
65
+ select: { id: true, name: true, hostname: true, ipAddress: true, port: true, status: true, lastHeartbeatAt: true },
66
+ });
67
+ // Probe all nodes concurrently
68
+ const probeResults = await Promise.allSettled(nodes.map(async (n) => {
69
+ const latency = await probeNode(n.ipAddress, n.port, probeTimeoutMs);
70
+ return { nodeId: n.id, reachable: latency !== null, latencyMs: latency };
71
+ }));
72
+ const probeMap = new Map();
73
+ for (const r of probeResults) {
74
+ if (r.status === 'fulfilled') {
75
+ probeMap.set(r.value.nodeId, { reachable: r.value.reachable, latencyMs: r.value.latencyMs });
76
+ }
77
+ }
78
+ // Build topology nodes
79
+ const topoNodes = nodes.map((n) => {
80
+ const probe = probeMap.get(n.id) ?? { reachable: false, latencyMs: null };
81
+ return {
82
+ id: n.id,
83
+ name: n.name,
84
+ hostname: n.hostname,
85
+ ip: n.ipAddress,
86
+ port: n.port,
87
+ status: n.status,
88
+ subnet: getSubnet(n.ipAddress),
89
+ reachable: probe.reachable,
90
+ latencyMs: probe.latencyMs,
91
+ lastHeartbeatAt: n.lastHeartbeatAt?.toISOString() ?? null,
92
+ };
93
+ });
94
+ // Group by subnet
95
+ const subnetMap = new Map();
96
+ for (const n of topoNodes) {
97
+ const list = subnetMap.get(n.subnet) ?? [];
98
+ list.push(n.id);
99
+ subnetMap.set(n.subnet, list);
100
+ }
101
+ // Detect gateway subnet (largest or most varied connectivity)
102
+ const maxSubnetSize = Math.max(...[...subnetMap.values()].map((v) => v.length), 0);
103
+ const subnets = [...subnetMap.entries()].map(([cidr, nodeIds]) => ({
104
+ cidr,
105
+ nodeCount: nodeIds.length,
106
+ nodes: nodeIds,
107
+ isGateway: nodeIds.length === maxSubnetSize && subnetMap.size > 1,
108
+ }));
109
+ // Build edges: nodes within same subnet are same-subnet edges, across subnets are cross-subnet
110
+ const edges = [];
111
+ const nodeSubnet = new Map(topoNodes.map((n) => [n.id, n.subnet]));
112
+ for (let i = 0; i < topoNodes.length; i++) {
113
+ for (let j = i + 1; j < topoNodes.length; j++) {
114
+ const a = topoNodes[i];
115
+ const b = topoNodes[j];
116
+ const sameSubnet = nodeSubnet.get(a.id) === nodeSubnet.get(b.id);
117
+ // Only create edges within same subnet automatically;
118
+ // cross-subnet edges only between reachable nodes
119
+ if (sameSubnet || (a.reachable && b.reachable)) {
120
+ edges.push({
121
+ from: a.id,
122
+ to: b.id,
123
+ latencyMs: a.latencyMs !== null && b.latencyMs !== null
124
+ ? Math.round((a.latencyMs + b.latencyMs) / 2)
125
+ : null,
126
+ type: sameSubnet ? 'same-subnet' : 'cross-subnet',
127
+ });
128
+ }
129
+ }
130
+ }
131
+ return {
132
+ nodes: topoNodes,
133
+ subnets,
134
+ edges,
135
+ scannedAt: new Date().toISOString(),
136
+ };
137
+ }
138
+ //# sourceMappingURL=topology-service.js.map
@@ -0,0 +1,151 @@
1
+ /** Read-only lease inventory. It is an observation, never a drain receipt.
2
+ * Shared leases are counted globally because their mission issuer is not an
3
+ * exclusive host owner. No zero count here closes admission or covers writers
4
+ * outside the Control API.
5
+ */
6
+ import { getDb } from '../db/index.js';
7
+ import { HttpError } from '../middleware/error-handler.js';
8
+ import { lockSharedAdmission } from './admission-fence.js';
9
+ import { lockResourceNode } from './resource-node-lock.js';
10
+ import { fetchHostLocalWriterProof, verifyHostLocalWriterProof } from './host-local-writer.js';
11
+ import { fetchOsSchedulerWriterProof, verifyOsSchedulerWriterProof } from './os-scheduler-writer.js';
12
+ const HELD = ['reserved', 'running', 'unknown'];
13
+ // These are separate owning-service boundaries. A Control API node fence or a
14
+ // zero lease count cannot attest to any of them. Keep the names stable so a
15
+ // future receipt verifier must cover the complete set before C11 can pass.
16
+ export const INDEPENDENT_WRITER_CLASSES = [
17
+ 'host-local-mutations', 'os-schedulers', 'native-hermes',
18
+ 'external-webhook-writers', 'peer-and-channel-writers',
19
+ ];
20
+ const SHA256 = /^[a-f0-9]{64}$/;
21
+ function empty(counts) {
22
+ return Number.isSafeInteger(counts.active) && counts.active === 0
23
+ && Number.isSafeInteger(counts.unknown) && counts.unknown === 0;
24
+ }
25
+ /** Shape and identity checks are necessary, but are not an owner signature.
26
+ * Callers may set ownerVerified only after an owning-service verifier has
27
+ * authenticated every receipt. The current snapshot has no such verifier. */
28
+ export function evaluateIndependentWriterCoverage(coverage, ownerVerified) {
29
+ const sources = coverage.sourceClasses;
30
+ const required = [...INDEPENDENT_WRITER_CLASSES];
31
+ const missing = required.filter(source => {
32
+ const entry = sources[source];
33
+ return entry?.status !== 'verified' || !entry.ownerReceiptSha256 || !SHA256.test(entry.ownerReceiptSha256)
34
+ || !entry.admissionFenceSha256 || !SHA256.test(entry.admissionFenceSha256);
35
+ });
36
+ const receipts = required.map(source => sources[source]?.ownerReceiptSha256);
37
+ const fences = required.map(source => sources[source]?.admissionFenceSha256);
38
+ const exactClasses = Object.keys(sources).length === required.length
39
+ && Object.keys(sources).every(source => required.includes(source));
40
+ const unique = new Set(receipts).size === required.length && new Set(fences).size === required.length;
41
+ const inventoriesBound = Boolean(coverage.mutationInventorySha256 && SHA256.test(coverage.mutationInventorySha256)
42
+ && coverage.scheduleInventorySha256 && SHA256.test(coverage.scheduleInventorySha256));
43
+ return { complete: ownerVerified && exactClasses && unique && inventoriesBound && missing.length === 0,
44
+ missingClasses: missing, ownerVerified, inventoriesBound };
45
+ }
46
+ /** A read-only decision summary. Every prerequisite is independently visible;
47
+ * this cannot close admission, settle unknown effects, or transfer a route. */
48
+ export function evaluateTransferAuthority(input) {
49
+ const gates = {
50
+ exactArdentScope: input.enrollment.state === 'enrolled' && input.enrollment.ardentScopeActive
51
+ && Boolean(input.enrollment.barrierId) && input.scope?.epoch === input.enrollment.epoch
52
+ && input.scope?.barrierId === input.enrollment.barrierId,
53
+ nodeAdmissionClosed: input.nodeAdmissionClosed,
54
+ sharedAdmissionClosed: input.sharedAdmissionClosed,
55
+ commandLeasesSettled: empty(input.commandLeases),
56
+ participantWritersSettled: empty(input.participantWriters),
57
+ sharedLeasesSettled: empty(input.globalSharedLeases),
58
+ managedCronSettled: Number.isSafeInteger(input.managedCronUnsettled) && input.managedCronUnsettled === 0,
59
+ independentWritersCovered: input.independentWritersCovered,
60
+ };
61
+ const blockers = Object.entries(gates).filter(([, passed]) => !passed).map(([name]) => name);
62
+ return { complete: blockers.length === 0, gates, blockers };
63
+ }
64
+ export async function transferAuthoritySnapshot(nodeFqdn, nonce) {
65
+ const db = getDb();
66
+ const nodes = await db.node.findMany({ where: { hostname: nodeFqdn },
67
+ select: { id: true, hostname: true, ipAddress: true, port: true }, take: 2 });
68
+ if (nodes.length !== 1)
69
+ throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'Canonical managed node identity required');
70
+ const node = nodes[0];
71
+ // Network evidence is fetched before the database locks. The node's
72
+ // admission drain is one-way; the signed proof is re-bound to the current
73
+ // enrollment identity while holding the locks below.
74
+ const [rawHostLocalProof, rawSchedulerProof] = await Promise.all([
75
+ fetchHostLocalWriterProof(node, nodeFqdn, nonce), fetchOsSchedulerWriterProof(node, nodeFqdn, nonce),
76
+ ]);
77
+ return db.$transaction(async (tx) => {
78
+ // Use the same lock order as fence close/release. A shared reserve/start
79
+ // cannot cross this snapshot between its lease count and fence read.
80
+ await lockSharedAdmission(tx);
81
+ await lockResourceNode(tx, node.id);
82
+ const current = await tx.node.findUnique({ where: { id: node.id }, select: { hostname: true } });
83
+ if (current?.hostname !== nodeFqdn)
84
+ throw new HttpError(409, 'RESOURCE_TARGET_INVALID', 'managed node identity changed');
85
+ const [commands, participants, cron, shared, enrollment, ardentScope, admissionFence, anyAdmissionFence] = await Promise.all([
86
+ tx.commandResourceLease.groupBy({ by: ['state'], where: { nodeId: node.id, state: { in: HELD } }, _count: { _all: true } }),
87
+ tx.resourceParticipantWork.groupBy({ by: ['state'], where: { nodeId: node.id, state: { in: HELD } }, _count: { _all: true } }),
88
+ tx.managedCronOccurrence.count({ where: { nodeId: node.id, state: { not: 'completed' } } }),
89
+ tx.sharedResourceLease.groupBy({ by: ['state'], where: { state: { in: HELD } }, _count: { _all: true } }),
90
+ tx.resourceEnrollment.findUnique({ where: { nodeId: node.id }, select: { state: true, epoch: true, barrierId: true } }),
91
+ tx.ardentScopeEnrollment.findUnique({ where: { nodeId: node.id }, select: { epoch: true, barrierId: true } }),
92
+ tx.admissionFence.findUnique({ where: { nodeId: node.id }, select: { releaseId: true, closedAt: true } }),
93
+ tx.admissionFence.findFirst({ select: { nodeId: true } }),
94
+ ]);
95
+ const counts = (rows) => ({
96
+ active: rows.filter(row => row.state !== 'unknown').reduce((sum, row) => sum + row._count._all, 0),
97
+ unknown: rows.filter(row => row.state === 'unknown').reduce((sum, row) => sum + row._count._all, 0),
98
+ });
99
+ const observedAt = Date.now();
100
+ const sourceClasses = Object.fromEntries(INDEPENDENT_WRITER_CLASSES.map(source => [source, { status: 'unverified', ownerReceiptSha256: null, admissionFenceSha256: null }]));
101
+ const hostLocal = verifyHostLocalWriterProof(rawHostLocalProof, { nodeId: node.id, nodeFqdn,
102
+ nonce, epoch: enrollment?.epoch ?? 0, barrierId: enrollment?.barrierId ?? null }, observedAt);
103
+ if (hostLocal && enrollment?.state === 'enrolled')
104
+ sourceClasses['host-local-mutations'] = {
105
+ status: 'verified', ownerReceiptSha256: hostLocal.ownerReceiptSha256,
106
+ admissionFenceSha256: hostLocal.admissionFenceSha256,
107
+ };
108
+ const osScheduler = verifyOsSchedulerWriterProof(rawSchedulerProof, { nodeId: node.id, nodeFqdn,
109
+ nonce, epoch: enrollment?.epoch ?? 0, barrierId: enrollment?.barrierId ?? null }, observedAt);
110
+ if (osScheduler && enrollment?.state === 'enrolled')
111
+ sourceClasses['os-schedulers'] = {
112
+ status: 'verified', ownerReceiptSha256: osScheduler.ownerReceiptSha256,
113
+ admissionFenceSha256: osScheduler.admissionFenceSha256,
114
+ };
115
+ const coverageDecision = evaluateIndependentWriterCoverage({ sourceClasses,
116
+ mutationInventorySha256: hostLocal?.mutationInventorySha256 ?? null,
117
+ scheduleInventorySha256: osScheduler?.scheduleInventorySha256 ?? null }, false);
118
+ const independentWriterCoverage = {
119
+ version: 1, nodeId: node.id, nodeFqdn,
120
+ epoch: enrollment?.epoch ?? 0, barrierId: enrollment?.barrierId ?? null,
121
+ releaseId: admissionFence?.releaseId ?? null, observedAt,
122
+ mutationInventorySha256: hostLocal?.mutationInventorySha256 ?? null,
123
+ scheduleInventorySha256: osScheduler?.scheduleInventorySha256 ?? null,
124
+ sourceClasses, ...coverageDecision,
125
+ };
126
+ const commandLeases = counts(commands);
127
+ const participantWriters = counts(participants);
128
+ const globalSharedLeases = counts(shared);
129
+ const transferAuthority = evaluateTransferAuthority({
130
+ enrollment: { state: enrollment?.state ?? 'legacy', epoch: enrollment?.epoch ?? 0,
131
+ barrierId: enrollment?.barrierId ?? null, ardentScopeActive: Boolean(ardentScope) },
132
+ scope: ardentScope, nodeAdmissionClosed: Boolean(admissionFence),
133
+ sharedAdmissionClosed: Boolean(anyAdmissionFence), commandLeases,
134
+ participantWriters, globalSharedLeases, managedCronUnsettled: cron,
135
+ independentWritersCovered: coverageDecision.complete,
136
+ });
137
+ return { version: 1, nodeId: node.id, nodeFqdn, nonce, observedAt,
138
+ commandLeases, participantWriters,
139
+ managedCronUnsettled: cron, globalSharedLeases,
140
+ enrollment: { state: enrollment?.state ?? 'legacy', epoch: enrollment?.epoch ?? 0,
141
+ barrierId: enrollment?.barrierId ?? null, ardentScopeActive: Boolean(ardentScope) },
142
+ controlApiNodeAdmission: { closed: Boolean(admissionFence), releaseId: admissionFence?.releaseId ?? null,
143
+ closedAt: admissionFence?.closedAt ?? null },
144
+ controlApiSharedAdmission: { closed: Boolean(anyAdmissionFence), coverage: 'control-api-shared-leases' },
145
+ admissionFenced: Boolean(admissionFence) && Boolean(anyAdmissionFence) && coverageDecision.complete,
146
+ independentWriterCoverage,
147
+ independentWritersCovered: independentWriterCoverage.complete,
148
+ transferAuthority, transferAuthorityComplete: transferAuthority.complete };
149
+ });
150
+ }
151
+ //# sourceMappingURL=transfer-authority-snapshot.js.map