@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,50 @@
1
+ import { getDb } from '../db/index.js';
2
+ import { securityFusionActionSources } from './security-fusion-actions.js';
3
+ /** Derive a durable verification deadline from the signed command's persisted
4
+ * completion, not a process-local timer or a heartbeat preceding the action. */
5
+ export async function reconcileRestartVerification(now = new Date()) {
6
+ const db = getDb();
7
+ const actions = await db.securityFusionAction.findMany({
8
+ where: { kind: 'service_restart', status: { in: ['dispatch_pending', 'executing', 'verifying'] } },
9
+ orderBy: { createdAt: 'asc' }, take: 100,
10
+ });
11
+ for (const action of actions) {
12
+ const outbox = await db.securityFusionCommandOutbox.findFirst({ where: { actionId: action.id, operation: 'execute' } });
13
+ if (!outbox?.remoteCommandId)
14
+ continue;
15
+ const command = await db.remoteCommand.findUnique({ where: { id: outbox.remoteCommandId }, include: { result: true } });
16
+ if (!command || !['COMPLETED', 'FAILED', 'TIMEOUT', 'CANCELLED'].includes(command.status))
17
+ continue;
18
+ const completedAt = command.completedAt;
19
+ const successful = command.status === 'COMPLETED' && command.result?.exitCode === 0;
20
+ const snapshot = successful && completedAt ? await db.healthSnapshot.findFirst({
21
+ where: { nodeId: action.nodeId, timestamp: { gt: completedAt, gte: new Date(now.getTime() - 120_000), lte: now } },
22
+ orderBy: { timestamp: 'desc' },
23
+ }) : null;
24
+ let active = false;
25
+ try {
26
+ const services = snapshot ? JSON.parse(snapshot.services) : [];
27
+ const name = action.serviceName?.replace(/\.service$/, '');
28
+ active = Boolean(name && Array.isArray(services) && services.some(s => s && typeof s.name === 'string' && s.name.replace(/\.service$/, '') === name && s.status === 'active'));
29
+ }
30
+ catch { /* malformed evidence never proves recovery */ }
31
+ const expired = !completedAt || now.getTime() - completedAt.getTime() >= 180_000;
32
+ const status = !successful || expired ? 'failed' : active ? 'succeeded' : 'verifying';
33
+ await db.$transaction(async (tx) => {
34
+ const changed = await tx.securityFusionAction.updateMany({
35
+ where: { id: action.id, status: { in: securityFusionActionSources(status) } },
36
+ data: { status, resultJson: JSON.stringify({ commandId: command.id,
37
+ commandCompletedAt: completedAt?.toISOString(), verificationDeadline: completedAt ? new Date(completedAt.getTime() + 180_000).toISOString() : null,
38
+ serviceName: action.serviceName, serviceVerified: active, snapshotId: snapshot?.id ?? null,
39
+ reason: !successful ? 'Command failed or outcome unknown' : expired ? 'Service recovery verification expired' : active ? 'Fresh service evidence passed' : 'Awaiting fresh service evidence',
40
+ }) },
41
+ });
42
+ if (changed.count && status !== 'verifying')
43
+ await tx.securityFusionCaseActivity.create({ data: {
44
+ caseId: action.caseId, kind: `containment.${status}`, actorId: 'system:command-reconciler',
45
+ detailJson: JSON.stringify({ actionId: action.id, commandId: command.id, serviceVerified: active, snapshotId: snapshot?.id ?? null }),
46
+ } });
47
+ });
48
+ }
49
+ }
50
+ //# sourceMappingURL=security-fusion-verification.js.map
@@ -0,0 +1,200 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import path from 'node:path';
4
+ import { readFile, access, constants } from 'node:fs/promises';
5
+ import { reserveApiLocalWork, withApiLocalWork } from './api-local-work.js';
6
+ const execAsync = promisify(execFile);
7
+ /** Root of the magnus-api repo (two levels up from this file in dist/). */
8
+ const REPO_ROOT = process.env['MAGNUS_REPO_ROOT'] ?? path.resolve(import.meta.dirname, '../../');
9
+ /** Check whether the REPO_ROOT is inside a valid git repository. */
10
+ export async function isGitRepo() {
11
+ try {
12
+ await access(path.join(REPO_ROOT, '.git'), constants.F_OK);
13
+ return true;
14
+ }
15
+ catch {
16
+ // Might be a subdirectory in a repo — ask git
17
+ try {
18
+ await git('rev-parse', '--git-dir');
19
+ return true;
20
+ }
21
+ catch {
22
+ return false;
23
+ }
24
+ }
25
+ }
26
+ let lastChecked = null;
27
+ /** Read the current version directly from the version.ts source file on disk. */
28
+ async function readVersionFromDisk() {
29
+ const versionFile = path.join(REPO_ROOT, 'src/config/version.ts');
30
+ const content = await readFile(versionFile, 'utf-8');
31
+ const agentMatch = content.match(/AGENT_VERSION\s*=\s*'([^']+)'/);
32
+ const controlMatch = content.match(/CONTROL_API_VERSION\s*=\s*'([^']+)'/);
33
+ return {
34
+ agent: agentMatch?.[1] ?? 'unknown',
35
+ controlApi: controlMatch?.[1] ?? 'unknown',
36
+ };
37
+ }
38
+ /** Run a git command in the repo root. */
39
+ async function git(...args) {
40
+ const { stdout } = await execAsync('git', args, {
41
+ cwd: REPO_ROOT,
42
+ timeout: 60_000,
43
+ });
44
+ return stdout.trim();
45
+ }
46
+ /** Run a shell command in the repo root. */
47
+ async function exec(cmd, args, timeoutMs = 300_000) {
48
+ const { stdout, stderr } = await execAsync(cmd, args, {
49
+ cwd: REPO_ROOT,
50
+ timeout: timeoutMs,
51
+ });
52
+ return `${stdout}\n${stderr}`.trim();
53
+ }
54
+ /** Get current git branch name. */
55
+ async function getGitBranch() {
56
+ try {
57
+ return await git('rev-parse', '--abbrev-ref', 'HEAD');
58
+ }
59
+ catch {
60
+ return null;
61
+ }
62
+ }
63
+ /** Get current git commit hash. */
64
+ async function getGitCommit() {
65
+ try {
66
+ return await git('rev-parse', '--short', 'HEAD');
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * Check for updates by running `git fetch` and comparing local vs remote HEAD.
74
+ * Returns true if there are new commits to pull.
75
+ * Gracefully returns "not available" when the deployment is not a git repo
76
+ * (e.g. deployed via tarball or docker) so callers never see a 500.
77
+ */
78
+ export async function checkForUpdates() {
79
+ lastChecked = new Date().toISOString();
80
+ if (!(await isGitRepo())) {
81
+ const versions = await readVersionFromDisk().catch(() => ({ controlApi: 'unknown', agent: 'unknown' }));
82
+ return {
83
+ available: false,
84
+ behindBy: 0,
85
+ currentCommit: versions.controlApi,
86
+ remoteCommit: versions.controlApi,
87
+ error: `No git repository at ${REPO_ROOT}. Use manual deploy or set MAGNUS_REPO_ROOT.`,
88
+ };
89
+ }
90
+ return withApiLocalWork('self-update-check', async () => {
91
+ await git('fetch', 'origin');
92
+ const branch = await getGitBranch() ?? 'main';
93
+ const local = await git('rev-parse', 'HEAD');
94
+ const remote = await git('rev-parse', `origin/${branch}`);
95
+ if (local === remote) {
96
+ return { available: false, behindBy: 0, currentCommit: local.slice(0, 8), remoteCommit: remote.slice(0, 8) };
97
+ }
98
+ // Count how many commits behind
99
+ const countOutput = await git('rev-list', '--count', `HEAD..origin/${branch}`);
100
+ const behindBy = parseInt(countOutput, 10) || 0;
101
+ return { available: behindBy > 0, behindBy, currentCommit: local.slice(0, 8), remoteCommit: remote.slice(0, 8) };
102
+ });
103
+ }
104
+ /**
105
+ * Perform the self-update:
106
+ * 1. `git pull origin <branch>`
107
+ * 2. `npm ci` (in case deps changed)
108
+ * 3. `npm run build` (rebuild control-api)
109
+ * 4. Read the new version from disk
110
+ * 5. Schedule a process restart via systemd (if running as a service)
111
+ */
112
+ export async function performSelfUpdate() {
113
+ const beforeVersions = await readVersionFromDisk().catch(() => ({ controlApi: 'unknown', agent: 'unknown' }));
114
+ if (!(await isGitRepo())) {
115
+ return {
116
+ success: false,
117
+ updated: false,
118
+ previousVersion: beforeVersions.controlApi,
119
+ currentVersion: beforeVersions.controlApi,
120
+ error: `No git repository at ${REPO_ROOT}. Self-update requires a git checkout. Set MAGNUS_REPO_ROOT or redeploy from source.`,
121
+ };
122
+ }
123
+ return withApiLocalWork('self-update-apply', async () => {
124
+ try {
125
+ const branch = await getGitBranch() ?? 'main';
126
+ // 1. Pull latest code
127
+ const gitOutput = await git('pull', 'origin', branch);
128
+ // 2. Install dependencies (in case lockfile changed)
129
+ const installOutput = await exec('npm', ['ci']);
130
+ // 3. Build everything
131
+ const buildOutput = await exec('npm', ['run', 'build']);
132
+ // 4. Read the new version from the freshly-pulled source
133
+ const afterVersions = await readVersionFromDisk();
134
+ const updated = beforeVersions.controlApi !== afterVersions.controlApi ||
135
+ beforeVersions.agent !== afterVersions.agent ||
136
+ !gitOutput.includes('Already up to date');
137
+ lastChecked = new Date().toISOString();
138
+ return {
139
+ success: true,
140
+ updated,
141
+ previousVersion: beforeVersions.controlApi,
142
+ currentVersion: afterVersions.controlApi,
143
+ gitOutput,
144
+ buildOutput: `${installOutput}\n---\n${buildOutput}`.slice(-2000),
145
+ };
146
+ }
147
+ catch (err) {
148
+ const message = err instanceof Error ? err.message : String(err);
149
+ return {
150
+ success: false,
151
+ updated: false,
152
+ previousVersion: beforeVersions.controlApi,
153
+ currentVersion: beforeVersions.controlApi,
154
+ error: message.slice(0, 2000),
155
+ };
156
+ }
157
+ });
158
+ }
159
+ /**
160
+ * Schedule a restart of the control-api systemd service.
161
+ * A process-manager fallback is retained; both paths require fresh durable admission.
162
+ */
163
+ export async function scheduleRestart() {
164
+ const work = await reserveApiLocalWork('self-update-restart');
165
+ let method = 'systemd';
166
+ try {
167
+ await execAsync('systemctl', ['is-active', 'magnus-control-api'], { timeout: 5_000 });
168
+ }
169
+ catch {
170
+ method = 'process.exit';
171
+ }
172
+ // Keep the reservation durable during the response delay. A drain that wins
173
+ // before this callback must deny the restart, including after lost replies.
174
+ setTimeout(() => {
175
+ void work.run(async () => {
176
+ if (method === 'systemd')
177
+ await execAsync('systemctl', ['restart', 'magnus-control-api'], { timeout: 30_000 });
178
+ else
179
+ process.exit(0);
180
+ }).catch(() => { console.warn('[SelfUpdate] Restart did not obtain verified completion; inspect its durable work receipt'); });
181
+ }, 3_000);
182
+ return { scheduled: true, method, workId: work.workId };
183
+ }
184
+ /** Get the current update status for reporting. */
185
+ export async function getUpdateStatus() {
186
+ const { AGENT_VERSION, CONTROL_API_VERSION } = await import('../config/index.js');
187
+ const branch = await getGitBranch();
188
+ const commit = await getGitCommit();
189
+ const gitRepo = await isGitRepo();
190
+ return {
191
+ controlApiVersion: CONTROL_API_VERSION,
192
+ agentVersion: AGENT_VERSION,
193
+ repoRoot: REPO_ROOT,
194
+ gitBranch: branch,
195
+ gitCommit: commit,
196
+ lastChecked,
197
+ isGitRepo: gitRepo,
198
+ };
199
+ }
200
+ //# sourceMappingURL=self-update-service.js.map
@@ -0,0 +1,413 @@
1
+ import { createHash, createHmac, createPublicKey, timingSafeEqual, verify } from 'node:crypto';
2
+ import { nanoid } from 'nanoid';
3
+ import { z } from 'zod';
4
+ import { getDb } from '../db/index.js';
5
+ import { HttpError } from '../middleware/error-handler.js';
6
+ import { assertSharedAdmissionOpen, lockSharedAdmission } from './admission-fence.js';
7
+ import { sharedResourceLeaseIdentitySchema, sharedResourceKeySchema, sharedResourceMissionSigningPayload, sharedResourceReconciliationSchema, sharedResourceReconciliationSigningPayload, sharedResourceReserveSchema, sharedResourceSettlementRecordSha256, sharedResourceTerminalEvidenceSchema, sharedResourceTerminalSigningPayload, } from '../schemas/shared-resource-lease.js';
8
+ const HELD = ['reserved', 'running', 'unknown'];
9
+ const MAX_DEADLINE_SECONDS = 24 * 60 * 60;
10
+ const resultTrustSchema = z.array(z.object({
11
+ issuer: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
12
+ keyId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
13
+ publicKeySpkiB64: z.string().min(40).max(4096),
14
+ resourceScopes: z.array(z.string().min(5).max(260)).min(1).max(64),
15
+ }).strict()).min(1).max(32);
16
+ const missionTrustSchema = z.array(z.object({
17
+ issuer: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
18
+ keyId: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/),
19
+ publicKeySpkiB64: z.string().min(40).max(4096),
20
+ ownerActors: z.array(z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/)).min(1).max(32),
21
+ callerKinds: z.array(z.enum(['direct', 'delegated'])).min(1).max(2),
22
+ }).strict()).min(1).max(32);
23
+ function fail(status, code, message) {
24
+ throw new HttpError(status, code, message);
25
+ }
26
+ function seconds(date) { return Math.floor(date.getTime() / 1000); }
27
+ function loadResultTrust() {
28
+ let raw;
29
+ try {
30
+ raw = JSON.parse(process.env['SHARED_RESOURCE_RESULT_TRUST_JSON'] ?? '');
31
+ }
32
+ catch {
33
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust is unavailable');
34
+ }
35
+ const parsed = resultTrustSchema.safeParse(raw);
36
+ if (!parsed.success)
37
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust is unavailable');
38
+ const seen = new Set();
39
+ return parsed.data.map(entry => {
40
+ const identity = `${entry.issuer}\0${entry.keyId}`;
41
+ if (seen.has(identity))
42
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust contains duplicate identities');
43
+ seen.add(identity);
44
+ let publicKey;
45
+ try {
46
+ publicKey = createPublicKey({ key: Buffer.from(entry.publicKeySpkiB64, 'base64'), format: 'der', type: 'spki' });
47
+ }
48
+ catch {
49
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust contains an invalid key');
50
+ }
51
+ if (publicKey.asymmetricKeyType !== 'ed25519')
52
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust requires Ed25519 keys');
53
+ const resourceScopes = entry.resourceScopes.map(scope => sharedResourceKeySchema.parse(scope));
54
+ if (new Set(resourceScopes).size !== resourceScopes.length)
55
+ fail(503, 'SHARED_RESOURCE_TRUST_UNAVAILABLE', 'Shared resource result trust contains duplicate scopes');
56
+ return { ...entry, resourceScopes, publicKey };
57
+ });
58
+ }
59
+ function scopeCovers(scope, resourceKey) {
60
+ return resourceKey === scope || resourceKey.startsWith(`${scope}:`) || resourceKey.startsWith(`${scope}/`);
61
+ }
62
+ function verifyMissionAssertion(value, ownerActor) {
63
+ let raw;
64
+ try {
65
+ raw = JSON.parse(process.env['SHARED_RESOURCE_MISSION_TRUST_JSON'] ?? '');
66
+ }
67
+ catch {
68
+ fail(503, 'SHARED_RESOURCE_MISSION_TRUST_UNAVAILABLE', 'Hermes mission authority trust is unavailable');
69
+ }
70
+ const parsed = missionTrustSchema.safeParse(raw);
71
+ if (!parsed.success)
72
+ fail(503, 'SHARED_RESOURCE_MISSION_TRUST_UNAVAILABLE', 'Hermes mission authority trust is unavailable');
73
+ const identities = parsed.data.map(entry => `${entry.issuer}\0${entry.keyId}`);
74
+ if (new Set(identities).size !== identities.length
75
+ || parsed.data.some(entry => new Set(entry.ownerActors).size !== entry.ownerActors.length
76
+ || new Set(entry.callerKinds).size !== entry.callerKinds.length))
77
+ fail(503, 'SHARED_RESOURCE_MISSION_TRUST_UNAVAILABLE', 'Hermes mission authority trust contains duplicate bindings');
78
+ const assertion = value.missionAssertion;
79
+ const trust = parsed.data.find(entry => entry.issuer === assertion.issuer && entry.keyId === assertion.keyId);
80
+ if (!trust || !trust.ownerActors.includes(ownerActor) || !trust.callerKinds.includes(value.caller.kind))
81
+ fail(403, 'SHARED_RESOURCE_MISSION_INVALID', 'Hermes mission authority is not trusted for this actor and caller kind');
82
+ const now = Math.floor(Date.now() / 1000);
83
+ if (assertion.issuedAt > now + 30 || assertion.issuedAt < now - 300
84
+ || assertion.expiresAt <= now || assertion.expiresAt > assertion.issuedAt + 300)
85
+ fail(409, 'SHARED_RESOURCE_MISSION_INVALID', 'Hermes mission authority assertion is stale or too broad');
86
+ const { missionAssertion: _missionAssertion, ...reservation } = value;
87
+ const { signature, ...authority } = assertion;
88
+ let publicKey;
89
+ try {
90
+ publicKey = createPublicKey({ key: Buffer.from(trust.publicKeySpkiB64, 'base64'), format: 'der', type: 'spki' });
91
+ }
92
+ catch {
93
+ fail(503, 'SHARED_RESOURCE_MISSION_TRUST_UNAVAILABLE', 'Hermes mission authority trust contains an invalid key');
94
+ }
95
+ if (publicKey.asymmetricKeyType !== 'ed25519')
96
+ fail(503, 'SHARED_RESOURCE_MISSION_TRUST_UNAVAILABLE', 'Hermes mission authority trust requires Ed25519 keys');
97
+ let valid = false;
98
+ try {
99
+ valid = verify(null, sharedResourceMissionSigningPayload({ reservation, ownerActor, authority }), publicKey, Buffer.from(signature, 'base64'));
100
+ }
101
+ catch {
102
+ valid = false;
103
+ }
104
+ if (!valid)
105
+ fail(403, 'SHARED_RESOURCE_MISSION_INVALID', 'Hermes mission authority assertion is invalid');
106
+ }
107
+ function verifyTerminalEvidence(value) {
108
+ const trust = loadResultTrust().find(entry => entry.issuer === value.issuer && entry.keyId === value.keyId);
109
+ if (!trust || value.resources.some(resource => !trust.resourceScopes.some(scope => scopeCovers(scope, resource.resourceKey))))
110
+ fail(403, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Terminal evidence issuer is not trusted for every resource');
111
+ const { signature, ...unsigned } = value;
112
+ let valid = false;
113
+ try {
114
+ valid = verify(null, sharedResourceTerminalSigningPayload(unsigned), trust.publicKey, Buffer.from(signature, 'base64'));
115
+ }
116
+ catch {
117
+ valid = false;
118
+ }
119
+ if (!valid)
120
+ fail(403, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Terminal evidence signature is invalid');
121
+ }
122
+ function reconciliationSecret() {
123
+ const value = process.env['SHARED_RESOURCE_RECONCILIATION_SECRET'] ?? '';
124
+ const conflicting = [
125
+ 'API_SECRET', 'JWT_SECRET', 'NODE_MANAGER_SECRET', 'RESULT_SIGNING_SECRET', 'RESULT_SIGNING_PREVIOUS_SECRET',
126
+ 'COMMAND_RECONCILIATION_SECRET', 'RESOURCE_PARTICIPANT_RECONCILIATION_SECRET', 'SECURITY_APPROVAL_BRIDGE_TOKEN',
127
+ ];
128
+ if (!/^[A-Za-z0-9._~+/=-]{32,4096}$/.test(value)
129
+ || conflicting.some(name => process.env[name] && process.env[name] === value))
130
+ fail(503, 'SHARED_RESOURCE_RECONCILIATION_UNAVAILABLE', 'Dedicated shared resource reconciliation authority is unavailable');
131
+ return value;
132
+ }
133
+ function verifyReconciliation(value) {
134
+ const { signature, ...unsigned } = value;
135
+ const actual = Buffer.from(signature, 'hex');
136
+ const expected = createHmac('sha256', reconciliationSecret())
137
+ .update(sharedResourceReconciliationSigningPayload(unsigned)).digest();
138
+ if (actual.length !== expected.length || !timingSafeEqual(actual, expected))
139
+ fail(403, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Shared resource reconciliation signature is invalid');
140
+ }
141
+ async function lockOperation(tx, operationId) {
142
+ await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`shared-resource-operation:${operationId}`}, 0))`;
143
+ }
144
+ async function lockResources(tx, keys) {
145
+ for (const key of [...keys].sort())
146
+ await tx.$executeRaw `SELECT pg_advisory_xact_lock(hashtextextended(${`shared-resource:${key}`}, 0))`;
147
+ }
148
+ async function loadLease(id) {
149
+ return getDb().sharedResourceLease.findUnique({ where: { id }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
150
+ }
151
+ function leaseIdentity(row) {
152
+ return sharedResourceLeaseIdentitySchema.parse({
153
+ version: 1,
154
+ resources: row.resources.map(resource => ({ resourceKey: resource.resourceKey,
155
+ fenceGeneration: resource.fenceGeneration.toString() })),
156
+ missionRootId: row.missionRootId,
157
+ missionRevision: row.missionRevision,
158
+ taskId: row.taskId,
159
+ operationId: row.operationId,
160
+ requestSha256: row.requestSha256,
161
+ actionClass: row.actionClass,
162
+ deadline: seconds(row.deadline),
163
+ caller: row.callerKind === 'direct'
164
+ ? { kind: 'direct' }
165
+ : { kind: 'delegated', delegationId: row.delegationId },
166
+ ownerActor: row.ownerActor,
167
+ });
168
+ }
169
+ function suppliedIdentity(value) {
170
+ return {
171
+ version: value.version, resources: value.resources, missionRootId: value.missionRootId,
172
+ missionRevision: value.missionRevision, taskId: value.taskId, operationId: value.operationId,
173
+ requestSha256: value.requestSha256, actionClass: value.actionClass, deadline: value.deadline,
174
+ caller: value.caller, ownerActor: value.ownerActor,
175
+ };
176
+ }
177
+ function exactIdentity(row, value) {
178
+ return JSON.stringify(leaseIdentity(row)) === JSON.stringify(suppliedIdentity(value));
179
+ }
180
+ function leaseView(row) {
181
+ return {
182
+ leaseId: row.id,
183
+ state: row.state,
184
+ ...leaseIdentity(row),
185
+ acquiredAt: seconds(row.acquiredAt),
186
+ startedAt: row.startedAt ? seconds(row.startedAt) : null,
187
+ settledAt: row.settledAt ? seconds(row.settledAt) : null,
188
+ settlement: row.settlement,
189
+ evidenceSha256: row.evidenceSha256,
190
+ settlementRecordSha256: row.settlementRecordSha256,
191
+ terminalEvidence: row.terminalEvidenceJson ? JSON.parse(row.terminalEvidenceJson) : null,
192
+ reconciliationDecisionId: row.reconciliationDecisionId,
193
+ reconciliation: row.reconciliationJson ? JSON.parse(row.reconciliationJson) : null,
194
+ };
195
+ }
196
+ function exactReserve(row, value, actorId) {
197
+ return row.ownerActor === actorId
198
+ && row.missionRootId === value.missionRootId && row.missionRevision === value.missionRevision
199
+ && row.taskId === value.taskId && row.operationId === value.operationId
200
+ && row.requestSha256 === value.requestSha256 && row.actionClass === value.actionClass
201
+ && row.missionIssuer === value.missionAssertion.issuer && row.missionKeyId === value.missionAssertion.keyId
202
+ && seconds(row.deadline) === value.deadline && row.callerKind === value.caller.kind
203
+ && row.delegationId === (value.caller.kind === 'delegated' ? value.caller.delegationId : null)
204
+ && JSON.stringify(row.resources.map(resource => resource.resourceKey)) === JSON.stringify(value.resourceKeys);
205
+ }
206
+ function exactStoredMissionAssertion(row, value) {
207
+ const serialized = JSON.stringify(value.missionAssertion);
208
+ return row.missionAssertionJson === serialized
209
+ && row.missionAssertionSha256 === createHash('sha256').update(serialized).digest('hex');
210
+ }
211
+ function assertLeaseOwner(row, actorId) {
212
+ if (row.ownerActor !== actorId)
213
+ fail(403, 'SHARED_RESOURCE_OWNER_REQUIRED', 'Only the exact lease owner may perform this lifecycle action');
214
+ }
215
+ function resourceSetBinding(resources) {
216
+ return resources.map(resource => `${resource.resourceKey}=${resource.fenceGeneration}`).join('\n');
217
+ }
218
+ async function assertCurrentFences(tx, row) {
219
+ const fences = await tx.sharedResourceFence.findMany({ where: { resourceKey: { in: row.resources.map(resource => resource.resourceKey) } } });
220
+ const current = new Map(fences.map(fence => [fence.resourceKey, fence.generation]));
221
+ if (row.resources.some(resource => current.get(resource.resourceKey) !== resource.fenceGeneration))
222
+ fail(409, 'SHARED_RESOURCE_FENCE_STALE', 'A resource fence generation is stale');
223
+ }
224
+ /** Atomically reserve every key or none. Exact operation replay returns the original durable lease. */
225
+ export async function reserveSharedResources(raw, actorId) {
226
+ const value = sharedResourceReserveSchema.parse(raw);
227
+ const db = getDb();
228
+ return db.$transaction(async (tx) => {
229
+ await lockOperation(tx, value.operationId);
230
+ // Exact replay is durable recovery: assertion freshness and the operation deadline
231
+ // gate only the first acquisition, never retrieval of ownership already recorded.
232
+ const prior = await tx.sharedResourceLease.findUnique({ where: { operationId: value.operationId },
233
+ include: { resources: { orderBy: { resourceKey: 'asc' } } } });
234
+ if (prior) {
235
+ if (!exactReserve(prior, value, actorId))
236
+ fail(409, 'SHARED_RESOURCE_OPERATION_CONFLICT', 'Operation identity already binds different ownership');
237
+ // The exact assertion accepted at acquisition remains valid for durable
238
+ // replay. Any replacement assertion must be freshly verified over the
239
+ // unchanged reservation and actor.
240
+ if (!exactStoredMissionAssertion(prior, value))
241
+ verifyMissionAssertion(value, actorId);
242
+ return leaseView(prior);
243
+ }
244
+ await lockSharedAdmission(tx);
245
+ await assertSharedAdmissionOpen(tx);
246
+ verifyMissionAssertion(value, actorId);
247
+ const nowSeconds = Math.floor(Date.now() / 1000);
248
+ if (value.deadline <= nowSeconds || value.deadline > nowSeconds + MAX_DEADLINE_SECONDS)
249
+ fail(409, 'SHARED_RESOURCE_DEADLINE_INVALID', 'Shared resource deadline must be future and no more than 24 hours');
250
+ await lockResources(tx, value.resourceKeys);
251
+ const conflict = await tx.sharedResourceLeaseResource.findFirst({ where: {
252
+ resourceKey: { in: value.resourceKeys }, state: { in: [...HELD] },
253
+ }, select: { resourceKey: true, leaseId: true } });
254
+ if (conflict)
255
+ fail(409, 'SHARED_RESOURCE_BUSY', `Resource ${conflict.resourceKey} retains durable ownership`);
256
+ const resources = [];
257
+ for (const resourceKey of value.resourceKeys) {
258
+ const fence = await tx.sharedResourceFence.upsert({ where: { resourceKey },
259
+ create: { resourceKey, generation: 1n }, update: { generation: { increment: 1n } } });
260
+ resources.push({ resourceKey, fenceGeneration: fence.generation });
261
+ }
262
+ const lease = await tx.sharedResourceLease.create({ data: {
263
+ id: nanoid(), missionRootId: value.missionRootId, missionRevision: value.missionRevision,
264
+ taskId: value.taskId, operationId: value.operationId, requestSha256: value.requestSha256,
265
+ actionClass: value.actionClass, ownerActor: actorId, callerKind: value.caller.kind,
266
+ delegationId: value.caller.kind === 'delegated' ? value.caller.delegationId : null,
267
+ missionIssuer: value.missionAssertion.issuer, missionKeyId: value.missionAssertion.keyId,
268
+ missionAssertionSha256: createHash('sha256').update(JSON.stringify(value.missionAssertion)).digest('hex'),
269
+ missionAssertionJson: JSON.stringify(value.missionAssertion), resourceSetBinding: resourceSetBinding(resources),
270
+ deadline: new Date(value.deadline * 1000), resources: { create: resources },
271
+ }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
272
+ return leaseView(lease);
273
+ });
274
+ }
275
+ async function mutateHeldLease(id, raw, actorId, action) {
276
+ const value = sharedResourceLeaseIdentitySchema.parse(raw);
277
+ const found = await loadLease(id);
278
+ if (!found)
279
+ fail(404, 'SHARED_RESOURCE_LEASE_NOT_FOUND', 'Shared resource lease not found');
280
+ const db = getDb();
281
+ return db.$transaction(async (tx) => {
282
+ await lockOperation(tx, found.operationId);
283
+ if (action === 'start')
284
+ await lockSharedAdmission(tx);
285
+ await lockResources(tx, found.resources.map(resource => resource.resourceKey));
286
+ const row = await tx.sharedResourceLease.findUniqueOrThrow({ where: { id }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
287
+ assertLeaseOwner(row, actorId);
288
+ if (!exactIdentity(row, value))
289
+ fail(409, 'SHARED_RESOURCE_IDENTITY_INVALID', 'Lease identity changed or does not match');
290
+ await assertCurrentFences(tx, row);
291
+ if (action === 'start') {
292
+ await assertSharedAdmissionOpen(tx);
293
+ if (row.state === 'running')
294
+ return leaseView(row);
295
+ if (row.state !== 'reserved')
296
+ fail(409, 'SHARED_RESOURCE_STATE_INVALID', 'Only a reserved lease can start');
297
+ if (row.deadline.getTime() <= Date.now())
298
+ fail(409, 'SHARED_RESOURCE_DEADLINE_INVALID', 'Expired ownership is retained and cannot start');
299
+ const startedAt = new Date();
300
+ await tx.sharedResourceLeaseResource.updateMany({ where: { leaseId: id, state: 'reserved' }, data: { state: 'running' } });
301
+ const updated = await tx.sharedResourceLease.update({ where: { id }, data: { state: 'running', startedAt },
302
+ include: { resources: { orderBy: { resourceKey: 'asc' } } } });
303
+ return leaseView(updated);
304
+ }
305
+ if (row.state === 'unknown')
306
+ return leaseView(row);
307
+ if (row.state !== 'running')
308
+ fail(409, 'SHARED_RESOURCE_STATE_INVALID', 'Only started work can become unknown');
309
+ await tx.sharedResourceLeaseResource.updateMany({ where: { leaseId: id, state: 'running' }, data: { state: 'unknown' } });
310
+ const updated = await tx.sharedResourceLease.update({ where: { id }, data: { state: 'unknown' },
311
+ include: { resources: { orderBy: { resourceKey: 'asc' } } } });
312
+ return leaseView(updated);
313
+ });
314
+ }
315
+ export async function startSharedResourceLease(id, raw, actorId) { return mutateHeldLease(id, raw, actorId, 'start'); }
316
+ export async function retainUnknownSharedResourceLease(id, raw, actorId) { return mutateHeldLease(id, raw, actorId, 'unknown'); }
317
+ export async function settleSharedResourceLease(id, raw, actorId) {
318
+ const value = sharedResourceTerminalEvidenceSchema.parse(raw);
319
+ verifyTerminalEvidence(value);
320
+ const found = await loadLease(id);
321
+ if (!found)
322
+ fail(404, 'SHARED_RESOURCE_LEASE_NOT_FOUND', 'Shared resource lease not found');
323
+ assertLeaseOwner(found, actorId);
324
+ if (!exactIdentity(found, value))
325
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Terminal evidence does not bind the exact lease');
326
+ if (value.completedAt < seconds(found.startedAt ?? found.acquiredAt)
327
+ || value.completedAt > Math.floor(Date.now() / 1000) + 30)
328
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Terminal evidence completion time is invalid');
329
+ const serialized = JSON.stringify(value);
330
+ const recordSha256 = sharedResourceSettlementRecordSha256(value);
331
+ const db = getDb();
332
+ return db.$transaction(async (tx) => {
333
+ await lockOperation(tx, found.operationId);
334
+ await lockResources(tx, found.resources.map(resource => resource.resourceKey));
335
+ const row = await tx.sharedResourceLease.findUniqueOrThrow({ where: { id }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
336
+ if (!exactIdentity(row, value))
337
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Terminal evidence does not bind the exact lease');
338
+ if (row.state === 'released') {
339
+ if (row.terminalEvidenceJson !== serialized || row.settlementRecordSha256 !== recordSha256)
340
+ fail(409, 'SHARED_RESOURCE_SETTLEMENT_CONFLICT', 'Lease already has different terminal evidence');
341
+ return leaseView(row);
342
+ }
343
+ await assertCurrentFences(tx, row);
344
+ if (row.state !== 'running' && row.state !== 'unknown')
345
+ fail(409, 'SHARED_RESOURCE_STATE_INVALID', 'Only started or unknown work accepts terminal evidence');
346
+ const settledAt = new Date();
347
+ await tx.sharedResourceLeaseResource.updateMany({ where: { leaseId: id, state: { in: ['running', 'unknown'] } }, data: { state: 'released' } });
348
+ const updated = await tx.sharedResourceLease.update({ where: { id }, data: { state: 'released', settledAt,
349
+ settlement: `signed-terminal:${value.outcome}`, evidenceSha256: value.evidenceSha256,
350
+ settlementRecordSha256: recordSha256, terminalEvidenceJson: serialized },
351
+ include: { resources: { orderBy: { resourceKey: 'asc' } } } });
352
+ return leaseView(updated);
353
+ });
354
+ }
355
+ export async function reconcileSharedResourceLease(id, raw) {
356
+ const value = sharedResourceReconciliationSchema.parse(raw);
357
+ verifyReconciliation(value);
358
+ const found = await loadLease(id);
359
+ if (!found)
360
+ fail(404, 'SHARED_RESOURCE_LEASE_NOT_FOUND', 'Shared resource lease not found');
361
+ if (!exactIdentity(found, value))
362
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Reconciliation does not bind the exact lease');
363
+ const serialized = JSON.stringify(value);
364
+ const recordSha256 = sharedResourceSettlementRecordSha256(value);
365
+ const db = getDb();
366
+ return db.$transaction(async (tx) => {
367
+ await lockOperation(tx, found.operationId);
368
+ await lockResources(tx, found.resources.map(resource => resource.resourceKey));
369
+ const row = await tx.sharedResourceLease.findUniqueOrThrow({ where: { id }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
370
+ if (!exactIdentity(row, value))
371
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Reconciliation does not bind the exact lease');
372
+ if (row.state === 'released') {
373
+ if (row.reconciliationDecisionId !== value.decisionId || row.reconciliationJson !== serialized
374
+ || row.settlementRecordSha256 !== recordSha256)
375
+ fail(409, 'SHARED_RESOURCE_SETTLEMENT_CONFLICT', 'Lease already has another terminal settlement');
376
+ return leaseView(row);
377
+ }
378
+ const now = Math.floor(Date.now() / 1000);
379
+ if (value.expiresAt <= now || value.expiresAt > now + 300)
380
+ fail(409, 'SHARED_RESOURCE_EVIDENCE_INVALID', 'Reconciliation validity is stale or too broad');
381
+ await assertCurrentFences(tx, row);
382
+ const reused = await tx.sharedResourceLease.findFirst({ where: { reconciliationDecisionId: value.decisionId }, select: { id: true } });
383
+ if (reused && reused.id !== id)
384
+ fail(409, 'SHARED_RESOURCE_SETTLEMENT_CONFLICT', 'Reconciliation decision is already bound to another lease');
385
+ await tx.sharedResourceLeaseResource.updateMany({ where: { leaseId: id, state: { in: [...HELD] } }, data: { state: 'released' } });
386
+ const updated = await tx.sharedResourceLease.update({ where: { id }, data: { state: 'released', settledAt: new Date(),
387
+ settlement: `signed-reconciliation:${value.disposition}`, evidenceSha256: value.evidenceSha256,
388
+ settlementRecordSha256: recordSha256, reconciliationDecisionId: value.decisionId,
389
+ reconciliationJson: serialized }, include: { resources: { orderBy: { resourceKey: 'asc' } } } });
390
+ return leaseView(updated);
391
+ });
392
+ }
393
+ export async function inspectSharedResourceLease(id, actorId, allowAdministrator = false) {
394
+ const row = await loadLease(id);
395
+ if (!row)
396
+ fail(404, 'SHARED_RESOURCE_LEASE_NOT_FOUND', 'Shared resource lease not found');
397
+ if (!allowAdministrator)
398
+ assertLeaseOwner(row, actorId);
399
+ return leaseView(row);
400
+ }
401
+ export async function recoverSharedResourceLease(operationId, actorId, allowAdministrator = false) {
402
+ const row = await getDb().sharedResourceLease.findUnique({ where: { operationId },
403
+ include: { resources: { orderBy: { resourceKey: 'asc' } } } });
404
+ if (!row)
405
+ fail(404, 'SHARED_RESOURCE_LEASE_NOT_FOUND', 'Shared resource lease not found');
406
+ if (!allowAdministrator)
407
+ assertLeaseOwner(row, actorId);
408
+ return leaseView(row);
409
+ }
410
+ export function sharedResourceResultTrustDigest() {
411
+ return createHash('sha256').update(process.env['SHARED_RESOURCE_RESULT_TRUST_JSON'] ?? '').digest('hex');
412
+ }
413
+ //# sourceMappingURL=shared-resource-leases.js.map