Files
Joseph Doherty bde042b4d4 feat(clients): CLI-04 typed single-item command parity (+CLI-30 unregister) — 4/5 clients
Every parity-critical single-item MXAccess command now has a typed session
helper instead of only a raw-Invoke escape hatch. Added per client:
- Phase 1: AdviseSupervisory, WriteSecured, WriteSecured2, AuthenticateUser,
  ArchestrAUserToId
- Phase 2: AddBufferedItem, SetBufferedUpdateInterval, Suspend, Activate
- CLI-30: Unregister (Rust + .NET; Go/Python already had it)

Each wraps the existing raw-command machinery (no new wire surface) and runs the
same MXAccess-level reply validation (hresult < 0 + MxStatusProxy). MXAccess
parity preserved: WriteSecured before AuthenticateUser+AdviseSupervisory surfaces
the native failure unchanged (not pre-validated/reordered). Credentials
(AuthenticateUser password, WriteSecured payloads) route through each client's
secret-redaction seam and never reach logs/exceptions/ToString/Debug/Display;
each suite asserts a distinctive credential is absent from surfaced errors. New
CLI subcommands source credentials via flag/env, never echoed.

- .NET: 21 helpers (validated + Raw), CLI subcommands, multi-secret CLI redactor.
  Build clean (0 warn), 102 passed.
- Go: 9 helpers + *Raw variants, redactSecrets seam, promoted CLI advise-supervisory
  to typed. gofmt/vet/build/test clean.
- Rust: 10 helpers incl. unregister; verified ensure_mxaccess_success runs on
  secured paths; error.rs credential scrub. fmt/check/test/clippy clean.
- Python: 9 async helpers, redact_secret seam + _invoke_redacted, CLI commands.
  145 passed.
- Shared doc: ClientLibrariesDesign "Typed Command Parity" section.

Java client typed parity is batched to windev (no local JRE); CLI-04 + CLI-30
stay open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:41:43 -04:00

197 lines
6.1 KiB
Go

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 = "<redacted>"
// 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
// the channel closes, so overflow is always observable rather than silently
// dropping events. Match it with errors.Is.
var ErrSlowConsumer = errors.New("mxgateway: event consumer fell behind; stream terminated")
// GatewayError wraps transport-level gRPC failures.
type GatewayError struct {
// Op names the operation that failed (for example "dial" or "invoke").
Op string
// Err is the underlying gRPC or transport error.
Err error
}
// Error returns the formatted gateway error message.
func (e *GatewayError) Error() string {
if e == nil {
return ""
}
if e.Op == "" {
return fmt.Sprintf("mxgateway: %v", e.Err)
}
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
}
// Unwrap returns the wrapped transport error.
func (e *GatewayError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
// CommandError reports a non-OK gateway protocol status and keeps the raw
// command reply when one exists.
type CommandError struct {
// Op names the gateway operation that produced the non-OK status.
Op string
// Status carries the gateway-reported protocol status.
Status *ProtocolStatus
// Reply is the raw command reply, when one was returned alongside the status.
Reply *MxCommandReply
}
// Error returns the formatted command error message.
func (e *CommandError) Error() string {
if e == nil {
return ""
}
status := e.Status
if status == nil {
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
}
if status.GetMessage() == "" {
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
}
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
}
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
type MxAccessError struct {
// Command is the wrapped CommandError when the protocol status carried one.
Command *CommandError
// Reply is the raw MXAccess command reply that surfaced the failure.
Reply *MxCommandReply
}
// Error returns the formatted MXAccess error message.
func (e *MxAccessError) Error() string {
if e == nil {
return ""
}
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
return e.Command.Error()
}
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
}
if e.Reply != nil && e.Reply.Hresult != nil {
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
}
return "mxgateway: MXAccess command failed"
}
// Unwrap returns the wrapped CommandError, when one is present.
func (e *MxAccessError) Unwrap() error {
if e == nil {
return nil
}
return e.Command
}
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
return nil
}
commandError := &CommandError{
Op: op,
Status: status,
Reply: reply,
}
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
return &MxAccessError{
Command: commandError,
Reply: reply,
}
}
return commandError
}
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
// MXSTATUS_PROXY entries.
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
if reply == nil {
return nil
}
if reply.Hresult != nil && reply.GetHresult() != 0 {
return &MxAccessError{Reply: reply}
}
for _, status := range reply.GetStatuses() {
if !StatusSucceeded(status) {
return &MxAccessError{Reply: reply}
}
}
return nil
}