@synth-deploy/server 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/dist/agent/debrief-retention.d.ts +12 -0
  2. package/dist/agent/debrief-retention.d.ts.map +1 -0
  3. package/dist/agent/debrief-retention.js +27 -0
  4. package/dist/agent/debrief-retention.js.map +1 -0
  5. package/dist/agent/envoy-client.d.ts +216 -0
  6. package/dist/agent/envoy-client.d.ts.map +1 -0
  7. package/dist/agent/envoy-client.js +266 -0
  8. package/dist/agent/envoy-client.js.map +1 -0
  9. package/dist/agent/envoy-registry.d.ts +102 -0
  10. package/dist/agent/envoy-registry.d.ts.map +1 -0
  11. package/dist/agent/envoy-registry.js +319 -0
  12. package/dist/agent/envoy-registry.js.map +1 -0
  13. package/dist/agent/health-checker.d.ts +39 -0
  14. package/dist/agent/health-checker.d.ts.map +1 -0
  15. package/dist/agent/health-checker.js +49 -0
  16. package/dist/agent/health-checker.js.map +1 -0
  17. package/dist/agent/mcp-client-manager.d.ts +36 -0
  18. package/dist/agent/mcp-client-manager.d.ts.map +1 -0
  19. package/dist/agent/mcp-client-manager.js +106 -0
  20. package/dist/agent/mcp-client-manager.js.map +1 -0
  21. package/dist/agent/stale-deployment-detector.d.ts +15 -0
  22. package/dist/agent/stale-deployment-detector.d.ts.map +1 -0
  23. package/dist/agent/stale-deployment-detector.js +50 -0
  24. package/dist/agent/stale-deployment-detector.js.map +1 -0
  25. package/dist/agent/step-runner.d.ts +31 -0
  26. package/dist/agent/step-runner.d.ts.map +1 -0
  27. package/dist/agent/step-runner.js +80 -0
  28. package/dist/agent/step-runner.js.map +1 -0
  29. package/dist/agent/synth-agent.d.ts +168 -0
  30. package/dist/agent/synth-agent.d.ts.map +1 -0
  31. package/dist/agent/synth-agent.js +1195 -0
  32. package/dist/agent/synth-agent.js.map +1 -0
  33. package/dist/api/agent.d.ts +36 -0
  34. package/dist/api/agent.d.ts.map +1 -0
  35. package/dist/api/agent.js +867 -0
  36. package/dist/api/agent.js.map +1 -0
  37. package/dist/api/api-keys.d.ts +4 -0
  38. package/dist/api/api-keys.d.ts.map +1 -0
  39. package/dist/api/api-keys.js +118 -0
  40. package/dist/api/api-keys.js.map +1 -0
  41. package/dist/api/artifacts.d.ts +5 -0
  42. package/dist/api/artifacts.d.ts.map +1 -0
  43. package/dist/api/artifacts.js +142 -0
  44. package/dist/api/artifacts.js.map +1 -0
  45. package/dist/api/auth.d.ts +4 -0
  46. package/dist/api/auth.d.ts.map +1 -0
  47. package/dist/api/auth.js +280 -0
  48. package/dist/api/auth.js.map +1 -0
  49. package/dist/api/deployments.d.ts +11 -0
  50. package/dist/api/deployments.d.ts.map +1 -0
  51. package/dist/api/deployments.js +1098 -0
  52. package/dist/api/deployments.js.map +1 -0
  53. package/dist/api/environments.d.ts +5 -0
  54. package/dist/api/environments.d.ts.map +1 -0
  55. package/dist/api/environments.js +69 -0
  56. package/dist/api/environments.js.map +1 -0
  57. package/dist/api/envoy-reports.d.ts +17 -0
  58. package/dist/api/envoy-reports.d.ts.map +1 -0
  59. package/dist/api/envoy-reports.js +138 -0
  60. package/dist/api/envoy-reports.js.map +1 -0
  61. package/dist/api/envoys.d.ts +5 -0
  62. package/dist/api/envoys.d.ts.map +1 -0
  63. package/dist/api/envoys.js +192 -0
  64. package/dist/api/envoys.js.map +1 -0
  65. package/dist/api/fleet.d.ts +11 -0
  66. package/dist/api/fleet.d.ts.map +1 -0
  67. package/dist/api/fleet.js +394 -0
  68. package/dist/api/fleet.js.map +1 -0
  69. package/dist/api/graph.d.ts +8 -0
  70. package/dist/api/graph.d.ts.map +1 -0
  71. package/dist/api/graph.js +355 -0
  72. package/dist/api/graph.js.map +1 -0
  73. package/dist/api/health.d.ts +20 -0
  74. package/dist/api/health.d.ts.map +1 -0
  75. package/dist/api/health.js +248 -0
  76. package/dist/api/health.js.map +1 -0
  77. package/dist/api/idp-schemas.d.ts +41 -0
  78. package/dist/api/idp-schemas.d.ts.map +1 -0
  79. package/dist/api/idp-schemas.js +17 -0
  80. package/dist/api/idp-schemas.js.map +1 -0
  81. package/dist/api/idp.d.ts +6 -0
  82. package/dist/api/idp.d.ts.map +1 -0
  83. package/dist/api/idp.js +620 -0
  84. package/dist/api/idp.js.map +1 -0
  85. package/dist/api/intake.d.ts +10 -0
  86. package/dist/api/intake.d.ts.map +1 -0
  87. package/dist/api/intake.js +418 -0
  88. package/dist/api/intake.js.map +1 -0
  89. package/dist/api/partitions.d.ts +5 -0
  90. package/dist/api/partitions.d.ts.map +1 -0
  91. package/dist/api/partitions.js +113 -0
  92. package/dist/api/partitions.js.map +1 -0
  93. package/dist/api/progress-event-store.d.ts +62 -0
  94. package/dist/api/progress-event-store.d.ts.map +1 -0
  95. package/dist/api/progress-event-store.js +118 -0
  96. package/dist/api/progress-event-store.js.map +1 -0
  97. package/dist/api/schemas.d.ts +1000 -0
  98. package/dist/api/schemas.d.ts.map +1 -0
  99. package/dist/api/schemas.js +328 -0
  100. package/dist/api/schemas.js.map +1 -0
  101. package/dist/api/security-boundaries.d.ts +4 -0
  102. package/dist/api/security-boundaries.d.ts.map +1 -0
  103. package/dist/api/security-boundaries.js +32 -0
  104. package/dist/api/security-boundaries.js.map +1 -0
  105. package/dist/api/settings.d.ts +4 -0
  106. package/dist/api/settings.d.ts.map +1 -0
  107. package/dist/api/settings.js +99 -0
  108. package/dist/api/settings.js.map +1 -0
  109. package/dist/api/system.d.ts +75 -0
  110. package/dist/api/system.d.ts.map +1 -0
  111. package/dist/api/system.js +558 -0
  112. package/dist/api/system.js.map +1 -0
  113. package/dist/api/telemetry.d.ts +4 -0
  114. package/dist/api/telemetry.d.ts.map +1 -0
  115. package/dist/api/telemetry.js +24 -0
  116. package/dist/api/telemetry.js.map +1 -0
  117. package/dist/api/users.d.ts +4 -0
  118. package/dist/api/users.d.ts.map +1 -0
  119. package/dist/api/users.js +173 -0
  120. package/dist/api/users.js.map +1 -0
  121. package/dist/archive-unpacker.d.ts +24 -0
  122. package/dist/archive-unpacker.d.ts.map +1 -0
  123. package/dist/archive-unpacker.js +239 -0
  124. package/dist/archive-unpacker.js.map +1 -0
  125. package/dist/artifact-analyzer.d.ts +59 -0
  126. package/dist/artifact-analyzer.d.ts.map +1 -0
  127. package/dist/artifact-analyzer.js +334 -0
  128. package/dist/artifact-analyzer.js.map +1 -0
  129. package/dist/auth/idp/index.d.ts +9 -0
  130. package/dist/auth/idp/index.d.ts.map +1 -0
  131. package/dist/auth/idp/index.js +5 -0
  132. package/dist/auth/idp/index.js.map +1 -0
  133. package/dist/auth/idp/ldap.d.ts +56 -0
  134. package/dist/auth/idp/ldap.d.ts.map +1 -0
  135. package/dist/auth/idp/ldap.js +276 -0
  136. package/dist/auth/idp/ldap.js.map +1 -0
  137. package/dist/auth/idp/oidc.d.ts +27 -0
  138. package/dist/auth/idp/oidc.d.ts.map +1 -0
  139. package/dist/auth/idp/oidc.js +97 -0
  140. package/dist/auth/idp/oidc.js.map +1 -0
  141. package/dist/auth/idp/role-mapping.d.ts +9 -0
  142. package/dist/auth/idp/role-mapping.d.ts.map +1 -0
  143. package/dist/auth/idp/role-mapping.js +16 -0
  144. package/dist/auth/idp/role-mapping.js.map +1 -0
  145. package/dist/auth/idp/saml.d.ts +40 -0
  146. package/dist/auth/idp/saml.d.ts.map +1 -0
  147. package/dist/auth/idp/saml.js +117 -0
  148. package/dist/auth/idp/saml.js.map +1 -0
  149. package/dist/auth/idp/types.d.ts +23 -0
  150. package/dist/auth/idp/types.d.ts.map +1 -0
  151. package/dist/auth/idp/types.js +2 -0
  152. package/dist/auth/idp/types.js.map +1 -0
  153. package/dist/fleet/fleet-executor.d.ts +35 -0
  154. package/dist/fleet/fleet-executor.d.ts.map +1 -0
  155. package/dist/fleet/fleet-executor.js +228 -0
  156. package/dist/fleet/fleet-executor.js.map +1 -0
  157. package/dist/fleet/fleet-store.d.ts +13 -0
  158. package/dist/fleet/fleet-store.d.ts.map +1 -0
  159. package/dist/fleet/fleet-store.js +13 -0
  160. package/dist/fleet/fleet-store.js.map +1 -0
  161. package/dist/fleet/index.d.ts +5 -0
  162. package/dist/fleet/index.d.ts.map +1 -0
  163. package/dist/fleet/index.js +4 -0
  164. package/dist/fleet/index.js.map +1 -0
  165. package/dist/fleet/representative-selector.d.ts +15 -0
  166. package/dist/fleet/representative-selector.d.ts.map +1 -0
  167. package/dist/fleet/representative-selector.js +71 -0
  168. package/dist/fleet/representative-selector.js.map +1 -0
  169. package/dist/graph/graph-executor.d.ts +36 -0
  170. package/dist/graph/graph-executor.d.ts.map +1 -0
  171. package/dist/graph/graph-executor.js +348 -0
  172. package/dist/graph/graph-executor.js.map +1 -0
  173. package/dist/graph/graph-inference.d.ts +22 -0
  174. package/dist/graph/graph-inference.d.ts.map +1 -0
  175. package/dist/graph/graph-inference.js +149 -0
  176. package/dist/graph/graph-inference.js.map +1 -0
  177. package/dist/graph/graph-store.d.ts +12 -0
  178. package/dist/graph/graph-store.d.ts.map +1 -0
  179. package/dist/graph/graph-store.js +61 -0
  180. package/dist/graph/graph-store.js.map +1 -0
  181. package/dist/graph/index.d.ts +5 -0
  182. package/dist/graph/index.d.ts.map +1 -0
  183. package/dist/graph/index.js +4 -0
  184. package/dist/graph/index.js.map +1 -0
  185. package/dist/index.d.ts +2 -0
  186. package/dist/index.d.ts.map +1 -0
  187. package/dist/index.js +837 -0
  188. package/dist/index.js.map +1 -0
  189. package/dist/intake/index.d.ts +6 -0
  190. package/dist/intake/index.d.ts.map +1 -0
  191. package/dist/intake/index.js +5 -0
  192. package/dist/intake/index.js.map +1 -0
  193. package/dist/intake/intake-processor.d.ts +17 -0
  194. package/dist/intake/intake-processor.d.ts.map +1 -0
  195. package/dist/intake/intake-processor.js +99 -0
  196. package/dist/intake/intake-processor.js.map +1 -0
  197. package/dist/intake/intake-store.d.ts +7 -0
  198. package/dist/intake/intake-store.d.ts.map +1 -0
  199. package/dist/intake/intake-store.js +7 -0
  200. package/dist/intake/intake-store.js.map +1 -0
  201. package/dist/intake/registry-poller.d.ts +41 -0
  202. package/dist/intake/registry-poller.d.ts.map +1 -0
  203. package/dist/intake/registry-poller.js +202 -0
  204. package/dist/intake/registry-poller.js.map +1 -0
  205. package/dist/intake/webhook-handlers.d.ts +37 -0
  206. package/dist/intake/webhook-handlers.d.ts.map +1 -0
  207. package/dist/intake/webhook-handlers.js +268 -0
  208. package/dist/intake/webhook-handlers.js.map +1 -0
  209. package/dist/logger.d.ts +5 -0
  210. package/dist/logger.d.ts.map +1 -0
  211. package/dist/logger.js +15 -0
  212. package/dist/logger.js.map +1 -0
  213. package/dist/mcp/resources.d.ts +9 -0
  214. package/dist/mcp/resources.d.ts.map +1 -0
  215. package/dist/mcp/resources.js +72 -0
  216. package/dist/mcp/resources.js.map +1 -0
  217. package/dist/mcp/server.d.ts +15 -0
  218. package/dist/mcp/server.d.ts.map +1 -0
  219. package/dist/mcp/server.js +20 -0
  220. package/dist/mcp/server.js.map +1 -0
  221. package/dist/mcp/tools.d.ts +9 -0
  222. package/dist/mcp/tools.d.ts.map +1 -0
  223. package/dist/mcp/tools.js +88 -0
  224. package/dist/mcp/tools.js.map +1 -0
  225. package/dist/middleware/auth.d.ts +29 -0
  226. package/dist/middleware/auth.d.ts.map +1 -0
  227. package/dist/middleware/auth.js +76 -0
  228. package/dist/middleware/auth.js.map +1 -0
  229. package/dist/middleware/permissions.d.ts +13 -0
  230. package/dist/middleware/permissions.d.ts.map +1 -0
  231. package/dist/middleware/permissions.js +32 -0
  232. package/dist/middleware/permissions.js.map +1 -0
  233. package/dist/pattern-store.d.ts +104 -0
  234. package/dist/pattern-store.d.ts.map +1 -0
  235. package/dist/pattern-store.js +299 -0
  236. package/dist/pattern-store.js.map +1 -0
  237. package/package.json +54 -0
  238. package/src/agent/debrief-retention.ts +44 -0
  239. package/src/agent/envoy-client.ts +474 -0
  240. package/src/agent/envoy-registry.ts +384 -0
  241. package/src/agent/health-checker.ts +70 -0
  242. package/src/agent/mcp-client-manager.ts +131 -0
  243. package/src/agent/stale-deployment-detector.ts +79 -0
  244. package/src/agent/step-runner.ts +124 -0
  245. package/src/agent/synth-agent.ts +1567 -0
  246. package/src/api/agent.ts +1075 -0
  247. package/src/api/api-keys.ts +129 -0
  248. package/src/api/artifacts.ts +194 -0
  249. package/src/api/auth.ts +320 -0
  250. package/src/api/deployments.ts +1347 -0
  251. package/src/api/environments.ts +97 -0
  252. package/src/api/envoy-reports.ts +159 -0
  253. package/src/api/envoys.ts +237 -0
  254. package/src/api/fleet.ts +510 -0
  255. package/src/api/graph.ts +516 -0
  256. package/src/api/health.ts +311 -0
  257. package/src/api/idp-schemas.ts +19 -0
  258. package/src/api/idp.ts +735 -0
  259. package/src/api/intake.ts +537 -0
  260. package/src/api/partitions.ts +147 -0
  261. package/src/api/progress-event-store.ts +153 -0
  262. package/src/api/schemas.ts +376 -0
  263. package/src/api/security-boundaries.ts +54 -0
  264. package/src/api/settings.ts +118 -0
  265. package/src/api/system.ts +704 -0
  266. package/src/api/telemetry.ts +32 -0
  267. package/src/api/users.ts +210 -0
  268. package/src/archive-unpacker.ts +271 -0
  269. package/src/artifact-analyzer.ts +438 -0
  270. package/src/auth/idp/index.ts +8 -0
  271. package/src/auth/idp/ldap.ts +340 -0
  272. package/src/auth/idp/oidc.ts +117 -0
  273. package/src/auth/idp/role-mapping.ts +22 -0
  274. package/src/auth/idp/saml.ts +148 -0
  275. package/src/auth/idp/types.ts +22 -0
  276. package/src/fleet/fleet-executor.ts +309 -0
  277. package/src/fleet/fleet-store.ts +13 -0
  278. package/src/fleet/index.ts +4 -0
  279. package/src/fleet/representative-selector.ts +83 -0
  280. package/src/graph/graph-executor.ts +446 -0
  281. package/src/graph/graph-inference.ts +184 -0
  282. package/src/graph/graph-store.ts +75 -0
  283. package/src/graph/index.ts +4 -0
  284. package/src/index.ts +916 -0
  285. package/src/intake/index.ts +5 -0
  286. package/src/intake/intake-processor.ts +111 -0
  287. package/src/intake/intake-store.ts +7 -0
  288. package/src/intake/registry-poller.ts +230 -0
  289. package/src/intake/webhook-handlers.ts +328 -0
  290. package/src/logger.ts +19 -0
  291. package/src/mcp/resources.ts +98 -0
  292. package/src/mcp/server.ts +34 -0
  293. package/src/mcp/tools.ts +117 -0
  294. package/src/middleware/auth.ts +103 -0
  295. package/src/middleware/permissions.ts +35 -0
  296. package/src/pattern-store.ts +409 -0
  297. package/tests/agent-mode.test.ts +536 -0
  298. package/tests/api-handlers.test.ts +1245 -0
  299. package/tests/archive-unpacker.test.ts +179 -0
  300. package/tests/artifact-analyzer.test.ts +240 -0
  301. package/tests/auth-middleware.test.ts +189 -0
  302. package/tests/decision-diary.test.ts +957 -0
  303. package/tests/diary-reader.test.ts +782 -0
  304. package/tests/envoy-client.test.ts +342 -0
  305. package/tests/envoy-reports.test.ts +156 -0
  306. package/tests/mcp-tools.test.ts +213 -0
  307. package/tests/orchestration.test.ts +536 -0
  308. package/tests/partition-deletion.test.ts +143 -0
  309. package/tests/partition-isolation.test.ts +830 -0
  310. package/tests/pattern-store.test.ts +371 -0
  311. package/tests/rbac-enforcement.test.ts +409 -0
  312. package/tests/ssrf-validation.test.ts +56 -0
  313. package/tests/stale-deployment.test.ts +85 -0
  314. package/tests/step-runner.test.ts +308 -0
  315. package/tests/ui-journey.test.ts +330 -0
  316. package/tsconfig.json +11 -0
  317. package/vitest.config.ts +27 -0
