diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs
new file mode 100644
index 00000000..c99c1194
--- /dev/null
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/MeshGrpcEndpoints.cs
@@ -0,0 +1,97 @@
+using ZB.MOM.WW.LocalDb.Replication;
+using ZB.MOM.WW.OtOpcUa.AdminUI.Grpc;
+using ZB.MOM.WW.OtOpcUa.Host.Grpc;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+///
+/// Maps the mesh gRPC ingress endpoints (artifact serve, central ack, telemetry stream, node
+/// command) and the LocalDb sync endpoint.
+///
+///
+///
+/// Do not "tidy" this into Program.cs. A standing census guard
+/// (MeshGrpcAnonymousMetadataTests) drives THIS method, so a map that moves back
+/// inline leaves the guard asserting a shape production no longer has — which is exactly
+/// how the AdminUI cookie FallbackPolicy silently blocked every mesh gRPC call on a
+/// login-enabled admin node for seven phases of live gates (archreview 03/S14).
+///
+///
+public static class MeshGrpcEndpoints
+{
+ ///
+ /// Maps the mesh gRPC ingress endpoints and the LocalDb sync endpoint, gated on the same
+ /// role + dedicated-port checks Program.cs used inline before this extraction.
+ ///
+ /// The built application to map endpoints on.
+ /// Whether this node carries the admin role.
+ /// Whether this node carries the driver role.
+ ///
+ /// The dedicated h2c port bound for the LocalDb passive sync endpoint, or 0 when it is
+ /// not bound.
+ ///
+ ///
+ /// The dedicated h2c port bound for the ConfigServe artifact + central command endpoints, or
+ /// 0 when it is not bound.
+ ///
+ ///
+ /// The dedicated h2c port bound for the telemetry stream + node command endpoints, or
+ /// 0 when it is not bound.
+ ///
+ public static void MapOtOpcUaMeshGrpcServices(
+ this WebApplication app,
+ bool hasAdmin,
+ bool hasDriver,
+ int syncListenPort,
+ int configServeGrpcPort,
+ int telemetryListenPort)
+ {
+ ArgumentNullException.ThrowIfNull(app);
+
+ // Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
+ // default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be
+ // safe even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey
+ // configured — but not mapping it at all is the stronger default.
+ if (hasDriver && syncListenPort > 0)
+ {
+ app.MapZbLocalDbSync();
+ }
+
+ // Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on
+ // admin-role nodes (which hold the central SQL connection) and only when a dedicated h2c
+ // port is bound. The ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes
+ // with no ConfigServe:ApiKey.
+ if (hasAdmin && configServeGrpcPort > 0)
+ {
+ app.MapGrpcService();
+
+ // Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
+ // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c
+ // port would buy nothing but another firewall rule, and both endpoints are admin-role and
+ // dialled by the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping
+ // it under Dps costs nothing (nothing dials it) whereas gating it would mean flipping the
+ // mode on a running fleet requires a central restart before any node can ack — the same
+ // reasoning that registers CentralCommunicationActor in both modes.
+ // CentralCommandAuthInterceptor (added at AddGrpc in Program.cs) is the ONLY inbound auth
+ // and fail-closes per cluster.
+ app.MapGrpcService();
+ }
+
+ // Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
+ // TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
+ //
+ // The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME
+ // driver-role h2c listener rather than claiming a fourth port: central already dials this
+ // node here, both directions are per-cluster-keyed, and a separate port would be one more
+ // thing to open on every site firewall for no isolation gain. Mapping it is what makes
+ // MeshCommandAuthInterceptor live — it is registered in Program.cs and is a pass-through
+ // until a call matches this service's method prefix. It fail-closes with no
+ // MeshTransport:GrpcPsk, so an operator who maps the service without setting a key gets a
+ // closed endpoint, not an open one.
+ if (hasDriver && telemetryListenPort > 0)
+ {
+ app.MapGrpcService();
+ app.MapGrpcService();
+ }
+ }
+}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
index 12eeee6b..40ae23ab 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs
@@ -740,48 +740,12 @@ if (hasAdmin)
app.MapOtOpcUaDeployApi(app.Configuration);
}
-// Passive LocalDb sync endpoint. Gated on the same port check that bound the listener, so a
-// default-OFF or admin-only graph carries no sync surface whatsoever. Mapping it would be safe
-// even unauthenticated — LocalDbSyncAuthInterceptor fail-closes with no ApiKey configured — but
-// not mapping it at all is the stronger default.
-if (hasDriver && syncListenPort > 0)
-{
- app.MapZbLocalDbSync();
-}
-
-// Central config-serve artifact endpoint (per-cluster mesh Phase 3). Mapped only on admin-role
-// nodes (which hold the central SQL connection) and only when a dedicated h2c port is bound. The
-// ConfigServeAuthInterceptor is the ONLY inbound auth and fail-closes with no ConfigServe:ApiKey.
-if (hasAdmin && configServeGrpcPort > 0)
-{
- app.MapGrpcService();
-
- // Central command ingress (mesh gRPC command plane): the node→central deployment ack. It
- // rides the SAME listener rather than claiming a port of its own — a second cleartext h2c port
- // would buy nothing but another firewall rule, and both endpoints are admin-role and dialled by
- // the same driver nodes. Deliberately NOT gated on MeshTransport:Mode: mapping it under Dps
- // costs nothing (nothing dials it) whereas gating it would mean flipping the mode on a running
- // fleet requires a central restart before any node can ack — the same reasoning that registers
- // CentralCommunicationActor in both modes. CentralCommandAuthInterceptor (added at AddGrpc
- // above) is the ONLY inbound auth and fail-closes per cluster.
- app.MapGrpcService();
-}
-
-// Driver-node live-telemetry stream (per-cluster mesh Phase 5). Central dials in; gated by
-// TelemetryStreamAuthInterceptor (fail-closed on empty Telemetry:ApiKey).
-//
-// The node-side command endpoint (mesh gRPC command plane, Phase 1) rides the SAME driver-role
-// h2c listener rather than claiming a fourth port: central already dials this node here, both
-// directions are per-cluster-keyed, and a separate port would be one more thing to open on every
-// site firewall for no isolation gain. Mapping it is what makes MeshCommandAuthInterceptor live —
-// it is registered above and is a pass-through until a call matches this service's method prefix.
-// It fail-closes with no MeshTransport:GrpcPsk, so an operator who maps the service without setting
-// a key gets a closed endpoint, not an open one.
-if (hasDriver && telemetryListenPort > 0)
-{
- app.MapGrpcService();
- app.MapGrpcService();
-}
+// Mesh gRPC ingress: the LocalDb passive sync endpoint, the ConfigServe artifact + central
+// command endpoints, and the telemetry-stream + node-command endpoints. Extracted to
+// MeshGrpcEndpoints so a standing census test drives the SAME production mapping (see that
+// file's remarks — archreview 03/S14).
+app.MapOtOpcUaMeshGrpcServices(
+ hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
app.MapOtOpcUaHealth();
app.MapOtOpcUaMetrics();
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs
new file mode 100644
index 00000000..1c734779
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.IntegrationTests/MeshGrpcAnonymousMetadataTests.cs
@@ -0,0 +1,150 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
+using Microsoft.Extensions.DependencyInjection;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Host.Configuration;
+
+namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests;
+
+///
+/// R3-01 (T1-T3) — an endpoint-metadata census over MapOtOpcUaMeshGrpcServices, driven in
+/// all three role shapes a node can actually carry (admin-only, driver-only, fused).
+///
+///
+///
+/// Why this drives the PRODUCTION mapper, not a hand-copied shape. The Critical this
+/// plan fixes (archreview 03/S14) survived seven phases of live gates precisely because every
+/// prior test built its own pipeline by hand — MeshGrpcBoundaryTests,
+/// ConfigServeListenerTests and LocalDbSyncListenerTests all reproduce the
+/// author's mental model of the mapping rather than the host's actual composition. This
+/// census calls — the SAME method
+/// Program.cs calls — so it is structurally impossible for the guard to assert a shape
+/// production no longer has.
+///
+///
+/// Filters out grpc-dotnet's own catch-all infrastructure. Every MapGrpcService
+/// call also registers a per-service "Unimplemented method for X" fallback, and the FIRST
+/// such call in an app additionally registers one shared "Unimplemented service" catch-all —
+/// neither carries a route pattern beginning with /, and neither ever reaches real
+/// logic (they always answer Unimplemented regardless of caller identity), so their
+/// authorization metadata carries no security meaning.
+/// keeps only the five real, callable RPC methods.
+///
+///
+/// Guard-of-the-guard. Every assertion below pins an EXACT expected count and the
+/// exact set of route patterns, never just "not empty" — a census that silently found zero
+/// endpoints (a typo'd role gate, a helper that never got called) would pass a bare
+/// non-empty check trivially. It cannot pass an exact-count-and-set check by accident.
+///
+///
+public sealed class MeshGrpcAnonymousMetadataTests
+{
+ private const string ArtifactFetch = "/deployment_artifact.v1.DeploymentArtifactService/Fetch";
+ private const string CentralReportApply = "/mesh_transport.v1.CentralCommandService/ReportApply";
+ private const string TelemetrySubscribe = "/telemetry.v1.TelemetryStreamService/Subscribe";
+ private const string NodeExecute = "/mesh_transport.v1.NodeCommandService/Execute";
+ private const string LocalDbSyncSync = "/localdb_sync.v1.LocalDbSync/Sync";
+
+ /// The five known mesh gRPC method prefixes — the ONLY endpoints this map may anonymise.
+ private static readonly string[] AllFivePrefixes =
+ [LocalDbSyncSync, ArtifactFetch, CentralReportApply, TelemetrySubscribe, NodeExecute];
+
+ [Fact]
+ public void AdminOnly_MapsExactlyTheConfigServeEndpoints()
+ {
+ // Admin-only: no driver role, so neither LocalDbSync nor the telemetry/node-command pair are
+ // mapped even though a port is configured for them below (0 here, but the shape is the point).
+ var endpoints = RealMethodEndpoints(BuildApp(
+ hasAdmin: true, hasDriver: false,
+ syncListenPort: 0, configServeGrpcPort: 4055, telemetryListenPort: 0));
+
+ endpoints.Count.ShouldBe(2);
+ endpoints.Select(RoutePatternOf).ShouldBe([ArtifactFetch, CentralReportApply], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void DriverOnly_WithNoSyncPort_MapsExactlyTheTelemetryAndNodeCommandEndpoints()
+ {
+ var endpoints = RealMethodEndpoints(BuildApp(
+ hasAdmin: false, hasDriver: true,
+ syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 4056));
+
+ endpoints.Count.ShouldBe(2);
+ endpoints.Select(RoutePatternOf).ShouldBe([TelemetrySubscribe, NodeExecute], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void DriverOnly_WithASyncPort_AlsoMapsTheLocalDbSyncEndpoint()
+ {
+ var endpoints = RealMethodEndpoints(BuildApp(
+ hasAdmin: false, hasDriver: true,
+ syncListenPort: 4054, configServeGrpcPort: 0, telemetryListenPort: 4056));
+
+ endpoints.Count.ShouldBe(3);
+ endpoints.Select(RoutePatternOf).ShouldBe(
+ [LocalDbSyncSync, TelemetrySubscribe, NodeExecute], ignoreOrder: true);
+ }
+
+ [Fact]
+ public void Fused_WithAllThreePorts_MapsAllFiveEndpoints()
+ {
+ // The docker-dev central pair's shape (admin,driver,cluster-MAIN) once all three dedicated
+ // h2c ports are configured — the shape 03/S14 called out as breaking the deploy plane for
+ // the whole fleet, not only the four sites.
+ var endpoints = RealMethodEndpoints(BuildApp(
+ hasAdmin: true, hasDriver: true,
+ syncListenPort: 4054, configServeGrpcPort: 4055, telemetryListenPort: 4056));
+
+ endpoints.Count.ShouldBe(5);
+ endpoints.Select(RoutePatternOf).ShouldBe(AllFivePrefixes, ignoreOrder: true);
+ }
+
+ [Fact]
+ public void NeitherRoleNorAnyPort_MapsNothing()
+ {
+ // The default-OFF shape: every dedicated port is 0. Nothing about the mesh gRPC surface
+ // should exist on a node that configured none of it.
+ var endpoints = RealMethodEndpoints(BuildApp(
+ hasAdmin: false, hasDriver: false,
+ syncListenPort: 0, configServeGrpcPort: 0, telemetryListenPort: 0));
+
+ endpoints.ShouldBeEmpty();
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // harness
+ // ---------------------------------------------------------------------------------------
+
+ private static WebApplication BuildApp(
+ bool hasAdmin,
+ bool hasDriver,
+ int syncListenPort,
+ int configServeGrpcPort,
+ int telemetryListenPort)
+ {
+ var builder = WebApplication.CreateBuilder();
+ builder.Environment.ApplicationName = typeof(MeshGrpcAnonymousMetadataTests).Assembly.GetName().Name!;
+ builder.Services.AddGrpc();
+
+ var app = builder.Build();
+ app.MapOtOpcUaMeshGrpcServices(
+ hasAdmin, hasDriver, syncListenPort, configServeGrpcPort, telemetryListenPort);
+ return app;
+ }
+
+ ///
+ /// The real, callable RPC method endpoints — excludes grpc-dotnet's per-app "Unimplemented
+ /// service" catch-all and per-service "Unimplemented method for X" fallback (see class
+ /// remarks). Both carry a route pattern that never starts with /, which is what this
+ /// filters on.
+ ///
+ private static List RealMethodEndpoints(WebApplication app) =>
+ ((IEndpointRouteBuilder)app).DataSources
+ .SelectMany(d => d.Endpoints)
+ .OfType()
+ .Where(e => e.RoutePattern.RawText?.StartsWith('/') == true)
+ .ToList();
+
+ private static string RoutePatternOf(RouteEndpoint e) => e.RoutePattern.RawText!;
+}