diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Validation/GrpcEndpointRules.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Validation/GrpcEndpointRules.cs
new file mode 100644
index 00000000..7dc75b32
--- /dev/null
+++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Validation/GrpcEndpointRules.cs
@@ -0,0 +1,37 @@
+namespace ZB.MOM.WW.ScadaBridge.Commons.Validation;
+
+///
+/// Validation for site gRPC endpoint addresses (Site.GrpcNodeAAddress/GrpcNodeBAddress).
+/// A bare host or host:port is rejected because the gRPC channel needs an absolute http(s) URI.
+///
+public static class GrpcEndpointRules
+{
+ ///
+ /// Validates a gRPC endpoint. Null/whitespace is "not configured": valid only when
+ /// is true.
+ ///
+ public static bool TryValidate(string? value, bool allowEmpty, out string? error)
+ {
+ error = null;
+ if (string.IsNullOrWhiteSpace(value))
+ {
+ if (allowEmpty)
+ {
+ return true;
+ }
+
+ error = "gRPC endpoint is required (e.g. http://host:8083).";
+ return false;
+ }
+
+ if (!Uri.TryCreate(value.Trim(), UriKind.Absolute, out var uri)
+ || (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
+ || string.IsNullOrEmpty(uri.Host))
+ {
+ error = $"gRPC endpoint '{value}' must be an absolute http:// or https:// URL with a host (e.g. http://host:8083).";
+ return false;
+ }
+
+ return true;
+ }
+}
diff --git a/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validation/GrpcEndpointRulesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validation/GrpcEndpointRulesTests.cs
new file mode 100644
index 00000000..a356953e
--- /dev/null
+++ b/tests/ZB.MOM.WW.ScadaBridge.Commons.Tests/Validation/GrpcEndpointRulesTests.cs
@@ -0,0 +1,37 @@
+using ZB.MOM.WW.ScadaBridge.Commons.Validation;
+
+namespace ZB.MOM.WW.ScadaBridge.Commons.Tests.Validation;
+
+public class GrpcEndpointRulesTests
+{
+ [Theory]
+ [InlineData("scadabridge-site-a-node-a")]
+ [InlineData("site-a-node-a:8083")]
+ [InlineData("ftp://x:1")]
+ public void TryValidate_RejectsNonHttpEndpoints(string value)
+ {
+ Assert.False(GrpcEndpointRules.TryValidate(value, false, out var error));
+ Assert.NotNull(error);
+ }
+
+ [Theory]
+ [InlineData("http://site-a-node-a:8083")]
+ [InlineData("https://h")]
+ public void TryValidate_AcceptsHttpEndpoints(string value)
+ {
+ Assert.True(GrpcEndpointRules.TryValidate(value, false, out var error));
+ Assert.Null(error);
+ }
+
+ [Theory]
+ [InlineData(null)]
+ [InlineData("")]
+ [InlineData(" ")]
+ public void TryValidate_EmptyHonoursAllowEmpty(string? value)
+ {
+ Assert.True(GrpcEndpointRules.TryValidate(value, true, out var ok));
+ Assert.Null(ok);
+ Assert.False(GrpcEndpointRules.TryValidate(value, false, out var err));
+ Assert.NotNull(err);
+ }
+}