diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
index cfaaab6d9..dcf60bee0 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Api/DeployApiEndpoints.cs
@@ -43,9 +43,14 @@ public static class DeployApiEndpoints
{
var configuredKey = config[ConfigKey];
+ // The optional body is read in the handler, NOT bound as an inferred `DeployRequest?` parameter
+ // (lmxopcua#761 gate run, 2026-11-12). An inferred JSON body stamps Accepts(application/json)
+ // metadata on the endpoint, and for a request with NO Content-Type the routing AcceptsMatcherPolicy
+ // prefers endpoints without consumes metadata — on a fused admin+driver host that is grpc-dotnet's
+ // `{unimplementedService}/{unimplementedMethod}` catch-all (mapped by MapOtOpcUaMeshGrpcServices),
+ // so the documented bodyless POST (docker-dev/deploy.sh) answered 404 and never reached this handler.
app.MapPost("/api/deployments", async (
HttpContext http,
- DeployRequest? body,
IAdminOperationsClient adminOps,
CancellationToken ct) =>
{
@@ -57,6 +62,10 @@ public static class DeployApiEndpoints
if (!IsAuthorized(http.Request.Headers[ApiKeyHeader].ToString(), configuredKey))
return Results.Unauthorized();
+ var (body, bodyError) = await ReadOptionalBodyAsync(http.Request, ct);
+ if (bodyError is not null)
+ return bodyError;
+
var createdBy = string.IsNullOrWhiteSpace(body?.CreatedBy) ? "rest-api" : body!.CreatedBy!.Trim();
var result = await adminOps.StartDeploymentAsync(createdBy, ct);
return ToResult(result);
@@ -151,6 +160,34 @@ public static class DeployApiEndpoints
statusCode: StatusCodes.Status500InternalServerError),
};
+ /// Reads the POST's optional JSON body. No body (absent or zero-length) is the documented
+ /// headless form and yields (null, null); a non-JSON body answers 415 and malformed JSON 400, as
+ /// the framework's own body binding did before the body stopped being an inferred parameter.
+ /// The incoming request.
+ /// Request cancellation.
+ /// The parsed body (or null), or the error result to return instead.
+ internal static async Task<(DeployRequest? Body, IResult? Error)> ReadOptionalBodyAsync(
+ HttpRequest request, CancellationToken ct)
+ {
+ var hasBody = request.ContentLength is > 0
+ || (request.ContentLength is null && request.Headers.TransferEncoding.Count > 0);
+ if (!hasBody)
+ return (null, null);
+
+ if (!request.HasJsonContentType())
+ return (null, Results.StatusCode(StatusCodes.Status415UnsupportedMediaType));
+
+ try
+ {
+ return (await request.ReadFromJsonAsync(ct), null);
+ }
+ catch (System.Text.Json.JsonException)
+ {
+ return (null, Results.Problem("The request body is not valid JSON.",
+ statusCode: StatusCodes.Status400BadRequest));
+ }
+ }
+
/// Optional request body. CreatedBy is recorded as the deployment's initiator
/// (audit); defaults to "rest-api" when absent.
/// Who/what initiated the deploy (for the audit trail).
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs
new file mode 100644
index 000000000..816f7ae0c
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/DeployApiBodylessPostTests.cs
@@ -0,0 +1,124 @@
+using System.Net;
+using System.Net.Http.Json;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Hosting.Server;
+using Microsoft.AspNetCore.Hosting.Server.Features;
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Api;
+using ZB.MOM.WW.OtOpcUa.Commons.Interfaces;
+using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Configuration;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// lmxopcua#761 (2026-11-12 gate run): the headless deploy POST must accept a request with NO body, as
+/// docker-dev/deploy.sh and the README document. It answered 404 on the rig: an inferred optional JSON
+/// body put Accepts(application/json) metadata on the endpoint, and for a request with no Content-Type
+/// the AcceptsMatcherPolicy routed to grpc-dotnet's unimplemented-service catch-all instead. The host is
+/// composed like a fused admin+driver node (deploy API + mesh gRPC services) and driven over real HTTP,
+/// so routing and the matcher policies all take part — the deploy API alone does not reproduce it.
+///
+[Trait("Category", "Unit")]
+public sealed class DeployApiBodylessPostTests
+{
+ private const string Key = "test-deploy-key";
+
+ [Theory]
+ [InlineData(false, false)] // no body, no Content-Length header at all
+ [InlineData(true, false)] // Content-Length: 0
+ [InlineData(true, true)] // a JSON body (positive control)
+ public async Task Post_without_a_body_is_accepted(bool contentLengthZero, bool jsonBody)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var ops = new RecordingAdminOps();
+ await using var app = await StartAsync(ops, ct);
+ using var http = new HttpClient { BaseAddress = new Uri(Address(app)) };
+
+ using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments");
+ req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key);
+ if (jsonBody) req.Content = JsonContent.Create(new { createdBy = "ci-bot" });
+ else if (contentLengthZero) req.Content = new ByteArrayContent([]);
+
+ using var resp = await http.SendAsync(req, ct);
+
+ resp.StatusCode.ShouldBe(HttpStatusCode.Accepted, await resp.Content.ReadAsStringAsync(ct));
+ ops.CreatedBy.ShouldBe(jsonBody ? "ci-bot" : "rest-api");
+ }
+
+ [Theory]
+ [InlineData("text/plain", "hello", HttpStatusCode.UnsupportedMediaType)]
+ [InlineData("application/json", "{not json", HttpStatusCode.BadRequest)]
+ public async Task Post_with_an_unusable_body_is_refused_without_deploying(
+ string contentType, string content, HttpStatusCode expected)
+ {
+ var ct = TestContext.Current.CancellationToken;
+ var ops = new RecordingAdminOps();
+ await using var app = await StartAsync(ops, ct);
+ using var http = new HttpClient { BaseAddress = new Uri(Address(app)) };
+
+ using var req = new HttpRequestMessage(HttpMethod.Post, "/api/deployments")
+ {
+ Content = new StringContent(content, System.Text.Encoding.UTF8, contentType),
+ };
+ req.Headers.Add(DeployApiEndpoints.ApiKeyHeader, Key);
+
+ using var resp = await http.SendAsync(req, ct);
+
+ resp.StatusCode.ShouldBe(expected);
+ ops.CreatedBy.ShouldBeNull();
+ }
+
+ private static async Task StartAsync(IAdminOperationsClient ops, CancellationToken ct)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.WebHost.UseUrls("http://127.0.0.1:0");
+ builder.Services.AddSingleton(ops);
+ builder.Services.AddAuthorization();
+ builder.Services.AddGrpc();
+ builder.Services.AddDbContextFactory(o => o.UseInMemoryDatabase("deploy-api-bodyless"));
+ var app = builder.Build();
+ app.UseAuthorization();
+ var config = new ConfigurationBuilder()
+ .AddInMemoryCollection(new Dictionary { [DeployApiEndpoints.ConfigKey] = Key })
+ .Build();
+ app.MapOtOpcUaDeployApi(config);
+ // Composed exactly as Program.cs does on a fused admin+driver node: the mesh gRPC services are
+ // mapped AFTER the deploy API, and every MapGrpcService adds grpc-dotnet catch-all routes.
+ app.MapOtOpcUaMeshGrpcServices(hasAdmin: true, hasDriver: true, syncListenPort: 9001,
+ configServeGrpcPort: 4055, telemetryListenPort: 4056);
+ await app.StartAsync(ct);
+ return app;
+ }
+
+ private static string Address(WebApplication app) =>
+ app.Services.GetRequiredService().Features.Get()!.Addresses.First();
+
+ private sealed class RecordingAdminOps : IAdminOperationsClient
+ {
+ public string? CreatedBy { get; private set; }
+
+ public Task StartDeploymentAsync(string createdBy, CancellationToken ct)
+ {
+ CreatedBy = createdBy;
+ return Task.FromResult(new StartDeploymentResult(
+ StartDeploymentOutcome.Accepted, new DeploymentId(Guid.NewGuid()), new RevisionHash("rev"), null,
+ CorrelationId.NewId()));
+ }
+
+ public Task AcknowledgeAlarmAsync(string alarmId, string user, string? comment, CancellationToken ct) =>
+ throw new NotSupportedException();
+
+ public Task ShelveAlarmAsync(string alarmId, string user, ShelveKind kind, DateTime? unshelveAtUtc,
+ string? comment, CancellationToken ct) => throw new NotSupportedException();
+
+ public Task AskAsync(object message, CancellationToken ct) => throw new NotSupportedException();
+ }
+}