Compare commits

..

25 Commits

Author SHA1 Message Date
Joseph Doherty 2201fd8828 docs(archreview): wrap P2 epic — defer TST-15 + TST-24 (user decision)
ci / live-mxaccess (push) Waiting to run
ci / portable (push) Failing after 41s
ci / live-mxaccess (pull_request) Waiting to run
ci / java (push) Failing after 51s
ci / portable (pull_request) Failing after 47s
ci / java (pull_request) Failing after 50s
ci / windows (push) Failing after 8s
ci / windows (pull_request) Failing after 7s
Epic bucket: 5/7 done (CLI-15, CLI-04, CLI-30, TST-01, TST-04). TST-15
(Phase-4 ACL feature; SEC-25 already prevents value leakage) and TST-24
(CI-gated on TST-03) deferred to a follow-up. Net P2: 35/38 Done.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 17:04:22 -04:00
Joseph Doherty f1d9ad5ca2 docs(archreview): CLI-15/CLI-04/CLI-30/TST-01 -> Done (Java completes 5/5)
Session-resilience epic Phase 3 fully closed (reconnect protocol + client
consumers across all five clients + server e2e test).

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:57:47 -04:00
Joseph Doherty 1cc0fa4007 feat(java-client): CLI-15 ReplayGap surface + CLI-04 typed-command parity (5/5 complete)
Completes both findings across all five clients — done locally on the Mac now
that homebrew openjdk@17 is available (JAVA_HOME=/opt/homebrew/opt/openjdk@17).

CLI-15: new MxEventStreamItem record + MxEventStream.nextItem() surfaces the
gateway's ReplayGap sentinel as a typed, non-terminal signal (isReplayGap()/
replayGap()/event()); existing Iterator<MxEvent> path unchanged, sentinel never
swallowed/synthesized. Javadoc covers gap semantics + resume contract.

CLI-04: typed single-item helpers on MxGatewaySession — Phase 1
adviseSupervisory/writeSecured/writeSecured2/authenticateUser/archestrAUserToId,
Phase 2 addBufferedItem/setBufferedUpdateInterval/suspend/activate (unregister
already present). Each routes through invokeCommand -> ensureProtocolSuccess +
ensureMxAccessSuccess (same validation as bulk). MXAccess parity preserved.
Credentials flow only into the request proto; exceptions carry only the reply and
gRPC status text is scrubbed via MxGatewaySecrets.redactCredentials — tests
assert the password/secured value is absent from getMessage()/toString()/CLI
output. New CLI subcommands write-secured/authenticate-user (credential via
--password/--password-env, prints only the user id).

gradle test: 106 tests, 0 failures (58 client + 48 cli); no generated churn.
Shared docs: ClientLibrariesDesign + CLAUDE.md updated to "all five clients".

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:57:13 -04:00
Joseph Doherty 908951be9d docs(archreview): E2 CLI-04 In progress (4/5) + CLI-30 Done; Java now local
Java client toolchain (homebrew openjdk@17) works on the Mac, so the remaining
Java halves of CLI-15/CLI-04 are done locally this session, not batched to windev.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:44:24 -04:00
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
Joseph Doherty 4090a478c8 docs(archreview): TST-04 -> Done (epic governance decision landed)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:33:51 -04:00
Joseph Doherty ed3c6c61c5 docs(archreview): TST-04 governance — Phase 3 done, Phase 4 scoped, Phase 5 deferred
Resolve the session-resilience epic's shipped-vs-planned entanglement:

- Phase 3 (reconnect) finished: Task 13 = TST-02 (owner-scoped attach, P0),
  Task 15 = TST-01 (reconnect integration test), Task 14 = CLI-15 for 4/5
  clients (Java pending, windev batch).
- Phase 4 (per-session dashboard ACL) scoped as TST-15; the open Viewer-default
  decision is settled: admin-sees-all, Viewer strict per owned/granted session
  (matches TST-02's gRPC owner binding).
- Phase 5 (orphan-worker reattach) marked DEFERRED, not planned. The
  EnableOrphanReattach flag does not exist and must not be referenced as if it
  does. The CLAUDE.md "gateway restart does not reattach orphan workers"
  invariant stands.

Updates oldtasks.md, the tasks.json mirror (statuses + governance note), and the
CLAUDE.md reconnect paragraph (clients now consume ReplayGap; reattach deferred).

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:33:25 -04:00
Joseph Doherty ef498c80c2 docs(archreview): E1 reconnect-replay — CLI-15 + TST-01 In progress
CLI-15 (4/5 clients, commit 0c6e5b3) and TST-01 server e2e test (commit fed0685)
landed; both gated on the Java client (windev batch) before flipping to Done.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:27:47 -04:00
Joseph Doherty fed0685633 test(gateway): TST-01 end-to-end reconnect/replay integration test
Proves the default-on reconnect protocol end to end through the real gRPC
StreamEvents path via the fake worker harness (no live COM). Two facts:

- ReconnectInsideRetainedWindow_ReplaysTailNoGap: capacity 16 retains all
  events; reconnect from a mid-batch after_worker_sequence cursor replays
  exactly the events with WorkerSequence > cursor (retained tail + events
  emitted while detached), strictly ascending, distinct, no ReplayGap.
- ReconnectWithStaleCursor_EmitsReplayGapSentinelFirst: capacity 3 with 6
  events forces eviction; reconnect with AfterWorkerSequence=1 yields the
  ReplayGap sentinel first (Family=Unspecified, no body, RequestedAfterSequence=1,
  OldestAvailableSequence=oldest retained), then the retained tail ascending.

Single-subscriber mode: the first stream is fully detached (cancel + await the
stream task runs EventStreamService's finally, dropping the subscriber count to
0) before reconnect; the distributor + replay ring are created once per session
and survive detach, so events emitted while detached are retained.

macOS note: fake-worker E2E tests need TMPDIR=/tmp (default macOS TMPDIR pushes
the CoreFxPipe Unix-socket path past the 104-byte sun_path limit). Windows CI
is unaffected. Server reconnect/replay behavior matched the contract exactly.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:26:40 -04:00
Joseph Doherty 0c6e5b3ace feat(clients): CLI-15 surface ReplayGap reconnect sentinel as typed signal (4/5 clients)
The gateway emits a ReplayGap sentinel MxEvent at the head of a StreamEvents
stream resumed via after_worker_sequence when the requested cursor predates the
oldest retained event. Clients previously ignored it, silently mis-treating a
lossy resume as continuous. Each client now surfaces the sentinel as a distinct,
typed, non-terminal signal (never synthesized, never swallowed) so a consumer can
detect the gap and re-snapshot; resume contract is
after_worker_sequence = oldest_available_sequence - 1.

- .NET: MxEventStreamItem (IsReplayGap/ReplayGap/Event) via new StreamEventItemsAsync
  + AsStreamItemsAsync extension. Build clean, 87 passed.
- Go: EventResult.ReplayGap field + IsReplayGap(); ReplayGap type alias. build/vet/test clean.
- Rust: EventItem enum (Event/ReplayGap); EventStream now yields Result<EventItem, Error>;
  CLI renders REPLAY_GAP line / replayGap JSON. fmt/check/test/clippy clean.
- Python: ReplayGap dataclass; stream_events yields pb.MxEvent | ReplayGap. 131 passed.
- Shared docs: ClientLibrariesDesign non-goals reframed (reconnect-replay protocol is
  consumable; auto-reconnect stays a non-goal); CrossLanguageSmokeMatrix resume-gap note.

Java client is deferred to the windev batch (no local JRE); CLI-15 stays open until it lands.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:22:28 -04:00
Joseph Doherty c823a7b60b docs(archreview): mark WRK-06/11/12 + IPC-15 Done (P2 worker hot-path, windev-verified)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 16:00:29 -04:00
Joseph Doherty f61c816acf perf(worker): event hot-path allocation + flush cuts (WRK-06/11/12, IPC-15)
WRK-06: MxStatusProxyConverter caches the four resolved FieldInfo per status
type in a static ConcurrentDictionary (the GetField metadata scan ran 4x per
status per event on the STA path). GetValue+Convert.ToInt32 still run per event
(late-bound RCW). Exceptions byte-identical: missing-field message unchanged
(ResolveField, not cached on throw via GetOrAdd); null-value message unchanged.

WRK-11: MxAccessEventQueue.Enqueue takes ownership of the passed MxEvent -
stamps WorkerSequence/WorkerTimestamp on it in place and enqueues it, no
Clone(). Audited all 3 callers (base/alarm event sinks, provider-mode handler):
each builds a fresh event per Enqueue, none reuse it. MxAccessValueCache.Set now
deep-copies its retained Value/SourceTimestamp/Statuses so the cache snapshot
never aliases the queue-owned (later serialized) event. Net: alarm/other events
clone nothing (was full clone); data-change clones payload-only.

WRK-12: WorkerFrameWriter coalesces the flush across a drained batch - each
frame is written but not flushed individually; one FlushAsync after the batch,
then all written frames complete. Preserves the written+flushed completion
contract; a burst of N events costs 1 flush, not N. On write failure the whole
in-flight batch + queue fail so no caller hangs.

IPC-15 (doc): the multi-event WorkerEnvelope body remains unimplemented (wire
still carries one event per worker_event frame); gateway.md Performance section
now distinguishes the shipped flush-coalescing from that deferred proto change.

net48-safe (no init/records; readonly struct cache entry). Worker builds x86
only - verification on windev. Tests added: converter cache-reuse, queue
ownership-transfer, value-cache snapshot independence, writer batch-flush-once.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:59:45 -04:00
Joseph Doherty 73ce824f6c docs(archreview): mark TST-11/WRK-15/TST-23 Done (P2 quick items)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:33:52 -04:00
Joseph Doherty ec6f82b124 fix(versioning,docs): stamp .NET assembly version + worker doc drift (TST-11, WRK-15)
TST-11: single-source the .NET-side version in src/Directory.Build.props so
Server/Worker/Contracts/tests stamp 0.1.2 (was the SDK default 1.0.0) and
InformationalVersion carries the git short SHA (0.1.2+<sha>) for support
correlation; the git query is guarded (ContinueOnError) so a build outside a
git checkout still succeeds. Verified: Server assembly stamps 0.1.2+579282f,
build clean. Kept at 0.1.2 (matches Contracts + aligned Python/Rust/Go
clients); Java leads at 0.2.0 post JDK-17 retarget. Convergence to a single
0.2.0 cadence left as a release decision, not forced here.

WRK-15 (docs-only, no x86 worker build needed): correct the STA thread name in
docs/WorkerSta.md + docs/MxAccessWorkerInstanceDesign.md to the actual
MxGateway.Worker.STA (was the FQ ZB.MOM.WW.MxGateway.Worker.STA), and fix the
stale heartbeat-counter note - CaptureHeartbeat now populates event queue depth
(eventQueue.Count) and sequence (LastEventSequence) from the live queue.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:33:22 -04:00
Joseph Doherty 579282f015 docs(archreview): mark SEC-25/SEC-30 Done (P2 dashboard value redaction + value-log doc)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:29:12 -04:00
Joseph Doherty e15c3cb052 fix(dashboard): redact mirror tag values when ShowTagValues off; trim value-log doc (SEC-25, SEC-30)
SEC-25 (near-term hardening; full per-session EventsHub ACL stays deferred to
roadmap item 12): DashboardEventBroadcaster now redacts tag values from a deep
CLONE of the event before mirroring to SignalR when Dashboard:ShowTagValues is
false (the default). Clears MxEvent.value (covers OnDataChange/OnWriteComplete/
OperationComplete/OnBufferedDataChange — their bodies are empty discriminators,
values ride in the top-level field incl. buffered arrays) and the alarm body's
current_value/limit_value. Source event never mutated (shared with the gRPC
path + replay ring) - verified by a source-not-mutated test. Makes the formerly
dead ShowTagValues flag live for the mirror. EventsHub TODO(per-session-acl)
kept and tied to roadmap item 12. Tests: DashboardEventBroadcasterTests (3).

SEC-30: trim docs/Diagnostics.md to mark opt-in command-value logging as NOT
YET IMPLEMENTED (no LogCommandValues knob, RedactCommandValue has no call
sites, no values logged); wiring deferred pending secured-bulk redaction
coverage (SEC-13). No option/call sites added.

Server build clean (0 warnings); Dashboard tests 152/152.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:28:24 -04:00
Joseph Doherty 96702321b1 docs(archreview): mark gateway hot-path P2 findings Done (GWC-06/07/08/14/15, IPC-05/13/14)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:19:38 -04:00
Joseph Doherty cf1b3d40a5 perf(gateway): replay ring LinkedList -> circular array (GWC-14)
Replace the replay buffer's LinkedList<ReplayEntry> (a node allocation per
retained event on the fan-out hot path) with a preallocated ReplayEntry[] ring
sized to ReplayBufferCapacity, tracked by _replayHead (oldest) + _replayCount.
Appending a retained event now allocates nothing.

Behavior preserved exactly: ascending-WorkerSequence append order; capacity
eviction (overwrite head + advance when full); time trim via
_timeProvider.GetUtcNow() cutoff at the same three call sites; oldest-read for
ReplayGap math; both replay-from-sequence query paths + highest-seen tracking;
same _replayLock. Capacity-0 stays retain-nothing (guarded early return, no
modulo-by-zero).

Server build clean (0 warnings); Distributor/Replay tests 33/33 incl. two new
cases (multi-wrap ring keeps newest in order; capacity-1 overwrite + gap).

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:17:11 -04:00
Joseph Doherty 5e2e40a927 perf(gateway): trim event/command hot-path allocations (GWC-06/07/15, IPC-05)
Behavior-preserving allocation cuts on the per-event/per-command path:
- GWC-06: StreamEvents send-timing uses Stopwatch.GetTimestamp() +
  GetElapsedTime() instead of a per-event Stopwatch allocation (same measured
  span).
- GWC-07/IPC-05 (event): MapEvent transfers ownership of the inner MxEvent
  instead of .Clone()-ing it. Safe: WorkerEvent is parsed fresh per pipe frame
  with the distributor pump as its single consumer (GWC-01), MapEvent runs once
  before fan-out, and every downstream consumer (subscribers, replay ring)
  only READS the event (WorkerSequence is stamped upstream; verified no
  post-mapping mutation). Comment documents the invariant + restore-clone
  caveat if a second consumer is added.
- IPC-05 (command): CreateCommandEnvelope no longer re-clones; MapCommand
  already isolated the graph from the caller-owned gRPC message.
- GWC-15: grpc_stream_queue.depth converts from a per-event push counter to an
  ObservableGauge summing registered channel sources at scrape time only
  (name/semantics unchanged); removes all per-event .Count/lock work.

Kept every load-bearing isolation clone (MapCommand, Invoke, bulk filters,
MapCommandReply). Server build clean (0 warnings); EventStream/Metrics/
Distributor/Mapper tests 62/62 (incl. formerly-flaky queue-depth tests, now
green under the lazy gauge, + 2 new MapEvent ownership tests). Docs: Metrics.md,
Grpc.md.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 15:12:43 -04:00
Joseph Doherty baae59ce3b perf(gateway): pool+coalesce worker pipe frame I/O (GWC-08, IPC-13, IPC-14)
Gateway server-side named-pipe frame path, wire format byte-identical:
- Writer (GWC-08/IPC-13): serialize once into a single ArrayPool-rented buffer
  holding the 4-byte LE length prefix + payload, then one WriteAsync. Removes
  the second serialization pass (ToByteArray re-ran CalculateSize), the
  separate prefix array + second stream write, and per-frame heap allocation.
- Reader (IPC-14): rent the payload buffer from ArrayPool instead of new byte[]
  per frame; read/parse bounded to the real length, return in finally. Matches
  the worker side which already pools.
- Correctness: length tracked separately from (larger) pool capacity; every
  rented buffer returned exactly once in finally incl. the malformed-protobuf
  path; ParseFrom copies into the message so the envelope never aliases the
  returned buffer.

Cross-checked LE-prefix framing agrees across gateway+worker writer/reader.
Server build clean (0 warnings); WorkerFrameProtocol tests 10/10 incl. a new
large-payload-near-cap round-trip (forces pool buffer > frame). The 2 failing
WorkerClient/FakeWorkerHarness tests are the known macOS UDS 104-char path
limit, not a regression.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:58:09 -04:00
Joseph Doherty 34aede6767 docs(archreview): mark SEC-03 Done (P2 dashboard __Host- cookie prefix)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:53:47 -04:00
Joseph Doherty 7d42c85345 fix(dashboard): conditionally restore __Host- cookie prefix (SEC-03)
Restore the __Host- browser guarantees for the default secure deployment
without breaking plaintext/dev:
- DashboardServiceCollectionExtensions PostConfigure now resolves the cookie
  name as: explicit MxGateway:Dashboard:CookieName override wins; else
  __Host-MxGatewayDashboard when SecurePolicy==Always (RequireHttpsCookie
  true); else the plain MxGatewayDashboard default. Guard: never apply the
  __Host- prefix without Secure (browsers silently drop it).
- DashboardAuthenticationDefaults: add SecureCookieName const; keep the plain
  CookieName as the non-secure fallback.
- Docs corrected to the actual conditional contract (five stale claims):
  gateway.md, GatewayProcessDesign.md, ImplementationPlanGateway.md,
  GatewayDashboardDesign.md, CLAUDE.md; GatewayConfiguration.md phrasing
  tightened.
- Test: DashboardCookieOptionsTests asserts the name flips with
  RequireHttpsCookie and that an explicit override wins.

Server build clean (0 warnings); Dashboard tests 149/149.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:53:19 -04:00
Joseph Doherty 5a3951bcfd docs(archreview): mark P2 Wave A findings Done (13: doc-drift + version alignment)
Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:47:11 -04:00
Joseph Doherty 06e1046317 docs(archreview): P2 doc-drift sweep (IPC-06/07/17/21, SEC-09/22, CLI-12/16, TST-13)
Reconcile load-bearing docs with shipped behavior:
- IPC-06: gateway.md Worker Envelope sketch -> points to mxaccess_worker.proto
  as source of truth (string correlation_id, real oneof arms incl.
  worker_shutdown_ack/worker_ready).
- IPC-07: docs/Grpc.md six RPCs -> seven; document QueryActiveAlarms handler
  + validation row.
- IPC-21: gateway.md Session RPC moved from live API into a 'Future work: not
  implemented' subsection.
- TST-13: drop stale design-era sketches from gateway.md; correct the
  single-subscriber-default (config-gated fan-out) note.
- SEC-09: dashboard GroupToRole sample GwAdmin:Admin -> Administrator so it
  passes GatewayOptionsValidator; clarify Administrator is the canonical role.
- SEC-22: rewrite docs/Authentication.md to the pipeline that actually ships
  (ZB.MOM.WW.Auth.ApiKeys package + gateway-owned CachingApiKeyVerifier,
  CoalescingMarkApiKeyStore, CanonicalForwardingApiKeyAuditStore, etc.);
  remove 18 stale type names (grep-verified absent).
- IPC-17: correct wrong Python generated dir (mxgateway -> zb_mom_ww_mxgateway)
  in CLAUDE.md + 3 docs.
- CLI-12: Java docs Java 21 -> Java 17 (JDK17 retarget for Ignition 8.3).
- CLI-16: docs/ClientPackaging.md reconciled with real .slnx, Python package
  name, and gradle project names; fix stale generateProto task name.

Docs-only; type/path/version claims verified against source.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:46:15 -04:00
Joseph Doherty 4fe1917aa6 fix(clients): align client version constants to package manifests (CLI-18/21/26/29)
- .NET: add explicit <Version>0.1.2</Version> to client csproj (CLI-18)
- Go: ClientVersion 0.1.0-dev -> 0.1.2 (CLI-21)
- Python: __version__ 0.1.0 -> 0.1.2 to match pyproject.toml (CLI-26)
- Rust: CLIENT_VERSION now sourced from env!(CARGO_PKG_VERSION) so it
  cannot drift from Cargo.toml (CLI-29)

Verified: dotnet build clean; go build/test ok; cargo check/test/clippy
clean; pytest 127 passed. P2 client version-drift cluster.

Claude-Session: https://claude.ai/code/session_01DMXXvNuPekkkrTEyPNxEkW
2026-07-09 14:43:03 -04:00
159 changed files with 7757 additions and 1327 deletions
+22 -29
View File
@@ -49,13 +49,6 @@ jobs:
- name: Build NonWindows solution - name: Build NonWindows solution
run: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx -c Release run: dotnet build src/ZB.MOM.WW.MxGateway.NonWindows.slnx -c Release
# GitHub-hosted runners ship pwsh; the self-hosted act image does not. Install it as a
# .NET global tool (the SDK is already set up above) so the codegen check's `shell: pwsh` works.
- name: Install PowerShell (pwsh)
run: |
dotnet tool install --global PowerShell
echo "$HOME/.dotnet/tools" >> "$GITHUB_PATH"
# IPC-01 / IPC-19 / IPC-20: descriptor set + Contracts/Generated must match the current protos. # IPC-01 / IPC-19 / IPC-20: descriptor set + Contracts/Generated must match the current protos.
- name: Codegen / descriptor freshness - name: Codegen / descriptor freshness
shell: pwsh shell: pwsh
@@ -100,32 +93,32 @@ jobs:
with: with:
distribution: temurin distribution: temurin
java-version: '17' java-version: '17'
# GitHub-hosted runners ship gradle on PATH; the self-hosted act image does not, and the repo
# has no gradle wrapper. act also can't resolve the gradle/actions monorepo action, so install
# gradle directly (pinned to 9.5.1, matching the local homebrew build in CLAUDE.md/memory).
- name: Install Gradle 9.5.1
run: |
curl -sSL "https://services.gradle.org/distributions/gradle-9.5.1-bin.zip" -o /tmp/gradle.zip
sudo unzip -q -d /opt/gradle /tmp/gradle.zip
echo "/opt/gradle/gradle-9.5.1/bin" >> "$GITHUB_PATH"
- name: Gradle test - name: Gradle test
working-directory: clients/java working-directory: clients/java
run: gradle test run: gradle test
- name: Revert spurious protobuf-version churn (no .proto changed) - name: Revert spurious protobuf-version churn (no .proto changed)
# Both generated aggregates can pick up protobuf-runtime-version churn on regen; revert run: git checkout -- clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java || true
# both so verify-clean still catches a real, uncommitted proto/codegen change elsewhere.
run: |
git checkout -- clients/java/src/main/generated/main/java/mxaccess_gateway/v1/MxaccessGateway.java || true
git checkout -- clients/java/src/main/generated/main/java/mxaccess_worker/v1/MxaccessWorker.java || true
- name: Verify generated tree is clean - name: Verify generated tree is clean
run: git diff --exit-code -- clients/java/src/main/generated run: git diff --exit-code -- clients/java/src/main/generated
# NOTE: there is intentionally no `windows` or `live-mxaccess` job here. windows:
# The x86 / net48 Worker and live-MXAccess tests need a Windows host, but Gitea's act_runner # Self-hosted windev runner (10.100.0.48): the only host that can build the x86 / net48 Worker
# v0.6.1 host-mode on Windows is broken (its hostexecutor writes each step's script to a path it # and MXAccess-adjacent code. Not required for a green portable build; runs where a runner exists.
# then cannot resolve, independent of shell; it also cannot stage JS actions), and Windows runs-on: [self-hosted, windows]
# containers are impractical for the net48/x86/MXAccess Worker. A gated job that no runner can steps:
# satisfy sticks in "queued" forever (it only resolves to "skipped" when a matching runner - uses: actions/checkout@v4
# exists), so keeping it here blocks every run from completing. x86 Worker + live-MXAccess - name: Build x86 Worker
# verification therefore stays on the manual windev worktree process (see CLAUDE.md / run: dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86
# docs/GatewayTesting.md). Restore these jobs only alongside a Windows runner that actually works. - name: Worker tests
run: dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86
live-mxaccess:
# Opt-in, scheduled only — needs installed MXAccess COM + live provider state. Never gates a push.
if: ${{ github.event_name == 'schedule' }}
runs-on: [self-hosted, windows, mxaccess]
env:
MXGATEWAY_RUN_LIVE_MXACCESS_TESTS: '1'
steps:
- uses: actions/checkout@v4
- name: Live MXAccess smoke
run: dotnet test src/ZB.MOM.WW.MxGateway.IntegrationTests/ZB.MOM.WW.MxGateway.IntegrationTests.csproj --filter FullyQualifiedName~WorkerLiveMxAccessSmokeTests
+3 -3
View File
@@ -76,11 +76,11 @@ powershell -ExecutionPolicy Bypass -File scripts/run-client-e2e-tests.ps1
- **Style guides** in `docs/style-guides/` are authoritative. Follow `CSharpStyleGuide.md` for gateway/worker/.NET-client code: file-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods, MXAccess-aligned names (`MxStatusProxy`, `ServerHandle`, `ItemHandle`, `HResult`). - **Style guides** in `docs/style-guides/` are authoritative. Follow `CSharpStyleGuide.md` for gateway/worker/.NET-client code: file-scoped namespaces, `sealed` by default, `Async` suffix on Task-returning methods, MXAccess-aligned names (`MxStatusProxy`, `ServerHandle`, `ItemHandle`, `HResult`).
- **MXAccess parity is the contract.** Don't "fix" surprising MXAccess behavior (e.g., `WriteSecured` failing before a value-bearing NMX body, distinct `OperationComplete` semantics, invalid-handle exceptions) unless the client explicitly opts into a non-parity mode. The installed MXAccess COM component is the baseline. - **MXAccess parity is the contract.** Don't "fix" surprising MXAccess behavior (e.g., `WriteSecured` failing before a value-bearing NMX body, distinct `OperationComplete` semantics, invalid-handle exceptions) unless the client explicitly opts into a non-parity mode. The installed MXAccess COM component is the baseline.
- **Don't synthesize events.** The gateway forwards only events the worker emits; it never invents `OperationComplete` from write completion or command replies. - **Don't synthesize events.** The gateway forwards only events the worker emits; it never invents `OperationComplete` from write completion or command replies.
- **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. See `docs/DesignDecisions.md` and `docs/Sessions.md`. - **One worker per session** (invariant). Multi-subscriber event fan-out and reconnect-with-replay have shipped and are config-gated: `AllowMultipleEventSubscribers` (default `false`) enables fan-out up to `MaxEventSubscribersPerSession` (default `8`); `DetachGraceSeconds` (default `30`) retains a session after its last subscriber drops so clients can reconnect; `ReplayBufferCapacity` / `ReplayRetentionSeconds` control how much event history the replay ring keeps. Default config is single-subscriber (`AllowMultipleEventSubscribers` off), but detach-grace and replay retention are **on** by default (`DetachGraceSeconds=30`, `ReplayBufferCapacity=1024`, `ReplayRetentionSeconds=300`): a detached session is retained for 30 s and recent events are buffered for reconnect. The reconnect protocol is consumable end-to-end: a resuming `StreamEvents` (via `after_worker_sequence`) that predates the retained ring gets a `ReplayGap` sentinel, and all five official clients surface it as a typed signal. Orphan-worker reattach after a gateway restart is **deferred, not planned** — see `oldtasks.md` (session-resilience epic Phase 5); the invariant on the next line stands. See `docs/DesignDecisions.md` and `docs/Sessions.md`.
- **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment. - **Gateway restart does not reattach orphan workers.** The first version terminates orphaned workers on startup; do not design code paths that assume reattachment.
- **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc. - **No Blazor UI component libraries.** Dashboard uses local Bootstrap CSS/JS only — do not introduce MudBlazor, Radzen, FluentUI, etc.
- **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted. - **Don't log secrets or full tag values by default.** API keys, passwords, `WriteSecured` payloads, and `AuthenticateUser` credentials must never reach logs. Value logging is opt-in and redacted.
- **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README. - **Generated code** under `src/ZB.MOM.WW.MxGateway.Contracts/Generated/`, `clients/*/generated*/`, `clients/python/src/zb_mom_ww_mxgateway/generated/`, etc., is build output. Don't hand-edit. To regenerate, build the contracts project (`dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`) or run the per-client generation step in that client's README.
- **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*. - **Documentation style** (`StyleGuide.md`): PascalCase filenames, no marketing language, present tense, explain *why* not *what*.
- **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior. - **Update docs in the same change as the source.** When public APIs, contracts, configuration, build steps, security behavior, event shapes, value conversion, status mapping, or lifecycle rules change, the affected docs (`gateway.md`, `docs/`, client READMEs, design docs) must change in the same commit. Don't leave stale prose describing old behavior.
@@ -123,7 +123,7 @@ Gateway gRPC clients authenticate with an API key in metadata: `authorization: B
Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session. Session event streaming is **owner-scoped**: the API key that opened a session is recorded on the session, and every `StreamEvents` attach/reattach is rejected with `PermissionDenied` unless the caller's key id matches the owner. Possessing the `event` scope and knowing a session id is not sufficient — this closes the reconnect/fan-out trust boundary (detach-grace and replay retention are on by default) so an `event`-scoped key cannot attach to another key's retained session.
Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure `__Host-MxGatewayDashboard` cookie. SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production. Dashboard auth is LDAP-backed (separate from the gRPC API-key model). `/login` binds against `MxGateway:Ldap` and maps the user's LDAP groups to `Admin` or `Viewer` via `MxGateway:Dashboard:GroupToRole`, then issues an HTTP-only secure cookie named `__Host-MxGatewayDashboard` when `Dashboard:RequireHttpsCookie` is true (default) and no `Dashboard:CookieName` override is set, else the plain `MxGatewayDashboard` (the `__Host-` prefix requires a Secure cookie). SignalR hubs at `/hubs/{snapshot,alarms,events}` accept either the cookie or a 5-minute bearer minted at `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` (default `true`) grants **read-only** loopback access — it satisfies the Viewer requirement but never the Admin-only requirement, so anonymous localhost can view the dashboard but not reach API-key CRUD or session Close/Kill (`Authentication:Mode=Disabled` is scoped the same way). `Dashboard:DisableLogin` (default `false`) auto-authenticates every dashboard request — including remote browsers — as `Dashboard:AutoLoginUser` (default `multi-role`) with both Admin and Viewer roles; dev/test only, never enable in production.
## Process / Platform Notes ## Process / Platform Notes
+50 -38
View File
@@ -64,16 +64,16 @@ Full design + implementation for each row lives in the linked domain doc under i
| GWC-03 | High | P0 | S | — | Done | Documented sparse-array max-length bound is unimplemented | | GWC-03 | High | P0 | S | — | Done | Documented sparse-array max-length bound is unimplemented |
| GWC-04 | Medium | P1 | M | — | Done | Full event channel stalls the worker read loop behind command replies | | GWC-04 | Medium | P1 | M | — | Done | Full event channel stalls the worker read loop behind command replies |
| GWC-05 | Medium | — | S | — | Not started | Worker pipe created with no ACL / no CurrentUserOnly | | GWC-05 | Medium | — | S | — | Not started | Worker pipe created with no ACL / no CurrentUserOnly |
| GWC-06 | Medium | P2 | S | GWC-07,08 | Not started | Stopwatch allocated per streamed event | | GWC-06 | Medium | P2 | S | GWC-07,08 | Done | Stopwatch allocated per streamed event |
| GWC-07 | Medium | P2 | S | GWC-06,08 | Not started | Every mapped event is deep-cloned | | GWC-07 | Medium | P2 | S | GWC-06,08 | Done | Every mapped event is deep-cloned |
| GWC-08 | Medium | P2 | M | GWC-06,07 | Not started | Pipe framing allocates per frame and writes twice | | GWC-08 | Medium | P2 | M | GWC-06,07 | Done | Pipe framing allocates per frame and writes twice |
| GWC-09 | Medium | — | M | — | Not started | Startup probe is a no-op; its retry pipeline can never retry | | GWC-09 | Medium | — | M | — | Not started | Startup probe is a no-op; its retry pipeline can never retry |
| GWC-10 | Medium | — | S | — | Not started | Envelope `sequence` monotonicity specified but not enforced | | GWC-10 | Medium | — | S | — | Not started | Envelope `sequence` monotonicity specified but not enforced |
| GWC-11 | Low | — | S | — | Not started | `_workerClient` written under lock but read lock-free | | GWC-11 | Low | — | S | — | Not started | `_workerClient` written under lock but read lock-free |
| GWC-12 | Low | — | S | — | Not started | `WorkerClient.DisposeAsync` not safe against double-dispose | | GWC-12 | Low | — | S | — | Not started | `WorkerClient.DisposeAsync` not safe against double-dispose |
| GWC-13 | Low | — | M | — | Not started | Worker-ready wait is a 25 ms poll loop | | GWC-13 | Low | — | M | — | Not started | Worker-ready wait is a 25 ms poll loop |
| GWC-14 | Low | P2 | M | — | Not started | Replay ring is a `LinkedList` with a node alloc per event | | GWC-14 | Low | P2 | M | — | Done | Replay ring is a `LinkedList` with a node alloc per event |
| GWC-15 | Low | P2 | S | — | Not started | Per-event gauge reads `ChannelReader.Count` every event | | GWC-15 | Low | P2 | S | — | Done | Per-event gauge reads `ChannelReader.Count` every event |
| GWC-16 | Low | — | S | — | Not started | `Invoke` resolves the session twice per command | | GWC-16 | Low | — | S | — | Not started | `Invoke` resolves the session twice per command |
| GWC-17 | Low | — | M | — | Not started | Sessions layer throws `Grpc.Core.RpcException` (layering leak) | | GWC-17 | Low | — | M | — | Not started | Sessions layer throws `Grpc.Core.RpcException` (layering leak) |
| GWC-18 | Low | — | S | — | Not started | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` | | GWC-18 | Low | — | S | — | Not started | `GatewaySession` implements `DisposeAsync` without `IAsyncDisposable` |
@@ -92,16 +92,16 @@ Full design + implementation for each row lives in the linked domain doc under i
| WRK-03 | Medium | — | S | WRK-02 | Not started | Commands after shutdown starts are dropped with no reply | | WRK-03 | Medium | — | S | WRK-02 | Not started | Commands after shutdown starts are dropped with no reply |
| WRK-04 | Medium | — | M | — | Done | Envelope `sequence` can appear out of order on the wire | | WRK-04 | Medium | — | M | — | Done | Envelope `sequence` can appear out of order on the wire |
| WRK-05 | Medium | — | M | — | Not started | One transient alarm-poll failure kills the whole session | | WRK-05 | Medium | — | M | — | Not started | One transient alarm-poll failure kills the whole session |
| WRK-06 | Medium | P2 | S | — | Not started | `MXSTATUS_PROXY` conversion reflects per field, per event | | WRK-06 | Medium | P2 | S | — | Done | `MXSTATUS_PROXY` conversion reflects per field, per event |
| WRK-07 | Medium | P1 | M | WRK-04 | Done | Documented outbound write priority not implemented | | WRK-07 | Medium | P1 | M | WRK-04 | Done | Documented outbound write priority not implemented |
| WRK-08 | Low | — | S | — | Not started | Residual event queue discarded at graceful shutdown, undocumented | | WRK-08 | Low | — | S | — | Not started | Residual event queue discarded at graceful shutdown, undocumented |
| WRK-09 | Low | — | S | — | Not started | No `AppDomain.UnhandledException` hook | | WRK-09 | Low | — | S | — | Not started | No `AppDomain.UnhandledException` hook |
| WRK-10 | Low | — | S | — | Not started | Top-level catch logs exception type but never message | | WRK-10 | Low | — | S | — | Not started | Top-level catch logs exception type but never message |
| WRK-11 | Low | P2 | S | — | Not started | Every accepted event is defensively cloned on enqueue | | WRK-11 | Low | P2 | S | — | Done | Every accepted event is defensively cloned on enqueue |
| WRK-12 | Low | P2 | M | WRK-04 | Not started | No event batching per envelope; one flush per event; 25 ms poll | | WRK-12 | Low | P2 | M | WRK-04 | Done | No event batching per envelope; one flush per event; 25 ms poll |
| WRK-13 | Low | — | S | — | Not started | Frame writer allocates a fresh buffer per frame; reader pools | | WRK-13 | Low | — | S | — | Not started | Frame writer allocates a fresh buffer per frame; reader pools |
| WRK-14 | Low | — | M | — | Not started | Private-field naming split `_camelCase` vs `camelCase` | | WRK-14 | Low | — | M | — | Not started | Private-field naming split `_camelCase` vs `camelCase` |
| WRK-15 | Low | P2 | S | — | Not started | Doc drift: STA thread name and heartbeat-counter note | | WRK-15 | Low | P2 | S | — | Done | Doc drift: STA thread name and heartbeat-counter note |
| WRK-16 | Low | — | S | — | Not started | Boilerplate duplication in IPC envelope/ctor overloads | | WRK-16 | Low | — | S | — | Not started | Boilerplate duplication in IPC envelope/ctor overloads |
| WRK-17 | Low | — | S | — | Not started | Gateway death exits with wrong exit code (6 not 5) | | WRK-17 | Low | — | S | — | Not started | Gateway death exits with wrong exit code (6 not 5) |
| WRK-18 | Low | — | S | — | Not started | Event-queue overflow exits as generic `UnexpectedFailure` | | WRK-18 | Low | — | S | — | Not started | Event-queue overflow exits as generic `UnexpectedFailure` |
@@ -116,23 +116,23 @@ Full design + implementation for each row lives in the linked domain doc under i
| IPC-02 | Medium | P1 | M | IPC-03 | Done | Worker max frame size hard-coded, cannot follow gateway config | | IPC-02 | Medium | P1 | M | IPC-03 | Done | Worker max frame size hard-coded, cannot follow gateway config |
| IPC-03 | Medium | P1 | M | IPC-02 | Done | gRPC max == pipe max with zero headroom; oversized write faults whole session | | IPC-03 | Medium | P1 | M | IPC-02 | Done | gRPC max == pipe max with zero headroom; oversized write faults whole session |
| IPC-04 | Medium | P1 | S | IPC-03 | Done | `DrainEvents max_events=0` packs entire queue into one reply frame | | IPC-04 | Medium | P1 | S | IPC-03 | Done | `DrainEvents max_events=0` packs entire queue into one reply frame |
| IPC-05 | Medium | P2 | S | — | Not started | Redundant deep copies on command and event hot paths | | IPC-05 | Medium | P2 | S | — | Done | Redundant deep copies on command and event hot paths |
| IPC-06 | Medium | P2 | S | — | Not started | `gateway.md` Worker Envelope sketch no longer matches the contract | | IPC-06 | Medium | P2 | S | — | Done | `gateway.md` Worker Envelope sketch no longer matches the contract |
| IPC-07 | Medium | P2 | S | — | Not started | `docs/Grpc.md` says six RPCs; there are seven | | IPC-07 | Medium | P2 | S | — | Done | `docs/Grpc.md` says six RPCs; there are seven |
| IPC-08 | Medium | — | M | — | Not started | `WorkerCancel` defined and handled but never sent | | IPC-08 | Medium | — | M | — | Not started | `WorkerCancel` defined and handled but never sent |
| IPC-09 | Medium | P1 | M | IPC-01 | Done | Known codegen fragilities have no in-repo guards | | IPC-09 | Medium | P1 | M | IPC-01 | Done | Known codegen fragilities have no in-repo guards |
| IPC-10 | Low | — | S | — | Not started | Envelope `sequence` is write-only; monotonicity never validated | | IPC-10 | Low | — | S | — | Not started | Envelope `sequence` is write-only; monotonicity never validated |
| IPC-11 | Low | — | S | — | Not started | No protocol version negotiation despite `supported_protocol_version` name | | IPC-11 | Low | — | S | — | Not started | No protocol version negotiation despite `supported_protocol_version` name |
| IPC-12 | Low | — | S | — | Not started | Gateway frame writer has no write lock; integrity rests on undocumented invariant | | IPC-12 | Low | — | S | — | Not started | Gateway frame writer has no write lock; integrity rests on undocumented invariant |
| IPC-13 | Low | P2 | S | IPC-05 | Not started | Gateway writer serializes twice and issues two stream writes | | IPC-13 | Low | P2 | S | IPC-05 | Done | Gateway writer serializes twice and issues two stream writes |
| IPC-14 | Low | P2 | S | IPC-05 | Not started | Gateway reader allocates fresh array per frame; worker rents from `ArrayPool` | | IPC-14 | Low | P2 | S | IPC-05 | Done | Gateway reader allocates fresh array per frame; worker rents from `ArrayPool` |
| IPC-15 | Low | P2 | M | — | Not started | Worker event delivery 25 ms poll with per-event write+flush, no batching | | IPC-15 | Low | P2 | M | — | Done | Worker event delivery 25 ms poll with per-event write+flush, no batching |
| IPC-16 | Low | — | S | — | N/A | Correlation id carried twice per reply (Info) | | IPC-16 | Low | — | S | — | N/A | Correlation id carried twice per reply (Info) |
| IPC-17 | Low | P2 | S | — | Not started | Two docs name the wrong Python generated-output directory | | IPC-17 | Low | P2 | S | — | Done | Two docs name the wrong Python generated-output directory |
| IPC-18 | Low | — | S | IPC-01 | N/A | Generated-code hygiene verified good — preserve under change (positive) | | IPC-18 | Low | — | S | IPC-01 | N/A | Generated-code hygiene verified good — preserve under change (positive) |
| IPC-19 | Low | P1 | S | IPC-01 | Done | Generated/-must-be-committed rule for net48 undocumented and unguarded | | IPC-19 | Low | P1 | S | IPC-01 | Done | Generated/-must-be-committed rule for net48 undocumented and unguarded |
| IPC-20 | Low | P1 | S | IPC-01 | Done | Descriptor `-Check` byte-compares source-info bytes; protoc-version-sensitive | | IPC-20 | Low | P1 | S | IPC-01 | Done | Descriptor `-Check` byte-compares source-info bytes; protoc-version-sensitive |
| IPC-21 | Low | P2 | S | IPC-06 | Not started | `gateway.md` presents unimplemented `Session` RPC as live | | IPC-21 | Low | P2 | S | IPC-06 | Done | `gateway.md` presents unimplemented `Session` RPC as live |
| IPC-22 | Low | — | S | — | N/A | Public error-detail model stops at status codes plus prose (Info) | | IPC-22 | Low | — | S | — | N/A | Public error-detail model stops at status codes plus prose (Info) |
### Security & dashboard — [40-security-dashboard.md](40-security-dashboard.md) ### Security & dashboard — [40-security-dashboard.md](40-security-dashboard.md)
@@ -141,13 +141,13 @@ Full design + implementation for each row lives in the linked domain doc under i
|---|---|:-:|:-:|---|---|---| |---|---|:-:|:-:|---|---|---|
| SEC-01 | Medium | P1 | M | — | Done | Windows-absolute default paths become relative files off-Windows | | SEC-01 | Medium | P1 | M | — | Done | Windows-absolute default paths become relative files off-Windows |
| SEC-02 | Medium | P1 | S | — | Done | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer | | SEC-02 | Medium | P1 | S | — | Done | `AllowAnonymousLocalhost` satisfies AdminOnly, not just Viewer |
| SEC-03 | Medium | P2 | S | — | Not started | Dashboard cookie lost its `__Host-` prefix; four docs still promise it | | SEC-03 | Medium | P2 | S | — | Done | Dashboard cookie lost its `__Host-` prefix; four docs still promise it |
| SEC-04 | Medium | P1 | S | SEC-03 | Done | `DisableLogin` has no production guard | | SEC-04 | Medium | P1 | S | SEC-03 | Done | `DisableLogin` has no production guard |
| SEC-05 | Medium | P1 | M | — | Done | Hub bearer tokens irrevocable for 30 min, carried in query string | | SEC-05 | Medium | P1 | M | — | Done | Hub bearer tokens irrevocable for 30 min, carried in query string |
| SEC-06 | Medium | P1 | M | — | Done | LDAP plaintext-by-default with a committed service password | | SEC-06 | Medium | P1 | M | — | Done | LDAP plaintext-by-default with a committed service password |
| SEC-07 | Medium | P1 | S | — | Done | `QueryActiveAlarmsRequest` missing from scope resolver; tests mislabeled | | SEC-07 | Medium | P1 | S | — | Done | `QueryActiveAlarmsRequest` missing from scope resolver; tests mislabeled |
| SEC-08 | Medium | P1 | L | — | Done | Per-RPC SQLite read + `last_used_utc` write; no verification cache | | SEC-08 | Medium | P1 | L | — | Done | Per-RPC SQLite read + `last_used_utc` write; no verification cache |
| SEC-09 | Medium | P2 | S | — | Not started | Dashboard design-doc `GroupToRole` sample now fails startup validation | | SEC-09 | Medium | P2 | S | — | Done | Dashboard design-doc `GroupToRole` sample now fails startup validation |
| SEC-10 | Medium | P1 | L | — | Done | No API-key expiry | | SEC-10 | Medium | P1 | L | — | Done | No API-key expiry |
| SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface | | SEC-11 | Medium | P1 | M | SEC-08 | Done | No rate limiting or lockout on either auth surface |
| SEC-12 | Medium | P1 | M | — | Done | Dashboard Close/Kill bypass the canonical audit store | | SEC-12 | Medium | P1 | M | — | Done | Dashboard Close/Kill bypass the canonical audit store |
@@ -160,15 +160,15 @@ Full design + implementation for each row lives in the linked domain doc under i
| SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read | | SEC-19 | Low | — | S | — | Not started | Canonical audit store re-issues `CREATE TABLE` per write/read |
| SEC-20 | Low | P1 | S | — | Done | `session_id` metric tag is unbounded cardinality | | SEC-20 | Low | P1 | S | — | Done | `session_id` metric tag is unbounded cardinality |
| SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience | | SEC-21 | Low | — | M | — | Not started | Snapshot publisher works every second regardless of audience |
| SEC-22 | Low | P2 | M | — | Not started | `docs/Authentication.md` documents types no longer in this repo | | SEC-22 | Low | P2 | M | — | Done | `docs/Authentication.md` documents types no longer in this repo |
| SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` | | SEC-23 | Low | — | S | SEC-03,04 | Not started | Validator ignores `DisableLogin`/`AutoLoginUser`/`RequireHttpsCookie`/`CookieName` |
| SEC-24 | Low | — | S | SEC-04 | Not started | Effective-config view omits the riskiest dashboard flags | | SEC-24 | Low | — | S | SEC-04 | Not started | Effective-config view omits the riskiest dashboard flags |
| SEC-25 | Low | P2 | M | SEC-02 | Not started | Per-session EventsHub ACL is an acknowledged TODO | | SEC-25 | Low | P2 | M | SEC-02 | Done | Per-session EventsHub ACL is an acknowledged TODO |
| SEC-26 | Low | — | M | — | Not started | Audit trail has no retention or pruning | | SEC-26 | Low | — | M | — | Not started | Audit trail has no retention or pruning |
| SEC-27 | Low | — | S | — | Not started | Dashboard `GatewayStatus` is hardcoded Healthy | | SEC-27 | Low | — | S | — | Not started | Dashboard `GatewayStatus` is hardcoded Healthy |
| SEC-28 | Info | — | S | — | N/A | Positive security observations (preserve under change) | | SEC-28 | Info | — | S | — | N/A | Positive security observations (preserve under change) |
| SEC-29 | Info | — | S | — | N/A | UI-stack rule verified compliant | | SEC-29 | Info | — | S | — | N/A | UI-stack rule verified compliant |
| SEC-30 | Info | P2 | S | SEC-13 | Not started | Value-logging feature is unwired end-to-end | | SEC-30 | Info | P2 | S | SEC-13 | Done | Value-logging feature is unwired end-to-end |
### Clients — [50-clients.md](50-clients.md) ### Clients — [50-clients.md](50-clients.md)
@@ -177,7 +177,7 @@ Full design + implementation for each row lives in the linked domain doc under i
| CLI-01 | High | P0 | M | — | Done | Go `Session.Events()` silently closes stream on 16-slot overflow | | CLI-01 | High | P0 | M | — | Done | Go `Session.Events()` silently closes stream on 16-slot overflow |
| CLI-02 | High | P1 | M | — | Done | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) | | CLI-02 | High | P1 | M | — | Done | Rust crate unbuildable outside the repo (`build.rs` path + `--no-verify`) |
| CLI-03 | High | P0 | M | — | Done | Rust `invoke` never validates HRESULT / MXSTATUS_PROXY | | CLI-03 | High | P0 | M | — | Done | Rust `invoke` never validates HRESULT / MXSTATUS_PROXY |
| CLI-04 | High | P2 | L | CLI-15 | Not started | Typed-command parity gap across all five clients | | CLI-04 | High | P2 | L | CLI-15 | Done | Typed-command parity gap across all five clients |
| CLI-05 | Medium | — | S | — | Not started | .NET session cannot be re-attached to an existing session id | | CLI-05 | Medium | — | S | — | Not started | .NET session cannot be re-attached to an existing session id |
| CLI-06 | Medium | — | S | — | Not started | .NET `DisposeAsync` blocks/throws on unreachable gateway | | CLI-06 | Medium | — | S | — | Not started | .NET `DisposeAsync` blocks/throws on unreachable gateway |
| CLI-07 | Medium | — | S | — | Not started | .NET retry budget self-defeats on `DeadlineExceeded` | | CLI-07 | Medium | — | S | — | Not started | .NET retry budget self-defeats on `DeadlineExceeded` |
@@ -185,25 +185,25 @@ Full design + implementation for each row lives in the linked domain doc under i
| CLI-09 | Medium | — | M | — | Not started | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) | | CLI-09 | Medium | — | M | — | Not started | Go has no typed auth-error mapping (Unauthenticated vs PermissionDenied) |
| CLI-10 | Medium | — | M | — | Not started | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` | | CLI-10 | Medium | — | M | — | Not started | Go uses deprecated `grpc.DialContext` + `grpc.WithBlock()` |
| CLI-11 | Medium | — | S | — | Not started | Go CLI cannot opt into strict TLS validation | | CLI-11 | Medium | — | S | — | Not started | Go CLI cannot opt into strict TLS validation |
| CLI-12 | Medium | P2 | S | — | Not started | Java docs still say "Java 21" after the JDK 17 retarget | | CLI-12 | Medium | P2 | S | — | Done | Java docs still say "Java 21" after the JDK 17 retarget |
| CLI-13 | Medium | — | M | — | Not started | Java event-stream buffer hardcoded 16, cancel-on-overflow | | CLI-13 | Medium | — | M | — | Not started | Java event-stream buffer hardcoded 16, cancel-on-overflow |
| CLI-14 | Medium | — | S | — | Not started | Python default TLS is blocking TOFU pin with silent `localhost` SNI | | CLI-14 | Medium | — | S | — | Not started | Python default TLS is blocking TOFU pin with silent `localhost` SNI |
| CLI-15 | Medium | P2 | M | — | Not started | No client handles `ReplayGap` or offers a reconnect helper | | CLI-15 | Medium | P2 | M | — | Done | No client handles `ReplayGap` or offers a reconnect helper |
| CLI-16 | Medium | P2 | S | CLI-12 | Not started | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` | | CLI-16 | Medium | P2 | S | CLI-12 | Done | `docs/ClientPackaging.md` drifted from Python naming and .NET `.slnx` |
| CLI-17 | Medium | — | M | CLI-14 | Not started | TLS default posture inconsistent across the five clients | | CLI-17 | Medium | — | M | CLI-14 | Not started | TLS default posture inconsistent across the five clients |
| CLI-18 | Low | P2 | S | — | Not started | .NET csproj records no `<Version>` | | CLI-18 | Low | P2 | S | — | Done | .NET csproj records no `<Version>` |
| CLI-19 | Low | — | S | — | Not started | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) | | CLI-19 | Low | — | S | — | Not started | .NET duplicate `InternalsVisibleTo` (AssemblyInfo + csproj) |
| CLI-20 | Low | — | S | CLI-17 | Not started | .NET `--tls` without CA installs accept-all callback | | CLI-20 | Low | — | S | CLI-17 | Not started | .NET `--tls` without CA installs accept-all callback |
| CLI-21 | Low | P2 | S | — | Not started | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases | | CLI-21 | Low | P2 | S | — | Done | Go `ClientVersion = "0.1.0-dev"` stale vs tagged releases |
| CLI-22 | Low | — | S | — | Not started | Go `newCorrelationID` swallows `crypto/rand` error → empty id | | CLI-22 | Low | — | S | — | Not started | Go `newCorrelationID` swallows `crypto/rand` error → empty id |
| CLI-23 | Low | — | S | — | Not started | Go nil-vs-empty bulk short-circuit asymmetry | | CLI-23 | Low | — | S | — | Not started | Go nil-vs-empty bulk short-circuit asymmetry |
| CLI-24 | Low | — | S | — | Not started | Java `MxEventStream` single-consumer constraint undocumented | | CLI-24 | Low | — | S | — | Not started | Java `MxEventStream` single-consumer constraint undocumented |
| CLI-25 | Low | — | S | — | Not started | Java `close()` does not await channel termination | | CLI-25 | Low | — | S | — | Not started | Java `close()` does not await channel termination |
| CLI-26 | Low | P2 | S | — | Not started | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) | | CLI-26 | Low | P2 | S | — | Done | Python `version.py` (0.1.0) ≠ `pyproject.toml` (0.1.2) |
| CLI-27 | Low | — | S | — | Not started | Python `Session.close()` not concurrency-safe; synthesizes reply | | CLI-27 | Low | — | S | — | Not started | Python `Session.close()` not concurrency-safe; synthesizes reply |
| CLI-28 | Low | — | S | — | Not started | Python circular-import workaround at end of `session.py` | | CLI-28 | Low | — | S | — | Not started | Python circular-import workaround at end of `session.py` |
| CLI-29 | Low | P2 | S | — | Not started | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) | | CLI-29 | Low | P2 | S | — | Done | Rust `CLIENT_VERSION` (0.1.0-dev) ≠ `Cargo.toml` (0.1.2) |
| CLI-30 | Low | — | S | CLI-04 | Not started | Rust has no `unregister` typed helper | | CLI-30 | Low | — | S | CLI-04 | Done | Rust has no `unregister` typed helper |
| CLI-31 | Low | — | M | — | Not started | Rust CLI is a single 2,699-line `main.rs` | | CLI-31 | Low | — | M | — | Not started | Rust CLI is a single 2,699-line `main.rs` |
| CLI-32 | Low | — | S | — | Not started | Client-side bulk caps differ (.NET/Java unbounded) | | CLI-32 | Low | — | S | — | Not started | Client-side bulk caps differ (.NET/Java unbounded) |
| CLI-33 | Low | — | S | CLI-01,13 | Not started | Per-language event backpressure semantics undocumented | | CLI-33 | Low | — | S | CLI-01,13 | Not started | Per-language event backpressure semantics undocumented |
@@ -213,19 +213,19 @@ Full design + implementation for each row lives in the linked domain doc under i
| ID | Sev | Tier | Eff | Dep | Status | Title | | ID | Sev | Tier | Eff | Dep | Status | Title |
|---|---|:-:|:-:|---|---|---| |---|---|:-:|:-:|---|---|---|
| TST-01 | High | P2 | L | TST-04 | Not started | Reconnect/replay has no e2e test and no client consumer | | TST-01 | High | P2 | L | TST-04 | Done | Reconnect/replay has no e2e test and no client consumer |
| TST-02 | High | P0 | M | TST-04 | Done | Reconnect owner re-validation not implemented | | TST-02 | High | P0 | M | TST-04 | Done | Reconnect owner re-validation not implemented |
| TST-03 | High | P1 | M | — | Done | No CI exists | | TST-03 | High | P1 | M | — | In review | No CI exists |
| TST-04 | High | P2 | L | — | Not started | Session-resilience epic 16/28 tasks unfinished | | TST-04 | High | P2 | L | — | Done | Session-resilience epic 16/28 tasks unfinished |
| TST-05 | Medium | P1 | S | TST-03 | Not started | Real-worker control/COM paths verified opt-in only | | TST-05 | Medium | P1 | S | TST-03 | Not started | Real-worker control/COM paths verified opt-in only |
| TST-06 | Medium | — | M | — | Not started | Dashboard live-data path untested | | TST-06 | Medium | — | M | — | Not started | Dashboard live-data path untested |
| TST-07 | Medium | — | S | — | Not started | Real-clock sleeps with negative assertions are latent flakes | | TST-07 | Medium | — | S | — | Not started | Real-clock sleeps with negative assertions are latent flakes |
| TST-08 | Medium | P1 | M | — | Done | Full-suite orphaned testhost processes (does not reproduce; doc de-stale) | | TST-08 | Medium | P1 | M | — | Done | Full-suite orphaned testhost processes (does not reproduce; doc de-stale) |
| TST-09 | Medium | — | M | — | Not started | Health checks cover only the auth store | | TST-09 | Medium | — | M | — | Not started | Health checks cover only the auth store |
| TST-10 | Medium | — | M | — | Not started | Deployment/upgrade undocumented and hand-rolled | | TST-10 | Medium | — | M | — | Not started | Deployment/upgrade undocumented and hand-rolled |
| TST-11 | Medium | P2 | M | — | Not started | No version discipline on server; client versions drift | | TST-11 | Medium | P2 | M | — | Done | No version discipline on server; client versions drift |
| TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour | | TST-12 | Medium | P0 | S | — | Done | CLAUDE.md misstates default retention behaviour |
| TST-13 | Medium | P2 | S | — | Not started | gateway.md carries stale design-era sketches | | TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches |
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage | | TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
| TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL | | TST-15 | Medium | P2 | M | TST-04 | Not started | Dashboard EventsHub has no per-session ACL |
| TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag | | TST-16 | Medium | — | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag |
@@ -235,7 +235,7 @@ Full design + implementation for each row lives in the linked domain doc under i
| TST-20 | Low | — | S | — | Not started | E2E script papers over a real advise-without-consumer sharp edge | | TST-20 | Low | — | S | — | Not started | E2E script papers over a real advise-without-consumer sharp edge |
| TST-21 | Low | — | S | — | Not started | Log rotation configured but minimal | | TST-21 | Low | — | S | — | Not started | Log rotation configured but minimal |
| TST-22 | Low | — | S | — | Not started | Config-shape JSON block omits documented keys | | TST-22 | Low | — | S | — | Not started | Config-shape JSON block omits documented keys |
| TST-23 | Low | P2 | S | — | Not started | Bidirectional `Session` RPC never built | | TST-23 | Low | P2 | S | — | Done | Bidirectional `Session` RPC never built |
| TST-24 | Low | P2 | M | TST-03 | Not started | Client wire behaviour has no automated verification | | TST-24 | Low | P2 | M | TST-03 | Not started | Client wire behaviour has no automated verification |
## Cross-cutting clusters ## Cross-cutting clusters
@@ -253,8 +253,21 @@ Findings the review flagged as one coordinated design pass — sequence them tog
| Date | Change | | Date | Change |
|---|---| |---|---|
| 2026-07-09 | **P2 Epic wrap — user decision: DEFER TST-15 + TST-24, close the epic.** Epic bucket result: 5 of 7 findings `Done` (CLI-15, CLI-04, CLI-30, TST-01, TST-04); **TST-15** and **TST-24** intentionally deferred to a follow-up (kept `Not started`, not `Won't fix` — they are gated, not rejected). **TST-15** (dashboard EventsHub per-session ACL) is epic Phase 4 — a real feature needing a new session-"tag" mechanism + dashboard group→tag config, not a mechanical fix; the `EventsHub` `TODO(per-session-acl)` stays, and the already-shipped **SEC-25** mitigation (tag *values* redacted from the dashboard mirror by default) means no sensitive payload leaks through the hub today regardless of the missing ACL — so deferring carries no value-leak risk. **TST-24** (per-client wire tests) depends on **TST-03** (CI), which is `In review` (YAML authored, never run on a Gitea runner) — no point wiring client tests into a pipeline that isn't live yet. Net P2: 35/38 `Done`; remaining = TST-15 (deferred feature), TST-24 (deferred, CI-gated), TST-14 (user deletes their own untracked gitignored `*-docs-*.md` files). |
| 2026-07-09 | P2 Epic — **Java client completes CLI-15 + CLI-04 locally** (commit `1cc0fa4`); **CLI-15, CLI-04, CLI-30, TST-01 all → `Done` (5/5 clients + server e2e)**. Java CLI-15: `MxEventStreamItem` record + `MxEventStream.nextItem()` (`isReplayGap()`/`replayGap()`/`event()`); existing `Iterator<MxEvent>` path unchanged, sentinel never swallowed. Java CLI-04: Phase 1 `adviseSupervisory`/`writeSecured`/`writeSecured2`/`authenticateUser`/`archestrAUserToId` + Phase 2 `addBufferedItem`/`setBufferedUpdateInterval`/`suspend`/`activate` (unregister already present) on `MxGatewaySession`, each through `invokeCommand``ensureProtocolSuccess`+`ensureMxAccessSuccess`; credentials scrubbed via `MxGatewaySecrets.redactCredentials` (tests assert absent from message/toString/CLI). `gradle test` 106/0 (58 client + 48 cli), no generated churn. Built locally with `JAVA_HOME=/opt/homebrew/opt/openjdk@17` — Java toolchain now works on the Mac (see prior note). Shared docs `ClientLibrariesDesign.md` + CLAUDE.md updated to "all five clients". **TST-01 → Done** (server e2e `fed0685` + all 5 client `ReplayGap` consumers). This closes session-resilience epic Phase 3 fully. |
| 2026-07-09 | P2 Epic Wave E2 — typed-command parity (commit `bde042b`). CLI-04 → `In progress` (4/5 clients; Java pending in the SAME session — see next note), CLI-30 → `Done`. Every parity-critical single-item command now has a typed session helper across .NET/Go/Rust/Python: Phase 1 `AdviseSupervisory`/`WriteSecured`/`WriteSecured2`/`AuthenticateUser`/`ArchestrAUserToId`, Phase 2 `AddBufferedItem`/`SetBufferedUpdateInterval`/`Suspend`/`Activate`, plus `Unregister` (CLI-30: Rust + .NET added; Go/Java/Python already had it — CLI-30 fully Done). Each wraps existing raw-command machinery (no new wire surface) and runs the client's MXAccess-level reply validation (`hresult < 0` + `MxStatusProxy`). MXAccess parity preserved (WriteSecured-before-authenticate surfaces the native failure). **Credentials route through each client's secret-redaction seam** (never in logs/exceptions/ToString/Debug/Display; each suite asserts a distinctive credential is absent from surfaced errors); new CLI subcommands take credentials via flag/env, never echoed. Verified per toolchain: .NET build clean 102 passed; Go gofmt/vet/build/test clean; Rust fmt/check/test/clippy clean; Python 145 passed. Shared doc: `ClientLibrariesDesign.md` "Typed Command Parity" section. |
| 2026-07-09 | **Java client toolchain now works locally on the Mac** (user-flagged + verified): homebrew `openjdk@17` (17.0.19), @21, @26 are installed (off PATH); `JAVA_HOME=/opt/homebrew/opt/openjdk@17 gradle test` → BUILD SUCCESSFUL. Retires the "Java client must be built on windev" constraint — the remaining Java halves of CLI-15 + CLI-04 are done locally this session (no windev batch). Memory `project_java_build_host` rewritten. |
| 2026-07-09 | P2 Epic — TST-04 (session-resilience epic governance) → `Done` (commit `ed3c6c6`, docs-only umbrella). Resolved the epic's shipped-vs-planned entanglement into three decisions: **Phase 3 finished** (Task 13=TST-02 owner-scoped attach P0, Task 15=TST-01 reconnect e2e, Task 14=CLI-15 4/5 clients w/ Java pending); **Phase 4 scoped** as TST-15 with the Viewer-default decision settled (admin-sees-all, Viewer strict per owned/granted session, matching TST-02's gRPC owner binding); **Phase 5 (orphan-worker reattach) DEFERRED, not planned** — the CLAUDE.md "gateway restart does not reattach orphan workers" invariant stands and the `EnableOrphanReattach` flag does not exist / must not be referenced. Updated `oldtasks.md`, the `tasks.json` mirror (per-task statuses + governance note), and the CLAUDE.md reconnect paragraph (clients consume ReplayGap; reattach deferred). The actionable slices carry their own verification: TST-01 (done), TST-02 (done, P0), TST-15 (pending, E3). |
| 2026-07-09 | P2 Epic Wave E1 — reconnect-replay: CLI-15 + TST-01 → `In progress` (both gated on the Java client, deferred to the windev batch). **CLI-15 (commit `0c6e5b3`)**: four clients now surface the gateway's `ReplayGap` reconnect sentinel as a distinct, typed, non-terminal signal (never synthesized, never swallowed) — .NET `MxEventStreamItem`/`StreamEventItemsAsync` (build clean, 87 passed), Go `EventResult.ReplayGap`+`IsReplayGap()` (build/vet/test clean), Rust `EventItem::ReplayGap` enum (`EventStream` now yields `Result<EventItem, Error>`; fmt/check/test/clippy clean), Python `ReplayGap` dataclass yielded from `stream_events` (131 passed). Shared docs: `ClientLibrariesDesign.md` non-goals reframed (reconnect-replay protocol is consumable; auto-reconnect stays a non-goal) + `CrossLanguageSmokeMatrix.md` resume-gap note. Resume contract uniform: `after_worker_sequence = oldest_available_sequence - 1`. **TST-01 server half (commit `fed0685`)**: `GatewayEndToEndReconnectReplayTests` (2 facts) proves the default-on reconnect protocol end-to-end over the real gRPC StreamEvents path via the fake worker — replay-tail-no-gap (capacity 16, mid-batch cursor) and stale-cursor-emits-ReplayGap-first (capacity 3, 6 events force eviction, cursor=1); single-subscriber detach→reconnect, ring survives detach. macOS-verified 2/2 (needs `TMPDIR=/tmp` — default macOS TMPDIR pushes the CoreFxPipe Unix-socket path past the 104-byte `sun_path` limit; Windows CI unaffected). No product bugs; server behavior matched the contract exactly. Remaining to close both: Java `ReplayGap` surface (windev) + Java client consumer for TST-01. |
| 2026-07-09 | P2 Wave B — worker event hot-path (commit `f61c816`), **windev-verified**. WRK-06 → `Done`: `MxStatusProxyConverter` caches the four resolved `FieldInfo` per status type in a static `ConcurrentDictionary` (the `GetField` metadata scan ran 4× per status per event on the STA path); `GetValue`+`Convert.ToInt32` still run per event (late-bound RCW); both exception messages byte-identical (missing-field via `ResolveField`, not cached on throw through `GetOrAdd`; null-value unchanged). WRK-11 → `Done`: `MxAccessEventQueue.Enqueue` takes ownership of the passed `MxEvent` (stamps `WorkerSequence`/`WorkerTimestamp` in place, no `Clone()`); audited all 3 callers (base/alarm event sinks + provider-mode handler) — each builds a fresh event, none reuse it; `MxAccessValueCache.Set` now deep-copies its retained `Value`/`SourceTimestamp`/`Statuses` so the cache snapshot never aliases the queue-owned (later serialized) event. WRK-12 → `Done`: `WorkerFrameWriter` coalesces the flush across a drained batch (write each frame, one `FlushAsync` after the batch, then complete all) — preserves the written+flushed completion contract; a burst of N events costs 1 flush not N; on failure the in-flight batch + queue all fail so no caller hangs. IPC-15 → `Done`: the multi-event `WorkerEnvelope` body stays unimplemented (wire still one event per `worker_event` frame); gateway.md Performance section now distinguishes the shipped flush-coalescing from that deferred additive-proto change. **Windev:** x86 Worker builds clean (fixed a Windows-only `TreatWarningsAsErrors` CS8604 in the value-cache null-guard that macOS can't surface); Worker.Tests **356 passed / 0 failed / 11 skipped** (+4 new: converter cache-reuse, queue ownership-transfer, value-cache snapshot independence, writer batch-flush-once); gateway Tests **815 passed / 1 failed** — the 1 is the known windev-environmental SelfSigned-SAN assertion (passes on macOS), and all pipe-harness tests (which also exercise the earlier G-frame gateway frame change + WRK-12 end-to-end over a real pipe) pass. |
| 2026-07-09 | P2 Wave B — quick Mac items (commit `ec6f82b`). TST-11 → `Done`: `src/Directory.Build.props` single-sources the .NET-side version (Server/Worker/Contracts/tests stamped 0.1.2, was SDK-default 1.0.0) and appends the git short SHA to InformationalVersion (`0.1.2+<sha>`, guarded so a git-less build still works); verified Server assembly stamps `0.1.2+579282f`, Server+Tests build clean. Kept 0.1.2 (matches Contracts + aligned Python/Rust/Go); converging all to a single 0.2.0 cadence left as a release decision. WRK-15 → `Done` (docs-only path, no x86 build): STA thread-name refs corrected to the actual `MxGateway.Worker.STA` in `docs/WorkerSta.md` + `docs/MxAccessWorkerInstanceDesign.md`, and the stale heartbeat-counter note fixed (CaptureHeartbeat populates queue depth + sequence from the live queue). TST-23 → `Done`: already resolved by the Wave A gateway.md edit — the bidi `Session` RPC now sits under a "Future work: not implemented" heading (no "best long-term shape" framing), consistent with the proto. **TST-14 left open by design**: its only concrete step is deleting the user's untracked, gitignored `*-docs-*.md` working files (zero repo impact) — surfaced to the user rather than deleting files not created here. |
| 2026-07-09 | P2 Wave B — dashboard/security (commit `e15c3cb`). SEC-25 → `Done` (near-term hardening only; full per-session EventsHub ACL stays deferred to roadmap item 12 / TST-15): `DashboardEventBroadcaster` redacts tag values from a deep clone of the event before mirroring to SignalR when `Dashboard:ShowTagValues` is false (default), so the hub seam cannot leak values regardless of the missing ACL. Clears `MxEvent.value` (the OnDataChange/OnWriteComplete/OperationComplete/OnBufferedDataChange bodies are empty discriminators — values ride in the top-level field, incl. buffered arrays) + the alarm body `current_value`/`limit_value`; source event never mutated (shared with gRPC path + replay ring; verified). Makes the formerly-dead `ShowTagValues` flag live for the mirror. `EventsHub` TODO(per-session-acl) kept + tied to roadmap item 12. Test `DashboardEventBroadcasterTests` (3). SEC-30 → `Done`: `docs/Diagnostics.md` trimmed to mark opt-in command-value logging NOT YET IMPLEMENTED (no `LogCommandValues` knob, `RedactCommandValue` unwired, no values logged); wiring deferred pending secured-bulk redaction (SEC-13), not wired now. Server build clean; Dashboard tests 152/152. |
| 2026-07-09 | P2 Wave B — gateway event/frame hot-path performance (8 findings). **Frame I/O (commit `baae59c`):** GWC-08/IPC-13 gateway `WorkerFrameWriter` serializes once into one ArrayPool-rented buffer (LE length prefix + payload) and issues a single write (was: second serialization via `ToByteArray`, separate prefix array, two writes); IPC-14 `WorkerFrameReader` rents the payload buffer from ArrayPool instead of `new byte[]` per frame, read/parse length-bounded, returned in finally. Wire format byte-identical, cross-checked against the worker writer/reader. **Event/command hot path (commit `5e2e40a`):** GWC-06 `StreamEvents` timing via `Stopwatch.GetTimestamp()`/`GetElapsedTime()` (no per-event Stopwatch alloc); GWC-07/IPC-05 `MapEvent` transfers ownership of the inner `MxEvent` instead of cloning (safe — single-consumer `WorkerEvent`, MapEvent runs once pre-fan-out, all downstream consumers read-only; verified no post-mapping mutation) and `CreateCommandEnvelope` drops its redundant second clone (`MapCommand` already isolated the graph); every load-bearing isolation clone kept; GWC-15 `grpc_stream_queue.depth` converted from a per-event push counter to an `ObservableGauge` summing registered channel sources only at scrape time (name/semantics unchanged). **Replay ring (commit `cf1b3d4`):** GWC-14 replaces the `LinkedList` (node alloc per retained event) with a preallocated circular `ReplayEntry[]` (head+count), preserving ascending order, capacity eviction, time trim, oldest-read/ReplayGap math, both query paths, and the lock. → all `Done`. Server build clean (0 warnings); per-cluster tests green (WorkerFrameProtocol 10/10 incl. new near-cap round-trip, EventStream/Metrics/Distributor/Mapper 62/62, Distributor/Replay 33/33 incl. 2 new wrap-around cases). Full-suite macOS checkpoint: 769 passed / 44 failed, all 44 the known named-pipe/fake-worker-harness UDS-104-char limit (0 failures in any touched area). **Pending: windev run** to exercise the frame I/O over a real pipe (the 44 pipe-harness tests are macOS-blind). |
| 2026-07-09 | P2 Wave B — SEC-03 (dashboard `__Host-` cookie prefix) → `Done` (commit `7d42c85`). Conditionally restore the prefix instead of a pure doc fix: `DashboardServiceCollectionExtensions` PostConfigure resolves the cookie name as explicit `MxGateway:Dashboard:CookieName` override → else `__Host-MxGatewayDashboard` when `SecurePolicy==Always` (`RequireHttpsCookie` true) → else the plain `MxGatewayDashboard` default; guard never applies `__Host-` without Secure (browsers drop it). `DashboardAuthenticationDefaults.SecureCookieName` added; plain name kept as the non-secure fallback. Five stale docs corrected to the conditional contract (gateway.md, GatewayProcessDesign, ImplementationPlanGateway, GatewayDashboardDesign, CLAUDE.md; GatewayConfiguration phrasing tightened). Test `DashboardCookieOptionsTests` asserts the name flips with `RequireHttpsCookie` and that an override wins. Server build clean; Dashboard tests 149/149. |
| 2026-07-09 | P2 Wave A (doc-drift sweep + client version alignment) via parallel agents, on `fix/archreview-p2` off `main`. **Version alignment (commit `4fe1917`):** CLI-18 (.NET `<Version>0.1.2</Version>`), CLI-21 (Go `ClientVersion` 0.1.0-dev→0.1.2), CLI-26 (Python `__version__`→0.1.2), CLI-29 (Rust `CLIENT_VERSION``env!(CARGO_PKG_VERSION)`) → `Done`; verified dotnet build + `go build`/`test` + `cargo check`/`test`/`clippy` + pytest 127 pass. **Doc-drift (commit `06e1046`):** IPC-06/07/21 + TST-13 (gateway.md envelope sketch → proto-as-truth, six→seven RPCs incl. `QueryActiveAlarms` in `docs/Grpc.md`, `Session` RPC marked not-implemented, stale sketches removed), SEC-09 (dashboard `GroupToRole` sample `Administrator` so it passes the validator), SEC-22 (`docs/Authentication.md` rewritten to the shipped `ZB.MOM.WW.Auth.ApiKeys`-package pipeline; 18 stale type names removed, grep-verified absent; named gateway types re-verified present), IPC-17 (wrong Python gen dir `mxgateway``zb_mom_ww_mxgateway` in CLAUDE.md + 3 docs), CLI-12 (Java 21→17), CLI-16 (`docs/ClientPackaging.md` reconciled with `.slnx`/Python package name/gradle project names) → `Done`. Docs-only claims verified against source. 13 findings closed. |
| 2026-07-09 | Initial tracking doc generated from the six domain remediation designs. All 153 findings `Not started`. | | 2026-07-09 | Initial tracking doc generated from the six domain remediation designs. All 153 findings `Not started`. |
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). | | 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
| 2026-07-09 | P0 tier executed via parallel agents. GWC-01/02/03, TST-02, TST-12, CLI-01, CLI-03 → `Done` (NonWindows build clean; gateway 135/135 targeted tests pass, Go `go test ./...` + `-race` pass, Rust `test`/`clippy -D warnings` pass). |
| 2026-07-09 | Windows full-suite temp-file-lock flakiness (discovered via TST-08) → **fixed** (commit `11a716a`). Two shared-state parallel collisions that macOS never surfaces: (1) self-signed cert generation — `SelfSignedCertificateProvider` now stages the PFX in a unique `<path>.<guid>.tmp` (was a fixed name that concurrent/interrupted writers collided on); `TestHostEnvironmentInitializer` defaults the cert path to a per-process temp dir (so the suite never writes shared `ProgramData` or fights the deployed service); `GatewayTlsBootstrapTests` moved to a `DisableParallelization` collection so its process-global env-var mutation can't bleed into parallel tests. (2) `AuthStoreHealthCheckTests` — reuse the existing `TempDatabaseDirectory` helper (`SqliteConnection.ClearAllPools()` before delete) so the Microsoft.Data.Sqlite pool releases the temp `.db` handle. **windev-verified:** 3 consecutive full-suite runs all **793 passed / 2 failed, 0 new orphans** (was flaky 24 fails, count-varying). The 2 stable remaining failures are pre-existing windev-environmental (`SelfSignedCertificateProviderTests.GenerateCertificate_HasExpectedSansEkuAndValidity` machine-name/FQDN SAN assertion; `EventStreamServiceTests…TracksAggregateQueueDepth` timing) — both pass on macOS, unrelated to this change. | | 2026-07-09 | Windows full-suite temp-file-lock flakiness (discovered via TST-08) → **fixed** (commit `11a716a`). Two shared-state parallel collisions that macOS never surfaces: (1) self-signed cert generation — `SelfSignedCertificateProvider` now stages the PFX in a unique `<path>.<guid>.tmp` (was a fixed name that concurrent/interrupted writers collided on); `TestHostEnvironmentInitializer` defaults the cert path to a per-process temp dir (so the suite never writes shared `ProgramData` or fights the deployed service); `GatewayTlsBootstrapTests` moved to a `DisableParallelization` collection so its process-global env-var mutation can't bleed into parallel tests. (2) `AuthStoreHealthCheckTests` — reuse the existing `TempDatabaseDirectory` helper (`SqliteConnection.ClearAllPools()` before delete) so the Microsoft.Data.Sqlite pool releases the temp `.db` handle. **windev-verified:** 3 consecutive full-suite runs all **793 passed / 2 failed, 0 new orphans** (was flaky 24 fails, count-varying). The 2 stable remaining failures are pre-existing windev-environmental (`SelfSignedCertificateProviderTests.GenerateCertificate_HasExpectedSansEkuAndValidity` machine-name/FQDN SAN assertion; `EventStreamServiceTests…TracksAggregateQueueDepth` timing) — both pass on macOS, unrelated to this change. |
| 2026-07-09 | P1 TST-08 (orphaned testhost) → `Done` by evidence: the claimed leak does **not** reproduce. Full `ZB.MOM.WW.MxGateway.Tests` suite exits cleanly on macOS (0 survivors) and on the Windows dev box (windev, `origin/main` worktree: 0 new `testhost` and 0 new `ZB.MOM.WW.MxGateway.Worker` processes after a full run, isolated by StartTime). Static audit confirms the prime-suspect fixtures already dispose deterministically (WorkerClient cancels its `_stopCts` + awaits read/write/heartbeat tasks with a 5 s timeout; GatewaySession disposes the client; E2E/harness fixtures use `await using`). CLAUDE.md "Source Update Workflow" updated to drop the stale "leaves orphaned testhost processes" rationale (filtered runs kept as a speed guideline). **Discovered separately (NOT TST-08, still open):** Windows-only temp-file-lock flakiness in ~4 full-host test classes — `AuthStoreHealthCheckTests` (Microsoft.Data.Sqlite connection-pool holds the temp `.db` handle after `await using`, so the finally `File.Delete` throws "used by another process"), `GatewayTlsBootstrapTests`/`DashboardCookieOptionsTests`/`DashboardHubsRegistrationTests` (a `gw.pfx.tmp` handle from `SelfSignedCertificateProvider`'s fixed-name `path+".tmp"` atomic-write racing teardown). Count varies run-to-run (24 fails); macOS never sees it (Unix allows deleting open files). Fix = `SqliteConnection.ClearAllPools()` before temp delete + a unique/guarded `.tmp` name; verify on windev. | | 2026-07-09 | P1 TST-08 (orphaned testhost) → `Done` by evidence: the claimed leak does **not** reproduce. Full `ZB.MOM.WW.MxGateway.Tests` suite exits cleanly on macOS (0 survivors) and on the Windows dev box (windev, `origin/main` worktree: 0 new `testhost` and 0 new `ZB.MOM.WW.MxGateway.Worker` processes after a full run, isolated by StartTime). Static audit confirms the prime-suspect fixtures already dispose deterministically (WorkerClient cancels its `_stopCts` + awaits read/write/heartbeat tasks with a 5 s timeout; GatewaySession disposes the client; E2E/harness fixtures use `await using`). CLAUDE.md "Source Update Workflow" updated to drop the stale "leaves orphaned testhost processes" rationale (filtered runs kept as a speed guideline). **Discovered separately (NOT TST-08, still open):** Windows-only temp-file-lock flakiness in ~4 full-host test classes — `AuthStoreHealthCheckTests` (Microsoft.Data.Sqlite connection-pool holds the temp `.db` handle after `await using`, so the finally `File.Delete` throws "used by another process"), `GatewayTlsBootstrapTests`/`DashboardCookieOptionsTests`/`DashboardHubsRegistrationTests` (a `gw.pfx.tmp` handle from `SelfSignedCertificateProvider`'s fixed-name `path+".tmp"` atomic-write racing teardown). Count varies run-to-run (24 fails); macOS never sees it (Unix allows deleting open files). Fix = `SqliteConnection.ClearAllPools()` before temp delete + a unique/guarded `.tmp` name; verify on windev. |
| 2026-07-09 | SEC-10 polish (gateway-side follow-up to the G-2 auth-lib core) → done. `apikey create-key` gains an optional `--expires` (absolute ISO-8601 UTC or relative `<N>d`/`<N>h`; omit = non-expiring, opt-in) threaded through `ApiKeyAdminCommand`/parser/runner into the library's `CreateKeyAsync(..., expiresUtc, ...)`; `list-keys` shows an expiry column + `active`/`expired`/`revoked` status. Dashboard API Keys page surfaces expiry: an `Expires` column (`Never` when unset) and a status badge reading `Expired` (red) / `Expiring` (≤7 days, amber) / `Revoked` / `Active` (`DashboardApiKeySummary.ExpiresUtc` projected in `DashboardSnapshotService`; `StatusBadge` maps the new states). Server build clean; targeted tests 32/32 (parser abs/relative/invalid + end-to-end past-expiry rejection via the live verifier) + dashboard 28/28. Docs: `docs/Authentication.md` (verification flow expiry step, CLI table + examples, dashboard badge). SEC-10 was already `Done` (core); this closes the tracked polish. | | 2026-07-09 | SEC-10 polish (gateway-side follow-up to the G-2 auth-lib core) → done. `apikey create-key` gains an optional `--expires` (absolute ISO-8601 UTC or relative `<N>d`/`<N>h`; omit = non-expiring, opt-in) threaded through `ApiKeyAdminCommand`/parser/runner into the library's `CreateKeyAsync(..., expiresUtc, ...)`; `list-keys` shows an expiry column + `active`/`expired`/`revoked` status. Dashboard API Keys page surfaces expiry: an `Expires` column (`Never` when unset) and a status badge reading `Expired` (red) / `Expiring` (≤7 days, amber) / `Revoked` / `Active` (`DashboardApiKeySummary.ExpiresUtc` projected in `DashboardSnapshotService`; `StatusBadge` maps the new states). Server build clean; targeted tests 32/32 (parser abs/relative/invalid + end-to-end past-expiry rejection via the live verifier) + dashboard 28/28. Docs: `docs/Authentication.md` (verification flow expiry step, CLI table + examples, dashboard badge). SEC-10 was already `Done` (core); this closes the tracked polish. |
@@ -265,4 +278,3 @@ Findings the review flagged as one coordinated design pass — sequence them tog
| 2026-07-09 | P1 Wave 2a (security). SEC-01/04/06 → `Done` (config path-rooting + production validator guards; Server build clean, validator+hygiene tests 53/53). SEC-10 → `Done`: the shared `ZB.MOM.WW.Auth.ApiKeys` gained optional `ExpiresUtc` (expired keys rejected, auth DB auto-migrates to schema v3) via a concurrent HistorianGateway-remediation session's "G-2"; this repo consumes it by bumping the four `Auth.*` refs 0.1.2→0.1.4 (commit 197731a). Remaining SEC-10 polish (`apikey create --expires` + dashboard staleness badge) tracked as a small follow-up. | | 2026-07-09 | P1 Wave 2a (security). SEC-01/04/06 → `Done` (config path-rooting + production validator guards; Server build clean, validator+hygiene tests 53/53). SEC-10 → `Done`: the shared `ZB.MOM.WW.Auth.ApiKeys` gained optional `ExpiresUtc` (expired keys rejected, auth DB auto-migrates to schema v3) via a concurrent HistorianGateway-remediation session's "G-2"; this repo consumes it by bumping the four `Auth.*` refs 0.1.2→0.1.4 (commit 197731a). Remaining SEC-10 polish (`apikey create --expires` + dashboard staleness badge) tracked as a small follow-up. |
| 2026-07-09 | P1 Wave 1 (CI + codegen freshness + Rust buildability) via parallel agents. IPC-01/09/19/20, CLI-02 → `Done`; TST-03 → `In review` (CI pipeline authored + YAML/layout-validated, but not yet executed on a Gitea runner — proven on first push). Verified on macOS: NonWindows build clean, `ClientProtoInputTests` 5/5, `publish-client-proto-inputs.ps1 -Check` exit 0, `cargo package` (no `--no-verify`) compiles standalone. Added a vendored-Rust-proto drift guard (Check 3) to `check-codegen.ps1` closing the CLI-02 static-copy risk. IPC-09 script guards not executed end-to-end (need pinned python/JRE toolchains); logic is PATH-resolution + version assertion. | | 2026-07-09 | P1 Wave 1 (CI + codegen freshness + Rust buildability) via parallel agents. IPC-01/09/19/20, CLI-02 → `Done`; TST-03 → `In review` (CI pipeline authored + YAML/layout-validated, but not yet executed on a Gitea runner — proven on first push). Verified on macOS: NonWindows build clean, `ClientProtoInputTests` 5/5, `publish-client-proto-inputs.ps1 -Check` exit 0, `cargo package` (no `--no-verify`) compiles standalone. Added a vendored-Rust-proto drift guard (Check 3) to `check-codegen.ps1` closing the CLI-02 static-copy risk. IPC-09 script guards not executed end-to-end (need pinned python/JRE toolchains); logic is PATH-resolution + version assertion. |
| 2026-07-09 | WRK-01 → `Done`. Verified on Windows host (windev) via an isolated `origin/main` worktree: worker builds x86 clean, `StaRuntimeTests`+`WorkerPipeSessionTests` 33/33 pass. Fixed an `xUnit1030` build error (the new worker test used `.ConfigureAwait(false)` in `[Fact]` bodies) that the macOS tree could not surface. Also ran GWC-01's Windows-only `WorkerClientTests` on windev: 18/18 pass (incl. `ReadEventsAsync_SecondEnumerator_Throws`). All 8 P0 findings now `Done`. Not yet committed. | | 2026-07-09 | WRK-01 → `Done`. Verified on Windows host (windev) via an isolated `origin/main` worktree: worker builds x86 clean, `StaRuntimeTests`+`WorkerPipeSessionTests` 33/33 pass. Fixed an `xUnit1030` build error (the new worker test used `.ConfigureAwait(false)` in `[Fact]` bodies) that the macOS tree could not surface. Also ran GWC-01's Windows-only `WorkerClientTests` on windev: 18/18 pass (incl. `ReadEventsAsync_SecondEnumerator_Throws`). All 8 P0 findings now `Done`. Not yet committed. |
| 2026-07-10 | **TST-03 → `Done`: CI is live and green** on branch `fix/ci-selfhosted-tooling`. The pipeline was authored (P1) but had never executed. Root cause it never ran: the co-located `gitea-runner` on the docker host (`10.100.0.35`) spawned job containers on an isolated network (`container.network: ""`) that could not resolve Gitea's internal clone URL `http://gitea:3000`; one-line host fix `container.network: "traefik"` + `docker restart gitea-runner`. The self-hosted `catthehacker` act image also lacks tooling GitHub-hosted runners preinstall — ci.yml now installs pwsh (dotnet global tool, for `check-codegen.ps1`) and Gradle 9.5.1 directly (act can't resolve the `gradle/actions` monorepo action; no gradle wrapper in repo). Driving to green surfaced and fixed **five real latent defects** (TST-03 doing its job): `check-codegen.ps1` `.Trim()`-on-`$null` on a clean tree; **stale vendored rust proto** (`clients/rust/protos/mxaccess_worker.proto` missing canonical `max_frame_bytes`); `OrphanWorkerTerminatorTests` hard-coded `C:\` path failing Linux `Path.GetFullPath` (0 kills); **stale java generated** `MxaccessWorker.java` (missing `max_frame_bytes`, regenerated); a sync python test building a `grpc.aio.Channel` with no current event loop on py3.12 (autouse conftest fixture). Result: `portable` **success** (NonWindows build + codegen freshness + 808/808 gateway tests + .NET/Go/Rust/Python clients) and `java` **success**. `windows` + `live-mxaccess` jobs remain `queued` pending a self-hosted **windev** runner (`10.100.0.48`) with those labels — separate follow-up, does not gate portable/java. |
+42
View File
@@ -84,6 +84,48 @@ messages. `MxGatewaySession.OpenSessionReply` keeps the raw session-open reply
available, and command helpers have `*RawAsync` variants when callers need the available, and command helpers have `*RawAsync` variants when callers need the
complete `MxCommandReply`. complete `MxCommandReply`.
### Event Streaming And Replay Gaps
`StreamEventsAsync(afterWorkerSequence)` yields raw generated `MxEvent`
messages. Passing a non-zero `afterWorkerSequence` resumes a session's event
stream after a known worker sequence — this is the reconnect cursor. If that
cursor is *stale* — older than the oldest event the gateway still retains in the
session replay ring — the events in between were evicted and cannot be replayed.
The gateway signals this by emitting a single **replay-gap sentinel** at the head
of the resumed stream: an `MxEvent` with its `ReplayGap` field set, `Family`
unspecified, and no body. It means "you missed events — discard local state and
re-snapshot."
Rather than force callers to inspect the raw sentinel, the client exposes a
typed surface, `StreamEventItemsAsync`, which yields `MxEventStreamItem` values:
```csharp
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(
afterWorkerSequence: lastSeenSequence))
{
if (item.IsReplayGap)
{
// We missed events: throw away local state and re-snapshot.
ReplayGap gap = item.ReplayGap!;
// Resume without incurring another gap:
lastSeenSequence = gap.OldestAvailableSequence - 1;
await ReSnapshotAsync();
continue;
}
HandleEvent(item.Event); // normal MXAccess event, IsReplayGap == false
lastSeenSequence = item.Event.WorkerSequence;
}
```
The typed surface never synthesizes or drops events — it only makes the
gateway's own sentinel observable. Normal events pass through with
`IsReplayGap == false` and `ReplayGap == null`. The gap is only ever produced by
`StreamEvents`; the diagnostic drain path never emits it. If you already consume
the raw `StreamEventsAsync` (or the client-level stream), the
`AsStreamItemsAsync()` extension projects any `IAsyncEnumerable<MxEvent>` into
the same `MxEventStreamItem` surface.
For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of For alarms, the client exposes `QueryActiveAlarmsAsync` (one-shot snapshot of
the active alarms the gateway's central monitor currently holds), the active alarms the gateway's central monitor currently holds),
`StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages `StreamAlarmsAsync` (server-streaming feed of alarm-state-change messages
@@ -44,7 +44,6 @@ internal sealed class CliArguments
/// <summary>Returns whether the named flag was present in the arguments.</summary> /// <summary>Returns whether the named flag was present in the arguments.</summary>
/// <param name="name">The flag name (without '--' prefix).</param> /// <param name="name">The flag name (without '--' prefix).</param>
/// <returns><c>true</c> if the flag was present; otherwise, <c>false</c>.</returns>
public bool HasFlag(string name) public bool HasFlag(string name)
{ {
return _flags.Contains(name); return _flags.Contains(name);
@@ -52,7 +51,6 @@ internal sealed class CliArguments
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary> /// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <returns>The argument value, or <c>null</c> if the argument is absent.</returns>
public string? GetOptional(string name) public string? GetOptional(string name)
{ {
return _values.TryGetValue(name, out string? value) return _values.TryGetValue(name, out string? value)
@@ -62,7 +60,6 @@ internal sealed class CliArguments
/// <summary>Returns the value for a required named argument, or throws if absent.</summary> /// <summary>Returns the value for a required named argument, or throws if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <returns>The argument value.</returns>
public string GetRequired(string name) public string GetRequired(string name)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -77,7 +74,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary> /// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param> /// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
/// <returns>The parsed int32 value.</returns>
public int GetInt32(string name, int? defaultValue = null) public int GetInt32(string name, int? defaultValue = null)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -97,7 +93,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary> /// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed uint32 value.</returns>
public uint GetUInt32(string name, uint defaultValue) public uint GetUInt32(string name, uint defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -109,7 +104,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary> /// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed uint64 value.</returns>
public ulong GetUInt64(string name, ulong defaultValue) public ulong GetUInt64(string name, ulong defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -121,7 +115,6 @@ internal sealed class CliArguments
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary> /// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
/// <param name="name">The argument name (without '--' prefix).</param> /// <param name="name">The argument name (without '--' prefix).</param>
/// <param name="defaultValue">The default value if the argument is absent.</param> /// <param name="defaultValue">The default value if the argument is absent.</param>
/// <returns>The parsed duration.</returns>
public TimeSpan GetDuration(string name, TimeSpan defaultValue) public TimeSpan GetDuration(string name, TimeSpan defaultValue)
{ {
string? value = GetOptional(name); string? value = GetOptional(name);
@@ -108,8 +108,7 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
return _galaxyClient.Value.BrowseChildrenRawAsync(request, cancellationToken); return _galaxyClient.Value.BrowseChildrenRawAsync(request, cancellationToken);
} }
/// <summary>Disposes the adapted gateway client and, if created, the lazily-initialized Galaxy Repository client.</summary> /// <inheritdoc />
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
if (_galaxyClient.IsValueCreated) if (_galaxyClient.IsValueCreated)
@@ -1,19 +1,31 @@
namespace ZB.MOM.WW.MxGateway.Client.Cli; namespace ZB.MOM.WW.MxGateway.Client.Cli;
/// <summary>Utility to redact API keys from error messages for safe output.</summary> /// <summary>Utility to redact secrets (API keys, MXAccess credentials) from error messages for safe output.</summary>
internal static class MxGatewayCliSecretRedactor internal static class MxGatewayCliSecretRedactor
{ {
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary> /// <summary>
/// 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.
/// </summary>
/// <param name="value">The message text to redact.</param> /// <param name="value">The message text to redact.</param>
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param> /// <param name="secrets">The secret values to remove (API key, verify-user password, secured payloads).</param>
/// <returns>The value with any occurrences of <paramref name="apiKey"/> replaced by a redacted placeholder.</returns> public static string Redact(string value, params string?[] secrets)
public static string Redact(string value, string? apiKey)
{ {
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(value) || secrets is null)
{ {
return value; 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;
} }
} }
@@ -22,7 +22,6 @@ public static class MxGatewayClientCli
/// <param name="args">Command-line arguments (command name followed by options).</param> /// <param name="args">Command-line arguments (command name followed by options).</param>
/// <param name="standardOutput">TextWriter for command output.</param> /// <param name="standardOutput">TextWriter for command output.</param>
/// <param name="standardError">TextWriter for error messages.</param> /// <param name="standardError">TextWriter for error messages.</param>
/// <returns>The process exit code: 0 on success, 1 on error.</returns>
public static int Run( public static int Run(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -39,7 +38,6 @@ public static class MxGatewayClientCli
/// <param name="standardError">TextWriter for error messages.</param> /// <param name="standardError">TextWriter for error messages.</param>
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param> /// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param> /// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
/// <returns>A task producing the process exit code: 0 on success, 1 on error.</returns>
public static Task<int> RunAsync( public static Task<int> RunAsync(
string[] args, string[] args,
TextWriter standardOutput, TextWriter standardOutput,
@@ -114,6 +112,24 @@ public static class MxGatewayClientCli
.ConfigureAwait(false), .ConfigureAwait(false),
"advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token) "advise-supervisory" => await AdviseSupervisoryAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .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) "subscribe-bulk" => await SubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
.ConfigureAwait(false), .ConfigureAwait(false),
"unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token) "unsubscribe-bulk" => await UnsubscribeBulkAsync(arguments, client, standardOutput, cancellation.Token)
@@ -157,8 +173,17 @@ public static class MxGatewayClientCli
} }
catch (Exception exception) when (exception is not OperationCanceledException) catch (Exception exception) when (exception is not OperationCanceledException)
{ {
// 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. 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? 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")) if (forceJsonErrors || arguments.HasFlag("json"))
{ {
@@ -302,7 +327,7 @@ public static class MxGatewayClientCli
/// <c>MXGATEWAY_API_KEY</c>), returning <see langword="null"/> when neither /// <c>MXGATEWAY_API_KEY</c>), returning <see langword="null"/> when neither
/// is set. Unlike <see cref="ResolveApiKey"/> this never throws, so the /// is set. Unlike <see cref="ResolveApiKey"/> this never throws, so the
/// error-redaction catch block can strip the env-var-sourced key from /// error-redaction catch block can strip the env-var-sourced key from
/// output without re-raising on the absent-key path. /// output (Client.Dotnet-028) without re-raising on the absent-key path.
/// </summary> /// </summary>
private static string? TryResolveApiKey(CliArguments arguments) private static string? TryResolveApiKey(CliArguments arguments)
{ {
@@ -318,6 +343,48 @@ public static class MxGatewayClientCli
return Environment.GetEnvironmentVariable(apiKeyEnvironmentName); return Environment.GetEnvironmentVariable(apiKeyEnvironmentName);
} }
/// <summary>
/// Resolves the effective MXAccess verify-user credential from
/// <c>--verify-user-password</c> or, failing that, the
/// <c>--verify-user-password-env</c>-named environment variable (default
/// <c>MXGATEWAY_VERIFY_USER_PASSWORD</c>). The credential is never echoed;
/// this resolver exists so the error-redaction catch block can strip it
/// from any surfaced error (CLI-04), mirroring <see cref="TryResolveApiKey"/>.
/// </summary>
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);
}
/// <summary>
/// Resolves the verify-user credential for <c>authenticate-user</c>, 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.
/// </summary>
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) private static CancellationTokenSource CreateCancellation(CliArguments arguments, string command)
{ {
var cancellation = new CancellationTokenSource(); var cancellation = new CancellationTokenSource();
@@ -474,6 +541,215 @@ public static class MxGatewayClientCli
cancellationToken); cancellationToken);
} }
private static Task<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> 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<int> SubscribeBulkAsync( private static Task<int> SubscribeBulkAsync(
CliArguments arguments, CliArguments arguments,
IMxGatewayCliClient client, IMxGatewayCliClient client,
@@ -743,6 +1019,7 @@ public static class MxGatewayClientCli
/// (e.g. <c>-1</c>, an easy copy-paste mistake for "unbounded") is /// (e.g. <c>-1</c>, an easy copy-paste mistake for "unbounded") is
/// rejected loudly rather than silently wrapped to <c>~49.7 days</c>, /// rejected loudly rather than silently wrapped to <c>~49.7 days</c>,
/// which would park one worker thread per pending tag for hours. /// which would park one worker thread per pending tag for hours.
/// Resolves Client.Dotnet-021.
/// </summary> /// </summary>
private static uint ParseTimeoutMs(CliArguments arguments, int defaultValue) private static uint ParseTimeoutMs(CliArguments arguments, int defaultValue)
{ {
@@ -764,6 +1041,7 @@ public static class MxGatewayClientCli
/// its absence must not silently fall through to /// its absence must not silently fall through to
/// <c>ReturnValue.Int32Value</c> (which would be <c>0</c> for an empty /// <c>ReturnValue.Int32Value</c> (which would be <c>0</c> for an empty
/// reply, driving the rest of the bench against an invalid handle). /// reply, driving the rest of the bench against an invalid handle).
/// Resolves Client.Dotnet-019.
/// </summary> /// </summary>
private static int RequireRegisterServerHandle(MxCommandReply reply, string sessionId) private static int RequireRegisterServerHandle(MxCommandReply reply, string sessionId)
{ {
@@ -888,6 +1166,11 @@ public static class MxGatewayClientCli
} }
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
// Client.Dotnet-020: never swallow OperationCanceledException
// here. A bare `catch` would let Ctrl+C / parent CTS /
// wall-clock timeouts keep spinning until --duration-seconds
// elapsed, burning CPU and skewing the p99/max latency numbers
// with hundreds of immediate-OCE iterations.
sw.Stop(); sw.Stop();
failedCalls++; failedCalls++;
latencyMillis.Add(sw.Elapsed.TotalMilliseconds); latencyMillis.Add(sw.Elapsed.TotalMilliseconds);
@@ -1114,6 +1397,8 @@ public static class MxGatewayClientCli
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
// Client.Dotnet-017: graceful end-of-window completion mode for a
// finite-window event collector. Emit aggregate JSON below and exit 0.
} }
if (json && !jsonLines) if (json && !jsonLines)
@@ -1183,6 +1468,10 @@ public static class MxGatewayClientCli
} }
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{ {
// Mirrors stream-events (Client.Dotnet-017): the supplied token covers
// the user's --timeout wall-clock budget and external Ctrl+C / parent
// CTS cancellation. All are graceful completion modes for a
// finite-window alarm-feed collector: emit what arrived and exit 0.
} }
if (json && !jsonLines) if (json && !jsonLines)
@@ -2015,6 +2304,15 @@ public static class MxGatewayClientCli
or "add-item" or "add-item"
or "advise" or "advise"
or "advise-supervisory" 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 "subscribe-bulk"
or "unsubscribe-bulk" or "unsubscribe-bulk"
or "read-bulk" or "read-bulk"
@@ -2078,6 +2376,15 @@ public static class MxGatewayClientCli
writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]"); writer.WriteLine("mxgw-dotnet add-item --session-id <id> --server-handle <n> --item <ref> [--json]");
writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]"); writer.WriteLine("mxgw-dotnet advise --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]"); writer.WriteLine("mxgw-dotnet advise-supervisory --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet unregister --session-id <id> --server-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet add-buffered-item --session-id <id> --server-handle <n> --item <ref> [--item-context <s>] [--json]");
writer.WriteLine("mxgw-dotnet set-buffered-update-interval --session-id <id> --server-handle <n> --interval-ms <n> [--json]");
writer.WriteLine("mxgw-dotnet suspend --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet activate --session-id <id> --server-handle <n> --item-handle <n> [--json]");
writer.WriteLine("mxgw-dotnet write-secured --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--json]");
writer.WriteLine("mxgw-dotnet write-secured2 --session-id <id> --server-handle <n> --item-handle <n> --type <type> --value <value> --current-user-id <n> [--verifier-user-id <n>] [--timestamp <iso>] [--json]");
writer.WriteLine("mxgw-dotnet authenticate-user --session-id <id> --server-handle <n> --verify-user <user> (--verify-user-password <pw> | --verify-user-password-env <ENVVAR>) [--json]");
writer.WriteLine("mxgw-dotnet archestra-user-to-id --session-id <id> --server-handle <n> --user-guid <guid> [--json]");
writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]"); writer.WriteLine("mxgw-dotnet subscribe-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--json]");
writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]"); writer.WriteLine("mxgw-dotnet unsubscribe-bulk --session-id <id> --server-handle <n> --item-handles <n,n> [--json]");
writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]"); writer.WriteLine("mxgw-dotnet read-bulk --session-id <id> --server-handle <n> --items <ref,ref> [--timeout-ms <n>] [--json]");
@@ -14,7 +14,6 @@ public sealed class BrowseChildrenSmokeTests
/// Verifies that BrowseChildren returns a non-zero cache sequence and /// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway. /// a consistent children/child-has-children count from a live gateway.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")] [Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence() public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{ {
@@ -8,10 +8,14 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// </summary> /// </summary>
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <inheritdoc /> /// <summary>
/// Gets the raw gRPC client; always null for the fake.
/// </summary>
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null; public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
/// <summary> /// <summary>
@@ -62,7 +66,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new(); public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The TestConnectionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<TestConnectionReply> TestConnectionAsync( public Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -76,7 +84,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(TestConnectionReply); return Task.FromResult(TestConnectionReply);
} }
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -90,7 +102,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
return Task.FromResult(GetLastDeployTimeReply); return Task.FromResult(GetLastDeployTimeReply);
} }
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -125,7 +141,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Func<Task>? BrowseChildrenGate { get; set; } public Func<Task>? BrowseChildrenGate { get; set; }
/// <inheritdoc /> /// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async Task<BrowseChildrenReply> BrowseChildrenAsync( public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request, BrowseChildrenRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -167,7 +187,11 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
/// </summary> /// </summary>
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; } public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
/// <inheritdoc /> /// <summary>
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
/// </summary>
/// <param name="request">The WatchDeployEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -11,10 +11,14 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
private readonly Queue<MxCommandReply> _invokeReplies = new(); private readonly Queue<MxCommandReply> _invokeReplies = new();
private readonly List<MxEvent> _events = []; private readonly List<MxEvent> _events = [];
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <inheritdoc /> /// <summary>
/// Gets null, since this is a test fake without a real gRPC client.
/// </summary>
public MxAccessGateway.MxAccessGatewayClient? RawClient => null; public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
/// <summary> /// <summary>
@@ -98,7 +102,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary> /// </summary>
public Queue<Exception> InvokeExceptions { get; } = new(); public Queue<Exception> InvokeExceptions { get; } = new();
/// <inheritdoc /> /// <summary>
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The OpenSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<OpenSessionReply> OpenSessionAsync( public Task<OpenSessionReply> OpenSessionAsync(
OpenSessionRequest request, OpenSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -112,7 +120,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(OpenSessionReply); return Task.FromResult(OpenSessionReply);
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
/// </summary>
/// <param name="request">The CloseSessionRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<CloseSessionReply> CloseSessionAsync( public Task<CloseSessionReply> CloseSessionAsync(
CloseSessionRequest request, CloseSessionRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -126,7 +138,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(CloseSessionReply); return Task.FromResult(CloseSessionReply);
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
/// </summary>
/// <param name="request">The MxCommandRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<MxCommandReply> InvokeAsync( public Task<MxCommandReply> InvokeAsync(
MxCommandRequest request, MxCommandRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -140,7 +156,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
return Task.FromResult(_invokeReplies.Dequeue()); return Task.FromResult(_invokeReplies.Dequeue());
} }
/// <inheritdoc /> /// <summary>
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
/// </summary>
/// <param name="request">The StreamEventsRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -173,7 +193,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
_events.Add(gatewayEvent); _events.Add(gatewayEvent);
} }
/// <inheritdoc /> /// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
/// <param name="request">The acknowledge alarm request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync( public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request, AcknowledgeAlarmRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -194,7 +218,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
}); });
} }
/// <inheritdoc /> /// <summary>
/// Records the query call and yields each enqueued snapshot.
/// </summary>
/// <param name="request">The query active alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync( public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -223,7 +251,11 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
_activeAlarmSnapshots.Add(snapshot); _activeAlarmSnapshots.Add(snapshot);
} }
/// <inheritdoc /> /// <summary>
/// Records the stream-alarms call and yields each enqueued feed message.
/// </summary>
/// <param name="request">The stream alarms request.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request, StreamAlarmsRequest request,
CallOptions callOptions) CallOptions callOptions)
@@ -9,7 +9,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag. /// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag() public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
{ {
@@ -28,7 +27,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync returns false when the server reports NotOk. /// Verifies that TestConnectionAsync returns false when the server reports NotOk.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk() public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
{ {
@@ -44,7 +42,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present. /// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent() public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
{ {
@@ -61,7 +58,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present. /// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent() public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
{ {
@@ -83,7 +79,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply. /// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply() public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
{ {
@@ -146,7 +141,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport. /// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport() public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
{ {
@@ -167,7 +161,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures. /// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError() public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
{ {
@@ -191,7 +184,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request. /// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters() public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
{ {
@@ -226,7 +218,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync retries on transient gRPC failures. /// Verifies that TestConnectionAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure() public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -244,7 +235,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures. /// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure() public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
{ {
@@ -261,7 +251,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event. /// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent() public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
{ {
@@ -298,7 +287,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync delivers multiple events in order. /// Verifies that WatchDeployEventsAsync delivers multiple events in order.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder() public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
{ {
@@ -337,7 +325,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled. /// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly() public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
{ {
@@ -382,7 +369,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed. /// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal() public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
{ {
@@ -398,7 +384,6 @@ public sealed class GalaxyRepositoryClientTests
/// <summary> /// <summary>
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed. /// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task TestConnectionAsync_ThrowsAfterDisposal() public async Task TestConnectionAsync_ThrowsAfterDisposal()
{ {
@@ -12,7 +12,6 @@ public sealed class LazyBrowseNodeTests
/// Verifies that calling BrowseAsync with no parent returns the root nodes /// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint. /// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_NoParent_ReturnsRoots() public async Task Browse_NoParent_ReturnsRoots()
{ {
@@ -37,7 +36,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC. /// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded() public async Task Expand_PopulatesChildrenAndMarksExpanded()
{ {
@@ -64,7 +62,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC. /// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_CalledTwice_NoSecondRpc() public async Task Expand_CalledTwice_NoSecondRpc()
{ {
@@ -89,7 +86,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException. /// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException() public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{ {
@@ -117,7 +113,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token. /// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages() public async Task Expand_MultiPageSiblings_GathersAllPages()
{ {
@@ -152,7 +147,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten. /// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc() public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{ {
@@ -185,9 +179,8 @@ public sealed class LazyBrowseNodeTests
/// Verifies that reading Children/IsExpanded concurrently with an in-flight ExpandAsync /// Verifies that reading Children/IsExpanded concurrently with an in-flight ExpandAsync
/// never throws (no torn enumeration of a mid-append list) and, once IsExpanded flips to /// never throws (no torn enumeration of a mid-append list) and, once IsExpanded flips to
/// true, the published Children snapshot is fully populated. Pins the safe-publication /// true, the published Children snapshot is fully populated. Pins the safe-publication
/// contract on the lock-free readers. /// contract on the lock-free readers (Client.Dotnet-025).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Expand_ConcurrentReadOfChildren_NeverTearsAndPublishesAtomically() public async Task Expand_ConcurrentReadOfChildren_NeverTearsAndPublishesAtomically()
{ {
@@ -275,7 +268,6 @@ public sealed class LazyBrowseNodeTests
/// <summary> /// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request. /// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Browse_WithFilter_ForwardsToRequest() public async Task Browse_WithFilter_ForwardsToRequest()
{ {
@@ -12,7 +12,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientAlarmsTests public sealed class MxGatewayClientAlarmsTests
{ {
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary> /// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply() public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
{ {
@@ -49,7 +48,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary> /// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_HonorsCancellation() public async Task AcknowledgeAlarmAsync_HonorsCancellation()
{ {
@@ -74,7 +72,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary> /// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException() public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
{ {
@@ -100,7 +97,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary> /// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots() public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
{ {
@@ -126,7 +122,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary> /// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix() public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
{ {
@@ -147,7 +142,6 @@ public sealed class MxGatewayClientAlarmsTests
} }
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary> /// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration() public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
{ {
@@ -24,7 +24,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary> /// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions() public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
{ {
@@ -39,7 +38,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary> /// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply() public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
{ {
@@ -85,7 +83,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// <c>advise-supervisory</c> was present in the command /// Client.Dotnet-030: <c>advise-supervisory</c> was present in the command
/// dispatch table but absent from <see cref="MxGatewayClientCli"/>'s /// dispatch table but absent from <see cref="MxGatewayClientCli"/>'s
/// <c>IsKnownGatewayCommand</c> guard, so the guard intercepted it first and /// <c>IsKnownGatewayCommand</c> guard, so the guard intercepted it first and
/// returned exit code 2 "Unknown command" before dispatch could run. This /// returned exit code 2 "Unknown command" before dispatch could run. This
@@ -93,7 +91,6 @@ public sealed class MxGatewayClientCliTests
/// "Unknown command") and reaches the dispatch handler (exit 0, reply written /// "Unknown command") and reaches the dispatch handler (exit 0, reply written
/// to stdout). /// to stdout).
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_AdviseSupervisory_IsRecognizedAndReachesDispatch() public async Task RunAsync_AdviseSupervisory_IsRecognizedAndReachesDispatch()
{ {
@@ -132,8 +129,121 @@ public sealed class MxGatewayClientCliTests
Assert.Equal(string.Empty, error.ToString()); Assert.Equal(string.Empty, error.ToString());
} }
/// <summary>Verifies that write-secured builds a WriteSecured command with the value and user ids.</summary>
[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());
}
/// <summary>
/// Verifies that authenticate-user builds an AuthenticateUser command sourcing the
/// credential from the flag, and that the credential never appears in stdout/stderr.
/// </summary>
[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());
}
/// <summary>
/// CLI-04: a surfaced error for authenticate-user must have the credential
/// redacted (never echoed to stderr), mirroring the API-key redaction seam.
/// </summary>
[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());
}
/// <summary>Verifies that error output redacts sensitive API key values.</summary> /// <summary>Verifies that error output redacts sensitive API key values.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey() public async Task RunAsync_ErrorOutput_RedactsApiKey()
{ {
@@ -158,13 +268,12 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// When the API key is sourced from the env var /// Client.Dotnet-028: when the API key is sourced from the env var
/// (<c>--api-key-env</c> path, no <c>--api-key</c> flag), the error-redaction /// (<c>--api-key-env</c> path, no <c>--api-key</c> flag), the error-redaction
/// catch block must still resolve and redact the effective key. Regression /// catch block must still resolve and redact the effective key. Regression
/// guard for the catch block reverting to <c>GetOptional("api-key")</c> only, /// guard for the catch block reverting to <c>GetOptional("api-key")</c> only,
/// which is null on the env-var path and leaves the key unredacted. /// which is null on the env-var path and leaves the key unredacted.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable() public async Task RunAsync_ErrorOutput_RedactsApiKey_WhenSourcedFromEnvironmentVariable()
{ {
@@ -201,7 +310,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary> /// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput() public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
{ {
@@ -244,7 +352,6 @@ public sealed class MxGatewayClientCliTests
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary> /// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases() public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
{ {
@@ -284,7 +391,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary> /// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply() public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
{ {
@@ -327,7 +433,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary> /// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession() public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
{ {
@@ -359,7 +464,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary> /// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply() public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
{ {
@@ -390,7 +494,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary> /// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary() public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
{ {
@@ -464,7 +567,6 @@ public sealed class MxGatewayClientCliTests
/// Verifies galaxy-browse walks root objects and eagerly expands one further /// Verifies galaxy-browse walks root objects and eagerly expands one further
/// level when --depth 1 is passed, printing an indented tree. /// level when --depth 1 is passed, printing an indented tree.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth() public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth()
{ {
@@ -521,7 +623,6 @@ public sealed class MxGatewayClientCliTests
/// Verifies galaxy-browse --json emits a nested JSON document and forwards /// Verifies galaxy-browse --json emits a nested JSON document and forwards
/// the filter flags onto the BrowseChildren request. /// the filter flags onto the BrowseChildren request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters() public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters()
{ {
@@ -566,7 +667,6 @@ public sealed class MxGatewayClientCliTests
/// Verifies galaxy-browse --parent fetches exactly one level of children for /// Verifies galaxy-browse --parent fetches exactly one level of children for
/// the supplied gobject id via a parent-scoped BrowseChildren request. /// the supplied gobject id via a parent-scoped BrowseChildren request.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel() public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel()
{ {
@@ -604,7 +704,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary> /// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents() public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
{ {
@@ -659,7 +758,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary> /// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent() public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
{ {
@@ -695,7 +793,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary> /// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord() public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
{ {
@@ -722,7 +819,6 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary> /// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson() public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
{ {
@@ -757,7 +853,7 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// The README CLI examples for the alarm subcommands at /// Client.Dotnet-018: the README CLI examples for the alarm subcommands at
/// `clients/dotnet/README.md` must drive cleanly through the production /// `clients/dotnet/README.md` must drive cleanly through the production
/// CLI argument parser. The previous text used non-existent flags /// CLI argument parser. The previous text used non-existent flags
/// (`--session-id`, `--max-messages`, `--alarm-reference`) that would /// (`--session-id`, `--max-messages`, `--alarm-reference`) that would
@@ -767,7 +863,6 @@ public sealed class MxGatewayClientCliTests
/// against exit code 0. /// against exit code 0.
/// </summary> /// </summary>
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param> /// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("stream-alarms")] [InlineData("stream-alarms")]
[InlineData("acknowledge-alarm")] [InlineData("acknowledge-alarm")]
@@ -816,13 +911,12 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// `BenchReadBulkAsync` previously fell back to /// Client.Dotnet-019: `BenchReadBulkAsync` previously fell back to
/// <c>reply.ReturnValue.Int32Value</c> when the register reply had no /// <c>reply.ReturnValue.Int32Value</c> when the register reply had no
/// typed <c>Register</c> payload, silently driving the rest of the bench /// typed <c>Register</c> payload, silently driving the rest of the bench
/// against a zero server handle. The fix must fail loudly with a /// against a zero server handle. The fix must fail loudly with a
/// descriptive <see cref="MxGatewayException"/>. /// descriptive <see cref="MxGatewayException"/>.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly() public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
{ {
@@ -830,6 +924,9 @@ public sealed class MxGatewayClientCliTests
using var error = new StringWriter(); using var error = new StringWriter();
FakeCliClient fakeClient = new(); FakeCliClient fakeClient = new();
// Successful protocol + MX status but no typed `Register` payload. // Successful protocol + MX status but no typed `Register` payload.
// Before the Client.Dotnet-019 fix this silently became serverHandle=0
// and the bench proceeded through SubscribeBulk / warmup / steady-state
// against an invalid handle, producing a misleading zero-result summary.
fakeClient.InvokeReplies.Enqueue(new MxCommandReply fakeClient.InvokeReplies.Enqueue(new MxCommandReply
{ {
SessionId = "session-fixture", SessionId = "session-fixture",
@@ -864,13 +961,12 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// The steady-state loop in `BenchReadBulkAsync` had a /// Client.Dotnet-020: the steady-state loop in `BenchReadBulkAsync` had a
/// bare `catch { failedCalls++; continue; }` that swallowed /// bare `catch { failedCalls++; continue; }` that swallowed
/// <see cref="OperationCanceledException"/>, so token-driven cancellation /// <see cref="OperationCanceledException"/>, so token-driven cancellation
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix /// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
/// the bench must exit promptly when the supplied token cancels. /// the bench must exit promptly when the supplied token cancels.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly() public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
{ {
@@ -959,13 +1055,12 @@ public sealed class MxGatewayClientCliTests
} }
/// <summary> /// <summary>
/// Both `ReadBulkAsync` and `BenchReadBulkAsync` cast /// Client.Dotnet-021: both `ReadBulkAsync` and `BenchReadBulkAsync` cast
/// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without /// the user-supplied <c>--timeout-ms</c> to <see cref="uint"/> without
/// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps /// bounds checking, so a negative value (e.g. <c>-1</c>) silently wraps
/// to ~49.7 days. The fix must reject negatives with a clear error. /// to ~49.7 days. The fix must reject negatives with a clear error.
/// </summary> /// </summary>
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param> /// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory] [Theory]
[InlineData("read-bulk")] [InlineData("read-bulk")]
[InlineData("bench-read-bulk")] [InlineData("bench-read-bulk")]
@@ -1128,8 +1223,7 @@ public sealed class MxGatewayClientCliTests
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary> /// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; } public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
/// <summary>Disposes the fake client. No-op; there are no unmanaged resources to release.</summary> /// <inheritdoc />
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
return ValueTask.CompletedTask; return ValueTask.CompletedTask;
@@ -7,7 +7,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayClientSessionTests public sealed class MxGatewayClientSessionTests
{ {
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary> /// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation() public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
{ {
@@ -23,7 +22,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that open session returns a session with the raw open reply.</summary> /// <summary>Verifies that open session returns a session with the raw open reply.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply() public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
{ {
@@ -39,7 +37,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that register builds a register command and returns server handle.</summary> /// <summary>Verifies that register builds a register command and returns server handle.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle() public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
{ {
@@ -65,7 +62,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary> /// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task AddItem2Async_BuildsAddItem2CommandWithContext() public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
{ {
@@ -91,7 +87,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write raw builds a write command with the raw value.</summary> /// <summary>Verifies that write raw builds a write command with the raw value.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue() public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
{ {
@@ -123,7 +118,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary> /// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp() public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
{ {
@@ -152,7 +146,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary> /// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults() public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
{ {
@@ -192,7 +185,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary> /// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder() public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
{ {
@@ -223,8 +215,56 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal("session-fixture", request.SessionId); Assert.Equal("session-fixture", request.SessionId);
} }
/// <summary>
/// Verifies that a reconnect-replay gap sentinel is surfaced as a typed
/// <see cref="MxEventStreamItem"/> with the gap populated, while normal
/// events pass through unchanged with IsReplayGap false.
/// </summary>
[Fact]
public async Task StreamEventItemsAsync_SurfacesReplayGapSentinel()
{
FakeGatewayTransport transport = CreateTransport();
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
ReplayGap = new ReplayGap
{
RequestedAfterSequence = 5,
OldestAvailableSequence = 42,
},
});
transport.AddEvent(new MxEvent
{
SessionId = "session-fixture",
Family = MxEventFamily.OnDataChange,
WorkerSequence = 42,
});
await using MxGatewayClient client = CreateClient(transport);
MxGatewaySession session = await client.OpenSessionAsync();
List<MxEventStreamItem> items = [];
await foreach (MxEventStreamItem item in session.StreamEventItemsAsync(afterWorkerSequence: 5))
{
items.Add(item);
}
Assert.Equal(2, items.Count);
MxEventStreamItem gap = items[0];
Assert.True(gap.IsReplayGap);
Assert.NotNull(gap.ReplayGap);
Assert.Equal(5UL, gap.ReplayGap!.RequestedAfterSequence);
Assert.Equal(42UL, gap.ReplayGap.OldestAvailableSequence);
Assert.Same(gap.ReplayGap, gap.Event.ReplayGap);
MxEventStreamItem normal = items[1];
Assert.False(normal.IsReplayGap);
Assert.Null(normal.ReplayGap);
Assert.Equal(42UL, normal.Event.WorkerSequence);
Assert.Equal(MxEventFamily.OnDataChange, normal.Event.Family);
}
/// <summary>Verifies that close is explicit and idempotent.</summary> /// <summary>Verifies that close is explicit and idempotent.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task CloseAsync_IsExplicitAndIdempotent() public async Task CloseAsync_IsExplicitAndIdempotent()
{ {
@@ -241,7 +281,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary> /// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure() public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
{ {
@@ -266,7 +305,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary> /// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure() public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
{ {
@@ -280,7 +318,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary> /// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeAsync_DoesNotRetryWriteCommand() public async Task InvokeAsync_DoesNotRetryWriteCommand()
{ {
@@ -296,7 +333,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary> /// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task InvokeHelpers_PassCancellationTokenToTransport() public async Task InvokeHelpers_PassCancellationTokenToTransport()
{ {
@@ -343,7 +379,6 @@ public sealed class MxGatewayClientSessionTests
} }
/// <summary>Verifies that WriteArrayElementsAsync builds a write command whose value is a SparseArrayValue.</summary> /// <summary>Verifies that WriteArrayElementsAsync builds a write command whose value is a SparseArrayValue.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task WriteArrayElementsAsync_BuildsWriteCommandWithSparseArrayValue() public async Task WriteArrayElementsAsync_BuildsWriteCommandWithSparseArrayValue()
{ {
@@ -380,6 +415,297 @@ public sealed class MxGatewayClientSessionTests
Assert.Equal(7, el.Value.Int32Value); Assert.Equal(7, el.Value.Int32Value);
} }
/// <summary>Verifies that unregister builds an unregister command with the server handle.</summary>
[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);
}
/// <summary>Verifies that advise-supervisory builds the supervisory advise command.</summary>
[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);
}
/// <summary>Verifies that add-buffered-item returns the item handle from the typed reply.</summary>
[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);
}
/// <summary>Verifies that set-buffered-update-interval builds the command with the interval.</summary>
[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);
}
/// <summary>Verifies that suspend builds the command and returns the reply status.</summary>
[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);
}
/// <summary>Verifies that activate builds the command and returns the reply status.</summary>
[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);
}
/// <summary>Verifies that write-secured builds a WriteSecured command with value and user ids.</summary>
[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);
}
/// <summary>
/// MXAccess parity: WriteSecured issued before a prior AuthenticateUser fails
/// natively. The client must surface that scripted failure (as an
/// <see cref="MxAccessException"/>) rather than pre-validating it away.
/// </summary>
[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<MxAccessException>(
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);
}
/// <summary>Verifies that write-secured2 builds a WriteSecured2 command with value and timestamp.</summary>
[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);
}
/// <summary>Verifies that authenticate-user builds the command and returns the resolved user id.</summary>
[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);
}
/// <summary>
/// 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.
/// </summary>
[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<MxAccessException>(
async () => await session.AuthenticateUserAsync(12, "operator", password));
Assert.DoesNotContain(password, exception.Message);
Assert.DoesNotContain(password, exception.ToString());
}
/// <summary>Verifies that archestra-user-to-id builds the command and returns the resolved user id.</summary>
[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) private static MxGatewayClient CreateClient(FakeGatewayTransport transport)
{ {
return new MxGatewayClient(transport.Options, transport); return new MxGatewayClient(transport.Options, transport);
@@ -3,7 +3,6 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
public sealed class MxGatewayGeneratedContractTests public sealed class MxGatewayGeneratedContractTests
{ {
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary> /// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact] [Fact]
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory() public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
{ {
@@ -337,9 +337,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>Builds a BrowseChildren request from the caller-supplied filter options.</summary>
/// <param name="options">The browse-children filter options.</param>
/// <returns>The populated request.</returns>
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options) internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{ {
ArgumentNullException.ThrowIfNull(options); ArgumentNullException.ThrowIfNull(options);
@@ -427,7 +424,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
/// <summary> /// <summary>
/// Closes the gRPC channel and releases resources. /// Closes the gRPC channel and releases resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -497,9 +493,6 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options); CreateHttpHandlerForTests(options);
/// <summary>Creates the <see cref="SocketsHttpHandler"/> used for gRPC calls, exposed for test configuration.</summary>
/// <param name="options">The client options supplying TLS and timeout settings.</param>
/// <returns>The configured HTTP handler.</returns>
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
@@ -10,7 +10,9 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary> /// <summary>
@@ -89,16 +91,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <summary> /// <inheritdoc />
/// Watches for deployment events from the Galaxy Repository server.
/// </summary>
/// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <param name="cancellationToken">
/// A cancellation token distinct from <paramref name="callOptions"/>'s; when cancelable, it
/// takes precedence over the token carried by <paramref name="callOptions"/>.
/// </param>
/// <returns>An asynchronous stream of deploy events.</returns>
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -10,7 +10,9 @@ internal sealed class GrpcMxGatewayClientTransport(
MxGatewayClientOptions options, MxGatewayClientOptions options,
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
{ {
/// <inheritdoc /> /// <summary>
/// Gets the gateway client options.
/// </summary>
public MxGatewayClientOptions Options { get; } = options; public MxGatewayClientOptions Options { get; } = options;
/// <summary> /// <summary>
@@ -72,13 +74,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <summary> /// <inheritdoc />
/// Streams events from the session.
/// </summary>
/// <param name="request">The stream events request.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Cancellation token; falls back to <paramref name="callOptions"/>'s token when not cancelable.</param>
/// <returns>An async enumerable of events.</returns>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -137,14 +133,7 @@ internal sealed class GrpcMxGatewayClientTransport(
} }
} }
/// <summary> /// <inheritdoc />
/// Streams a snapshot of all alarms currently in Active or ActiveAcked state — the
/// ConditionRefresh equivalent for the gateway.
/// </summary>
/// <param name="request">The query request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Cancellation token; falls back to <paramref name="callOptions"/>'s token when not cancelable.</param>
/// <returns>An async enumerable of active-alarm snapshots.</returns>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync( public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request, QueryActiveAlarmsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -186,14 +175,7 @@ internal sealed class GrpcMxGatewayClientTransport(
return QueryActiveAlarmsAsync(request, callOptions); return QueryActiveAlarmsAsync(request, callOptions);
} }
/// <summary> /// <inheritdoc />
/// Attaches to the gateway's central alarm feed — the current active-alarm
/// snapshot followed by live transitions.
/// </summary>
/// <param name="request">The stream request, optionally scoped by alarm-reference prefix.</param>
/// <param name="callOptions">gRPC call options.</param>
/// <param name="cancellationToken">Cancellation token; falls back to <paramref name="callOptions"/>'s token when not cancelable.</param>
/// <returns>An async enumerable of alarm feed messages.</returns>
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
StreamAlarmsRequest request, StreamAlarmsRequest request,
CallOptions callOptions, CallOptions callOptions,
@@ -15,7 +15,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Tests the connection to the Galaxy Repository server.</summary> /// <summary>Tests the connection to the Galaxy Repository server.</summary>
/// <param name="request">The test connection request.</param> /// <param name="request">The test connection request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>The test connection reply.</returns>
Task<TestConnectionReply> TestConnectionAsync( Task<TestConnectionReply> TestConnectionAsync(
TestConnectionRequest request, TestConnectionRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -23,7 +22,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary> /// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
/// <param name="request">The get last deploy time request.</param> /// <param name="request">The get last deploy time request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>The last deploy time reply.</returns>
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync( Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
GetLastDeployTimeRequest request, GetLastDeployTimeRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -31,7 +29,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary> /// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
/// <param name="request">The discover hierarchy request.</param> /// <param name="request">The discover hierarchy request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>The discovered hierarchy reply.</returns>
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync( Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -39,7 +36,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary> /// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param> /// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>The browse children reply.</returns>
Task<BrowseChildrenReply> BrowseChildrenAsync( Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request, BrowseChildrenRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -47,7 +43,6 @@ internal interface IGalaxyRepositoryClientTransport
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary> /// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param> /// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
/// <returns>An asynchronous stream of deploy events.</returns>
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
CallOptions callOptions); CallOptions callOptions);
@@ -12,7 +12,7 @@ public sealed class LazyBrowseNode
{ {
private readonly GalaxyRepositoryClient _client; private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options; private readonly BrowseChildrenOptions _options;
// Won't Fix: this gate is used only via WaitAsync/Release and // Client.Dotnet-027 (Won't Fix): this gate is used only via WaitAsync/Release and
// never via AvailableWaitHandle, so SemaphoreSlim allocates no kernel wait handle — // never via AvailableWaitHandle, so SemaphoreSlim allocates no kernel wait handle —
// it holds no unmanaged/OS handle to leak. It is pure managed memory whose lifetime // it holds no unmanaged/OS handle to leak. It is pure managed memory whose lifetime
// is the node's, so the type is intentionally not IDisposable: making it disposable // is the node's, so the type is intentionally not IDisposable: making it disposable
@@ -27,11 +27,6 @@ public sealed class LazyBrowseNode
private IReadOnlyList<LazyBrowseNode> _children = []; private IReadOnlyList<LazyBrowseNode> _children = [];
private volatile bool _isExpanded; private volatile bool _isExpanded;
/// <summary>Initializes a new instance of the <see cref="LazyBrowseNode"/> class.</summary>
/// <param name="client">The Galaxy Repository client used to fetch children on expansion.</param>
/// <param name="object">The underlying Galaxy object for this node.</param>
/// <param name="hasChildrenHint">Whether the server reports this node has at least one matching descendant.</param>
/// <param name="options">The browse options to apply when fetching children.</param>
internal LazyBrowseNode( internal LazyBrowseNode(
GalaxyRepositoryClient client, GalaxyRepositoryClient client,
GalaxyObject @object, GalaxyObject @object,
@@ -71,7 +66,6 @@ public sealed class LazyBrowseNode
/// partially-built list. /// partially-built list.
/// </remarks> /// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param> /// <param name="cancellationToken">Token to observe for cancellation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task ExpandAsync(CancellationToken cancellationToken = default) public async Task ExpandAsync(CancellationToken cancellationToken = default)
{ {
if (_isExpanded) if (_isExpanded)
@@ -7,7 +7,6 @@ public static class MxCommandReplyExtensions
{ {
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary> /// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
/// <param name="reply">The command reply to check.</param> /// <param name="reply">The command reply to check.</param>
/// <returns>The same reply, for chaining.</returns>
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply) public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -25,7 +24,6 @@ public static class MxCommandReplyExtensions
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary> /// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
/// <param name="reply">The command reply to check.</param> /// <param name="reply">The command reply to check.</param>
/// <returns>The same reply, for chaining.</returns>
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply) public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
{ {
ArgumentNullException.ThrowIfNull(reply); ArgumentNullException.ThrowIfNull(reply);
@@ -0,0 +1,40 @@
using System.Runtime.CompilerServices;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Extension methods that project a raw <see cref="MxEvent"/> stream into the
/// typed <see cref="MxEventStreamItem"/> surface, making the gateway's
/// reconnect-replay gap sentinel observable.
/// </summary>
public static class MxEventStreamExtensions
{
/// <summary>
/// Projects a raw <see cref="MxEvent"/> stream (e.g.
/// <see cref="MxGatewaySession.StreamEventsAsync"/> or
/// <see cref="MxGatewayClient.StreamEventsAsync"/>) into typed
/// <see cref="MxEventStreamItem"/> values. Normal events pass through with
/// <see cref="MxEventStreamItem.IsReplayGap"/> false; the gateway's
/// reconnect-replay gap sentinel is surfaced with
/// <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated. The stream is
/// forwarded faithfully — no event is synthesized or dropped.
/// </summary>
/// <param name="source">The raw event stream to wrap.</param>
/// <param name="cancellationToken">Cancellation token for the enumeration.</param>
/// <returns>The same events, each wrapped as an <see cref="MxEventStreamItem"/>.</returns>
public static async IAsyncEnumerable<MxEventStreamItem> AsStreamItemsAsync(
this IAsyncEnumerable<MxEvent> source,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(source);
await foreach (MxEvent gatewayEvent in source
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
yield return MxEventStreamItem.From(gatewayEvent);
}
}
}
@@ -0,0 +1,82 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One item yielded by the typed event stream. It is either a normal MXAccess
/// <see cref="MxEvent"/> or a reconnect-replay <em>gap sentinel</em>.
/// </summary>
/// <remarks>
/// <para>
/// The gateway emits a single sentinel <see cref="MxEvent"/> at the head of a
/// <c>StreamEvents</c> stream that was resumed via
/// <c>StreamEventsRequest.after_worker_sequence</c> when the requested sequence
/// predates the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed. On that sentinel the
/// <c>MxEvent.replay_gap</c> field is set, <see cref="MxEvent.Family"/> is
/// <see cref="MxEventFamily.Unspecified"/>, the <c>body</c> oneof is unset, and
/// no per-item fields are populated.
/// </para>
/// <para>
/// This wrapper makes that sentinel observable instead of forcing the consumer
/// to inspect the raw <see cref="MxEvent"/>. It never synthesizes or swallows an
/// event: the gap is exactly the gateway's own sentinel, exposed with
/// <see cref="IsReplayGap"/> set and <see cref="ReplayGap"/> populated. Every
/// other event flows through unchanged with <see cref="IsReplayGap"/> false.
/// </para>
/// <para>
/// When <see cref="IsReplayGap"/> is <see langword="true"/> the consumer has
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </para>
/// </remarks>
public sealed class MxEventStreamItem
{
private MxEventStreamItem(MxEvent gatewayEvent, ReplayGap? replayGap)
{
Event = gatewayEvent;
ReplayGap = replayGap;
}
/// <summary>
/// The underlying raw <see cref="MxEvent"/>. For a normal event this is the
/// MXAccess event itself; for a replay-gap item this is the gateway's
/// sentinel event whose only meaningful payload is <see cref="ReplayGap"/>.
/// Never <see langword="null"/>.
/// </summary>
public MxEvent Event { get; }
/// <summary>
/// The reconnect-replay gap payload when this item is a gap sentinel;
/// otherwise <see langword="null"/>. Read
/// <see cref="Contracts.Proto.ReplayGap.RequestedAfterSequence"/> and
/// <see cref="Contracts.Proto.ReplayGap.OldestAvailableSequence"/> to learn
/// which events were lost.
/// </summary>
public ReplayGap? ReplayGap { get; }
/// <summary>
/// <see langword="true"/> when this item is a reconnect-replay gap sentinel:
/// the consumer missed events and must discard local state and re-snapshot.
/// Resume without another gap by reconnecting with
/// <c>after_worker_sequence = ReplayGap.OldestAvailableSequence - 1</c>.
/// </summary>
public bool IsReplayGap => ReplayGap is not null;
/// <summary>
/// Wraps a raw stream <see cref="MxEvent"/> as a typed item, classifying it
/// as a replay-gap sentinel when <c>MxEvent.replay_gap</c> is present.
/// </summary>
/// <param name="gatewayEvent">The raw event from the <c>StreamEvents</c> stream.</param>
/// <returns>A typed item exposing either the normal event or the replay gap.</returns>
internal static MxEventStreamItem From(MxEvent gatewayEvent)
{
ArgumentNullException.ThrowIfNull(gatewayEvent);
// For a message-typed proto3 field, presence is a non-null reference.
return gatewayEvent.ReplayGap is { } replayGap
? new MxEventStreamItem(gatewayEvent, replayGap)
: new MxEventStreamItem(gatewayEvent, replayGap: null);
}
}
@@ -249,7 +249,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
/// <summary> /// <summary>
/// Disposes the client and releases all resources. /// Disposes the client and releases all resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public ValueTask DisposeAsync() public ValueTask DisposeAsync()
{ {
if (_disposed) if (_disposed)
@@ -319,11 +318,6 @@ public sealed class MxGatewayClient : IAsyncDisposable
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) => private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
CreateHttpHandlerForTests(options); CreateHttpHandlerForTests(options);
/// <summary>
/// Creates the <see cref="SocketsHttpHandler"/> used for gRPC calls; exposed internally so tests can inspect it.
/// </summary>
/// <param name="options">The client options that configure connect timeout and TLS behavior.</param>
/// <returns>A configured <see cref="SocketsHttpHandler"/>.</returns>
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options) internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
{ {
SocketsHttpHandler handler = new() SocketsHttpHandler handler = new()
@@ -12,7 +12,6 @@ internal static class MxGatewayClientRetryPolicy
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary> /// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param> /// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
/// <param name="logger">Optional logger for retry diagnostics.</param> /// <param name="logger">Optional logger for retry diagnostics.</param>
/// <returns>A configured resilience pipeline that retries transient gRPC failures.</returns>
public static ResiliencePipeline Create( public static ResiliencePipeline Create(
MxGatewayClientRetryOptions options, MxGatewayClientRetryOptions options,
ILogger? logger) ILogger? logger)
@@ -43,7 +42,6 @@ internal static class MxGatewayClientRetryPolicy
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary> /// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
/// <param name="kind">The command kind to check.</param> /// <param name="kind">The command kind to check.</param>
/// <returns><see langword="true"/> if the command kind may be automatically retried; otherwise, <see langword="false"/>.</returns>
public static bool IsRetryableCommand(MxCommandKind kind) public static bool IsRetryableCommand(MxCommandKind kind)
{ {
return kind is MxCommandKind.Ping return kind is MxCommandKind.Ping
@@ -211,7 +211,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task AdviseAsync( public async Task AdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -253,7 +252,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task UnAdviseAsync( public async Task UnAdviseAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -295,7 +293,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="serverHandle">The ServerHandle from register.</param> /// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param> /// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task RemoveItemAsync( public async Task RemoveItemAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -678,7 +675,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="value">The value to write.</param> /// <param name="value">The value to write.</param>
/// <param name="userId">User ID context for the write.</param> /// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task WriteAsync( public async Task WriteAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -708,7 +704,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="elements">Map of zero-based array index to scalar <see cref="MxValue"/>.</param> /// <param name="elements">Map of zero-based array index to scalar <see cref="MxValue"/>.</param>
/// <param name="userId">User ID context for the write.</param> /// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WriteArrayElementsAsync( public Task WriteArrayElementsAsync(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -791,7 +786,6 @@ public sealed class MxGatewaySession : IAsyncDisposable
/// <param name="timestampValue">The timestamp to write with the value.</param> /// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="userId">User ID context for the write.</param> /// <param name="userId">User ID context for the write.</param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public async Task Write2Async( public async Task Write2Async(
int serverHandle, int serverHandle,
int itemHandle, int itemHandle,
@@ -848,6 +842,525 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Unregisters a previously registered client from the MXAccess session
/// (MXAccess <c>Unregister</c>), releasing its ServerHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task UnregisterAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await UnregisterRawAsync(serverHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Unregisters a previously registered client without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> UnregisterRawAsync(
int serverHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Unregister,
Unregister = new UnregisterCommand { ServerHandle = serverHandle },
},
cancellationToken);
}
/// <summary>
/// Subscribes to supervisory events for an item (MXAccess <c>AdviseSupervisory</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task AdviseSupervisoryAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await AdviseSupervisoryRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Subscribes to supervisory events for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> AdviseSupervisoryRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.AdviseSupervisory,
AdviseSupervisory = new AdviseSupervisoryCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Adds a buffered item to the MXAccess session (MXAccess <c>AddBufferedItem</c>),
/// returning an ItemHandle.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item handle assigned to the new buffered item.</returns>
public async Task<int> 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;
}
/// <summary>
/// Adds a buffered item to the MXAccess session without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemDefinition">The item tag address.</param>
/// <param name="itemContext">Additional context for the item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Sets the buffered-item update interval on the MXAccess session
/// (MXAccess <c>SetBufferedUpdateInterval</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task SetBufferedUpdateIntervalAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SetBufferedUpdateIntervalRawAsync(
serverHandle,
updateIntervalMilliseconds,
cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
}
/// <summary>
/// Sets the buffered-item update interval without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="updateIntervalMilliseconds">The buffered update interval, in milliseconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SetBufferedUpdateIntervalRawAsync(
int serverHandle,
int updateIntervalMilliseconds,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.SetBufferedUpdateInterval,
SetBufferedUpdateInterval = new SetBufferedUpdateIntervalCommand
{
ServerHandle = serverHandle,
UpdateIntervalMilliseconds = updateIntervalMilliseconds,
},
},
cancellationToken);
}
/// <summary>
/// Suspends updates for an item (MXAccess <c>Suspend</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> SuspendAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await SuspendRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Suspend?.Status;
}
/// <summary>
/// Suspends updates for an item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> SuspendRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Suspend,
Suspend = new SuspendCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Resumes updates for a suspended item (MXAccess <c>Activate</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The item's MXSTATUS_PROXY as reported by the worker, or <c>null</c> if the reply omitted it.</returns>
public async Task<MxStatusProxy?> ActivateAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
MxCommandReply reply = await ActivateRawAsync(serverHandle, itemHandle, cancellationToken)
.ConfigureAwait(false);
reply.EnsureProtocolSuccess().EnsureMxAccessSuccess();
return reply.Activate?.Status;
}
/// <summary>
/// Resumes updates for a suspended item without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> ActivateRawAsync(
int serverHandle,
int itemHandle,
CancellationToken cancellationToken = default)
{
return InvokeCommandAsync(
new MxCommand
{
Kind = MxCommandKind.Activate,
Activate = new ActivateCommand
{
ServerHandle = serverHandle,
ItemHandle = itemHandle,
},
},
cancellationToken);
}
/// <summary>
/// Writes a secured value to an item on the MXAccess server (MXAccess <c>WriteSecured</c>).
/// </summary>
/// <remarks>
/// MXAccess parity: <c>WriteSecured</c> fails when it is issued before a value-bearing
/// NMX body or before a prior <c>AuthenticateUser</c> + <c>AdviseSupervisory</c>. That
/// native failure is surfaced unchanged — the client does not pre-validate or reorder it.
/// The <paramref name="value"/> is credential-sensitive and must never reach logs; the
/// client mirrors the single-item WriteSecured redaction contract.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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();
}
/// <summary>
/// Writes a secured value to an item without error checking. See
/// <see cref="WriteSecuredAsync"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Writes a secured value and timestamp to an item (MXAccess <c>WriteSecured2</c>).
/// </summary>
/// <remarks>
/// Same parity and redaction contract as <see cref="WriteSecuredAsync"/>: the native
/// failure that occurs before a value-bearing NMX body or a prior authenticate is
/// surfaced unchanged, and the credential-sensitive <paramref name="value"/> must never
/// reach logs.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
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();
}
/// <summary>
/// Writes a secured value and timestamp to an item without error checking. See
/// <see cref="WriteSecured2Async"/> for the parity and redaction contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="itemHandle">The ItemHandle from add-item.</param>
/// <param name="value">The secured value to write.</param>
/// <param name="timestampValue">The timestamp to write with the value.</param>
/// <param name="currentUserId">The current operator user id.</param>
/// <param name="verifierUserId">The verifier (secondary approver) user id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Authenticates an MXAccess verify-user (MXAccess <c>AuthenticateUser</c>), returning
/// the resolved user id used by <c>WriteSecured</c> / <c>WriteSecured2</c>.
/// </summary>
/// <remarks>
/// The <paramref name="verifyUserPassword"/> 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.
/// </remarks>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The authenticated user id.</returns>
public async Task<int> 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;
}
/// <summary>
/// Authenticates an MXAccess verify-user without error checking. See
/// <see cref="AuthenticateUserAsync"/> for the credential-handling contract.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="verifyUser">The user to verify.</param>
/// <param name="verifyUserPassword">The verify-user credential. Never logged.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id
/// (MXAccess <c>ArchestrAUserToId</c>).
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The resolved MXAccess user id.</returns>
public async Task<int> 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;
}
/// <summary>
/// Resolves an ArchestrA user GUID to its MXAccess user id without error checking.
/// </summary>
/// <param name="serverHandle">The ServerHandle from register.</param>
/// <param name="userIdGuid">The ArchestrA user GUID to resolve.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<MxCommandReply> 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);
}
/// <summary> /// <summary>
/// Invokes an MXAccess command on this session. /// Invokes an MXAccess command on this session.
/// </summary> /// </summary>
@@ -881,10 +1394,37 @@ public sealed class MxGatewaySession : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>
/// Streams events as typed <see cref="MxEventStreamItem"/> values, surfacing
/// the gateway's reconnect-replay gap sentinel as an observable, typed signal.
/// </summary>
/// <remarks>
/// When resuming with a stale <paramref name="afterWorkerSequence"/> (older than
/// the oldest event still retained in the session replay ring), the gateway emits
/// a single gap sentinel at the head of the stream. It arrives here as an item
/// with <see cref="MxEventStreamItem.IsReplayGap"/> true and
/// <see cref="MxEventStreamItem.ReplayGap"/> populated, meaning the consumer
/// missed events and MUST discard local state and re-snapshot. To resume without
/// incurring another gap, reconnect with
/// <c>afterWorkerSequence = item.ReplayGap.OldestAvailableSequence - 1</c>.
/// All other events pass through with <see cref="MxEventStreamItem.IsReplayGap"/>
/// false. Use <see cref="StreamEventsAsync"/> when raw generated
/// <see cref="MxEvent"/> messages are needed instead.
/// </remarks>
/// <param name="afterWorkerSequence">The sequence number to stream from. Defaults to 0.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>An async enumerable of typed event items.</returns>
public IAsyncEnumerable<MxEventStreamItem> StreamEventItemsAsync(
ulong afterWorkerSequence = 0,
CancellationToken cancellationToken = default)
{
return StreamEventsAsync(afterWorkerSequence, cancellationToken)
.AsStreamItemsAsync(cancellationToken);
}
/// <summary> /// <summary>
/// Closes the session and releases resources. /// Closes the session and releases resources.
/// </summary> /// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await CloseAsync().ConfigureAwait(false); await CloseAsync().ConfigureAwait(false);
@@ -7,7 +7,6 @@ public static class MxStatusProxyExtensions
{ {
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary> /// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
/// <param name="status">The status to check.</param> /// <param name="status">The status to check.</param>
/// <returns><see langword="true"/> if the status indicates success; otherwise <see langword="false"/>.</returns>
public static bool IsSuccess(this MxStatusProxy status) public static bool IsSuccess(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -18,7 +17,6 @@ public static class MxStatusProxyExtensions
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary> /// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
/// <param name="status">The status to summarize.</param> /// <param name="status">The status to summarize.</param>
/// <returns>A formatted diagnostic summary string.</returns>
public static string ToDiagnosticSummary(this MxStatusProxy status) public static string ToDiagnosticSummary(this MxStatusProxy status)
{ {
ArgumentNullException.ThrowIfNull(status); ArgumentNullException.ThrowIfNull(status);
@@ -14,7 +14,6 @@ public static class MxValueExtensions
/// Converts a boolean value to an MxValue with MxDataType.Boolean. /// Converts a boolean value to an MxValue with MxDataType.Boolean.
/// </summary> /// </summary>
/// <param name="value">Scalar boolean value to wrap.</param> /// <param name="value">Scalar boolean value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped boolean.</returns>
public static MxValue ToMxValue(this bool value) public static MxValue ToMxValue(this bool value)
{ {
return new MxValue return new MxValue
@@ -29,7 +28,6 @@ public static class MxValueExtensions
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer. /// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="value">32-bit integer value to wrap.</param> /// <param name="value">32-bit integer value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped integer.</returns>
public static MxValue ToMxValue(this int value) public static MxValue ToMxValue(this int value)
{ {
return new MxValue return new MxValue
@@ -44,7 +42,6 @@ public static class MxValueExtensions
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer. /// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="value">64-bit integer value to wrap.</param> /// <param name="value">64-bit integer value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped integer.</returns>
public static MxValue ToMxValue(this long value) public static MxValue ToMxValue(this long value)
{ {
return new MxValue return new MxValue
@@ -59,7 +56,6 @@ public static class MxValueExtensions
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float. /// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
/// </summary> /// </summary>
/// <param name="value">Single-precision floating-point value to wrap.</param> /// <param name="value">Single-precision floating-point value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped value.</returns>
public static MxValue ToMxValue(this float value) public static MxValue ToMxValue(this float value)
{ {
return new MxValue return new MxValue
@@ -74,7 +70,6 @@ public static class MxValueExtensions
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double. /// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
/// </summary> /// </summary>
/// <param name="value">Double-precision floating-point value to wrap.</param> /// <param name="value">Double-precision floating-point value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped value.</returns>
public static MxValue ToMxValue(this double value) public static MxValue ToMxValue(this double value)
{ {
return new MxValue return new MxValue
@@ -89,7 +84,6 @@ public static class MxValueExtensions
/// Converts a string value to an MxValue with MxDataType.String. /// Converts a string value to an MxValue with MxDataType.String.
/// </summary> /// </summary>
/// <param name="value">String value to wrap.</param> /// <param name="value">String value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped string.</returns>
public static MxValue ToMxValue(this string value) public static MxValue ToMxValue(this string value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -106,7 +100,6 @@ public static class MxValueExtensions
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time. /// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="value">DateTimeOffset value to wrap.</param> /// <param name="value">DateTimeOffset value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped timestamp.</returns>
public static MxValue ToMxValue(this DateTimeOffset value) public static MxValue ToMxValue(this DateTimeOffset value)
{ {
return new MxValue return new MxValue
@@ -121,7 +114,6 @@ public static class MxValueExtensions
/// Converts a DateTime value to an MxValue with MxDataType.Time. /// Converts a DateTime value to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="value">DateTime value to wrap.</param> /// <param name="value">DateTime value to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped timestamp.</returns>
public static MxValue ToMxValue(this DateTime value) public static MxValue ToMxValue(this DateTime value)
{ {
return new DateTimeOffset( return new DateTimeOffset(
@@ -135,7 +127,6 @@ public static class MxValueExtensions
/// Converts a boolean array to an MxValue with MxDataType.Boolean. /// Converts a boolean array to an MxValue with MxDataType.Boolean.
/// </summary> /// </summary>
/// <param name="values">Array of boolean values to wrap.</param> /// <param name="values">Array of boolean values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<bool> values) public static MxValue ToMxValue(this IReadOnlyList<bool> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -154,7 +145,6 @@ public static class MxValueExtensions
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer. /// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="values">Array of 32-bit integer values to wrap.</param> /// <param name="values">Array of 32-bit integer values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<int> values) public static MxValue ToMxValue(this IReadOnlyList<int> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -173,7 +163,6 @@ public static class MxValueExtensions
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer. /// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
/// </summary> /// </summary>
/// <param name="values">Array of 64-bit integer values to wrap.</param> /// <param name="values">Array of 64-bit integer values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<long> values) public static MxValue ToMxValue(this IReadOnlyList<long> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -192,7 +181,6 @@ public static class MxValueExtensions
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float. /// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
/// </summary> /// </summary>
/// <param name="values">Array of single-precision floating-point values to wrap.</param> /// <param name="values">Array of single-precision floating-point values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<float> values) public static MxValue ToMxValue(this IReadOnlyList<float> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -211,7 +199,6 @@ public static class MxValueExtensions
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double. /// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
/// </summary> /// </summary>
/// <param name="values">Array of double-precision floating-point values to wrap.</param> /// <param name="values">Array of double-precision floating-point values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<double> values) public static MxValue ToMxValue(this IReadOnlyList<double> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -230,7 +217,6 @@ public static class MxValueExtensions
/// Converts a string array to an MxValue with MxDataType.String. /// Converts a string array to an MxValue with MxDataType.String.
/// </summary> /// </summary>
/// <param name="values">Array of string values to wrap.</param> /// <param name="values">Array of string values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<string> values) public static MxValue ToMxValue(this IReadOnlyList<string> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -249,7 +235,6 @@ public static class MxValueExtensions
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time. /// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
/// </summary> /// </summary>
/// <param name="values">Array of DateTimeOffset values to wrap.</param> /// <param name="values">Array of DateTimeOffset values to wrap.</param>
/// <returns>An <see cref="MxValue"/> carrying the wrapped array.</returns>
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values) public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
{ {
ArgumentNullException.ThrowIfNull(values); ArgumentNullException.ThrowIfNull(values);
@@ -268,7 +253,6 @@ public static class MxValueExtensions
/// Gets the projection kind (field name) of the given MxValue's current oneof value. /// Gets the projection kind (field name) of the given MxValue's current oneof value.
/// </summary> /// </summary>
/// <param name="value">The MxValue whose oneof projection kind is returned.</param> /// <param name="value">The MxValue whose oneof projection kind is returned.</param>
/// <returns>The field name of the value's current oneof projection (e.g. "boolValue").</returns>
public static string GetProjectionKind(this MxValue value) public static string GetProjectionKind(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -292,7 +276,6 @@ public static class MxValueExtensions
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues. /// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
/// </summary> /// </summary>
/// <param name="value">The MxValue to convert.</param> /// <param name="value">The MxValue to convert.</param>
/// <returns>The boxed CLR value, or <see langword="null"/> if the MxValue is null.</returns>
public static object? ToClrValue(this MxValue value) public static object? ToClrValue(this MxValue value)
{ {
ArgumentNullException.ThrowIfNull(value); ArgumentNullException.ThrowIfNull(value);
@@ -316,7 +299,6 @@ public static class MxValueExtensions
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type. /// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
/// </summary> /// </summary>
/// <param name="array">The MxArray to convert.</param> /// <param name="array">The MxArray to convert.</param>
/// <returns>The CLR array, or <see langword="null"/> if the element type is not known.</returns>
public static object? ToClrArrayValue(this MxArray array) public static object? ToClrArrayValue(this MxArray array)
{ {
ArgumentNullException.ThrowIfNull(array); ArgumentNullException.ThrowIfNull(array);
@@ -346,7 +328,6 @@ public static class MxValueExtensions
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param> /// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param> /// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
/// <param name="rawDataType">Optional MXAccess data type override.</param> /// <param name="rawDataType">Optional MXAccess data type override.</param>
/// <returns>An <see cref="MxValue"/> with MxDataType.Unknown carrying the raw payload.</returns>
public static MxValue ToRawMxValue( public static MxValue ToRawMxValue(
byte[] value, byte[] value,
string variantType, string variantType,
@@ -19,6 +19,7 @@
<PropertyGroup> <PropertyGroup>
<IsPackable>true</IsPackable> <IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId> <PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
<Version>0.1.2</Version>
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description> <Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
<PackageReadmeFile>README.md</PackageReadmeFile> <PackageReadmeFile>README.md</PackageReadmeFile>
<!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and <!-- Only the shipped library generates XML docs (matching src/Contracts). The Cli and
+61 -16
View File
@@ -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). [Gateway Configuration](../../docs/GatewayConfiguration.md#automatic-self-signed-certificate).
`Client.OpenSession` returns a `Session` with helpers for `Register`, `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 `SubscribeEvents` or `SubscribeEventsAfter` for long-running streams because the
returned subscription owns cancellation and exposes `Close` for deterministic returned subscription owns cancellation and exposes `Close` for deterministic
goroutine cleanup. Raw protobuf messages remain available through the goroutine cleanup. Raw protobuf messages remain available through the
@@ -92,6 +94,44 @@ goroutine cleanup. Raw protobuf messages remain available through the
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command `errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
errors preserve the raw reply. errors preserve the raw reply.
### Reconnect-replay gap
Each `EventResult` carries exactly one of `Event`, `ReplayGap`, or `Err`. When
you resume a stream with `EventsAfter`/`SubscribeEventsAfter` and a non-zero
`afterWorkerSequence`, the gateway replays buffered events from that point. If
the requested sequence predates the oldest event still retained in its replay
ring, it delivers a single **replay-gap sentinel** at the head of the resumed
stream: `res.ReplayGap` is non-nil (`res.Event` is nil, `res.IsReplayGap()` is
true) and normal events follow it.
A gap means events were lost, so any locally cached tag/alarm state is now
stale. On seeing it, discard your cached state and re-snapshot. To resume
without provoking another gap, reconnect from just before the oldest retained
sequence:
```go
for res := range events {
switch {
case res.Err != nil:
// terminal: stream ended (see ErrSlowConsumer for the overflow case)
return res.Err
case res.IsReplayGap():
gap := res.ReplayGap
log.Printf("replay gap: requested after %d, oldest available %d; re-snapshotting",
gap.GetRequestedAfterSequence(), gap.GetOldestAvailableSequence())
resnapshot()
// to resume cleanly, reconnect with:
// session.EventsAfter(ctx, gap.GetOldestAvailableSequence()-1)
default:
handle(res.Event)
}
}
```
The gateway sets `ReplayGap` only on `StreamEvents` results (never on a fresh,
non-resumed stream and never on `DrainEvents`). The client makes the gateway's
sentinel typed and observable; it never synthesizes or swallows it.
For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot For alarms, the package exposes `Client.QueryActiveAlarms` for one-shot
snapshots, `Client.StreamAlarms` for the server-streaming feed, and snapshots, `Client.StreamAlarms` for the server-streaming feed, and
`Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming `Client.AcknowledgeAlarm` to ack an alarm by full reference. The streaming
@@ -113,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 supervisory and then pass that user id on the write. Without the supervisory
advise the `userID` on a plain write is ignored. advise the `userID` on a plain write is ignored.
The session exposes `Advise`/`UnAdvise` but not supervisory advise, so send it The session exposes a typed `AdviseSupervisory` helper alongside `Advise`/`UnAdvise`:
through the generic command channel:
```go ```go
_, err := client.Invoke(ctx, &pb.MxCommandRequest{ err := session.AdviseSupervisory(ctx, serverHandle, itemHandle)
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.Write(ctx, serverHandle, itemHandle, value, userID) err = session.Write(ctx, serverHandle, itemHandle, value, userID)
``` ```
The CLI exposes the same command as `advise-supervisory`, and `write` The CLI exposes the same command as `advise-supervisory`, and `write`
takes `-user-id`. 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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an A write to an array attribute **replaces the entire array**; it is not an
@@ -340,6 +383,8 @@ Every subcommand wired into the CLI. All accept the common flags
| `unsubscribe-bulk` | Unadvise many item handles in one call. | | `unsubscribe-bulk` | Unadvise many item handles in one call. |
| `read-bulk` | Read snapshots for many item handles in one call. | | `read-bulk` | Read snapshots for many item handles in one call. |
| `write` | Write one value (`-type`, `-value`). | | `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). | | `write-bulk` | Write many values (`-item-handles`, `-values`, counts must match). |
| `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). | | `write2-bulk` | `write-bulk` with a shared `-timestamp-value` (RFC 3339). |
| `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). | | `write-secured-bulk` | Secured bulk write (`-current-user-id`, `-verifier-user-id`). |
+86 -14
View File
@@ -21,7 +21,6 @@ import (
"syscall" "syscall"
"time" "time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway" "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
"google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/reflect/protoreflect" "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) return runAdvise(ctx, args[1:], stdout, stderr)
case "advise-supervisory": case "advise-supervisory":
return runAdviseSupervisory(ctx, args[1:], stdout, stderr) 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": case "subscribe-bulk":
return runSubscribeBulk(ctx, args[1:], stdout, stderr) return runSubscribeBulk(ctx, args[1:], stdout, stderr)
case "unsubscribe-bulk": case "unsubscribe-bulk":
@@ -383,21 +386,90 @@ func runAdviseSupervisory(ctx context.Context, args []string, stdout, stderr io.
} }
defer client.Close() defer client.Close()
reply, err := client.Invoke(ctx, &pb.MxCommandRequest{ session := mxgateway.NewSessionForID(client, *sessionID)
SessionId: *sessionID, reply, err := session.AdviseSupervisoryRaw(ctx, int32(*serverHandle), int32(*itemHandle))
Command: &pb.MxCommand{
Kind: pb.MxCommandKind_MX_COMMAND_KIND_ADVISE_SUPERVISORY,
Payload: &pb.MxCommand_AdviseSupervisory{
AdviseSupervisory: &pb.AdviseSupervisoryCommand{
ServerHandle: int32(*serverHandle),
ItemHandle: int32(*itemHandle),
},
},
},
})
return writeCommandOutput(stdout, *jsonOutput, "advise-supervisory", options, reply, err) 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 { func runSubscribeBulk(ctx context.Context, args []string, stdout, stderr io.Writer) error {
flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError) flags := flag.NewFlagSet("subscribe-bulk", flag.ContinueOnError)
flags.SetOutput(stderr) flags.SetOutput(stderr)
@@ -1295,7 +1367,7 @@ type protojsonMessage interface {
} }
func writeUsage(writer io.Writer) { func writeUsage(writer io.Writer) {
fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured-bulk|write-secured2-bulk|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>") fmt.Fprintln(writer, "usage: mxgw-go <version|open-session|close-session|ping|register|add-item|advise|advise-supervisory|subscribe-bulk|unsubscribe-bulk|read-bulk|write-bulk|write2-bulk|write-secured|write-secured-bulk|write-secured2-bulk|authenticate-user|bench-read-bulk|write|stream-events|stream-alarms|acknowledge-alarm|smoke|galaxy-test-connection|galaxy-last-deploy|galaxy-discover|galaxy-watch|galaxy-browse|batch>")
} }
// batchEOR is the end-of-result sentinel emitted to stdout after every command // batchEOR is the end-of-result sentinel emitted to stdout after every command
+30
View File
@@ -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 // TestRunWriteBulkVariantRejectsMismatchedHandlesAndValues pins the len-mismatch
// guard so a write-bulk with unequal item-handles / values counts fails fast // guard so a write-bulk with unequal item-handles / values counts fails fast
// before any dial. // before any dial.
@@ -200,6 +200,63 @@ func TestEventsSlowConsumerYieldsErrSlowConsumerBeforeClose(t *testing.T) {
} }
} }
func TestEventsSurfacesReplayGapSentinelAsTypedSignal(t *testing.T) {
fake := &fakeGatewayServer{
streamStarted: make(chan struct{}),
streamReplayGap: &pb.ReplayGap{
RequestedAfterSequence: 5,
OldestAvailableSequence: 42,
},
}
client, cleanup := newBufconnClient(t, fake)
defer cleanup()
session := NewSessionForID(client, "session-1")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
events, err := session.EventsAfter(ctx, 5)
if err != nil {
t.Fatalf("EventsAfter() error = %v", err)
}
<-fake.streamStarted
// First result must be the typed replay-gap signal, not a normal event.
first := <-events
if first.Err != nil {
t.Fatalf("first result error = %v", first.Err)
}
if !first.IsReplayGap() {
t.Fatalf("first result IsReplayGap() = false, want true")
}
if first.Event != nil {
t.Fatalf("replay-gap result carried a non-nil Event %+v; want the sentinel to clear Event", first.Event)
}
if got := first.ReplayGap.GetRequestedAfterSequence(); got != 5 {
t.Fatalf("ReplayGap.RequestedAfterSequence = %d, want 5", got)
}
if got := first.ReplayGap.GetOldestAvailableSequence(); got != 42 {
t.Fatalf("ReplayGap.OldestAvailableSequence = %d, want 42", got)
}
// Normal events after the sentinel are unaffected: Event set, ReplayGap nil.
second := <-events
if second.Err != nil {
t.Fatalf("second result error = %v", second.Err)
}
if second.IsReplayGap() {
t.Fatalf("second result IsReplayGap() = true, want false for a normal event")
}
if second.Event == nil {
t.Fatal("second result Event = nil, want a normal event")
}
if got := second.Event.GetWorkerSequence(); got != 1 {
t.Fatalf("normal event worker sequence = %d, want 1", got)
}
if second.Event.GetFamily() != pb.MxEventFamily_MX_EVENT_FAMILY_ON_DATA_CHANGE {
t.Fatalf("normal event family = %s, want ON_DATA_CHANGE", second.Event.GetFamily())
}
}
func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) { func TestSessionHelpersBuildCommandsAndExposeRawReply(t *testing.T) {
fake := &fakeGatewayServer{ fake := &fakeGatewayServer{
invokeReply: &pb.MxCommandReply{ invokeReply: &pb.MxCommandReply{
@@ -643,6 +700,7 @@ type fakeGatewayServer struct {
streamStarted chan struct{} streamStarted chan struct{}
streamDone chan struct{} streamDone chan struct{}
streamEventCount int streamEventCount int
streamReplayGap *pb.ReplayGap
invokeReply *pb.MxCommandReply invokeReply *pb.MxCommandReply
invokeRequest *pb.MxCommandRequest invokeRequest *pb.MxCommandRequest
} }
@@ -691,6 +749,16 @@ func (s *fakeGatewayServer) StreamEvents(req *pb.StreamEventsRequest, stream grp
if s.streamStarted != nil { if s.streamStarted != nil {
close(s.streamStarted) close(s.streamStarted)
} }
if s.streamReplayGap != nil {
// Emit the reconnect-replay gap sentinel at the head of the resumed
// stream: family UNSPECIFIED, body unset, only replay_gap populated.
if err := stream.Send(&pb.MxEvent{
SessionId: req.GetSessionId(),
ReplayGap: s.streamReplayGap,
}); err != nil {
return err
}
}
eventCount := s.streamEventCount eventCount := s.streamEventCount
if eventCount == 0 { if eventCount == 0 {
eventCount = 1 eventCount = 1
+58
View File
@@ -3,10 +3,68 @@ package mxgateway
import ( import (
"errors" "errors"
"fmt" "fmt"
"strings"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" 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 // ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
// (cancel-when-full) path when the buffered results channel overflows because // (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 consumer fell behind. It is delivered as the final EventResult.Err before
+307 -3
View File
@@ -27,14 +27,39 @@ const eventBufferSize = 16
// non-blockingly on overflow, even when all data slots are full. // non-blockingly on overflow, even when all data slots are full.
const eventBufferReservedSlots = 1 const eventBufferReservedSlots = 1
// EventResult carries either the next ordered event or a terminal stream error. // EventResult carries the next ordered event, a replay-gap signal, or a
// terminal stream error. Exactly one of Event, ReplayGap, or Err is set on any
// delivered result.
type EventResult struct { type EventResult struct {
// Event is the next event from the stream when Err is nil. // Event is the next MXAccess event from the stream when both ReplayGap and
// Err are nil.
Event *MxEvent Event *MxEvent
// ReplayGap, when non-nil, is the gateway's reconnect-replay gap sentinel: it
// is delivered at the head of a resumed stream (one opened with a non-zero
// after_worker_sequence via EventsAfter/SubscribeEventsAfter) when the
// requested sequence predates the oldest event still retained in the replay
// ring, so the events in between were lost.
//
// It is a non-terminal, observable signal — the stream continues with normal
// events after it, and Event is nil on a gap result so a gap is never
// mistaken for a normal MXAccess event. On seeing a gap the consumer must
// discard any locally cached tag/alarm state and re-snapshot. To resume
// cleanly (without provoking another gap), reconnect with EventsAfter using
// afterWorkerSequence = ReplayGap.GetOldestAvailableSequence() - 1.
//
// The gateway sets ReplayGap only on StreamEvents results, never on a normal
// (non-resumed) stream and never on DrainEvents.
ReplayGap *ReplayGap
// Err is the terminal stream error; when non-nil no further results follow. // Err is the terminal stream error; when non-nil no further results follow.
Err error Err error
} }
// IsReplayGap reports whether this result carries the gateway's reconnect-replay
// gap sentinel rather than a normal event or a terminal error.
func (r EventResult) IsReplayGap() bool {
return r.ReplayGap != nil
}
// EventSubscription owns a running gateway event stream. // EventSubscription owns a running gateway event stream.
type EventSubscription struct { type EventSubscription struct {
results <-chan EventResult results <-chan EventResult
@@ -681,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, // Events streams ordered session events until the server ends the stream,
// context cancellation stops Recv, or a terminal error is sent. // context cancellation stops Recv, or a terminal error is sent.
// //
@@ -736,7 +1032,15 @@ func (s *Session) subscribeEventsAfter(ctx context.Context, afterWorkerSequence
for { for {
event, err := stream.Recv() event, err := stream.Recv()
if err == nil { if err == nil {
if !sendEventResult(streamCtx, results, EventResult{Event: event}, cancelWhenResultBufferFull, cancel) { result := EventResult{Event: event}
// The gateway marks a reconnect-replay gap with a sentinel MxEvent
// carrying replay_gap (family UNSPECIFIED, body unset). Surface it
// as a distinct typed signal rather than a normal event: clear
// Event so consumers never process the sentinel as a data change.
if gap := event.GetReplayGap(); gap != nil {
result = EventResult{ReplayGap: gap}
}
if !sendEventResult(streamCtx, results, result, cancelWhenResultBufferFull, cancel) {
return return
} }
continue continue
@@ -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())
}
}
+6
View File
@@ -30,6 +30,12 @@ type (
MxCommand = pb.MxCommand MxCommand = pb.MxCommand
// MxEvent is one ordered event delivered on a session event stream. // MxEvent is one ordered event delivered on a session event stream.
MxEvent = pb.MxEvent MxEvent = pb.MxEvent
// ReplayGap is the gateway sentinel payload signalling that a resumed event
// stream skipped past the oldest event still retained in the replay ring.
// RequestedAfterSequence is the after_worker_sequence the client resumed
// from; OldestAvailableSequence is the oldest sequence the gateway can still
// replay. See EventResult.ReplayGap for consumption guidance.
ReplayGap = pb.ReplayGap
// MxValue is the protobuf representation of an MXAccess value. // MxValue is the protobuf representation of an MXAccess value.
MxValue = pb.MxValue MxValue = pb.MxValue
// Value is an alias for MxValue retained for symmetry with other clients. // Value is an alias for MxValue retained for symmetry with other clients.
+3 -3
View File
@@ -1,9 +1,9 @@
package mxgateway package mxgateway
const ( const (
// ClientVersion identifies this Go client scaffold before package releases // ClientVersion is the released semantic version of this Go client module.
// assign semantic versions. // Keep it in sync with the module tag applied by scripts/tag-go-module.ps1.
ClientVersion = "0.1.0-dev" ClientVersion = "0.1.2"
// GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion // GatewayProtocolVersion matches GatewayContractInfo.GatewayProtocolVersion
// in the shared .NET contracts. // in the shared .NET contracts.
+2 -2
View File
@@ -31,8 +31,8 @@ Alternative Maven layout is acceptable if the repo standardizes on Maven.
Target Java: Target Java:
- Java 21 recommended. - Java 17 required (retargeted from 21 for Ignition 8.3 compatibility).
- The Gradle scaffold uses the Java 21 toolchain for compilation and tests. - The Gradle scaffold uses the Java 17 toolchain for compilation and tests.
Expected dependencies: Expected dependencies:
+74 -16
View File
@@ -76,7 +76,40 @@ data-bearing MXAccess failure.
`MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it `MxEventStream` implements `Iterator<MxEvent>` and `AutoCloseable`. Closing it
cancels the underlying gRPC stream. Canceling or timing out a Java client call cancels the underlying gRPC stream. Canceling or timing out a Java client call
only stops the client from waiting; it does not abort an in-flight MXAccess COM only stops the client from waiting; it does not abort an in-flight MXAccess COM
call on the worker STA. call on the worker STA. It is a **single-consumer** surface: drive
`hasNext()`/`next()` (or `nextItem()`) from one thread only.
### Reconnect-replay gap signal
When you resume a stream with `streamEventsAfter(afterWorkerSequence)` and the
requested cursor predates the oldest event the gateway still retains, the
gateway emits a single **replay-gap sentinel** at the head of the stream: an
`MxEvent` with its `replay_gap` field set, `family` unspecified, and the body
oneof unset. It means "you missed events — discard cached state and
re-snapshot": the events in the open interval `(requested_after_sequence,
oldest_available_sequence)` were evicted and cannot be replayed. The gateway
never synthesizes this signal from anything else, and the client never swallows
it.
Use `nextItem()` to branch on it as a distinct typed item; `next()` still
returns the sentinel as a plain `MxEvent` (test `event.hasReplayGap()`). After a
gap, re-snapshot, then resume without another gap by requesting
`oldestAvailableSequence - 1` as the next `afterWorkerSequence`:
```java
try (MxEventStream events = session.streamEventsAfter(lastSeenSequence)) {
while (events.hasNext()) {
MxEventStreamItem item = events.nextItem();
if (item.isReplayGap()) {
long resumeFrom = item.replayGap().getOldestAvailableSequence() - 1;
// discard cached per-item state, re-snapshot, then resume from resumeFrom
continue;
}
MxEvent event = item.event();
// normal event handling
}
}
```
For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot), For alarms, `MxGatewayClient` exposes `queryActiveAlarms` (one-shot snapshot),
`streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator `streamAlarms` (returns an `MxGatewayAlarmFeedSubscription` whose iterator
@@ -89,30 +122,52 @@ ack target). Close the subscription to cancel the underlying gRPC stream.
These are MXAccess parity behaviors that surprise new callers. The gateway These are MXAccess parity behaviors that surprise new callers. The gateway
forwards them unchanged — it does not paper over them. forwards them unchanged — it does not paper over them.
### Typed single-item command helpers
`MxGatewaySession` exposes typed helpers for the parity-critical single-item
commands, so you do not need to build raw `MxCommand` messages:
- `adviseSupervisory(serverHandle, itemHandle)` (and `adviseSupervisoryRaw`)
- `writeSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value)`
and `writeSecured2(..., timestampValue)` (plus `*Raw` variants)
- `authenticateUser(serverHandle, verifyUser, verifyUserPassword)` → user id
- `archestrAUserToId(serverHandle, userIdGuid)` → user id
- `addBufferedItem(serverHandle, itemDefinition, itemContext)` → item handle
- `setBufferedUpdateInterval(serverHandle, updateIntervalMs)`
- `suspend(serverHandle, itemHandle)` / `activate(serverHandle, itemHandle)`
the reply's `MxStatusProxy`
All of them run the same MXAccess reply validation as the bulk helpers (protocol
status plus HRESULT/`MxStatusProxy` check) via the shared `invoke` path, so an
MXAccess COM-side failure surfaces as `MxAccessException`.
**Secret redaction.** Credentials passed to `authenticateUser` (and the
credential-sensitive values passed to `writeSecured`/`writeSecured2`) travel
only in the request. They never appear in logs, exception messages, or
`toString()`: gateway status text is scrubbed through `MxGatewaySecrets`, and
MXAccess failures carry only the reply (never the request). Do not log the
credentials yourself.
### Attributing a write to a user without `authenticateUser` ### Attributing a write to a user without `authenticateUser`
MXAccess only stamps a plain `write`/`write2` with a Galaxy user id when the MXAccess only stamps a plain `write`/`write2` with a Galaxy user id when the
item carries an active *supervisory* advise. If you are **not** using the item carries an active *supervisory* advise. If you are **not** using the
verified/secured path (`authenticateUser``writeSecured`/`writeSecured2`) but verified/secured path (`authenticateUser``writeSecured`/`writeSecured2`) but
still need the write attributed to a user id, you must first advise the item still need the write attributed to a user id, first advise the item supervisory
supervisory and then pass that user id on the write. Without the supervisory and then pass that user id on the write. Without the supervisory advise the
advise the `userId` on a plain write is ignored. `userId` on a plain write is ignored.
The session exposes `advise`/`unAdvise` but not supervisory advise, so send it
through the generic command channel:
```java ```java
session.invokeCommand(MxCommand.newBuilder() session.adviseSupervisory(serverHandle, itemHandle);
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
session.write(serverHandle, itemHandle, value, userId); session.write(serverHandle, itemHandle, value, userId);
``` ```
The CLI exposes the same command as `advise-supervisory`, and `write` / **MXAccess parity:** `writeSecured` failing before a prior `authenticateUser` +
`adviseSupervisory`, or before a value-bearing body, is correct behavior — the
native failure is surfaced, not papered over.
The CLI exposes `advise-supervisory`, `write-secured`, and `authenticate-user`
(credential via `--password` or `--password-env`, never echoed), and `write` /
`write2` take `--user-id`. `write2` take `--user-id`.
### Array writes replace the whole array ### Array writes replace the whole array
@@ -324,6 +379,9 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="register --endpoint localhost:5000 -
gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json" gradle :zb-mom-ww-mxgateway-cli:run --args="add-item --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item TestObject.TestInt --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="advise --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="write --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="advise-supervisory --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="authenticate-user --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --verify-user operator --password-env MXGATEWAY_VERIFY_PASSWORD --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="write-secured --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --server-handle 1 --item-handle 1 --type int32 --value 123 --current-user-id 100 --verifier-user-id 100 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-events --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --session-id <id> --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json" gradle :zb-mom-ww-mxgateway-cli:run --args="stream-alarms --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --filter-prefix Galaxy --limit 1 --json"
gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json" gradle :zb-mom-ww-mxgateway-cli:run --args="acknowledge-alarm --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --reference \"\\Galaxy\Area001.Pump001.PumpFault\" --json"
@@ -351,7 +409,7 @@ Run the Java checks from `clients/java`:
gradle test gradle test
``` ```
The build uses the Java 21 Gradle toolchain, compiles generated protobuf/gRPC The build uses the Java 17 Gradle toolchain, compiles generated protobuf/gRPC
code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures, code, and runs JUnit 5 tests for the client wrapper, shared behavior fixtures,
in-process gRPC behavior, stream cancellation, and CLI parser/output behavior. in-process gRPC behavior, stream cancellation, and CLI parser/output behavior.
@@ -3789,20 +3789,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
*/ */
com.google.protobuf.ByteString com.google.protobuf.ByteString
getGatewayVersionBytes(); getGatewayVersionBytes();
/**
* <pre>
* Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
* configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
* "use the worker's built-in default". Sits above the public gRPC cap by an
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
* </pre>
*
* <code>uint32 max_frame_bytes = 4;</code>
* @return The maxFrameBytes.
*/
int getMaxFrameBytes();
} }
/** /**
* Protobuf type {@code mxaccess_worker.v1.GatewayHello} * Protobuf type {@code mxaccess_worker.v1.GatewayHello}
@@ -3932,25 +3918,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
} }
} }
public static final int MAX_FRAME_BYTES_FIELD_NUMBER = 4;
private int maxFrameBytes_ = 0;
/**
* <pre>
* Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
* configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
* "use the worker's built-in default". Sits above the public gRPC cap by an
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
* </pre>
*
* <code>uint32 max_frame_bytes = 4;</code>
* @return The maxFrameBytes.
*/
@java.lang.Override
public int getMaxFrameBytes() {
return maxFrameBytes_;
}
private byte memoizedIsInitialized = -1; private byte memoizedIsInitialized = -1;
@java.lang.Override @java.lang.Override
public final boolean isInitialized() { public final boolean isInitialized() {
@@ -3974,9 +3941,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
if (!com.google.protobuf.GeneratedMessage.isStringEmpty(gatewayVersion_)) { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(gatewayVersion_)) {
com.google.protobuf.GeneratedMessage.writeString(output, 3, gatewayVersion_); com.google.protobuf.GeneratedMessage.writeString(output, 3, gatewayVersion_);
} }
if (maxFrameBytes_ != 0) {
output.writeUInt32(4, maxFrameBytes_);
}
getUnknownFields().writeTo(output); getUnknownFields().writeTo(output);
} }
@@ -3996,10 +3960,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
if (!com.google.protobuf.GeneratedMessage.isStringEmpty(gatewayVersion_)) { if (!com.google.protobuf.GeneratedMessage.isStringEmpty(gatewayVersion_)) {
size += com.google.protobuf.GeneratedMessage.computeStringSize(3, gatewayVersion_); size += com.google.protobuf.GeneratedMessage.computeStringSize(3, gatewayVersion_);
} }
if (maxFrameBytes_ != 0) {
size += com.google.protobuf.CodedOutputStream
.computeUInt32Size(4, maxFrameBytes_);
}
size += getUnknownFields().getSerializedSize(); size += getUnknownFields().getSerializedSize();
memoizedSize = size; memoizedSize = size;
return size; return size;
@@ -4021,8 +3981,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
.equals(other.getNonce())) return false; .equals(other.getNonce())) return false;
if (!getGatewayVersion() if (!getGatewayVersion()
.equals(other.getGatewayVersion())) return false; .equals(other.getGatewayVersion())) return false;
if (getMaxFrameBytes()
!= other.getMaxFrameBytes()) return false;
if (!getUnknownFields().equals(other.getUnknownFields())) return false; if (!getUnknownFields().equals(other.getUnknownFields())) return false;
return true; return true;
} }
@@ -4040,8 +3998,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
hash = (53 * hash) + getNonce().hashCode(); hash = (53 * hash) + getNonce().hashCode();
hash = (37 * hash) + GATEWAY_VERSION_FIELD_NUMBER; hash = (37 * hash) + GATEWAY_VERSION_FIELD_NUMBER;
hash = (53 * hash) + getGatewayVersion().hashCode(); hash = (53 * hash) + getGatewayVersion().hashCode();
hash = (37 * hash) + MAX_FRAME_BYTES_FIELD_NUMBER;
hash = (53 * hash) + getMaxFrameBytes();
hash = (29 * hash) + getUnknownFields().hashCode(); hash = (29 * hash) + getUnknownFields().hashCode();
memoizedHashCode = hash; memoizedHashCode = hash;
return hash; return hash;
@@ -4176,7 +4132,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
supportedProtocolVersion_ = 0; supportedProtocolVersion_ = 0;
nonce_ = ""; nonce_ = "";
gatewayVersion_ = ""; gatewayVersion_ = "";
maxFrameBytes_ = 0;
return this; return this;
} }
@@ -4219,9 +4174,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
if (((from_bitField0_ & 0x00000004) != 0)) { if (((from_bitField0_ & 0x00000004) != 0)) {
result.gatewayVersion_ = gatewayVersion_; result.gatewayVersion_ = gatewayVersion_;
} }
if (((from_bitField0_ & 0x00000008) != 0)) {
result.maxFrameBytes_ = maxFrameBytes_;
}
} }
@java.lang.Override @java.lang.Override
@@ -4249,9 +4201,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
bitField0_ |= 0x00000004; bitField0_ |= 0x00000004;
onChanged(); onChanged();
} }
if (other.getMaxFrameBytes() != 0) {
setMaxFrameBytes(other.getMaxFrameBytes());
}
this.mergeUnknownFields(other.getUnknownFields()); this.mergeUnknownFields(other.getUnknownFields());
onChanged(); onChanged();
return this; return this;
@@ -4293,11 +4242,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
bitField0_ |= 0x00000004; bitField0_ |= 0x00000004;
break; break;
} // case 26 } // case 26
case 32: {
maxFrameBytes_ = input.readUInt32();
bitField0_ |= 0x00000008;
break;
} // case 32
default: { default: {
if (!super.parseUnknownField(input, extensionRegistry, tag)) { if (!super.parseUnknownField(input, extensionRegistry, tag)) {
done = true; // was an endgroup tag done = true; // was an endgroup tag
@@ -4491,62 +4435,6 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
return this; return this;
} }
private int maxFrameBytes_ ;
/**
* <pre>
* Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
* configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
* "use the worker's built-in default". Sits above the public gRPC cap by an
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
* </pre>
*
* <code>uint32 max_frame_bytes = 4;</code>
* @return The maxFrameBytes.
*/
@java.lang.Override
public int getMaxFrameBytes() {
return maxFrameBytes_;
}
/**
* <pre>
* Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
* configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
* "use the worker's built-in default". Sits above the public gRPC cap by an
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
* </pre>
*
* <code>uint32 max_frame_bytes = 4;</code>
* @param value The maxFrameBytes to set.
* @return This builder for chaining.
*/
public Builder setMaxFrameBytes(int value) {
maxFrameBytes_ = value;
bitField0_ |= 0x00000008;
onChanged();
return this;
}
/**
* <pre>
* Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
* configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
* instead of a hard-coded default; 0 (an older gateway that never set the field) means
* "use the worker's built-in default". Sits above the public gRPC cap by an
* envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
* </pre>
*
* <code>uint32 max_frame_bytes = 4;</code>
* @return This builder for chaining.
*/
public Builder clearMaxFrameBytes() {
bitField0_ = (bitField0_ & ~0x00000008);
maxFrameBytes_ = 0;
onChanged();
return this;
}
// @@protoc_insertion_point(builder_scope:mxaccess_worker.v1.GatewayHello) // @@protoc_insertion_point(builder_scope:mxaccess_worker.v1.GatewayHello)
} }
@@ -12664,65 +12552,64 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
"rker_heartbeat\030\023 \001(\0132#.mxaccess_worker.v" + "rker_heartbeat\030\023 \001(\0132#.mxaccess_worker.v" +
"1.WorkerHeartbeatH\000\0227\n\014worker_fault\030\024 \001(" + "1.WorkerHeartbeatH\000\0227\n\014worker_fault\030\024 \001(" +
"\0132\037.mxaccess_worker.v1.WorkerFaultH\000B\006\n\004" + "\0132\037.mxaccess_worker.v1.WorkerFaultH\000B\006\n\004" +
"body\"s\n\014GatewayHello\022\"\n\032supported_protoc" + "body\"Z\n\014GatewayHello\022\"\n\032supported_protoc" +
"ol_version\030\001 \001(\r\022\r\n\005nonce\030\002 \001(\t\022\027\n\017gatew" + "ol_version\030\001 \001(\r\022\r\n\005nonce\030\002 \001(\t\022\027\n\017gatew" +
"ay_version\030\003 \001(\t\022\027\n\017max_frame_bytes\030\004 \001(" + "ay_version\030\003 \001(\t\"i\n\013WorkerHello\022\030\n\020proto" +
"\r\"i\n\013WorkerHello\022\030\n\020protocol_version\030\001 \001" + "col_version\030\001 \001(\r\022\r\n\005nonce\030\002 \001(\t\022\031\n\021work" +
"(\r\022\r\n\005nonce\030\002 \001(\t\022\031\n\021worker_process_id\030\003" + "er_process_id\030\003 \001(\005\022\026\n\016worker_version\030\004 " +
" \001(\005\022\026\n\016worker_version\030\004 \001(\t\"\216\001\n\013WorkerR" + "\001(\t\"\216\001\n\013WorkerReady\022\031\n\021worker_process_id" +
"eady\022\031\n\021worker_process_id\030\001 \001(\005\022\027\n\017mxacc" + "\030\001 \001(\005\022\027\n\017mxaccess_progid\030\002 \001(\t\022\026\n\016mxacc" +
"ess_progid\030\002 \001(\t\022\026\n\016mxaccess_clsid\030\003 \001(\t" + "ess_clsid\030\003 \001(\t\0223\n\017ready_timestamp\030\004 \001(\013" +
"\0223\n\017ready_timestamp\030\004 \001(\0132\032.google.proto" + "2\032.google.protobuf.Timestamp\"w\n\rWorkerCo" +
"buf.Timestamp\"w\n\rWorkerCommand\022/\n\007comman" + "mmand\022/\n\007command\030\001 \001(\0132\036.mxaccess_gatewa" +
"d\030\001 \001(\0132\036.mxaccess_gateway.v1.MxCommand\022" + "y.v1.MxCommand\0225\n\021enqueue_timestamp\030\002 \001(" +
"5\n\021enqueue_timestamp\030\002 \001(\0132\032.google.prot" + "\0132\032.google.protobuf.Timestamp\"\201\001\n\022Worker" +
"obuf.Timestamp\"\201\001\n\022WorkerCommandReply\0222\n" + "CommandReply\0222\n\005reply\030\001 \001(\0132#.mxaccess_g" +
"\005reply\030\001 \001(\0132#.mxaccess_gateway.v1.MxCom" + "ateway.v1.MxCommandReply\0227\n\023completed_ti" +
"mandReply\0227\n\023completed_timestamp\030\002 \001(\0132\032" + "mestamp\030\002 \001(\0132\032.google.protobuf.Timestam" +
".google.protobuf.Timestamp\"\036\n\014WorkerCanc" + "p\"\036\n\014WorkerCancel\022\016\n\006reason\030\001 \001(\t\"Q\n\016Wor" +
"el\022\016\n\006reason\030\001 \001(\t\"Q\n\016WorkerShutdown\022/\n\014" + "kerShutdown\022/\n\014grace_period\030\001 \001(\0132\031.goog" +
"grace_period\030\001 \001(\0132\031.google.protobuf.Dur" + "le.protobuf.Duration\022\016\n\006reason\030\002 \001(\t\"H\n\021" +
"ation\022\016\n\006reason\030\002 \001(\t\"H\n\021WorkerShutdownA" + "WorkerShutdownAck\0223\n\006status\030\001 \001(\0132#.mxac" +
"ck\0223\n\006status\030\001 \001(\0132#.mxaccess_gateway.v1" + "cess_gateway.v1.ProtocolStatus\":\n\013Worker" +
".ProtocolStatus\":\n\013WorkerEvent\022+\n\005event\030" + "Event\022+\n\005event\030\001 \001(\0132\034.mxaccess_gateway." +
"\001 \001(\0132\034.mxaccess_gateway.v1.MxEvent\"\245\002\n\017" + "v1.MxEvent\"\245\002\n\017WorkerHeartbeat\022\031\n\021worker" +
"WorkerHeartbeat\022\031\n\021worker_process_id\030\001 \001" + "_process_id\030\001 \001(\005\022.\n\005state\030\002 \001(\0162\037.mxacc" +
"(\005\022.\n\005state\030\002 \001(\0162\037.mxaccess_worker.v1.W" + "ess_worker.v1.WorkerState\022?\n\033last_sta_ac" +
"orkerState\022?\n\033last_sta_activity_timestam" + "tivity_timestamp\030\003 \001(\0132\032.google.protobuf" +
"p\030\003 \001(\0132\032.google.protobuf.Timestamp\022\035\n\025p" + ".Timestamp\022\035\n\025pending_command_count\030\004 \001(" +
"ending_command_count\030\004 \001(\r\022\"\n\032outbound_e" + "\r\022\"\n\032outbound_event_queue_depth\030\005 \001(\r\022\033\n" +
"vent_queue_depth\030\005 \001(\r\022\033\n\023last_event_seq" + "\023last_event_sequence\030\006 \001(\004\022&\n\036current_co" +
"uence\030\006 \001(\004\022&\n\036current_command_correlati" + "mmand_correlation_id\030\007 \001(\t\"\364\001\n\013WorkerFau" +
"on_id\030\007 \001(\t\"\364\001\n\013WorkerFault\0229\n\010category\030" + "lt\0229\n\010category\030\001 \001(\0162\'.mxaccess_worker.v" +
"\001 \001(\0162\'.mxaccess_worker.v1.WorkerFaultCa" + "1.WorkerFaultCategory\022\026\n\016command_method\030" +
"tegory\022\026\n\016command_method\030\002 \001(\t\022\024\n\007hresul" + "\002 \001(\t\022\024\n\007hresult\030\003 \001(\005H\000\210\001\001\022\026\n\016exception" +
"t\030\003 \001(\005H\000\210\001\001\022\026\n\016exception_type\030\004 \001(\t\022\032\n\022" + "_type\030\004 \001(\t\022\032\n\022diagnostic_message\030\005 \001(\t\022" +
"diagnostic_message\030\005 \001(\t\022<\n\017protocol_sta" + "<\n\017protocol_status\030\006 \001(\0132#.mxaccess_gate" +
"tus\030\006 \001(\0132#.mxaccess_gateway.v1.Protocol" + "way.v1.ProtocolStatusB\n\n\010_hresult*\227\002\n\013Wo" +
"StatusB\n\n\010_hresult*\227\002\n\013WorkerState\022\034\n\030WO" + "rkerState\022\034\n\030WORKER_STATE_UNSPECIFIED\020\000\022" +
"RKER_STATE_UNSPECIFIED\020\000\022\031\n\025WORKER_STATE" + "\031\n\025WORKER_STATE_STARTING\020\001\022\034\n\030WORKER_STA" +
"_STARTING\020\001\022\034\n\030WORKER_STATE_HANDSHAKING\020" + "TE_HANDSHAKING\020\002\022!\n\035WORKER_STATE_INITIAL" +
"\002\022!\n\035WORKER_STATE_INITIALIZING_STA\020\003\022\026\n\022" + "IZING_STA\020\003\022\026\n\022WORKER_STATE_READY\020\004\022\"\n\036W" +
"WORKER_STATE_READY\020\004\022\"\n\036WORKER_STATE_EXE" + "ORKER_STATE_EXECUTING_COMMAND\020\005\022\036\n\032WORKE" +
"CUTING_COMMAND\020\005\022\036\n\032WORKER_STATE_SHUTTIN" + "R_STATE_SHUTTING_DOWN\020\006\022\030\n\024WORKER_STATE_" +
"G_DOWN\020\006\022\030\n\024WORKER_STATE_STOPPED\020\007\022\030\n\024WO" + "STOPPED\020\007\022\030\n\024WORKER_STATE_FAULTED\020\010*\307\004\n\023" +
"RKER_STATE_FAULTED\020\010*\307\004\n\023WorkerFaultCate" + "WorkerFaultCategory\022%\n!WORKER_FAULT_CATE" +
"gory\022%\n!WORKER_FAULT_CATEGORY_UNSPECIFIE" + "GORY_UNSPECIFIED\020\000\022+\n\'WORKER_FAULT_CATEG" +
"D\020\000\022+\n\'WORKER_FAULT_CATEGORY_INVALID_ARG" + "ORY_INVALID_ARGUMENTS\020\001\0227\n3WORKER_FAULT_" +
"UMENTS\020\001\0227\n3WORKER_FAULT_CATEGORY_GATEWA" + "CATEGORY_GATEWAY_AUTHENTICATION_FAILED\020\002" +
"Y_AUTHENTICATION_FAILED\020\002\022+\n\'WORKER_FAUL" + "\022+\n\'WORKER_FAULT_CATEGORY_PROTOCOL_MISMA" +
"T_CATEGORY_PROTOCOL_MISMATCH\020\003\022,\n(WORKER" + "TCH\020\003\022,\n(WORKER_FAULT_CATEGORY_PROTOCOL_" +
"_FAULT_CATEGORY_PROTOCOL_VIOLATION\020\004\022+\n\'" + "VIOLATION\020\004\022+\n\'WORKER_FAULT_CATEGORY_PIP" +
"WORKER_FAULT_CATEGORY_PIPE_DISCONNECTED\020" + "E_DISCONNECTED\020\005\0222\n.WORKER_FAULT_CATEGOR" +
"\005\0222\n.WORKER_FAULT_CATEGORY_MXACCESS_CREA" + "Y_MXACCESS_CREATION_FAILED\020\006\0221\n-WORKER_F" +
"TION_FAILED\020\006\0221\n-WORKER_FAULT_CATEGORY_M" + "AULT_CATEGORY_MXACCESS_COMMAND_FAILED\020\007\022" +
"XACCESS_COMMAND_FAILED\020\007\022:\n6WORKER_FAULT" + ":\n6WORKER_FAULT_CATEGORY_MXACCESS_EVENT_" +
"_CATEGORY_MXACCESS_EVENT_CONVERSION_FAIL" + "CONVERSION_FAILED\020\010\022\"\n\036WORKER_FAULT_CATE" +
"ED\020\010\022\"\n\036WORKER_FAULT_CATEGORY_STA_HUNG\020\t" + "GORY_STA_HUNG\020\t\022(\n$WORKER_FAULT_CATEGORY" +
"\022(\n$WORKER_FAULT_CATEGORY_QUEUE_OVERFLOW" + "_QUEUE_OVERFLOW\020\n\022*\n&WORKER_FAULT_CATEGO" +
"\020\n\022*\n&WORKER_FAULT_CATEGORY_SHUTDOWN_TIM" + "RY_SHUTDOWN_TIMEOUT\020\013B&\252\002#ZB.MOM.WW.MxGa" +
"EOUT\020\013B&\252\002#ZB.MOM.WW.MxGateway.Contracts" + "teway.Contracts.Protob\006proto3"
".Protob\006proto3"
}; };
descriptor = com.google.protobuf.Descriptors.FileDescriptor descriptor = com.google.protobuf.Descriptors.FileDescriptor
.internalBuildGeneratedFileFrom(descriptorData, .internalBuildGeneratedFileFrom(descriptorData,
@@ -12742,7 +12629,7 @@ public final class MxaccessWorker extends com.google.protobuf.GeneratedFile {
internal_static_mxaccess_worker_v1_GatewayHello_fieldAccessorTable = new internal_static_mxaccess_worker_v1_GatewayHello_fieldAccessorTable = new
com.google.protobuf.GeneratedMessage.FieldAccessorTable( com.google.protobuf.GeneratedMessage.FieldAccessorTable(
internal_static_mxaccess_worker_v1_GatewayHello_descriptor, internal_static_mxaccess_worker_v1_GatewayHello_descriptor,
new java.lang.String[] { "SupportedProtocolVersion", "Nonce", "GatewayVersion", "MaxFrameBytes", }); new java.lang.String[] { "SupportedProtocolVersion", "Nonce", "GatewayVersion", });
internal_static_mxaccess_worker_v1_WorkerHello_descriptor = internal_static_mxaccess_worker_v1_WorkerHello_descriptor =
getDescriptor().getMessageType(2); getDescriptor().getMessageType(2);
internal_static_mxaccess_worker_v1_WorkerHello_fieldAccessorTable = new internal_static_mxaccess_worker_v1_WorkerHello_fieldAccessorTable = new
@@ -155,6 +155,8 @@ public final class MxGatewayCli implements Callable<Integer> {
commandLine.addSubcommand("advise", new AdviseCommand(clientFactory)); commandLine.addSubcommand("advise", new AdviseCommand(clientFactory));
commandLine.addSubcommand( commandLine.addSubcommand(
"advise-supervisory", new AdviseSupervisoryCommand(clientFactory)); "advise-supervisory", new AdviseSupervisoryCommand(clientFactory));
commandLine.addSubcommand("write-secured", new WriteSecuredCommand(clientFactory));
commandLine.addSubcommand("authenticate-user", new AuthenticateUserCommand(clientFactory));
commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory)); commandLine.addSubcommand("subscribe-bulk", new SubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory)); commandLine.addSubcommand("unsubscribe-bulk", new UnsubscribeBulkCommand(clientFactory));
commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory)); commandLine.addSubcommand("read-bulk", new ReadBulkCommand(clientFactory));
@@ -1074,6 +1076,106 @@ public final class MxGatewayCli implements Callable<Integer> {
} }
} }
@Command(
name = "write-secured",
description = "Invokes MXAccess WriteSecured (verified single-item write).")
static final class WriteSecuredCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--item-handle", required = true, description = "MXAccess item handle.")
int itemHandle;
@Option(names = "--type", defaultValue = "string", description = "Value type.")
String type;
@Option(names = "--value", required = true, description = "Value text (credential-sensitive; never echoed).")
String value;
@Option(names = "--current-user-id", defaultValue = "0", description = "MXAccess current user id.")
int currentUserId;
@Option(names = "--verifier-user-id", defaultValue = "0", description = "MXAccess verifier user id.")
int verifierUserId;
WriteSecuredCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
// The secured write value is credential-sensitive: it goes only
// into the request. The reply printed below never carries it.
MxCommandReply reply = client.session(sessionId)
.writeSecuredRaw(
serverHandle, itemHandle, currentUserId, verifierUserId, parseValue(type, value));
writeOutput("write-secured", common, json, reply, () -> reply.getKind().name());
}
return 0;
}
}
@Command(
name = "authenticate-user",
description = "Invokes MXAccess AuthenticateUser and prints the resolved user id.")
static final class AuthenticateUserCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.")
String sessionId;
@Option(names = "--server-handle", required = true, description = "MXAccess server handle.")
int serverHandle;
@Option(names = "--verify-user", required = true, description = "Galaxy user name to authenticate.")
String verifyUser;
@Option(
names = "--password",
description = "Galaxy user password (credential; prefer --password-env). Never echoed.")
String password = "";
@Option(
names = "--password-env",
defaultValue = "MXGATEWAY_VERIFY_PASSWORD",
description = "Environment variable holding the password when --password is omitted.")
String passwordEnv;
AuthenticateUserCommand(MxGatewayCliClientFactory clientFactory) {
super(clientFactory);
}
@Override
public Integer call() {
// Resolve the credential from the flag or environment. It flows only
// into the request; it is never written to output, logs, or errors.
String resolvedPassword = password == null || password.isBlank()
? System.getenv(passwordEnv)
: password;
if (resolvedPassword == null) {
resolvedPassword = "";
}
try (MxGatewayCliClient client = clientFactory.connect(common.resolved())) {
int userId = client.session(sessionId)
.authenticateUser(serverHandle, verifyUser, resolvedPassword);
PrintWriter out = common.spec.commandLine().getOut();
if (json) {
Map<String, Object> output = new LinkedHashMap<>();
output.put("command", "authenticate-user");
output.put("options", common.redactedJsonMap());
output.put("verifyUser", verifyUser);
output.put("userId", userId);
out.println(jsonObject(output));
} else {
out.println(userId);
}
}
return 0;
}
}
@Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.") @Command(name = "subscribe-bulk", description = "Invokes MXAccess SubscribeBulk.")
static final class SubscribeBulkCommand extends GatewayCommand { static final class SubscribeBulkCommand extends GatewayCommand {
@Option(names = "--session-id", required = true, description = "Gateway session id.") @Option(names = "--session-id", required = true, description = "Gateway session id.")
@@ -1864,6 +1966,11 @@ public final class MxGatewayCli implements Callable<Integer> {
MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId); MxCommandReply writeRaw(int serverHandle, int itemHandle, MxValue value, int userId);
MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value);
int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword);
List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items); List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items);
List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles); List<SubscribeResult> unsubscribeBulk(int serverHandle, List<Integer> itemHandles);
@@ -1982,13 +2089,7 @@ public final class MxGatewayCli implements Callable<Integer> {
@Override @Override
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) { public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
return session.invokeCommand(MxCommand.newBuilder() return session.adviseSupervisoryRaw(serverHandle, itemHandle);
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(
mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
} }
@Override @Override
@@ -1996,6 +2097,17 @@ public final class MxGatewayCli implements Callable<Integer> {
return session.writeRaw(serverHandle, itemHandle, value, userId); return session.writeRaw(serverHandle, itemHandle, value, userId);
} }
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
return session.writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
return session.authenticateUser(serverHandle, verifyUser, verifyUserPassword);
}
@Override @Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) { public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
return session.subscribeBulk(serverHandle, items); return session.subscribeBulk(serverHandle, items);
@@ -168,6 +168,49 @@ final class MxGatewayCliTests {
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\"")); assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_ADVISE_SUPERVISORY\""));
} }
@Test
void writeSecuredCommandForwardsUserIdsAndValue() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"write-secured",
"--session-id", "session-cli",
"--server-handle", "12",
"--item-handle", "34",
"--type", "int32",
"--value", "77",
"--current-user-id", "100",
"--verifier-user-id", "200",
"--json");
assertEquals(0, run.exitCode());
assertEquals(77, factory.client.session.lastWriteSecuredValue.getInt32Value());
assertEquals(100, factory.client.session.lastWriteSecuredCurrentUserId);
assertEquals(200, factory.client.session.lastWriteSecuredVerifierUserId);
assertTrue(run.output().contains("\"kind\":\"MX_COMMAND_KIND_WRITE_SECURED\""));
}
@Test
void authenticateUserCommandForwardsCredentialAndPrintsUserIdWithoutEchoingPassword() {
FakeClientFactory factory = new FakeClientFactory();
CliRun run = execute(
factory,
"authenticate-user",
"--session-id", "session-cli",
"--server-handle", "3",
"--verify-user", "operator",
"--password", "super-secret-pw",
"--json");
assertEquals(0, run.exitCode());
// The credential reaches the session (request) but never the output.
assertEquals("operator", factory.client.session.lastAuthenticateUser);
assertEquals("super-secret-pw", factory.client.session.lastAuthenticatePassword);
assertTrue(run.output().contains("\"userId\":4242"), run.output());
assertFalse(run.output().contains("super-secret-pw"), "password must never be echoed");
assertFalse(run.errors().contains("super-secret-pw"), "password must never be echoed to stderr");
}
// ---- ping subcommand (D4) ---- // ---- ping subcommand (D4) ----
@Test @Test
@@ -1257,6 +1300,11 @@ final class MxGatewayCliTests {
private boolean adviseCalled; private boolean adviseCalled;
private boolean adviseSupervisoryCalled; private boolean adviseSupervisoryCalled;
private MxValue lastWriteValue; private MxValue lastWriteValue;
private MxValue lastWriteSecuredValue;
private int lastWriteSecuredCurrentUserId;
private int lastWriteSecuredVerifierUserId;
private String lastAuthenticateUser;
private String lastAuthenticatePassword;
private String lastPingMessage; private String lastPingMessage;
private long lastReadBulkTimeoutMs; private long lastReadBulkTimeoutMs;
private List<String> lastReadBulkItems; private List<String> lastReadBulkItems;
@@ -1341,6 +1389,25 @@ final class MxGatewayCliTests {
.build(); .build();
} }
@Override
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
lastWriteSecuredValue = value;
lastWriteSecuredCurrentUserId = currentUserId;
lastWriteSecuredVerifierUserId = verifierUserId;
return MxCommandReply.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
.setProtocolStatus(ok())
.build();
}
@Override
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
lastAuthenticateUser = verifyUser;
lastAuthenticatePassword = verifyUserPassword;
return 4242;
}
@Override @Override
public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) { public List<SubscribeResult> subscribeBulk(int serverHandle, List<String> items) {
List<SubscribeResult> results = new ArrayList<>(); List<SubscribeResult> results = new ArrayList<>();
@@ -21,6 +21,24 @@ import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
* stream cancels the underlying gRPC call. If the queue overflows the call is * stream cancels the underlying gRPC call. If the queue overflows the call is
* cancelled and a follow-up call to {@link #next()} throws * cancelled and a follow-up call to {@link #next()} throws
* {@link MxGatewayException}. * {@link MxGatewayException}.
*
* <p><strong>Single consumer.</strong> This stream is not safe to drain from
* more than one thread. Interleave {@link #hasNext()}/{@link #next()} (or
* {@link #nextItem()}) from a single consumer only; concurrent drains race on
* the internal cursor.
*
* <p><strong>Reconnect-replay gap.</strong> When the stream was resumed via
* {@code StreamEventsRequest.after_worker_sequence} and the requested cursor
* predates the oldest event the gateway still retains, the gateway emits a
* single gap sentinel {@link MxEvent} at the head of the stream with its
* {@code replay_gap} field set (family unspecified, body oneof unset). It is a
* distinct, non-terminal signal, forwarded verbatim never synthesized and
* never swallowed. {@link #next()} returns it as a normal {@link MxEvent}
* (callers can test {@code event.hasReplayGap()}); {@link #nextItem()} wraps it
* in an {@link MxEventStreamItem} whose {@link MxEventStreamItem#isReplayGap()}
* is {@code true}. On a gap the consumer must discard cached per-item state and
* re-snapshot, then resume without a further gap by requesting
* {@code oldest_available_sequence - 1} as the next {@code after_worker_sequence}.
*/ */
public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable { public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
private static final Object END = new Object(); private static final Object END = new Object();
@@ -91,6 +109,24 @@ public final class MxEventStream implements Iterator<MxEvent>, AutoCloseable {
return (MxEvent) value; return (MxEvent) value;
} }
/**
* Drains the next stream element as a typed {@link MxEventStreamItem} so a
* consumer can branch on the reconnect-replay gap sentinel via
* {@link MxEventStreamItem#isReplayGap()} without inspecting the raw event.
*
* <p>Equivalent to wrapping {@link #next()}; the gap sentinel is surfaced as
* a distinct typed item rather than being swallowed, and normal events are
* returned unchanged on {@link MxEventStreamItem#event()}. Do not mix
* {@link #next()} and {@code nextItem()} on the same element each call
* advances the single shared cursor.
*
* @return the next stream item
* @throws NoSuchElementException if the stream is exhausted
*/
public MxEventStreamItem nextItem() {
return new MxEventStreamItem(next());
}
@Override @Override
public void close() { public void close() {
closed = true; closed = true;
@@ -0,0 +1,71 @@
package com.zb.mom.ww.mxgateway.client;
import java.util.Objects;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
/**
* Typed view over a single item drained from an {@link MxEventStream}.
*
* <p>A {@code StreamEvents} stream resumed via {@code after_worker_sequence}
* may begin with a gateway reconnect-replay <em>gap sentinel</em>: a single
* {@link MxEvent} whose {@code replay_gap} field is set, whose
* {@code family} is {@code MX_EVENT_FAMILY_UNSPECIFIED}, and whose {@code body}
* oneof is unset. It means the requested resume cursor predates the oldest
* event the gateway still retains, so the events in the open interval
* {@code (requested_after_sequence, oldest_available_sequence)} were evicted and
* cannot be replayed.
*
* <p>This wrapper lets a consumer branch on that sentinel without inspecting
* the raw event: {@link #isReplayGap()} is {@code true} only for the sentinel,
* and {@link #replayGap()} exposes the typed {@link ReplayGap} cursors. Normal
* MXAccess events return {@code false} from {@link #isReplayGap()} and carry
* their payload on {@link #event()} exactly as before.
*
* <p>The gateway never synthesizes an {@code OperationComplete} or any other
* event from the gap; the sentinel is the gateway's own forwarded signal and is
* surfaced here untouched. On receiving a gap the consumer must discard any
* cached per-item state and re-snapshot, then resume without incurring another
* gap by requesting {@code oldest_available_sequence - 1} as the new
* {@code after_worker_sequence} cursor.
*
* @param event the raw event; for a gap sentinel this is the sentinel event
* itself (family unspecified, body unset, {@code replay_gap} set)
*/
public record MxEventStreamItem(MxEvent event) {
/**
* Creates a stream-item view over the supplied event.
*
* @param event the raw event; must not be {@code null}
*/
public MxEventStreamItem {
Objects.requireNonNull(event, "event");
}
/**
* Returns whether this item is the reconnect-replay gap sentinel rather
* than a normal MXAccess event. Detected via the generated
* {@code MxEvent.hasReplayGap()} presence flag.
*
* @return {@code true} for the gap sentinel, {@code false} for normal events
*/
public boolean isReplayGap() {
return event.hasReplayGap();
}
/**
* Returns the typed reconnect-replay gap descriptor when this item is the
* gap sentinel.
*
* @return the {@link ReplayGap} carrying {@code requested_after_sequence}
* and {@code oldest_available_sequence}
* @throws IllegalStateException if this item is a normal event (check
* {@link #isReplayGap()} first)
*/
public ReplayGap replayGap() {
if (!event.hasReplayGap()) {
throw new IllegalStateException("stream item is not a replay-gap sentinel");
}
return event.getReplayGap();
}
}
@@ -7,11 +7,16 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.TreeMap; import java.util.TreeMap;
import mxaccess_gateway.v1.MxaccessGateway.ActivateCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddBufferedItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command; import mxaccess_gateway.v1.MxaccessGateway.AddItem2Command;
import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.AddItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand; import mxaccess_gateway.v1.MxaccessGateway.AddItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.AdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand; import mxaccess_gateway.v1.MxaccessGateway.AdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.AdviseSupervisoryCommand;
import mxaccess_gateway.v1.MxaccessGateway.ArchestrAUserToIdCommand;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserCommand;
import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult; import mxaccess_gateway.v1.MxaccessGateway.BulkReadResult;
import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult; import mxaccess_gateway.v1.MxaccessGateway.BulkWriteResult;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply; import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
@@ -23,15 +28,18 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType; import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseArray; import mxaccess_gateway.v1.MxaccessGateway.MxSparseArray;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement; import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy;
import mxaccess_gateway.v1.MxaccessGateway.MxValue; import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.ReadBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand; import mxaccess_gateway.v1.MxaccessGateway.RegisterCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.RemoveItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand; import mxaccess_gateway.v1.MxaccessGateway.RemoveItemCommand;
import mxaccess_gateway.v1.MxaccessGateway.SetBufferedUpdateIntervalCommand;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest; import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.SubscribeBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult; import mxaccess_gateway.v1.MxaccessGateway.SubscribeResult;
import mxaccess_gateway.v1.MxaccessGateway.SuspendCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand; import mxaccess_gateway.v1.MxaccessGateway.UnAdviseCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.UnAdviseItemBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.UnsubscribeBulkCommand;
@@ -42,6 +50,8 @@ import mxaccess_gateway.v1.MxaccessGateway.Write2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.WriteBulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry; import mxaccess_gateway.v1.MxaccessGateway.WriteBulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteCommand; import mxaccess_gateway.v1.MxaccessGateway.WriteCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2Command;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand; import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkCommand;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry; import mxaccess_gateway.v1.MxaccessGateway.WriteSecured2BulkEntry;
import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand; import mxaccess_gateway.v1.MxaccessGateway.WriteSecuredBulkCommand;
@@ -697,6 +707,285 @@ public final class MxGatewaySession implements AutoCloseable {
.build()); .build());
} }
/**
* Invokes MXAccess {@code AdviseSupervisory} so the item accepts
* supervisory-attributed writes. Required before a plain {@link #write}
* can stamp a Galaxy user id without the verified/secured path.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to advise supervisory
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void adviseSupervisory(int serverHandle, int itemHandle) {
adviseSupervisoryRaw(serverHandle, itemHandle);
}
/**
* Invokes MXAccess {@code AdviseSupervisory} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to advise supervisory
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply adviseSupervisoryRaw(int serverHandle, int itemHandle) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY)
.setAdviseSupervisory(AdviseSupervisoryCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
}
/**
* Invokes MXAccess {@code WriteSecured} a verified write gated by a
* previously authenticated Galaxy user.
*
* <p><strong>MXAccess parity:</strong> the native call fails if the caller
* has not first {@link #authenticateUser authenticated} and
* {@link #adviseSupervisory advised supervisory}, or if the value body is
* absent; that native failure is surfaced (as {@link MxAccessException}),
* not papered over.
*
* <p><strong>Secret handling:</strong> {@code value} may carry a
* credential-sensitive payload. It is placed only in the request and never
* appears in any surfaced error (exceptions carry the reply, not the
* request), so callers must likewise avoid logging it.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id (second-signature); use
* the same value as {@code currentUserId} when no separate verifier applies
* @param value the credential-sensitive value to write
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void writeSecured(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
writeSecuredRaw(serverHandle, itemHandle, currentUserId, verifierUserId, value);
}
/**
* Invokes MXAccess {@code WriteSecured} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply writeSecuredRaw(
int serverHandle, int itemHandle, int currentUserId, int verifierUserId, MxValue value) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED)
.setWriteSecured(WriteSecuredCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(value))
.build());
}
/**
* Invokes MXAccess {@code WriteSecured2} a verified, explicitly
* timestamped write. Parity and secret-handling notes mirror
* {@link #writeSecured}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @param timestampValue the timestamp value to associate with the write
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void writeSecured2(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
MxValue value,
MxValue timestampValue) {
writeSecured2Raw(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestampValue);
}
/**
* Invokes MXAccess {@code WriteSecured2} and returns the raw reply.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to write
* @param currentUserId the authenticated (current) Galaxy user id
* @param verifierUserId the verifier Galaxy user id
* @param value the credential-sensitive value to write
* @param timestampValue the timestamp value to associate with the write
* @return the raw command reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxCommandReply writeSecured2Raw(
int serverHandle,
int itemHandle,
int currentUserId,
int verifierUserId,
MxValue value,
MxValue timestampValue) {
return invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED2)
.setWriteSecured2(WriteSecured2Command.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle)
.setCurrentUserId(currentUserId)
.setVerifierUserId(verifierUserId)
.setValue(value)
.setTimestampValue(timestampValue))
.build());
}
/**
* Invokes MXAccess {@code AuthenticateUser} and returns the resolved
* Galaxy user id used to attribute subsequent secured writes.
*
* <p><strong>Secret handling:</strong> {@code verifyUserPassword} is a raw
* MXAccess credential. It is placed only in the request and never appears in
* any surfaced error, log, or {@code toString()} (exceptions carry the
* reply, not the request). Callers must not log it either.
*
* @param serverHandle the {@code ServerHandle} for the session
* @param verifyUser the Galaxy user name to authenticate
* @param verifyUserPassword the user's credential; never logged or surfaced
* @return the authenticated Galaxy user id
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess rejects the credential
*/
public int authenticateUser(int serverHandle, String verifyUser, String verifyUserPassword) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_AUTHENTICATE_USER)
.setAuthenticateUser(AuthenticateUserCommand.newBuilder()
.setServerHandle(serverHandle)
.setVerifyUser(verifyUser)
.setVerifyUserPassword(verifyUserPassword))
.build());
if (reply.hasAuthenticateUser()) {
return reply.getAuthenticateUser().getUserId();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code ArchestrAUserToId}, resolving a Galaxy user GUID
* to its integer user id.
*
* @param serverHandle the {@code ServerHandle} for the session
* @param userIdGuid the Galaxy user GUID string
* @return the resolved Galaxy user id
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public int archestrAUserToId(int serverHandle, String userIdGuid) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ARCHESTRA_USER_TO_ID)
.setArchestraUserToId(ArchestrAUserToIdCommand.newBuilder()
.setServerHandle(serverHandle)
.setUserIdGuid(userIdGuid))
.build());
if (reply.hasArchestraUserToId()) {
return reply.getArchestraUserToId().getUserId();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code AddBufferedItem} and returns the new item handle.
* The buffered add family delivers coalesced {@code OnBufferedDataChange}
* updates on the interval set by {@link #setBufferedUpdateInterval}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemDefinition the MXAccess item definition (tag reference)
* @param itemContext the MXAccess item context (e.g. galaxy/object scope)
* @return the {@code ItemHandle} assigned by MXAccess
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public int addBufferedItem(int serverHandle, String itemDefinition, String itemContext) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ADD_BUFFERED_ITEM)
.setAddBufferedItem(AddBufferedItemCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemDefinition(itemDefinition)
.setItemContext(itemContext))
.build());
if (reply.hasAddBufferedItem()) {
return reply.getAddBufferedItem().getItemHandle();
}
return reply.getReturnValue().getInt32Value();
}
/**
* Invokes MXAccess {@code SetBufferedUpdateInterval}, controlling how often
* the worker coalesces buffered updates for the given server handle.
*
* @param serverHandle the {@code ServerHandle} to configure
* @param updateIntervalMilliseconds the buffered update interval in milliseconds
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public void setBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) {
invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_SET_BUFFERED_UPDATE_INTERVAL)
.setSetBufferedUpdateInterval(SetBufferedUpdateIntervalCommand.newBuilder()
.setServerHandle(serverHandle)
.setUpdateIntervalMilliseconds(updateIntervalMilliseconds))
.build());
}
/**
* Invokes MXAccess {@code Suspend} on an item and returns the reply's
* {@link MxStatusProxy}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to suspend
* @return the {@code MxStatusProxy} carried by the suspend reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxStatusProxy suspend(int serverHandle, int itemHandle) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_SUSPEND)
.setSuspend(SuspendCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return reply.getSuspend().getStatus();
}
/**
* Invokes MXAccess {@code Activate} on a previously suspended item and
* returns the reply's {@link MxStatusProxy}.
*
* @param serverHandle the {@code ServerHandle} owning the item
* @param itemHandle the {@code ItemHandle} to activate
* @return the {@code MxStatusProxy} carried by the activate reply
* @throws MxGatewayException on transport or protocol failure
* @throws MxAccessException when MXAccess reports a COM-side failure
*/
public MxStatusProxy activate(int serverHandle, int itemHandle) {
MxCommandReply reply = invokeCommand(MxCommand.newBuilder()
.setKind(MxCommandKind.MX_COMMAND_KIND_ACTIVATE)
.setActivate(ActivateCommand.newBuilder()
.setServerHandle(serverHandle)
.setItemHandle(itemHandle))
.build());
return reply.getActivate().getStatus();
}
/** /**
* Subscribes to gateway events for this session starting from the * Subscribes to gateway events for this session starting from the
* beginning of the worker event log. * beginning of the worker event log.
@@ -1,6 +1,7 @@
package com.zb.mom.ww.mxgateway.client; package com.zb.mom.ww.mxgateway.client;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -30,6 +31,7 @@ import mxaccess_gateway.v1.MxaccessGateway.AddItemReply;
import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState; import mxaccess_gateway.v1.MxaccessGateway.AlarmConditionState;
import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage; import mxaccess_gateway.v1.MxaccessGateway.AlarmFeedMessage;
import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind; import mxaccess_gateway.v1.MxaccessGateway.AlarmTransitionKind;
import mxaccess_gateway.v1.MxaccessGateway.AuthenticateUserReply;
import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply; import mxaccess_gateway.v1.MxaccessGateway.BulkSubscribeReply;
import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent; import mxaccess_gateway.v1.MxaccessGateway.OnAlarmTransitionEvent;
import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply; import mxaccess_gateway.v1.MxaccessGateway.CloseSessionReply;
@@ -39,6 +41,7 @@ import mxaccess_gateway.v1.MxaccessGateway.MxCommandReply;
import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest; import mxaccess_gateway.v1.MxaccessGateway.MxCommandRequest;
import mxaccess_gateway.v1.MxaccessGateway.MxDataType; import mxaccess_gateway.v1.MxaccessGateway.MxDataType;
import mxaccess_gateway.v1.MxaccessGateway.MxEvent; import mxaccess_gateway.v1.MxaccessGateway.MxEvent;
import mxaccess_gateway.v1.MxaccessGateway.MxEventFamily;
import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement; import mxaccess_gateway.v1.MxaccessGateway.MxSparseElement;
import mxaccess_gateway.v1.MxaccessGateway.MxValue; import mxaccess_gateway.v1.MxaccessGateway.MxValue;
import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply; import mxaccess_gateway.v1.MxaccessGateway.OpenSessionReply;
@@ -47,6 +50,7 @@ import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatus;
import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode; import mxaccess_gateway.v1.MxaccessGateway.ProtocolStatusCode;
import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest; import mxaccess_gateway.v1.MxaccessGateway.QueryActiveAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.RegisterReply; import mxaccess_gateway.v1.MxaccessGateway.RegisterReply;
import mxaccess_gateway.v1.MxaccessGateway.ReplayGap;
import mxaccess_gateway.v1.MxaccessGateway.SessionState; import mxaccess_gateway.v1.MxaccessGateway.SessionState;
import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest; import mxaccess_gateway.v1.MxaccessGateway.StreamAlarmsRequest;
import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest; import mxaccess_gateway.v1.MxaccessGateway.StreamEventsRequest;
@@ -509,6 +513,232 @@ final class MxGatewayClientSessionTests {
} }
} }
@Test
void streamEventsSurfacesReplayGapSentinelAsTypedItem() throws Exception {
// CLI-15: a resumed stream whose cursor predates the retained window
// begins with the gateway's replay-gap sentinel. It must surface as a
// distinct typed item, and normal events must be unaffected.
TestGatewayService service = new TestGatewayService() {
@Override
public void streamEvents(StreamEventsRequest request, StreamObserver<MxEvent> responseObserver) {
responseObserver.onNext(MxEvent.newBuilder()
.setSessionId(request.getSessionId())
.setReplayGap(ReplayGap.newBuilder()
.setRequestedAfterSequence(5)
.setOldestAvailableSequence(9))
.build());
responseObserver.onNext(MxEvent.newBuilder()
.setSessionId(request.getSessionId())
.setFamily(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE)
.setWorkerSequence(9)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5));
MxEventStream events =
MxGatewaySession.forSessionId(client, "resume-session").streamEventsAfter(5)) {
assertTrue(events.hasNext());
MxEventStreamItem gap = events.nextItem();
assertTrue(gap.isReplayGap(), "head sentinel must classify as a replay gap");
assertEquals(5, gap.replayGap().getRequestedAfterSequence());
assertEquals(9, gap.replayGap().getOldestAvailableSequence());
// Sentinel carries no MXAccess payload.
assertEquals(MxEventFamily.MX_EVENT_FAMILY_UNSPECIFIED, gap.event().getFamily());
assertTrue(events.hasNext());
MxEventStreamItem normal = events.nextItem();
assertFalse(normal.isReplayGap(), "normal event must not classify as a replay gap");
assertEquals(9, normal.event().getWorkerSequence());
assertEquals(MxEventFamily.MX_EVENT_FAMILY_ON_DATA_CHANGE, normal.event().getFamily());
}
}
@Test
void adviseSupervisoryBuildsSupervisoryCommand() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = okInvokeService(commandRequest);
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "advise-super-session");
session.adviseSupervisory(12, 34);
assertEquals(
MxCommandKind.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
commandRequest.get().getCommand().getKind());
assertEquals(12, commandRequest.get().getCommand().getAdviseSupervisory().getServerHandle());
assertEquals(34, commandRequest.get().getCommand().getAdviseSupervisory().getItemHandle());
}
}
@Test
void writeSecuredSurfacesNativeMxAccessFailure() throws Exception {
// CLI-04 parity: WriteSecured before authenticate/advise fails natively;
// the client surfaces the failure rather than papering over it.
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("WriteSecured rejected: user not authenticated."))
.setHresult(-2147220992)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-session");
MxAccessException error = assertThrows(
MxAccessException.class,
() -> session.writeSecured(1, 2, 100, 100, MxValues.stringValue("secret-payload")));
assertEquals(-2147220992, error.reply().getHresult());
// The credential-bearing value lives only in the request, so it must
// not appear in any surfaced error text.
assertFalse(String.valueOf(error.getMessage()).contains("secret-payload"));
}
}
@Test
void writeSecuredScriptedSuccessSendsSecuredCommand() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = okInvokeService(commandRequest);
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "secured-ok-session");
session.writeSecured(7, 8, 100, 200, MxValues.int32Value(42));
var command = commandRequest.get().getCommand();
assertEquals(MxCommandKind.MX_COMMAND_KIND_WRITE_SECURED, command.getKind());
assertEquals(7, command.getWriteSecured().getServerHandle());
assertEquals(8, command.getWriteSecured().getItemHandle());
assertEquals(100, command.getWriteSecured().getCurrentUserId());
assertEquals(200, command.getWriteSecured().getVerifierUserId());
assertEquals(42, command.getWriteSecured().getValue().getInt32Value());
}
}
@Test
void authenticateUserReturnsUserIdAndForwardsCredential() throws Exception {
AtomicReference<MxCommandRequest> commandRequest = new AtomicReference<>();
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.setAuthenticateUser(AuthenticateUserReply.newBuilder().setUserId(4242))
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-session");
int userId = session.authenticateUser(3, "operator", "super-secret-pw");
assertEquals(4242, userId);
// The credential is forwarded in the request only.
assertEquals(
"super-secret-pw",
commandRequest.get().getCommand().getAuthenticateUser().getVerifyUserPassword());
}
}
@Test
void authenticateUserFailureKeepsCredentialOutOfSurfacedError() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_MXACCESS_FAILURE)
.setMessage("AuthenticateUser rejected the credential."))
.setHresult(-2147220992)
.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "auth-fail-session");
MxAccessException error = assertThrows(
MxAccessException.class,
() -> session.authenticateUser(3, "operator", "super-secret-pw"));
// The password must never reach the exception message or toString().
assertFalse(String.valueOf(error.getMessage()).contains("super-secret-pw"));
assertFalse(error.toString().contains("super-secret-pw"));
}
}
@Test
void suspendAndActivateReturnStatusProxy() throws Exception {
TestGatewayService service = new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
MxCommandReply.Builder reply = MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok());
if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_SUSPEND) {
reply.setSuspend(mxaccess_gateway.v1.MxaccessGateway.SuspendReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
} else if (request.getCommand().getKind() == MxCommandKind.MX_COMMAND_KIND_ACTIVATE) {
reply.setActivate(mxaccess_gateway.v1.MxaccessGateway.ActivateReply.newBuilder()
.setStatus(mxaccess_gateway.v1.MxaccessGateway.MxStatusProxy.newBuilder()
.setSuccess(1)));
}
responseObserver.onNext(reply.build());
responseObserver.onCompleted();
}
};
try (InProcessGateway gateway = InProcessGateway.start(service, new AtomicReference<>());
MxGatewayClient client = gateway.client("", Duration.ofSeconds(5))) {
MxGatewaySession session = MxGatewaySession.forSessionId(client, "suspend-session");
assertTrue(MxStatuses.succeeded(session.suspend(1, 2)));
assertTrue(MxStatuses.succeeded(session.activate(1, 2)));
}
}
private static TestGatewayService okInvokeService(AtomicReference<MxCommandRequest> commandRequest) {
return new TestGatewayService() {
@Override
public void invoke(MxCommandRequest request, StreamObserver<MxCommandReply> responseObserver) {
commandRequest.set(request);
responseObserver.onNext(MxCommandReply.newBuilder()
.setSessionId(request.getSessionId())
.setKind(request.getCommand().getKind())
.setProtocolStatus(ok())
.build());
responseObserver.onCompleted();
}
};
}
private static ProtocolStatus ok() { private static ProtocolStatus ok() {
return ProtocolStatus.newBuilder() return ProtocolStatus.newBuilder()
.setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK) .setCode(ProtocolStatusCode.PROTOCOL_STATUS_CODE_OK)
+61 -11
View File
@@ -105,6 +105,40 @@ terminate the stream.
Canceling a Python task cancels the client-side gRPC call or stream wait. It Canceling a Python task cancels the client-side gRPC call or stream wait. It
does not abort an in-flight MXAccess COM call inside the worker process. does not abort an in-flight MXAccess COM call inside the worker process.
### Event streaming and reconnect gaps
`Session.stream_events()` yields an async iterator whose items are either a
normal `MxEvent` or a `ReplayGap`. Track the `worker_sequence` of the last event
you processed and pass it back as `after_worker_sequence` to resume after a
disconnect:
```python
from zb_mom_ww_mxgateway import ReplayGap
cursor = 0
async for item in session.stream_events(after_worker_sequence=cursor):
if isinstance(item, ReplayGap):
# The gateway dropped events between item.requested_after_sequence and
# item.oldest_available_sequence — they are gone from the replay ring.
# Discard local tag/alarm state and re-snapshot (e.g. read_bulk /
# query_active_alarms), then resume without another gap:
cursor = item.resume_after_worker_sequence # oldest_available_sequence - 1
continue
# Normal MXAccess event.
cursor = item.worker_sequence
handle(item)
```
`ReplayGap` is the gateway's reconnect-replay gap sentinel made typed and
observable. It is delivered only at the head of a stream resumed with a non-zero
`after_worker_sequence` when the requested cursor predates the oldest retained
event. It is a **non-terminal** signal — the stream continues with normal events
after it — and it is never yielded as an `MxEvent`, so it can never be mistaken
for a real MXAccess event. The client does not synthesize or swallow it; the
gateway only sets it on `StreamEvents` results (never on a fresh stream or a
`DrainEvents` reply). `GatewayClient.stream_events_raw` remains the raw protobuf
stream (the sentinel arrives there as an `MxEvent` with `replay_gap` set).
## Write Semantics And Common Pitfalls ## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway These are MXAccess parity behaviors that surprise new callers. The gateway
@@ -119,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 item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored. supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`unadvise` but not supervisory advise, so send it The session exposes a typed `advise_supervisory` helper alongside
through the generic command channel: `advise`/`unadvise`:
```python ```python
await session.invoke( await session.advise_supervisory(server_handle, item_handle)
pb.MxCommand(
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY,
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=server_handle,
item_handle=item_handle,
),
)
)
await session.write(server_handle, item_handle, value, user_id=user_id) await session.write(server_handle, item_handle, value, user_id=user_id)
``` ```
@@ -139,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` / The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`. `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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an A write to an array attribute **replaces the entire array**; it is not an
@@ -9,6 +9,7 @@ from .generated.galaxy_repository_pb2 import (
GalaxyObject, GalaxyObject,
WatchDeployEventsRequest, WatchDeployEventsRequest,
) )
from .events import ReplayGap
from .errors import ( from .errors import (
MxAccessError, MxAccessError,
MxGatewayAuthenticationError, MxGatewayAuthenticationError,
@@ -43,6 +44,7 @@ __all__ = [
"MxGatewayTransportError", "MxGatewayTransportError",
"MxGatewayWorkerError", "MxGatewayWorkerError",
"MxValueView", "MxValueView",
"ReplayGap",
"Session", "Session",
"WatchDeployEventsRequest", "WatchDeployEventsRequest",
"__version__", "__version__",
@@ -0,0 +1,63 @@
"""Typed event-stream signals for the MXAccess Gateway Python client."""
from __future__ import annotations
from dataclasses import dataclass
from .generated import mxaccess_gateway_pb2 as pb
@dataclass(frozen=True, slots=True)
class ReplayGap:
"""Reconnect-replay gap signal surfaced on a resumed event stream.
The gateway emits this at the head of a stream resumed with
:meth:`Session.stream_events`'s ``after_worker_sequence`` cursor when the
requested sequence predates the oldest event still retained in the gateway's
replay ring. That means the events between ``requested_after_sequence`` and
``oldest_available_sequence`` were dropped from the ring and can no longer be
replayed the client has an unrecoverable hole in its event history.
``ReplayGap`` is a *non-terminal, observable* signal: the stream keeps
delivering normal :class:`~zb_mom_ww_mxgateway.generated.mxaccess_gateway_pb2.MxEvent`
values after it. :meth:`Session.stream_events` yields it as a distinct type
(never as an ``MxEvent``) so a consumer can branch on
``isinstance(item, ReplayGap)`` and never mistake a gap for a real MXAccess
event. The client neither synthesizes nor swallows the gateway's sentinel —
it only makes that sentinel typed and observable.
On seeing a gap the consumer must discard any locally cached tag/alarm state
and re-snapshot (for example via :meth:`Session.read_bulk` or
:meth:`~zb_mom_ww_mxgateway.GatewayClient.query_active_alarms`). To resume
the stream without provoking another gap, reconnect with
``after_worker_sequence = gap.resume_after_worker_sequence`` (that is,
``oldest_available_sequence - 1``) so the next replayed event is the oldest
the gateway still retains.
The gateway sets this only on ``StreamEvents`` results never on a normal
(non-resumed) stream and never on a ``DrainEvents`` reply.
"""
requested_after_sequence: int
"""The ``after_worker_sequence`` cursor the resumed stream was opened with."""
oldest_available_sequence: int
"""Oldest worker sequence the gateway can still replay."""
@classmethod
def from_proto(cls, gap: pb.ReplayGap) -> "ReplayGap":
"""Build a :class:`ReplayGap` from the generated ``ReplayGap`` message."""
return cls(
requested_after_sequence=gap.requested_after_sequence,
oldest_available_sequence=gap.oldest_available_sequence,
)
@property
def resume_after_worker_sequence(self) -> int:
"""``after_worker_sequence`` to resume the stream without another gap.
Equal to ``oldest_available_sequence - 1`` so the next event the gateway
replays is ``oldest_available_sequence`` the oldest it still retains.
Clamped at ``0`` so it is never negative.
"""
return max(self.oldest_available_sequence - 1, 0)
@@ -4,7 +4,9 @@ from __future__ import annotations
from collections.abc import AsyncIterator, Sequence 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 .generated import mxaccess_gateway_pb2 as pb
from .values import MxValueInput, to_mx_value from .values import MxValueInput, to_mx_value
@@ -568,18 +570,304 @@ class Session:
correlation_id=correlation_id, 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( def stream_events(
self, self,
*, *,
after_worker_sequence: int = 0, after_worker_sequence: int = 0,
) -> AsyncIterator[pb.MxEvent]: ) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Return an async iterator of `MxEvent` messages for this session.""" """Return an async iterator over this session's `MxEvent` stream.
return self.client.stream_events_raw(
Each yielded item is either a normal :class:`~...MxEvent` or a
:class:`ReplayGap`. Branch on ``isinstance(item, ReplayGap)`` a gap is
never delivered as an ``MxEvent`` so it cannot be mistaken for a real
MXAccess event.
Pass a non-zero *after_worker_sequence* to resume a previously observed
stream. If that cursor predates the oldest event the gateway still
retains in its replay ring, the stream opens with a single
:class:`ReplayGap` sentinel (events in the gap were dropped and cannot be
replayed), then continues with normal events. On a gap, discard locally
cached state, re-snapshot, and to resume without another gap
reconnect with ``after_worker_sequence = gap.resume_after_worker_sequence``.
See :class:`ReplayGap` for the full semantics. The underlying protobuf
stream is available raw via ``GatewayClient.stream_events_raw``.
"""
raw = self.client.stream_events_raw(
pb.StreamEventsRequest( pb.StreamEventsRequest(
session_id=self.session_id, session_id=self.session_id,
after_worker_sequence=after_worker_sequence, after_worker_sequence=after_worker_sequence,
), ),
) )
return _surface_replay_gaps(raw)
async def _surface_replay_gaps(
raw: AsyncIterator[pb.MxEvent],
) -> AsyncIterator[pb.MxEvent | ReplayGap]:
"""Map the gateway's ``replay_gap`` sentinel event to a typed :class:`ReplayGap`.
Normal events pass through unchanged. The sentinel (``replay_gap`` set,
``family`` unspecified, body unset) is converted to a distinct
:class:`ReplayGap` so a consumer can branch on it without inspecting proto
presence, and is never yielded as an ``MxEvent``. The sentinel is forwarded
faithfully it is neither dropped nor turned into a normal event.
Closing this generator (``aclose``) propagates to *raw* so the underlying
gRPC call is cancelled, preserving the raw stream's cancel-on-stop contract.
"""
try:
async for event in raw:
if event.HasField("replay_gap"):
yield ReplayGap.from_proto(event.replay_gap)
else:
yield event
finally:
aclose = getattr(raw, "aclose", None)
if aclose is not None:
await aclose()
def _ensure_bulk_size(name: str, count: int) -> None: def _ensure_bulk_size(name: str, count: int) -> None:
@@ -587,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)") 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 from .client import GatewayClient # noqa: E402
@@ -1,3 +1,3 @@
"""Package version information.""" """Package version information."""
__version__ = "0.1.0" __version__ = "0.1.2"
@@ -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") @main.command("subscribe-bulk")
@gateway_options @gateway_options
@click.option("--session-id", required=True, help="Gateway session id.") @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 def _advise_supervisory(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client: async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"]) session = _session(client, kwargs["session_id"])
await session.invoke( await session.advise_supervisory(
pb.MxCommand( kwargs["server_handle"],
kind=pb.MX_COMMAND_KIND_ADVISE_SUPERVISORY, kwargs["item_handle"],
advise_supervisory=pb.AdviseSupervisoryCommand(
server_handle=kwargs["server_handle"],
item_handle=kwargs["item_handle"],
),
),
correlation_id=kwargs["correlation_id"], correlation_id=kwargs["correlation_id"],
) )
return {"ok": True} 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 def _subscribe_bulk(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client: async with await _connect(kwargs) as client:
session = _session(client, kwargs["session_id"]) session = _session(client, kwargs["session_id"])
-27
View File
@@ -1,27 +0,0 @@
"""Shared pytest fixtures for the client test suite.
pytest-asyncio 1.x no longer installs a current event loop for *synchronous*
tests, and it clears the loop after each ``async`` test. Some synchronous tests
build a ``grpc.aio.Channel`` (via ``create_channel``), which calls
``asyncio.get_event_loop()`` and therefore raises
``RuntimeError: There is no current event loop`` on Python 3.12 when a prior
async test has left the policy with no current loop. Ensure every test starts
with a usable current loop so channel construction in a sync test is
order-independent.
"""
from __future__ import annotations
import asyncio
from collections.abc import Iterator
import pytest
@pytest.fixture(autouse=True)
def _ensure_current_event_loop() -> Iterator[None]:
try:
asyncio.get_event_loop()
except RuntimeError:
asyncio.set_event_loop(asyncio.new_event_loop())
yield
+172
View File
@@ -645,3 +645,175 @@ def test_galaxy_browse_help_shows_parent_gobject_id() -> None:
assert result.exit_code == 0 assert result.exit_code == 0
assert "--parent-gobject-id" in result.output 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
+106
View File
@@ -0,0 +1,106 @@
"""Tests for the typed ReplayGap signal on Session.stream_events (CLI-15)."""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest
from zb_mom_ww_mxgateway import ReplayGap, Session
from zb_mom_ww_mxgateway.generated import mxaccess_gateway_pb2 as pb
class _FakeClient:
"""Minimal client stub exposing only what Session.stream_events needs."""
def __init__(self, events: list[pb.MxEvent]) -> None:
self._events = events
self.last_request: pb.StreamEventsRequest | None = None
def stream_events_raw(
self,
request: pb.StreamEventsRequest,
) -> AsyncIterator[pb.MxEvent]:
self.last_request = request
async def _gen() -> AsyncIterator[pb.MxEvent]:
for event in self._events:
yield event
return _gen()
def _gap_sentinel(*, requested: int, oldest: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
family=pb.MX_EVENT_FAMILY_UNSPECIFIED,
replay_gap=pb.ReplayGap(
requested_after_sequence=requested,
oldest_available_sequence=oldest,
),
)
def _normal_event(worker_sequence: int) -> pb.MxEvent:
return pb.MxEvent(
session_id="session-1",
worker_sequence=worker_sequence,
family=pb.MX_EVENT_FAMILY_ON_DATA_CHANGE,
)
def _session(events: list[pb.MxEvent]) -> tuple[Session, _FakeClient]:
client = _FakeClient(events)
session = Session(client=client, session_id="session-1") # type: ignore[arg-type]
return session, client
@pytest.mark.asyncio
async def test_replay_gap_sentinel_surfaces_as_typed_signal() -> None:
session, _client = _session(
[_gap_sentinel(requested=5, oldest=10), _normal_event(10)],
)
items = [item async for item in session.stream_events(after_worker_sequence=5)]
assert isinstance(items[0], ReplayGap)
assert items[0].requested_after_sequence == 5
assert items[0].oldest_available_sequence == 10
# Resume cursor is oldest_available_sequence - 1 so the next replayed event
# is the oldest the gateway still retains.
assert items[0].resume_after_worker_sequence == 9
# Normal events after the sentinel pass through unchanged as MxEvent.
assert isinstance(items[1], pb.MxEvent)
assert not items[1].HasField("replay_gap")
assert items[1].worker_sequence == 10
@pytest.mark.asyncio
async def test_normal_events_are_unaffected() -> None:
session, _client = _session([_normal_event(1), _normal_event(2)])
items = [item async for item in session.stream_events()]
assert all(isinstance(item, pb.MxEvent) for item in items)
assert [item.worker_sequence for item in items] == [1, 2]
assert not any(isinstance(item, ReplayGap) for item in items)
@pytest.mark.asyncio
async def test_stream_events_forwards_resume_cursor() -> None:
session, client = _session([])
async for _ in session.stream_events(after_worker_sequence=42):
pass
assert client.last_request is not None
assert client.last_request.after_worker_sequence == 42
assert client.last_request.session_id == "session-1"
def test_replay_gap_resume_cursor_never_negative() -> None:
gap = ReplayGap.from_proto(
pb.ReplayGap(requested_after_sequence=0, oldest_available_sequence=0),
)
assert gap.resume_after_worker_sequence == 0
@@ -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
+63 -12
View File
@@ -137,6 +137,47 @@ redaction. Per-item bulk failures are reported inside each result entry
`invoke_raw` / `client.invoke_raw` escape hatch performs neither check and `invoke_raw` / `client.invoke_raw` escape hatch performs neither check and
returns the unvalidated reply. returns the unvalidated reply.
## Event Streaming And Reconnect-Replay Gaps
`session.events()` / `session.events_after(after_worker_sequence)` (and the
lower-level `client.stream_events`) return an `EventStream` that yields
`EventItem` values, not bare `MxEvent`s:
```rust
use zb_mom_ww_mxgateway_client::EventItem;
let mut stream = session.events_after(cursor).await?;
while let Some(item) = stream.next().await {
match item? {
EventItem::Event(event) => { /* apply the MXAccess change */ }
EventItem::ReplayGap(gap) => {
// Recent history was evicted — discard local state and re-snapshot,
// then resume without provoking another gap:
let resume = gap.oldest_available_sequence.saturating_sub(1);
stream = session.events_after(resume).await?;
}
}
}
```
Almost every item is a normal `EventItem::Event`. `EventItem::ReplayGap` is a
faithful, typed surfacing of the gateway's reconnect-replay gap sentinel — the
client does not synthesize it. The gateway emits the sentinel at most once, at
the head of a stream **resumed** via `events_after` (`after_worker_sequence`)
when the requested sequence is older than the oldest event still retained in the
session's replay ring: events in the open interval
`(requested_after_sequence, oldest_available_sequence)` were evicted and cannot
be replayed. A `ReplayGap` therefore means "you missed events — discard any
local state and re-snapshot." To resume without a second gap, reconnect with
`events_after(gap.oldest_available_sequence - 1)`, which replays starting at the
first still-retained event. A stream opened from the beginning
(`session.events()` / `events_after(0)`) never produces a `ReplayGap`.
`EventItem` provides `as_event()`, `into_event()`, and `replay_gap()` accessors
for callers that prefer not to `match`. The `mxgw-cli stream-events` subcommand
renders the sentinel as a distinct `REPLAY_GAP …` line (or a `replayGap` JSON
object under `--json` / `--jsonl`).
## Write Semantics And Common Pitfalls ## Write Semantics And Common Pitfalls
These are MXAccess parity behaviors that surprise new callers. The gateway These are MXAccess parity behaviors that surprise new callers. The gateway
@@ -151,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 item supervisory and then pass that user id on the write. Without the
supervisory advise the `user_id` on a plain write is ignored. supervisory advise the `user_id` on a plain write is ignored.
The session exposes `advise`/`un_advise` but not supervisory advise, so send it The session exposes a typed `advise_supervisory` helper alongside
through the generic command channel: `advise`/`un_advise`:
```rust ```rust
session session.advise_supervisory(server_handle, item_handle).await?;
.invoke(
MxCommandKind::AdviseSupervisory,
Payload::AdviseSupervisory(AdviseSupervisoryCommand {
server_handle,
item_handle,
}),
)
.await?;
session.write(server_handle, item_handle, value, user_id).await?; session.write(server_handle, item_handle, value, user_id).await?;
``` ```
The CLI exposes the same command as `advise-supervisory`, and `write` / The CLI exposes the same command as `advise-supervisory`, and `write` /
`write2` take `--user-id`. `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 ### Array writes replace the whole array
A write to an array attribute **replaces the entire array**; it is not an A write to an array attribute **replaces the entire array**; it is not an
+208 -18
View File
@@ -21,15 +21,14 @@ use serde_json::Value;
use zb_mom_ww_mxgateway_client::galaxy::{BrowseChildrenOptions, LazyBrowseNode}; 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::galaxy_repository::v1::DeployEvent;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmRequest, AdviseSupervisoryCommand, AlarmFeedMessage, alarm_feed_message, AcknowledgeAlarmRequest, AlarmFeedMessage, CloseSessionRequest, MxCommand,
CloseSessionRequest, MxCommand, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxCommandKind, MxCommandRequest, MxEvent, MxEventFamily, MxValue as ProtoMxValue,
MxValue as ProtoMxValue, OpenSessionRequest, PingCommand, StreamAlarmsRequest, OpenSessionRequest, PingCommand, StreamAlarmsRequest, StreamEventsRequest, Write2BulkEntry,
StreamEventsRequest, Write2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
WriteSecuredBulkEntry,
}; };
use zb_mom_ww_mxgateway_client::{ use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, next_correlation_id, ApiKey, ClientOptions, Error, EventItem, GalaxyClient, GatewayClient,
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION, MxValue, MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
}; };
const MAX_AGGREGATE_EVENTS: usize = 10_000; const MAX_AGGREGATE_EVENTS: usize = 10_000;
@@ -118,6 +117,67 @@ enum Command {
#[arg(long)] #[arg(long)]
json: bool, 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<String>,
/// 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 { SubscribeBulk {
#[command(flatten)] #[command(flatten)]
connection: ConnectionArgs, connection: ConnectionArgs,
@@ -669,18 +729,69 @@ async fn dispatch(command: Command) -> Result<(), Error> {
} => { } => {
let session = session_for(connection, session_id).await?; let session = session_for(connection, session_id).await?;
session session
.invoke( .advise_supervisory(server_handle, item_handle)
MxCommandKind::AdviseSupervisory,
zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::AdviseSupervisory(
AdviseSupervisoryCommand {
server_handle,
item_handle,
},
),
)
.await?; .await?;
print_ok("advise-supervisory", json); 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 { Command::SubscribeBulk {
connection, connection,
session_id, session_id,
@@ -886,11 +997,13 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let mut events: Vec<Value> = Vec::new(); let mut events: Vec<Value> = Vec::new();
let mut event_count = 0usize; let mut event_count = 0usize;
while event_count < max_events { while event_count < max_events {
let Some(event) = stream.next().await else { let Some(item) = stream.next().await else {
break; break;
}; };
let event = event?; let item = item?;
event_count += 1; event_count += 1;
match item {
EventItem::Event(event) => {
if jsonl { if jsonl {
println!("{}", event_to_json(&event)); println!("{}", event_to_json(&event));
} else if json { } else if json {
@@ -899,6 +1012,30 @@ async fn dispatch(command: Command) -> Result<(), Error> {
println!("{} {}", event.worker_sequence, event.family); println!("{} {}", event.worker_sequence, event.family);
} }
} }
// Reconnect-replay gap sentinel: recent history was evicted
// before this resumed stream could replay it. Render it as a
// distinct row so the caller can re-snapshot and resume with
// `oldest_available_sequence - 1`.
EventItem::ReplayGap(gap) => {
let value = json!({
"replayGap": {
"requestedAfterSequence": gap.requested_after_sequence,
"oldestAvailableSequence": gap.oldest_available_sequence,
}
});
if jsonl {
println!("{value}");
} else if json {
events.push(value);
} else {
println!(
"REPLAY_GAP requested_after={} oldest_available={}",
gap.requested_after_sequence, gap.oldest_available_sequence
);
}
}
}
}
if json { if json {
// `eventCount` is preserved for back-compat; `events` carries // `eventCount` is preserved for back-compat; `events` carries
// the per-event detail the cross-language e2e matrix compares. // the per-event detail the cross-language e2e matrix compares.
@@ -2463,6 +2600,59 @@ mod tests {
assert_eq!(value["workerProtocolVersion"], 1); 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] #[test]
fn parses_stream_alarms_command() { fn parses_stream_alarms_command() {
let parsed = Cli::try_parse_from([ let parsed = Cli::try_parse_from([
@@ -42,12 +42,6 @@ message GatewayHello {
uint32 supported_protocol_version = 1; uint32 supported_protocol_version = 1;
string nonce = 2; string nonce = 2;
string gateway_version = 3; string gateway_version = 3;
// Maximum worker-frame payload size, in bytes, negotiated by the gateway from its
// configured pipe limit. The worker adopts this as its frame-protocol MaxMessageBytes
// instead of a hard-coded default; 0 (an older gateway that never set the field) means
// "use the worker's built-in default". Sits above the public gRPC cap by an
// envelope-overhead margin so an accepted gRPC payload always fits one worker frame.
uint32 max_frame_bytes = 4;
} }
message WorkerHello { message WorkerHello {
+121 -8
View File
@@ -18,7 +18,7 @@ use crate::generated::mxaccess_gateway::v1::mx_access_gateway_client::MxAccessGa
use crate::generated::mxaccess_gateway::v1::{ use crate::generated::mxaccess_gateway::v1::{
AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, AlarmFeedMessage,
CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent, CloseSessionReply, CloseSessionRequest, MxCommandReply, MxCommandRequest, MxEvent,
OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, StreamAlarmsRequest, OpenSessionReply, OpenSessionRequest, QueryActiveAlarmsRequest, ReplayGap, StreamAlarmsRequest,
StreamEventsRequest, StreamEventsRequest,
}; };
use crate::options::{build_tls_config, ClientOptions}; use crate::options::{build_tls_config, ClientOptions};
@@ -28,11 +28,120 @@ use crate::session::Session;
/// [`GatewayClient`] uses internally. /// [`GatewayClient`] uses internally.
pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>; pub type RawGatewayClient = MxAccessGatewayClient<InterceptedService<Channel, AuthInterceptor>>;
/// Pinned, boxed [`MxEvent`] stream returned by /// One item yielded by the per-session event stream returned by
/// [`GatewayClient::stream_events`]. Errors are pre-mapped from /// [`GatewayClient::stream_events`].
/// `tonic::Status` to [`Error`]; dropping the stream cancels the call. ///
/// Almost every item is an ordinary MXAccess event ([`EventItem::Event`]).
/// The one exception is the reconnect-replay gap sentinel
/// ([`EventItem::ReplayGap`]): the gateway emits it at most once, at the head
/// of a stream that was *resumed* via
/// [`Session::events_after`](crate::session::Session::events_after)
/// (`StreamEventsRequest.after_worker_sequence`) when the requested sequence is
/// older than the oldest event still retained in the session replay ring — i.e.
/// events were evicted and cannot be replayed.
///
/// The client does **not** synthesize this signal: it faithfully forwards the
/// gateway's sentinel `MxEvent` (whose `replay_gap` field is set), and only
/// makes it a distinct, typed variant so consumers can `match` on it instead of
/// inspecting a field on a value that otherwise looks like a normal event.
///
/// # Reacting to a gap
///
/// A [`EventItem::ReplayGap`] means "you missed events — discard any local
/// state and re-snapshot." The events in the open interval
/// `(requested_after_sequence, oldest_available_sequence)` are gone. To resume
/// the stream without provoking another gap, reconnect with
/// [`Session::events_after`](crate::session::Session::events_after) passing
/// `oldest_available_sequence - 1`, which replays starting at the first still
/// retained event (`oldest_available_sequence`):
///
/// ```no_run
/// # use zb_mom_ww_mxgateway_client::{EventItem, Session};
/// # use futures_util::StreamExt;
/// # async fn run(session: Session, cursor: u64) -> Result<(), zb_mom_ww_mxgateway_client::Error> {
/// let mut stream = session.events_after(cursor).await?;
/// while let Some(item) = stream.next().await {
/// match item? {
/// EventItem::Event(event) => {
/// let _ = event; // apply the change
/// }
/// EventItem::ReplayGap(gap) => {
/// // Local state is stale — re-snapshot, then resume without a gap.
/// let resume_cursor = gap.oldest_available_sequence.saturating_sub(1);
/// stream = session.events_after(resume_cursor).await?;
/// }
/// }
/// }
/// # Ok(())
/// # }
/// ```
// The `Event` variant is the hot path (nearly every stream item) and is the
// large one; the rare `ReplayGap` sentinel is small. Boxing `Event` to equalize
// the variants would add a heap allocation to every streamed event — a
// regression versus the prior `Result<MxEvent, Error>` surface, which already
// moved `MxEvent` by value. Keep the common path allocation-free.
#[allow(clippy::large_enum_variant)]
#[derive(Clone, Debug, PartialEq)]
pub enum EventItem {
/// A normal MXAccess event forwarded from the worker.
Event(MxEvent),
/// The reconnect-replay gap sentinel — recent event history was evicted
/// before this resumed stream could replay it. See [`EventItem`] for how
/// to react.
ReplayGap(ReplayGap),
}
impl EventItem {
/// Classify an incoming `MxEvent` into the typed stream item.
///
/// A present `replay_gap` promotes the event to [`EventItem::ReplayGap`];
/// otherwise it is an ordinary [`EventItem::Event`]. The sentinel is never
/// dropped and never surfaced as a normal event.
fn from_event(mut event: MxEvent) -> Self {
match event.replay_gap.take() {
Some(gap) => EventItem::ReplayGap(gap),
None => EventItem::Event(event),
}
}
/// Borrow the inner [`MxEvent`] when this item is a normal event, or
/// `None` when it is the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn as_event(&self) -> Option<&MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
/// Borrow the [`ReplayGap`] when this item is the reconnect-replay gap
/// sentinel, or `None` for a normal event.
#[must_use]
pub fn replay_gap(&self) -> Option<&ReplayGap> {
match self {
EventItem::ReplayGap(gap) => Some(gap),
EventItem::Event(_) => None,
}
}
/// Consume the item and return the inner [`MxEvent`] when it is a normal
/// event, or `None` for the [`EventItem::ReplayGap`] sentinel.
#[must_use]
pub fn into_event(self) -> Option<MxEvent> {
match self {
EventItem::Event(event) => Some(event),
EventItem::ReplayGap(_) => None,
}
}
}
/// Pinned, boxed [`EventItem`] stream returned by
/// [`GatewayClient::stream_events`]. Each item is either a normal
/// [`EventItem::Event`] or the [`EventItem::ReplayGap`] reconnect-replay
/// sentinel. Errors are pre-mapped from `tonic::Status` to [`Error`]; dropping
/// the stream cancels the call.
pub type EventStream = pub type EventStream =
std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<MxEvent, Error>> + Send + 'static>>; std::pin::Pin<Box<dyn futures_core::Stream<Item = Result<EventItem, Error>> + Send + 'static>>;
/// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by /// Pinned, boxed [`ActiveAlarmSnapshot`] stream returned by
/// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from /// [`GatewayClient::query_active_alarms`]. Errors are pre-mapped from
@@ -190,8 +299,12 @@ impl GatewayClient {
/// Open the server-streaming `StreamEvents` RPC. /// Open the server-streaming `StreamEvents` RPC.
/// ///
/// The returned [`EventStream`] yields `MxEvent` messages as the worker /// The returned [`EventStream`] yields [`EventItem`] values as the worker
/// produces them. Dropping the stream cancels the gRPC call cooperatively. /// produces them: ordinary MXAccess events as [`EventItem::Event`], and the
/// gateway's reconnect-replay gap sentinel — set only on resumed streams
/// whose requested sequence predates the retained replay history — as
/// [`EventItem::ReplayGap`]. Dropping the stream cancels the gRPC call
/// cooperatively.
/// ///
/// # Errors /// # Errors
/// ///
@@ -201,7 +314,7 @@ impl GatewayClient {
let mut client = self.inner.clone(); let mut client = self.inner.clone();
let response = client.stream_events(self.stream_request(request)).await?; let response = client.stream_events(self.stream_request(request)).await?;
let stream = futures_util::StreamExt::map(response.into_inner(), |result| { let stream = futures_util::StreamExt::map(response.into_inner(), |result| {
result.map_err(Error::from) result.map(EventItem::from_event).map_err(Error::from)
}); });
Ok(Box::pin(stream)) Ok(Box::pin(stream))
+3 -1
View File
@@ -24,12 +24,14 @@ pub mod version;
#[doc(inline)] #[doc(inline)]
pub use auth::{ApiKey, AuthInterceptor}; pub use auth::{ApiKey, AuthInterceptor};
#[doc(inline)] #[doc(inline)]
pub use client::{AlarmFeedStream, EventStream, GatewayClient}; pub use client::{AlarmFeedStream, EventItem, EventStream, GatewayClient};
#[doc(inline)] #[doc(inline)]
pub use error::{CommandError, Error, MxAccessError}; pub use error::{CommandError, Error, MxAccessError};
#[doc(inline)] #[doc(inline)]
pub use galaxy::{DeployEventStream, GalaxyClient}; pub use galaxy::{DeployEventStream, GalaxyClient};
#[doc(inline)] #[doc(inline)]
pub use generated::mxaccess_gateway::v1::ReplayGap;
#[doc(inline)]
pub use options::ClientOptions; pub use options::ClientOptions;
#[doc(inline)] #[doc(inline)]
pub use session::{next_correlation_id, Session}; pub use session::{next_correlation_id, Session};
+373 -9
View File
@@ -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::Payload;
use crate::generated::mxaccess_gateway::v1::mx_command_reply; use crate::generated::mxaccess_gateway::v1::mx_command_reply;
use crate::generated::mxaccess_gateway::v1::{ use crate::generated::mxaccess_gateway::v1::{
AddItem2Command, AddItemBulkCommand, AddItemCommand, AdviseCommand, AdviseItemBulkCommand, ActivateCommand, AddBufferedItemCommand, AddItem2Command, AddItemBulkCommand, AddItemCommand,
BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand, MxCommandKind, MxCommandReply, AdviseCommand, AdviseItemBulkCommand, AdviseSupervisoryCommand, ArchestrAUserToIdCommand,
MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement, MxValue as ProtoMxValue, AuthenticateUserCommand, BulkReadResult, BulkWriteResult, CloseSessionRequest, MxCommand,
OpenSessionRequest, ReadBulkCommand, RegisterCommand, RemoveItemBulkCommand, RemoveItemCommand, MxCommandKind, MxCommandReply, MxCommandRequest, MxDataType, MxSparseArray, MxSparseElement,
StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, UnAdviseCommand, MxValue as ProtoMxValue, OpenSessionRequest, ReadBulkCommand, RegisterCommand,
UnAdviseItemBulkCommand, UnsubscribeBulkCommand, Write2BulkCommand, Write2BulkEntry, RemoveItemBulkCommand, RemoveItemCommand, SetBufferedUpdateIntervalCommand,
Write2Command, WriteBulkCommand, WriteBulkEntry, WriteCommand, WriteSecured2BulkCommand, StreamEventsRequest, SubscribeBulkCommand, SubscribeResult, SuspendCommand, UnAdviseCommand,
WriteSecured2BulkEntry, WriteSecuredBulkCommand, WriteSecuredBulkEntry, 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; const MAX_BULK_ITEMS: usize = 1_000;
@@ -129,6 +132,25 @@ impl Session {
register_server_handle(&reply) 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 /// Run MXAccess `AddItem` against `server_handle` and return the
/// assigned `ItemHandle`. /// assigned `ItemHandle`.
/// ///
@@ -230,6 +252,133 @@ impl Session {
Ok(()) 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<MxStatus, Error> {
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<MxStatus, Error> {
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<i32, Error> {
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 /// Bulk variant of [`Session::add_item`]. Each tag address yields one
/// `SubscribeResult` in the returned vector. /// `SubscribeResult` in the returned vector.
/// ///
@@ -628,8 +777,151 @@ impl Session {
Ok(()) 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<i32, Error> {
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<i32, Error> {
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. /// Open the per-session event stream from the beginning.
/// ///
/// The returned [`EventStream`] yields [`EventItem`](crate::EventItem)
/// values — normal MXAccess events as
/// [`EventItem::Event`](crate::EventItem::Event). A stream opened from the
/// beginning never produces a
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap); that sentinel
/// only appears on a resumed stream (see [`Session::events_after`]).
///
/// # Errors /// # Errors
/// ///
/// Returns the `tonic::Status` mapped through [`Error::from`] when the /// Returns the `tonic::Status` mapped through [`Error::from`] when the
@@ -642,6 +934,15 @@ impl Session {
/// `worker_sequence` is greater than `after_worker_sequence`. Pass `0` /// `worker_sequence` is greater than `after_worker_sequence`. Pass `0`
/// to receive every buffered event. /// to receive every buffered event.
/// ///
/// If `after_worker_sequence` predates the oldest event still retained in
/// the gateway's replay ring, the stream opens with a single
/// [`EventItem::ReplayGap`](crate::EventItem::ReplayGap) sentinel: recent
/// history was evicted and cannot be replayed, so the caller must discard
/// any local state and re-snapshot. To resume without provoking another
/// gap, call this method again with
/// `gap.oldest_available_sequence - 1`. See
/// [`EventItem`](crate::EventItem) for the full contract.
///
/// # Errors /// # Errors
/// ///
/// Same conditions as [`Session::events`]. /// Same conditions as [`Session::events`].
@@ -753,6 +1054,69 @@ fn add_item2_handle(reply: &MxCommandReply) -> Result<i32, Error> {
} }
} }
fn add_buffered_item_handle(reply: &MxCommandReply) -> Result<i32, Error> {
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<i32, Error> {
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<i32, Error> {
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<MxStatus, Error> {
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<MxStatus, Error> {
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 { enum BulkReplyKind {
AddItem, AddItem,
AdviseItem, AdviseItem,
+3 -2
View File
@@ -3,8 +3,9 @@
//! The protocol versions track the values the gateway and worker negotiate on //! The protocol versions track the values the gateway and worker negotiate on
//! `OpenSession` and let test harnesses cross-check the wire contract. //! `OpenSession` and let test harnesses cross-check the wire contract.
/// Semantic version of this Rust client crate. Mirrors `Cargo.toml`. /// Semantic version of this Rust client crate. Sourced from `Cargo.toml` at
pub const CLIENT_VERSION: &str = "0.1.0-dev"; /// compile time so the two cannot drift.
pub const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Public gateway gRPC protocol version this client targets. /// Public gateway gRPC protocol version this client targets.
pub const GATEWAY_PROTOCOL_VERSION: u32 = 3; pub const GATEWAY_PROTOCOL_VERSION: u32 = 3;
+270 -14
View File
@@ -23,17 +23,18 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_value::Kind;
use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot, alarm_feed_message, AcknowledgeAlarmReply, AcknowledgeAlarmRequest, ActiveAlarmSnapshot,
AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind, AddItem2Reply, AddItemReply, AlarmConditionState, AlarmFeedMessage, AlarmTransitionKind,
BulkReadReply, BulkReadResult, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, AuthenticateUserCommand, AuthenticateUserReply, BulkReadReply, BulkReadResult,
CloseSessionReply, CloseSessionRequest, MxCommandKind, MxCommandReply, MxDataType, MxEvent, BulkSubscribeReply, BulkWriteReply, BulkWriteResult, CloseSessionReply, CloseSessionRequest,
MxEventFamily, MxSparseArray, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxCommandKind, MxCommandReply, MxDataType, MxEvent, MxEventFamily, MxSparseArray,
MxValue, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus, MxSparseElement, MxStatusCategory, MxStatusProxy, MxStatusSource, MxValue,
ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, SessionState, StreamAlarmsRequest, OnAlarmTransitionEvent, OpenSessionReply, OpenSessionRequest, ProtocolStatus,
StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry, WriteCommand, ProtocolStatusCode, QueryActiveAlarmsRequest, RegisterReply, ReplayGap, SessionState,
WriteSecured2BulkEntry, WriteSecuredBulkEntry, StreamAlarmsRequest, StreamEventsRequest, SubscribeResult, Write2BulkEntry, WriteBulkEntry,
WriteCommand, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
}; };
use zb_mom_ww_mxgateway_client::{ use zb_mom_ww_mxgateway_client::{
next_correlation_id, ApiKey, ClientOptions, CommandError, Error, GatewayClient, MxStatus, next_correlation_id, ApiKey, ClientOptions, CommandError, Error, EventItem, GatewayClient,
MxValue as ClientMxValue, MxValueProjection, MxStatus, MxValue as ClientMxValue, MxValueProjection,
}; };
#[tokio::test] #[tokio::test]
@@ -128,8 +129,28 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
.await .await
.unwrap(); .unwrap();
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 1); assert_eq!(
assert_eq!(stream.next().await.unwrap().unwrap().worker_sequence, 2); stream
.next()
.await
.unwrap()
.unwrap()
.as_event()
.unwrap()
.worker_sequence,
1
);
assert_eq!(
stream
.next()
.await
.unwrap()
.unwrap()
.as_event()
.unwrap()
.worker_sequence,
2
);
drop(stream); drop(stream);
for _ in 0..20 { for _ in 0..20 {
@@ -142,6 +163,55 @@ async fn event_stream_preserves_order_and_drop_cancels_server_stream() {
assert!(state.stream_dropped.load(Ordering::SeqCst)); assert!(state.stream_dropped.load(Ordering::SeqCst));
} }
#[tokio::test]
async fn replay_gap_sentinel_surfaces_as_typed_event_item() {
let state = Arc::new(FakeState::default());
// Script a resumed stream: the reconnect-replay gap sentinel at the head
// (family UNSPECIFIED, no body, `replay_gap` set) followed by a normal
// event. The client must promote the sentinel to `EventItem::ReplayGap`
// and leave the following event as a normal `EventItem::Event`.
*state.stream_events_script.lock().await = Some(vec![
MxEvent {
replay_gap: Some(ReplayGap {
requested_after_sequence: 5,
oldest_available_sequence: 42,
}),
..MxEvent::default()
},
event(42),
]);
let endpoint = spawn_fake_gateway(state.clone()).await;
let client = GatewayClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let mut stream = client
.stream_events(StreamEventsRequest {
session_id: "session-fixture".to_owned(),
after_worker_sequence: 5,
})
.await
.unwrap();
// First item is the typed gap sentinel, not a normal event.
let first = stream.next().await.unwrap().unwrap();
match &first {
EventItem::ReplayGap(gap) => {
assert_eq!(gap.requested_after_sequence, 5);
assert_eq!(gap.oldest_available_sequence, 42);
}
EventItem::Event(_) => panic!("expected a ReplayGap sentinel, got a normal event"),
}
// Accessor helpers reflect the variant.
assert!(first.as_event().is_none());
assert_eq!(first.replay_gap().unwrap().oldest_available_sequence, 42);
// The normal event that follows is unaffected.
let second = stream.next().await.unwrap().unwrap();
assert_eq!(second.as_event().unwrap().worker_sequence, 42);
assert!(second.replay_gap().is_none());
}
#[tokio::test] #[tokio::test]
async fn acknowledge_alarm_returns_reply_with_native_status() { async fn acknowledge_alarm_returns_reply_with_native_status() {
let state = Arc::new(FakeState::default()); let state = Arc::new(FakeState::default());
@@ -555,6 +625,133 @@ async fn write_secured2_bulk_round_trips_through_the_fake_gateway() {
assert_eq!(*last_command, Some(MxCommandKind::WriteSecured2Bulk as i32)); 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("<redacted>"), "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] #[tokio::test]
async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() { async fn stream_alarms_emits_snapshot_then_complete_then_transition_in_order() {
let state = Arc::new(FakeState::default()); let state = Arc::new(FakeState::default());
@@ -663,6 +860,10 @@ struct FakeState {
/// Captures the last `WriteCommand` payload received, populated when the /// Captures the last `WriteCommand` payload received, populated when the
/// `WriteOk` override is active. Used by `write_array_elements` e2e test. /// `WriteOk` override is active. Used by `write_array_elements` e2e test.
last_write_command: Mutex<Option<WriteCommand>>, last_write_command: Mutex<Option<WriteCommand>>,
/// Captures the last `AuthenticateUserCommand` payload received, populated
/// by the `AuthenticateUser` happy-path handler so a test can confirm the
/// credential reaches the wire (but never a surfaced error).
last_authenticate_user: Mutex<Option<AuthenticateUserCommand>>,
stream_dropped: Arc<AtomicBool>, stream_dropped: Arc<AtomicBool>,
/// Optional per-test override that pins the fake's `Invoke` handler to /// Optional per-test override that pins the fake's `Invoke` handler to
/// a specific reply shape (or `Err(Status)`). The default of `None` /// a specific reply shape (or `Err(Status)`). The default of `None`
@@ -672,6 +873,11 @@ struct FakeState {
/// handler to emit a synthetic ConditionRefresh -> snapshot_complete /// handler to emit a synthetic ConditionRefresh -> snapshot_complete
/// -> transition sequence. /// -> transition sequence.
stream_alarms_script: Mutex<Option<Vec<AlarmFeedMessage>>>, stream_alarms_script: Mutex<Option<Vec<AlarmFeedMessage>>>,
/// Optional per-test override that pins the fake's `StreamEvents`
/// handler to emit a scripted `MxEvent` sequence (e.g. a `replay_gap`
/// sentinel followed by a normal event). When `None`, the handler falls
/// back to the default `event(1)` / `event(2)` pair.
stream_events_script: Mutex<Option<Vec<MxEvent>>>,
} }
/// Per-test override for the fake's `Invoke` handler. /// Per-test override for the fake's `Invoke` handler.
@@ -691,6 +897,12 @@ enum InvokeOverride {
/// and capture the decoded `WriteCommand` in /// and capture the decoded `WriteCommand` in
/// `FakeState::last_write_command` for inspection. /// `FakeState::last_write_command` for inspection.
WriteOk, WriteOk,
/// Reply with an `Ok` protocol envelope but a negative `hresult` and a
/// non-success status entry carrying a credential-shaped diagnostic. This
/// mimics the worker's COMException path (e.g. `WriteSecured` /
/// `AuthenticateUser` rejected by MXAccess) so the client's
/// `ensure_mxaccess_success` check is exercised on the typed helper path.
MxAccessFailure,
} }
#[derive(Clone)] #[derive(Clone)]
@@ -772,6 +984,27 @@ impl MxAccessGateway for FakeGateway {
..MxCommandReply::default() ..MxCommandReply::default()
})), })),
InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)), InvokeOverride::Unavailable(message) => Err(Status::unavailable(message)),
InvokeOverride::MxAccessFailure => Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
// Protocol envelope succeeds; MXAccess itself failed.
protocol_status: Some(ok_status("command ok")),
// 0x80040E14 (a COM failure) as a signed 32-bit value.
hresult: Some(-2_147_217_900),
statuses: vec![MxStatusProxy {
success: 0,
category: MxStatusCategory::SecurityError as i32,
detected_by: MxStatusSource::RespondingLmx as i32,
detail: 123,
// A credential-shaped token that must be scrubbed from
// any surfaced diagnostic text.
diagnostic_text: "denied for mxgw_leaked_secret".to_owned(),
..MxStatusProxy::default()
}],
payload: None,
..MxCommandReply::default()
})),
InvokeOverride::WriteOk => { InvokeOverride::WriteOk => {
// Extract and capture the WriteCommand payload so the test // Extract and capture the WriteCommand payload so the test
// can assert on server_handle, item_handle, user_id, and value. // can assert on server_handle, item_handle, user_id, and value.
@@ -903,6 +1136,26 @@ impl MxAccessGateway for FakeGateway {
))); )));
} }
if kind == MxCommandKind::AuthenticateUser as i32 {
// Capture the transmitted command so a test can confirm the
// credential reaches the wire but never an error message.
if let Some(mx_command::Payload::AuthenticateUser(auth)) =
request.command.and_then(|command| command.payload)
{
*self.state.last_authenticate_user.lock().await = Some(auth);
}
return Ok(Response::new(MxCommandReply {
session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(),
kind,
protocol_status: Some(ok_status("command ok")),
payload: Some(mx_command_reply::Payload::AuthenticateUser(
AuthenticateUserReply { user_id: 4242 },
)),
..MxCommandReply::default()
}));
}
Ok(Response::new(MxCommandReply { Ok(Response::new(MxCommandReply {
session_id: request.session_id, session_id: request.session_id,
correlation_id: "fake-correlation".to_owned(), correlation_id: "fake-correlation".to_owned(),
@@ -921,9 +1174,12 @@ impl MxAccessGateway for FakeGateway {
&self, &self,
_request: Request<StreamEventsRequest>, _request: Request<StreamEventsRequest>,
) -> Result<Response<Self::StreamEventsStream>, Status> { ) -> Result<Response<Self::StreamEventsStream>, Status> {
let (sender, receiver) = mpsc::channel(4); let script = self.state.stream_events_script.lock().await.take();
sender.send(Ok(event(1))).await.unwrap(); let events = script.unwrap_or_else(|| vec![event(1), event(2)]);
sender.send(Ok(event(2))).await.unwrap(); let (sender, receiver) = mpsc::channel(events.len().max(1));
for event in events {
sender.send(Ok(event)).await.unwrap();
}
Ok(Response::new(DropAwareStream { Ok(Response::new(DropAwareStream {
inner: ReceiverStream::new(receiver), inner: ReceiverStream::new(receiver),
+179 -235
View File
@@ -1,227 +1,215 @@
# Gateway Authentication # Gateway Authentication
The gateway authentication subsystem verifies inbound API key credentials against a SQLite-backed key store, hashes secrets with a configurable pepper, and records administrative and verification events to an audit trail. The gateway authenticates inbound gRPC callers with API keys: a bearer token is
parsed, its secret is hashed with a peppered HMAC and compared in constant time
against a stored hash, and administrative and verification events are recorded to
an audit trail.
The peppered-HMAC pipeline itself — token parsing, secret generation, hashing,
constant-time compare, the SQLite schema, the key store, the verifier, and schema
migration — lives in the shared **`ZB.MOM.WW.Auth.ApiKeys`** package, of which
this gateway is the donor. The gateway does not reimplement or fork those types;
it binds the library through `AddZbApiKeyAuth` and layers gateway-specific
concerns on top: constraint enforcement, the gRPC authorization interceptor,
hot-path decorators, the admin CLI, the dashboard, and a canonical audit store
that supersedes the library's own audit table. This document describes the
consumer side — the token format, the options the gateway binds, the pieces it
adds, and where the library boundary sits. For the library internals (the concrete
`ApiKeyVerifier`, the SQLite stores, the schema and migrator), read the
`ZB.MOM.WW.Auth.ApiKeys` sources; they are not duplicated in this repository.
## Token Format ## Token Format
API keys travel in the HTTP `Authorization` header as a bearer token shaped `mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the `<keyId>` segment is the public identifier used for lookup, and `<secret>` is the high-entropy portion that the gateway verifies against a stored hash. API keys travel in the HTTP `Authorization` header as a bearer token shaped
`mxgw_<keyId>_<secret>`. The `mxgw_` prefix scopes parsing to gateway tokens, the
`<keyId>` segment is the public identifier used for lookup, and `<secret>` is the
high-entropy portion verified against a stored hash. The prefix and the pepper
configuration key the gateway pins are constants on
`AuthStoreServiceCollectionExtensions`
(`TokenPrefix = "mxgw"`, `PepperSecretName = "MxGateway:ApiKeyPepper"`); they are
supplied to the library at registration so the library's parser and pepper
provider use the gateway's contract. The library parser rejects a malformed token
before any database round-trip, and only a well-formed `mxgw_<keyId>_<secret>`
token reaches the store lookup.
`ApiKeyParser` enforces the format and rejects malformed tokens before any database round-trip: ## Secrets And Peppered Hashing
```csharp New secret material is high-entropy: the library generates 32 random bytes and
public bool TryParseAuthorizationHeader(string? authorizationHeader, out ParsedApiKey? apiKey) encodes them URL-safe base64 (no padding) so a secret embeds in a header without
{ escaping. The gateway never persists a plaintext secret — only its hash.
apiKey = null;
if (string.IsNullOrWhiteSpace(authorizationHeader) Secrets are hashed with `HMAC-SHA256` keyed by a server-side **pepper**. The
|| !authorizationHeader.StartsWith(BearerPrefix, StringComparison.OrdinalIgnoreCase)) pepper lives outside the database and is resolved from configuration under the
{ `MxGateway:ApiKeyPepper` key (the library's pepper provider reads it). Keeping the
return false; pepper out of the SQLite file means an attacker who exfiltrates only the database
} holds the hashes but lacks the keying material to brute-force candidate secrets,
even if the hash algorithm is known.
string token = authorizationHeader[BearerPrefix.Length..].Trim(); When the pepper is not configured, the library surfaces the failure as an
`InvalidOperationException` whose message reports the pepper is unavailable rather
if (!token.StartsWith(TokenPrefix, StringComparison.OrdinalIgnoreCase)) than persisting a key with an unkeyed hash. The dashboard management path
{ (`DashboardApiKeyManagementService`) catches that condition and returns the
return false; friendly "API key pepper is not configured." result instead of faulting the Blazor
} circuit; it currently matches on the message text, so a library wording change
``` would need to be reflected there (a typed pepper-unavailable exception is a pending
library improvement).
A successful parse produces a `ParsedApiKey(KeyId, Secret)` record. The `IApiKeyParser` interface exists so verification consumers can be tested without depending on header-format details.
## Parsing and Secrets
### Secret generation
`ApiKeySecretGenerator.Generate()` is the single source of new secret material. It uses 32 bytes from `RandomNumberGenerator.Fill` and encodes with URL-safe base64 (no padding) so secrets can be embedded in headers without escaping:
```csharp
public static string Generate()
{
Span<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
}
```
### Peppered hashing
`ApiKeySecretHasher` (registered behind `IApiKeySecretHasher`) hashes secrets with `HMACSHA256` keyed by a server-side pepper. The pepper lives outside the database and is resolved by `IConfiguration` lookup against the configured `PepperSecretName`:
```csharp
public byte[] HashSecret(string secret)
{
string pepper = GetPepper();
byte[] pepperBytes = Encoding.UTF8.GetBytes(pepper);
byte[] secretBytes = Encoding.UTF8.GetBytes(secret);
using HMACSHA256 hmac = new(pepperBytes);
return hmac.ComputeHash(secretBytes);
}
```
The pepper is intentionally not stored alongside the hash: an attacker who exfiltrates only the SQLite file holds the hashes but lacks the keying material to brute-force candidate secrets, even if the stored hash algorithm and salt scheme are known. If the pepper is missing the hasher throws `ApiKeyPepperUnavailableException`, which the verifier converts to a distinct failure code rather than treating it as a credential mismatch.
## Verification ## Verification
`ApiKeyVerifier` (`IApiKeyVerifier`) implements the verification flow: The gateway consumes the library's `IApiKeyVerifier` from
`GatewayGrpcAuthorizationInterceptor`. The verifier's flow is:
1. Parse the `Authorization` header into a `ParsedApiKey`. 1. Parse the `Authorization` header into the key id and presented secret.
2. Look up the `ApiKeyRecord` by `KeyId` through `IApiKeyStore.FindByKeyIdAsync`. 2. Look up the stored key record by key id.
3. Reject revoked records (`RevokedUtc is not null`) and expired records (`ExpiresUtc` in the past). Expiry is opt-in — keys created without an expiry never expire; an expired key fails opaquely, indistinguishable to the client from any other auth failure. 3. Reject a revoked record, and reject an expired record whose `ExpiresUtc` is in
the past. Expiry is opt-in — keys created without an expiry never expire; an
expired key fails opaquely, indistinguishable to the client from any other auth
failure.
4. Hash the presented secret with the configured pepper. 4. Hash the presented secret with the configured pepper.
5. Compare hashes with `CryptographicOperations.FixedTimeEquals` to avoid timing oracles. 5. Compare hashes in constant time to avoid a timing oracle.
6. Record a `LastUsedUtc` timestamp via `MarkKeyUsedAsync` and return an `ApiKeyIdentity`. 6. Stamp a `LastUsedUtc` timestamp and return a shared `ApiKeyIdentity` carrying
the key id, key prefix, display name, scopes, and the opaque constraints JSON.
```csharp A verification failure is opaque to the client: the interceptor returns
if (!CryptographicOperations.FixedTimeEquals(presentedHash, storedKey.SecretHash)) `Unauthenticated`/`PermissionDenied` without disclosing which check failed, while
{ the failure detail is available server-side for audit.
return ApiKeyVerificationResult.Fail(ApiKeyVerificationFailure.SecretMismatch);
}
await keyStore.MarkKeyUsedAsync(storedKey.KeyId, DateTimeOffset.UtcNow, cancellationToken) `GatewayApiKeyIdentityMapper.ToGatewayIdentity` maps the library's shared
.ConfigureAwait(false); `ApiKeyIdentity` onto the gateway's own `ApiKeyIdentity`
(`Security/Authentication/ApiKeyIdentity.cs`), which exposes the deserialized
return ApiKeyVerificationResult.Success(new ApiKeyIdentity( `ApiKeyConstraints` — parsed from the opaque constraints JSON via
KeyId: storedKey.KeyId, `ApiKeyConstraintSerializer` — that the downstream `ConstraintEnforcer` and the
KeyPrefix: storedKey.KeyPrefix, request-identity accessor enforce. The gateway identity exposes only non-secret
DisplayName: storedKey.DisplayName, fields (`KeyId`, `KeyPrefix`, `DisplayName`, `Scopes`, `Constraints`).
Scopes: storedKey.Scopes,
Constraints: storedKey.Constraints));
```
`ApiKeyVerificationResult` carries either an `ApiKeyIdentity` or a discriminated `ApiKeyVerificationFailure` value. The failure enum distinguishes parse errors, missing pepper, missing or revoked keys, and secret mismatch so the calling middleware can emit precise audit detail without leaking which check failed to the client.
`ApiKeyIdentity` exposes only non-secret fields (`KeyId`, `KeyPrefix`,
`DisplayName`, `Scopes`, and `Constraints`) and is the type downstream
authorization code consumes.
### Hot-path caching and last-used coalescing ### Hot-path caching and last-used coalescing
Left unmediated, every authenticated gRPC call costs a SQLite read plus a Left unmediated, every authenticated gRPC call costs a SQLite read plus a
`last_used_utc` **write** (the library verifier couples `MarkKeyUsed` into `last_used_utc` **write** (the library verifier couples `MarkUsed` into
`VerifyAsync`), which makes the auth store the throughput ceiling on the `VerifyAsync`), which makes the auth store the throughput ceiling on the
bulk-read workload. The gateway layers two decorators over the shared library's bulk-read workload. The gateway layers two decorators over the shared library's
registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the registrations (in `AuthStoreServiceCollectionExtensions`) — it does not edit the
library: library:
- **`CachingApiKeyVerifier`** wraps `IApiKeyVerifier` with an `IMemoryCache` - **`CachingApiKeyVerifier`** wraps the library `IApiKeyVerifier` with an
entry per successful verification, keyed on a SHA-256 hash of the presented `IMemoryCache` entry per successful verification, keyed on a SHA-256 hash of the
token (never the plaintext secret). A cache hit within presented token (never the plaintext secret). A cache hit within
`MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the `MxGateway:Security:ApiKeyVerificationCacheSeconds` (default 15 s) returns the
cached result without touching the store, so both the read and the coupled cached result without touching the store, so both the read and the coupled write
write are skipped. Only successes are cached; failures always reach the inner are skipped. Only successes are cached; failures always reach the inner verifier.
verifier. On a gateway-initiated revoke/rotate/delete the dashboard admin On a gateway-initiated revoke/rotate/delete the dashboard admin service calls
service calls `IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached `IApiKeyCacheInvalidator.Invalidate(keyId)`, evicting the cached entry
entry immediately. The short TTL is the backstop for out-of-band mutations immediately. The short TTL is the backstop for out-of-band mutations (a direct DB
(a direct DB edit, or a revoke run by the separate `apikey` CLI process, whose edit, or a revoke run by the separate `apikey` CLI process, whose in-memory cache
in-memory cache is not the running gateway's cache). is not the running gateway's cache).
- **`CoalescingMarkApiKeyStore`** wraps `IApiKeyStore` and forwards at most one - **`CoalescingMarkApiKeyStore`** wraps the library `IApiKeyStore` and forwards at
`MarkUsed` write per key per `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` most one `MarkUsed` write per key per
(default 60 s), so even under a cache miss the `last_used_utc` write is bounded `MxGateway:Security:ApiKeyLastUsedCoalesceSeconds` (default 60 s), so even under a
to roughly one per key per minute rather than one per RPC. `last_used_utc` is a cache miss the `last_used_utc` write is bounded to roughly one per key per minute
coarse staleness hint, not an audit record (audit rows are written separately), rather than one per RPC. `last_used_utc` is a coarse staleness hint, not an audit
so bounded staleness of up to one window is acceptable. record (audit rows are written separately), so bounded staleness of up to one
window is acceptable.
`GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON `GatewayApiKeyIdentityMapper` additionally memoizes the constraints-JSON
deserialization by blob, so the per-call parse on the mapped identity collapses to deserialization by blob, so the per-call parse on the mapped identity collapses to
a dictionary lookup. Both windows are configurable and may be set to `0` to a dictionary lookup. Both windows are configurable and may be set to `0` to disable
disable the respective mechanism; see [GatewayConfiguration](./GatewayConfiguration.md). the respective mechanism; see
[GatewayConfiguration](./GatewayConfiguration.md).
## Storage ## Storage
The gateway keeps API key state in a dedicated SQLite database. SQLite is sufficient because credential volume is small, the gateway runs as a single process, and the file is straightforward to back up and rotate independently of the main application data. API-key state lives in a dedicated SQLite database owned by the shared library.
SQLite is sufficient because credential volume is small, the gateway runs as a
single process, and the file is straightforward to back up and rotate independently
of the main application data.
The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)` (`C:\ProgramData\MxGateway\gateway-auth.db` on Windows, `/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the credential store is never written relative to the launch working directory on a non-Windows host. The production hosts pin the explicit Windows path in `appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative) `SqlitePath` so a bad override fails fast at startup rather than scattering the store by launch CWD (SEC-01). The database path is `GatewayOptions.Authentication.SqlitePath`. Its code default
is derived from `Environment.GetFolderPath(SpecialFolder.CommonApplicationData)`
(`C:\ProgramData\MxGateway\gateway-auth.db` on Windows,
`/usr/share/MxGateway/gateway-auth.db` or the container equivalent elsewhere) so the
credential store is never written relative to the launch working directory on a
non-Windows host. The production hosts pin the explicit Windows path in
`appsettings.json`. `GatewayOptionsValidator` rejects a non-rooted (relative)
`SqlitePath` so a bad override fails fast at startup rather than scattering the store
by launch CWD (SEC-01).
### Connection factory The library owns the SQLite schema and connection factory. The `api_keys` table
carries the key id, key prefix, secret-hash blob, display name, serialized scopes,
`AuthSqliteConnectionFactory` reads `GatewayOptions.Authentication.SqlitePath`, ensures the parent directory exists, and builds a connection string in `ReadWriteCreate` mode so first-run installations can create the file without manual provisioning. Connection pooling is enabled and the connection string carries a non-zero `DefaultTimeout`: optional serialized constraints, and the `created_utc`, `last_used_utc`,
`revoked_utc`, and `expires_utc` timestamps. Because the schema, stores, and migrator
```csharp belong to `ZB.MOM.WW.Auth.ApiKeys`, this document does not restate their column
SqliteConnectionStringBuilder builder = new() readers or SQL; consult the library for that detail.
{
DataSource = sqlitePath,
Mode = SqliteOpenMode.ReadWriteCreate,
Pooling = true,
DefaultTimeout = (int)BusyTimeout.TotalSeconds,
};
```
Every store opens its connection through `OpenConnectionAsync`, which opens the connection and then applies `PRAGMA journal_mode=WAL` and `PRAGMA busy_timeout`. WAL is a persistent database-level setting so re-applying it per connection is a cheap no-op; `busy_timeout` is per-connection state. Because `MarkKeyUsedAsync` runs on every authenticated request and `SqliteApiKeyAuditStore` appends on every denial, this lets concurrent readers and writers retry briefly instead of surfacing `SQLITE_BUSY` as a hard failure on the request path.
### Schema
`SqliteAuthSchema` declares table names and the current schema version as constants. Three tables are involved:
- `api_keys` stores `key_id`, `key_prefix`, the `secret_hash` blob,
`display_name`, serialized `scopes`, optional serialized `constraints`, and
the `created_utc`, `last_used_utc`, and `revoked_utc` timestamps.
- `api_key_audit` is an append-only log keyed by an autoincrement `audit_id` with `key_id`, `event_type`, `remote_address`, `created_utc`, and `details` columns.
- `schema_version` carries a single row whose `version` column is matched against `SqliteAuthSchema.CurrentVersion`.
### Read paths
`SqliteApiKeyStore` (`IApiKeyStore`) handles the two reads needed at request time: `FindByKeyIdAsync` returns any record (so revoked keys can be reported distinctly) and `FindActiveByKeyIdAsync` filters to non-revoked rows. `MarkKeyUsedAsync` updates `last_used_utc` only for non-revoked rows so a freshly revoked key cannot have its timestamp refreshed by a racing verification.
`ApiKeyRecord` is the in-memory projection. `ApiKeyRecordReader.Read` is shared by every read path so column ordering is defined in one place:
```csharp
public static ApiKeyRecord Read(SqliteDataReader reader)
{
return new ApiKeyRecord(
KeyId: reader.GetString(0),
KeyPrefix: reader.GetString(1),
SecretHash: (byte[])reader["secret_hash"],
DisplayName: reader.GetString(3),
Scopes: ApiKeyScopeSerializer.Deserialize(reader.GetString(4)),
Constraints: ApiKeyConstraintSerializer.Deserialize(reader.IsDBNull(5) ? null : reader.GetString(5)),
CreatedUtc: DateTimeOffset.Parse(reader.GetString(6), System.Globalization.CultureInfo.InvariantCulture),
LastUsedUtc: ReadNullableDateTimeOffset(reader, 7),
RevokedUtc: ReadNullableDateTimeOffset(reader, 8));
}
```
### Write paths
`SqliteApiKeyAdminStore` (`IApiKeyAdminStore`) implements administrative mutations: `CreateAsync` accepts an `ApiKeyCreateRequest`, `RevokeAsync` sets `revoked_utc` only when not already revoked, `RotateAsync` replaces `secret_hash`, clears `last_used_utc`, and clears `revoked_utc` so a rotated key is immediately usable, and `DeleteAsync` permanently removes a row but only when `revoked_utc IS NOT NULL` — active keys are untouched (returns false) so the revoke event lands in the audit log before the row disappears.
Because `RotateAsync` clears `revoked_utc`, rotating a previously revoked key reactivates it. The dashboard API Keys page therefore offers the Rotate (and Revoke) actions only for keys whose status is `Active`; revoked keys instead show a Delete action that calls `DeleteAsync`, so an operator can permanently remove a revoked row without ever risking un-revocation as a side effect of a rotation.
The dashboard API Keys page also surfaces expiry: each row shows an `Expires` column (`Never` when unset) and a status badge that reads `Expired` (past expiry, red), `Expiring` (within seven days, amber), `Revoked`, or `Active`. This is display-only staleness surfacing; expiry is set at creation time via the `apikey create-key --expires` CLI, not from the dashboard.
### Audit trail ### Audit trail
`SqliteApiKeyAuditStore` (`IApiKeyAuditStore`) appends `ApiKeyAuditEntry` values to the `api_key_audit` table and stamps each row with a UTC timestamp inside the store rather than trusting the caller. `ListRecentAsync` returns the most recent rows ordered by `audit_id` descending and projects them into `ApiKeyAuditRecord`. Rows are kept even after the referenced key is revoked because the audit history is the durable record of administrative action; the `key_id` column is nullable to accommodate non-key-scoped events such as `init-db`. The library emits its own API-key audit entries (from the admin verbs — create,
revoke, rotate, `init-db`, and constraint denials), but the gateway **overrides**
the library's `IApiKeyAuditStore` registration with
`CanonicalForwardingApiKeyAuditStore`. That adapter canonicalizes every
library-emitted `ApiKeyAuditEntry` onto the gateway's `AuditEvent` shape and routes
it through `IAuditWriter` (`CanonicalAuditWriter`) into `SqliteCanonicalAuditStore`,
which persists to a single **`audit_event`** table (columns `event_id`,
`occurred_at_utc`, `actor`, `action`, `outcome`, `category`, `target`,
`source_node`, `correlation_id`, `details_json`). Reads for the dashboard "recent
audit" view go back through the same adapter, which maps `audit_event` rows back to
`ApiKeyAuditEntry` values so the existing view keeps working unchanged.
## Migration Consequently the library's own `api_key_audit` table is left in place but
**unused** after adoption — nothing writes to it once the override is registered.
The canonical `audit_event` table is the single durable record of both API-key
administrative actions and the dashboard's own audit vocabulary
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`,
`dashboard-delete-key`, and the session Close/Kill actions). This is why any prose
that describes credential audits as landing in `api_key_audit` is stale: the
canonical store is `audit_event`.
Schema bring-up is centralised behind `IAuthStoreMigrator`. `SqliteAuthStoreMigrator` executes the migration inside a single transaction so a partial failure leaves the database untouched, refuses to start when the on-disk schema version is newer than the binary supports, and idempotently creates the v1 schema: ## Registration
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore(IConfiguration)` wires the
whole subsystem. It does not register the library types directly — it delegates to
the shared provider and then layers the gateway concerns:
```csharp ```csharp
if (existingVersion > SqliteAuthSchema.CurrentVersion) public static IServiceCollection AddSqliteAuthStore(
this IServiceCollection services,
IConfiguration configuration)
{ {
throw new AuthStoreMigrationException( // Pin the gateway's token prefix ("mxgw") and pepper key ("MxGateway:ApiKeyPepper")
$"Auth database schema version {existingVersion} is newer than supported version {SqliteAuthSchema.CurrentVersion}."); // as fallback defaults UNDER the supplied configuration, then register the shared
// provider: it binds ApiKeyOptions from MxGateway:Authentication and wires the SQLite
// stores, the configuration-backed pepper provider, the verifier, the migrator, and
// the migration hosted service.
services.AddZbApiKeyAuth(effectiveConfig, AuthenticationSectionPath);
// SEC-08 hot-path decorators layered over the library registrations.
services.AddMemoryCache();
// CoalescingMarkApiKeyStore decorates IApiKeyStore; CachingApiKeyVerifier decorates
// IApiKeyVerifier and also serves as IApiKeyCacheInvalidator.
// Canonical audit: override the library's IApiKeyAuditStore so every API-key audit
// event is forwarded through IAuditWriter into the audit_event table.
services.AddSingleton<IApiKeyAuditStore, CanonicalForwardingApiKeyAuditStore>();
// The shared admin command set (ApiKeyAdminCommands) and the gateway CLI runner.
services.AddSingleton<ApiKeyAdminCliRunner>();
return services;
} }
await ApplyVersionOneAsync(connection, transaction, cancellationToken).ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
``` ```
`AuthStoreMigrationHostedService` runs the migrator at startup, but only when API-key authentication is enabled and `RunMigrationsOnStartup` is true. Operators who manage schema out-of-band can disable the hosted run and use the admin CLI's `init-db` command instead. The decorators wrap the library's last registration for each interface rather than
replacing the library types, preserving singleton semantics; the audit override is
`AuthStoreMigrationException` is a sealed `InvalidOperationException` so it can be caught precisely without swallowing unrelated failures. registered after `AddZbApiKeyAuth` so it wins as the resolved `IApiKeyAuditStore`.
## Admin CLI ## Admin CLI
`ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult` carrying an error). `ApiKeyAdminCliRunner` then executes the command, runs the migrator first, calls the relevant store method, appends an audit row, and writes either text or JSON output via `ApiKeyAdminOutput`. The returned `ApiKeyAdminListedKey` projection deliberately omits the `secret_hash` so listing a database does not surface hash material. `ApiKeyAdminCommandLineParser.Parse` recognises a leading `apikey` argument and
dispatches to one of the subcommands declared by `ApiKeyAdminCommandKind`. Each
parsed invocation produces an `ApiKeyAdminCommand` (or an `ApiKeyAdminParseResult`
carrying an error). `ApiKeyAdminCliRunner` then runs the migrator, invokes the shared
`ApiKeyAdminCommands` verb, and writes text or JSON output via `ApiKeyAdminOutput`.
The returned `ApiKeyAdminListedKey` projection deliberately omits the secret hash so
listing a database never surfaces hash material.
The supported subcommands match `ApiKeyAdminCommandKind` exactly: The supported subcommands match `ApiKeyAdminCommandKind` exactly:
@@ -230,7 +218,7 @@ The supported subcommands match `ApiKeyAdminCommandKind` exactly:
| `init-db` | none | Runs the migrator and records an audit entry. | | `init-db` | none | Runs the migrator and records an audit entry. |
| `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw_<keyId>_<secret>` token. Optional `--expires` sets an expiry (absolute ISO-8601 UTC, or a relative `<N>d`/`<N>h` from now); omit it for a non-expiring key. | | `create-key` | `--key-id`, `--display-name` | Generates a new secret, stores its peppered hash and optional constraints, and prints the assembled `mxgw_<keyId>_<secret>` token. Optional `--expires` sets an expiry (absolute ISO-8601 UTC, or a relative `<N>d`/`<N>h` from now); omit it for a non-expiring key. |
| `list-keys` | none | Lists every stored key with its scopes, constraints, revocation state, and expiry (`active`/`expired`/`revoked`). | | `list-keys` | none | Lists every stored key with its scopes, constraints, revocation state, and expiry (`active`/`expired`/`revoked`). |
| `revoke-key` | `--key-id` | Sets `revoked_utc` if the key is currently active. | | `revoke-key` | `--key-id` | Marks the key revoked if it is currently active. |
| `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. | | `rotate-key` | `--key-id` | Replaces the secret hash and prints the new token. |
Examples: Examples:
@@ -249,68 +237,24 @@ mxgateway apikey rotate-key --key-id ops.alice
Constraint flags are optional. `--read-subtree`, `--write-subtree`, Constraint flags are optional. `--read-subtree`, `--write-subtree`,
`--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable. `--read-tag-glob`, `--write-tag-glob`, and `--browse-subtree` are repeatable.
`--max-write-classification` accepts one integer. `--read-alarm-only` and `--max-write-classification` accepts one integer. `--read-alarm-only` and
`--read-historized-only` are boolean flags. Existing rows with null `--read-historized-only` are boolean flags. Existing rows with null constraints
constraints remain fully unconstrained after migration. remain fully unconstrained after migration.
Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens so they remain safe to embed in the token format and in URL paths used by administrative tooling. Key ids are restricted by the parser to ASCII letters, digits, periods, and hyphens
so they remain safe to embed in the token format and in URL paths used by
administrative tooling.
The CLI is not the only management surface: the dashboard API Keys page The CLI is not the only management surface: the dashboard API Keys page creates,
creates, rotates, revokes, and deletes (revoked-only) keys through the same rotates, revokes, and deletes (revoked-only) keys through the same shared admin
`IApiKeyAdminStore`. Every destructive dashboard action is gated by a command set. Every destructive dashboard action is gated by a confirmation dialog
confirmation dialog and emits its own audit event and emits its own audit event (`dashboard-create-key`, `dashboard-rotate-key`,
(`dashboard-create-key`, `dashboard-rotate-key`, `dashboard-revoke-key`, `dashboard-revoke-key`, `dashboard-delete-key`) into the canonical `audit_event`
`dashboard-delete-key`). See store. The page also surfaces expiry: each row shows an `Expires` column (`Never`
when unset) and a status badge that reads `Expired`, `Expiring` (within seven days),
`Revoked`, or `Active`. This staleness surfacing is display-only; expiry is set at
creation time via `apikey create-key --expires`, not from the dashboard. See
[Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page). [Gateway Dashboard Design](./GatewayDashboardDesign.md#api-keys-page).
## Scope Serialization
Scopes are persisted as a single TEXT column rather than a join table because the set is small, never queried by membership at the database level, and changes atomically with the owning row. `ApiKeyScopeSerializer.Serialize` writes a JSON array sorted with `StringComparer.Ordinal` so equivalent scope sets produce byte-identical column values, which makes audit diffing and database comparisons deterministic:
```csharp
public static string Serialize(IReadOnlySet<string> scopes)
{
return JsonSerializer.Serialize(scopes.Order(StringComparer.Ordinal));
}
public static IReadOnlySet<string> Deserialize(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return new HashSet<string>(StringComparer.Ordinal);
}
string[]? scopes = JsonSerializer.Deserialize<string[]>(value);
return new HashSet<string>(scopes ?? [], StringComparer.Ordinal);
}
```
`Deserialize` tolerates an empty column by returning an empty set so older rows or hand-edited records do not crash the verifier.
## Registration
`AuthStoreServiceCollectionExtensions.AddSqliteAuthStore` wires every service in this subsystem as a singleton and registers the migration hosted service:
```csharp
public static IServiceCollection AddSqliteAuthStore(this IServiceCollection services)
{
services.AddSingleton<IApiKeyParser, ApiKeyParser>();
services.AddSingleton<IApiKeySecretHasher, ApiKeySecretHasher>();
services.AddSingleton<IApiKeyVerifier, ApiKeyVerifier>();
services.AddSingleton<ApiKeyAdminCliRunner>();
services.AddSingleton<AuthSqliteConnectionFactory>();
services.AddSingleton<IAuthStoreMigrator, SqliteAuthStoreMigrator>();
services.AddSingleton<IApiKeyStore, SqliteApiKeyStore>();
services.AddSingleton<IApiKeyAdminStore, SqliteApiKeyAdminStore>();
services.AddSingleton<IApiKeyAuditStore, SqliteApiKeyAuditStore>();
services.AddHostedService<AuthStoreMigrationHostedService>();
return services;
}
```
Singletons are safe because each operation opens its own short-lived `SqliteConnection` through the factory; there is no shared mutable state inside the services.
## Related Documentation ## Related Documentation
- [Gateway Configuration](./GatewayConfiguration.md) - [Gateway Configuration](./GatewayConfiguration.md)
+43 -3
View File
@@ -63,12 +63,30 @@ Goals:
Non-goals for v1: Non-goals for v1:
- client-side reconnectable sessions, - automatic client-side session reconnection (the library never transparently
- client-side event replay, re-opens a dropped stream on the consumer's behalf — reconnect timing and
policy stay an application concern),
- client-side event replay buffering (the gateway owns the replay ring; the
client does not retain its own event history),
- client-side command batching, - client-side command batching,
- synthetic MXAccess events, - synthetic MXAccess events,
- hiding MXAccess handles behind opaque client-only handles. - hiding MXAccess handles behind opaque client-only handles.
The gateway's reconnect-replay *protocol* is, however, consumable from every
client: each exposes the `after_worker_sequence` resume cursor on
`StreamEvents` and surfaces the gateway's `ReplayGap` sentinel as a distinct,
typed, non-terminal signal (CLI-15) so a consumer can detect an evicted-history
gap and re-snapshot. Per the no-synthesized-events invariant the client never
fabricates or swallows the sentinel — it only makes the gateway's own signal
observable. The reaction contract is identical across languages: on a gap,
discard cached state, re-snapshot, and resume with
`after_worker_sequence = oldest_available_sequence - 1`. The per-language
surface follows each idiom (.NET `MxEventStreamItem.IsReplayGap` via
`StreamEventItemsAsync`; Go `EventResult.ReplayGap`/`IsReplayGap()`; Rust
`EventItem::ReplayGap`; Python yields a typed `ReplayGap` from `stream_events`;
Java `MxEventStreamItem.isReplayGap()` via `MxEventStream.nextItem()`). Shipped
in all five clients.
## Public Client Concepts ## Public Client Concepts
All languages should expose the same core concepts, using idiomatic naming: All languages should expose the same core concepts, using idiomatic naming:
@@ -87,6 +105,28 @@ All languages should expose the same core concepts, using idiomatic naming:
The gateway session id and MXAccess handles must remain visible. The library may The gateway session id and MXAccess handles must remain visible. The library may
offer helper methods, but it must not invent alternate handle semantics. offer helper methods, but it must not invent alternate handle semantics.
## Typed Command Parity
Every command kind in the wire contract has a typed single-item session helper,
not just a raw-`Invoke` escape hatch (CLI-04). Beyond the register/add/advise/
remove/write family, the parity-critical single-item helpers are:
`AdviseSupervisory`, `WriteSecured` / `WriteSecured2`, `AuthenticateUser`,
`ArchestrAUserToId`, `AddBufferedItem`, `SetBufferedUpdateInterval`, `Suspend`,
`Activate`, and `Unregister`. Each is a thin wrapper over the same raw-command
machinery the bulk helpers use — it adds no wire surface — and runs the same
MXAccess-level reply validation (HRESULT `< 0` + per-item `MxStatusProxy`) as the
rest of the client. **MXAccess parity is preserved exactly**: e.g. `WriteSecured`
failing before a prior `AuthenticateUser` + `AdviseSupervisory` surfaces the
native failure unchanged — the helper does not pre-validate or reorder it.
**Credential handling:** `AuthenticateUser` credentials and `WriteSecured`
secured payloads route through each client's secret-redaction seam so they never
reach logs, exception text, or `ToString`/`Debug`/`Display` — the value is carried
only on the wire. Each client's test suite asserts a distinctive credential is
absent from any surfaced error.
Shipped in all five clients (.NET / Go / Rust / Python / Java).
## Shared API Shape ## Shared API Shape
Each language should support this conceptual API: Each language should support this conceptual API:
@@ -407,7 +447,7 @@ The stable client proto manifest defines the generated-code directories:
clients/dotnet/generated clients/dotnet/generated
clients/go/internal/generated clients/go/internal/generated
clients/rust/src/generated clients/rust/src/generated
clients/python/src/mxgateway/generated clients/python/src/zb_mom_ww_mxgateway/generated
clients/java/src/main/generated clients/java/src/main/generated
``` ```
+12 -12
View File
@@ -48,8 +48,8 @@ dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csp
Build and test from the repository root: Build and test from the repository root:
```powershell ```powershell
dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln dotnet build clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx
dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.sln --no-build dotnet test clients/dotnet/ZB.MOM.WW.MxGateway.Client.slnx --no-build
``` ```
Create local package artifacts: Create local package artifacts:
@@ -156,8 +156,8 @@ Pop-Location
## Python ## Python
The Python package is `mxaccess-gateway-client`. Generated modules live under The Python package is `zb-mom-ww-mxaccess-gateway-client`. Generated modules
`clients/python/src/mxgateway/generated`. live under `clients/python/src/zb_mom_ww_mxgateway/generated`.
Regenerate the Python bindings: Regenerate the Python bindings:
@@ -184,21 +184,21 @@ Push-Location clients/python
mxgw-py version --json mxgw-py version --json
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json mxgw-py smoke --endpoint mxgateway.example.local:5001 --tls --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
python -m mxgateway_cli version --json python -m zb_mom_ww_mxgateway_cli version --json
Pop-Location Pop-Location
``` ```
## Java ## Java
The Java workspace uses Gradle, Java 21, `mxgateway-client`, and The Java workspace uses Gradle, Java 17, `zb-mom-ww-mxgateway-client`, and
`mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and `zb-mom-ww-mxgateway-cli`. The Gradle protobuf plugin writes generated Java protobuf and
gRPC sources under `clients/java/src/main/generated`. gRPC sources under `clients/java/src/main/generated`.
Regenerate Java bindings: Regenerate Java bindings:
```powershell ```powershell
Push-Location clients/java Push-Location clients/java
gradle :mxgateway-client:generateProto gradle :zb-mom-ww-mxgateway-client:generateProto
Pop-Location Pop-Location
``` ```
@@ -214,7 +214,7 @@ Create local library and CLI artifacts:
```powershell ```powershell
Push-Location clients/java Push-Location clients/java
gradle :mxgateway-client:jar :mxgateway-cli:installDist gradle :zb-mom-ww-mxgateway-client:jar :zb-mom-ww-mxgateway-cli:installDist
Pop-Location Pop-Location
``` ```
@@ -222,9 +222,9 @@ Run the CLI through Gradle:
```powershell ```powershell
Push-Location clients/java Push-Location clients/java
gradle :mxgateway-cli:run --args="version --json" gradle :zb-mom-ww-mxgateway-cli:run --args="version --json"
gradle :mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
gradle :mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json" gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint mxgateway.example.local:5001 --ca-file C:\certs\mxgateway-ca.pem --server-name-override mxgateway.example.local --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
Pop-Location Pop-Location
``` ```
+3 -3
View File
@@ -107,7 +107,7 @@ The manifest declares these generated-code directories:
| .NET | `clients/dotnet/generated` | | .NET | `clients/dotnet/generated` |
| Go | `clients/go/internal/generated` | | Go | `clients/go/internal/generated` |
| Rust | `clients/rust/src/generated` | | Rust | `clients/rust/src/generated` |
| Python | `clients/python/src/mxgateway/generated` | | Python | `clients/python/src/zb_mom_ww_mxgateway/generated` |
| Java | `clients/java/src/main/generated` | | Java | `clients/java/src/main/generated` |
Only generator output belongs in these directories. Handwritten client wrappers Only generator output belongs in these directories. Handwritten client wrappers
@@ -128,7 +128,7 @@ Use these commands to regenerate language-specific client bindings:
| Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` | | Go | `Push-Location clients/go; ./generate-proto.ps1; Pop-Location` |
| Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` | | Rust | `Push-Location clients/rust; cargo check --workspace; Pop-Location` |
| Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` | | Python | `Push-Location clients/python; ./generate-proto.ps1; Pop-Location` |
| Java | `Push-Location clients/java; gradle :mxgateway-client:generateProto; Pop-Location` | | Java | `Push-Location clients/java; gradle :zb-mom-ww-mxgateway-client:generateProto; Pop-Location` |
.NET generation currently runs through the contracts project: .NET generation currently runs through the contracts project:
@@ -172,7 +172,7 @@ cargo check --workspace
``` ```
Python clients should use `grpc_tools.protoc` and write generated modules under Python clients should use `grpc_tools.protoc` and write generated modules under
`clients/python/src/mxgateway/generated` so imports stay separate from `clients/python/src/zb_mom_ww_mxgateway/generated` so imports stay separate from
handwritten async wrappers. handwritten async wrappers.
The Python scaffold provides a repo-local generation script: The Python scaffold provides a repo-local generation script:
+8
View File
@@ -30,6 +30,14 @@ Each client entry defines commands for the same required operation sequence:
The optional `write` command is documented separately because writing changes The optional `write` command is documented separately because writing changes
provider state and should only run when the operator supplies a safe test value. provider state and should only run when the operator supplies a safe test value.
When `stream-events` is resumed with an `after_worker_sequence` cursor that
predates the oldest event still in the gateway's replay ring, the gateway emits a
single `ReplayGap` sentinel at the head of the stream. Every client surfaces this
as a distinct, typed, non-terminal signal (see each client README); the resume
contract is `after_worker_sequence = oldest_available_sequence - 1`. The default
smoke sequence opens a fresh stream (no cursor) and does not exercise the gap
path; a resume-with-gap fixture case is tracked separately (TST-24).
## Integration Gate ## Integration Gate
Cross-language smoke execution is opt-in. Runners should skip the matrix unless Cross-language smoke execution is opt-in. Runners should skip the matrix unless
+3 -1
View File
@@ -123,7 +123,9 @@ The split uses `count: 3` because the secret portion may itself contain undersco
### Command value redaction ### Command value redaction
`RedactCommandValue` enforces the "values are opt-in and redacted by default" rule: > **Not yet implemented.** Command-value logging is *not* wired end-to-end. There is no `MxGateway:Diagnostics:LogCommandValues` (or equivalent) configuration knob, and `RedactCommandValue` / `IsCredentialBearingCommand` have no call sites in the gateway — no command values are logged anywhere today, which is the safest posture. The helpers below exist as the intended redaction seam for a future opt-in value-logging feature; that wiring is deferred until secured-bulk command variants are covered by the redactor's credential list (the `WriteSecuredBulk` / `WriteSecured2Bulk` gap), so enabling value logging cannot leak a secured-bulk payload. Until then, treat this section as describing the planned shape, not current behavior.
The intended `RedactCommandValue` would enforce the "values are opt-in and redacted by default" rule:
```csharp ```csharp
public static object? RedactCommandValue( public static object? RedactCommandValue(
+1 -1
View File
@@ -178,7 +178,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. | | `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. | | `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. | | `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. | | `MxGateway:Dashboard:CookieName` | _(unset)_ | Dashboard auth cookie name. Leave unset (null/blank) to use the resolved default: `__Host-MxGatewayDashboard` when `RequireHttpsCookie` is `true` (Secure cookie, so the `__Host-` prefix's Secure/no-Domain/`Path=/` guarantees apply), else the plain `MxGatewayDashboard` (a `__Host-` cookie without Secure is silently dropped by browsers). Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. An explicit override always wins over the `__Host-` default. Changing it signs out existing dashboard sessions on next deploy. |
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. | | `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. | | `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
+16 -7
View File
@@ -167,7 +167,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
|---|---|---|---|---| |---|---|---|---|---|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick; new connections receive the current snapshot synchronously in `OnConnectedAsync`. | | `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick; new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. | | `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. |
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity (per-session dashboard ACL is tracked separately). | | `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`. The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. The per-session hub ACL that would scope a Viewer to specific sessions is still outstanding (SEC-25 / remediation roadmap item 12); the value redaction is the near-term hardening that closes the value-leak seam independently of that ACL. |
`DashboardPageBase` opens a `DashboardSnapshotHub` connection via the connection `DashboardPageBase` opens a `DashboardSnapshotHub` connection via the connection
factory in `OnInitializedAsync`, seeds `Snapshot` synchronously from factory in `OnInitializedAsync`, seeds `Snapshot` synchronously from
@@ -311,8 +311,9 @@ Right-clicking an attribute (or double-clicking it) adds it to the subscription
panel. The panel shows each subscribed tag's live value, MXAccess data type, panel. The panel shows each subscribed tag's live value, MXAccess data type,
quality and source timestamp, refreshed every two seconds. The subscription quality and source timestamp, refreshed every two seconds. The subscription
panel is the explicit opt-in tag-value surface: it always shows values panel is the explicit opt-in tag-value surface: it always shows values
regardless of `Dashboard:ShowTagValues`, which continues to govern only the regardless of `Dashboard:ShowTagValues`, which governs the diagnostic
diagnostic session/worker views. session/worker views and the per-session `EventsHub` mirror (values are
redacted from the mirrored events when the flag is false).
### Alarms page ### Alarms page
@@ -418,8 +419,12 @@ Do not show API key secrets or pepper values.
Dashboard authentication is LDAP-backed, distinct from the API-key model used Dashboard authentication is LDAP-backed, distinct from the API-key model used
on the gRPC API. Users sign in with directory credentials; the gateway maps on the gRPC API. Users sign in with directory credentials; the gateway maps
their LDAP groups to one of two dashboard roles (`Admin` or `Viewer`) and their LDAP groups to one of two dashboard roles (`Administrator` or `Viewer`) and
issues a cookie carrying those role claims. issues a cookie carrying those role claims. `Administrator` is the canonical
role value — the exact string `GroupToRole` values and the validator accept
(`DashboardRoles.Admin`); the shorthand "Admin" used elsewhere in this document
names the same role and the `MxGateway.Dashboard.Admin` policy, not a distinct
config value.
Implemented behavior: Implemented behavior:
@@ -431,7 +436,11 @@ Implemented behavior:
`ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group` `ClaimTypes.Role` claims, alongside the per-group `mxgateway:ldap_group`
claims; claims;
- a successful login signs in the `MxGateway.Dashboard` cookie scheme - a successful login signs in the `MxGateway.Dashboard` cookie scheme
(`__Host-MxGatewayDashboard`, HttpOnly, SameSite=Strict, Secure); (HttpOnly, SameSite=Strict, Secure); the cookie is named
`__Host-MxGatewayDashboard` when `MxGateway:Dashboard:RequireHttpsCookie` is
true (default) and no `MxGateway:Dashboard:CookieName` override is set,
otherwise the plain `MxGatewayDashboard` name is used (the `__Host-` prefix is
only honoured on a Secure cookie);
- a user with no matching group cannot sign in — the login screen returns the - a user with no matching group cannot sign in — the login screen returns the
generic credential-rejected message; generic credential-rejected message;
- antiforgery tokens guard the login and logout POSTs. - antiforgery tokens guard the login and logout POSTs.
@@ -541,7 +550,7 @@ Effective configuration:
"RecentSessionLimit": 200, "RecentSessionLimit": 200,
"ShowTagValues": false, "ShowTagValues": false,
"GroupToRole": { "GroupToRole": {
"GwAdmin": "Admin", "GwAdmin": "Administrator",
"GwReader": "Viewer" "GwReader": "Viewer"
} }
} }
+5 -2
View File
@@ -699,8 +699,11 @@ password in a form post, calls `DashboardAuthenticator` to bind against
`MxGateway:Ldap`, resolves the user's LDAP groups through `MxGateway:Ldap`, resolves the user's LDAP groups through
`MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in `MxGateway:Dashboard:GroupToRole` to one of `Admin` / `Viewer`, and signs in
with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only, with the `MxGateway.Dashboard` cookie scheme. The cookie is HTTP-only,
secure, strict SameSite, and named `__Host-MxGatewayDashboard`. Logout secure, and strict SameSite. It is named `__Host-MxGatewayDashboard` when
clears it. Login and logout posts validate antiforgery tokens. SignalR `MxGateway:Dashboard:RequireHttpsCookie` is true (default) and no
`MxGateway:Dashboard:CookieName` override is set; otherwise it falls back to the
plain `MxGatewayDashboard` name (the `__Host-` prefix requires a Secure cookie).
Logout clears it. Login and logout posts validate antiforgery tokens. SignalR
connections additionally accept a 30-minute data-protected bearer minted at connections additionally accept a 30-minute data-protected bearer minted at
`/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests `/hubs/token`. `Dashboard:AllowAnonymousLocalhost` permits loopback requests
to bypass the cookie requirement and defaults to `true`. to bypass the cookie requirement and defaults to `true`.
+11 -2
View File
@@ -10,7 +10,7 @@ The layer is composed of four collaborators:
| Type | Lifetime | Role | | Type | Lifetime | Role |
|------|----------|------| |------|----------|------|
| `MxAccessGatewayService` | scoped (gRPC) | Implements the six `MxAccessGateway` RPCs, performs exception mapping. | | `MxAccessGatewayService` | scoped (gRPC) | Implements the seven `MxAccessGateway` RPCs, performs exception mapping. |
| `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. | | `MxAccessGrpcRequestValidator` | singleton | Rejects malformed requests before any session work runs. |
| `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. | | `MxAccessGrpcMapper` | singleton | Converts public proto types to internal `WorkerCommand`/`WorkerEvent` types and back. |
| `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. | | `IEventStreamService` (`EventStreamService`) | singleton | Owns the event stream pipeline, including bounded queue and backpressure handling. |
@@ -29,7 +29,7 @@ A second gRPC service, `GalaxyRepositoryGrpcService`, is mapped alongside it. It
## RPC Handlers ## RPC Handlers
`MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — six in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, and `StreamAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract. `MxAccessGatewayService` derives from the generated `MxAccessGateway.MxAccessGatewayBase` and implements every RPC declared in `mxaccess_gateway.proto` — seven in total: `OpenSession`, `CloseSession`, `Invoke`, `StreamEvents`, `AcknowledgeAlarm`, `StreamAlarms`, and `QueryActiveAlarms`. The proto contract itself is documented in [Contracts](./Contracts.md); this section covers only what the server-side handler does on top of that contract.
Public gRPC send and receive message sizes are configured from Public gRPC send and receive message sizes are configured from
`MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use `MxGateway:Protocol:MaxGrpcMessageBytes` (default 16 MiB). Official clients use
@@ -94,6 +94,10 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree. `StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
### `QueryActiveAlarms`
`QueryActiveAlarms` is a server-streaming, **session-less** RPC that returns a point-in-time snapshot of the currently-active alarm set. Unlike `StreamAlarms`, it does not stay attached for live transitions — it writes the snapshot and completes, which clients use after a reconnect to reseed alarm state or to reconcile alarms that may have been missed during a transport blip. It is served from the always-on `GatewayAlarmMonitor` cache (`IGatewayAlarmService.CurrentAlarms`), so no worker session is opened. The handler does not run through `MxAccessGrpcRequestValidator`; it only null-checks the request inline, then streams one `ActiveAlarmSnapshot` per cached alarm, honouring cancellation between writes. When `alarm_filter_prefix` is non-empty, only alarms whose `alarm_full_reference` starts with that prefix (ordinal `StartsWith`) are emitted; an empty prefix returns the full set. Exceptions are translated by the same `MapException` path as the other handlers. The RPC requires the `event` scope (`GatewayScopes.EventsRead`), the same scope as `StreamEvents` and `StreamAlarms`.
#### Provider status on the alarm feed #### Provider status on the alarm feed
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying `AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
@@ -173,6 +177,7 @@ receive this event directly.
| `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` | | `Invoke` | `session_id` non-empty, `command` present, `kind` not `Unspecified`, payload oneof must match `kind`. | `InvalidArgument` |
| `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` | | `AcknowledgeAlarm` | `alarm_full_reference` must be non-empty. Validated inline in the handler, not by `MxAccessGrpcRequestValidator`. | `InvalidArgument` |
| `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — | | `StreamAlarms` | No required fields — `alarm_filter_prefix` is optional. | — |
| `QueryActiveAlarms` | No required fields — `alarm_filter_prefix` is optional. Not routed through `MxAccessGrpcRequestValidator`; the handler only null-checks the request. | — |
The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error: The payload-vs-kind check matters because the `MxCommand.payload` oneof is non-discriminated on the wire — a misaligned client could send `kind = Write` with a `Register` payload and silently confuse the worker. The validator turns that into a clear client error:
@@ -210,6 +215,10 @@ public WorkerCommand MapCommand(MxCommandRequest request)
} }
``` ```
The command clone above is the only isolating copy on the command path. `WorkerClient.CreateCommandEnvelope` no longer re-clones the `WorkerCommand` into the outbound envelope: the mapper's clone already produced a fresh graph owned by the invoke pipeline, and the envelope is built and owned entirely inside `WorkerClient`, so a second copy would be redundant.
`MapEvent` takes the opposite stance from `MapCommand`: it transfers ownership of the inner `MxEvent` rather than cloning it. The enclosing `WorkerEvent` is parsed fresh from a single pipe frame and the `SessionEventDistributor` pump is its single consumer, so nothing else aliases or mutates it. The pump then shares that one `MxEvent` across every subscriber and the replay ring, but that fan-out is read-only, so a single instance is safe. If a second consumer of the `WorkerEvent` is ever introduced, restore the clone in `MapEvent`.
When the worker reply or event payload is missing, the mapper returns a synthetic public message with `ProtocolStatusCode.ProtocolViolation` (for replies) or a sentinel `MxEvent` with `MxEventFamily.Unspecified` (for events). The gateway never relays a partial frame to clients — anything missing is reported as a protocol violation against the worker, not a transport error against the client. When the worker reply or event payload is missing, the mapper returns a synthetic public message with `ProtocolStatusCode.ProtocolViolation` (for replies) or a sentinel `MxEvent` with `MxEventFamily.Unspecified` (for events). The gateway never relays a partial frame to clients — anything missing is reported as a protocol violation against the worker, not a transport error against the client.
The mapper also exposes static factory methods for every `ProtocolStatusCode` (`Ok`, `InvalidRequest`, `SessionNotFound`, `SessionNotReady`, `WorkerUnavailable`, `Timeout`, `Canceled`, `ProtocolViolation`) so that handlers and tests can produce status payloads without duplicating the enum-to-string mapping. The mapper also exposes static factory methods for every `ProtocolStatusCode` (`Ok`, `InvalidRequest`, `SessionNotFound`, `SessionNotReady`, `WorkerUnavailable`, `Timeout`, `Canceled`, `ProtocolViolation`) so that handlers and tests can produce status payloads without duplicating the enum-to-string mapping.
+3 -1
View File
@@ -451,7 +451,9 @@ Deliverables:
- LDAP bind against `MxGateway:Ldap`, - LDAP bind against `MxGateway:Ldap`,
- LDAP-group → role mapping (`Admin` / `Viewer`) via - LDAP-group → role mapping (`Admin` / `Viewer`) via
`MxGateway:Dashboard:GroupToRole`, `MxGateway:Dashboard:GroupToRole`,
- HTTP-only secure cookie (`__Host-MxGatewayDashboard`), - HTTP-only secure cookie (`__Host-MxGatewayDashboard` when
`RequireHttpsCookie` is true and no `CookieName` override; else
`MxGatewayDashboard`),
- `/hubs/token` bearer mint for SignalR connections, - `/hubs/token` bearer mint for SignalR connections,
- `/logout`, - `/logout`,
- antiforgery protection, - antiforgery protection,
+11 -10
View File
@@ -73,7 +73,7 @@ Observable gauges are pull-based; the `Meter` invokes the supplied callback when
| `mxgateway.sessions.open` | `_openSessions` | Currently open sessions tracked by `SessionManager`. | | `mxgateway.sessions.open` | `_openSessions` | Currently open sessions tracked by `SessionManager`. |
| `mxgateway.workers.running` | `_workersRunning` | Worker clients in a running state. | | `mxgateway.workers.running` | `_workersRunning` | Worker clients in a running state. |
| `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepth` | Last reported depth of the worker-side event queue. | | `mxgateway.events.worker_queue.depth` | `_workerEventQueueDepth` | Last reported depth of the worker-side event queue. |
| `mxgateway.events.grpc_stream_queue.depth` | `_grpcEventStreamQueueDepth` | Backlog held by `EventStreamService` for the active gRPC stream consumer. | | `mxgateway.events.grpc_stream_queue.depth` | `_eventStreamBacklogSources` (summed on demand) | Live backlog buffered across every active `EventStreamService` subscriber, summed from the subscribers' channel `Count` at collection time. |
## Snapshot Shape ## Snapshot Shape
@@ -173,14 +173,13 @@ This is the only fault path where the factory itself owns the kill decision; onc
### gRPC event stream service ### gRPC event stream service
`Grpc/EventStreamService.cs` records the dashboard/client event-stream backlog and disconnect counters: `Grpc/EventStreamService.cs` reports the event-stream backlog through a live backlog source and records the disconnect/overflow counters:
```csharp ```csharp
metrics.AdjustGrpcEventStreamQueueDepth(1); IDisposable backlogRegistration = metrics.RegisterEventStreamBacklogSource(
() => subscriber.Reader.CanCount ? subscriber.Reader.Count : 0);
... ...
metrics.AdjustGrpcEventStreamQueueDepth(-1); backlogRegistration.Dispose();
...
metrics.AdjustGrpcEventStreamQueueDepth(-remainingDepth);
metrics.StreamDisconnected("Detached"); metrics.StreamDisconnected("Detached");
... ...
metrics.QueueOverflow("grpc-event-stream"); metrics.QueueOverflow("grpc-event-stream");
@@ -189,14 +188,16 @@ metrics.Fault(SessionManagerErrorCode.EventQueueOverflow.ToString());
metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString()); metrics.Fault(WorkerClientErrorCode.WorkerFaulted.ToString());
``` ```
The service tracks per-message enqueues and dequeues, so `AdjustGrpcEventStreamQueueDepth` updates the aggregate stream backlog. The `Math.Max(0, ...)` clamp inside the adjuster prevents a negative depth if the bookkeeping ever drifts. Rather than pushing a running total on every streamed event (which took the subscriber channel's `Count` lock and the metric lock per event), each subscriber registers its channel as a backlog source for the duration of the stream. The gauge callback and `GetSnapshot` sum those sources' `Count` on demand — only when the metric is scraped or projected — so the streaming hot path does no per-event gauge bookkeeping (GWC-15). Disposing the registration in the `finally` removes the subscriber's contribution, so the gauge reflects only the remaining subscribers and returns to zero once the last stream detaches. Negative source returns are clamped to zero when summed.
`Grpc/MxAccessGatewayService.cs` records gRPC event send latency around each response-stream write: `Grpc/MxAccessGatewayService.cs` records gRPC event send latency around each response-stream write using the allocation-free timestamp API (no per-event `Stopwatch` object):
```csharp ```csharp
Stopwatch stopwatch = Stopwatch.StartNew(); long sendStartTimestamp = Stopwatch.GetTimestamp();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false); await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed); metrics.RecordEventStreamSend(
publicEvent.Family.ToString(),
Stopwatch.GetElapsedTime(sendStartTimestamp));
``` ```
## Dashboard Consumption ## Dashboard Consumption
+3 -3
View File
@@ -251,7 +251,7 @@ The loop should update a heartbeat timestamp after:
- processing an MXAccess event. - processing an MXAccess event.
`StaRuntime` implements this runtime boundary in the worker. It starts one `StaRuntime` implements this runtime boundary in the worker. It starts one
background thread named `ZB.MOM.WW.MxGateway.Worker.STA`, sets it to `ApartmentState.STA`, background thread named `MxGateway.Worker.STA`, sets it to `ApartmentState.STA`,
initializes COM through `StaComApartmentInitializer`, and runs initializes COM through `StaComApartmentInitializer`, and runs
`StaMessagePump`. Commands are scheduled through `InvokeAsync`; the command `StaMessagePump`. Commands are scheduled through `InvokeAsync`; the command
queue signals an `AutoResetEvent` so `MsgWaitForMultipleObjectsEx` can wake the queue signals an `AutoResetEvent` so `MsgWaitForMultipleObjectsEx` can wake the
@@ -650,8 +650,8 @@ Heartbeat payload includes:
`MxAccessStaSession.CaptureHeartbeat()` reads `StaRuntime.LastActivityUtc` and `MxAccessStaSession.CaptureHeartbeat()` reads `StaRuntime.LastActivityUtc` and
`StaCommandDispatcher` queue state without touching the raw MXAccess COM object `StaCommandDispatcher` queue state without touching the raw MXAccess COM object
outside the STA. Event queue depth and event sequence are reported as zero until outside the STA. Event queue depth (`eventQueue.Count`) and event sequence
the event queue implementation owns those counters. (`eventQueue.LastEventSequence`) are populated from the live event queue.
The STA watchdog currently emits a `WorkerFault` with The STA watchdog currently emits a `WorkerFault` with
`WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than `WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than
+2 -2
View File
@@ -20,13 +20,13 @@ The installed MXAccess interop assembly declares an `Apartment` threading model
## STA Thread Initialization ## STA Thread Initialization
`StaRuntime`'s constructor configures a background `Thread` named `ZB.MOM.WW.MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call: `StaRuntime`'s constructor configures a background `Thread` named `MxGateway.Worker.STA` and forces it into `ApartmentState.STA` before the thread starts. `Start()` releases the thread and then blocks on `startedEvent` so callers observe a fully-initialized apartment (or a captured `startupException`) before the first `InvokeAsync` call:
```csharp ```csharp
staThread = new Thread(ThreadMain) staThread = new Thread(ThreadMain)
{ {
IsBackground = true, IsBackground = true,
Name = "ZB.MOM.WW.MxGateway.Worker.STA" Name = "MxGateway.Worker.STA"
}; };
staThread.SetApartmentState(ApartmentState.STA); staThread.SetApartmentState(ApartmentState.STA);
``` ```
@@ -13,22 +13,23 @@
{"id": 117, "subject": "Task 10: Proto - ReplayGap signal", "status": "completed", "blockedBy": [116]}, {"id": 117, "subject": "Task 10: Proto - ReplayGap signal", "status": "completed", "blockedBy": [116]},
{"id": 118, "subject": "Task 11: Detach-grace session retention", "status": "completed", "blockedBy": [117]}, {"id": 118, "subject": "Task 11: Detach-grace session retention", "status": "completed", "blockedBy": [117]},
{"id": 119, "subject": "Task 12: Replay-on-reconnect + emit ReplayGap", "status": "completed", "blockedBy": [118, 110]}, {"id": 119, "subject": "Task 12: Replay-on-reconnect + emit ReplayGap", "status": "completed", "blockedBy": [118, 110]},
{"id": 120, "subject": "Task 13: Owner re-validation on reconnect", "status": "pending", "blockedBy": [119, 108]}, {"id": 120, "subject": "Task 13: Owner re-validation on reconnect", "status": "completed", "blockedBy": [119, 108], "note": "Shipped as archreview TST-02 (P0): session attach is owner-scoped."},
{"id": 121, "subject": "Task 14: Client ReplayGap handling - all 5 clients", "status": "pending", "blockedBy": [117]}, {"id": 121, "subject": "Task 14: Client ReplayGap handling - all 5 clients", "status": "in_progress", "blockedBy": [117], "note": "Shipped as archreview CLI-15 for .NET/Go/Rust/Python; Java pending (windev batch)."},
{"id": 122, "subject": "Task 15: Reconnect integration test (fake worker)", "status": "pending", "blockedBy": [119]}, {"id": 122, "subject": "Task 15: Reconnect integration test (fake worker)", "status": "completed", "blockedBy": [119], "note": "Shipped as archreview TST-01: GatewayEndToEndReconnectReplayTests."},
{"id": 123, "subject": "Task 16: gRPC session-owner gate + all-sessions admin scope", "status": "pending", "blockedBy": [116, 108]}, {"id": 123, "subject": "Task 16: gRPC session-owner gate + all-sessions admin scope", "status": "pending", "blockedBy": [116, 108], "note": "gRPC owner gate exists (TST-02); Phase 4 adds admin all-sessions scope. Tracked as TST-15."},
{"id": 124, "subject": "Task 17: Session Tag + dashboard group-to-tag config", "status": "pending", "blockedBy": [116]}, {"id": 124, "subject": "Task 17: Session Tag + dashboard group-to-tag config", "status": "pending", "blockedBy": [116], "note": "Phase 4 / TST-15."},
{"id": 125, "subject": "Task 18: EventsHub per-session ACL + hub-token tag claim", "status": "pending", "blockedBy": [124]}, {"id": 125, "subject": "Task 18: EventsHub per-session ACL + hub-token tag claim", "status": "pending", "blockedBy": [124], "note": "Phase 4 / TST-15. Decision settled: admin-sees-all, Viewer strict per owned/granted session."},
{"id": 126, "subject": "Task 19: ACL tests incl. live LDAP users", "status": "pending", "blockedBy": [125]}, {"id": 126, "subject": "Task 19: ACL tests incl. live LDAP users", "status": "pending", "blockedBy": [125], "note": "Phase 4 / TST-15."},
{"id": 127, "subject": "Task 20: Stable gateway-instance id + stable pipe naming", "status": "pending", "blockedBy": [126]}, {"id": 127, "subject": "Task 20: Stable gateway-instance id + stable pipe naming", "status": "deferred", "blockedBy": [126], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 128, "subject": "Task 21: Adoption manifest store (SQLite)", "status": "pending", "blockedBy": [127]}, {"id": 128, "subject": "Task 21: Adoption manifest store (SQLite)", "status": "deferred", "blockedBy": [127], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 129, "subject": "Task 22: Proto - worker adopt/reconnect frame", "status": "pending", "blockedBy": [128]}, {"id": 129, "subject": "Task 22: Proto - worker adopt/reconnect frame", "status": "deferred", "blockedBy": [128], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 130, "subject": "Task 23: Worker phone-home reconnect loop + self-terminate", "status": "pending", "blockedBy": [129]}, {"id": 130, "subject": "Task 23: Worker phone-home reconnect loop + self-terminate", "status": "deferred", "blockedBy": [129], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 131, "subject": "Task 24: Gateway adoption - re-open pipes, nonce-validate, reject impostors", "status": "pending", "blockedBy": [130]}, {"id": 131, "subject": "Task 24: Gateway adoption - re-open pipes, nonce-validate, reject impostors", "status": "deferred", "blockedBy": [130], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 132, "subject": "Task 25: Resync adopted worker + ReplayGap to subscribers", "status": "pending", "blockedBy": [131, 119]}, {"id": 132, "subject": "Task 25: Resync adopted worker + ReplayGap to subscribers", "status": "deferred", "blockedBy": [131, 119], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 133, "subject": "Task 26: EnableOrphanReattach flag (default off) + terminator fallback", "status": "pending", "blockedBy": [131]}, {"id": 133, "subject": "Task 26: EnableOrphanReattach flag (default off) + terminator fallback", "status": "deferred", "blockedBy": [131], "note": "Phase 5 deferred, not planned (TST-04). The EnableOrphanReattach flag does not exist yet."},
{"id": 134, "subject": "Task 27: Gateway-restart reattach round-trip (WINDEV + live worker)", "status": "pending", "blockedBy": [132, 133]}, {"id": 134, "subject": "Task 27: Gateway-restart reattach round-trip (WINDEV + live worker)", "status": "deferred", "blockedBy": [132, 133], "note": "Phase 5 deferred, not planned (TST-04)."},
{"id": 135, "subject": "Task 28: Documented-rule reversals + stillpending refresh", "status": "pending", "blockedBy": [134]} {"id": 135, "subject": "Task 28: Documented-rule reversals + stillpending refresh", "status": "deferred", "blockedBy": [134], "note": "Phase 5 deferred, not planned (TST-04)."}
], ],
"lastUpdated": "2026-06-15" "governance": "2026-07-09 (archreview TST-04): Phase 3 finished (Task 13=TST-02, Task 15=TST-01, Task 14=CLI-15 4/5, Java pending). Phase 4 (Tasks 16-19) scoped as TST-15, Viewer decision settled (admin-sees-all + Viewer strict). Phase 5 (Tasks 20-28) DEFERRED, not planned; EnableOrphanReattach does not exist.",
"lastUpdated": "2026-07-09"
} }
+69 -39
View File
@@ -219,7 +219,12 @@ Dashboard authentication is LDAP-backed (distinct from the API-key model on
the gRPC API). `/login` accepts username and password in a form body, binds the gRPC API). `/login` accepts username and password in a form body, binds
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer` against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure via `MxGateway:Dashboard:GroupToRole`, and issues an HTTP-only secure
`__Host-MxGatewayDashboard` cookie. `/logout` clears it. Login and logout cookie. The cookie is named `__Host-MxGatewayDashboard` when
`MxGateway:Dashboard:RequireHttpsCookie` is true (the default, forcing Secure)
and no `MxGateway:Dashboard:CookieName` override is set; otherwise it is named
`MxGatewayDashboard` (the `__Host-` prefix is only valid on a Secure cookie, so
it is dropped for HTTP-dev or custom-name deployments). `/logout` clears it.
Login and logout
posts validate antiforgery tokens. SignalR hub connections accept either the posts validate antiforgery tokens. SignalR hub connections accept either the
cookie or a 30-minute data-protected bearer minted at `/hubs/token`. cookie or a 30-minute data-protected bearer minted at `/hubs/token`.
`MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the `MxGateway:Dashboard:AllowAnonymousLocalhost` permits loopback to bypass the
@@ -297,43 +302,46 @@ Pipe security:
### Worker Envelope ### Worker Envelope
Every IPC message uses a common envelope: Every IPC message uses a common `WorkerEnvelope`. The authoritative definition
lives in `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto` — treat
that file as the single source of truth and do not hand-copy the message here,
because an inlined copy drifts. The shape below describes the current contract so
this document explains *why* the envelope is structured the way it is.
```protobuf The envelope header carries four fields: `protocol_version`, `session_id`, a
message WorkerEnvelope { `uint64 sequence`, and a **`string correlation_id`** (an opaque id, not a
uint32 protocol_version = 1; numeric one). The `body` is a `oneof` whose arms are the handshake and traffic
string session_id = 2; messages, tagged from 10 upward:
uint64 sequence = 3;
uint64 correlation_id = 4; - `gateway_hello` / `worker_hello` — the startup handshake pair.
oneof body { - `worker_ready` — the worker signalling its MXAccess COM instance is live.
WorkerHello worker_hello = 10; - `worker_command` / `worker_command_reply` — a command and its correlated reply.
GatewayHello gateway_hello = 11; - `worker_cancel` — best-effort cancellation of an in-flight command.
WorkerReady worker_ready = 12; - `worker_shutdown` / `worker_shutdown_ack` — graceful shutdown request and its
WorkerCommand command = 20; acknowledgement.
WorkerCommandReply command_reply = 21; - `worker_event` — a converted MXAccess event.
WorkerEvent event = 22; - `worker_heartbeat` — periodic worker liveness/state.
WorkerHeartbeat heartbeat = 23; - `worker_fault` — a terminal worker fault report.
WorkerCancel cancel = 24;
WorkerShutdown shutdown = 25;
WorkerFault fault = 26;
}
}
```
Rules: Rules:
- `sequence` is monotonic per sender. - `sequence` is a monotonic per-sender counter used as a diagnostic aid; it is
- `correlation_id` links commands to replies. not validated for gaps or ordering on receive (the named pipe already
- Events use their own correlation id or zero. guarantees FIFO delivery).
- `correlation_id` links a command to its reply; it is authoritative on the
envelope, and the inner `MxCommandReply.correlation_id` echoes it for
MXAccess parity.
- Events carry their own correlation id or leave it empty.
- Replies must preserve MXAccess HRESULT/status information even when the - Replies must preserve MXAccess HRESULT/status information even when the
command is also represented as a protocol-level failure. command is also represented as a protocol-level failure.
- Protocol version mismatch fails session creation. - Protocol version mismatch fails session creation.
## Public gRPC API ## Public gRPC API
The external API should be session-oriented. A bidirectional stream is the best The external API is session-oriented. The shipped service is unary plus
long-term shape because it naturally carries commands, replies, events, server-streaming; the authoritative definition is
heartbeats, and cancellation. `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto`. As built, the
`MxAccessGateway` service exposes seven RPCs:
```protobuf ```protobuf
service MxAccessGateway { service MxAccessGateway {
@@ -341,19 +349,34 @@ service MxAccessGateway {
rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply); rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
rpc Invoke(MxCommandRequest) returns (MxCommandReply); rpc Invoke(MxCommandRequest) returns (MxCommandReply);
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent); rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
rpc Session(stream ClientMessage) returns (stream ServerMessage); rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
rpc StreamAlarms(StreamAlarmsRequest) returns (stream AlarmFeedMessage);
rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
} }
``` ```
Recommended rollout: `OpenSession`, `CloseSession`, and `Invoke` are unary; `StreamEvents`,
`StreamAlarms`, and `QueryActiveAlarms` are server-streaming. `AcknowledgeAlarm`,
`StreamAlarms`, and `QueryActiveAlarms` are session-less — they route to the
gateway's always-on central alarm monitor rather than a client worker session.
The unary-plus-event-stream shape is easier to debug and reason about than a
single multiplexed channel.
1. Implement unary `OpenSession`, `CloseSession`, and `Invoke`. ### Future work: bidirectional `Session` (not implemented)
2. Implement server-streaming `StreamEvents`.
3. Add bidirectional `Session` after the command/event model is stable.
The unary plus event-stream shape is easier to debug initially. The A single bidirectional `Session` stream carrying commands, replies, events,
bidirectional stream can later reduce per-command overhead and improve heartbeats, and cancellation was considered as a possible long-term shape:
backpressure.
```protobuf
// Not implemented — design sketch only.
rpc Session(stream ClientMessage) returns (stream ServerMessage);
```
It is **not part of the shipped contract** and there are no `ClientMessage` /
`ServerMessage` types in `mxaccess_gateway.proto`. A bidirectional stream could
later reduce per-command overhead and improve backpressure, but it would only be
added after the command/event model is stable and if a concrete requirement
appears.
## Public MXAccess Command Surface ## Public MXAccess Command Surface
@@ -750,8 +773,10 @@ Gateway policy:
- one event sequencer per session, - one event sequencer per session,
- preserve per-session event order, - preserve per-session event order,
- allow one active client event subscriber per session, - allow one active client event subscriber per session **by default**, rejecting
- reject a second subscriber with a clear session error, a second subscriber with a clear session error; multi-subscriber fan-out is
config-gated (`AllowMultipleEventSubscribers`, off by default) — see the
fan-out and reconnect detail in [Sessions](docs/Sessions.md),
- use a bounded `EventStreamService` queue between worker events and gRPC - use a bounded `EventStreamService` queue between worker events and gRPC
writes, writes,
- fault the session when the bounded stream queue overflows, - fault the session when the bounded stream queue overflows,
@@ -859,7 +884,12 @@ Baseline choices:
Optimizations after parity: Optimizations after parity:
- batch commands where MXAccess semantics allow, - batch commands where MXAccess semantics allow,
- batch events from worker to gateway while preserving order, - batch events from worker to gateway while preserving order. Implemented so
far: the worker frame writer coalesces the *flush* across a drained batch of
event frames, so a burst costs one flush syscall rather than one per event
(WRK-12). Still not implemented: a multi-event `WorkerEnvelope` body that
packs several events into a single frame — the wire still carries one event
per `worker_event` frame (IPC-15), so this remains an additive-proto change,
- optional data-change coalescing by item handle, - optional data-change coalescing by item handle,
- memory-mapped payload slabs for very large arrays, - memory-mapped payload slabs for very large arrays,
- shared schema for typed values to avoid raw COM marshaling at the gateway, - shared schema for typed values to avoid raw COM marshaling at the gateway,
+48 -21
View File
@@ -15,8 +15,27 @@ The authoritative resume state lives in
## Status ## Status
**12 of 28 tasks complete** (Phases 12 + reconnect core of Phase 3). All completed **Governance update 2026-07-09 (archreview TST-04).** The epic is resolved into three
work is merged to `main` (commit `c446bef`, pushed to origin). decisions rather than one open backlog:
- **Phase 3 (reconnect) — finishing now, essentially complete.** Task 13 (owner
re-validation) shipped as archreview **TST-02** (P0, session attach is owner-scoped,
see CLAUDE.md Authentication). Task 15 (reconnect integration test) shipped as
**TST-01** (`GatewayEndToEndReconnectReplayTests`). Task 14 (client `ReplayGap`
handling) shipped as **CLI-15** for four of five clients (.NET/Go/Rust/Python);
the Java client is the only remainder, batched to the windev build host.
- **Phase 4 (per-session dashboard ACL) — scoped, not yet built.** Tracked as archreview
**TST-15**. The previously-open Viewer-default decision is **settled**: admin-sees-all,
Viewer strictly scoped to sessions it owns/is granted — matching TST-02's gRPC owner
binding for consistency.
- **Phase 5 (orphan-worker reattach) — DEFERRED, not planned.** It reverses the CLAUDE.md
invariant "Gateway restart does not reattach orphan workers" and adds an adoption manifest
store + worker phone-home protocol. It stays deferred unless a concrete requirement
appears. **The `EnableOrphanReattach` flag (Task 26) does not exist and must not be
referenced anywhere as if it does** until that task actually lands.
Original snapshot (historical): **12 of 28 tasks complete** (Phases 12 + reconnect core of
Phase 3), merged to `main` (commit `c446bef`).
### Completed — Phase 1 (Foundation) ### Completed — Phase 1 (Foundation)
- ✅ Task 1 (#108): Add OwnerKeyId to the session - ✅ Task 1 (#108): Add OwnerKeyId to the session
@@ -36,33 +55,41 @@ work is merged to `main` (commit `c446bef`, pushed to origin).
- ✅ Task 11 (#118): Detach-grace session retention - ✅ Task 11 (#118): Detach-grace session retention
- ✅ Task 12 (#119): Replay-on-reconnect + emit ReplayGap - ✅ Task 12 (#119): Replay-on-reconnect + emit ReplayGap
### Pending — Phase 3 finish ### Phase 3 finish — DONE (via archreview P0/P2)
- Task 13 (#120): Owner re-validation on reconnect — blockedBy 12, 1 - Task 13 (#120): Owner re-validation on reconnect — shipped as **TST-02** (P0).
- Task 14 (#121): Client ReplayGap handling — all 5 clients — blockedBy 10 - 🔄 Task 14 (#121): Client ReplayGap handling — shipped as **CLI-15** for 4/5 clients
- Carry the per-language presence-check idiom note for `optional` message fields. (.NET/Go/Rust/Python); Java pending (windev batch). Per-language presence-check idiom
- ⏳ Task 15 (#122): Reconnect integration test (fake worker) — blockedBy 12 for `optional` message fields carried in each client's surface.
- ✅ Task 15 (#122): Reconnect integration test (fake worker) — shipped as **TST-01**.
### Pending — Phase 4 (Per-session dashboard ACL) ### Phase 4 (Per-session dashboard ACL) — SCOPED, tracked as archreview TST-15
- ⏳ Task 16 (#123): gRPC session-owner gate + all-sessions admin scope — blockedBy 9, 1 - ⏳ Task 16 (#123): gRPC session-owner gate + all-sessions admin scope — blockedBy 9, 1
- Note: the gRPC owner gate itself already exists (TST-02); Phase 4 adds the admin
all-sessions scope + the dashboard-side twin.
- ⏳ Task 17 (#124): Session Tag + dashboard group-to-tag config — blockedBy 9 - ⏳ Task 17 (#124): Session Tag + dashboard group-to-tag config — blockedBy 9
- ⏳ Task 18 (#125): EventsHub per-session ACL + hub-token tag claim — blockedBy 17 - ⏳ Task 18 (#125): EventsHub per-session ACL + hub-token tag claim — blockedBy 17
- Open decision: Viewer default (admin-sees-all vs strict per-session). - Decision SETTLED: admin-sees-all, Viewer strictly scoped to owned/granted sessions
(matches TST-02 gRPC owner binding).
- ⏳ Task 19 (#126): ACL tests incl. live LDAP users — blockedBy 18 - ⏳ Task 19 (#126): ACL tests incl. live LDAP users — blockedBy 18
### Pending — Phase 5 (Orphan-worker reattach) ### Phase 5 (Orphan-worker reattach) — DEFERRED, NOT PLANNED
- ⏳ Task 20 (#127): Stable gateway-instance id + stable pipe naming — blockedBy 19 Deferred unless a concrete requirement appears. It reverses the CLAUDE.md invariant
- ⏳ Task 21 (#128): Adoption manifest store (SQLite) — blockedBy 20 "Gateway restart does not reattach orphan workers" and adds an adoption manifest store +
- ⏳ Task 22 (#129): Proto — worker adopt/reconnect frame — blockedBy 21 worker phone-home protocol. **`EnableOrphanReattach` (Task 26) does not exist** — do not
- ⏳ Task 23 (#130): Worker phone-home reconnect loop + self-terminate — blockedBy 22 (net48/x86, windev) reference it as if it does until the task lands.
- Task 24 (#131): Gateway adoption — re-open pipes, nonce-validate, reject impostors — blockedBy 23 - 🚫 Task 20 (#127): Stable gateway-instance id + stable pipe naming
- Task 25 (#132): Resync adopted worker + ReplayGap to subscribers — blockedBy 24, 12 - 🚫 Task 21 (#128): Adoption manifest store (SQLite)
- Task 26 (#133): EnableOrphanReattach flag (default off) + terminator fallback — blockedBy 24 - 🚫 Task 22 (#129): Proto — worker adopt/reconnect frame
- Task 27 (#134): Gateway-restart reattach round-trip (WINDEV + live worker) — blockedBy 25, 26 - 🚫 Task 23 (#130): Worker phone-home reconnect loop + self-terminate (net48/x86, windev)
- Task 28 (#135): Documented-rule reversals + stillpending refresh — blockedBy 27 - 🚫 Task 24 (#131): Gateway adoption — re-open pipes, nonce-validate, reject impostors
- 🚫 Task 25 (#132): Resync adopted worker + ReplayGap to subscribers
- 🚫 Task 26 (#133): EnableOrphanReattach flag (default off) + terminator fallback
- 🚫 Task 27 (#134): Gateway-restart reattach round-trip (WINDEV + live worker)
- 🚫 Task 28 (#135): Documented-rule reversals + stillpending refresh
## Notes ## Notes
- Phase 5 reverses the documented "Gateway restart does not reattach orphan workers" - Phase 5 was designed to reverse the "Gateway restart does not reattach orphan workers"
rule (CLAUDE.md) — this was explicitly approved during design. rule (CLAUDE.md), but is now **deferred, not planned** (TST-04) — the invariant stands.
- Two deferred follow-ups noted earlier: dashboard visibility of `DetachedAtUtc` on - Two deferred follow-ups noted earlier: dashboard visibility of `DetachedAtUtc` on
`DashboardSessionSummary`. `DashboardSessionSummary`.
- Worker (net48/x86) tasks build/test on windev; everything else builds on macOS. - Worker (net48/x86) tasks build/test on windev; everything else builds on macOS.
+1 -3
View File
@@ -52,9 +52,7 @@ try {
$failures.Add('Contracts project failed to build during codegen check.') $failures.Add('Contracts project failed to build during codegen check.')
} }
# Pipe through Out-String so a clean tree (git emits nothing -> $null) does not throw $diff = (& git -C $repoRoot status --porcelain -- 'src/ZB.MOM.WW.MxGateway.Contracts/Generated').Trim()
# "cannot call a method on a null-valued expression"; Out-String yields '' for no output.
$diff = (& git -C $repoRoot status --porcelain -- 'src/ZB.MOM.WW.MxGateway.Contracts/Generated' | Out-String).Trim()
if (-not [string]::IsNullOrEmpty($diff)) { if (-not [string]::IsNullOrEmpty($diff)) {
Write-Host $diff Write-Host $diff
$failures.Add('Contracts/Generated differs from a fresh regeneration. Run `dotnet build` on the contracts project and commit Generated/ (required for the net48 worker build).') $failures.Add('Contracts/Generated differs from a fresh regeneration. Run `dotnet build` on the contracts project and commit Generated/ (required for the net48 worker build).')
+25
View File
@@ -9,6 +9,31 @@
<Deterministic>true</Deterministic> <Deterministic>true</Deterministic>
</PropertyGroup> </PropertyGroup>
<!-- TST-11: single-source the .NET-side version for Server, Worker, Contracts, and tests
(they otherwise stamp the SDK default 1.0.0, so a deployed gateway cannot be correlated
to a release). Kept at 0.1.2 to match the Contracts package and the aligned Python/Rust/
Go clients; the Java client leads at 0.2.0 after its JDK-17 retarget. The git short SHA is
appended to InformationalVersion (0.1.2+<sha>) so support can map a running binary to a
commit; the query is guarded so a build outside a git checkout still succeeds. -->
<PropertyGroup>
<Version>0.1.2</Version>
</PropertyGroup>
<Target Name="StampSourceRevision"
BeforeTargets="GetAssemblyVersion;GenerateAssemblyInfo"
Condition="'$(SourceRevisionId)' == ''">
<Exec Command="git -C &quot;$(MSBuildThisFileDirectory)&quot; rev-parse --short HEAD"
ConsoleToMSBuild="true"
StandardOutputImportance="Low"
ContinueOnError="true"
IgnoreExitCode="true">
<Output TaskParameter="ConsoleOutput" PropertyName="_StampedGitSha" />
</Exec>
<PropertyGroup>
<SourceRevisionId Condition="'$(_StampedGitSha)' != ''">$(_StampedGitSha.Trim())</SourceRevisionId>
</PropertyGroup>
</Target>
<!-- SQLitePCLRaw.lib.e_sqlite3 2.1.11 (transitive via Microsoft.Data.Sqlite) carries GHSA-2m69-gcr7-jv3q, <!-- SQLitePCLRaw.lib.e_sqlite3 2.1.11 (transitive via Microsoft.Data.Sqlite) carries GHSA-2m69-gcr7-jv3q,
which surfaces as NU1903 (warning-as-error). No patched e_sqlite3 release exists yet (2.1.11 is latest), which surfaces as NU1903 (warning-as-error). No patched e_sqlite3 release exists yet (2.1.11 is latest),
so this targeted suppression keeps every OTHER transitive package audited. Remove once an upstream fix ships. --> so this targeted suppression keeps every OTHER transitive package audited. Remove once an upstream fix ships. -->
@@ -228,7 +228,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// Consume mapped MxEvents through the session's single distributor pump (as an // Consume mapped MxEvents through the session's single distributor pump (as an
// internal, non-counted subscriber) rather than opening a second raw drain of the // internal, non-counted subscriber) rather than opening a second raw drain of the
// worker event channel — a second drain would split events with the dashboard // worker event channel — a second drain would split events with the dashboard
// mirror pump and silently lose Acknowledge/mode-change transitions. // mirror pump and silently lose Acknowledge/mode-change transitions (GWC-01).
await foreach (MxEvent mxEvent in _sessionManager await foreach (MxEvent mxEvent in _sessionManager
.ReadAlarmEventsAsync(session.SessionId, linked.Token) .ReadAlarmEventsAsync(session.SessionId, linked.Token)
.ConfigureAwait(false)) .ConfigureAwait(false))
@@ -15,8 +15,8 @@ public static class GatewayConfigurationServiceCollectionExtensions
public static IServiceCollection AddGatewayConfiguration( public static IServiceCollection AddGatewayConfiguration(
this IServiceCollection services, IConfiguration configuration) this IServiceCollection services, IConfiguration configuration)
{ {
// GatewayOptionsValidator depends on IHostEnvironment to gate the production-only rules. // GatewayOptionsValidator depends on IHostEnvironment to gate the production-only rules
// The real host and the apikey CLI both build through WebApplicationBuilder, // (SEC-04/06). The real host and the apikey CLI both build through WebApplicationBuilder,
// which registers IHostEnvironment before this call — so TryAdd is a no-op there. Minimal // which registers IHostEnvironment before this call — so TryAdd is a no-op there. Minimal
// containers (unit tests, tooling) have none; fall back to a non-production environment so // containers (unit tests, tooling) have none; fall back to a non-production environment so
// the validator still activates and the production guards stay off outside a real // the validator still activates and the production guards stay off outside a real
@@ -37,16 +37,12 @@ public static class GatewayConfigurationServiceCollectionExtensions
/// </summary> /// </summary>
private sealed class FallbackHostEnvironment : IHostEnvironment private sealed class FallbackHostEnvironment : IHostEnvironment
{ {
/// <summary>Gets or sets the name of the environment; defaults to <see cref="Environments.Development"/>.</summary>
public string EnvironmentName { get; set; } = Environments.Development; public string EnvironmentName { get; set; } = Environments.Development;
/// <summary>Gets or sets the name of the application.</summary>
public string ApplicationName { get; set; } = typeof(FallbackHostEnvironment).Assembly.GetName().Name ?? "MxGateway"; public string ApplicationName { get; set; } = typeof(FallbackHostEnvironment).Assembly.GetName().Name ?? "MxGateway";
/// <summary>Gets or sets the absolute path to the content root directory.</summary>
public string ContentRootPath { get; set; } = AppContext.BaseDirectory; public string ContentRootPath { get; set; } = AppContext.BaseDirectory;
/// <summary>Gets or sets the file provider for the content root.</summary>
public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider(); public IFileProvider ContentRootFileProvider { get; set; } = new NullFileProvider();
} }
} }
@@ -470,7 +470,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
// The worker-frame (pipe) maximum must stay above the public gRPC cap by the envelope-overhead // The worker-frame (pipe) maximum must stay above the public gRPC cap by the envelope-overhead
// reserve, otherwise a maximally-sized accepted gRPC payload does not fit one worker frame once // reserve, otherwise a maximally-sized accepted gRPC payload does not fit one worker frame once
// wrapped in a WorkerEnvelope and the outbound write faults the whole session. Fail fast // wrapped in a WorkerEnvelope and the outbound write faults the whole session (IPC-03). Fail fast
// at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the // at startup rather than mid-traffic. Only checked when both knobs are themselves in range so the
// message is not doubled up with the individual range errors. // message is not doubled up with the individual range errors.
private static void ValidateFrameSizeHeadroom( private static void ValidateFrameSizeHeadroom(
@@ -2,8 +2,8 @@ namespace ZB.MOM.WW.MxGateway.Server.Configuration;
/// <summary> /// <summary>
/// Security hot-path options bound from <c>MxGateway:Security</c>. Groups the API-key verification /// Security hot-path options bound from <c>MxGateway:Security</c>. Groups the API-key verification
/// cache and <c>last_used</c> write-coalescing knobs with the login and API-key /// cache and <c>last_used</c> write-coalescing knobs (SEC-08) with the login and API-key
/// rate-limiting knobs. Defaults preserve safe, low-overhead behaviour without requiring /// rate-limiting knobs (SEC-11). Defaults preserve safe, low-overhead behaviour without requiring
/// operators to configure anything. /// operators to configure anything.
/// </summary> /// </summary>
public sealed class SecurityOptions public sealed class SecurityOptions
@@ -37,8 +37,8 @@ public sealed class WorkerOptions
/// The maximum worker-frame (pipe) message size in bytes. Must stay at least /// The maximum worker-frame (pipe) message size in bytes. Must stay at least
/// <see cref="Workers.WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes"/> above /// <see cref="Workers.WorkerFrameProtocolOptions.EnvelopeOverheadReserveBytes"/> above
/// <see cref="ProtocolOptions.MaxGrpcMessageBytes"/> so a maximally-sized accepted gRPC payload /// <see cref="ProtocolOptions.MaxGrpcMessageBytes"/> so a maximally-sized accepted gRPC payload
/// still fits one worker frame; the gateway conveys this value to the worker in the /// still fits one worker frame (IPC-03); the gateway conveys this value to the worker in the
/// handshake (<c>GatewayHello.max_frame_bytes</c>). Default is the 16 MB public gRPC cap /// handshake (<c>GatewayHello.max_frame_bytes</c>, IPC-02). Default is the 16 MB public gRPC cap
/// plus that reserve. /// plus that reserve.
/// </summary> /// </summary>
public int MaxMessageBytes { get; init; } = public int MaxMessageBytes { get; init; } =
@@ -101,6 +101,8 @@ public sealed class DashboardApiKeyManagementService(
.RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .RevokeKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// SEC-08: drop any cached verification for this key in THIS gateway process so the revoke
// takes effect immediately rather than after the verification-cache TTL elapses.
cacheInvalidator?.Invalidate(normalizedKeyId); cacheInvalidator?.Invalidate(normalizedKeyId);
await WriteDashboardAuditAsync( await WriteDashboardAuditAsync(
@@ -141,6 +143,8 @@ public sealed class DashboardApiKeyManagementService(
.RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken) .RotateKeyAsync(normalizedKeyId, RemoteAddress(), cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// SEC-08: the old secret's cached verification must not outlive the rotate — evict it so
// the superseded secret stops authenticating within this process immediately.
cacheInvalidator?.Invalidate(normalizedKeyId); cacheInvalidator?.Invalidate(normalizedKeyId);
bool succeeded = rotated.Token is not null; bool succeeded = rotated.Token is not null;
@@ -187,6 +191,8 @@ public sealed class DashboardApiKeyManagementService(
.DeleteAsync(normalizedKeyId, cancellationToken) .DeleteAsync(normalizedKeyId, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// SEC-08: a deleted key is only reachable after a prior revoke, but evict defensively so no
// cached verification survives the delete.
cacheInvalidator?.Invalidate(normalizedKeyId); cacheInvalidator?.Invalidate(normalizedKeyId);
await WriteDashboardAuditAsync( await WriteDashboardAuditAsync(
@@ -35,5 +35,23 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group"; public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix"; public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary>
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
/// or when an explicit <c>MxGateway:Dashboard:CookieName</c> override is absent but the
/// secure default cannot be applied. This plain name carries no browser-enforced guarantees.
/// </summary>
public const string CookieName = "MxGatewayDashboard"; public const string CookieName = "MxGatewayDashboard";
/// <summary>
/// Dashboard auth cookie name applied when the cookie is guaranteed Secure
/// (<c>RequireHttpsCookie=true</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/>)
/// and no explicit <c>MxGateway:Dashboard:CookieName</c> override is set. The <c>__Host-</c>
/// prefix instructs browsers to enforce Secure, no <c>Domain</c>, and <c>Path=/</c>; those
/// guarantees only hold for a Secure cookie, so this name must never be applied unless
/// <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.Always"/> is in
/// effect — a <c>__Host-</c> cookie without Secure is silently dropped by browsers.
/// </summary>
public const string SecureCookieName = "__Host-MxGatewayDashboard";
} }
@@ -12,7 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
public static class DashboardEndpointRouteBuilderExtensions public static class DashboardEndpointRouteBuilderExtensions
{ {
/// <summary> /// <summary>
/// The named rate-limiter policy applied to the <c>POST /auth/login</c> route. Registered /// The named rate-limiter policy applied to the <c>POST /auth/login</c> route (SEC-11). Registered
/// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>. /// in <c>GatewayApplication</c> via <see cref="GetLoginRateLimitPartition"/>.
/// </summary> /// </summary>
internal const string LoginRateLimiterPolicy = "dashboard-login"; internal const string LoginRateLimiterPolicy = "dashboard-login";
@@ -40,12 +40,8 @@ public static class DashboardEndpointRouteBuilderExtensions
}); });
} }
/// <summary> // Test seam: a standalone partitioned limiter over the production partition logic, so a test can
/// Test seam: builds a standalone partitioned limiter over the production partition logic, so a // acquire leases and assert the (permit+1)th is rejected without an HTTP server.
/// test can acquire leases and assert the (permit+1)th is rejected without an HTTP server.
/// </summary>
/// <param name="security">The bound security options carrying the login-limit knobs.</param>
/// <returns>A partitioned rate limiter keyed the same way as the production login policy.</returns>
internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security) internal static PartitionedRateLimiter<HttpContext> CreateLoginRateLimiter(SecurityOptions security)
=> PartitionedRateLimiter.Create<HttpContext, string>( => PartitionedRateLimiter.Create<HttpContext, string>(
ctx => GetLoginRateLimitPartition(ctx, security)); ctx => GetLoginRateLimitPartition(ctx, security));
@@ -80,6 +76,7 @@ public static class DashboardEndpointRouteBuilderExtensions
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) => (HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
PostLoginAsync(httpContext, antiforgery, authenticator)) PostLoginAsync(httpContext, antiforgery, authenticator))
.AllowAnonymous() .AllowAnonymous()
// SEC-11: throttle credential stuffing before the LDAP bind is relayed to the directory.
.RequireRateLimiting(LoginRateLimiterPolicy) .RequireRateLimiting(LoginRateLimiterPolicy)
.WithName("DashboardLoginPost"); .WithName("DashboardLoginPost");
@@ -123,13 +123,22 @@ public static class DashboardServiceCollectionExtensions
? CookieSecurePolicy.Always ? CookieSecurePolicy.Always
: CookieSecurePolicy.SameAsRequest; : CookieSecurePolicy.SameAsRequest;
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps // Config-driven cookie name (MxGateway:Dashboard:CookieName). An explicit override
// the canonical default set above, so a misconfiguration cannot unname the cookie. // always wins. With no override, restore the __Host- prefix when the cookie is
// guaranteed Secure (RequireHttpsCookie true → SecurePolicy Always): the __Host-
// browser guarantees (Secure required, no Domain, Path=/) hold only for a Secure
// cookie, and a __Host- cookie without Secure is silently dropped — so the prefix
// is never applied unless SecurePolicy is Always. Otherwise keep the plain
// canonical default set by AddCookie above, so a misconfiguration cannot unname it.
var cookieName = gatewayOptions.Value.Dashboard.CookieName; var cookieName = gatewayOptions.Value.Dashboard.CookieName;
if (!string.IsNullOrWhiteSpace(cookieName)) if (!string.IsNullOrWhiteSpace(cookieName))
{ {
cookieOptions.Cookie.Name = cookieName; cookieOptions.Cookie.Name = cookieName;
} }
else if (cookieOptions.Cookie.SecurePolicy == CookieSecurePolicy.Always)
{
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.SecureCookieName;
}
}); });
services.AddAuthorization(authorization => services.AddAuthorization(authorization =>
@@ -33,7 +33,7 @@ public sealed class HubTokenService
// bounds how long a stale role set survives a role change. Five minutes is transparent to // bounds how long a stale role set survives a role change. Five minutes is transparent to
// clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect; // clients because DashboardHubConnectionFactory mints a fresh token on every (re)connect;
// see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately // see docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation is deliberately
// deferred until per-session hub ACLs land, when tokens gain session binding. // deferred until per-session hub ACLs land (SEC-25), when tokens gain session binding.
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5); internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
private readonly ITimeLimitedDataProtector _protector; private readonly ITimeLimitedDataProtector _protector;
@@ -1,5 +1,7 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs; namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
@@ -10,10 +12,22 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// Errors are logged once and dropped — keeping the SignalR mirror best-effort /// Errors are logged once and dropped — keeping the SignalR mirror best-effort
/// preserves the gRPC contract that exists today. /// preserves the gRPC contract that exists today.
/// </summary> /// </summary>
/// <remarks>
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), tag
/// values are stripped from a redacted copy of the event before it reaches any
/// dashboard client. The source <see cref="MxEvent"/> is shared with the gRPC
/// event path and the reconnect replay ring, so it is never mutated in place —
/// the redaction is applied to a deep clone. This closes the value-leak seam at
/// the mirror independently of the still-outstanding per-session hub ACL
/// (see <see cref="EventsHub"/>).
/// </remarks>
public sealed class DashboardEventBroadcaster( public sealed class DashboardEventBroadcaster(
IHubContext<EventsHub> hubContext, IHubContext<EventsHub> hubContext,
IOptions<GatewayOptions> options,
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
{ {
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
/// <inheritdoc /> /// <inheritdoc />
public void Publish(string sessionId, MxEvent mxEvent) public void Publish(string sessionId, MxEvent mxEvent)
{ {
@@ -22,6 +36,8 @@ public sealed class DashboardEventBroadcaster(
return; return;
} }
MxEvent outbound = _showTagValues ? mxEvent : RedactValues(mxEvent);
// Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw // Wrap the Task acquisition in a try/catch so a hypothetical synchronous throw
// from SendAsync (e.g. an implementation that throws before returning the Task) // from SendAsync (e.g. an implementation that throws before returning the Task)
// cannot escape Publish. The interface contract is never-throw; fire-and-forget. // cannot escape Publish. The interface contract is never-throw; fire-and-forget.
@@ -30,7 +46,7 @@ public sealed class DashboardEventBroadcaster(
{ {
send = hubContext.Clients send = hubContext.Clients
.Group(EventsHub.GroupName(sessionId)) .Group(EventsHub.GroupName(sessionId))
.SendAsync(EventsHub.EventMessage, mxEvent); .SendAsync(EventsHub.EventMessage, outbound);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -51,4 +67,27 @@ public sealed class DashboardEventBroadcaster(
TaskScheduler.Default); TaskScheduler.Default);
} }
} }
/// <summary>
/// Produces a deep clone of <paramref name="source"/> with every tag-value
/// field cleared, leaving tag reference, quality, status, and timestamps
/// intact so the dashboard still renders the event without the value. The
/// source event is left untouched because it is shared downstream with the
/// gRPC stream and the replay ring.
/// </summary>
/// <param name="source">The source event to redact a copy of.</param>
/// <returns>A redacted deep clone of the event.</returns>
private static MxEvent RedactValues(MxEvent source)
{
MxEvent redacted = source.Clone();
redacted.Value = null;
if (redacted.BodyCase == MxEvent.BodyOneofCase.OnAlarmTransition)
{
redacted.OnAlarmTransition.CurrentValue = null;
redacted.OnAlarmTransition.LimitValue = null;
}
return redacted;
}
} }
@@ -32,15 +32,19 @@ public sealed class EventsHub : Hub
/// the dashboard roles (Admin or Viewer); both roles may subscribe to /// the dashboard roles (Admin or Viewer); both roles may subscribe to
/// any session id they choose. This is acceptable today because (a) the /// any session id they choose. This is acceptable today because (a) the
/// dashboard's per-session views show non-secret session metadata that /// dashboard's per-session views show non-secret session metadata that
/// any authenticated dashboard user can already see, and (b) value /// any authenticated dashboard user can already see, and (b) tag values
/// logging in the source gRPC stream is gated by the same redaction /// are stripped from the mirrored events by
/// policy that protects logs. The per-session ACL that gates the gRPC /// <see cref="DashboardEventBroadcaster"/> when
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), so the
/// most sensitive payload cannot leak through this seam regardless of the
/// still-missing ACL. The per-session ACL that gates the gRPC
/// <c>StreamEvents</c> RPC is intentionally not yet mirrored here. /// <c>StreamEvents</c> RPC is intentionally not yet mirrored here.
/// TODO(per-session-acl): once a role/scope is introduced that scopes a /// TODO(per-session-acl): tracked as remediation roadmap item 12
/// Viewer to a specific session or tenant, add a session-access check /// (SEC-25). Once a role/scope is introduced that scopes a Viewer to a
/// at this seam — either inline (consult the per-user allowed-session /// specific session or tenant, add a session-access check at this seam —
/// set on <c>Context.User</c> claims / <c>Context.Items</c>) or via a /// either inline (consult the per-user allowed-session set on
/// dedicated authorization policy applied to the hub method itself. /// <c>Context.User</c> claims / <c>Context.Items</c>) or via a dedicated
/// authorization policy applied to the hub method itself.
/// </remarks> /// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param> /// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns> /// <returns>A task representing the subscription operation.</returns>
@@ -41,7 +41,7 @@ public static class GatewayApplication
app.UseStaticFiles(); app.UseStaticFiles();
app.UseAuthentication(); app.UseAuthentication();
app.UseAuthorization(); app.UseAuthorization();
// The rate limiter must run after routing has selected the endpoint (so its // SEC-11: the rate limiter must run after routing has selected the endpoint (so its
// RequireRateLimiting policy metadata is visible) and before the endpoint executes. // RequireRateLimiting policy metadata is visible) and before the endpoint executes.
app.UseRateLimiter(); app.UseRateLimiter();
app.UseAntiforgery(); app.UseAntiforgery();
@@ -105,7 +105,7 @@ public static class GatewayApplication
return builder; return builder;
} }
// Registers the named fixed-window rate-limiter policy applied to POST /auth/login. The // SEC-11: register the named fixed-window rate-limiter policy applied to POST /auth/login. The
// limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on // limit knobs are read from MxGateway:Security at startup; the per-request partition is keyed on
// the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition). // the remote IP (see DashboardEndpointRouteBuilderExtensions.GetLoginRateLimitPartition).
private static void AddLoginRateLimiter(WebApplicationBuilder builder) private static void AddLoginRateLimiter(WebApplicationBuilder builder)
@@ -14,33 +14,43 @@ public sealed class EventStreamService(
GatewayMetrics metrics) : IEventStreamService GatewayMetrics metrics) : IEventStreamService
{ {
/// <inheritdoc /> /// <inheritdoc />
/// <remarks>
/// <para>
/// This reads the subscriber's lease channel fed by the session's single
/// <see cref="SessionEventDistributor"/> pump. The pump owns the single drain of
/// the worker event stream and the worker→public mapping (mirroring the former
/// <c>ProduceEventsAsync</c>); this loop is the per-subscriber boundary that
/// applies the per-RPC filter (<c>AfterWorkerSequence</c>), queue-depth metrics,
/// and the backpressure/overflow policy.
/// </para>
/// <para>
/// The dashboard mirror runs OFF this per-RPC loop. The dashboard is a
/// first-class internal subscriber on the session's
/// <see cref="SessionEventDistributor"/> (see <c>GatewaySession.StartDashboardMirror</c>),
/// so it receives session events even when no gRPC client is streaming. This loop
/// does not mirror to the dashboard. One deliberate consequence: the dashboard sees
/// RAW session events, not the per-gRPC-subscriber <c>AfterWorkerSequence</c>-filtered
/// view this loop applies — the dashboard is a separate LDAP-authenticated monitoring
/// view that should see the session's full event activity.
/// </para>
/// <para>
/// Overflow handling: the distributor's per-subscriber channel is bounded
/// and the pump writes non-blocking. When this subscriber's channel is full the pump
/// applies the per-subscriber backpressure policy and completes this subscriber's
/// channel with a <see cref="SessionManagerException"/>
/// (<see cref="SessionManagerErrorCode.EventQueueOverflow"/>). That terminal fault
/// surfaces here when the reader's <c>MoveNextAsync</c> throws, and it propagates to
/// the gRPC client unchanged. The overflow metric, and (in the legacy
/// single-subscriber FailFast case) the session fault + fault metric, are recorded by
/// the distributor's overflow handler so the session, the pump, and other subscribers
/// are isolated from this subscriber's slowness.
/// </para>
/// </remarks>
public async IAsyncEnumerable<MxEvent> StreamEventsAsync( public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request, StreamEventsRequest request,
string? callerKeyId, string? callerKeyId,
[EnumeratorCancellation] CancellationToken cancellationToken) [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
// This reads the subscriber's lease channel fed by the session's single
// SessionEventDistributor pump. The pump owns the single drain of the worker event
// stream and the worker->public mapping (mirroring the former ProduceEventsAsync); this
// loop is the per-subscriber boundary that applies the per-RPC filter (AfterWorkerSequence),
// queue-depth metrics, and the backpressure/overflow policy.
//
// The dashboard mirror runs OFF this per-RPC loop. The dashboard is a first-class internal
// subscriber on the session's SessionEventDistributor (see GatewaySession.StartDashboardMirror),
// so it receives session events even when no gRPC client is streaming. This loop does not
// mirror to the dashboard. One deliberate consequence: the dashboard sees RAW session events,
// not the per-gRPC-subscriber AfterWorkerSequence-filtered view this loop applies — the
// dashboard is a separate LDAP-authenticated monitoring view that should see the session's
// full event activity.
//
// Overflow handling: the distributor's per-subscriber channel is bounded and the pump writes
// non-blocking. When this subscriber's channel is full the pump applies the per-subscriber
// backpressure policy and completes this subscriber's channel with a SessionManagerException
// (SessionManagerErrorCode.EventQueueOverflow). That terminal fault surfaces here when the
// reader's MoveNextAsync throws, and it propagates to the gRPC client unchanged. The overflow
// metric, and (in the legacy single-subscriber FailFast case) the session fault + fault metric,
// are recorded by the distributor's overflow handler so the session, the pump, and other
// subscribers are isolated from this subscriber's slowness.
if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession? session) || session is null) if (!sessionManager.TryGetSession(request.SessionId, out GatewaySession? session) || session is null)
{ {
throw new SessionManagerException( throw new SessionManagerException(
@@ -48,7 +58,7 @@ public sealed class EventStreamService(
$"Session {request.SessionId} was not found."); $"Session {request.SessionId} was not found.");
} }
// Owner-scoped attach (security control): a session's event stream may be // Owner-scoped attach (TST-02, security control): a session's event stream may be
// attached or reattached ONLY by the API key that opened the session. The detach-grace // attached or reattached ONLY by the API key that opened the session. The detach-grace
// and fan-out retention windows are on by default, so without this check any event-scoped // and fan-out retention windows are on by default, so without this check any event-scoped
// key that learns a session id could attach to another key's retained session and receive // key that learns a session id could attach to another key's retained session and receive
@@ -106,11 +116,23 @@ public sealed class EventStreamService(
options.Value.Sessions.MaxEventSubscribersPerSession); options.Value.Sessions.MaxEventSubscribersPerSession);
} }
int streamQueueDepth = 0;
IAsyncEnumerator<MxEvent> reader = subscriber.Reader IAsyncEnumerator<MxEvent> reader = subscriber.Reader
.ReadAllAsync(cancellationToken) .ReadAllAsync(cancellationToken)
.GetAsyncEnumerator(cancellationToken); .GetAsyncEnumerator(cancellationToken);
// GWC-15: register this subscriber's channel as a live backlog source instead of
// reconciling the queue-depth gauge on every event. The gauge previously read the
// bounded channel's Count (which takes the channel's internal lock) and adjusted the
// metric under its own lock on every streamed event. Now the metric reads Count only
// when it is scraped (ObservableGauge callback) or projected (GetSnapshot), summing the
// live backlog across every registered subscriber — the same "buffered, not yet
// delivered" aggregate the per-event push reported, but with no per-event lock traffic.
// Disposing the registration in the finally removes this subscriber's contribution, so
// the gauge returns to the other subscribers' backlog (zero when none remain) on
// disconnect. CanCount guards a channel that ever cannot report Count (contributes 0).
IDisposable backlogRegistration = metrics.RegisterEventStreamBacklogSource(
() => subscriber.Reader.CanCount ? subscriber.Reader.Count : 0);
try try
{ {
// Emit order for a resume: the ReplayGap sentinel FIRST (only when events were // Emit order for a resume: the ReplayGap sentinel FIRST (only when events were
@@ -169,32 +191,21 @@ public sealed class EventStreamService(
continue; continue;
} }
// Queue-depth gauge tracks events the pump has fanned into this subscriber's // The queue-depth gauge is maintained lazily via the backlog registration above
// channel but the client has not yet consumed — the same "buffered, not yet // (GWC-15): the metric reads this subscriber's channel Count only when scraped,
// delivered" quantity the original per-RPC channel reported. The bounded // so there is no per-event gauge bookkeeping on this hot path.
// subscriber channel supports counting, so reconcile the gauge to the current
// backlog; falling back to a no-op delta if a channel ever cannot count.
int backlog = subscriber.Reader.CanCount ? subscriber.Reader.Count : streamQueueDepth;
int delta = backlog - streamQueueDepth;
if (delta != 0)
{
streamQueueDepth = backlog;
metrics.AdjustGrpcEventStreamQueueDepth(delta);
}
yield return mxEvent; yield return mxEvent;
} }
} }
finally finally
{ {
await reader.DisposeAsync().ConfigureAwait(false); await reader.DisposeAsync().ConfigureAwait(false);
subscriber.Dispose();
if (streamQueueDepth != 0) // Remove this subscriber's live backlog contribution before disposing the lease so
{ // the gauge stops counting a channel that is about to be completed; after this the
metrics.AdjustGrpcEventStreamQueueDepth(-streamQueueDepth); // gauge reflects only the remaining subscribers (zero when none remain).
streamQueueDepth = 0; backlogRegistration.Dispose();
} subscriber.Dispose();
metrics.StreamDisconnected("Detached"); metrics.StreamDisconnected("Detached");
} }

Some files were not shown because too many files have changed in this diff Show More