diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
index d4e3003..7119c85 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliSecretRedactor.cs
@@ -1,18 +1,31 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli;
-/// Utility to redact API keys from error messages for safe output.
+/// Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output.
internal static class MxGatewayCliSecretRedactor
{
- /// Replaces occurrences of the API key in the value with a redacted placeholder.
+ ///
+ /// Replaces every occurrence of any supplied secret in the value with a
+ /// redacted placeholder. Null or empty secrets are ignored, so callers can
+ /// pass optional credentials without pre-filtering.
+ ///
/// The message text to redact.
- /// The API key to remove; no redaction if null or empty.
- public static string Redact(string value, string? apiKey)
+ /// The secret values to remove (API key, verify-user password, secured payloads).
+ public static string Redact(string value, params string?[] secrets)
{
- if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
+ if (string.IsNullOrEmpty(value) || secrets is null)
{
return value;
}
- return value.Replace(apiKey, "[redacted]", StringComparison.Ordinal);
+ string redacted = value;
+ foreach (string? secret in secrets)
+ {
+ if (!string.IsNullOrEmpty(secret))
+ {
+ redacted = redacted.Replace(secret, "[redacted]", StringComparison.Ordinal);
+ }
+ }
+
+ return redacted;
}
}
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
index e8d56a8..bb7250d 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs
@@ -112,6 +112,24 @@ public static class MxGatewayClientCli
.ConfigureAwait(false),
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
+ "unregister" => await UnregisterAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "add-buffered-item" => await AddBufferedItemAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "set-buffered-update-interval" => await SetBufferedUpdateIntervalAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "suspend" => await SuspendAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "activate" => await ActivateAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "write-secured" => await WriteSecuredAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "write-secured2" => await WriteSecured2Async(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "authenticate-user" => await AuthenticateUserAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
+ "archestra-user-to-id" => await ArchestraUserToIdAsync(arguments, client, standardOutput, cancellation.Token)
+ .ConfigureAwait(false),
"subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false),
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
@@ -157,9 +175,15 @@ public static class MxGatewayClientCli
{
// Client.Dotnet-028: redact the *effective* key — from --api-key or the
// --api-key-env environment variable — so an env-var-sourced key echoed
- // in a transport error never reaches stderr unredacted.
+ // in a transport error never reaches stderr unredacted. CLI-04: also
+ // redact MXAccess credentials (AuthenticateUser password, WriteSecured
+ // payloads) that could otherwise be echoed back in a surfaced error.
string? apiKey = TryResolveApiKey(arguments);
- string message = MxGatewayCliSecretRedactor.Redact(exception.Message, apiKey);
+ string message = MxGatewayCliSecretRedactor.Redact(
+ exception.Message,
+ apiKey,
+ TryResolveVerifyUserPassword(arguments),
+ arguments.GetOptional("value"));
if (forceJsonErrors || arguments.HasFlag("json"))
{
@@ -319,6 +343,48 @@ public static class MxGatewayClientCli
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
}
+ ///
+ /// Resolves the effective MXAccess verify-user credential from
+ /// --verify-user-password or, failing that, the
+ /// --verify-user-password-env-named environment variable (default
+ /// MXGATEWAY_VERIFY_USER_PASSWORD). The credential is never echoed;
+ /// this resolver exists so the error-redaction catch block can strip it
+ /// from any surfaced error (CLI-04), mirroring .
+ ///
+ private static string? TryResolveVerifyUserPassword(CliArguments arguments)
+ {
+ string? password = arguments.GetOptional("verify-user-password");
+ if (!string.IsNullOrEmpty(password))
+ {
+ return password;
+ }
+
+ string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
+ ?? "MXGATEWAY_VERIFY_USER_PASSWORD";
+
+ return Environment.GetEnvironmentVariable(passwordEnvironmentName);
+ }
+
+ ///
+ /// Resolves the verify-user credential for authenticate-user, throwing
+ /// a redaction-safe error when neither the flag nor the env var is set. The
+ /// thrown message names only the option/env var, never the value.
+ ///
+ private static string ResolveVerifyUserPassword(CliArguments arguments)
+ {
+ string? password = TryResolveVerifyUserPassword(arguments);
+ if (!string.IsNullOrEmpty(password))
+ {
+ return password;
+ }
+
+ string passwordEnvironmentName = arguments.GetOptional("verify-user-password-env")
+ ?? "MXGATEWAY_VERIFY_USER_PASSWORD";
+
+ throw new ArgumentException(
+ $"Verify-user password is required. Pass --verify-user-password or set {passwordEnvironmentName}.");
+ }
+
private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{
var cancellation = new CancellationTokenSource();
@@ -475,6 +541,215 @@ public static class MxGatewayClientCli
cancellationToken);
}
+ private static Task UnregisterAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.Unregister,
+ Unregister = new UnregisterCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task AddBufferedItemAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.AddBufferedItem,
+ AddBufferedItem = new AddBufferedItemCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ ItemDefinition = arguments.GetRequired("item"),
+ ItemContext = arguments.GetOptional("item-context") ?? string.Empty,
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task SetBufferedUpdateIntervalAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.SetBufferedUpdateInterval,
+ SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ UpdateIntervalMilliseconds = arguments.GetInt32("interval-ms"),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task SuspendAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.Suspend,
+ Suspend = new SuspendCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ ItemHandle = arguments.GetInt32("item-handle"),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task ActivateAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.Activate,
+ Activate = new ActivateCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ ItemHandle = arguments.GetInt32("item-handle"),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task WriteSecuredAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.WriteSecured,
+ WriteSecured = new WriteSecuredCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ ItemHandle = arguments.GetInt32("item-handle"),
+ CurrentUserId = arguments.GetInt32("current-user-id"),
+ VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
+ Value = ParseValue(arguments),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task WriteSecured2Async(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.WriteSecured2,
+ WriteSecured2 = new WriteSecured2Command
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ ItemHandle = arguments.GetInt32("item-handle"),
+ CurrentUserId = arguments.GetInt32("current-user-id"),
+ VerifierUserId = arguments.GetInt32("verifier-user-id", 0),
+ Value = ParseValue(arguments),
+ TimestampValue = ParseTimestampValue(arguments),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task AuthenticateUserAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ // The credential is resolved from --verify-user-password or its env var and
+ // is never echoed. On any surfaced error the RunCoreAsync catch block routes
+ // it through MxGatewayCliSecretRedactor so it cannot reach stderr (CLI-04).
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.AuthenticateUser,
+ AuthenticateUser = new AuthenticateUserCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ VerifyUser = arguments.GetRequired("verify-user"),
+ VerifyUserPassword = ResolveVerifyUserPassword(arguments),
+ },
+ },
+ cancellationToken);
+ }
+
+ private static Task ArchestraUserToIdAsync(
+ CliArguments arguments,
+ IMxGatewayCliClient client,
+ TextWriter output,
+ CancellationToken cancellationToken)
+ {
+ return InvokeAndWriteAsync(
+ arguments,
+ client,
+ output,
+ new MxCommand
+ {
+ Kind = MxCommandKind.ArchestraUserToId,
+ ArchestraUserToId = new ArchestrAUserToIdCommand
+ {
+ ServerHandle = arguments.GetInt32("server-handle"),
+ UserIdGuid = arguments.GetRequired("user-guid"),
+ },
+ },
+ cancellationToken);
+ }
+
private static Task SubscribeBulkAsync(
CliArguments arguments,
IMxGatewayCliClient client,
@@ -2029,6 +2304,15 @@ public static class MxGatewayClientCli
or "add-item"
or "advise"
or "advise-supervisory"
+ or "unregister"
+ or "add-buffered-item"
+ or "set-buffered-update-interval"
+ or "suspend"
+ or "activate"
+ or "write-secured"
+ or "write-secured2"
+ or "authenticate-user"
+ or "archestra-user-to-id"
or "subscribe-bulk"
or "unsubscribe-bulk"
or "read-bulk"
@@ -2092,6 +2376,15 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet add-item --session-id --server-handle --item [ [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id --server-handle --item-handle [--json]");
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id --server-handle --item-handle [--json]");
+ writer.WriteLine("mxgw-dotnet unregister --session-id --server-handle [--json]");
+ writer.WriteLine("mxgw-dotnet add-buffered-item --session-id --server-handle --item ][ [--item-context ]] [--json]");
+ writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id --server-handle --interval-ms [--json]");
+ writer.WriteLine("mxgw-dotnet suspend --session-id --server-handle --item-handle [--json]");
+ writer.WriteLine("mxgw-dotnet activate --session-id --server-handle --item-handle [--json]");
+ writer.WriteLine("mxgw-dotnet write-secured --session-id --server-handle --item-handle --type --value --current-user-id [--verifier-user-id ] [--json]");
+ writer.WriteLine("mxgw-dotnet write-secured2 --session-id --server-handle --item-handle --type --value --current-user-id [--verifier-user-id ] [--timestamp ] [--json]");
+ writer.WriteLine("mxgw-dotnet authenticate-user --session-id --server-handle --verify-user (--verify-user-password | --verify-user-password-env ) [--json]");
+ writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id --server-handle --user-guid [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id --server-handle --items [ [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id --server-handle --item-handles [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id --server-handle --items ][ [--timeout-ms ] [--json]");
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs
index bd39120..54642a7 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs
@@ -129,6 +129,120 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString());
}
+ /// Verifies that write-secured builds a WriteSecured command with the value and user ids.
+ [Fact]
+ public async Task RunAsync_WriteSecured_BuildsWriteSecuredCommand()
+ {
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ FakeCliClient fakeClient = new();
+ fakeClient.InvokeReplies.Enqueue(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.WriteSecured,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+
+ int exitCode = await MxGatewayClientCli.RunAsync(
+ [
+ "write-secured",
+ "--endpoint", "http://localhost:5000",
+ "--api-key", "test-api-key",
+ "--session-id", "session-fixture",
+ "--server-handle", "12",
+ "--item-handle", "34",
+ "--type", "int32",
+ "--value", "123",
+ "--current-user-id", "5",
+ "--verifier-user-id", "6",
+ "--json",
+ ],
+ output,
+ error,
+ _ => fakeClient);
+
+ Assert.Equal(0, exitCode);
+ MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
+ Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
+ Assert.Equal(123, request.Command.WriteSecured.Value.Int32Value);
+ Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
+ Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
+ Assert.Equal(string.Empty, error.ToString());
+ }
+
+ ///
+ /// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
+ /// credential from the flag, and that the credential never appears in stdout/stderr.
+ ///
+ [Fact]
+ public async Task RunAsync_AuthenticateUser_BuildsCommandAndDoesNotEchoCredential()
+ {
+ const string password = "cli-secret-credential-987";
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+ FakeCliClient fakeClient = new();
+ fakeClient.InvokeReplies.Enqueue(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.AuthenticateUser,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
+ });
+
+ int exitCode = await MxGatewayClientCli.RunAsync(
+ [
+ "authenticate-user",
+ "--endpoint", "http://localhost:5000",
+ "--api-key", "test-api-key",
+ "--session-id", "session-fixture",
+ "--server-handle", "12",
+ "--verify-user", "operator",
+ "--verify-user-password", password,
+ "--json",
+ ],
+ output,
+ error,
+ _ => fakeClient);
+
+ Assert.Equal(0, exitCode);
+ MxCommandRequest request = Assert.Single(fakeClient.InvokeRequests);
+ Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
+ Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
+ Assert.Equal(password, request.Command.AuthenticateUser.VerifyUserPassword);
+ Assert.DoesNotContain(password, output.ToString());
+ Assert.DoesNotContain(password, error.ToString());
+ }
+
+ ///
+ /// CLI-04: a surfaced error for authenticate-user must have the credential
+ /// redacted (never echoed to stderr), mirroring the API-key redaction seam.
+ ///
+ [Fact]
+ public async Task RunAsync_AuthenticateUser_ErrorOutput_RedactsCredential()
+ {
+ const string password = "leaky-credential-value";
+ using var output = new StringWriter();
+ using var error = new StringWriter();
+
+ int exitCode = await MxGatewayClientCli.RunAsync(
+ [
+ "authenticate-user",
+ "--endpoint", "http://localhost:5000",
+ "--api-key", "test-api-key",
+ "--session-id", "session-fixture",
+ "--server-handle", "12",
+ "--verify-user", "operator",
+ "--verify-user-password", password,
+ ],
+ output,
+ error,
+ _ => throw new InvalidOperationException($"boom {password}"));
+
+ Assert.Equal(1, exitCode);
+ Assert.DoesNotContain(password, error.ToString());
+ Assert.Contains("[redacted]", error.ToString());
+ }
+
/// Verifies that error output redacts sensitive API key values.
[Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey()
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs
index 1de4290..0b83237 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientSessionTests.cs
@@ -415,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(7, el.Value.Int32Value);
}
+ /// Verifies that unregister builds an unregister command with the server handle.
+ [Fact]
+ public async Task UnregisterAsync_BuildsUnregisterCommand()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.Unregister,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ await session.UnregisterAsync(12);
+
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.Unregister, request.Command.Kind);
+ Assert.Equal(12, request.Command.Unregister.ServerHandle);
+ }
+
+ /// Verifies that advise-supervisory builds the supervisory advise command.
+ [Fact]
+ public async Task AdviseSupervisoryAsync_BuildsAdviseSupervisoryCommand()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.AdviseSupervisory,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ await session.AdviseSupervisoryAsync(12, 34);
+
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.AdviseSupervisory, request.Command.Kind);
+ Assert.Equal(12, request.Command.AdviseSupervisory.ServerHandle);
+ Assert.Equal(34, request.Command.AdviseSupervisory.ItemHandle);
+ }
+
+ /// Verifies that add-buffered-item returns the item handle from the typed reply.
+ [Fact]
+ public async Task AddBufferedItemAsync_BuildsCommandAndReturnsItemHandle()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.AddBufferedItem,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ AddBufferedItem = new AddBufferedItemReply { ItemHandle = 77 },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ int itemHandle = await session.AddBufferedItemAsync(12, "Area001.Pump001.Speed", "runtime");
+
+ Assert.Equal(77, itemHandle);
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.AddBufferedItem, request.Command.Kind);
+ Assert.Equal(12, request.Command.AddBufferedItem.ServerHandle);
+ Assert.Equal("Area001.Pump001.Speed", request.Command.AddBufferedItem.ItemDefinition);
+ Assert.Equal("runtime", request.Command.AddBufferedItem.ItemContext);
+ }
+
+ /// Verifies that set-buffered-update-interval builds the command with the interval.
+ [Fact]
+ public async Task SetBufferedUpdateIntervalAsync_BuildsCommandWithInterval()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.SetBufferedUpdateInterval,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ await session.SetBufferedUpdateIntervalAsync(12, 500);
+
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.SetBufferedUpdateInterval, request.Command.Kind);
+ Assert.Equal(12, request.Command.SetBufferedUpdateInterval.ServerHandle);
+ Assert.Equal(500, request.Command.SetBufferedUpdateInterval.UpdateIntervalMilliseconds);
+ }
+
+ /// Verifies that suspend builds the command and returns the reply status.
+ [Fact]
+ public async Task SuspendAsync_BuildsCommandAndReturnsStatus()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.Suspend,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ Suspend = new SuspendReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ MxStatusProxy? status = await session.SuspendAsync(12, 34);
+
+ Assert.NotNull(status);
+ Assert.Equal(1, status!.Success);
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.Suspend, request.Command.Kind);
+ Assert.Equal(12, request.Command.Suspend.ServerHandle);
+ Assert.Equal(34, request.Command.Suspend.ItemHandle);
+ }
+
+ /// Verifies that activate builds the command and returns the reply status.
+ [Fact]
+ public async Task ActivateAsync_BuildsCommandAndReturnsStatus()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.Activate,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ Activate = new ActivateReply { Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok } },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ MxStatusProxy? status = await session.ActivateAsync(12, 34);
+
+ Assert.NotNull(status);
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.Activate, request.Command.Kind);
+ Assert.Equal(34, request.Command.Activate.ItemHandle);
+ }
+
+ /// Verifies that write-secured builds a WriteSecured command with value and user ids.
+ [Fact]
+ public async Task WriteSecuredAsync_BuildsWriteSecuredCommand()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.WriteSecured,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+ MxValue value = 123.ToMxValue();
+
+ await session.WriteSecuredAsync(12, 34, value, currentUserId: 5, verifierUserId: 6);
+
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.WriteSecured, request.Command.Kind);
+ Assert.Equal(12, request.Command.WriteSecured.ServerHandle);
+ Assert.Equal(34, request.Command.WriteSecured.ItemHandle);
+ Assert.Same(value, request.Command.WriteSecured.Value);
+ Assert.Equal(5, request.Command.WriteSecured.CurrentUserId);
+ Assert.Equal(6, request.Command.WriteSecured.VerifierUserId);
+ }
+
+ ///
+ /// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
+ /// natively. The client must surface that scripted failure (as an
+ /// ) rather than pre-validating it away.
+ ///
+ [Fact]
+ public async Task WriteSecuredAsync_SurfacesNativeFailureWhenNotAuthenticated()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.WriteSecured,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
+ Hresult = unchecked((int)0x80040200),
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ MxAccessException exception = await Assert.ThrowsAsync(
+ async () => await session.WriteSecuredAsync(12, 34, 123.ToMxValue(), currentUserId: 0, verifierUserId: 0));
+
+ // The native HRESULT is surfaced; the request payload is never in the message.
+ Assert.Contains("WriteSecured", exception.Message);
+ Assert.Single(transport.InvokeCalls);
+ }
+
+ /// Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.
+ [Fact]
+ public async Task WriteSecured2Async_BuildsWriteSecured2Command()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.WriteSecured2,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+ MxValue value = 123.ToMxValue();
+ MxValue timestampValue = DateTimeOffset.Parse("2026-01-01T00:00:00Z").ToMxValue();
+
+ await session.WriteSecured2Async(12, 34, value, timestampValue, currentUserId: 5, verifierUserId: 6);
+
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.WriteSecured2, request.Command.Kind);
+ Assert.Same(value, request.Command.WriteSecured2.Value);
+ Assert.Same(timestampValue, request.Command.WriteSecured2.TimestampValue);
+ Assert.Equal(5, request.Command.WriteSecured2.CurrentUserId);
+ Assert.Equal(6, request.Command.WriteSecured2.VerifierUserId);
+ }
+
+ /// Verifies that authenticate-user builds the command and returns the resolved user id.
+ [Fact]
+ public async Task AuthenticateUserAsync_BuildsCommandAndReturnsUserId()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.AuthenticateUser,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ AuthenticateUser = new AuthenticateUserReply { UserId = 4242 },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ int userId = await session.AuthenticateUserAsync(12, "operator", "s3cr3t-p@ss");
+
+ Assert.Equal(4242, userId);
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.AuthenticateUser, request.Command.Kind);
+ Assert.Equal("operator", request.Command.AuthenticateUser.VerifyUser);
+ Assert.Equal("s3cr3t-p@ss", request.Command.AuthenticateUser.VerifyUserPassword);
+ }
+
+ ///
+ /// SECRET REDACTION: when AuthenticateUser fails, the surfaced exception message
+ /// must never contain the credential — the error path is built only from
+ /// reply-derived diagnostics, not the request payload.
+ ///
+ [Fact]
+ public async Task AuthenticateUserAsync_FailureDoesNotLeakCredentialInErrorMessage()
+ {
+ const string password = "super-secret-credential-123";
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.AuthenticateUser,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.MxaccessFailure },
+ Hresult = unchecked((int)0x80040210),
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ MxAccessException exception = await Assert.ThrowsAsync(
+ async () => await session.AuthenticateUserAsync(12, "operator", password));
+
+ Assert.DoesNotContain(password, exception.Message);
+ Assert.DoesNotContain(password, exception.ToString());
+ }
+
+ /// Verifies that archestra-user-to-id builds the command and returns the resolved user id.
+ [Fact]
+ public async Task ArchestraUserToIdAsync_BuildsCommandAndReturnsUserId()
+ {
+ FakeGatewayTransport transport = CreateTransport();
+ transport.AddInvokeReply(new MxCommandReply
+ {
+ SessionId = "session-fixture",
+ Kind = MxCommandKind.ArchestraUserToId,
+ ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
+ ArchestraUserToId = new ArchestrAUserToIdReply { UserId = 909 },
+ });
+ await using MxGatewayClient client = CreateClient(transport);
+ MxGatewaySession session = await client.OpenSessionAsync();
+
+ int userId = await session.ArchestraUserToIdAsync(12, "BCC47053-9542-4D65-BDAA-BCDEA6A32A73");
+
+ Assert.Equal(909, userId);
+ MxCommandRequest request = Assert.Single(transport.InvokeCalls).Request;
+ Assert.Equal(MxCommandKind.ArchestraUserToId, request.Command.Kind);
+ Assert.Equal("BCC47053-9542-4D65-BDAA-BCDEA6A32A73", request.Command.ArchestraUserToId.UserIdGuid);
+ }
+
private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{
return new MxGatewayClient(transport.Options, transport);
diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs
index 1740a96..a8ca738 100644
--- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs
+++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client/MxGatewaySession.cs
@@ -842,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken);
}
+ ///
+ /// Unregisters a previously registered client from the MXAccess session
+ /// (MXAccess Unregister), releasing its ServerHandle.
+ ///
+ /// The ServerHandle from register.
+ /// Cancellation token.
+ public async Task UnregisterAsync(
+ int serverHandle,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ }
+
+ ///
+ /// Unregisters a previously registered client without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task UnregisterRawAsync(
+ int serverHandle,
+ CancellationToken cancellationToken = default)
+ {
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.Unregister,
+ Unregister = new UnregisterCommand { ServerHandle = serverHandle },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Subscribes to supervisory events for an item (MXAccess AdviseSupervisory).
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ public async Task AdviseSupervisoryAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ }
+
+ ///
+ /// Subscribes to supervisory events for an item without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task AdviseSupervisoryRawAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.AdviseSupervisory,
+ AdviseSupervisory = new AdviseSupervisoryCommand
+ {
+ ServerHandle = serverHandle,
+ ItemHandle = itemHandle,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Adds a buffered item to the MXAccess session (MXAccess AddBufferedItem),
+ /// returning an ItemHandle.
+ ///
+ /// The ServerHandle from register.
+ /// The item tag address.
+ /// Additional context for the item.
+ /// Cancellation token.
+ /// The item handle assigned to the new buffered item.
+ public async Task AddBufferedItemAsync(
+ int serverHandle,
+ string itemDefinition,
+ string itemContext,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await AddBufferedItemRawAsync(
+ serverHandle,
+ itemDefinition,
+ itemContext,
+ cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ return reply.AddBufferedItem?.ItemHandle ?? reply.ReturnValue.Int32Value;
+ }
+
+ ///
+ /// Adds a buffered item to the MXAccess session without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The item tag address.
+ /// Additional context for the item.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task AddBufferedItemRawAsync(
+ int serverHandle,
+ string itemDefinition,
+ string itemContext,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(itemDefinition);
+
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.AddBufferedItem,
+ AddBufferedItem = new AddBufferedItemCommand
+ {
+ ServerHandle = serverHandle,
+ ItemDefinition = itemDefinition,
+ ItemContext = itemContext ?? string.Empty,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Sets the buffered-item update interval on the MXAccess session
+ /// (MXAccess SetBufferedUpdateInterval).
+ ///
+ /// The ServerHandle from register.
+ /// The buffered update interval, in milliseconds.
+ /// Cancellation token.
+ public async Task SetBufferedUpdateIntervalAsync(
+ int serverHandle,
+ int updateIntervalMilliseconds,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync(
+ serverHandle,
+ updateIntervalMilliseconds,
+ cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ }
+
+ ///
+ /// Sets the buffered-item update interval without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The buffered update interval, in milliseconds.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task SetBufferedUpdateIntervalRawAsync(
+ int serverHandle,
+ int updateIntervalMilliseconds,
+ CancellationToken cancellationToken = default)
+ {
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.SetBufferedUpdateInterval,
+ SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
+ {
+ ServerHandle = serverHandle,
+ UpdateIntervalMilliseconds = updateIntervalMilliseconds,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Suspends updates for an item (MXAccess Suspend).
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ /// The item's MXSTATUS_PROXY as reported by the worker, or null if the reply omitted it.
+ public async Task SuspendAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ return reply.Suspend?.Status;
+ }
+
+ ///
+ /// Suspends updates for an item without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task SuspendRawAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.Suspend,
+ Suspend = new SuspendCommand
+ {
+ ServerHandle = serverHandle,
+ ItemHandle = itemHandle,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Resumes updates for a suspended item (MXAccess Activate).
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ /// The item's MXSTATUS_PROXY as reported by the worker, or null if the reply omitted it.
+ public async Task ActivateAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ return reply.Activate?.Status;
+ }
+
+ ///
+ /// Resumes updates for a suspended item without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task ActivateRawAsync(
+ int serverHandle,
+ int itemHandle,
+ CancellationToken cancellationToken = default)
+ {
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.Activate,
+ Activate = new ActivateCommand
+ {
+ ServerHandle = serverHandle,
+ ItemHandle = itemHandle,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Writes a secured value to an item on the MXAccess server (MXAccess WriteSecured).
+ ///
+ ///
+ /// MXAccess parity: WriteSecured fails when it is issued before a value-bearing
+ /// NMX body or before a prior AuthenticateUser + AdviseSupervisory. That
+ /// native failure is surfaced unchanged — the client does not pre-validate or reorder it.
+ /// The is credential-sensitive and must never reach logs; the
+ /// client mirrors the single-item WriteSecured redaction contract.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// The secured value to write.
+ /// The current operator user id.
+ /// The verifier (secondary approver) user id.
+ /// Cancellation token.
+ public async Task WriteSecuredAsync(
+ int serverHandle,
+ int itemHandle,
+ MxValue value,
+ int currentUserId,
+ int verifierUserId,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await WriteSecuredRawAsync(
+ serverHandle,
+ itemHandle,
+ value,
+ currentUserId,
+ verifierUserId,
+ cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ }
+
+ ///
+ /// Writes a secured value to an item without error checking. See
+ /// for the parity and redaction contract.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// The secured value to write.
+ /// The current operator user id.
+ /// The verifier (secondary approver) user id.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task WriteSecuredRawAsync(
+ int serverHandle,
+ int itemHandle,
+ MxValue value,
+ int currentUserId,
+ int verifierUserId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.WriteSecured,
+ WriteSecured = new WriteSecuredCommand
+ {
+ ServerHandle = serverHandle,
+ ItemHandle = itemHandle,
+ CurrentUserId = currentUserId,
+ VerifierUserId = verifierUserId,
+ Value = value,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Writes a secured value and timestamp to an item (MXAccess WriteSecured2).
+ ///
+ ///
+ /// Same parity and redaction contract as : the native
+ /// failure that occurs before a value-bearing NMX body or a prior authenticate is
+ /// surfaced unchanged, and the credential-sensitive must never
+ /// reach logs.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// The secured value to write.
+ /// The timestamp to write with the value.
+ /// The current operator user id.
+ /// The verifier (secondary approver) user id.
+ /// Cancellation token.
+ public async Task WriteSecured2Async(
+ int serverHandle,
+ int itemHandle,
+ MxValue value,
+ MxValue timestampValue,
+ int currentUserId,
+ int verifierUserId,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await WriteSecured2RawAsync(
+ serverHandle,
+ itemHandle,
+ value,
+ timestampValue,
+ currentUserId,
+ verifierUserId,
+ cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ }
+
+ ///
+ /// Writes a secured value and timestamp to an item without error checking. See
+ /// for the parity and redaction contract.
+ ///
+ /// The ServerHandle from register.
+ /// The ItemHandle from add-item.
+ /// The secured value to write.
+ /// The timestamp to write with the value.
+ /// The current operator user id.
+ /// The verifier (secondary approver) user id.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task WriteSecured2RawAsync(
+ int serverHandle,
+ int itemHandle,
+ MxValue value,
+ MxValue timestampValue,
+ int currentUserId,
+ int verifierUserId,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ ArgumentNullException.ThrowIfNull(timestampValue);
+
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.WriteSecured2,
+ WriteSecured2 = new WriteSecured2Command
+ {
+ ServerHandle = serverHandle,
+ ItemHandle = itemHandle,
+ CurrentUserId = currentUserId,
+ VerifierUserId = verifierUserId,
+ Value = value,
+ TimestampValue = timestampValue,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Authenticates an MXAccess verify-user (MXAccess AuthenticateUser), returning
+ /// the resolved user id used by WriteSecured / WriteSecured2.
+ ///
+ ///
+ /// The is a raw MXAccess credential. It is never
+ /// logged and never placed on the exception path: gateway/MXAccess failures surface only
+ /// reply-derived diagnostics (kind, HRESULT, MXSTATUS_PROXY), never the request payload.
+ ///
+ /// The ServerHandle from register.
+ /// The user to verify.
+ /// The verify-user credential. Never logged.
+ /// Cancellation token.
+ /// The authenticated user id.
+ public async Task AuthenticateUserAsync(
+ int serverHandle,
+ string verifyUser,
+ string verifyUserPassword,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await AuthenticateUserRawAsync(
+ serverHandle,
+ verifyUser,
+ verifyUserPassword,
+ cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ return reply.AuthenticateUser?.UserId ?? reply.ReturnValue.Int32Value;
+ }
+
+ ///
+ /// Authenticates an MXAccess verify-user without error checking. See
+ /// for the credential-handling contract.
+ ///
+ /// The ServerHandle from register.
+ /// The user to verify.
+ /// The verify-user credential. Never logged.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task AuthenticateUserRawAsync(
+ int serverHandle,
+ string verifyUser,
+ string verifyUserPassword,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(verifyUser);
+ ArgumentNullException.ThrowIfNull(verifyUserPassword);
+
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.AuthenticateUser,
+ AuthenticateUser = new AuthenticateUserCommand
+ {
+ ServerHandle = serverHandle,
+ VerifyUser = verifyUser,
+ VerifyUserPassword = verifyUserPassword,
+ },
+ },
+ cancellationToken);
+ }
+
+ ///
+ /// Resolves an ArchestrA user GUID to its MXAccess user id
+ /// (MXAccess ArchestrAUserToId).
+ ///
+ /// The ServerHandle from register.
+ /// The ArchestrA user GUID to resolve.
+ /// Cancellation token.
+ /// The resolved MXAccess user id.
+ public async Task ArchestraUserToIdAsync(
+ int serverHandle,
+ string userIdGuid,
+ CancellationToken cancellationToken = default)
+ {
+ MxCommandReply reply = await ArchestraUserToIdRawAsync(serverHandle, userIdGuid, cancellationToken)
+ .ConfigureAwait(false);
+ reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
+ return reply.ArchestraUserToId?.UserId ?? reply.ReturnValue.Int32Value;
+ }
+
+ ///
+ /// Resolves an ArchestrA user GUID to its MXAccess user id without error checking.
+ ///
+ /// The ServerHandle from register.
+ /// The ArchestrA user GUID to resolve.
+ /// Cancellation token.
+ /// The raw server reply.
+ public Task ArchestraUserToIdRawAsync(
+ int serverHandle,
+ string userIdGuid,
+ CancellationToken cancellationToken = default)
+ {
+ ArgumentException.ThrowIfNullOrWhiteSpace(userIdGuid);
+
+ return InvokeCommandAsync(
+ new MxCommand
+ {
+ Kind = MxCommandKind.ArchestraUserToId,
+ ArchestraUserToId = new ArchestrAUserToIdCommand
+ {
+ ServerHandle = serverHandle,
+ UserIdGuid = userIdGuid,
+ },
+ },
+ cancellationToken);
+ }
+
///
/// Invokes an MXAccess command on this session.
///
diff --git a/clients/go/README.md b/clients/go/README.md
index 2adfec4..c76cbbe 100644
--- a/clients/go/README.md
+++ b/clients/go/README.md
@@ -84,7 +84,9 @@ true` to verify against the OS/system trust roots without pinning. See
[Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`,
-`AddItem`, `AddItem2`, `Advise`, `Write`, `Events`, and `Close`. Prefer
+`AddItem`, `AddItem2`, `Advise`, `AdviseSupervisory`, `Write`, `WriteSecured`,
+`WriteSecured2`, `AuthenticateUser`, `ArchestrAUserToId`, `AddBufferedItem`,
+`SetBufferedUpdateInterval`, `Suspend`, `Activate`, `Events`, and `Close`. Prefer
`SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the
@@ -151,29 +153,32 @@ still need the write attributed to a user id, you must first advise the item
supervisory and then pass that user id on the write. Without the supervisory
advise the `userID` on a plain write is ignored.
-The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it
-through the generic command channel:
+The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
```go
-_, err := client.Invoke(ctx, &pb.MxCommandRequest{
- SessionId: session.ID(),
- Command: &pb.MxCommand{
- Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
- Payload: &pb.MxCommand_AdviseSupervisory{
- AdviseSupervisory: &pb.AdviseSupervisoryCommand{
- ServerHandle: serverHandle,
- ItemHandle: itemHandle,
- },
- },
- },
-})
-
+err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
+// ...
err = session.Write(ctx, serverHandle, itemHandle, value, userID)
```
The CLI exposes the same command as `advise-supervisory`, and `write`
takes `-user-id`.
+### Secured writes and user authentication
+
+The verified/secured path has typed single-item helpers too:
+`AuthenticateUser(ctx, serverHandle, verifyUser, verifyUserPassword)` returns the
+resolved MXAccess user id, and `WriteSecured` / `WriteSecured2` issue the secured
+write. Credentials passed to `AuthenticateUser`, and the string content of a
+`WriteSecured`/`WriteSecured2` value, are kept out of any error the client
+surfaces (they route through the same redaction seam as the API key) and are
+never logged — callers must likewise keep them out of their own logs. MXAccess
+parity holds: a `WriteSecured` issued without a matching prior `AuthenticateUser`
+and supervisory advise fails natively, and that failure is surfaced unchanged
+rather than pre-empted. The CLI exposes `authenticate-user` (credential via
+`-password-env`, default `MXGATEWAY_VERIFY_PASSWORD`, or `-password`) and
+`write-secured`.
+
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
@@ -378,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags
| `unsubscribe-bulk` | Unadvise many item handles in one call. |
| `read-bulk` | Read snapshots for many item handles in one call. |
| `write` | Write one value (`-type`, `-value`). |
+| `write-secured` | Secured single-item write (`-current-user-id`, `-verifier-user-id`, `-type`, `-value`). |
+| `authenticate-user` | Authenticate a user, printing the resolved user id (`-verify-user`, `-password-env`/`-password`). |
| `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
diff --git a/clients/go/cmd/mxgw-go/main.go b/clients/go/cmd/mxgw-go/main.go
index 4d8e4c8..3a25ade 100644
--- a/clients/go/cmd/mxgw-go/main.go
+++ b/clients/go/cmd/mxgw-go/main.go
@@ -21,7 +21,6 @@ import (
"syscall"
"time"
- pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect"
@@ -90,6 +89,10 @@ func runWithIO(ctx context.Context, args []string, stdout, stderr io.Writer) err
return runAdvise(ctx, args[1:], stdout, stderr)
case "advise-supervisory":
return runAdviseSupervisory(ctx, args[1:], stdout, stderr)
+ case "write-secured":
+ return runWriteSecured(ctx, args[1:], stdout, stderr)
+ case "authenticate-user":
+ return runAuthenticateUser(ctx, args[1:], stdout, stderr)
case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk":
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
}
defer client.Close()
- reply, err := client.Invoke(ctx, &pb.MxCommandRequest{
- SessionId: *sessionID,
- Command: &pb.MxCommand{
- Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
- Payload: &pb.MxCommand_AdviseSupervisory{
- AdviseSupervisory: &pb.AdviseSupervisoryCommand{
- ServerHandle: int32(*serverHandle),
- ItemHandle: int32(*itemHandle),
- },
- },
- },
- })
+ session := mxgateway.NewSessionForID(client, *sessionID)
+ reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err)
}
+func runWriteSecured(ctx context.Context, args []string, stdout, stderr io.Writer) error {
+ flags := flag.NewFlagSet("write-secured", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ common := bindCommonFlags(flags)
+ jsonOutput := flags.Bool("json", false, "write JSON output")
+ sessionID := flags.String("session-id", "", "gateway session id")
+ serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
+ itemHandle := flags.Int("item-handle", 0, "MXAccess item handle")
+ currentUserID := flags.Int("current-user-id", 0, "MXAccess current user id")
+ verifierUserID := flags.Int("verifier-user-id", 0, "MXAccess verifier user id")
+ valueType := flags.String("type", "string", "value type: bool, int32, int64, float, double, string")
+ valueText := flags.String("value", "", "value text")
+
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if *sessionID == "" {
+ return errors.New("session-id is required")
+ }
+
+ value, err := parseValue(*valueType, *valueText)
+ if err != nil {
+ return err
+ }
+
+ client, options, err := dialForCommand(ctx, common)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+
+ session := mxgateway.NewSessionForID(client, *sessionID)
+ reply, err := session.WriteSecuredRaw(ctx, int32(*serverHandle), int32(*itemHandle), int32(*currentUserID), int32(*verifierUserID), value)
+ return writeCommandOutput(stdout, *jsonOutput, "write-secured", options, reply, err)
+}
+
+func runAuthenticateUser(ctx context.Context, args []string, stdout, stderr io.Writer) error {
+ flags := flag.NewFlagSet("authenticate-user", flag.ContinueOnError)
+ flags.SetOutput(stderr)
+ common := bindCommonFlags(flags)
+ jsonOutput := flags.Bool("json", false, "write JSON output")
+ sessionID := flags.String("session-id", "", "gateway session id")
+ serverHandle := flags.Int("server-handle", 0, "MXAccess server handle")
+ verifyUser := flags.String("verify-user", "", "MXAccess user to authenticate")
+ // The credential is never accepted echoed on the command line by default:
+ // prefer the environment variable so it stays out of shell history and the
+ // process table. The -password flag remains for non-interactive scripting.
+ password := flags.String("password", "", "verify-user password (prefer -password-env)")
+ passwordEnv := flags.String("password-env", "MXGATEWAY_VERIFY_PASSWORD", "environment variable containing the verify-user password")
+
+ if err := flags.Parse(args); err != nil {
+ return err
+ }
+ if *sessionID == "" {
+ return errors.New("session-id is required")
+ }
+ if *verifyUser == "" {
+ return errors.New("verify-user is required")
+ }
+
+ resolvedPassword := *password
+ if resolvedPassword == "" && *passwordEnv != "" {
+ resolvedPassword = os.Getenv(*passwordEnv)
+ }
+
+ client, options, err := dialForCommand(ctx, common)
+ if err != nil {
+ return err
+ }
+ defer client.Close()
+
+ session := mxgateway.NewSessionForID(client, *sessionID)
+ // The raw reply carries only the resolved user id, never the credential, so
+ // writeCommandOutput can render it as-is; the credential is additionally
+ // scrubbed from any surfaced error by AuthenticateUserRaw.
+ reply, err := session.AuthenticateUserRaw(ctx, int32(*serverHandle), *verifyUser, resolvedPassword)
+ return writeCommandOutput(stdout, *jsonOutput, "authenticate-user", options, reply, err)
+}
+
func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr)
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
}
func writeUsage(writer io.Writer) {
- fmt.Fprintln(writer, "usage: mxgw-go ")
+ fmt.Fprintln(writer, "usage: mxgw-go ")
}
// batchEOR is the end-of-result sentinel emitted to stdout after every command
diff --git a/clients/go/cmd/mxgw-go/main_test.go b/clients/go/cmd/mxgw-go/main_test.go
index f78879b..b3102d8 100644
--- a/clients/go/cmd/mxgw-go/main_test.go
+++ b/clients/go/cmd/mxgw-go/main_test.go
@@ -568,6 +568,36 @@ func TestRunAdviseSupervisoryRequiresSessionID(t *testing.T) {
}
}
+// TestRunWriteSecuredRequiresSessionID pins the write-secured session-id guard so
+// it fails fast before dialing.
+func TestRunWriteSecuredRequiresSessionID(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ err := runWithIO(t.Context(), []string{"write-secured", "-plaintext", "-api-key", "test"}, &stdout, &stderr)
+ if err == nil || !strings.Contains(err.Error(), "session-id is required") {
+ t.Fatalf("write-secured without -session-id error = %v", err)
+ }
+}
+
+// TestRunAuthenticateUserRequiresVerifyUser pins that authenticate-user fails fast
+// (before dialing) when the user is absent, and never echoes any credential in
+// the guard error.
+func TestRunAuthenticateUserRequiresVerifyUser(t *testing.T) {
+ var stdout, stderr bytes.Buffer
+ err := runWithIO(t.Context(), []string{
+ "authenticate-user",
+ "-session-id", "s1",
+ "-password", "hunter2-password",
+ "-plaintext",
+ "-api-key", "test",
+ }, &stdout, &stderr)
+ if err == nil || !strings.Contains(err.Error(), "verify-user is required") {
+ t.Fatalf("authenticate-user without -verify-user error = %v", err)
+ }
+ if strings.Contains(err.Error(), "hunter2-password") {
+ t.Fatalf("authenticate-user guard error leaked the credential: %v", err)
+ }
+}
+
// TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial.
diff --git a/clients/go/mxgateway/errors.go b/clients/go/mxgateway/errors.go
index 778e56e..572716d 100644
--- a/clients/go/mxgateway/errors.go
+++ b/clients/go/mxgateway/errors.go
@@ -3,10 +3,68 @@ package mxgateway
import (
"errors"
"fmt"
+ "strings"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
)
+// redactedSecretMarker is the placeholder substituted for credential material in
+// surfaced error text. It matches the marker used by RedactAPIKey so the client
+// presents one consistent redaction shape everywhere secrets could otherwise
+// leak.
+const redactedSecretMarker = ""
+
+// secretRedactingError wraps a typed error so any occurrence of a known
+// credential in the underlying message is replaced with redactedSecretMarker in
+// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
+// errors.Is continue to reach the underlying MxAccessError, CommandError, or
+// GatewayError. This is the seam that keeps AuthenticateUser credentials and
+// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
+// even if a gateway diagnostic message were to echo them back.
+type secretRedactingError struct {
+ err error
+ secrets []string
+}
+
+// Error returns the wrapped error's message with every non-empty secret redacted.
+func (e *secretRedactingError) Error() string {
+ if e == nil || e.err == nil {
+ return ""
+ }
+ message := e.err.Error()
+ for _, secret := range e.secrets {
+ if secret != "" {
+ message = strings.ReplaceAll(message, secret, redactedSecretMarker)
+ }
+ }
+ return message
+}
+
+// Unwrap returns the wrapped error so typed-error inspection still works through
+// the redaction wrapper.
+func (e *secretRedactingError) Unwrap() error {
+ if e == nil {
+ return nil
+ }
+ return e.err
+}
+
+// redactSecrets wraps err so any occurrence of a non-empty secret in the surfaced
+// message is redacted, while errors.As / errors.Is still reach the wrapped typed
+// error. It returns nil unchanged and skips wrapping when no non-empty secret is
+// supplied, so non-secret-bearing calls keep their original error verbatim.
+func redactSecrets(err error, secrets ...string) error {
+ if err == nil {
+ return nil
+ }
+ for _, secret := range secrets {
+ if secret != "" {
+ return &secretRedactingError{err: err, secrets: secrets}
+ }
+ }
+ return err
+}
+
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
// (cancel-when-full) path when the buffered results channel overflows because
// the consumer fell behind. It is delivered as the final EventResult.Err before
diff --git a/clients/go/mxgateway/session.go b/clients/go/mxgateway/session.go
index c58e00f..7e920aa 100644
--- a/clients/go/mxgateway/session.go
+++ b/clients/go/mxgateway/session.go
@@ -706,6 +706,277 @@ func (s *Session) Write2Raw(ctx context.Context, serverHandle, itemHandle int32,
})
}
+// AdviseSupervisory invokes MXAccess AdviseSupervisory, advising an item on the
+// supervisory (as opposed to runtime) data path.
+func (s *Session) AdviseSupervisory(ctx context.Context, serverHandle, itemHandle int32) error {
+ _, err := s.AdviseSupervisoryRaw(ctx, serverHandle, itemHandle)
+ return err
+}
+
+// AdviseSupervisoryRaw invokes MXAccess AdviseSupervisory and returns the raw reply.
+func (s *Session) AdviseSupervisoryRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
+ Payload: &pb.MxCommand_AdviseSupervisory{
+ AdviseSupervisory: &pb.AdviseSupervisoryCommand{
+ ServerHandle: serverHandle,
+ ItemHandle: itemHandle,
+ },
+ },
+ })
+}
+
+// WriteSecured invokes MXAccess WriteSecured (secured single-item write).
+//
+// The value is credential-sensitive: callers must not log it, and any error this
+// call surfaces has the value's string content redacted (see WriteSecuredRaw).
+// MXAccess parity is preserved — WriteSecured legitimately fails when it is not
+// preceded by a matching AuthenticateUser + AdviseSupervisory or when the body
+// carries no value; that native failure is surfaced as-is, never pre-empted or
+// reordered by this client.
+func (s *Session) WriteSecured(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) error {
+ _, err := s.WriteSecuredRaw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value)
+ return err
+}
+
+// WriteSecuredRaw invokes MXAccess WriteSecured and returns the raw reply. Any
+// surfaced error is routed through the client's secret-redaction seam so a string
+// write value never appears in error text.
+func (s *Session) WriteSecuredRaw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value *MxValue) (*MxCommandReply, error) {
+ if value == nil {
+ return nil, errors.New("mxgateway: write-secured value is required")
+ }
+
+ reply, err := s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
+ Payload: &pb.MxCommand_WriteSecured{
+ WriteSecured: &pb.WriteSecuredCommand{
+ ServerHandle: serverHandle,
+ ItemHandle: itemHandle,
+ CurrentUserId: currentUserID,
+ VerifierUserId: verifierUserID,
+ Value: value,
+ },
+ },
+ })
+ return reply, redactSecrets(err, stringSecrets(value)...)
+}
+
+// WriteSecured2 invokes MXAccess WriteSecured2 (secured, timestamped single-item write).
+//
+// Like WriteSecured, the value is credential-sensitive and its string content is
+// scrubbed from any surfaced error. Native parity failures (missing prior
+// AuthenticateUser/AdviseSupervisory, value-less body) are surfaced unchanged.
+func (s *Session) WriteSecured2(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) error {
+ _, err := s.WriteSecured2Raw(ctx, serverHandle, itemHandle, currentUserID, verifierUserID, value, timestampValue)
+ return err
+}
+
+// WriteSecured2Raw invokes MXAccess WriteSecured2 and returns the raw reply. Any
+// surfaced error is routed through the client's secret-redaction seam.
+func (s *Session) WriteSecured2Raw(ctx context.Context, serverHandle, itemHandle, currentUserID, verifierUserID int32, value, timestampValue *MxValue) (*MxCommandReply, error) {
+ if value == nil {
+ return nil, errors.New("mxgateway: write-secured2 value is required")
+ }
+ if timestampValue == nil {
+ return nil, errors.New("mxgateway: write-secured2 timestamp value is required")
+ }
+
+ reply, err := s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED2,
+ Payload: &pb.MxCommand_WriteSecured2{
+ WriteSecured2: &pb.WriteSecured2Command{
+ ServerHandle: serverHandle,
+ ItemHandle: itemHandle,
+ CurrentUserId: currentUserID,
+ VerifierUserId: verifierUserID,
+ Value: value,
+ TimestampValue: timestampValue,
+ },
+ },
+ })
+ return reply, redactSecrets(err, stringSecrets(value)...)
+}
+
+// AuthenticateUser invokes MXAccess AuthenticateUser and returns the resolved
+// MXAccess user id.
+//
+// verifyUserPassword is a raw MXAccess credential: this client never logs it and
+// scrubs it from any error it surfaces (see AuthenticateUserRaw). Callers must
+// likewise keep it out of their own logs, metrics, and diagnostics.
+func (s *Session) AuthenticateUser(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (int32, error) {
+ reply, err := s.AuthenticateUserRaw(ctx, serverHandle, verifyUser, verifyUserPassword)
+ if err != nil {
+ return 0, err
+ }
+ if reply.GetAuthenticateUser() != nil {
+ return reply.GetAuthenticateUser().GetUserId(), nil
+ }
+ return reply.GetReturnValue().GetInt32Value(), nil
+}
+
+// AuthenticateUserRaw invokes MXAccess AuthenticateUser and returns the raw
+// reply. The credential is scrubbed from any surfaced error via the client's
+// secret-redaction seam, so even a gateway diagnostic echoing the password back
+// cannot leak it through this call's error.
+func (s *Session) AuthenticateUserRaw(ctx context.Context, serverHandle int32, verifyUser, verifyUserPassword string) (*MxCommandReply, error) {
+ if verifyUser == "" {
+ return nil, errors.New("mxgateway: verify user is required")
+ }
+
+ reply, err := s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
+ Payload: &pb.MxCommand_AuthenticateUser{
+ AuthenticateUser: &pb.AuthenticateUserCommand{
+ ServerHandle: serverHandle,
+ VerifyUser: verifyUser,
+ VerifyUserPassword: verifyUserPassword,
+ },
+ },
+ })
+ return reply, redactSecrets(err, verifyUserPassword)
+}
+
+// ArchestrAUserToId invokes MXAccess ArchestrAUserToId, resolving an ArchestrA
+// user GUID to its MXAccess integer user id.
+func (s *Session) ArchestrAUserToId(ctx context.Context, serverHandle int32, userIDGuid string) (int32, error) {
+ reply, err := s.ArchestrAUserToIdRaw(ctx, serverHandle, userIDGuid)
+ if err != nil {
+ return 0, err
+ }
+ if reply.GetArchestraUserToId() != nil {
+ return reply.GetArchestraUserToId().GetUserId(), nil
+ }
+ return reply.GetReturnValue().GetInt32Value(), nil
+}
+
+// ArchestrAUserToIdRaw invokes MXAccess ArchestrAUserToId and returns the raw reply.
+func (s *Session) ArchestrAUserToIdRaw(ctx context.Context, serverHandle int32, userIDGuid string) (*MxCommandReply, error) {
+ if userIDGuid == "" {
+ return nil, errors.New("mxgateway: user id GUID is required")
+ }
+
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
+ Payload: &pb.MxCommand_ArchestraUserToId{
+ ArchestraUserToId: &pb.ArchestrAUserToIdCommand{
+ ServerHandle: serverHandle,
+ UserIdGuid: userIDGuid,
+ },
+ },
+ })
+}
+
+// AddBufferedItem invokes MXAccess AddBufferedItem and returns the item handle.
+func (s *Session) AddBufferedItem(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (int32, error) {
+ reply, err := s.AddBufferedItemRaw(ctx, serverHandle, itemDefinition, itemContext)
+ if err != nil {
+ return 0, err
+ }
+ if reply.GetAddBufferedItem() != nil {
+ return reply.GetAddBufferedItem().GetItemHandle(), nil
+ }
+ return reply.GetReturnValue().GetInt32Value(), nil
+}
+
+// AddBufferedItemRaw invokes MXAccess AddBufferedItem and returns the raw reply.
+func (s *Session) AddBufferedItemRaw(ctx context.Context, serverHandle int32, itemDefinition, itemContext string) (*MxCommandReply, error) {
+ if itemDefinition == "" {
+ return nil, errors.New("mxgateway: item definition is required")
+ }
+
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
+ Payload: &pb.MxCommand_AddBufferedItem{
+ AddBufferedItem: &pb.AddBufferedItemCommand{
+ ServerHandle: serverHandle,
+ ItemDefinition: itemDefinition,
+ ItemContext: itemContext,
+ },
+ },
+ })
+}
+
+// SetBufferedUpdateInterval invokes MXAccess SetBufferedUpdateInterval.
+func (s *Session) SetBufferedUpdateInterval(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) error {
+ _, err := s.SetBufferedUpdateIntervalRaw(ctx, serverHandle, updateIntervalMilliseconds)
+ return err
+}
+
+// SetBufferedUpdateIntervalRaw invokes MXAccess SetBufferedUpdateInterval and returns the raw reply.
+func (s *Session) SetBufferedUpdateIntervalRaw(ctx context.Context, serverHandle, updateIntervalMilliseconds int32) (*MxCommandReply, error) {
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
+ Payload: &pb.MxCommand_SetBufferedUpdateInterval{
+ SetBufferedUpdateInterval: &pb.SetBufferedUpdateIntervalCommand{
+ ServerHandle: serverHandle,
+ UpdateIntervalMilliseconds: updateIntervalMilliseconds,
+ },
+ },
+ })
+}
+
+// Suspend invokes MXAccess Suspend and returns the resulting item status.
+func (s *Session) Suspend(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
+ reply, err := s.SuspendRaw(ctx, serverHandle, itemHandle)
+ if err != nil {
+ return nil, err
+ }
+ return reply.GetSuspend().GetStatus(), nil
+}
+
+// SuspendRaw invokes MXAccess Suspend and returns the raw reply.
+func (s *Session) SuspendRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
+ Payload: &pb.MxCommand_Suspend{
+ Suspend: &pb.SuspendCommand{
+ ServerHandle: serverHandle,
+ ItemHandle: itemHandle,
+ },
+ },
+ })
+}
+
+// Activate invokes MXAccess Activate and returns the resulting item status.
+func (s *Session) Activate(ctx context.Context, serverHandle, itemHandle int32) (*MxStatusProxy, error) {
+ reply, err := s.ActivateRaw(ctx, serverHandle, itemHandle)
+ if err != nil {
+ return nil, err
+ }
+ return reply.GetActivate().GetStatus(), nil
+}
+
+// ActivateRaw invokes MXAccess Activate and returns the raw reply.
+func (s *Session) ActivateRaw(ctx context.Context, serverHandle, itemHandle int32) (*MxCommandReply, error) {
+ return s.invokeCommand(ctx, &pb.MxCommand{
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_ACTIVATE,
+ Payload: &pb.MxCommand_Activate{
+ Activate: &pb.ActivateCommand{
+ ServerHandle: serverHandle,
+ ItemHandle: itemHandle,
+ },
+ },
+ })
+}
+
+// stringSecrets collects the non-empty string content of the given values so it
+// can be scrubbed from surfaced errors. Only string-typed MxValues carry
+// scrubable text; non-string values (numbers, timestamps, arrays) contribute
+// nothing.
+func stringSecrets(values ...*MxValue) []string {
+ var secrets []string
+ for _, value := range values {
+ if value == nil {
+ continue
+ }
+ if stringValue, ok := value.GetKind().(*pb.MxValue_StringValue); ok && stringValue.StringValue != "" {
+ secrets = append(secrets, stringValue.StringValue)
+ }
+ }
+ return secrets
+}
+
// Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent.
//
diff --git a/clients/go/mxgateway/session_parity_helpers_test.go b/clients/go/mxgateway/session_parity_helpers_test.go
new file mode 100644
index 0000000..79bae27
--- /dev/null
+++ b/clients/go/mxgateway/session_parity_helpers_test.go
@@ -0,0 +1,224 @@
+package mxgateway
+
+import (
+ "context"
+ "errors"
+ "strings"
+ "testing"
+
+ pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
+)
+
+// TestAdviseSupervisoryBuildsCommandAndExposesRawReply pins that the promoted
+// typed helper emits an ADVISE_SUPERVISORY command carrying the server/item
+// handles and returns the raw reply.
+func TestAdviseSupervisoryBuildsCommandAndExposesRawReply(t *testing.T) {
+ fake := &fakeGatewayServer{
+ invokeReply: &pb.MxCommandReply{
+ SessionId: "session-1",
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
+ ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
+ },
+ }
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ reply, err := session.AdviseSupervisoryRaw(context.Background(), 12, 34)
+ if err != nil {
+ t.Fatalf("AdviseSupervisoryRaw() error = %v", err)
+ }
+ if reply.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
+ t.Fatalf("reply kind = %s", reply.GetKind())
+ }
+ cmd := fake.invokeRequest.GetCommand()
+ if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY {
+ t.Fatalf("command kind = %s", cmd.GetKind())
+ }
+ if cmd.GetAdviseSupervisory().GetServerHandle() != 12 || cmd.GetAdviseSupervisory().GetItemHandle() != 34 {
+ t.Fatalf("advise-supervisory handles = (%d, %d), want (12, 34)",
+ cmd.GetAdviseSupervisory().GetServerHandle(), cmd.GetAdviseSupervisory().GetItemHandle())
+ }
+
+ // The error-returning wrapper drops the reply but must not error on success.
+ if err := session.AdviseSupervisory(context.Background(), 12, 34); err != nil {
+ t.Fatalf("AdviseSupervisory() error = %v", err)
+ }
+}
+
+// TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate pins MXAccess
+// parity: WriteSecured issued without a preceding AuthenticateUser is rejected
+// natively, and the client surfaces that failure as a typed MxAccessError rather
+// than pre-validating or reordering. The write value is also kept out of the
+// surfaced error text.
+func TestWriteSecuredSurfacesNativeFailureWithoutPriorAuthenticate(t *testing.T) {
+ hresult := int32(-2147024891) // E_ACCESSDENIED
+ fake := &fakeGatewayServer{
+ invokeReply: &pb.MxCommandReply{
+ SessionId: "session-1",
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED,
+ Hresult: &hresult,
+ DiagnosticMessage: "WriteSecured requires a prior AuthenticateUser",
+ ProtocolStatus: &pb.ProtocolStatus{
+ Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
+ Message: "MXAccess failed",
+ },
+ },
+ }
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ securedValue := "supersecret-payload"
+ err := session.WriteSecured(context.Background(), 12, 34, 0, 0, StringValue(securedValue))
+
+ var mxErr *MxAccessError
+ if !errors.As(err, &mxErr) {
+ t.Fatalf("error %T does not support errors.As(*MxAccessError); err = %v", err, err)
+ }
+ if strings.Contains(err.Error(), securedValue) {
+ t.Fatalf("surfaced error leaked the secured payload: %q", err.Error())
+ }
+
+ // The command must carry the secured fields verbatim (parity: unaltered).
+ cmd := fake.invokeRequest.GetCommand()
+ if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_WRITE_SECURED {
+ t.Fatalf("command kind = %s", cmd.GetKind())
+ }
+ if cmd.GetWriteSecured().GetValue().GetStringValue() != securedValue {
+ t.Fatalf("wire value = %q, want %q", cmd.GetWriteSecured().GetValue().GetStringValue(), securedValue)
+ }
+}
+
+// TestWriteSecuredRejectsNilValueWithoutRoundTrip pins the client-side required
+// guard, which never echoes the (absent) value.
+func TestWriteSecuredRejectsNilValue(t *testing.T) {
+ fake := &fakeGatewayServer{}
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ if err := session.WriteSecured(context.Background(), 1, 2, 0, 0, nil); err == nil ||
+ !strings.Contains(err.Error(), "write-secured value is required") {
+ t.Fatalf("WriteSecured(nil value) error = %v", err)
+ }
+}
+
+// TestAuthenticateUserReturnsUserIDOnHappyPath pins the typed helper unpacking of
+// AuthenticateUserReply.user_id and that the credential is carried on the wire
+// but never surfaced.
+func TestAuthenticateUserReturnsUserIDOnHappyPath(t *testing.T) {
+ fake := &fakeGatewayServer{
+ invokeReply: &pb.MxCommandReply{
+ SessionId: "session-1",
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
+ ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
+ Payload: &pb.MxCommandReply_AuthenticateUser{
+ AuthenticateUser: &pb.AuthenticateUserReply{UserId: 4242},
+ },
+ },
+ }
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ userID, err := session.AuthenticateUser(context.Background(), 12, "operator", "hunter2-password")
+ if err != nil {
+ t.Fatalf("AuthenticateUser() error = %v", err)
+ }
+ if userID != 4242 {
+ t.Fatalf("user id = %d, want 4242", userID)
+ }
+
+ cmd := fake.invokeRequest.GetCommand()
+ if cmd.GetKind() != pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER {
+ t.Fatalf("command kind = %s", cmd.GetKind())
+ }
+ if cmd.GetAuthenticateUser().GetVerifyUser() != "operator" {
+ t.Fatalf("verify user = %q, want operator", cmd.GetAuthenticateUser().GetVerifyUser())
+ }
+ if cmd.GetAuthenticateUser().GetVerifyUserPassword() != "hunter2-password" {
+ t.Fatalf("password not carried to the wire verbatim")
+ }
+}
+
+// TestAuthenticateUserScrubsCredentialFromSurfacedError proves the redaction
+// seam: even when the gateway diagnostic message echoes the credential back, the
+// surfaced error redacts it while the typed MxAccessError remains reachable via
+// errors.As.
+func TestAuthenticateUserScrubsCredentialFromSurfacedError(t *testing.T) {
+ password := "hunter2-password"
+ fake := &fakeGatewayServer{
+ invokeReply: &pb.MxCommandReply{
+ SessionId: "session-1",
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_AUTHENTICATE_USER,
+ DiagnosticMessage: "authentication failed for password " + password,
+ // Message is intentionally left empty so MxAccessError.Error() falls
+ // through to the diagnostic message — the free-text field that could
+ // otherwise echo the credential back to a caller's log.
+ ProtocolStatus: &pb.ProtocolStatus{
+ Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
+ },
+ },
+ }
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ _, err := session.AuthenticateUser(context.Background(), 12, "operator", password)
+ if err == nil {
+ t.Fatal("AuthenticateUser() returned no error on native failure")
+ }
+ if strings.Contains(err.Error(), password) {
+ t.Fatalf("surfaced error leaked the credential: %q", err.Error())
+ }
+ if !strings.Contains(err.Error(), redactedSecretMarker) {
+ t.Fatalf("surfaced error missing redaction marker: %q", err.Error())
+ }
+ var mxErr *MxAccessError
+ if !errors.As(err, &mxErr) {
+ t.Fatalf("redaction wrapper broke errors.As(*MxAccessError); err = %v", err)
+ }
+}
+
+// TestAuthenticateUserRequiresVerifyUser pins the client-side required guard.
+func TestAuthenticateUserRequiresVerifyUser(t *testing.T) {
+ fake := &fakeGatewayServer{}
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ if _, err := session.AuthenticateUser(context.Background(), 12, "", "pw"); err == nil ||
+ !strings.Contains(err.Error(), "verify user is required") {
+ t.Fatalf("AuthenticateUser(empty user) error = %v", err)
+ }
+}
+
+// TestSuspendActivateReturnStatus covers two Phase 2 helpers that unpack an
+// MxStatusProxy from their dedicated reply arms.
+func TestSuspendActivateReturnStatus(t *testing.T) {
+ fake := &fakeGatewayServer{
+ invokeReply: &pb.MxCommandReply{
+ SessionId: "session-1",
+ Kind: pb.MxCommandKind_MX_COMMAND_KIND_SUSPEND,
+ ProtocolStatus: &pb.ProtocolStatus{Code: pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK},
+ Payload: &pb.MxCommandReply_Suspend{
+ Suspend: &pb.SuspendReply{Status: &pb.MxStatusProxy{Success: 1, DiagnosticText: "suspended"}},
+ },
+ },
+ }
+ client, cleanup := newBufconnClient(t, fake)
+ defer cleanup()
+ session := NewSessionForID(client, "session-1")
+
+ status, err := session.Suspend(context.Background(), 12, 34)
+ if err != nil {
+ t.Fatalf("Suspend() error = %v", err)
+ }
+ if status.GetDiagnosticText() != "suspended" {
+ t.Fatalf("status diagnostic = %q, want suspended", status.GetDiagnosticText())
+ }
+ if fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle() != 34 {
+ t.Fatalf("suspend item handle = %d, want 34", fake.invokeRequest.GetCommand().GetSuspend().GetItemHandle())
+ }
+}
diff --git a/clients/python/README.md b/clients/python/README.md
index ef8409f..2e14f73 100644
--- a/clients/python/README.md
+++ b/clients/python/README.md
@@ -153,19 +153,11 @@ but still need the write attributed to a user id, you must first advise the
item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored.
-The session exposes `advise`/`unadvise` but not supervisory advise, so send it
-through the generic command channel:
+The session exposes a typed `advise_supervisory` helper alongside
+`advise`/`unadvise`:
```python
-await session.invoke(
- pb.MxCommand(
- kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
- advise_supervisory=pb.AdviseSupervisoryCommand(
- server_handle=server_handle,
- item_handle=item_handle,
- ),
- )
-)
+await session.advise_supervisory(server_handle, item_handle)
await session.write(server_handle, item_handle, value, user_id=user_id)
```
@@ -173,6 +165,30 @@ await session.write(server_handle, item_handle, value, user_id=user_id)
The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`.
+For the verified/secured path, `authenticate_user`, `write_secured`,
+`write_secured2`, and `archestra_user_to_id` are typed session helpers too. The
+credential passed to `authenticate_user` and the values written by
+`write_secured`/`write_secured2` are treated as secrets: they are never logged
+and are scrubbed from any surfaced error message. MXAccess parity is preserved —
+a `write_secured` that fails because no prior `authenticate_user` +
+`advise_supervisory` established a supervisory context surfaces the native
+failure as `MxAccessError` rather than being silently "fixed":
+
+```python
+user_id = await session.authenticate_user(server_handle, "operator", password)
+await session.advise_supervisory(server_handle, item_handle)
+await session.write_secured(
+ server_handle,
+ item_handle,
+ value,
+ current_user_id=user_id,
+ verifier_user_id=user_id,
+)
+```
+
+The CLI mirrors these as `authenticate-user` (credential via `--password` or,
+preferably, `--password-env`) and `write-secured`.
+
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
diff --git a/clients/python/src/zb_mom_ww_mxgateway/session.py b/clients/python/src/zb_mom_ww_mxgateway/session.py
index 973ba9a..057d37f 100644
--- a/clients/python/src/zb_mom_ww_mxgateway/session.py
+++ b/clients/python/src/zb_mom_ww_mxgateway/session.py
@@ -4,7 +4,8 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence
-from .errors import ensure_mxaccess_success
+from .auth import redact_secret
+from .errors import MxGatewayError, ensure_mxaccess_success
from .events import ReplayGap
from .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value
@@ -569,6 +570,249 @@ class Session:
correlation_id=correlation_id,
)
+ async def _invoke_redacted(
+ self,
+ command: pb.MxCommand,
+ *,
+ correlation_id: str,
+ secrets: Sequence[str | None],
+ ) -> pb.MxCommandReply:
+ """Invoke a command whose request carries credential-sensitive data.
+
+ Runs the same gateway + MXAccess validation as :meth:`invoke`, but scrubs
+ the supplied secret substrings from any surfaced error message before it
+ propagates. MXAccess parity is preserved — the native failure is still
+ raised as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`; only the
+ credential text is removed from the message so it can never reach logs.
+ """
+
+ try:
+ return await self.invoke(command, correlation_id=correlation_id)
+ except MxGatewayError as error:
+ _redact_error(error, secrets)
+ raise
+
+ async def advise_supervisory(
+ self,
+ server_handle: int,
+ item_handle: int,
+ *,
+ correlation_id: str = "",
+ ) -> None:
+ """Invoke MXAccess `AdviseSupervisory` for an `ItemHandle`.
+
+ Supervisory advise is the prerequisite for user-attributed and secured
+ writes: it must be established (typically after :meth:`authenticate_user`)
+ before a ``user_id``-bearing :meth:`write` or a :meth:`write_secured`
+ takes effect.
+ """
+ await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
+ advise_supervisory=pb.AdviseSupervisoryCommand(
+ server_handle=server_handle,
+ item_handle=item_handle,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+
+ async def write_secured(
+ self,
+ server_handle: int,
+ item_handle: int,
+ value: MxValueInput,
+ *,
+ current_user_id: int = 0,
+ verifier_user_id: int = 0,
+ correlation_id: str = "",
+ ) -> None:
+ """Invoke MXAccess `WriteSecured` — a signed/verified write.
+
+ The written *value* is credential-sensitive and is scrubbed from any
+ surfaced error message (never logged). MXAccess parity is the contract:
+ ``WriteSecured`` failing before a prior :meth:`authenticate_user` +
+ :meth:`advise_supervisory`, or before a value-bearing body, is the native
+ behaviour and is surfaced as-is — it is not "fixed".
+ """
+ await self._invoke_redacted(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
+ write_secured=pb.WriteSecuredCommand(
+ server_handle=server_handle,
+ item_handle=item_handle,
+ current_user_id=current_user_id,
+ verifier_user_id=verifier_user_id,
+ value=to_mx_value(value),
+ ),
+ ),
+ correlation_id=correlation_id,
+ secrets=_value_secrets(value),
+ )
+
+ async def write_secured2(
+ self,
+ server_handle: int,
+ item_handle: int,
+ value: MxValueInput,
+ timestamp_value: MxValueInput,
+ *,
+ current_user_id: int = 0,
+ verifier_user_id: int = 0,
+ correlation_id: str = "",
+ ) -> None:
+ """Invoke MXAccess `WriteSecured2` — a signed/verified, timestamped write.
+
+ Like :meth:`write_secured` but also stamps a client-supplied timestamp.
+ The written *value* is credential-sensitive and is scrubbed from any
+ surfaced error message. Native pre-condition failures are surfaced as-is.
+ """
+ await self._invoke_redacted(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_WRITE_SECURED2,
+ write_secured2=pb.WriteSecured2Command(
+ server_handle=server_handle,
+ item_handle=item_handle,
+ current_user_id=current_user_id,
+ verifier_user_id=verifier_user_id,
+ value=to_mx_value(value),
+ timestamp_value=to_mx_value(timestamp_value),
+ ),
+ ),
+ correlation_id=correlation_id,
+ secrets=_value_secrets(value),
+ )
+
+ async def authenticate_user(
+ self,
+ server_handle: int,
+ verify_user: str,
+ verify_user_password: str,
+ *,
+ correlation_id: str = "",
+ ) -> int:
+ """Invoke MXAccess `AuthenticateUser` and return the resolved Galaxy user id.
+
+ *verify_user_password* is a raw MXAccess credential: it is never logged
+ and is scrubbed from any surfaced error message. A native authentication
+ failure is surfaced as :class:`~zb_mom_ww_mxgateway.errors.MxAccessError`
+ with the credential removed.
+ """
+ reply = await self._invoke_redacted(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
+ authenticate_user=pb.AuthenticateUserCommand(
+ server_handle=server_handle,
+ verify_user=verify_user,
+ verify_user_password=verify_user_password,
+ ),
+ ),
+ correlation_id=correlation_id,
+ secrets=[verify_user_password],
+ )
+ return reply.authenticate_user.user_id
+
+ async def archestra_user_to_id(
+ self,
+ server_handle: int,
+ user_id_guid: str,
+ *,
+ correlation_id: str = "",
+ ) -> int:
+ """Invoke MXAccess `ArchestrAUserToId` and return the resolved user id."""
+ reply = await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID,
+ archestra_user_to_id=pb.ArchestrAUserToIdCommand(
+ server_handle=server_handle,
+ user_id_guid=user_id_guid,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+ return reply.archestra_user_to_id.user_id
+
+ async def add_buffered_item(
+ self,
+ server_handle: int,
+ item_definition: str,
+ item_context: str = "",
+ *,
+ correlation_id: str = "",
+ ) -> int:
+ """Invoke MXAccess `AddBufferedItem` and return the new `ItemHandle`."""
+ reply = await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM,
+ add_buffered_item=pb.AddBufferedItemCommand(
+ server_handle=server_handle,
+ item_definition=item_definition,
+ item_context=item_context,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+ return reply.add_buffered_item.item_handle
+
+ async def set_buffered_update_interval(
+ self,
+ server_handle: int,
+ update_interval_milliseconds: int,
+ *,
+ correlation_id: str = "",
+ ) -> None:
+ """Invoke MXAccess `SetBufferedUpdateInterval` for the server handle."""
+ await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL,
+ set_buffered_update_interval=pb.SetBufferedUpdateIntervalCommand(
+ server_handle=server_handle,
+ update_interval_milliseconds=update_interval_milliseconds,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+
+ async def suspend(
+ self,
+ server_handle: int,
+ item_handle: int,
+ *,
+ correlation_id: str = "",
+ ) -> pb.MxStatusProxy:
+ """Invoke MXAccess `Suspend` for an `ItemHandle` and return its status."""
+ reply = await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_SUSPEND,
+ suspend=pb.SuspendCommand(
+ server_handle=server_handle,
+ item_handle=item_handle,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+ return reply.suspend.status
+
+ async def activate(
+ self,
+ server_handle: int,
+ item_handle: int,
+ *,
+ correlation_id: str = "",
+ ) -> pb.MxStatusProxy:
+ """Invoke MXAccess `Activate` for a suspended `ItemHandle` and return its status."""
+ reply = await self.invoke(
+ pb.MxCommand(
+ kind=pb.MX_COMMAND_KIND_ACTIVATE,
+ activate=pb.ActivateCommand(
+ server_handle=server_handle,
+ item_handle=item_handle,
+ ),
+ ),
+ correlation_id=correlation_id,
+ )
+ return reply.activate.status
+
def stream_events(
self,
*,
@@ -631,4 +875,39 @@ def _ensure_bulk_size(name: str, count: int) -> None:
raise ValueError(f"{name} bulk commands are limited to {MAX_BULK_ITEMS} item(s)")
+def _value_secrets(value: MxValueInput) -> list[str]:
+ """Return the redaction candidate strings for a credential-sensitive write value.
+
+ Secured-write payloads may carry a password or other secret. Only textual
+ values can appear verbatim in a surfaced error, so a string value (or a
+ UTF-8-decodable ``bytes`` value) is returned for scrubbing; other value
+ kinds have no verbatim text form to leak.
+ """
+ if isinstance(value, str):
+ return [value] if value else []
+ if isinstance(value, bytes):
+ try:
+ decoded = value.decode("utf-8")
+ except UnicodeDecodeError:
+ return []
+ return [decoded] if decoded else []
+ return []
+
+
+def _redact_error(error: MxGatewayError, secrets: Sequence[str | None]) -> None:
+ """Scrub secret substrings from a raised error's message in place.
+
+ Rewrites ``error.args[0]`` (the message returned by ``str(error)``) through
+ the shared :func:`~zb_mom_ww_mxgateway.auth.redact_secret` seam so credential
+ text can never reach logs or be re-raised to a caller. The
+ ``protocol_status`` / ``raw_reply`` context is left untouched — those hold the
+ gateway's own fields, which never echo the client-supplied secret.
+ """
+ scrubbed = [secret for secret in secrets if secret]
+ if not scrubbed:
+ return
+ if error.args and isinstance(error.args[0], str):
+ error.args = (redact_secret(error.args[0], scrubbed), *error.args[1:])
+
+
from .client import GatewayClient # noqa: E402
diff --git a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py
index 99876ad..154c339 100644
--- a/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py
+++ b/clients/python/src/zb_mom_ww_mxgateway_cli/commands.py
@@ -294,6 +294,56 @@ def advise_supervisory(**kwargs: Any) -> None:
)
+@main.command("write-secured")
+@gateway_options
+@click.option("--session-id", required=True, help="Gateway session id.")
+@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
+@click.option("--item-handle", required=True, type=int, help="MXAccess item handle.")
+@click.option("--type", "value_type", default="string", show_default=True)
+@click.option("--value", required=True, help="Value to write (credential-sensitive; never logged).")
+@click.option("--current-user-id", default=0, type=int, show_default=True)
+@click.option("--verifier-user-id", default=0, type=int, show_default=True)
+@click.option("--correlation-id", default="", help="Client correlation id.")
+@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
+def write_secured(**kwargs: Any) -> None:
+ """Invoke MXAccess WriteSecured — a signed/verified write (credential-sensitive)."""
+
+ _run(
+ _write_secured(**kwargs),
+ output_json=kwargs["output_json"],
+ secrets=_secrets(kwargs) + [kwargs.get("value")],
+ )
+
+
+@main.command("authenticate-user")
+@gateway_options
+@click.option("--session-id", required=True, help="Gateway session id.")
+@click.option("--server-handle", required=True, type=int, help="MXAccess server handle.")
+@click.option("--verify-user", required=True, help="MXAccess user name to authenticate.")
+@click.option(
+ "--password",
+ default=None,
+ help="User password. Prefer --password-env so the secret is not visible on the command line.",
+)
+@click.option(
+ "--password-env",
+ default=None,
+ help="Environment variable holding the user password.",
+)
+@click.option("--correlation-id", default="", help="Client correlation id.")
+@click.option("--json", "output_json", is_flag=True, help="Emit JSON output.")
+def authenticate_user(**kwargs: Any) -> None:
+ """Invoke MXAccess AuthenticateUser — resolve a Galaxy user id (credential-sensitive)."""
+
+ password = _resolve_password(kwargs)
+ kwargs["password"] = password
+ _run(
+ _authenticate_user(**kwargs),
+ output_json=kwargs["output_json"],
+ secrets=_secrets(kwargs) + [password],
+ )
+
+
@main.command("subscribe-bulk")
@gateway_options
@click.option("--session-id", required=True, help="Gateway session id.")
@@ -745,19 +795,58 @@ async def _advise(**kwargs: Any) -> dict[str, Any]:
async def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
- await session.invoke(
- pb.MxCommand(
- kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
- advise_supervisory=pb.AdviseSupervisoryCommand(
- server_handle=kwargs["server_handle"],
- item_handle=kwargs["item_handle"],
- ),
- ),
+ await session.advise_supervisory(
+ kwargs["server_handle"],
+ kwargs["item_handle"],
correlation_id=kwargs["correlation_id"],
)
return {"ok": True}
+async def _write_secured(**kwargs: Any) -> dict[str, Any]:
+ value = _parse_value(kwargs["value"], kwargs["value_type"])
+ async with await _connect(kwargs) as client:
+ session = _session(client, kwargs["session_id"])
+ await session.write_secured(
+ kwargs["server_handle"],
+ kwargs["item_handle"],
+ value,
+ current_user_id=kwargs["current_user_id"],
+ verifier_user_id=kwargs["verifier_user_id"],
+ correlation_id=kwargs["correlation_id"],
+ )
+ return {"ok": True}
+
+
+async def _authenticate_user(**kwargs: Any) -> dict[str, Any]:
+ async with await _connect(kwargs) as client:
+ session = _session(client, kwargs["session_id"])
+ user_id = await session.authenticate_user(
+ kwargs["server_handle"],
+ kwargs["verify_user"],
+ kwargs["password"],
+ correlation_id=kwargs["correlation_id"],
+ )
+ return {"userId": user_id}
+
+
+def _resolve_password(kwargs: dict[str, Any]) -> str:
+ """Resolve the authenticate-user password from --password or --password-env.
+
+ Prefers the explicit flag, then falls back to the named environment
+ variable. The resolved secret is never echoed; callers pass it into the
+ ``secrets`` redaction list so it cannot leak through a surfaced error.
+ """
+
+ password = kwargs.get("password")
+ if not password:
+ env_name = kwargs.get("password_env")
+ password = os.environ.get(env_name) if env_name else None
+ if not password:
+ raise click.UsageError("a password is required via --password or --password-env")
+ return password
+
+
async def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"])
diff --git a/clients/python/tests/test_cli.py b/clients/python/tests/test_cli.py
index c0f34e6..856b83f 100644
--- a/clients/python/tests/test_cli.py
+++ b/clients/python/tests/test_cli.py
@@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
assert result.exit_code == 0
assert "--parent-gobject-id" in result.output
+
+
+class _FakeInvokeClient:
+ """Async-context-manager fake whose invoke_raw returns a scripted reply.
+
+ Satisfies the session-backed CLI command bodies (register / write-secured /
+ authenticate-user) which build a Session over this client and call through to
+ ``invoke_raw``. Records the last command so tests can assert credentials are
+ carried on the wire but never echoed to stdout.
+ """
+
+ def __init__(self, reply) -> None:
+ self._reply = reply
+ self.last_request = None
+
+ async def __aenter__(self) -> "_FakeInvokeClient":
+ return self
+
+ async def __aexit__(self, *_exc: object) -> None:
+ return None
+
+ async def invoke_raw(self, request):
+ self.last_request = request
+ return self._reply
+
+
+def test_authenticate_user_command_returns_user_id_without_echoing_password(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
+
+ reply = pb.MxCommandReply(
+ session_id="s1",
+ kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
+ protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
+ authenticate_user=pb.AuthenticateUserReply(user_id=42),
+ )
+ fake = _FakeInvokeClient(reply)
+
+ async def fake_connect(options, **_kwargs):
+ return fake
+
+ monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "authenticate-user",
+ "--plaintext",
+ "--session-id",
+ "s1",
+ "--server-handle",
+ "3",
+ "--verify-user",
+ "operator",
+ "--password",
+ "cli-secret-pw",
+ "--json",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert json.loads(result.output)["userId"] == 42
+ assert "cli-secret-pw" not in result.output
+ # Credential is carried on the wire, not echoed.
+ assert fake.last_request.command.authenticate_user.verify_user_password == "cli-secret-pw"
+
+
+def test_authenticate_user_reads_password_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
+ from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
+
+ reply = pb.MxCommandReply(
+ session_id="s1",
+ kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
+ protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
+ authenticate_user=pb.AuthenticateUserReply(user_id=7),
+ )
+ fake = _FakeInvokeClient(reply)
+
+ async def fake_connect(options, **_kwargs):
+ return fake
+
+ monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
+ monkeypatch.setenv("MXGW_TEST_PW", "env-secret-pw")
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "authenticate-user",
+ "--plaintext",
+ "--session-id",
+ "s1",
+ "--server-handle",
+ "3",
+ "--verify-user",
+ "operator",
+ "--password-env",
+ "MXGW_TEST_PW",
+ "--json",
+ ],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert "env-secret-pw" not in result.output
+ assert fake.last_request.command.authenticate_user.verify_user_password == "env-secret-pw"
+
+
+def test_authenticate_user_requires_a_password() -> None:
+ result = CliRunner().invoke(
+ main,
+ [
+ "authenticate-user",
+ "--plaintext",
+ "--session-id",
+ "s1",
+ "--server-handle",
+ "3",
+ "--verify-user",
+ "operator",
+ "--json",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "password is required" in result.output
+
+
+def test_write_secured_command_does_not_echo_value_on_failure(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
+
+ reply = pb.MxCommandReply(
+ session_id="s1",
+ kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
+ protocol_status=pb.ProtocolStatus(
+ code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
+ message="WriteSecured rejected value cli-secret-value",
+ ),
+ hresult=-1,
+ )
+ fake = _FakeInvokeClient(reply)
+
+ async def fake_connect(options, **_kwargs):
+ return fake
+
+ monkeypatch.setattr(commands_module.GatewayClient, "connect", fake_connect)
+
+ result = CliRunner().invoke(
+ main,
+ [
+ "write-secured",
+ "--plaintext",
+ "--session-id",
+ "s1",
+ "--server-handle",
+ "3",
+ "--item-handle",
+ "4",
+ "--value",
+ "cli-secret-value",
+ "--json",
+ ],
+ )
+
+ assert result.exit_code != 0
+ assert "cli-secret-value" not in result.output
+
+
+def test_write_secured_and_authenticate_user_commands_are_registered() -> None:
+ names = set(main.commands)
+ assert {"write-secured", "authenticate-user"} <= names
diff --git a/clients/python/tests/test_typed_command_helpers.py b/clients/python/tests/test_typed_command_helpers.py
new file mode 100644
index 0000000..c0f7124
--- /dev/null
+++ b/clients/python/tests/test_typed_command_helpers.py
@@ -0,0 +1,227 @@
+"""Tests for the typed single-item command helpers (CLI-04).
+
+Covers the parity-critical MXAccess commands promoted from raw ``Invoke`` to
+typed async session helpers: ``advise_supervisory``, ``write_secured`` /
+``write_secured2``, ``authenticate_user``, ``archestra_user_to_id``, and the
+buffered/suspend/activate family. The credential-redaction contract for the
+secured/auth helpers is asserted explicitly.
+"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import pytest
+
+from zb_mom_ww_mxgateway import ClientOptions, GatewayClient, MxAccessError
+from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
+
+
+class FakeUnary:
+ """Records requests and pops scripted replies, matching the client's call shape."""
+
+ def __init__(self, replies: list[Any]) -> None:
+ self.replies = replies
+ self.requests: list[Any] = []
+ self.metadata: tuple[tuple[str, str], ...] | None = None
+
+ async def __call__(
+ self,
+ request: Any,
+ *,
+ metadata: tuple[tuple[str, str], ...],
+ ) -> Any:
+ self.requests.append(request)
+ self.metadata = metadata
+ return self.replies.pop(0)
+
+
+class FakeGatewayStub:
+ """Minimal stub: a fixed open-session reply plus a scriptable invoke queue."""
+
+ def __init__(self, invoke_replies: list[Any]) -> None:
+ self.open_session = FakeUnary(
+ [
+ pb.OpenSessionReply(
+ session_id="session-1",
+ protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
+ ),
+ ],
+ )
+ self.invoke = FakeUnary(invoke_replies)
+ self.OpenSession = self.open_session
+ self.Invoke = self.invoke
+
+
+async def _session_with(invoke_replies: list[Any]):
+ stub = FakeGatewayStub(invoke_replies)
+ client = await GatewayClient.connect(
+ ClientOptions(endpoint="fake", api_key="mxgw_test_secret", plaintext=True),
+ stub=stub,
+ )
+ session = await client.open_session()
+ return session, stub
+
+
+def _ok(kind: "pb.MxCommandKind.ValueType", **payload: Any) -> pb.MxCommandReply:
+ return pb.MxCommandReply(
+ session_id="session-1",
+ kind=kind,
+ protocol_status=pb.ProtocolStatus(code=pb.PROTOCOL_STATUS_CODE_OK),
+ **payload,
+ )
+
+
+@pytest.mark.asyncio
+async def test_advise_supervisory_sends_typed_command() -> None:
+ session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY)])
+
+ await session.advise_supervisory(12, 34)
+
+ command = stub.invoke.requests[0].command
+ assert command.kind == pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY
+ assert command.advise_supervisory.server_handle == 12
+ assert command.advise_supervisory.item_handle == 34
+
+
+@pytest.mark.asyncio
+async def test_authenticate_user_returns_user_id_and_sends_credentials() -> None:
+ session, stub = await _session_with(
+ [_ok(pb.MX_COMMAND_KIND_AUTHENTICATE_USER, authenticate_user=pb.AuthenticateUserReply(user_id=77))],
+ )
+
+ user_id = await session.authenticate_user(12, "operator", "s3cr3t-pw")
+
+ assert user_id == 77
+ command = stub.invoke.requests[0].command
+ assert command.kind == pb.MX_COMMAND_KIND_AUTHENTICATE_USER
+ assert command.authenticate_user.verify_user == "operator"
+ # The credential is carried on the wire (redaction is about logs/errors, not the RPC).
+ assert command.authenticate_user.verify_user_password == "s3cr3t-pw"
+
+
+@pytest.mark.asyncio
+async def test_authenticate_user_scrubs_credential_from_surfaced_error() -> None:
+ password = "super-secret-pw"
+ failure = pb.MxCommandReply(
+ session_id="session-1",
+ kind=pb.MX_COMMAND_KIND_AUTHENTICATE_USER,
+ protocol_status=pb.ProtocolStatus(
+ code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
+ # Simulate a gateway that unwisely echoed the credential back in the message.
+ message=f"authentication failed for password {password}",
+ ),
+ hresult=-1,
+ )
+ session, _ = await _session_with([failure])
+
+ with pytest.raises(MxAccessError) as captured:
+ await session.authenticate_user(12, "operator", password)
+
+ assert password not in str(captured.value)
+ assert "[redacted]" in str(captured.value)
+
+
+@pytest.mark.asyncio
+async def test_write_secured_surfaces_native_failure_without_prior_authenticate() -> None:
+ """Parity: WriteSecured failing before authenticate/advise-supervisory is surfaced as-is."""
+ secret_value = "priv-payload"
+ failure = pb.MxCommandReply(
+ session_id="session-1",
+ kind=pb.MX_COMMAND_KIND_WRITE_SECURED,
+ protocol_status=pb.ProtocolStatus(
+ code=pb.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE,
+ message=f"WriteSecured rejected value {secret_value}",
+ ),
+ hresult=-2147217407,
+ )
+ session, stub = await _session_with([failure])
+
+ with pytest.raises(MxAccessError) as captured:
+ await session.write_secured(12, 34, secret_value, current_user_id=5, verifier_user_id=6)
+
+ # Native failure is surfaced (not "fixed") and the raw reply is preserved...
+ assert captured.value.raw_reply is failure
+ # ...but the credential-sensitive value is scrubbed from the surfaced message.
+ assert secret_value not in str(captured.value)
+ command = stub.invoke.requests[0].command
+ assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED
+ assert command.write_secured.current_user_id == 5
+ assert command.write_secured.verifier_user_id == 6
+
+
+@pytest.mark.asyncio
+async def test_write_secured2_sends_value_and_timestamp() -> None:
+ from datetime import datetime, timezone
+
+ session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_WRITE_SECURED2)])
+
+ stamp = datetime(2026, 1, 2, 3, 4, 5, tzinfo=timezone.utc)
+ await session.write_secured2(12, 34, 42, stamp, current_user_id=5, verifier_user_id=6)
+
+ command = stub.invoke.requests[0].command
+ assert command.kind == pb.MX_COMMAND_KIND_WRITE_SECURED2
+ assert command.write_secured2.value.int32_value == 42
+ assert command.write_secured2.HasField("timestamp_value")
+
+
+@pytest.mark.asyncio
+async def test_archestra_user_to_id_returns_user_id() -> None:
+ session, stub = await _session_with(
+ [_ok(pb.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID, archestra_user_to_id=pb.ArchestrAUserToIdReply(user_id=9))],
+ )
+
+ user_id = await session.archestra_user_to_id(12, "guid-123")
+
+ assert user_id == 9
+ assert stub.invoke.requests[0].command.archestra_user_to_id.user_id_guid == "guid-123"
+
+
+@pytest.mark.asyncio
+async def test_add_buffered_item_returns_item_handle() -> None:
+ session, stub = await _session_with(
+ [_ok(pb.MX_COMMAND_KIND_ADD_BUFFERED_ITEM, add_buffered_item=pb.AddBufferedItemReply(item_handle=55))],
+ )
+
+ item_handle = await session.add_buffered_item(12, "Object.Attribute", "ctx")
+
+ assert item_handle == 55
+ command = stub.invoke.requests[0].command
+ assert command.add_buffered_item.item_definition == "Object.Attribute"
+ assert command.add_buffered_item.item_context == "ctx"
+
+
+@pytest.mark.asyncio
+async def test_set_buffered_update_interval_sends_command() -> None:
+ session, stub = await _session_with([_ok(pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)])
+
+ await session.set_buffered_update_interval(12, 250)
+
+ command = stub.invoke.requests[0].command
+ assert command.kind == pb.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL
+ assert command.set_buffered_update_interval.update_interval_milliseconds == 250
+
+
+@pytest.mark.asyncio
+async def test_suspend_and_activate_return_status() -> None:
+ session, _ = await _session_with(
+ [
+ _ok(
+ pb.MX_COMMAND_KIND_SUSPEND,
+ suspend=pb.SuspendReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
+ ),
+ ],
+ )
+ status = await session.suspend(12, 34)
+ assert status.category == pb.MX_STATUS_CATEGORY_OK
+
+ session, _ = await _session_with(
+ [
+ _ok(
+ pb.MX_COMMAND_KIND_ACTIVATE,
+ activate=pb.ActivateReply(status=pb.MxStatusProxy(category=pb.MX_STATUS_CATEGORY_OK)),
+ ),
+ ],
+ )
+ status = await session.activate(12, 34)
+ assert status.category == pb.MX_STATUS_CATEGORY_OK
diff --git a/clients/rust/README.md b/clients/rust/README.md
index e27f707..2f1b951 100644
--- a/clients/rust/README.md
+++ b/clients/rust/README.md
@@ -192,26 +192,36 @@ but still need the write attributed to a user id, you must first advise the
item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored.
-The session exposes `advise`/`un_advise` but not supervisory advise, so send it
-through the generic command channel:
+The session exposes a typed `advise_supervisory` helper alongside
+`advise`/`un_advise`:
```rust
-session
- .invoke(
- MxCommandKind::AdviseSupervisory,
- Payload::AdviseSupervisory(AdviseSupervisoryCommand {
- server_handle,
- item_handle,
- }),
- )
- .await?;
-
+session.advise_supervisory(server_handle, item_handle).await?;
session.write(server_handle, item_handle, value, user_id).await?;
```
The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`.
+### Verified / secured writes and user resolution
+
+The verified path has typed session helpers too: `authenticate_user` (returns
+the resolved MXAccess user id), `archestra_user_to_id`, and
+`write_secured` / `write_secured2`. MXAccess parity is preserved — a
+`write_secured` issued before the required `authenticate_user` +
+`advise_supervisory` (or before a value-bearing body) fails natively and the
+failure surfaces as `Error::MxAccess`; it is not smoothed over. Credentials
+passed to `authenticate_user` (and secured write payloads) are placed only on
+the wire — the client never logs them and never embeds them in an `Error`'s
+`Display`/`Debug`; the only error text that can surface (from `tonic::Status`
+messages and reply diagnostics) is scrubbed by the credential-redaction seam.
+The CLI mirrors these as `authenticate-user` (password via `--password` or the
+`--password-env` env var, never echoed) and `write-secured`.
+
+The remaining single-item command helpers round out MXAccess parity:
+`unregister`, `suspend` / `activate` (each returns the operation's
+`MxStatus`), `add_buffered_item`, and `set_buffered_update_interval`.
+
### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an
diff --git a/clients/rust/crates/mxgw-cli/src/main.rs b/clients/rust/crates/mxgw-cli/src/main.rs
index 8a84ffe..056d510 100644
--- a/clients/rust/crates/mxgw-cli/src/main.rs
+++ b/clients/rust/crates/mxgw-cli/src/main.rs
@@ -21,11 +21,10 @@ use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode};
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
- alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage,
- CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily,
- MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest,
- StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry,
- WriteSecuredBulkEntry,
+ alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
+ MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
+ OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
+ WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
@@ -118,6 +117,67 @@ enum Command {
#[arg(long)]
json: bool,
},
+ /// Release a `ServerHandle` (and the items advised under it) via
+ /// MXAccess `Unregister`.
+ Unregister {
+ #[command(flatten)]
+ connection: ConnectionArgs,
+ #[arg(long)]
+ session_id: String,
+ #[arg(long)]
+ server_handle: i32,
+ #[arg(long)]
+ json: bool,
+ },
+ /// Resolve an MXAccess user id from a credential via `AuthenticateUser`.
+ /// The password is read from `--password` or, if omitted, from the
+ /// environment variable named by `--password-env`; it is never echoed to
+ /// stdout/stderr.
+ AuthenticateUser {
+ #[command(flatten)]
+ connection: ConnectionArgs,
+ #[arg(long)]
+ session_id: String,
+ #[arg(long)]
+ server_handle: i32,
+ #[arg(long)]
+ verify_user: String,
+ /// Verifier password. Prefer `--password-env` so the secret never
+ /// appears in the process command line.
+ #[arg(long)]
+ password: Option,
+ /// Name of the environment variable holding the verifier password.
+ /// Used only when `--password` is not supplied.
+ #[arg(long, default_value = "MXGATEWAY_VERIFY_PASSWORD")]
+ password_env: String,
+ #[arg(long)]
+ json: bool,
+ },
+ /// Single credential-verified write via MXAccess `WriteSecured`.
+ ///
+ /// Parity note: this fails natively unless the session has first run
+ /// `authenticate-user` + `advise-supervisory` and the item carries a
+ /// value-bearing body — the native failure is surfaced, not hidden.
+ WriteSecured {
+ #[command(flatten)]
+ connection: ConnectionArgs,
+ #[arg(long)]
+ session_id: String,
+ #[arg(long)]
+ server_handle: i32,
+ #[arg(long)]
+ item_handle: i32,
+ #[arg(long, value_enum)]
+ value_type: CliValueType,
+ #[arg(long)]
+ value: String,
+ #[arg(long, default_value_t = 0)]
+ current_user_id: i32,
+ #[arg(long, default_value_t = 0)]
+ verifier_user_id: i32,
+ #[arg(long)]
+ json: bool,
+ },
SubscribeBulk {
#[command(flatten)]
connection: ConnectionArgs,
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
} => {
let session = session_for(connection, session_id).await?;
session
- .invoke(
- MxCommandKind::AdviseSupervisory,
- zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
- AdviseSupervisoryCommand {
- server_handle,
- item_handle,
- },
- ),
- )
+ .advise_supervisory(server_handle, item_handle)
.await?;
print_ok("advise-supervisory", json);
}
+ Command::Unregister {
+ connection,
+ session_id,
+ server_handle,
+ json,
+ } => {
+ let session = session_for(connection, session_id).await?;
+ session.unregister(server_handle).await?;
+ print_ok("unregister", json);
+ }
+ Command::AuthenticateUser {
+ connection,
+ session_id,
+ server_handle,
+ verify_user,
+ password,
+ password_env,
+ json,
+ } => {
+ // Resolve the credential from --password or the named env var.
+ // The password is passed straight to the typed helper and is never
+ // echoed to stdout/stderr or embedded in an error message.
+ let verify_user_password = password
+ .or_else(|| env::var(&password_env).ok())
+ .ok_or_else(|| Error::InvalidArgument {
+ name: "password".to_owned(),
+ detail: format!(
+ "supply --password or set the environment variable `{password_env}`"
+ ),
+ })?;
+ let session = session_for(connection, session_id).await?;
+ let user_id = session
+ .authenticate_user(server_handle, &verify_user, &verify_user_password)
+ .await?;
+ print_handle("userId", user_id, json);
+ }
+ Command::WriteSecured {
+ connection,
+ session_id,
+ server_handle,
+ item_handle,
+ value_type,
+ value,
+ current_user_id,
+ verifier_user_id,
+ json,
+ } => {
+ let session = session_for(connection, session_id).await?;
+ session
+ .write_secured(
+ server_handle,
+ item_handle,
+ current_user_id,
+ verifier_user_id,
+ parse_value(value_type, &value)?,
+ )
+ .await?;
+ print_ok("write-secured", json);
+ }
Command::SubscribeBulk {
connection,
session_id,
@@ -2489,6 +2600,59 @@ mod tests {
assert_eq!(value["workerProtocolVersion"], 1);
}
+ #[test]
+ fn parses_authenticate_user_command_with_password_env() {
+ let parsed = Cli::try_parse_from([
+ "mxgw",
+ "authenticate-user",
+ "--session-id",
+ "session-1",
+ "--server-handle",
+ "7",
+ "--verify-user",
+ "verifier",
+ "--password-env",
+ "MY_PW_VAR",
+ ]);
+ assert!(parsed.is_ok(), "parse failed: {parsed:?}");
+ }
+
+ #[test]
+ fn parses_write_secured_command() {
+ let parsed = Cli::try_parse_from([
+ "mxgw",
+ "write-secured",
+ "--session-id",
+ "session-1",
+ "--server-handle",
+ "12",
+ "--item-handle",
+ "34",
+ "--value-type",
+ "int32",
+ "--value",
+ "5",
+ "--current-user-id",
+ "1",
+ "--verifier-user-id",
+ "2",
+ ]);
+ assert!(parsed.is_ok(), "parse failed: {parsed:?}");
+ }
+
+ #[test]
+ fn parses_unregister_command() {
+ let parsed = Cli::try_parse_from([
+ "mxgw",
+ "unregister",
+ "--session-id",
+ "session-1",
+ "--server-handle",
+ "12",
+ ]);
+ assert!(parsed.is_ok(), "parse failed: {parsed:?}");
+ }
+
#[test]
fn parses_stream_alarms_command() {
let parsed = Cli::try_parse_from([
diff --git a/clients/rust/src/session.rs b/clients/rust/src/session.rs
index 63f8189..621474a 100644
--- a/clients/rust/src/session.rs
+++ b/clients/rust/src/session.rs
@@ -15,16 +15,19 @@ use crate::error::{ensure_protocol_success, Error};
use crate::generated::mxaccess_gateway::v1::mx_command::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{
- AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand,
- BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply,
- MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue,
- OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand,
- StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand,
- UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry,
- Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand,
- WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry,
+ ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
+ AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
+ AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
+ MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
+ MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
+ RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
+ StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
+ UnAdviseItemBulkCommand, UnregisterCommand, UnsubscribeBulkCommand, Write2BulkCommand,
+ Write2BulkEntry, Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand,
+ WriteSecured2BulkCommand, WriteSecured2BulkEntry, WriteSecured2Command,
+ WriteSecuredBulkCommand, WriteSecuredBulkEntry, WriteSecuredCommand,
};
-use crate::value::MxValue;
+use crate::value::{MxStatus, MxValue};
const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply)
}
+ /// Run MXAccess `Unregister` to release the given `ServerHandle` and the
+ /// items advised under it. Mirrors [`Session::register`]; the worker
+ /// returns no payload, so the call resolves to `()` on success.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`] if the worker reports a non-OK protocol
+ /// status and [`Error::MxAccess`] if MXAccess itself rejects the
+ /// unregister (negative `hresult` / non-success status), plus the usual
+ /// transport/status errors.
+ pub async fn unregister(&self, server_handle: i32) -> Result<(), Error> {
+ self.invoke(
+ MxCommandKind::Unregister,
+ Payload::Unregister(UnregisterCommand { server_handle }),
+ )
+ .await?;
+ Ok(())
+ }
+
/// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`.
///
@@ -230,6 +252,133 @@ impl Session {
Ok(())
}
+ /// Run MXAccess `AdviseSupervisory` to start supervisory-mode change
+ /// notifications for the given item. Mirrors [`Session::advise`]; the
+ /// worker returns no payload, so the call resolves to `()` on success.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`] for a non-OK protocol status and
+ /// [`Error::MxAccess`] when MXAccess reports a negative `hresult` /
+ /// non-success status, plus the usual transport/status errors.
+ pub async fn advise_supervisory(
+ &self,
+ server_handle: i32,
+ item_handle: i32,
+ ) -> Result<(), Error> {
+ self.invoke(
+ MxCommandKind::AdviseSupervisory,
+ Payload::AdviseSupervisory(AdviseSupervisoryCommand {
+ server_handle,
+ item_handle,
+ }),
+ )
+ .await?;
+ Ok(())
+ }
+
+ /// Run MXAccess `Suspend` on the given item and return the native
+ /// `MXSTATUS_PROXY` the worker reports for the operation.
+ ///
+ /// A top-level MXAccess failure (negative `hresult` or a non-success
+ /// top-level status) still surfaces as [`Error::MxAccess`] via the shared
+ /// reply validation; the returned [`MxStatus`] is the per-operation status
+ /// carried in the `SuspendReply` payload.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`] for a non-OK protocol status,
+ /// [`Error::MxAccess`] on an MXAccess-level failure, and
+ /// [`Error::MalformedReply`] if the OK reply lacks the `Suspend` payload,
+ /// plus the usual transport/status errors.
+ pub async fn suspend(&self, server_handle: i32, item_handle: i32) -> Result {
+ let reply = self
+ .invoke(
+ MxCommandKind::Suspend,
+ Payload::Suspend(SuspendCommand {
+ server_handle,
+ item_handle,
+ }),
+ )
+ .await?;
+
+ suspend_status(reply)
+ }
+
+ /// Run MXAccess `Activate` on the given item and return the native
+ /// `MXSTATUS_PROXY` the worker reports for the operation. See
+ /// [`Session::suspend`] for the status/error contract.
+ ///
+ /// # Errors
+ ///
+ /// Same conditions as [`Session::suspend`] (with the `Activate` payload).
+ pub async fn activate(&self, server_handle: i32, item_handle: i32) -> Result {
+ let reply = self
+ .invoke(
+ MxCommandKind::Activate,
+ Payload::Activate(ActivateCommand {
+ server_handle,
+ item_handle,
+ }),
+ )
+ .await?;
+
+ activate_status(reply)
+ }
+
+ /// Run MXAccess `AddBufferedItem` against `server_handle` and return the
+ /// assigned `ItemHandle`. Mirrors [`Session::add_item2`] — the buffered
+ /// item carries a caller-supplied context string.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`]/[`Error::MxAccess`] when the worker or
+ /// MXAccess rejects the item, [`Error::MalformedReply`] if the OK reply
+ /// lacks the item handle, plus the usual transport/status errors.
+ pub async fn add_buffered_item(
+ &self,
+ server_handle: i32,
+ item_definition: &str,
+ item_context: &str,
+ ) -> Result {
+ let reply = self
+ .invoke(
+ MxCommandKind::AddBufferedItem,
+ Payload::AddBufferedItem(AddBufferedItemCommand {
+ server_handle,
+ item_definition: item_definition.to_owned(),
+ item_context: item_context.to_owned(),
+ }),
+ )
+ .await?;
+
+ add_buffered_item_handle(&reply)
+ }
+
+ /// Run MXAccess `SetBufferedUpdateInterval` for `server_handle`. The
+ /// worker returns no payload, so the call resolves to `()` on success.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
+ /// status or an MXAccess-level failure, plus the usual transport/status
+ /// errors.
+ pub async fn set_buffered_update_interval(
+ &self,
+ server_handle: i32,
+ update_interval_milliseconds: i32,
+ ) -> Result<(), Error> {
+ self.invoke(
+ MxCommandKind::SetBufferedUpdateInterval,
+ Payload::SetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand {
+ server_handle,
+ update_interval_milliseconds,
+ }),
+ )
+ .await?;
+ Ok(())
+ }
+
/// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector.
///
@@ -628,6 +777,142 @@ impl Session {
Ok(())
}
+ /// Run MXAccess `WriteSecured` (single credential-verified write, no
+ /// caller-supplied timestamp).
+ ///
+ /// **MXAccess parity:** `WriteSecured` failing before a prior
+ /// [`Session::authenticate_user`] + [`Session::advise_supervisory`], or
+ /// before a value-bearing body, is the native contract, not a client bug —
+ /// the failure surfaces as [`Error::MxAccess`] (negative `hresult`) and is
+ /// **not** smoothed over. The `value` is credential-sensitive: it is placed
+ /// only in the wire command and is never logged or embedded in an error
+ /// message.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`] for a non-OK protocol status,
+ /// [`Error::MxAccess`] when MXAccess rejects the secured write, plus the
+ /// usual transport/status errors.
+ pub async fn write_secured(
+ &self,
+ server_handle: i32,
+ item_handle: i32,
+ current_user_id: i32,
+ verifier_user_id: i32,
+ value: MxValue,
+ ) -> Result<(), Error> {
+ self.invoke(
+ MxCommandKind::WriteSecured,
+ Payload::WriteSecured(WriteSecuredCommand {
+ server_handle,
+ item_handle,
+ current_user_id,
+ verifier_user_id,
+ value: Some(value.into_proto()),
+ }),
+ )
+ .await?;
+ Ok(())
+ }
+
+ /// Run MXAccess `WriteSecured2` (credential-verified write with a
+ /// caller-supplied timestamp). See [`Session::write_secured`] for the
+ /// parity and credential-handling contract.
+ ///
+ /// # Errors
+ ///
+ /// Same conditions as [`Session::write_secured`].
+ pub async fn write_secured2(
+ &self,
+ server_handle: i32,
+ item_handle: i32,
+ current_user_id: i32,
+ verifier_user_id: i32,
+ value: MxValue,
+ timestamp_value: MxValue,
+ ) -> Result<(), Error> {
+ self.invoke(
+ MxCommandKind::WriteSecured2,
+ Payload::WriteSecured2(WriteSecured2Command {
+ server_handle,
+ item_handle,
+ current_user_id,
+ verifier_user_id,
+ value: Some(value.into_proto()),
+ timestamp_value: Some(timestamp_value.into_proto()),
+ }),
+ )
+ .await?;
+ Ok(())
+ }
+
+ /// Run MXAccess `AuthenticateUser` and return the resolved MXAccess user
+ /// id.
+ ///
+ /// **Credential handling:** `verify_user_password` is a raw MXAccess
+ /// credential. It is placed only in the wire command; this helper never
+ /// logs it and never embeds it in an [`Error`] — the only error text that
+ /// can surface comes from `tonic::Status` messages and the reply's
+ /// diagnostic fields, both of which are scrubbed by the client's
+ /// credential-redaction seam (see [`crate::error`]). The reply itself
+ /// carries no echo of the credential.
+ ///
+ /// **MXAccess parity:** a failed authentication is a native outcome, not a
+ /// client error to paper over — it surfaces as [`Error::MxAccess`]
+ /// (negative `hresult`) with the credential absent from the message.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`] for a non-OK protocol status,
+ /// [`Error::MxAccess`] when MXAccess rejects the credential,
+ /// [`Error::MalformedReply`] if the OK reply lacks the user id, plus the
+ /// usual transport/status errors.
+ pub async fn authenticate_user(
+ &self,
+ server_handle: i32,
+ verify_user: &str,
+ verify_user_password: &str,
+ ) -> Result {
+ let reply = self
+ .invoke(
+ MxCommandKind::AuthenticateUser,
+ Payload::AuthenticateUser(AuthenticateUserCommand {
+ server_handle,
+ verify_user: verify_user.to_owned(),
+ verify_user_password: verify_user_password.to_owned(),
+ }),
+ )
+ .await?;
+
+ authenticate_user_id(&reply)
+ }
+
+ /// Run MXAccess `ArchestrAUserToId` to resolve an ArchestrA user GUID to
+ /// its MXAccess user id.
+ ///
+ /// # Errors
+ ///
+ /// Returns [`Error::Command`]/[`Error::MxAccess`] on a non-OK protocol
+ /// status or MXAccess-level failure, [`Error::MalformedReply`] if the OK
+ /// reply lacks the user id, plus the usual transport/status errors.
+ pub async fn archestra_user_to_id(
+ &self,
+ server_handle: i32,
+ user_id_guid: &str,
+ ) -> Result {
+ let reply = self
+ .invoke(
+ MxCommandKind::ArchestraUserToId,
+ Payload::ArchestraUserToId(ArchestrAUserToIdCommand {
+ server_handle,
+ user_id_guid: user_id_guid.to_owned(),
+ }),
+ )
+ .await?;
+
+ archestra_user_id(&reply)
+ }
+
/// Open the per-session event stream from the beginning.
///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
@@ -769,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result {
}
}
+fn add_buffered_item_handle(reply: &MxCommandReply) -> Result {
+ match reply.payload.as_ref() {
+ Some(mx_command_reply::Payload::AddBufferedItem(add_buffered)) => {
+ Ok(add_buffered.item_handle)
+ }
+ _ => reply
+ .return_value
+ .as_ref()
+ .and_then(int32_reply_value)
+ .ok_or_else(|| Error::MalformedReply {
+ detail:
+ "add_buffered_item reply lacked an item_handle payload or int32 return_value"
+ .to_owned(),
+ }),
+ }
+}
+
+fn authenticate_user_id(reply: &MxCommandReply) -> Result {
+ match reply.payload.as_ref() {
+ Some(mx_command_reply::Payload::AuthenticateUser(authenticate)) => Ok(authenticate.user_id),
+ _ => Err(Error::MalformedReply {
+ detail: "authenticate_user reply lacked an AuthenticateUser payload".to_owned(),
+ }),
+ }
+}
+
+fn archestra_user_id(reply: &MxCommandReply) -> Result {
+ match reply.payload.as_ref() {
+ Some(mx_command_reply::Payload::ArchestraUserToId(archestra)) => Ok(archestra.user_id),
+ _ => Err(Error::MalformedReply {
+ detail: "archestra_user_to_id reply lacked an ArchestraUserToId payload".to_owned(),
+ }),
+ }
+}
+
+fn suspend_status(reply: MxCommandReply) -> Result {
+ match reply.payload {
+ Some(mx_command_reply::Payload::Suspend(suspend)) => suspend
+ .status
+ .map(MxStatus::from_proto)
+ .ok_or_else(|| Error::MalformedReply {
+ detail: "suspend reply payload lacked a status entry".to_owned(),
+ }),
+ _ => Err(Error::MalformedReply {
+ detail: "suspend reply did not carry a Suspend payload".to_owned(),
+ }),
+ }
+}
+
+fn activate_status(reply: MxCommandReply) -> Result {
+ match reply.payload {
+ Some(mx_command_reply::Payload::Activate(activate)) => activate
+ .status
+ .map(MxStatus::from_proto)
+ .ok_or_else(|| Error::MalformedReply {
+ detail: "activate reply payload lacked a status entry".to_owned(),
+ }),
+ _ => Err(Error::MalformedReply {
+ detail: "activate reply did not carry an Activate payload".to_owned(),
+ }),
+ }
+}
+
enum BulkReplyKind {
AddItem,
AdviseItem,
diff --git a/clients/rust/tests/client_behavior.rs b/clients/rust/tests/client_behavior.rs
index 477fa81..faf7fba 100644
--- a/clients/rust/tests/client_behavior.rs
+++ b/clients/rust/tests/client_behavior.rs
@@ -23,10 +23,11 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
- BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult,
- CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent,
- MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource,
- MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
+ AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
+ BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
+ MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
+ MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
+ OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
@@ -624,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32));
}
+#[tokio::test]
+async fn advise_supervisory_round_trips_and_sends_advise_supervisory_kind() {
+ let state = Arc::new(FakeState::default());
+ let endpoint = spawn_fake_gateway(state.clone()).await;
+ let client = GatewayClient::connect(ClientOptions::new(endpoint))
+ .await
+ .unwrap();
+ let session = client.session("session-fixture");
+
+ session.advise_supervisory(12, 34).await.unwrap();
+
+ let last_command = state.last_command_kind.lock().await;
+ assert_eq!(*last_command, Some(MxCommandKind::AdviseSupervisory as i32));
+}
+
+#[tokio::test]
+async fn unregister_round_trips_and_sends_unregister_kind() {
+ let state = Arc::new(FakeState::default());
+ let endpoint = spawn_fake_gateway(state.clone()).await;
+ let client = GatewayClient::connect(ClientOptions::new(endpoint))
+ .await
+ .unwrap();
+ let session = client.session("session-fixture");
+
+ session.unregister(12).await.unwrap();
+
+ let last_command = state.last_command_kind.lock().await;
+ assert_eq!(*last_command, Some(MxCommandKind::Unregister as i32));
+}
+
+#[tokio::test]
+async fn write_secured_surfaces_native_mxaccess_failure_and_redacts_diagnostic() {
+ // MXAccess parity: WriteSecured failing (e.g. before authenticate +
+ // advise-supervisory) is a native outcome, surfaced — not smoothed over.
+ // The scripted reply carries an Ok protocol envelope but a negative
+ // hresult, so this also proves the typed helper runs ensure_mxaccess_success.
+ let state = Arc::new(FakeState::default());
+ *state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
+ let endpoint = spawn_fake_gateway(state.clone()).await;
+ let client = GatewayClient::connect(ClientOptions::new(endpoint))
+ .await
+ .unwrap();
+ let session = client.session("session-fixture");
+
+ let error = session
+ .write_secured(12, 34, 0, 0, ClientMxValue::int32(1))
+ .await
+ .unwrap_err();
+
+ let Error::MxAccess(mx_access) = &error else {
+ panic!("write_secured must surface the native failure as Error::MxAccess: {error:?}");
+ };
+ assert_eq!(mx_access.reply().hresult, Some(-2_147_217_900));
+ let rendered = error.to_string();
+ assert!(rendered.contains(""), "diagnostic: {rendered}");
+ assert!(
+ !rendered.contains("leaked_secret"),
+ "credential-shaped diagnostic must be scrubbed: {rendered}"
+ );
+
+ let last_command = state.last_command_kind.lock().await;
+ assert_eq!(*last_command, Some(MxCommandKind::WriteSecured as i32));
+}
+
+#[tokio::test]
+async fn authenticate_user_returns_user_id_and_transmits_credential_on_wire() {
+ let state = Arc::new(FakeState::default());
+ let endpoint = spawn_fake_gateway(state.clone()).await;
+ let client = GatewayClient::connect(ClientOptions::new(endpoint))
+ .await
+ .unwrap();
+ let session = client.session("session-fixture");
+
+ let user_id = session
+ .authenticate_user(7, "verifier", "sup3r-s3cret-pw")
+ .await
+ .unwrap();
+
+ assert_eq!(user_id, 4242);
+ let captured = state
+ .last_authenticate_user
+ .lock()
+ .await
+ .take()
+ .expect("fake should have captured an AuthenticateUserCommand");
+ assert_eq!(captured.server_handle, 7);
+ assert_eq!(captured.verify_user, "verifier");
+ // The credential must reach the wire so authentication can succeed...
+ assert_eq!(captured.verify_user_password, "sup3r-s3cret-pw");
+ let last_command = state.last_command_kind.lock().await;
+ assert_eq!(*last_command, Some(MxCommandKind::AuthenticateUser as i32));
+}
+
+#[tokio::test]
+async fn authenticate_user_keeps_credentials_out_of_surfaced_errors() {
+ // ...but a native authentication failure must never leak the credential
+ // into the error's Display or Debug rendering.
+ let state = Arc::new(FakeState::default());
+ *state.invoke_override.lock().await = Some(InvokeOverride::MxAccessFailure);
+ let endpoint = spawn_fake_gateway(state.clone()).await;
+ let client = GatewayClient::connect(ClientOptions::new(endpoint))
+ .await
+ .unwrap();
+ let session = client.session("session-fixture");
+
+ let password = "unique-credential-9f3b2";
+ let error = session
+ .authenticate_user(7, "verifier", password)
+ .await
+ .unwrap_err();
+
+ assert!(
+ matches!(error, Error::MxAccess(_)),
+ "native auth failure should surface as Error::MxAccess: {error:?}"
+ );
+ let display = error.to_string();
+ let debug = format!("{error:?}");
+ assert!(
+ !display.contains(password),
+ "credential leaked into Display: {display}"
+ );
+ assert!(
+ !debug.contains(password),
+ "credential leaked into Debug: {debug}"
+ );
+}
+
#[tokio::test]
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
let state = Arc::new(FakeState::default());
@@ -732,6 +860,10 @@ struct FakeState {
/// Captures the last `WriteCommand` payload received, populated when the
/// `WriteOk` override is active. Used by `write_array_elements` e2e test.
last_write_command: Mutex]