@@ -0,0 +1,537 @@
1
+ /**
2
+ * Intake API routes — channel management, webhook receiver, API intake, events.
3
+ */
4
+
5
+ import crypto from "node:crypto";
6
+ import type { FastifyInstance } from "fastify";
7
+ import type { IArtifactStore } from "@synth-deploy/core";
8
+ import { requirePermission } from "../middleware/permissions.js";
9
+ import { detectArtifactType } from "../artifact-analyzer.js";
10
+ import type { IntakeChannelStore, IntakeEventStore } from "../intake/intake-store.js";
11
+ import type { IntakeProcessor } from "../intake/intake-processor.js";
12
+ import type { RegistryPoller } from "../intake/registry-poller.js";
13
+ import { parseWebhook, verifyWebhookSignature } from "../intake/webhook-handlers.js";
14
+ import type { RegistryConfig } from "@synth-deploy/core";
15
+
16
+ /** Extract a semver-like version from a filename. Returns null if none found. */
17
+ function extractVersionFromFilename(filename: string): string | null {
18
+ const match = filename.match(/[-_v](\d+\.\d+[\.\d-]*)(?:\.\w+)*$/);
19
+ return match ? match[1] : null;
20
+ }
21
+
22
+ /** Strip version suffix and extension(s) from a filename to get the artifact name. */
23
+ function extractNameFromFilename(filename: string): string {
24
+ let name = filename;
25
+ // Strip common multi-part extensions like .tar.gz, .tar.bz2
26
+ name = name.replace(/\.tar\.\w+$/, "");
27
+ // Strip remaining single extension — only if it's a short, lowercase-alpha extension
28
+ // (≤4 chars, all lowercase letters). This preserves qualifier suffixes like
29
+ // Dockerfile.server, Dockerfile.envoy, nginx.conf.template, etc.
30
+ name = name.replace(/\.[a-z]{1,4}$/, "");
31
+ // Strip trailing version suffix: -1.2.3 or _1.2.3 or -v1.2.3
32
+ name = name.replace(/[-_]v?\d+[\d.\-]*$/, "");
33
+ return name || filename;
34
+ }
35
+
36
+ export function registerIntakeRoutes(
37
+ app: FastifyInstance,
38
+ channelStore: IntakeChannelStore,
39
+ eventStore: IntakeEventStore,
40
+ processor: IntakeProcessor,
41
+ poller: RegistryPoller,
42
+ artifactStore: IArtifactStore,
43
+ ): void {
44
+ // -----------------------------------------------------------------------
45
+ // Channel management (require settings.manage permission)
46
+ // -----------------------------------------------------------------------
47
+
48
+ // List all intake channels
49
+ app.get(
50
+ "/api/intake/channels",
51
+ { preHandler: [requirePermission("settings.manage")] },
52
+ async () => {
53
+ const channels = channelStore.list().map((ch) => ({
54
+ ...ch,
55
+ // Never expose authToken in list responses — only on creation
56
+ authToken: undefined,
57
+ }));
58
+ return { channels };
59
+ },
60
+ );
61
+
62
+ // Create a new intake channel
63
+ app.post(
64
+ "/api/intake/channels",
65
+ { preHandler: [requirePermission("settings.manage")] },
66
+ async (request, reply) => {
67
+ const body = request.body as Record<string, unknown>;
68
+ const type = body.type as string;
69
+ const name = body.name as string;
70
+ const config = (body.config as Record<string, unknown>) ?? {};
71
+ const enabled = body.enabled !== false;
72
+
73
+ if (!type || !name) {
74
+ return reply.status(400).send({ error: "type and name are required" });
75
+ }
76
+
77
+ if (!["webhook", "registry", "api", "manual"].includes(type)) {
78
+ return reply.status(400).send({ error: "type must be one of: webhook, registry, api, manual" });
79
+ }
80
+
81
+ // Generate an auth token for webhook channels
82
+ const authToken = type === "webhook" || type === "api"
83
+ ? crypto.randomUUID()
84
+ : undefined;
85
+
86
+ const channel = channelStore.create({
87
+ type: type as "webhook" | "registry" | "api" | "manual",
88
+ name,
89
+ enabled,
90
+ config,
91
+ authToken,
92
+ });
93
+
94
+ // Start polling if it's a registry channel and enabled
95
+ if (type === "registry" && enabled) {
96
+ poller.startPolling(channel);
97
+ }
98
+
99
+ return reply.status(201).send({ channel });
100
+ },
101
+ );
102
+
103
+ // Update an intake channel
104
+ app.put<{ Params: { id: string } }>(
105
+ "/api/intake/channels/:id",
106
+ { preHandler: [requirePermission("settings.manage")] },
107
+ async (request, reply) => {
108
+ const channel = channelStore.get(request.params.id);
109
+ if (!channel) {
110
+ return reply.status(404).send({ error: "Channel not found" });
111
+ }
112
+
113
+ const body = request.body as Record<string, unknown>;
114
+ const updates: Record<string, unknown> = {};
115
+ if (body.name !== undefined) updates.name = body.name;
116
+ if (body.enabled !== undefined) updates.enabled = body.enabled;
117
+ if (body.config !== undefined) updates.config = body.config;
118
+
119
+ const updated = channelStore.update(request.params.id, updates as Parameters<typeof channelStore.update>[1]);
120
+
121
+ // Handle registry polling state changes
122
+ if (updated.type === "registry") {
123
+ if (updated.enabled) {
124
+ poller.startPolling(updated);
125
+ } else {
126
+ poller.stopPolling(updated.id);
127
+ }
128
+ }
129
+
130
+ return { channel: { ...updated, authToken: undefined } };
131
+ },
132
+ );
133
+
134
+ // Delete an intake channel
135
+ app.delete<{ Params: { id: string } }>(
136
+ "/api/intake/channels/:id",
137
+ { preHandler: [requirePermission("settings.manage")] },
138
+ async (request, reply) => {
139
+ const channel = channelStore.get(request.params.id);
140
+ if (!channel) {
141
+ return reply.status(404).send({ error: "Channel not found" });
142
+ }
143
+
144
+ // Stop polling if applicable
145
+ if (channel.type === "registry") {
146
+ poller.stopPolling(channel.id);
147
+ }
148
+
149
+ channelStore.delete(request.params.id);
150
+ return reply.status(204).send();
151
+ },
152
+ );
153
+
154
+ // Test registry connection
155
+ app.post<{ Params: { id: string } }>(
156
+ "/api/intake/channels/:id/test",
157
+ { preHandler: [requirePermission("settings.manage")] },
158
+ async (request, reply) => {
159
+ const channel = channelStore.get(request.params.id);
160
+ if (!channel) {
161
+ return reply.status(404).send({ error: "Channel not found" });
162
+ }
163
+
164
+ if (channel.type !== "registry") {
165
+ return reply.status(400).send({ error: "Test is only available for registry channels" });
166
+ }
167
+
168
+ const config = channel.config as unknown as RegistryConfig;
169
+ try {
170
+ const baseUrl = config.url.replace(/\/$/, "");
171
+ const headers: Record<string, string> = {};
172
+ if (config.credentials) {
173
+ const auth = Buffer.from(`${config.credentials.username}:${config.credentials.password}`).toString("base64");
174
+ headers["Authorization"] = `Basic ${auth}`;
175
+ }
176
+
177
+ let testUrl: string;
178
+ switch (config.type) {
179
+ case "docker":
180
+ testUrl = `${baseUrl}/v2/`;
181
+ break;
182
+ case "npm":
183
+ testUrl = baseUrl || "https://registry.npmjs.org/";
184
+ break;
185
+ case "nuget":
186
+ testUrl = `${baseUrl || "https://api.nuget.org/v3"}/index.json`;
187
+ break;
188
+ default:
189
+ testUrl = baseUrl;
190
+ }
191
+
192
+ const res = await fetch(testUrl, { headers, signal: AbortSignal.timeout(10_000) });
193
+ if (res.ok || res.status === 401) {
194
+ // 401 means the registry exists but needs auth — still a valid connection
195
+ return { success: true, status: res.status };
196
+ }
197
+ return { success: false, error: `HTTP ${res.status}: ${res.statusText}` };
198
+ } catch (err) {
199
+ return { success: false, error: err instanceof Error ? err.message : "Connection failed" };
200
+ }
201
+ },
202
+ );
203
+
204
+ // -----------------------------------------------------------------------
205
+ // Webhook receiver — authenticated by channel token in URL, NOT JWT
206
+ // -----------------------------------------------------------------------
207
+
208
+ app.post<{ Params: { channelId: string } }>(
209
+ "/api/intake/webhook/:channelId",
210
+ async (request, reply) => {
211
+ const channel = channelStore.get(request.params.channelId);
212
+ if (!channel) {
213
+ return reply.status(404).send({ error: "Channel not found" });
214
+ }
215
+
216
+ if (!channel.enabled) {
217
+ return reply.status(403).send({ error: "Channel is disabled" });
218
+ }
219
+
220
+ if (channel.type !== "webhook") {
221
+ return reply.status(400).send({ error: "Channel is not a webhook channel" });
222
+ }
223
+
224
+ // Validate auth token from query parameter or header
225
+ const queryToken = (request.query as Record<string, string>).token;
226
+ const headerToken = request.headers["x-intake-token"] as string | undefined;
227
+ const token = queryToken || headerToken;
228
+
229
+ if (!token || token !== channel.authToken) {
230
+ return reply.status(401).send({ error: "Invalid or missing intake token" });
231
+ }
232
+
233
+ // Verify webhook signature (GitHub X-Hub-Signature-256, GitLab X-Gitlab-Token)
234
+ const webhookSource = (channel.config as Record<string, unknown>).source as string ?? "generic";
235
+ const webhookSecret = (channel.config as Record<string, unknown>).secretToken as string | undefined;
236
+ const rawBody = typeof request.body === "string" ? request.body : JSON.stringify(request.body);
237
+ const sigResult = verifyWebhookSignature(
238
+ webhookSource,
239
+ webhookSecret,
240
+ request.headers as Record<string, string | string[] | undefined>,
241
+ rawBody,
242
+ );
243
+ if (!sigResult.verified) {
244
+ return reply.status(401).send({ error: sigResult.error ?? "Webhook signature verification failed" });
245
+ }
246
+
247
+ // Create intake event
248
+ const event = eventStore.create({
249
+ channelId: channel.id,
250
+ status: "received",
251
+ payload: (request.body as Record<string, unknown>) ?? {},
252
+ });
253
+
254
+ // Parse the webhook payload
255
+ const source = (channel.config as Record<string, unknown>).source as string ?? "generic";
256
+ const parsed = parseWebhook(source, request.body);
257
+
258
+ if (!parsed) {
259
+ eventStore.update(event.id, {
260
+ status: "failed",
261
+ error: "Could not parse webhook payload",
262
+ processedAt: new Date(),
263
+ });
264
+ return reply.status(422).send({ error: "Could not parse webhook payload", eventId: event.id });
265
+ }
266
+
267
+ // Process the payload asynchronously
268
+ eventStore.update(event.id, { status: "processing" });
269
+
270
+ try {
271
+ const result = await processor.process(parsed, channel.id);
272
+ eventStore.update(event.id, {
273
+ status: "completed",
274
+ artifactId: result.artifactId,
275
+ processedAt: new Date(),
276
+ });
277
+
278
+ return reply.status(201).send({
279
+ eventId: event.id,
280
+ artifactId: result.artifactId,
281
+ versionId: result.versionId,
282
+ });
283
+ } catch (err) {
284
+ eventStore.update(event.id, {
285
+ status: "failed",
286
+ error: err instanceof Error ? err.message : "Processing failed",
287
+ processedAt: new Date(),
288
+ });
289
+ return reply.status(500).send({
290
+ error: "Intake processing failed",
291
+ eventId: event.id,
292
+ });
293
+ }
294
+ },
295
+ );
296
+
297
+ // -----------------------------------------------------------------------
298
+ // API intake — JWT authenticated, direct artifact submission
299
+ // -----------------------------------------------------------------------
300
+
301
+ app.post(
302
+ "/api/intake/artifacts",
303
+ { preHandler: [requirePermission("artifact.create")] },
304
+ async (request, reply) => {
305
+ const body = request.body as Record<string, unknown>;
306
+ const artifactName = body.artifactName as string;
307
+ const artifactType = body.artifactType as string;
308
+ const version = body.version as string;
309
+ const source = (body.source as string) ?? "api";
310
+ const downloadUrl = body.downloadUrl as string | undefined;
311
+ const metadata = (body.metadata as Record<string, unknown>) ?? {};
312
+
313
+ if (!artifactName || !version) {
314
+ return reply.status(400).send({ error: "artifactName and version are required" });
315
+ }
316
+
317
+ const event = eventStore.create({
318
+ channelId: "api-direct",
319
+ status: "processing",
320
+ payload: body,
321
+ });
322
+
323
+ try {
324
+ const result = await processor.process(
325
+ {
326
+ artifactName,
327
+ artifactType: artifactType ?? "unknown",
328
+ version,
329
+ source,
330
+ downloadUrl,
331
+ metadata,
332
+ },
333
+ "api-direct",
334
+ );
335
+
336
+ eventStore.update(event.id, {
337
+ status: "completed",
338
+ artifactId: result.artifactId,
339
+ processedAt: new Date(),
340
+ });
341
+
342
+ return reply.status(201).send({
343
+ eventId: event.id,
344
+ artifactId: result.artifactId,
345
+ versionId: result.versionId,
346
+ });
347
+ } catch (err) {
348
+ eventStore.update(event.id, {
349
+ status: "failed",
350
+ error: err instanceof Error ? err.message : "Processing failed",
351
+ processedAt: new Date(),
352
+ });
353
+ return reply.status(500).send({
354
+ error: "Intake processing failed",
355
+ eventId: event.id,
356
+ });
357
+ }
358
+ },
359
+ );
360
+
361
+ // -----------------------------------------------------------------------
362
+ // Manual upload — form-based artifact submission via UI
363
+ // -----------------------------------------------------------------------
364
+
365
+ app.post(
366
+ "/api/intake/manual",
367
+ { preHandler: [requirePermission("artifact.create")] },
368
+ async (request, reply) => {
369
+ const body = request.body as Record<string, unknown>;
370
+ const artifactName = body.artifactName as string;
371
+ const artifactType = body.artifactType as string;
372
+ const version = body.version as string;
373
+ const source = (body.source as string) ?? "manual-upload";
374
+ const metadata = (body.metadata as Record<string, unknown>) ?? {};
375
+
376
+ if (!artifactName || !artifactType || !version) {
377
+ return reply.status(400).send({ error: "artifactName, artifactType, and version are required" });
378
+ }
379
+
380
+ const event = eventStore.create({
381
+ channelId: "manual",
382
+ status: "processing",
383
+ payload: body,
384
+ });
385
+
386
+ try {
387
+ const result = await processor.process(
388
+ {
389
+ artifactName,
390
+ artifactType,
391
+ version,
392
+ source,
393
+ metadata,
394
+ },
395
+ "manual",
396
+ );
397
+
398
+ eventStore.update(event.id, {
399
+ status: "completed",
400
+ artifactId: result.artifactId,
401
+ processedAt: new Date(),
402
+ });
403
+
404
+ return reply.status(201).send({
405
+ eventId: event.id,
406
+ artifactId: result.artifactId,
407
+ versionId: result.versionId,
408
+ });
409
+ } catch (err) {
410
+ eventStore.update(event.id, {
411
+ status: "failed",
412
+ error: err instanceof Error ? err.message : "Processing failed",
413
+ processedAt: new Date(),
414
+ });
415
+ return reply.status(500).send({
416
+ error: "Intake processing failed",
417
+ eventId: event.id,
418
+ });
419
+ }
420
+ },
421
+ );
422
+
423
+ // -----------------------------------------------------------------------
424
+ // File upload — multipart/form-data artifact submission
425
+ // -----------------------------------------------------------------------
426
+
427
+ app.post(
428
+ "/api/intake/upload",
429
+ { preHandler: [requirePermission("artifact.create")] },
430
+ async (request, reply) => {
431
+ let fileBuffer: Buffer | null = null;
432
+ let originalFilename = "";
433
+ let existingArtifactId: string | undefined;
434
+
435
+ try {
436
+ const parts = request.parts();
437
+ for await (const part of parts) {
438
+ if (part.type === "file") {
439
+ const chunks: Buffer[] = [];
440
+ for await (const chunk of part.file) {
441
+ chunks.push(chunk as Buffer);
442
+ }
443
+ fileBuffer = Buffer.concat(chunks);
444
+ originalFilename = part.filename ?? "unknown";
445
+ } else if (part.type === "field" && part.fieldname === "existingArtifactId") {
446
+ existingArtifactId = String(part.value ?? "").trim() || undefined;
447
+ }
448
+ }
449
+ } catch (err) {
450
+ return reply.status(400).send({ error: "Failed to parse multipart upload" });
451
+ }
452
+
453
+ if (!fileBuffer || !originalFilename) {
454
+ return reply.status(400).send({ error: "File is required" });
455
+ }
456
+
457
+ // If attaching to an existing artifact, look it up to get the canonical name
458
+ let artifactName: string;
459
+ let artifactType: string;
460
+ if (existingArtifactId) {
461
+ const existing = artifactStore.get(existingArtifactId);
462
+ if (!existing) {
463
+ return reply.status(404).send({ error: "Artifact not found" });
464
+ }
465
+ artifactName = existing.name;
466
+ artifactType = existing.type;
467
+ } else {
468
+ artifactName = extractNameFromFilename(originalFilename);
469
+ artifactType = detectArtifactType({ name: originalFilename, source: "manual-upload", content: fileBuffer });
470
+ }
471
+
472
+ const version = extractVersionFromFilename(originalFilename) ?? "unknown";
473
+
474
+ const event = eventStore.create({
475
+ channelId: "manual-upload",
476
+ status: "processing",
477
+ payload: { filename: originalFilename, artifactName, version },
478
+ });
479
+
480
+ try {
481
+ const result = await processor.process(
482
+ {
483
+ artifactName,
484
+ artifactType,
485
+ version,
486
+ source: "manual-upload",
487
+ metadata: { filename: originalFilename },
488
+ content: fileBuffer,
489
+ },
490
+ "manual-upload",
491
+ );
492
+
493
+ eventStore.update(event.id, {
494
+ status: "completed",
495
+ artifactId: result.artifactId,
496
+ processedAt: new Date(),
497
+ });
498
+
499
+ return reply.status(201).send({
500
+ eventId: event.id,
501
+ artifactId: result.artifactId,
502
+ versionId: result.versionId,
503
+ });
504
+ } catch (err) {
505
+ eventStore.update(event.id, {
506
+ status: "failed",
507
+ error: err instanceof Error ? err.message : "Processing failed",
508
+ processedAt: new Date(),
509
+ });
510
+ return reply.status(500).send({
511
+ error: "Upload processing failed",
512
+ eventId: event.id,
513
+ });
514
+ }
515
+ },
516
+ );
517
+
518
+ // -----------------------------------------------------------------------
519
+ // Events — view recent intake events
520
+ // -----------------------------------------------------------------------
521
+
522
+ app.get(
523
+ "/api/intake/events",
524
+ { preHandler: [requirePermission("artifact.view")] },
525
+ async (request) => {
526
+ const query = request.query as Record<string, string>;
527
+ const channelId = query.channelId;
528
+ const limit = parseInt(query.limit ?? "50", 10);
529
+
530
+ const events = channelId
531
+ ? eventStore.listByChannel(channelId, limit)
532
+ : eventStore.listRecent(limit);
533
+
534
+ return { events };
535
+ },
536
+ );
537
+ }
@@ -0,0 +1,147 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import type { IPartitionStore, ITelemetryStore, DebriefReader, DebriefWriter } from "@synth-deploy/core";
3
+ import { generateOperationHistory } from "@synth-deploy/core";
4
+ import type { DeploymentStore } from "../agent/synth-agent.js";
5
+ import { CreatePartitionSchema, UpdatePartitionSchema, SetVariablesSchema } from "./schemas.js";
6
+ import { requirePermission } from "../middleware/permissions.js";
7
+
8
+ export function registerPartitionRoutes(
9
+ app: FastifyInstance,
10
+ partitions: IPartitionStore,
11
+ deployments: DeploymentStore,
12
+ debrief: DebriefReader & DebriefWriter,
13
+ telemetry: ITelemetryStore,
14
+ ): void {
15
+ // List all partitions
16
+ app.get("/api/partitions", { preHandler: [requirePermission("partition.view")] }, async () => {
17
+ return { partitions: partitions.list() };
18
+ });
19
+
20
+ // Create a partition
21
+ app.post("/api/partitions", { preHandler: [requirePermission("partition.create")] }, async (request, reply) => {
22
+ const parsed = CreatePartitionSchema.safeParse(request.body);
23
+ if (!parsed.success) {
24
+ return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
25
+ }
26
+
27
+ const partition = partitions.create(parsed.data.name.trim(), parsed.data.variables ?? {});
28
+ telemetry.record({ actor: (request.user?.email) ?? "anonymous", action: "partition.created", target: { type: "partition", id: partition.id }, details: { name: parsed.data.name } });
29
+ return reply.status(201).send({ partition });
30
+ });
31
+
32
+ // Get partition by ID
33
+ app.get<{ Params: { id: string } }>("/api/partitions/:id", { preHandler: [requirePermission("partition.view")] }, async (request, reply) => {
34
+ const partition = partitions.get(request.params.id);
35
+ if (!partition) {
36
+ return reply.status(404).send({ error: "Partition not found" });
37
+ }
38
+ return { partition };
39
+ });
40
+
41
+ // Update partition (name)
42
+ app.put<{ Params: { id: string } }>("/api/partitions/:id", { preHandler: [requirePermission("partition.update")] }, async (request, reply) => {
43
+ const parsed = UpdatePartitionSchema.safeParse(request.body);
44
+ if (!parsed.success) {
45
+ return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
46
+ }
47
+
48
+ try {
49
+ const partition = partitions.update(request.params.id, {
50
+ name: parsed.data.name?.trim(),
51
+ });
52
+ return { partition };
53
+ } catch (err) {
54
+ if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
55
+ return reply.status(404).send({ error: "Partition not found" });
56
+ }
57
+ app.log.error(err, "Failed to update partition");
58
+ return reply.status(500).send({ error: "Internal server error" });
59
+ }
60
+ });
61
+
62
+ // Delete partition
63
+ app.delete<{ Params: { id: string }; Querystring: { cascade?: string } }>(
64
+ "/api/partitions/:id",
65
+ { preHandler: [requirePermission("partition.delete")] },
66
+ async (request, reply) => {
67
+ const { id } = request.params;
68
+ const partition = partitions.get(id);
69
+ if (!partition) {
70
+ return reply.status(404).send({ error: "Partition not found" });
71
+ }
72
+
73
+ const linkedDeployments = deployments.getByPartition(id);
74
+ const hasLinks = linkedDeployments.length > 0;
75
+
76
+ if (hasLinks && request.query.cascade !== "true") {
77
+ return reply.status(409).send({
78
+ error: "Partition has linked records",
79
+ deployments: linkedDeployments.length,
80
+ hint: "Add ?cascade=true to force-delete with all linked records",
81
+ });
82
+ }
83
+
84
+ if (hasLinks && request.query.cascade === "true") {
85
+ // Log cascade deletion to Decision Diary
86
+ debrief.record({
87
+ partitionId: id,
88
+ deploymentId: null,
89
+ agent: "server",
90
+ decisionType: "system",
91
+ decision: `Cascade-deleted partition "${partition.name}" with ${linkedDeployments.length} deployment(s)`,
92
+ reasoning: "User requested cascade deletion via ?cascade=true query parameter",
93
+ context: {
94
+ partitionId: id,
95
+ partitionName: partition.name,
96
+ deploymentCount: linkedDeployments.length,
97
+ },
98
+ });
99
+ }
100
+
101
+ partitions.delete(id);
102
+ return { deleted: true, cascade: hasLinks };
103
+ },
104
+ );
105
+
106
+ // Update partition variables
107
+ app.put<{ Params: { id: string } }>(
108
+ "/api/partitions/:id/variables",
109
+ { preHandler: [requirePermission("partition.update")] },
110
+ async (request, reply) => {
111
+ const parsed = SetVariablesSchema.safeParse(request.body);
112
+ if (!parsed.success) {
113
+ return reply.status(400).send({ error: "Invalid input", details: parsed.error.format() });
114
+ }
115
+
116
+ try {
117
+ const partition = partitions.setVariables(request.params.id, parsed.data.variables);
118
+ telemetry.record({ actor: (request.user?.email) ?? "anonymous", action: "partition.variables.updated", target: { type: "partition", id: request.params.id }, details: { variableCount: Object.keys(parsed.data.variables).length } });
119
+ return { partition };
120
+ } catch (err) {
121
+ if (err instanceof Error && err.message.toLowerCase().includes("not found")) {
122
+ return reply.status(404).send({ error: "Partition not found" });
123
+ }
124
+ app.log.error(err, "Failed to set partition variables");
125
+ return reply.status(500).send({ error: "Internal server error" });
126
+ }
127
+ },
128
+ );
129
+
130
+ // Get partition deployment history / operation history
131
+ app.get<{ Params: { id: string } }>(
132
+ "/api/partitions/:id/history",
133
+ { preHandler: [requirePermission("partition.view")] },
134
+ async (request, reply) => {
135
+ const partition = partitions.get(request.params.id);
136
+ if (!partition) {
137
+ return reply.status(404).send({ error: "Partition not found" });
138
+ }
139
+
140
+ const partitionDeployments = deployments.getByPartition(request.params.id);
141
+ const partitionEntries = debrief.getByPartition(request.params.id);
142
+ const history = generateOperationHistory(partitionEntries, partitionDeployments);
143
+
144
+ return { history };
145
+ },
146
+ );
147
+ }