Compare commits

...

105 Commits

Author SHA1 Message Date
Joseph Doherty 66c3e060ae docs(index): mxaccessgw Contracts/Client 0.3.0 published; main e5e7f18 2026-11-06 16:01:22 -05:00
Joseph Doherty 19d59915ea docs(index): HistorianGateway main now gRPC-only (swap-historian-client merged, 99b4a7d) 2026-11-06 15:52:22 -05:00
Joseph Doherty 6b737b12b1 docs(index): refresh ScadaBridge/HistorianGateway rows; record HG 2026-10-16 diagnostics + Int1 milestones 2026-11-06 15:38:26 -05:00
Joseph Doherty 484d530e65 docs: point remaining Auth/observability GAPS items at issues (#20, ScadaBridge#81) 2026-11-06 15:36:33 -05:00
Joseph Doherty fd180e576f docs: point markdown backlogs at Gitea issues (status lives in Gitea)
Adds the family backlog standard to the root index (open work = Gitea issues,
shared label set, BTDB excluded) and converts components/*/GAPS, hosted-lib
CLAUDE.md notes, code-reviews, infra/glauth and plan docs to banner + issue
pointers (scadaproj #5-#19, ScadaBridge#79).
2026-11-06 15:34:30 -05:00
Joseph Doherty 967da5b43b feat(theme): upstream router controls and responsive shell fixes 2026-11-04 00:52:25 -05:00
Joseph Doherty 6ad15fcf93 Index: record mxaccessgw operator-authenticated alarm ack (proto change)
Claude-Session: https://claude.ai/code/session_016J6isfcgqy4SB2RK3ZvFvT
2026-10-20 08:52:46 -04:00
Joseph Doherty 795af1da71 Record merged-main verification and remaining issue gates 2026-10-13 22:10:40 -04:00
Joseph Doherty 65c6b99cf2 Record verified driver fixture cleanup merge 2026-10-13 21:08:01 -04:00
Joseph Doherty 420c2e0783 Record verified freshness merge and pending cleanup follow-ups 2026-10-13 19:04:46 -04:00
Joseph Doherty 3cacdca861 Record verified ServiceLevel fixes and freshness follow-up 2026-10-13 16:00:45 -04:00
Joseph Doherty ade49c0935 Record verified OPC UA CI closures and pending ServiceLevel work 2026-10-13 13:58:03 -04:00
Joseph Doherty 02480d4fbc docs: record verified OPC UA CI and S7 evidence corrections 2026-10-13 11:57:35 -04:00
Joseph Doherty 87bb647efb Record verified keepalive survival test fix 2026-10-13 11:02:00 -04:00
Joseph Doherty a0cec479b6 Record explicit live-test gates and CI follow-ups 2026-10-13 10:37:45 -04:00
Joseph Doherty 91baba8fcc Record verified corrected Gateway rollout 2026-10-13 10:15:45 -04:00
Joseph Doherty 9688707d82 Record gateway correction and reset-echo verification 2026-10-13 10:02:44 -04:00
Joseph Doherty 509114360d Record failed Gateway event-read rollout and rollback evidence 2026-10-13 09:43:48 -04:00
Joseph Doherty cfd371ce18 Record OtOpcUa documentation merge and CI follow-up evidence 2026-10-13 09:16:31 -04:00
Joseph Doherty 51a956a878 Record verified event route and ScadaBridge issue closures 2026-10-13 08:58:15 -04:00
Joseph Doherty 2a3baae301 Record verified SQL event fault fix and route probe limits 2026-10-13 08:18:12 -04:00
Joseph Doherty 10fc22b87e Record Gateway read cancellation fix and event probe evidence 2026-10-13 07:29:35 -04:00
Joseph Doherty 8825cd966f docs(index): record durable alarm identity fix 2026-10-13 07:12:19 -04:00
Joseph Doherty 69a7f8e1b5 docs(index): record historian cleanup status fix 2026-10-13 06:56:02 -04:00
Joseph Doherty 01a6313fe7 docs: record verified restart wire test fix 2026-10-13 06:30:59 -04:00
Joseph Doherty e7cfa21fda docs: record verified local Gateway metadata rollout 2026-10-13 06:19:29 -04:00
Joseph Doherty 68f1b56acf docs: record Docker auth correction and reconnect reproduction 2026-10-13 05:43:43 -04:00
Joseph Doherty b4da0deacb docs: record aggregate and catalog fixes and verification gates 2026-10-13 05:31:38 -04:00
Joseph Doherty 180403cb17 docs: record verified OtOpcUa cache coverage merge 2026-10-13 05:29:45 -04:00
Joseph Doherty 67c182ce18 docs: record gateway catalog and key follow-up merge 2026-10-13 05:06:02 -04:00
Joseph Doherty 05042e5e36 docs: record historian merge and pending verification issues 2026-10-13 04:49:11 -04:00
Joseph Doherty aed5fbf046 docs(index): record missing-tag and gateway discovery fixes 2026-10-13 04:33:36 -04:00
Joseph Doherty 00ef22823f docs(index): record OtOpcUa historian fixes and live measurement 2026-10-13 03:51:45 -04:00
Joseph Doherty c05a6d93b0 docs(index): OtOpcUa #692–#694 historian paging/AtTime/ceiling batch 2026-10-13 02:12:30 -04:00
Joseph Doherty ec2615a9d2 docs(history): OtOpcUa #689–#691 reverse walk sizing, ReturnBounds, gateway ceiling/events 2026-10-13 00:11:34 -04:00
Joseph Doherty 2a0b34e056 docs(history): OtOpcUa #685–#688 Gateway historian raw/aggregate fixes 2026-10-12 16:04:02 -04:00
Joseph Doherty 4555a3bdad docs(index): OtOpcUa live AVEVA gate — #675/#677/#679/#681 closed, historiangw#27 + #685–#688 filed 2026-10-12 14:21:18 -04:00
Joseph Doherty 681606dad0 docs(index): OtOpcUa batch #681–#684 — Gateway per-sample from raw, advertised aggregates, QuestDB tag index, Good-only aggregates 2026-10-12 13:59:34 -04:00
Joseph Doherty aa7093b53b docs(index): OtOpcUa batch #679–#680 — historian aggregate parity, Gateway Average/Count refused 2026-10-12 08:38:50 -04:00
Joseph Doherty 17cb91a315 docs(index): OtOpcUa follow-up batch #674-#678 (fast OpcUaClient recovery, reverse history reads, HistoryUpdate Primary-only, role overlays) 2026-10-11 13:20:46 -04:00
Joseph Doherty 0adfb244b2 docs(index): OtOpcUa batch #667-#673 (retention, Part 11 modified values, event CPs, OpcUaClient session loss, time-weighted aggregates, unreachable-Primary capture) 2026-10-11 10:18:36 -04:00
Joseph Doherty c6e60cb6c4 docs(index): OtOpcUa pluggable historian (SqlServer #652, QuestDB #666) 2026-10-11 04:42:08 -04:00
Joseph Doherty c713925ac7 docs(index): OtOpcUa #665 dead in-place ApplyDelta seam deleted
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:32:54 -04:00
Joseph Doherty 0b056c6fee docs(index): OtOpcUa #664 mid-connect ApplyDelta config adoption
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 11:17:15 -04:00
Joseph Doherty 484b176cd6 docs(index): OtOpcUa #663 in-place ApplyDelta re-subscribes data + alarms
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 08:00:11 -04:00
Joseph Doherty dd1eea2106 docs(index): OtOpcUa #662 native-alarm feed survives non-respawning deploys
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 07:36:04 -04:00
Joseph Doherty 63cc772e8d docs(index): OtOpcUa hw5 batch — #657 alarm restatement, #660, #661, #592 hw evidence, #662 filed 2026-10-09 07:20:35 -04:00
Joseph Doherty 3b4d81e630 docs(index): OtOpcUa hw4 batch #611/#592 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 04:36:47 -04:00
Joseph Doherty b6f5eb2535 docs(index): OtOpcUa hw3 batch #654-#656 history entry
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-09 00:19:30 -04:00
Joseph Doherty a8f5f66647 docs(index): OtOpcUa hardware batch #647-#651 + hw2 live gates 2026-10-08 22:03:14 -04:00
Joseph Doherty df21d365ec docs(history): OtOpcUa hardware-available batch #640-#646 (alarm carry-over, honest deploy acks, OpcUaClient/AbCip alarm routing)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 18:23:34 -04:00
Joseph Doherty a2b40b7922 fix(theme): .rise uses fill-mode backwards so it stops trapping position:fixed descendants (OtOpcUa #643)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 16:50:08 -04:00
Joseph Doherty 3c91f0f813 docs(history): OtOpcUa issue batch #626-#638 (exact condition severity, MTConnect alarms) 2026-10-08 16:33:45 -04:00
Joseph Doherty a6c4161719 docs(history): OtOpcUa hardware-available issue sweep #593-#625 2026-10-08 09:10:46 -04:00
Joseph Doherty 78ca2a6744 docs(index): OtOpcUa Gitea issue sweep #562-#591 — ScadaBridge-visible ACL/alarm-event changes
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
2026-10-08 01:45:05 -04:00
Joseph Doherty 21a95c7f50 docs(history): OtOpcUa native AB transport merged — AbCip native default, AbLegacy opt-in, real-press gate passed 2026-10-07 22:45:47 -04:00
Joseph Doherty c43d7ee1ad docs(history): OtOpcUa AbCip/AbLegacy gain a native managed transport behind a Transport switch 2026-10-07 19:12:40 -04:00
Joseph Doherty d0b66bf105 docs(history): OtOpcUa TwinCAT native array subscriptions (2026-10-07) 2026-10-07 15:09:04 -04:00
Joseph Doherty 44421dc5aa docs(history): OtOpcUa FOCAS/TwinCAT/AbCip driver fixes ported from ignitionoee + real-device gates (2026-10-07) 2026-10-07 14:32:57 -04:00
Joseph Doherty 8fa7631ec4 overview: register BTDB Manager on vd03 (Wonder overlay) and QA; deployment notes 2026-10-01 02:05:44 -04:00
Joseph Doherty b62cfad48d index: BTDB master a81a480 — LDAP sign-in by user bind (Auth 0.4.1) 2026-10-01 01:44:27 -04:00
Joseph Doherty 625f207e08 index: Auth feed carries 0.4.1 2026-10-01 01:31:16 -04:00
Joseph Doherty 29bfa8fbf9 Merge fix/auth-ldap-0.4.1: Auth 0.4.1 (canonical username, empty-password guard in both modes) 2026-10-01 01:31:06 -04:00
Joseph Doherty b0582eb66a release(auth): 0.4.1 — canonical username, empty passwords refused in both modes, template guidance 2026-10-01 01:27:55 -04:00
Joseph Doherty 9090b1b511 docs(auth): prefer the down-level UserBindTemplate; warn on UPN templates (I1) 2026-10-01 01:27:55 -04:00
Joseph Doherty e3d146aa21 fix(auth): canonical username from the entry; refuse empty passwords in both bind modes
- Request UserNameAttribute in the search (de-duplicated, case-insensitive) and return its
  first value as LdapAuthResult.Username, falling back to the typed name (I2).
- The empty-password guard now applies in ServiceAccount mode too: AD treats an empty-password
  simple bind as an unauthenticated bind and reports success (I3).
- Restore the dropped explanatory comments in CompleteWithServiceAccount / SearchUser.
2026-10-01 01:27:27 -04:00
Joseph Doherty 177881c423 Merge feat/auth-ldap-user-bind: Auth 0.4.0 opt-in LDAP user bind 2026-10-01 01:08:53 -04:00
Joseph Doherty 3c42069673 docs(index): 0.4.0 entry placement 2026-10-01 01:02:47 -04:00
Joseph Doherty 00ac436ae2 release(auth): 0.4.0 — opt-in LDAP user bind 2026-10-01 00:58:55 -04:00
Joseph Doherty 302d1245cd feat(auth): LdapAuthService user-bind flow (bind as the user, read own groups on the same connection) 2026-10-01 00:55:04 -04:00
Joseph Doherty fa0ca66a05 feat(auth): normalise DOMAIN\name and name@suffix logins for user bind 2026-10-01 00:48:25 -04:00
Joseph Doherty 711e8beb2b feat(auth): LdapBindMode.User options and validation 2026-10-01 00:47:04 -04:00
Joseph Doherty 9ac6ed51d3 index: ScadaBridge main @ 3a217b94 (stale secret-mirror status rows pruned) 2026-09-29 11:17:52 -04:00
Joseph Doherty efd5d881dc index: Secrets 0.6.3 published; ScadaBridge site-secrets follow-ups (2650f794) 2026-09-28 21:47:04 -04:00
Joseph Doherty 0793159cbb docs(secrets): 0.6.3 changelog entry 2026-09-28 21:25:14 -04:00
Joseph Doherty 51b6f1ab9f index: ScadaBridge site-secrets-on-artifact-deploy merged (23a139a4); Secrets 0.6.3 on main, unpublished 2026-09-28 21:14:16 -04:00
Joseph Doherty 29d0c50f07 index: ScadaBridge secrets via artifact deploy; Secrets 0.6.3 2026-09-28 20:24:16 -04:00
Joseph Doherty adbf2eb182 fix(secrets): synchronize SecretsHubSyncStatus getters under the write lock
Review finding (Fix round 1, Task 1): the property getters read their
backing fields without synchronization while RecordSuccess/RecordFailure
write them under _gate, so a concurrent read could observe a torn
DateTimeOffset or a mix of fields from two different writes. Converted the
four auto-properties to explicit properties over private fields, each
getter taking the same lock the writers use. Public interface unchanged.
2026-09-28 17:30:33 -04:00
Joseph Doherty b392b84f21 feat(secrets): 0.6.3 — hub sweep outcome logging (in-sync debug + hourly heartbeat + recovered) and ISecretsHubSyncStatus
GrpcSecretSyncService's background sweep now records outcomes on a new
SecretsHubSyncStatus (registered singleton, exposed as public
ISecretsHubSyncStatus) and logs three new events on the outcome-recording
path: EventId 4 (Debug, every in-sync sweep), EventId 5 (Information, the
same outcome throttled to at most once per the new
SecretsGrpcHubClientOptions.InSyncLogInterval, default 1h), and EventId 6
(Information, the first sweep to succeed after one or more failures).
EventIds 1-3 are unchanged; the raw internal SweepAsync still throws on
failure exactly as before. Not yet published to the Gitea feed and
ScadaBridge is not re-pinned — that is a separate, later step.
2026-09-28 17:26:25 -04:00
Joseph Doherty 5c73be486d index: ScadaBridge b3021e7c deployed to QA vq03/vq04 (2026-09-28, migrations 57) 2026-09-28 11:12:04 -04:00
Joseph Doherty 308742b7a6 docs: ScadaBridge — main 5cc09b08 (script Files access), DEV vd03 on b3021e7c 2026-09-28 10:46:08 -04:00
Joseph Doherty 8cfe076012 docs: ScadaBridge — DEV vd03 on fe89363b + MES test machines (2026-09-28); row 126 now QA-only 2026-09-28 10:10:21 -04:00
Joseph Doherty 646af887d1 docs: winrm re-enabled for deployment activities only (2026-09-28)
Replaces the 2026-09-11 remote-exec hold: WinRM exec is allowed only for the on-box steps of a
user-requested deploy; ad-hoc diagnostics/version checks use SMB, HTTP or SQL. SMB file copies and
direct SQL logins remain always allowed. Scope rules live in the winrm skill's Scope section.
2026-09-28 09:25:12 -04:00
Joseph Doherty 1e0bfe03d4 docs: HistorianGateway row notes the Java client 2026-09-15 19:31:18 -04:00
Joseph Doherty 9934bea05a docs: ScadaBridge index row — main @ fb54ed42 (register row 126: vd03/vq03/vq04 migration rollout TODO)
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:43:33 -04:00
Joseph Doherty d0079ba1a1 docs: ScadaBridge index row — main @ ac833f5a pushed 2026-09-14
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:41:37 -04:00
Joseph Doherty 4dda768801 docs: ScadaBridge index row — local main @ 3a21cfc2 (2026-09-14: site artifact staleness, pending-updates indicator, scrollable nav); not pushed
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-14 14:38:48 -04:00
Joseph Doherty f6eef9d8eb docs: BTDB index row — master @ 695192d (2026-09-11: Manager edits, shows and audits batch transfers; ~198 ahead, unpushed)
Claude-Session: https://claude.ai/code/session_01XuWnsPJZ4XK9qPBBtnuqr2
2026-09-11 13:04:34 -04:00
Joseph Doherty 16a84f9df4 docs: ScadaBridge index row — main pushed @ 9d529b82 (2026-09-11: #36 handshake, MES-reportable alarm flag, Flow Debugger nav); deployed tip unchanged
Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
2026-09-11 12:30:30 -04:00
Joseph Doherty 3c92bf54b0 docs(btdb): index the DB-6 re-fix — forward loop guard, whole-lot chain walks (5f0da01)
Claude-Session: https://claude.ai/code/session_01XuWnsPJZ4XK9qPBBtnuqr2
2026-09-11 09:44:56 -04:00
Joseph Doherty fb33924473 docs(index): BTDB — dbo.TransferMachineBatch merged on local master; no branch in flight
Claude-Session: https://claude.ai/code/session_01XuWnsPJZ4XK9qPBBtnuqr2
2026-09-11 09:22:47 -04:00
Joseph Doherty 46fa06a612 docs(index): BTDB row + history — Blazor conversion, review remediation and DARS schema all merged on local master; Claude artifacts gitignored in btdb; cut-over blocked by the remote-access hold
Claude-Session: https://claude.ai/code/session_01PpXxRCFznFqE65ay6wAJwe
2026-09-11 09:16:48 -04:00
Joseph Doherty 5ee31dbf7b docs(index): winrm remote exec on the wonder boxes disabled 2026-09-11 (security software); UNC copies + direct SQL still allowed
Claude-Session: https://claude.ai/code/session_01PpXxRCFznFqE65ay6wAJwe
2026-09-11 08:55:08 -04:00
Joseph Doherty 98868a60d0 docs(index): DEV + QA wonder boxes at every repo tip after the 2026-09-10 winrm round
CLAUDE.md: ScadaBridge row records main @ 17864d1a (records only, code tip
2b9a7c3c) deployed on vd03 and the vq03/vq04 pair; Theme/Auth/LocalDb rows
lose their "not yet deployed" caveats (QA pair now runs LocalDb replication,
connected); tier note gains the deployed-state line and the winrm access path.

deployment.md: §2.1 access rows replaced by the winrm skill (WinRM 5985 +
SMB 445; servecli/psbridge retired), §7.1 port map gains 5985/445 and marks
6150 retired, live-state table updated (OtOpcUa active flipped to node-a),
new "Deployed builds on QA" paragraph, §9 deployed-state summary.

Claude-Session: https://claude.ai/code/session_01RgthVLVU5CJ8SUuEwruTkt
2026-09-10 12:37:44 -04:00
Joseph Doherty 3903c9a196 index: ScadaBridge main @ fef3e767 — 2026-09-09 arch-review remediation merged (13 High + 33 Medium) 2026-09-09 22:10:21 -04:00
Joseph Doherty ef487957b2 docs: ScadaBridge index — flow debugger script entry
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-08 01:21:47 -04:00
Joseph Doherty 895e881807 docs(index): ScadaBridge row — Flow Debugger shipped and arch-review remediated (main @ 789377ef)
Claude-Session: https://claude.ai/code/session_01NcMG6xXdsJbmD2wYAgkK29
2026-09-06 17:11:19 -04:00
Joseph Doherty 54f111e8b1 docs(btdb): register BTDB Manager in the Overview docker registry; record the local rig
- ZB.MOM.WW.Overview appsettings.Docker.json: application "BTDB Manager", instance manager at
  http://zb-btdb-manager:9877 (HasActiveRole false); docker/README.md fleet row.
- CLAUDE.md BTDB row and sister-project-history/btdb.md §7/§9: docker/ layout, the
  dacpac + SQL CLR side-project build, scadabridge-mssql as the BT host.

Claude-Session: https://claude.ai/code/session_015dYqtaTxgf6NK2KuwriMSg
2026-09-04 06:14:22 -04:00
Joseph Doherty 370a9eea96 docs(btdb): record the BTDB Manager Blazor conversion (feat/manager-blazor) in the index and history
Claude-Session: https://claude.ai/code/session_015dYqtaTxgf6NK2KuwriMSg
2026-09-04 05:46:05 -04:00
Joseph Doherty 4988c24618 docs(btdb): Manager batch-edit path now carries ContainerID/MoveOutTimestamp (btdb feat/machine-batch-transfer)
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 14:39:14 -04:00
Joseph Doherty fe6216c533 docs(index): BTDB is now a git checkout; DARS BatchTracker_DB fork migrated onto btdb feat/machine-batch-transfer (local, unpushed)
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 14:34:20 -04:00
Joseph Doherty 10990a43c8 docs(index): add BTDB (Batch Tracking Database) as a sister project + analysis
Row in the sister-project table, a BTDB-edges subsection (Galaxy write contract, ScadaBridge inbound-API SAPID lookup on db-connection BTDB, Historian CLR/INSQL co-location), break-surface note, Windows-only build commands, and the DARS bullet now points at the unversioned WW_ARCH/BatchTracker_DB fork. Full schema/contract notes and the 2026-09-03 gap analysis live in .claude/skills/sister-project-history/btdb.md.

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 14:15:34 -04:00
Joseph Doherty e538ea36e3 docs(index): track .claude/skills/sister-project-history — the OtOpcUa/HistorianGateway narratives split out in c72271a were gitignored and existed only locally
.gitignore ignored all of .claude/, so the per-project history files the root index points at were never committed; the pushed index had lost that history. Un-ignore .claude/skills/ (settings.local.json and worktrees stay ignored) and add the three files.

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 14:15:34 -04:00
Joseph Doherty 63e6d3f867 docs(index): all four apps on Auth 0.3.0, OtOpcUa on LocalDb 0.3.4, MxGateway.Client/Contracts 0.2.1; Secrets consumer matrix corrected to 0.6.2 everywhere
Family-wide pin refresh of 2026-09-03: OtOpcUa b8b4c18f, mxgw 4f931e4,
HistorianGateway d8d890c, ScadaBridge e45c4af9 (Auth already on main).
Per-library narratives in ZB.MOM.WW.Auth/CLAUDE.md and
ZB.MOM.WW.LocalDb/CLAUDE.md updated to match; the stale
"unpushed / 0.6.0 / 0.5.1 / merge pending" notes are corrected.

Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 11:25:29 -04:00
Joseph Doherty c72271aa8a docs: split per-library history out of the root index into ZB.MOM.WW.*/CLAUDE.md files
Claude-Session: https://claude.ai/code/session_01Wzg6NUCWHipSexLs2cgRFv
2026-09-03 10:48:29 -04:00
78 changed files with 3065 additions and 418 deletions
@@ -0,0 +1,16 @@
---
name: sister-project-history
description: Use when you need the detailed change history, live-gate records, redundancy/auth/ACL behaviour, or ScadaBridge consumer-impact notes for a sister project (OtOpcUa, HistorianGateway, BTDB) — the scadaproj root CLAUDE.md keeps only a short summary row per project; the full narrative lives in this directory.
---
# Sister project history
The root `CLAUDE.md` index keeps one short row per sister project. The full narrative rows that used to live
in that table were moved here **verbatim** on 2026-09-03 (they were ~57k characters loading into every session):
- `otopcua.md` — OtOpcUa: v3 dual-namespace address space, every driver, redundancy/mesh program, data-plane auth + ACL enforcement, deferment register, the authoring-vs-deploy-gate parity arc, and the per-issue consumer-impact record for ScadaBridge.
- `historiangateway.md` — HistorianGateway: ArchReview cycles, pending.md items, session pools, datatype gaps, SQL-path workarounds, PR/commit history.
- `btdb.md` — BTDB (Batch Tracking Database, added 2026-09-03): schema + sproc write contract, Historian/CLR coupling, Galaxy/MESAPI/ScadaBridge edges, the DARS `BatchTracker_DB` fork, and the gap analysis.
Read the file for the project you are working against. When a sister repo tells you to "update its entry in
`scadaproj/CLAUDE.md`", append the detail to the file here and keep the index row to a few lines.
@@ -0,0 +1,207 @@
# BTDB (Batch Tracking Database) — index history + analysis
> Added 2026-09-03 as a sister project. **This file is the authoritative place for BTDB detail — append here and keep the root index row short.** Everything below was verified from source on 2026-09-03 unless marked _(inferred)_.
| Project | Location | Stack | Repo | Summary |
|---|---|---|---|---|
| **BTDB** (Batch Tracker) | `~/Desktop/btdb` (case-insensitive FS: also `BTDB`) | SQL Server 2016+ SSDT project + SQL CLR (net472) · manager: **Blazor Web App net10.0** on `feat/manager-blazor` (§8; `master` still carries the EOL net6.0 MVC app) | **`ZimmerBiomet/BTDB`** on the corporate Gitea `wonder-app-vd03.zmr.zimmer.com:3000` (`master` @ `d147c10e`, 2023-07-26). **Not** on `gitea.dohertylan.com`. | The `BT` SQL Server database that is the plant's **batch/cycle/work-order system of record for data collection**: Galaxy machine objects call its `StartMachineBatch`/`StartMachineCycle`/`End*`/`AddMachineBatchWorkOrder`/`AddOtherData*` sprocs; reports pull time-series per batch out of the AVEVA Historian through its CLR sprocs (`OPENQUERY(INSQL, … WideHistory …)` + `Runtime.dbo.*`); MESAPI and ScadaBridge's inbound API resolve `SAPID → Machine.Code` from `dbo.Machine`. `BTDBManager` is a small Windows-service MVC site for machine/group config, batch edits (audited) and cross-environment config import. |
## 1. What is on disk, and where the truth is
- `~/Desktop/btdb` is **not a git checkout** — no `.git`, no `CLAUDE.md`, no `README`. It is a file-for-file snapshot of `ZimmerBiomet/BTDB` `master` (the only branch) at its head commit `d147c10e` (2023-07-26, "Update notification system tables to add urgent flag"). Verified by comparing the Gitea tree with the local tree: 170 vs 168 files, the only difference being the remote's `.gitattributes`/`.gitignore`. The repo's `updated_at` of 2024-11-07 is not a commit (the branch head is still 2023-07-26).
- `~/Desktop/DARS/WW_ARCH/BatchTracker_DB/` is a **fork of the `Database` project only** (no Manager), modified 2026-08-13, also not under git (DARS is not a repo). Substantive differences vs the snapshot, all matching the DARS sprint-1 spec `DARS/sprints/sprint1/bt_db_changes.md`:
- new table `MachineBatchTransfer` (source/dest batch chain for mid-run reactor failure; `TransferType` CHECK `'mid_run_failure'`; three indexes) + sproc `AddMachineBatchTransfer` (linear-chain guard);
- `MachineBatchWO` gains `ContainerID NVARCHAR(64) NULL` + `MoveOutTimestamp DATETIME2 NULL` with filtered indexes (Camstar container traceability; mirrors the `SAPID`/`ZTag` idiom);
- new CLR sproc `GetLinkedBatchTSV` (+ `_Wrapper`) that walks the transfer chain and stitches TSVs, and `LinkedBatchAlarm_Wrapper`;
- `StartMachineBatch.sql`, `MachineBatch.sql`, `AuditLogMachineBatch.sql` differ **only** in BOM/CRLF → LF normalization (no logic change).
- Reference doc for the schema: `DARS/WW_ARCH/BatchTracker_Reference.md` (states 22 tables / 35 SQL sprocs / 4 CLR sprocs / 5 CLR UDFs / 6 table types / 1 aggregate).
- **Resolved 2026-09-03 — the divergence is closed locally:** `~/Desktop/btdb` was turned into a real checkout (`git init` + fetch of `ZimmerBiomet/BTDB`, `master` tracking `origin/master`; the snapshot was tree-identical apart from exec bits copied from Windows, which were cleared). The DARS fork's transfer-chain work was migrated onto branch **`feat/machine-batch-transfer`** as one commit: the five new objects, the two `MachineBatchWO` columns, and the `.sqlproj` wiring **including the `Compile` entry for `GetLinkedBatchTSV.cs` that the fork had omitted** (without it the CLR sproc is never built and its wrapper fails at deploy). The BOM/CRLF re-saves were not carried over. The sprint-1 plan (`bt_db_changes.md`, overview, diagrams) and `BatchTracker_Reference.md` now live under `btdb/docs/`. **The branch is local only — not pushed to the corporate Gitea.** `DARS/WW_ARCH/BatchTracker_DB/` is now superseded and should be deleted or replaced by a pointer once the branch is pushed. A second commit on the same branch carried the two columns through the Manager's batch-edit path: `MachineBatchWOType` (appended last — the TVP binds by ordinal and `ToDataTable` emits properties in declaration order, so the view model's property order must keep matching), `AuditLogMachineBatchWO` (`Prev_`/current pairs), the DTOs and view model, `SaveBatchEdit.sql` (MERGE change-detection with `COALESCE` because both columns are NULL-able, OUTPUT and the three audit inserts), and the Kendo work-order grid in `Views/BatchEdit/Edit.cshtml` (masked timestamp editor like the cycle grid). `LookupBatchInfo.sql` is `SELECT *` so it needed nothing. Not touched: the Telerik `BatchEditAuditLogReport.trdp` (binary package; open it in the Telerik designer to add the audit columns) and the batch search grid (`SearchBatches.sql`), which still lists only work-order numbers. Nothing here was built or run: the Manager needs Telerik + Windows, and the SSDT project needs MSBuild; a throwaway compile of the Manager retargeted to .NET 10 on the Mac showed no errors in the touched files.
## 2. Solution shape
`BTDB.sln` (VS 2022) = two projects:
| Project | Kind | Notes |
|---|---|---|
| `Database/Database.sqlproj` | SSDT, `Sql130DatabaseSchemaProvider` (SQL Server 2016+), CLR assembly targets **net472**, C# namespace `BT` | 22 tables, 6 table types, ~35 T-SQL sprocs, 3 CLR sprocs (`GetTSV`, `GetMachineBatchTSV`, `GetCycleTSV`), 5 CLR UDFs (`GetAggregateValues`, `CheckTriggered`, `WhenTriggered`, `WhenCleared`, `StringCompare`), 1 CLR aggregate (`StringJoin`), 1 T-SQL TVF (`GenerateShifts`), 1 trigger (`TrgNotification` → `AuditLogNotification`). Requires `CLR enabled` on the instance. |
| `Manager/BTDBManager.csproj` (**`master` only — deleted on `feat/manager-blazor`, see §8**) | ASP.NET Core MVC, **net6.0** (out of support since 2024-11), Dapper 2.0, `System.Data.SqlClient`, `System.DirectoryServices` (Windows-only LDAP), `Microsoft.Extensions.Hosting.WindowsServices`, Kendo UI + Bootstrap, **Telerik Reporting R2 2022** referenced by an absolute `HintPath` into `Program Files (x86)` | Kestrel `http://*:9876`; runs as a Windows service; 13 embedded `.sql` query resources; 3 `.trdp` Telerik audit reports. `Manager/install.bat`/`uninstall.bat` are **leftovers from a different service** (`WWNotificationSystem.exe`) and do not install the Manager. |
## 3. Database model (what the family writes and reads)
**Core hierarchy:** `Machine` (`Code` unique; optional unique `ZTag`, `SAPID`; `TimeZone`; `MultipleBatch`/`MultipleCycle` flags; `OtherData` JSON) → `MachineBatch` (start/end operator + UTC timestamps, `OtherData`) → `MachineCycle` (same shape) and `MachineBatchWO` (`WorkOrderNumber`, `ItemNumber`). `MachineGroup` + `MachineGroupMembership` give roll-ups (DARS models one `Machine` per reactor *side*, one `MachineGroup` per unit). `MachineAlarm` is a per-machine alarm catalog (`Severity` 0–999, `FlaggedForMES`).
**Lifecycle sprocs (the write contract):**
| Sproc | Behaviour |
|---|---|
| `StartMachineBatch(@Code, @OperatorID, @o_MachineBatchID OUT)` | Looks up machine by `Code`; unless `MultipleCycle`/`MultipleBatch`, **auto-closes** any open cycle/batch first; inserts with `GETUTCDATE()`. Throws 50000 if machine unknown. |
| `EndMachineBatch(@MachineBatchID, @EndOperatorID)` | Closes the open cycle too; throws if not found / already closed. |
| `StartMachineCycle(@MachineBatchID, @OperatorID, @o_MachineCycleID OUT)` / `EndMachineCycle` | Cycle must belong to an open batch. |
| `AddMachineBatchWorkOrder(@MachineBatchID, @WorkOrderNumber, @ItemNumber, @o OUT)` | Only on open batches. |
| `AddOtherDataMachineBatch` / `…MachineBatchWorkOrder` / `…MachineCycle (@ID, @DataKey, @DataValue)` | `JSON_MODIFY` into `OtherData` — via **dynamic SQL that concatenates `@DataKey`/`@DataValue`** into the statement (`sp_executesql` of a built string). Injection surface; callers today are trusted Galaxy scripts. |
All timestamps are `GETUTCDATE()`; `Machine.TimeZone` is used only at retrieval time (`wwTimeZone=` in report wrappers). This matches the family's all-UTC rule.
**Historian coupling (the read contract):** the CLR code resolves a machine-relative tag (`<Code>.<local>`; `*.<attr>` shorthand; `|literal|` static columns) to the Historian's global tagname via `Runtime.dbo.TagExtendedPropertyInfo` (`HierarchicalName`), then runs `SELECT … FROM OPENQUERY(INSQL, 'SELECT DateTime, [tag]… FROM WideHistory WHERE … wwTimeZone="UTC" AND <queryParams>')` (`wwRetrievalMode`, `wwResolution`, `wwTimeZone` are passed through **verbatim** from `@queryParams`). Discrete checks read `Runtime.dbo.DiscreteHistory`; alarm wrappers read `Runtime.dbo.Events` (`IsAlarm=1`, `Alarm_OriginationTime`, `Alarm_DurationMs`, `Alarm_UnAckDurationMs`). **Consequence: the `BT` database must live on (or three-part-name-reach) the Historian's SQL instance with the `INSQL` linked server** — it is co-tenant with `Runtime`, which is why it sits on the `wonder-sql-*` boxes.
**Report wrappers** are per machine family and are the consumers of the CLR layer: Crest (`CrestWide`/`CrestMinMax`/`CrestAlarm`/`CrestCleaner*`), Elma (`ElmaData`, `Elma_Bulk_Query_Nitric`), `ExtruderWide`, `PultrusionWide`, Haas (`HaasStatusReport`, `HaasAlarm`, `QV_Haas*` = QlikView), Solar (`SolarAlarms`/`SolarEvents`), DryHeat (`DryHeatCycleReport`/`Search`/`BulkReport`), PoncePolyPress, `Z002432Wide`/`Z002433Wide`. `GenerateShifts` builds a 3-shift calendar table for these.
**Auth tables that are not the Manager's:** `UserAuth`, `UserAuthDetails`, `UserAuthRole`, `ApiKey` are ServiceStack's schema — they belong to **MESAPI/WWSupport** (the Camstar-facing REST service, see `mes-delmia-integration-api.md` §1.1: `ApiKeyAuthProvider` + `LdapAuthProvider`, role `MESAPI`), which shares the `BT` database. The Manager authenticates via LDAP only.
**Notification tables** (`Notification`, `NotificationLog`, audited by trigger) back the separate `WWNotificationSystem` Windows service, which is **not in this repo**.
## 4. BTDBManager (the config/edit UI) — `master` state; the replacement is §8
- **Auth:** cookie scheme; `LdapAuthenticationService` binds `LDAP://nam.zimmer.com` with the user's own credentials via `System.DirectoryServices` (so it only runs on a domain-joined Windows host), reads `memberOf`, and keeps only the three configured group DNs. Policies `READONLY` / `MACHINECONFIG` / `BATCHEDIT` map to `ReadOnlySecurityGroup` / `MachineConfigSecurityGroup` / `BatchEditSecurityGroup`; the checked-in DEV config points all three at `UG_WW_APP_ADMIN_DEV`. A global `AuthorizeFilter` makes every controller (incl. `/Api/GetMachines`, `/Api/GetMachineGroups`) require login. This is exactly the AD-group→role problem the family's `ZB.MOM.WW.Auth` 0.3.0 + `ldap_mapping.md` already solve.
- **Features:** Machine CRUD (with group memberships + alarm catalog via TVPs `MachineGroupMembershipType`/`MachineAlarmType`), MachineGroup CRUD, batch search/edit (`SaveBatchEdit` writes `AuditLogBatchEdit` + prev/new `AuditLogMachineBatch`/`…WO`/`…Cycle` rows), **MachineConfigImport** (copies machine/group config from another environment's `BT` — sources `QA` and `PROD` are hard-wired in `appsettings.json`), and Telerik `.trdp` audit reports.
- **Secrets are checked in:** `appsettings.json` carries plaintext `wonderapp` SQL passwords for DEV/QA/PROD, and `install.bat` carries a domain service-account password. Treat all of them as compromised-by-repo; do not copy them into any other file or reply. This is the pattern `ZB.MOM.WW.Secrets` (`${secret:}`) exists to replace.
- **Environment hosts as recorded in 2023:** DEV `wonder-sql-vd01/BT`, QA `wondersqlqa/BT` (`MultiSubnetFailover`), PROD `wondersqlpd/BT`. ScadaBridge's 2026-06-16 design says the DEV BTDB is on **`wonder-sql-vd03/BT`** (db-connection id 1). `mes-delmia-integration-api.md` also says `vd01`. **Host drift is unresolved** — do not assume either without probing.
## 5. How BTDB relates to the rest of the family
```
Galaxy machine objects ──(script: EXEC StartMachineBatch / StartMachineCycle /
($Reactor_Side etc.) AddMachineBatchWorkOrder / AddOtherData* …)──────────┐
▼
Camstar MES ──HTTPS──▶ MESAPI/WWSupport ──(SELECT Machine by SAPID; alarm catalog; ┌────────────────────┐
(ServiceStack, own UserAuth/ApiKey tables) ────────────────▶ │ SQL Server "BT" │
│ (BTDB) │
ScadaBridge inbound API ──(Database helper, connection "BTDB": SELECT Code FROM │ co-tenant with │
(IpsenMESMoveIn, MES alarm-status) ─────────────────────────────────────────────▶ │ Historian Runtime │
ScadaBridge site scripts ──(Database.Connection / CachedWrite — customer scripts)─▶ │ + INSQL linked │
└─────────┬──────────┘
BTDBManager (config, batch edits, env import) ─────────────────────────────────────▶ │ CLR: OPENQUERY(INSQL) WideHistory,
SSRS / QlikView / Telerik reports ──(EXEC *_Wrapper, GetMachineBatchTSV …)──────────▶ │ Runtime.dbo.{TagExtendedPropertyInfo,
▼ DiscreteHistory, Events}
AVEVA Historian (Runtime DB)
```
- **Galaxy → BT** is the primary write path (batches/cycles/work orders/OtherData JSON). DARS `model_info.md` §: "Existing per-machine BT sprocs (`StartMachineBatch`, `StartMachineCycle`, `GetCycleTSV`, `Alarm_Wrapper`, …) work unchanged" — the sprocs are the contract Galaxy scripts bind to.
- **ScadaBridge → BT (verified):** the inbound-API script context has a `Database` helper bound to named db-connections; production defines connection **`BTDB`** (id 1), and the `IpsenMESMoveIn` (design 2026-06-16) and MES alarm-status (2026-06-30) methods do `SELECT Code FROM dbo.Machine WHERE SAPID=@s` before `Route.To(instance)`. Template/site scripts expose `Database.Connection(name)` + `Database.CachedWrite(...)`, so site scripts **can** write batches into BT — but **no checked-in ScadaBridge script calls a BT sproc**; any batch logging is customer-site script configuration, not platform code, and the docker rig does **not** seed a `BT` database (`infra/mssql/setup.sql` creates only `ScadaBridgeConfig` + `ScadaBridgeMachineData`; the `InboundDatabaseHelperTests` use a stand-in). ScadaBridge's inbound API is the designated successor of MESAPI's move-in/move-out surface, which is why it needs the same `SAPID → Code` lookup.
- **HistorianGateway ↔ BT:** no direct coupling, but both read `Runtime.dbo.Events` (HG's SQL-path `ReadEvents`) and both need the Historian SQL instance. BT's CLR `GetTSV`/`GetAggregateValues` is a bespoke, in-database equivalent of what HG's `ReadRaw`/aggregate retrieval modes do over gRPC — a candidate for consolidation if the report layer is ever re-platformed.
- **OtOpcUa / mxaccessgw:** no coupling.
- **Break surface:** the sproc signatures + `Machine.Code`/`SAPID`/`ZTag` semantics (Galaxy scripts, MESAPI, ScadaBridge inbound methods all bind to them), the `OtherData` JSON key conventions (DARS sprint 1 defines ~14 new keys with single writers), and the `INSQL`/`Runtime` co-location assumption.
## 6. Analysis — gaps and recommendations (2026-09-03)
1. **Push the branch.** ~~Put both copies under git~~ — done locally 2026-09-03 (`feat/machine-batch-transfer`). Remaining: push it to `ZimmerBiomet/BTDB`, review/merge, decide whether BTDB also gets a mirror on `gitea.dohertylan.com` like the other sisters, then delete the superseded `DARS/WW_ARCH/BatchTracker_DB/` copy.
2. **Rotate and externalize the checked-in credentials** (three SQL passwords, one domain account). _Externalised on `feat/manager-blazor` (`${secret:btdb/...}` refs, store outside the content root); rotation is a cut-over step (§8)._ The family standard is `ZB.MOM.WW.Secrets` `${secret:}` with an absolute `Secrets:SqlitePath` outside the content root.
3. **Manager re-platform** — _done on `feat/manager-blazor`, see §8_: net6.0 is EOL; Telerik is pinned by an absolute machine path; `System.DirectoryServices` binds the user's own credentials on a domain-joined box. A .NET 10 rewrite on the family libs is mostly assembly: Auth 0.3.0 (LDAP + `UG_WW_*` group→role mapping, `FallbackServers`), Theme 0.4.1, Health 0.3.0 (`MapZbHealth`, so Overview can watch it), Telemetry, Configuration, Audit (the batch-edit audit is a natural `ZB.MOM.WW.Audit` writer), Secrets. Keep Dapper + the embedded queries; replace Telerik `.trdp` with server-side rendering only if the three audit reports are still used.
4. **Injection surfaces in the SQL layer:** `AddOtherData*` build dynamic SQL from `@DataKey`/`@DataValue`; `GetTSV`/`GetAggregateValues` interpolate tag names and append `@queryParams` raw into `OPENQUERY`. Trusted callers today, but ScadaBridge scripts and the inbound API widen the caller set. Fix `AddOtherData*` with parameterised `JSON_MODIFY` (no dynamic SQL needed), and whitelist `queryParams` to the three `ww*` keys.
5. **ServiceStack auth tables** (`UserAuth*`, `ApiKey`) are MESAPI's, not BT's domain. Once ScadaBridge's inbound API fully replaces MESAPI (Ipsen MoveIn is the first slice; ScadaBridge keys already live in the `zbauth` schema via `Auth.ApiKeys.SqlServer` 0.3.0), those tables are dead weight in the schema and should be dropped from the sqlproj rather than carried forward.
6. **Host inventory drift:** 2023 config says DEV BT is on `wonder-sql-vd01`; ScadaBridge's 2026 design says `wonder-sql-vd03`. Probe and record the answer in `deployment.md` §2.1 (which currently has no BT row at all).
7. **`install.bat`/`uninstall.bat` are wrong for this project** (they install `WWNotificationSystem`). _Deleted on `feat/manager-blazor`; replaced by `deploy/install-service.bat` (NSSM, service `ZB.MOM.WW.BTDB.Manager`)._
8. **CLR + Historian co-location is a hard deployment constraint** the index did not previously record: BT cannot be moved to the app-tier SQL (`wonder-app-vd03,1433`, where ScadaBridge's ConfigDb lives) without breaking every report wrapper. Any consolidation plan must keep BT on the Historian instance or replace the CLR with HistorianGateway calls.
## 7. Primary commands (Windows only)
```powershell
# from ~/Desktop/btdb (master)
dotnet build Manager\BTDBManager.csproj # net6.0; needs Telerik Reporting R2 2022 at the HintPath
msbuild Database\Database.sqlproj /p:Configuration=Release # SSDT (VS 2022 + SSDT) → bin\Release\Database.dacpac
SqlPackage /Action:Publish /SourceFile:Database\bin\Release\Database.dacpac /TargetServerName:<historian-sql> /TargetDatabaseName:BT
# Instance prerequisites: CLR enabled, `INSQL` linked server, `Runtime` DB reachable by three-part name
```
```bash
# feat/manager-blazor (any OS for the app; SSDT still Windows-only)
./build.sh # restore + build (Release, warnings-as-errors) + test BTDB.slnx
dotnet run --project src/ZB.MOM.WW.BTDB.Manager # appsettings.Development.json → local glauth; needs a BT
pwsh deploy/publish.ps1 # self-contained win-x64 → deploy/out/ (+ NSSM install scripts)
```
```bash
# Local Docker rig (2026-09-04, any OS) — see §9
docker/deploy-db.sh # BT on scadabridge-mssql from Database/ (dacpac + SQL CLR); --recreate drops it first
docker/deploy.sh # publish FDD on the host → zb-btdb-manager:local, up on :9877 (glauth login enabled)
```
On `master` nothing builds on macOS (SSDT/CLR need Windows MSBuild, the MVC Manager needs Telerik + `System.DirectoryServices`). On the branch the app and its tests build and run on macOS; only `Database.sqlproj` (kept alone in `BTDB.sln`) still needs Windows — but §9's side projects build the same sources into a dacpac and a CLR assembly anywhere, for the local rig.
## 8. Blazor conversion — branch `feat/manager-blazor` (2026-09-04)
Plan: `btdb/docs/plans/manager_blazor_conversion.md` (+ `.tasks.json` tracker beside it; mockups `docs/mockups/manager_blazor_mockups.html`). Executed 2026-09-04 in worktree `~/.config/superpowers/worktrees/BTDB/feat-manager-blazor`; **uncommitted and unpushed at the time of writing** — phases 1–3 (scaffold, core, auth, widgets, queries, all pages) plus the local half of phase 4 are done; the DEV-box half of phase 4 (parity run, secrets seed + rotation, NSSM side-by-side, port swap) is written up as a runbook and not yet performed.
- **Shape on the branch:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor Web App, InteractiveServer, net10.0) + `src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer moved verbatim from the MVC app, hardened) + two xunit/bUnit test projects (334 tests, warnings-as-errors gate via root `Directory.Build.props`, `build.sh`/`build.ps1`). `BTDB.sln` keeps only `Database.sqlproj`. `Manager/` (MVC, Kendo, the three Telerik `.trdp` reports with embedded connection strings, the mis-named `install.bat`) is **deleted**; `/Api/GetMachines`+`GetMachineGroups` are gone (no external consumer — ScadaBridge/MESAPI read `dbo.Machine` by SQL).
- **Family libs:** Theme 0.4.1, Health 0.3.0 (`MapZbHealth`; readiness = a `SELECT 1` against `BT` with a 3 s timeout, so `/health/ready` is 503 until the DB answers — register it in Overview), Telemetry + Serilog 0.1.0, Configuration 0.1.0 (`ConfigPreflight` requires `ConnectionStrings:BT` and `Btdb:Security:Ldap:Server`), Secrets 0.6.2 (`${secret:btdb/connectionstrings/{bt,qa,prod}}`, `${secret:btdb/ldap/serviceaccountpassword}`; SQLite store `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`, KEK from `ZB_SECRETS_MASTER_KEY`), Auth.Ldap/AspNetCore 0.3.0 (service-account bind, `Btdb:Security:GroupToRole` → `CanonicalRole`; policies Viewer / BatchEdit (Operator+) / MachineConfig (Administrator); cookie `btdb.auth`, plain-HTTP cookie because the box has no certificate). `Microsoft.Data.SqlClient` 7.x with `Encrypt=False` forced by `BtConnectionStrings.Normalize` unless the string sets it.
- **Behavioural deltas vs the MVC app worth knowing:** config import derives the group set from the selected machines' memberships (no separate group pick) and has a dry-run preview (`MachineConfigImportPreview.sql`: same MERGEs, `OUTPUT` into a table variable, always `ROLLBACK`); batch-edit timestamps are 7-fraction UTC strings validated on blur (no Kendo mask); the three audit reports are HTML tables with a print stylesheet instead of Telerik PDFs; `MachineAlarm` DTO property order/types were wrong in the MVC app (alarm import always threw) and are fixed against `MachineAlarmType`; every `Save*` runs in a transaction and `THROW 5001x` maps to `BtdbSaveException`.
- **Cut-over material:** `docs/operations/cut-over.md` (parity checklist per route + THROW paths + audit-username check, secrets seed via the `secret` console + rotation, NSSM side-by-side on `:9877`, port swap to `:9876`, Overview registry entry), `deploy/` (`publish.ps1`, `install-service.bat` / `uninstall-service.bat` for NSSM service `ZB.MOM.WW.BTDB.Manager`), `tools/smoke-new-queries.sql` (runs the six never-executed queries read-only against DEV with sqlcmd). Still open after cut-over: Overview lacks the mockup's per-machine "open > 24 h" rows; Ldap `Server`/`SearchBase`/`ServiceAccountDn` are blank in `appsettings.json` by design (per-environment).
## 9. Local Docker rig — `docker/` (2026-09-04)
Mirrors the HistorianGateway / Overview pattern: publish framework-dependent on the host (Gitea feed creds), copy into `aspnet:10.0`, never restore in Docker. Compose project `zb-btdb`, container `zb-btdb-manager`, image `zb-btdb-manager:local`, port `:9877` (same as the DEV side-by-side port, so `appsettings.json`'s Kestrel section is the single binding authority; the Dockerfile clears `ASPNETCORE_URLS`/`ASPNETCORE_HTTP_PORTS`). `ASPNETCORE_ENVIRONMENT=Production`; all environment-specific values are compose env vars, dev-only and committed under the family's rig exception.
- **Database:** no MSSQL of its own — joins the external `scadabridge-net` and uses ScadaBridge's `scadabridge-mssql` (the instance that already carries `ScadaBridgeConfig` + `ZbSecretsHub`). `docker/deploy-db.sh`: (1) `docker/db/BT.Clr.csproj` compiles `Database/**/*.cs` to `Database.dll` (net472 via the auto-referenced reference assemblies — builds on macOS); (2) `docker/db/BT.Local.sqlproj` (SDK-style `Microsoft.Build.Sql/2.2.0`, `EnableDefaultSqlItems=false`, globs the four `Database/` folders, Sql130 model, suppresses the legacy build's own SQL71562/71558/71502) → `BT.Local.dacpac`; (3) `db/setup.sql` — `BT` + login `btdb_app` (db_owner); (4) `db/clr-objects.sql` — `clr enabled` on the instance, `sp_add_trusted_assembly` by SHA2-512 (strict security stays on), `CREATE ASSEMBLY [Database] … SAFE` from the bytes streamed as 2000-char `0x` chunks (a single 57 KB literal gets split by sqlcmd), then `StringJoin` aggregate, `StringCompare`/`CheckTriggered`/`WhenTriggered`/`WhenCleared`/`GetAggregateValues`, `GetTSV`/`GetMachineBatchTSV`/`GetCycleTSV`/`GetLinkedBatchTSV` with SSDT's type mapping; (5) `sqlpackage /Action:Publish` from a one-shot container (`db/Dockerfile.sqlpackage`, SDK image + the dotnet tool — the host's 10.0.5 runtime could not run the tool's 10.0.11 pin) with `DropObjectsNotInSource=false`, `BlockOnPossibleDataLoss=false`, `AllowIncompatiblePlatform=true`; (6) smoke. Result on the rig: 23 tables, 37 T-SQL procs + 1 TVF, 10 CLR objects, `Database` assembly `SAFE_ACCESS`. The script diffs the legacy `<Build Include>` list against the folders and warns on drift (found: `ElmaData_Nitric.sql` + `Elma_Bulk_Query_Nitric.sql` were on disk but not in `Database.sqlproj` — added). `ScadaBridge/infra/teardown.sh`/`reseed.sh` drop the volume and BT with it; re-run `deploy-db.sh`.
- **Why not convert `Database.sqlproj` itself:** `Microsoft.Build.Sql` does not compile SQL CLR, and the Manager depends on `dbo.StringJoin` (batch search) and `dbo.StringCompare` (machine saves). The legacy SSDT project stays the artifact of record; the two side projects reference the same files, nothing is copied.
- **Login:** enabled, `host.docker.internal:3893` + `FallbackServers` `:3894` (glauth-redundant), bind `cn=serviceaccount,dc=zb,dc=local`; `GroupToRole` `SCADA-Admins→Administrator`, `SCADA-Designers→Operator`, `SCADA-Viewers→Viewer`. Verified: `admin`/`designer` sign in, `gw-viewer` refused (fail closed). Literal `ConnectionStrings__BT` / LDAP password env vars override the `${secret:}` refs, so the `/data/btdb-secrets.db` store stays empty and no master key is set (confirmed to boot without one). The two config-import sources point at the local BT.
- **Health / Overview:** `/healthz` 200, `/health/ready` 200 with the `sql` entry (503 when BT is down), `/health/active` 200, `/metrics`. Compose `healthcheck` = bash `/dev/tcp` GET of `/healthz` (liveness on purpose). Registered in `ZB.MOM.WW.Overview/src/…/appsettings.Docker.json` as application **BTDB Manager**, instance `manager`, `BaseUrl http://zb-btdb-manager:9877`, `HasActiveRole false`; the containerised Overview was republished and shows the tile Up with `sql` healthy (the dashboard already sits on `scadabridge-net`, so no extra network).
## 10. State as of 2026-09-11 (supersedes §1, §4, §6.1, §6.3, §6.7, §8 where they differ)
- **Everything is on local `master`** (`78195b8`): the Blazor conversion (§8) was merged, the
`feat/manager-blazor` worktree is gone, and the 2026-09-04 full review
(`docs/reviews/2026-09-04-full-review.md`, reviewed at `c624d41`) was remediated in 12 work-package
branches — `fix/save-batch-edit-null-compare`, `fix/db-lifecycle-sprocs`, `fix/clr-openquery-escaping`,
`fix/config-import-parents-and-nulls`, `test/db-integration`, `fix/manager-robustness`,
`fix/auth-hardening`, `ops/runbook-and-deploy`, `perf/indexes`, `db/schema-hygiene`,
`fix/concurrency-and-async`, `chore/nits` — all merged; 60 findings fixed, 3 partial by decision
(DB-15 `QV_*` impersonation, DB-16 alarm-CTE consolidation, DB-21 ServiceStack table drop), 0 open.
`feat/machine-batch-transfer` (DARS sprint 1, §1 item 1) is merged too, and so is
`feat/transfer-machine-batch` (merged `be976a6`, 2026-09-11): **`dbo.TransferMachineBatch`**, a tenth
Galaxy-facing writer that ends the source batch if still open, starts the destination batch by machine
`Code`, copies the `MachineBatchWO` rows still on the source (`MoveOutTimestamp IS NULL`, narrowed to
`@ContainerID`; blank = whole batch; source rows never modified) and records the `MachineBatchTransfer`
link — by composing `EndMachineBatch` / `StartMachineBatch` / `AddMachineBatchTransfer` in one owned
transaction. Outputs `@o_DestMachineBatchID`, `@o_MachineBatchTransferID`. Documented in
`docs/BatchTracker_Reference.md`; proven by `ValidateLifecycleSprocs.sql` §F (82 PASS on the rig).
`fix/transfer-lot-walk` (merged `5f0da01`, 2026-09-11) **reopened review DB-6**: the 2026-09-04 loop guard
in `AddMachineBatchTransfer` walked backward (toward the batches that fed the destination), which the
already-transferred check makes unreachable for any source, so `A→B` then `B→A` was still accepted; it
now walks forward along source→dest links with `HOLDLOCK`, and the destination row is held like the
source. The chain walks in `GetLinkedBatchTSV.cs` and `LinkedBatchAlarm_Wrapper.sql` picked one root
with `TOP 1` and dropped the other branch of a lot merge (`A→C, B→C`); both now resolve the whole lot
(forward to the final batch, then every batch that fed it) with one CTE pair pinned equal by
`SchemaHygieneTests`. Rig proofs: `ValidateLifecycleSprocs.sql` 87/0, `docker/db/validate-clr.sql`
27/0 over a merged lot with a `Runtime.dbo.Events` stand-in.
`feat/manager-transfers` (merged `695192d`, 2026-09-11) gave the **Manager** the transfer side it lacked: the
batch edit page edits the batch's outgoing `MachineBatchTransfer` row as a fourth MERGEd collection in
`SaveBatchEdit.sql` (guards `50015` self, `50016` destination missing, `50017` loop, `50018` more than one;
destination row and chain walk held like `AddMachineBatchTransfer`), audited into the new
`AuditLogMachineBatchTransfer` under the batch-edit header (new TVP `MachineBatchTransferType`); a read-only
lot panel (`GetBatchLot.sql`, the same `Down`/`Lot` CTE as the CLR, pinned equal by a static test); a lineage
column on Find Batch; a Transfers section on the batch-edit audit page and PDF. Rig proofs: 28 integration
tests (three new: round trip with audit, loop refused, merged lot), smoke 17/17, lifecycle 87/0, transfer
chains 4/0, validate-clr 27/0; gate 961 tests, 0 warnings. No branch is in flight.
- **`master` is ~198 commits ahead of `origin/master` (`d147c10`) and unpushed.** Pushing is the user's
call — the remote is the corporate `ZimmerBiomet/BTDB`.
- **Solution shape now:** `BTDB.slnx` = `src/ZB.MOM.WW.BTDB.Manager` (Blazor host) +
`src/ZB.MOM.WW.BTDB.Manager.Core` (Dapper data layer, embedded `Queries/*.sql`) + five test projects
(`Manager.Core.Tests` static query invariants, `Manager.Tests` bUnit/boot, `Database.Tests` static SQL
+ sqlproj invariants, `Clr.Tests` compiles the CLR sources into net10, `Manager.Core.IntegrationTests`
opt-in on the rig behind `BTDB_TEST_CONNECTION`). `BTDB.sln` keeps `Database/Database.sqlproj`
(Windows MSBuild only). `.gitea/workflows/build.yml` runs `./build.sh` (needs `GITEA_NUGET_TOKEN`).
- **Invariants a future session must not break** (all test-enforced): TVPs bind by ordinal so model
property order + lengths must equal `Database/Types/*.sql`; every write query is transaction-free and
audited (the C# wrapper owns the transaction); `dbo.X` only, never `BT.dbo.X`; `SYSUTCDATETIME()` in
Manager queries; every writer sproc follows the own-or-join `TRY/CATCH` template with locked lookups;
every `.sql` on disk must be in `Database.sqlproj`'s `<Build Include>` list; optimistic concurrency via
`ExpectedLastUpdate` → `THROW 50014`.
- **Secrets/credentials (§6.2):** `${secret:btdb/connectionstrings/{bt,qa,prod}}` (the LDAP
service-account secret is gone since 2026-10-01 — see below; only `dotnet run` against local glauth uses it), store at `C:\ProgramData\ZB.MOM.WW\btdb-manager\secrets.db`,
master key only in `ZB_SECRETS_MASTER_KEY`. Rotation is cut-over step 4.2.
- **Claude artifacts are gitignored + untracked in btdb** (2026-09-11): `CLAUDE.md`, `.claude/`,
`docs/plans/`, `docs/reviews/`, `*.tasks.json` — they exist on disk (btdb's `CLAUDE.md` is the repo
orientation and still loads) but never reach the corporate remote. `docs/operations/`,
`docs/BatchTracker_Reference.md`, `docs/mockups/` stay tracked.
- **Open step:** phase-4 cut-over on the DEV box (`docs/operations/cut-over.md`; 4.1 parity walk-through
gates the rest) — **blocked by the 2026-09-11 remote-access hold** (no psbridge, no winrm; UNC copies and
direct SQL logins still allowed). §6 item 6 (which SQL host carries DEV `BT`) is still unrecorded.
- **2026-10-01 — LDAP sign-in by user bind** (merged `a81a480`): Auth 0.4.0/0.4.1 added opt-in
`LdapOptions.BindMode=User`; BTDB ships it. Sign-in binds as `UserBindTemplate` (`<DOMAIN>\{0}` —
down-level maps exactly to the sAMAccountName the group search uses), the same connection reads the
user's own `memberOf`/`displayName`, and the username returned is the directory's spelling. No service
account, no LDAP secret; the preflight requires the template and refuses `AllowInsecure`/`Transport=None`
with user bind outside Development. Local dev + docker rig stay `BindMode=ServiceAccount` (GLAuth bind DNs
embed the user's OU). Rollout to the three Manager nodes (vd03 first) and retiring the stored secret are
the open steps (plan `docs/plans/2026-10-01-ldap-user-bind.md`, gitignored).
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,5 @@
# ScadaBridge — index history
- **2026-10-13 existing fixes verified and issues closed.** Main `513fa1aa` already includes #35 fail-closed unavailable configured body/SQL redactors (`006202f3`) and #37 Flow Debugger structured/raw validated method inputs (`b5eb08f1`). Fresh full AuditLog project 407 passed/0 failed; FlowDebuggerPageTests 47 passed/0 failed; both terminal exit0. Remote ancestry confirmed; #35/#37 closed with evidence. No source changes/deployment/MES transactions. #36 runtime reset-echo overload is implemented, but QA/prod script port remains tracked; #30 per-site PSK deployment gate remains open.
- **2026-10-13 #36 runtime regression proof refreshed.** Rebuilt HandshakeResetEchoTests passed all three cases with local fakes, terminal exit0; an earlier no-build attempt matched no tests and is excluded from evidence. Tracker now distinguishes implemented runtime b623fdb0 from remaining QA/prod database-owned script rollout (deployment register row119, scripts13/14/36 and15/16/25/26/37/38). No database writes, deployments or fresh live rig claims.
+4 -2
View File
@@ -7,8 +7,10 @@ obj/
[Aa]rtifacts/
*.user
# Claude Code tooling / agent worktrees (local-only)
.claude/
# Claude Code tooling / agent worktrees (local-only) — but .claude/skills/ IS tracked:
# the sister-project-history skill holds the per-project narratives the root index points at.
.claude/*
!.claude/skills/
# Local ops/infra notes — internal access details, never publish
zimmer-vpn-tunnel.md
+77 -216
View File
File diff suppressed because one or more lines are too long
+37
View File
@@ -0,0 +1,37 @@
# ZB.MOM.WW.Audit
Shared audit event model + writer seam for the **ZB.MOM.WW SCADA family** (single package; DEEP-adopted by all apps as the canonical record). Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/audit/`.
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Narrative
The audit component is fully populated: a normalized [`spec`](components/audit/spec/SPEC.md), an
[`event-model`](components/audit/spec/EVENT-MODEL.md) reference, a
[`shared-contract`](components/audit/shared-contract/ZB.MOM.WW.Audit.md), three
[`current-state`](components/audit/current-state/) docs, and an adoption [`GAPS`](components/audit/GAPS.md)
backlog. Common ground = canonical `AuditEvent` record + `AuditOutcome` enum + `IAuditWriter` /
`IAuditRedactor` seams + helpers (`NullAuditRedactor`, `TruncatingAuditRedactor`, `NoOpAuditWriter`,
`CompositeAuditWriter`, `RedactingAuditWriter`) + `AddZbAudit` DI registration; left per-project =
transport/storage and domain vocabulary. Closes the loop on Auth — audit's `Actor` field = the Auth
principal. `IAuditRedactor` is aligned with Telemetry's `ILogRedactor` seam convention.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Audit/`](ZB.MOM.WW.Audit/)
(.NET 10; 1 package — `ZB.MOM.WW.Audit`; only non-BCL dependency `Microsoft.Extensions.DependencyInjection.Abstractions`;
23 tests (verified 2026-07-18); `dotnet pack` → 1 nupkg @ 0.1.0). Repo: `https://gitea.dohertylan.com/dohertj2/zb-mom-ww-audit`.
**Adopted across all three apps on 2026-06-02** (audit GAPS #1–#6) on each repo's `feat/adopt-zb-audit` branch
(stacked on `feat/adopt-zb-auth`) — committed + reviewed, then **merged into the repo's local default (main/master)
and PUSHED to origin (gitea) on 2026-06-03** (in sync). Depth =
**DEEP adopt** (the canonical 9-field `AuditEvent` is the record everywhere; domain fields ride in `DetailsJson`).
OtOpcUa: canonical record + `AuditWriterActor : IAuditWriter` + `Outcome` column/migration + `ClusterAudit` fix.
MxGateway: new canonical SQLite `audit_event` store + `IAuditWriter` + `IApiKeyAuditStore`→canonical adapter.
**ScadaBridge: a full audit-subsystem re-architecture** (the program's largest task) — canonical record everywhere via a
deterministic codec; site SQLite split into `audit_event` + an `audit_forward_state` forwarding sidecar; central
partitioned `dbo.AuditLog` collapsed to 10 canonical cols + persisted computed cols (`CollapseAuditLogToCanonical`
migration, MSSQL-verified). Phase 3 wires `Actor` from the Auth principal at authenticated emit sites (per-app
`IAuditActorAccessor`). Per-repo detail in [`components/audit/GAPS.md`](components/audit/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization-phase2-deep.md` + `…-scadabridge-audit-rearch.md`.
Build/test from `ZB.MOM.WW.Audit/`: `dotnet test`. Consumer matrix: all three apps consume the single
`ZB.MOM.WW.Audit` package (OtOpcUa, MxAccessGateway, ScadaBridge — DEEP-adopted as the canonical record).
+3 -3
View File
@@ -16,9 +16,9 @@ Canonical audit event model, best-effort writer seam, and redactor seam for the
| Consumer | ZB.MOM.WW.Audit |
|---|:---:|
| **OtOpcUa** | yes (adoption deferred) |
| **MxAccessGateway** | yes (adoption deferred) |
| **ScadaBridge** | yes (adoption deferred — "align, don't replace") |
| **OtOpcUa** | yes (adoption deferred; stale → #17) |
| **MxAccessGateway** | yes (adoption deferred; stale → #17) |
| **ScadaBridge** | yes (adoption deferred — "align, don't replace"; stale → #17) |
Adoption is tracked in `components/audit/GAPS.md` in the outer `scadaproj` workspace. Each app brings its own transport (Akka broadcast / SQLite append / SQL ingest) and domain vocabulary (channels / kinds / event-types) — those stay per-project. The shared library provides the canonical record and the two seams that decouple "what to audit" from "how to store it".
+43
View File
@@ -0,0 +1,43 @@
# ZB.MOM.WW.Auth
Shared login / identity / API-key library for the **ZB.MOM.WW SCADA family** (4 packages + `ApiKeys.SqlServer`; consumed by OtOpcUa, ScadaBridge, MxAccessGateway, HistorianGateway). Build/test from this directory: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`). Component spec + gaps: `../components/auth/`.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #5 (roll apps to `0.4.1`), #6 (VD03 NAM AD cutover), #18 (Secrets migrator jitter, ported from here). The history below is a record, not a status tracker; file new follow-ups as issues.
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Component-table row as it read in the index
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Auth (login / identity / authz) | Adopted (**feed carries `0.3.0` as of 2026-09-02 — five packages, the new one being `ZB.MOM.WW.Auth.ApiKeys.SqlServer`; ALL FOUR apps pin `0.3.0` as of 2026-09-03**: OtOpcUa `master @ b8b4c18f`, ScadaBridge `main @ e45c4af9` (the `inbound-api-key-shared-store` work, merged), MxAccessGateway `main @ 4f931e4`, HistorianGateway `main @ d8d890c` — each built + tested with no new failures versus its pre-bump baseline, all PUSHED to origin. The preceding `0.2.1` pins landed 2026-08-13 (OtOpcUa `08e33fc7`, ScadaBridge `d28824d9`, MxAccessGateway `1cb14d2`, HistorianGateway `e42ff31`). **`0.2.0` (2026-08-10) adds LDAP backup-DC sticky failover**, **`0.2.1` (2026-08-12) fixes a defect that broke AD logins outright** — an AD continuation referral escaped `NovellLdapConnection.Search`, so any `SearchBase` at a domain root failed every login as `ServiceAccountBindFailed` against a perfectly healthy directory. Live-proven against `DC=nam,DC=zimmer,DC=com`; the app-level exposure is closed by the pin bumps. **`FallbackServers` is live-gated** via the local redundant GLAuth pair ([`infra/glauth-redundant/`](infra/glauth-redundant/), primary :3893 + backup :3894 on the dev Mac): HistorianGateway dashboard drill PASS 2026-08-13 (failover, stickiness, walk-back). | Shared `ZB.MOM.WW.Auth` lib | [`components/auth/`](components/auth/) | [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/) |
### Narrative
The auth component is fully populated: a normalized [`spec`](components/auth/spec/SPEC.md), a
proposed [`shared-contract`](components/auth/shared-contract/ZB.MOM.WW.Auth.md), three
[`current-state`](components/auth/current-state/) docs, and an adoption [`GAPS`](components/auth/GAPS.md)
backlog. Common ground = LDAP/GLAuth identity + peppered-HMAC API keys; left per-project =
the authz vocabularies (OPC-UA permissions / gRPC scopes / roles + site-scoping).
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Auth/`](ZB.MOM.WW.Auth/)
(a plain directory of this repo — no nested `.git`, verified 2026-08-07; .NET 10; 4 packages — `Abstractions`, `Ldap`, `ApiKeys`, `AspNetCore`;
250 tests passing + 2 skipped (verified 2026-08-10 at `0.2.0`; was 215+1 at `0.1.5`); feed carries `0.1.0`–`0.2.0`). The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-auth-shared-library.md).
**Adopted across all three apps on 2026-06-02** (auth GAPS #1–#8) on each repo's `feat/adopt-zb-auth` branch —
committed + reviewed, then **fast-forward-merged into the repo's local default (main/master) and PUSHED to origin
(gitea) on 2026-06-03** (in sync; the `feat/*` branches kept locally as history). Cutover: shared `Auth.Ldap`,
`Auth.ApiKeys` (ScadaBridge inbound fully re-architected to the keyId/Bearer model), `IGroupRoleMapper<TRole>` seam,
`Transport`-enum config, canonical `ZbClaimTypes`/`ZbCookieDefaults`, unified dev base DN `dc=zb,dc=local`, and the
canonical-six role vocabulary (with ScadaBridge's accepted auditor/admin SoD collapse). Consumer pins: **all four apps pin `0.3.0` as of 2026-09-03** (commits listed in the table row above, all pushed to origin); all four pinned `0.2.1` from 2026-08-13 before that. Prior history — all four pinned `0.1.5` (verified 2026-08-07): MxGateway via `e107019` 2026-07-18 (earlier bumped `0.1.2`→`0.1.4` 2026-07-09 for the G-2 API-key-expiry verifier enforcement — commit `197731a`; also required implementing the 0.1.3 `IApiKeyAdminStore.SetScopes/SetEnabled` members on a test fake), ScadaBridge `50d79ed1` + HistorianGateway `df211ee` 2026-07-18, OtOpcUa `1adaa2fc` 2026-07-20 (PR #484, family alignment from `0.1.1`). Per-repo detail in [`components/auth/GAPS.md`](components/auth/GAPS.md) +
`docs/plans/2026-06-02-auth-audit-normalization*.md`.
Build/test from `ZB.MOM.WW.Auth/`: `dotnet test` (the `ApiKeys.SqlServer` live suite is gated on `AUTH_SQLSERVER_CONNSTR`, skipped otherwise). Consumer matrix: OtOpcUa → Abstractions+Ldap+AspNetCore;
MxAccessGateway & HistorianGateway → the original four; ScadaBridge → those four **plus `ApiKeys.SqlServer`** (ApiKeys not used by OtOpcUa).
**`0.1.4` (2026-07-09, archreview G-2):** optional `ApiKeyRecord.ExpiresUtc` (nullable = never-expires), `ApiKeyFailure.KeyExpired`, `ApiKeyVerifier` expiry enforcement (inclusive, injected clock), a `CreateKeyAsync(expiresUtc)` overload, and a SQLite **schema v3** additive `expires_utc` column (idempotent `ALTER`; donor v2 DBs upgrade in place, no key invalidated). **All four Auth pkgs published to the Gitea feed at `0.1.4`** (2026-07-09). **HistorianGateway adopted the expiry surface at `0.1.4`** — `apikey create --expires` + dashboard Expires column (Task-10 done). **`0.1.5` (2026-07-18)** is `0.1.4` plus the `SQLitePCLRaw.lib.e_sqlite3 2.1.12` transitive security pin (GHSA-2m69-gcr7-jv3q), no API change; all four apps now pin it (see consumer pins above). **`0.2.0` (2026-08-10) — LDAP backup-DC sticky failover** (plan `docs/plans/2026-08-10-ldap-backup-dc-failover.md`, design `…-design.md`; **4 pkgs published to the feed, restore-verified**; family `Directory.Build.props` now `0.2.0`): one additive `LdapOptions.FallbackServers` list (`"host"` or `"host:port"`, empty default = failover off, single-endpoint behaviour byte-unchanged) makes `LdapAuthService` walk an ordered endpoint list (primary + fallbacks) on **system-side failures only** — connect/TLS, service-account bind, or search. A user-side answer from a healthy directory (`Success`/`UserNotFound`/`AmbiguousUser`/`BadCredentials`/`GroupLookupFailed`) is **final and never retried elsewhere** (re-asking a second DC about a rejected password would multiply one bad-password event into several against the account lockout policy). Endpoint preference is **sticky** — the last endpoint that gave *any* authoritative answer (success **or** a user-side failure) is tried first next time — so a primary outage costs its connect timeout once, not per login; an exhausted walk leaves the preference untouched. Fail-closed contract preserved per-endpoint; boot validation rejects a malformed `FallbackServers` entry naming the field; optional `ILogger<LdapAuthService>` emits a Warning per failed hop (endpoint + stage, no secrets) and an Information on sticky change; wired via `AddZbLdapAuth` (`GetService`, so logging is optional). Motivation: the ZB production directory is NAM AD with two DCs. Unit suite covers the walk/stickiness (14 tests) + env-gated live GLAuth failover test (`ZB_LDAP_IT=1`). **No `Abstractions` contract change beyond the new option; no `LdapAuthFailure` enum change.** App adoption (pin bump to `0.2.0` + authoring `FallbackServers`) is a follow-up.
**`0.2.1` (2026-08-12) — AD continuation referrals no longer fail the search** (**4 pkgs published to the feed, restore-verified**; the assembly restored *from the feed* carries the fix and `0.2.0` does not — checked as a positive control, not by trusting the push). Active Directory answers **any search based at a domain root** with continuation references (to `CN=Configuration`, `DC=ForestDnsZones`, …). With referral following disabled — the default, and what we want, since chasing one would re-bind somewhere we never authenticated — Novell surfaces each by throwing `LdapReferralException` from `Next()`. That escaped `NovellLdapConnection.Search`, and `LdapAuthService` maps a failed search to `ServiceAccountBindFailed`, so **every login against `SearchBase = DC=nam,DC=zimmer,DC=com` failed system-side on both DCs while the directory, the credentials and the service account were all healthy** — ~546 ms, identical on each endpoint, so failover could not help either. Found by the new `tools/LdapProbe` (below) and live-proven fixed against the ZB NAM domain root. The search loop now skips referrals (`ReadEntries(ILdapSearchResults)`, extracted so it is unit-testable through the Novell seam without a live directory) and continues; a 1000-referral cap guards a client that throws without advancing, since that would spin forever inside a fail-closed auth path. **Non-referral faults still propagate** — swallowing those would turn "the directory is broken" into "no such user", which is the wrong answer to give about someone's credentials. 7 new tests (`NovellSearchReferralTests`), **6 verified red against the pre-fix code**, the seventh being the non-referral control that must stay green; suite 257 pass / 2 skip / 0 fail. **No `Abstractions` change; no config change.** **Consumer impact: an app on `0.1.5` cannot authenticate against an AD domain root at all**, so the pin bump is a functional fix, not housekeeping. **Closed 2026-08-13: all four apps adopted `0.2.1`** (see the table row for commits), and `FallbackServers` was live-gated the same day against the redundant local GLAuth pair (`infra/glauth-redundant/`).
**`0.3.0` (2026-09-02) — `ZB.MOM.WW.Auth.ApiKeys.SqlServer`, a SQL Server provider for the API-key store** (design `ScadaBridge/docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; **5 pkgs published to the feed**, `ApiKeys.SqlServer` search-verified from the ScadaBridge checkout; family `Directory.Build.props` now `0.3.0`). Motivation: `Auth.ApiKeys`' SQLite store is a per-node file, so on a two-node active/standby host (ScadaBridge central) a key minted on one node exists only there and a failover 401s every inbound caller (ScadaBridge deferred-work row 108). The new package implements `IApiKeyStore`/`IApiKeyAdminStore`/`IApiKeyAuditStore` over `Microsoft.Data.SqlClient` in a `zbauth` schema (v1: `schema_version`, `api_keys`, `api_key_audit`; all timestamps `DATETIMEOFFSET(7)`, tick-exact) with a concurrent-boot-safe migrator (Serializable tx, retry on 1205/2714/1913/2627 with **jittered** backoff — the Secrets replicator's flat `100*attempt` backoff exhausted five retries about once per sixty 8-way boots in the port's soak test, so the same latent defect is an open follow-up there → #18) and a `SqliteApiKeyStoreImporter` that copies an existing SQLite file into the schema insert-where-absent (idempotent, so both nodes of a pair can import their own file and the result is the union). Wiring: `services.AddZbApiKeyAuthSqlServer(config, apiKeySection, sqlServerSection)` registers the SQL Server stores + migrator **before** delegating to `AddZbApiKeyAuth`, whose `TryAdd*` registrations then yield (same displacement mechanism as Secrets 0.5.0). One `Abstractions` addition, additive: `IApiKeyStoreMigrator`, which `SqliteAuthStoreMigrator` now implements and `ApiKeyAdminCommands` accepts via a new ctor (old `SqliteAuthStoreMigrator` ctor retained — the three `0.2.1` consumers compile unchanged). Store semantics are pinned to the SQLite contract tests re-run live (mark-used skips revoked keys, delete only removes revoked keys, rotate clears last-used/revoked, list newest-first). 92 tests in the new project, 351 across the solution, 0 failed. A DBA-run copy of the DDL ships at `docs/sql/zbauth-schema.sql`.
**`tools/LdapProbe`** (`ldapprobe`, not packable, outside the Auth solution) is the diagnostic that found the above: it drives the **real** `LdapAuthService` so its verdict is what the four apps produce, then separately queries the directory for what the library cannot report — full group DNs, **nested** groups via `LDAP_MATCHING_RULE_IN_CHAIN`, the **primary group** (which AD omits from `memberOf`, so it can never be a role group), group type (security vs distribution), decoded `userAccountControl`, referral counts, and the TLS chain with key sizes. `--list-groups '<pat>'` enumerates every group in the directory matching a pattern, which a membership dump structurally cannot do. Findings recorded in [`ldap_setup.md`](ldap_setup.md), including the **`UG_WW_*` Wonderware group convention (39 groups)** that partly overturns that doc's earlier "no group→role convention exists" claim — it was true of Ignition, false of the domain.
+6 -6
View File
@@ -5,12 +5,12 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<!-- 0.3.0: new ZB.MOM.WW.Auth.ApiKeys.SqlServer package — a shared SQL Server API-key store
for clustered hosts (e.g. a ScadaBridge central pair), fixing the per-node SQLite store
going out of sync on failover. Adds the IApiKeyStoreMigrator abstraction (SQLite
implements it unchanged). SQLite stays the default for every existing consumer; no
consumer code change is required to move to 0.3.0. -->
<Version>0.3.0</Version>
<!-- 0.4.0: opt-in LdapOptions.BindMode = User — authenticate by binding as the signing-in user
(UserBindTemplate) and read their own groups on the same connection; no service account.
ServiceAccount stays the default, so existing consumers need no change. -->
<!-- 0.4.1: Username canonicalised from the entry's UserNameAttribute; empty passwords refused
in both bind modes (ServiceAccount-mode behaviour change); template docs prefer EXAMPLE\{0}. -->
<Version>0.4.1</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
+17 -1
View File
@@ -30,7 +30,23 @@ Authentication and authorisation libraries for the **ZB.MOM.WW SCADA family** (O
## Versioning
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.3.0**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
All five packages are versioned **lockstep** from `Directory.Build.props`. The current release is **0.4.1**. A single version bump in `Directory.Build.props` bumps all five packages simultaneously — consumers should reference the same version for all ZB.MOM.WW.Auth packages.
### 0.4.1 — LDAP review fixes
- **Canonical username (both modes):** the search now also requests `UserNameAttribute`, and `LdapAuthResult.Username` is the entry's first value of it (the directory's spelling), so `JDoe` and `jdoe` sign in as one identity in consumers' audit columns. Falls back to the normalised typed name when the entry has no such value.
- **Behaviour change for `ServiceAccount`-mode consumers:** an empty (or null) password is now refused by the library as `BadCredentials` before any network call, in both bind modes. Previously only `User` mode refused it; in `ServiceAccount` mode the re-bind as the user's DN with an empty password is an unauthenticated bind that AD reports as success, so a consumer without its own edge check admitted anyone.
- **Template guidance:** prefer the down-level `UserBindTemplate` `EXAMPLE\{0}` — it maps exactly to the `sAMAccountName` the group search uses. A UPN template (`{0}@example.com`) is only correct where every account's UPN prefix equals its `sAMAccountName`; AD resolves UPN binds against the explicit `userPrincipalName`, so a mismatch can fail sign-in or bind a different account than the one whose groups are read.
- No `Abstractions` contract change.
### 0.4.0 — LDAP sign-in by user bind (opt-in)
- **`LdapOptions.BindMode`** (`ServiceAccount` default | `User`), **`UserBindTemplate`** (`{0}` = the login name, e.g. `EXAMPLE\{0}` — preferred, see 0.4.1 — or `{0}@example.com`) and **`AcceptDomainQualifiedNames`** (default `true`).
- In `User` mode `LdapAuthService` binds as the user; success authenticates them and the same connection reads their entry (`UserNameAttribute`, `DisplayNameAttribute`, `GroupAttribute`). No `ServiceAccountDn` / `ServiceAccountPassword` is needed or validated.
- Failure mapping: empty password or an unusable name → `BadCredentials` with no network call (AD treats an empty-password simple bind as an unauthenticated success). Bind result 49 → `BadCredentials`, final — never retried on a fallback DC, so one wrong password is one lockout strike. Any other bind error, a connect failure or a search failure → the system-side bucket (`ServiceAccountBindFailed`) and the next endpoint. Zero/several entries or no groups after a good bind → `GroupLookupFailed`.
- `DOMAIN\name` / `name@suffix` are reduced to `name` (or refused when `AcceptDomainQualifiedNames` is `false`), so the result's `Username` has one spelling (0.4.1 also canonicalises its case from the entry).
- No `Abstractions` contract change beyond the new enum and option properties; `ServiceAccount` mode is unchanged.
- `UserBindTemplate` output is used verbatim as the bind name, so use a down-level (`EXAMPLE\{0}`, preferred) or UPN (`{0}@example.com`, only where every UPN prefix equals the `sAMAccountName`) template; a DN template is not escaped.
### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)
@@ -4,6 +4,20 @@ namespace ZB.MOM.WW.Auth.Abstractions.Ldap;
public enum LdapTransport { Ldaps, StartTls, None }
/// <summary>How <see cref="LdapOptions"/> authenticates a sign-in.</summary>
public enum LdapBindMode
{
/// <summary>Bind as the service account, search for the user, re-bind as the user (0.3.0 behaviour).</summary>
ServiceAccount,
/// <summary>
/// Bind as the signing-in user (identity built from <see cref="LdapOptions.UserBindTemplate"/>);
/// a successful bind authenticates them, and the same connection reads their own entry for groups.
/// No service account is used.
/// </summary>
User,
}
public sealed record LdapOptions
{
public bool Enabled { get; init; } = true;
@@ -19,6 +33,33 @@ public sealed record LdapOptions
public string GroupAttribute { get; init; } = "memberOf";
public int ConnectionTimeoutMs { get; init; } = 10_000;
/// <summary>
/// Selects the authentication flow. <see cref="LdapBindMode.ServiceAccount"/> (the default) is the
/// 0.3.0 bind-then-search-then-bind flow; <see cref="LdapBindMode.User"/> binds as the user and needs
/// no <see cref="ServiceAccountDn"/> / <see cref="ServiceAccountPassword"/>.
/// </summary>
public LdapBindMode BindMode { get; init; } = LdapBindMode.ServiceAccount;
/// <summary>
/// <see cref="LdapBindMode.User"/> only: the simple-bind identity, with <c>{0}</c> replaced by the
/// normalised login name. Must contain <c>{0}</c> exactly once.
/// <para>
/// Prefer the down-level form <c>EXAMPLE\{0}</c>: it maps exactly to the <c>sAMAccountName</c> the
/// group search uses, so the account that binds is the account whose groups are read. A UPN
/// template (<c>{0}@example.com</c>) is only correct where every account's UPN prefix equals its
/// <c>sAMAccountName</c> — AD resolves a UPN bind against the explicit <c>userPrincipalName</c>, so
/// a mismatch can fail sign-in or bind a different account than the one whose groups are read.
/// </para>
/// </summary>
public string UserBindTemplate { get; init; } = "";
/// <summary>
/// <see cref="LdapBindMode.User"/> only: when <see langword="true"/> (default) a login typed as
/// <c>DOMAIN\name</c> or <c>name@suffix</c> is reduced to <c>name</c>; when <see langword="false"/>
/// such input is refused as bad credentials without contacting the directory.
/// </summary>
public bool AcceptDomainQualifiedNames { get; init; } = true;
/// <summary>
/// Ordered fallback LDAP endpoints tried when the primary (and any earlier fallback)
/// fails with a system-side error (connect/TLS, service-account bind, or search).
@@ -0,0 +1,60 @@
namespace ZB.MOM.WW.Auth.Ldap.Internal;
/// <summary>
/// Reduces a typed login to the bare account name used by <see cref="Abstractions.Ldap.LdapBindMode.User"/>:
/// the qualifier is stripped before the name reaches the bind template and the search filter, so audit
/// rows never carry <c>DOMAIN\jdoe</c> for one sign-in and <c>jdoe</c> for the next. Case is NOT
/// normalised here: the returned username is canonicalised from the directory entry's
/// <c>UserNameAttribute</c> value, so <c>JDoe</c> and <c>jdoe</c> also yield one identity; this name is
/// only the fallback when the entry has no such value.
/// </summary>
internal static class LdapUserName
{
/// <summary>
/// Trims <paramref name="input"/> and, when <paramref name="acceptDomainQualified"/>, strips one
/// leading <c>DOMAIN\</c> or one trailing <c>@suffix</c>. Returns <see langword="false"/> (with
/// <paramref name="name"/> empty) for blank input, for both forms at once, for a repeated
/// separator, for an empty side, or for any qualified form when <paramref name="acceptDomainQualified"/>
/// is <see langword="false"/>.
/// </summary>
public static bool TryNormalize(string? input, bool acceptDomainQualified, out string name)
{
name = "";
if (string.IsNullOrWhiteSpace(input))
return false;
var s = input.Trim();
var slash = s.IndexOf('\\');
var at = s.IndexOf('@');
if (slash >= 0 || at >= 0)
{
if (!acceptDomainQualified || (slash >= 0 && at >= 0))
return false;
if (slash >= 0)
{
if (slash == 0 || s.IndexOf('\\', slash + 1) >= 0)
return false;
s = s[(slash + 1)..];
}
else
{
// Unlike the slash branch (whose extracted suffix is checked for emptiness by the
// final length check below), the extracted piece here is the *prefix* — so an empty
// domain part (a trailing "@", e.g. "jdoe@") must be rejected explicitly.
if (at == s.Length - 1 || s.IndexOf('@', at + 1) >= 0)
return false;
s = s[..at];
}
s = s.Trim();
}
if (s.Length == 0)
return false;
name = s;
return true;
}
}
@@ -38,6 +38,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// only mutable state; concurrent logins may race on it, which is benign — the worst outcome
/// is one login starting at a stale endpoint and walking on.
/// </para>
/// <para>
/// With <c>BindMode = User</c> the service-account bind and the user re-bind are replaced by a
/// single bind as the user (<see cref="LdapOptions.UserBindTemplate"/>); see <see cref="CompleteAsUser"/>.
/// </para>
/// </remarks>
public sealed class LdapAuthService : ILdapAuthService
{
@@ -141,6 +145,21 @@ public sealed class LdapAuthService : ILdapAuthService
// filter and the returned result (avoids two identities for one person).
username = username.Trim();
// 3b. An empty password must never reach the directory, in EITHER bind mode: AD answers a
// simple bind with an empty password as an UNAUTHENTICATED bind and reports success —
// the user bind (or the ServiceAccount-mode re-bind as the user's DN) would admit anyone.
// Refused here, before any network call, so no consumer depends on its own edge check.
if (string.IsNullOrEmpty(password))
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
// 3c. User-bind mode: reduce the name to the bare account name the bind template and the
// search filter share. (The returned username is canonicalised from the entry later.)
if (_options.BindMode == LdapBindMode.User
&& !LdapUserName.TryNormalize(username, _options.AcceptDomainQualifiedNames, out username))
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
// 4. Walk the endpoint list, preferred one first. A single-endpoint configuration
// (no FallbackServers) collapses to exactly one attempt — unchanged behaviour.
var preferred = Volatile.Read(ref _preferredIndex);
@@ -235,74 +254,9 @@ public sealed class LdapAuthService : ILdapAuthService
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "connect");
}
// 6. Service-account bind so we can search for the user's DN. A bind failure
// here is a service-account misconfiguration — DISTINCT from a user-credential
// failure — so it maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
}
// 7. Search for the user entry by the configured username attribute. A search
// failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: same enum limitation as Connect above; ServiceAccountBindFailed is the
// closest system-side bucket until a DirectoryUnavailable value exists.
IReadOnlyList<LdapSearchEntry> entries;
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
entries = conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
}
// 8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// Both are authoritative answers from a healthy directory, so they end the walk.
// We never attempt a user bind against an ambiguous DN.
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure
// here is the end user's bad credentials — the directory answered, so this is
// returned directly and never retried against another endpoint.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
// 10. Group extraction. Fail closed: an empty/missing group set, or any error while
// extracting groups, is a GroupLookupFailed — never a zero-group success. This
// too is an answer about the user, not about the endpoint, so it ends the walk.
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
// 11. Success — and only here, with a verified password and >= 1 group.
var displayName = ExtractDisplayName(entry, username);
return LdapAuthResult.Success(username, displayName, groups);
return _options.BindMode == LdapBindMode.User
? CompleteAsUser(conn, username, password)
: CompleteWithServiceAccount(conn, username, password);
}
catch (StageFailure stage)
{
@@ -323,6 +277,121 @@ public sealed class LdapAuthService : ILdapAuthService
}
}
/// <summary>Steps 6–11 of the 0.3.0 flow: service-account bind, search, user re-bind, groups.</summary>
private LdapAuthResult CompleteWithServiceAccount(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{
// 6. Service-account bind so we can search for the user's DN. A bind failure here is a
// service-account misconfiguration — DISTINCT from a user-credential failure — so it
// maps to ServiceAccountBindFailed.
try
{
conn.Bind(_options.ServiceAccountDn, _options.ServiceAccountPassword);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "service-bind");
}
// 7–8. Require exactly one match. Zero -> UserNotFound; two or more -> AmbiguousUser.
// Both are authoritative answers from a healthy directory, so they end the walk.
// We never attempt a user bind against an ambiguous DN.
var entries = SearchUser(conn, username);
if (entries.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.UserNotFound);
if (entries.Count >= 2)
return LdapAuthResult.Fail(LdapAuthFailure.AmbiguousUser);
var entry = entries[0];
// 9. User bind: re-bind as the resolved DN to verify the password. A bind failure here is
// the end user's bad credentials — the directory answered, so this is returned directly
// and never retried against another endpoint.
try
{
conn.Bind(entry.Dn, password);
}
catch (LdapException)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
return SucceedFromEntry(entry, username);
}
/// <summary>
/// <see cref="LdapBindMode.User"/>: the user's own bind is the authentication; the same
/// connection then reads their entry. Only result 49 (invalid credentials — AD's answer for a
/// wrong password AND for unknown, locked, disabled or expired accounts) is the user's failure
/// and final; any other bind error means this endpoint gave no answer, so the walk moves on.
/// </summary>
private LdapAuthResult CompleteAsUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username, string password)
{
var bindName = _options.UserBindTemplate.Replace("{0}", username, StringComparison.Ordinal);
try
{
conn.Bind(bindName, password);
}
catch (LdapException ex) when (ex.ResultCode == LdapException.InvalidCredentials)
{
return LdapAuthResult.Fail(LdapAuthFailure.BadCredentials);
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "user-bind");
}
// After a good bind, anything but exactly one readable entry is a failure to establish the
// user's groups — never a success, and never UserNotFound (the directory just accepted them).
var entries = SearchUser(conn, username);
if (entries.Count != 1)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
return SucceedFromEntry(entries[0], username);
}
/// <summary>Step 7: search for the user entry; a search fault is system-side.</summary>
private IReadOnlyList<LdapSearchEntry> SearchUser(ZB.MOM.WW.Auth.Ldap.Internal.ILdapConnection conn, string username)
{
// A search failure is infrastructure (directory unreachable / unhealthy) — system-side.
// NOTE: the LdapAuthFailure enum has no DirectoryUnavailable value, so a failed search maps
// to ServiceAccountBindFailed, the closest system-side bucket (same limitation as
// Connect), until a DirectoryUnavailable value exists.
try
{
var filter = $"({_options.UserNameAttribute}={LdapEscaping.Filter(username)})";
return conn.Search(_options.SearchBase, filter, BuildSearchAttributes());
}
catch (LdapException)
{
throw new StageFailure(LdapAuthFailure.ServiceAccountBindFailed, "search");
}
}
/// <summary>
/// Steps 10–11: fail-closed group extraction, then success. The returned username is the
/// entry's own <see cref="LdapOptions.UserNameAttribute"/> value (the directory's spelling), so
/// <c>JDoe</c> and <c>jdoe</c> sign in as one identity; the typed (normalised) name is the
/// fallback when the entry carries no such value.
/// </summary>
private LdapAuthResult SucceedFromEntry(LdapSearchEntry entry, string username)
{
IReadOnlyList<string> groups;
try
{
groups = ExtractGroups(entry);
}
catch
{
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
}
if (groups.Count == 0)
return LdapAuthResult.Fail(LdapAuthFailure.GroupLookupFailed);
var canonical = ExtractUserName(entry, username);
return LdapAuthResult.Success(canonical, ExtractDisplayName(entry, canonical), groups);
}
/// <summary>
/// Internal control-flow exception that carries an already-mapped <see cref="LdapAuthFailure"/>
/// out of a stage to the single fail-closed catch site. Never escapes this type.
@@ -337,21 +406,42 @@ public sealed class LdapAuthService : ILdapAuthService
public LdapAuthFailure Failure { get; }
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>search</c>), for diagnostics.</summary>
/// <summary>Which stage faulted (<c>connect</c> / <c>service-bind</c> / <c>user-bind</c> / <c>search</c>), for diagnostics.</summary>
public string Stage { get; }
}
/// <summary>
/// Builds the distinct attribute list requested from the directory. The display-name
/// and group attributes are de-duplicated so we never request the same attribute twice
/// when an operator configures them to the same value.
/// Builds the distinct attribute list requested from the directory: display name, groups and
/// the username attribute (read back as the canonical username). They are de-duplicated,
/// case-insensitively, so we never request the same attribute twice when an operator
/// configures two of them to the same value.
/// </summary>
private IReadOnlyList<string> BuildSearchAttributes()
{
if (string.Equals(_options.DisplayNameAttribute, _options.GroupAttribute, StringComparison.OrdinalIgnoreCase))
return new[] { _options.DisplayNameAttribute };
var attributes = new List<string>(3);
foreach (var name in new[] { _options.DisplayNameAttribute, _options.GroupAttribute, _options.UserNameAttribute })
{
if (!attributes.Contains(name, StringComparer.OrdinalIgnoreCase))
attributes.Add(name);
}
return new[] { _options.DisplayNameAttribute, _options.GroupAttribute };
return attributes;
}
/// <summary>
/// Returns the first non-blank value of the configured username attribute — the directory's
/// canonical spelling — falling back to the (already normalised) typed username.
/// </summary>
private string ExtractUserName(LdapSearchEntry entry, string username)
{
if (entry.Attributes.TryGetValue(_options.UserNameAttribute, out var values)
&& values.Count > 0
&& !string.IsNullOrWhiteSpace(values[0]))
{
return values[0];
}
return username;
}
/// <summary>
@@ -18,8 +18,10 @@ namespace ZB.MOM.WW.Auth.Ldap;
/// <item><see cref="LdapOptions.Server"/> must be specified (no sane default host);</item>
/// <item><see cref="LdapOptions.SearchBase"/> must be specified (the DN root every
/// search runs against);</item>
/// <item><see cref="LdapOptions.ServiceAccountDn"/> must be specified — an empty value
/// would bind anonymously, defeating the search-then-bind authentication flow;</item>
/// <item>in <see cref="LdapBindMode.ServiceAccount"/> mode <see cref="LdapOptions.ServiceAccountDn"/>
/// must be specified — an empty value would bind anonymously, defeating the search-then-bind
/// authentication flow; in <see cref="LdapBindMode.User"/> mode <see cref="LdapOptions.UserBindTemplate"/>
/// must contain <c>{0}</c> exactly once instead;</item>
/// <item>every <see cref="LdapOptions.FallbackServers"/> entry must parse as
/// <c>"host"</c> or <c>"host:port"</c> — a typo'd backup DC must not lie dormant
/// until the primary outage it exists to survive.</item>
@@ -63,7 +65,20 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
"set it to the search-base DN (e.g. \"dc=example,dc=com\").");
}
if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
if (options.BindMode == LdapBindMode.User)
{
// User mode binds as the signing-in user, so there is no service account to require.
// The template is the bind identity: without exactly one {0} every login would bind as
// the same fixed name (or as a name with the user spliced in twice).
if (CountPlaceholders(options.UserBindTemplate) != 1)
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.UserBindTemplate)} must contain {{0}} exactly once when " +
$"{nameof(LdapOptions.BindMode)} is {nameof(LdapBindMode.User)} — e.g. \"{{0}}@example.com\" " +
"or \"EXAMPLE\\{0}\".");
}
}
else if (string.IsNullOrWhiteSpace(options.ServiceAccountDn))
{
return ValidateOptionsResult.Fail(
$"{nameof(LdapOptions.ServiceAccountDn)} is required but was empty or whitespace — " +
@@ -88,4 +103,16 @@ public sealed class LdapOptionsValidator : IValidateOptions<LdapOptions>
return ValidateOptionsResult.Success;
}
private static int CountPlaceholders(string? template)
{
if (string.IsNullOrEmpty(template))
return 0;
var count = 0;
for (var i = template.IndexOf("{0}", StringComparison.Ordinal); i >= 0;
i = template.IndexOf("{0}", i + 3, StringComparison.Ordinal))
count++;
return count;
}
}
@@ -134,4 +134,30 @@ public class ServiceCollectionExtensionsTests
await host.StartAsync();
await host.StopAsync();
}
[Fact]
public void AddZbLdapAuth_BindsUserBindModeKeys()
{
IConfiguration config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{LdapSection}:Server"] = LdapServer,
[$"{LdapSection}:SearchBase"] = "dc=example,dc=com",
[$"{LdapSection}:Transport"] = nameof(LdapTransport.Ldaps),
[$"{LdapSection}:BindMode"] = "User",
[$"{LdapSection}:UserBindTemplate"] = "{0}@corp.test",
[$"{LdapSection}:AcceptDomainQualifiedNames"] = "false",
})
.Build();
var services = new ServiceCollection();
services.AddZbLdapAuth(config, LdapSection);
using ServiceProvider provider = services.BuildServiceProvider();
var options = provider.GetRequiredService<IOptions<LdapOptions>>().Value;
Assert.Equal(LdapBindMode.User, options.BindMode);
Assert.Equal("{0}@corp.test", options.UserBindTemplate);
Assert.False(options.AcceptDomainQualifiedNames);
}
}
@@ -16,6 +16,8 @@ internal sealed class FakeLdapConnection : ILdapConnection
private bool _throwOnConnect;
private bool _throwOnServiceBind;
private bool _throwOnUserBind;
private int? _firstBindResultCode;
private bool _throwOnSearch;
// ---- observation -----
@@ -32,13 +34,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
/// </summary>
public int BindAttempts { get; private set; }
/// <summary>The attribute list passed to the most recent <see cref="Search"/> call (the fake still returns every scripted attribute).</summary>
public IReadOnlyList<string>? SearchAttributes { get; private set; }
/// <summary>Every call in order: <c>connect</c>, <c>bind:&lt;dn&gt;</c>, <c>search:&lt;filter&gt;</c>.</summary>
public List<string> Calls { get; } = new();
// ---- builder methods -----
/// <summary>
/// Scripts a user entry that will be returned by the next <see cref="Search"/> call.
/// Builds a minimal attribute bag with <c>memberOf</c> and optional <c>displayName</c>.
/// Builds a minimal attribute bag with <c>memberOf</c>, optional <c>displayName</c> and any
/// <paramref name="extraAttributes"/> (e.g. <c>sAMAccountName</c>) the test needs.
/// </summary>
public FakeLdapConnection WithUserEntry(string dn, string[] memberOf, string? displayName = null)
public FakeLdapConnection WithUserEntry(
string dn,
string[] memberOf,
string? displayName = null,
IReadOnlyDictionary<string, string[]>? extraAttributes = null)
{
var attrs = new Dictionary<string, IReadOnlyList<string>>(StringComparer.OrdinalIgnoreCase)
{
@@ -46,6 +59,11 @@ internal sealed class FakeLdapConnection : ILdapConnection
};
if (displayName is not null)
attrs["displayName"] = new[] { displayName };
if (extraAttributes is not null)
{
foreach (var (name, values) in extraAttributes)
attrs[name] = values;
}
_scriptedEntries.Add(new LdapSearchEntry(dn, attrs));
return this;
@@ -83,6 +101,24 @@ internal sealed class FakeLdapConnection : ILdapConnection
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> with <paramref name="resultCode"/> on the
/// FIRST bind. In user-bind mode the first bind is the user's own, so 49 simulates a wrong
/// password and e.g. 52 (Unavailable) a domain controller that cannot answer.
/// </summary>
public FakeLdapConnection ThrowOnFirstBind(int resultCode)
{
_firstBindResultCode = resultCode;
return this;
}
/// <summary>Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Search"/> (unhealthy directory).</summary>
public FakeLdapConnection ThrowOnSearch()
{
_throwOnSearch = true;
return this;
}
/// <summary>
/// Throw <see cref="Novell.Directory.Ldap.LdapException"/> from <see cref="Connect"/> to
/// simulate an unreachable directory (infrastructure failure).
@@ -120,6 +156,7 @@ internal sealed class FakeLdapConnection : ILdapConnection
int timeoutMs,
RemoteCertificateValidationCallback? serverCertificateValidationCallback = null)
{
Calls.Add("connect");
ConnectArgs = (host, port, transport, allowInsecure, timeoutMs);
ConnectCertCallback = serverCertificateValidationCallback;
if (_throwOnConnect)
@@ -130,8 +167,12 @@ internal sealed class FakeLdapConnection : ILdapConnection
public void Bind(string dn, string password)
{
BindAttempts++;
Calls.Add($"bind:{dn}");
var isServiceBind = BindAttempts == 1;
if (isServiceBind && _firstBindResultCode is { } code)
throw new Novell.Directory.Ldap.LdapException("Bind refused", code, dn);
if ((_throwOnServiceBind && isServiceBind)
|| (_throwOnUserBind && !isServiceBind)
|| _throwBindDns.Contains(dn))
@@ -147,7 +188,14 @@ internal sealed class FakeLdapConnection : ILdapConnection
string searchBase,
string filter,
IReadOnlyList<string> attributes)
=> _scriptedEntries.AsReadOnly();
{
Calls.Add($"search:{filter}");
SearchAttributes = attributes;
if (_throwOnSearch)
throw new Novell.Directory.Ldap.LdapException(
"Search failed", Novell.Directory.Ldap.LdapException.Unavailable, searchBase);
return _scriptedEntries.AsReadOnly();
}
public void Dispose() { /* nothing to clean up */ }
}
@@ -147,4 +147,45 @@ public class LdapAuthServiceTests
Assert.True(r.Succeeded);
Assert.Equal(new[] { "Eng,ineers", "A,B" }, r.Groups);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_InServiceAccountMode()
{
var fake = new FakeLdapConnection().WithUserEntry(
"cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice",
new Dictionary<string, string[]> { ["cn"] = new[] { "alice" } });
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("alice", r.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_InServiceAccountMode_WhenAttributeAbsent()
{
var fake = new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice");
var svc = new LdapAuthService(Opts(), new FakeLdapConnectionFactory(fake));
var r = await svc.AuthenticateAsync("ALICE", "pw", default);
Assert.True(r.Succeeded);
Assert.Equal("ALICE", r.Username);
}
[Theory]
[InlineData("")]
[InlineData(null)]
public async Task EmptyPassword_IsRefused_InServiceAccountMode_WithoutContactingTheDirectory(string? password)
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("cn=alice,dc=x", new[] { "cn=g,dc=x" }, "Alice"));
var svc = new LdapAuthService(Opts(), factory);
var r = await svc.AuthenticateAsync("alice", password!, default);
Assert.Equal(LdapAuthFailure.BadCredentials, r.Failure);
Assert.Empty(factory.Created);
}
}
@@ -0,0 +1,222 @@
using Novell.Directory.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Ldap;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
/// <summary>
/// <see cref="LdapBindMode.User"/>: bind as the signing-in user, then read their own entry on the
/// same connection. The service account in <see cref="Opts"/> is deliberately populated so the
/// tests prove it is never used.
/// </summary>
public class LdapAuthServiceUserBindTests
{
private static LdapOptions Opts(params string[] fallbacks) => new()
{
Enabled = true,
Server = "dc1",
Port = 636,
Transport = LdapTransport.Ldaps,
SearchBase = "dc=x",
UserNameAttribute = "sAMAccountName",
DisplayNameAttribute = "displayName",
GroupAttribute = "memberOf",
BindMode = LdapBindMode.User,
UserBindTemplate = "{0}@corp.test",
ServiceAccountDn = "cn=svc,dc=x",
ServiceAccountPassword = "svcpw",
FallbackServers = fallbacks,
};
private static FakeLdapConnection Jane() => new FakeLdapConnection()
.WithUserEntry("CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x", "CN=UG_B,OU=g,DC=x" }, "Jane Doe");
private static Task<LdapAuthResult> Login(LdapAuthService svc, string user, string pw = "pw") =>
svc.AuthenticateAsync(user, pw, CancellationToken.None);
[Fact]
public async Task Success_BindsAsUser_ThenSearchesOnTheSameConnection()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
Assert.Equal("Jane Doe", result.DisplayName);
Assert.Equal(new[] { "UG_A", "UG_B" }, result.Groups);
var conn = Assert.Single(factory.Created);
Assert.Equal(new[] { "connect", "bind:jdoe@corp.test", "search:(sAMAccountName=jdoe)" }, conn.Calls);
Assert.Equal(new[] { "jdoe@corp.test" }, conn.BoundDns);
}
[Theory]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
[InlineData(" jdoe ")]
public async Task QualifiedNames_BindAsTheTemplate_AndReturnTheBareName(string typed)
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), typed);
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
Assert.Equal(new[] { "jdoe@corp.test" }, factory.Created[0].BoundDns);
}
[Fact]
public async Task EmptyPassword_IsRefused_WithoutContactingTheDirectory()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe", "");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Theory]
[InlineData("@x")]
[InlineData(@"a\b\c")]
[InlineData("a@b@c")]
public async Task UnusableNames_AreRefused_WithoutContactingTheDirectory(string typed)
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), typed);
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Fact]
public async Task StrictMode_RefusesQualifiedNames_WithoutContactingTheDirectory()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { AcceptDomainQualifiedNames = false }, factory);
var result = await Login(svc, @"NAM\jdoe");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Empty(factory.Created);
}
[Fact]
public async Task WrongPassword_IsFinal_AndNeverAsksTheFallback()
{
var factory = new SequenceLdapConnectionFactory(
Jane().ThrowOnFirstBind(LdapException.InvalidCredentials), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe", "wrong");
Assert.Equal(LdapAuthFailure.BadCredentials, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task NonCredentialBindError_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(
Jane().ThrowOnFirstBind(LdapException.Unavailable), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
Assert.Equal("dc2", factory.Created[1].ConnectArgs!.Value.Host);
}
[Fact]
public async Task ConnectFailure_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().ThrowOnConnect(), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
}
[Fact]
public async Task SearchFailure_FailsOver()
{
var factory = new SequenceLdapConnectionFactory(Jane().ThrowOnSearch(), Jane());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.True(result.Succeeded);
Assert.Equal(2, factory.Created.Count);
}
[Fact]
public async Task EveryEndpointDown_IsSystemSide()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().ThrowOnConnect(), new FakeLdapConnection().ThrowOnConnect());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.Equal(LdapAuthFailure.ServiceAccountBindFailed, result.Failure);
}
[Fact]
public async Task NoEntryAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithNoMatch());
var result = await Login(new LdapAuthService(Opts("dc2"), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
Assert.Single(factory.Created);
}
[Fact]
public async Task TwoEntriesAfterAGoodBind_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithDuplicateMatch());
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task NoGroups_IsGroupLookupFailed()
{
var factory = new SequenceLdapConnectionFactory(
new FakeLdapConnection().WithUserEntry("CN=Jane,DC=x", Array.Empty<string>(), "Jane"));
var result = await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(LdapAuthFailure.GroupLookupFailed, result.Failure);
}
[Fact]
public async Task Username_IsCanonicalisedFromTheEntry_NotTheTypedCase()
{
var factory = new SequenceLdapConnectionFactory(new FakeLdapConnection().WithUserEntry(
"CN=Jane Doe,OU=u,DC=x", new[] { "CN=UG_A,OU=g,DC=x" }, "Jane Doe",
new Dictionary<string, string[]> { ["sAMAccountName"] = new[] { "jdoe" } }));
var result = await Login(new LdapAuthService(Opts(), factory), @"NAM\JDOE");
Assert.True(result.Succeeded);
Assert.Equal("jdoe", result.Username);
}
[Fact]
public async Task Username_FallsBackToTheTypedName_WhenTheEntryLacksTheAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var result = await Login(new LdapAuthService(Opts(), factory), "JDOE");
Assert.True(result.Succeeded);
Assert.Equal("JDOE", result.Username);
}
[Fact]
public async Task Search_RequestsTheUserNameAttribute()
{
var factory = new SequenceLdapConnectionFactory(Jane());
await Login(new LdapAuthService(Opts(), factory), "jdoe");
Assert.Equal(new[] { "displayName", "memberOf", "sAMAccountName" }, factory.Created[0].SearchAttributes);
}
[Fact]
public async Task Search_DeDuplicatesTheUserNameAttribute_CaseInsensitively()
{
var factory = new SequenceLdapConnectionFactory(Jane());
var svc = new LdapAuthService(Opts() with { DisplayNameAttribute = "SAMACCOUNTNAME" }, factory);
await Login(svc, "jdoe");
Assert.Equal(new[] { "SAMACCOUNTNAME", "memberOf" }, factory.Created[0].SearchAttributes);
}
}
@@ -116,4 +116,38 @@ public class LdapOptionsValidatorTests
Assert.False(new LdapOptionsValidator()
.Validate(null, Opts() with { Enabled = false, FallbackServers = new[] { ":::" } })
.Failed);
private static LdapOptions UserOpts() => Opts() with
{
BindMode = LdapBindMode.User,
UserBindTemplate = "{0}@corp.test",
ServiceAccountDn = "",
ServiceAccountPassword = "",
};
[Fact]
public void Defaults_KeepServiceAccountMode()
{
var o = new LdapOptions();
Assert.Equal(LdapBindMode.ServiceAccount, o.BindMode);
Assert.Equal("", o.UserBindTemplate);
Assert.True(o.AcceptDomainQualifiedNames);
}
[Fact]
public void Validator_UserMode_DoesNotRequireServiceAccount() =>
Assert.True(new LdapOptionsValidator().Validate(null, UserOpts()).Succeeded);
[Theory]
[InlineData("")]
[InlineData(" ")]
[InlineData("jdoe@corp.test")]
[InlineData("{0}{0}@corp.test")]
public void Validator_UserMode_RequiresExactlyOnePlaceholder(string template)
{
var result = new LdapOptionsValidator().Validate(null, UserOpts() with { UserBindTemplate = template });
Assert.True(result.Failed);
Assert.Contains(nameof(LdapOptions.UserBindTemplate), result.FailureMessage, StringComparison.Ordinal);
}
}
@@ -0,0 +1,47 @@
using ZB.MOM.WW.Auth.Ldap.Internal;
namespace ZB.MOM.WW.Auth.Ldap.Tests;
public class LdapUserNameTests
{
[Theory]
[InlineData("jdoe")]
[InlineData(" jdoe ")]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
public void Accepting_ReducesToBareName(string input)
{
Assert.True(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
Assert.Equal("jdoe", name);
}
[Theory]
[InlineData(@"NAM\jdoe")]
[InlineData("jdoe@corp.test")]
public void Strict_RefusesQualifiedNames(string input) =>
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: false, out _));
[Fact]
public void Strict_AcceptsBareName()
{
Assert.True(LdapUserName.TryNormalize(" jdoe ", acceptDomainQualified: false, out var name));
Assert.Equal("jdoe", name);
}
[Theory]
[InlineData(null)]
[InlineData("")]
[InlineData(" ")]
[InlineData("@x")]
[InlineData(@"\jdoe")]
[InlineData("jdoe@")]
[InlineData(@"NAM\")]
[InlineData(@"a\b\c")]
[InlineData("a@b@c")]
[InlineData(@"NAM\jdoe@corp.test")]
public void Unusable_IsRefused(string? input)
{
Assert.False(LdapUserName.TryNormalize(input, acceptDomainQualified: true, out var name));
Assert.Equal("", name);
}
}
+32 -2
View File
@@ -4,7 +4,7 @@ Startup configuration-validation library for the **ZB.MOM.WW SCADA family** (OtO
The library normalizes the three-project configuration-validation surface: a failure-accumulating `IValidateOptions` base, reusable rule primitives, a bind+validate+`ValidateOnStart` DI extension, and a pre-host `ConfigPreflight` aggregator for raw `IConfiguration` — so the plumbing is written once and domain rules stay per-project.
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
**Built at 0.1.0. Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17). Adoption tracked in `~/Desktop/scadaproj/components/configuration/GAPS.md`.
---
@@ -66,7 +66,7 @@ ZB.MOM.WW.Configuration/
## Status
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes) — per-app result is tracked in:
Part of the **scadaproj component-normalization family** — this is the configuration + validation component. Built at **0.1.0**. **Adopted by OtOpcUa, MxAccessGateway, and ScadaBridge on 2026-06-01** (local default branches; not yet pushed to remotes — stale → #17) — per-app result is tracked in:
- `~/Desktop/scadaproj/components/configuration/GAPS.md`
@@ -75,3 +75,33 @@ Design documentation:
- `~/Desktop/scadaproj/components/configuration/spec/SPEC.md` — normalized validation target
- `~/Desktop/scadaproj/components/configuration/shared-contract/ZB.MOM.WW.Configuration.md` — proposed shared-library API
- `~/Desktop/scadaproj/components/configuration/current-state/` — per-project current state (code-verified)
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Narrative
The configuration component is fully populated: a normalized [`spec`](components/configuration/spec/SPEC.md), a
[`shared-contract`](components/configuration/shared-contract/ZB.MOM.WW.Configuration.md), three
[`current-state`](components/configuration/current-state/) docs, and an adoption [`GAPS`](components/configuration/GAPS.md)
backlog. Shared = the `IValidateOptions<T>` failure-accumulation base (`OptionsValidatorBase<T>`) +
reusable rule primitives (`ValidationBuilder`: port / host:port / required / positive-duration / one-of /
min-count) + `AddValidatedOptions<TOptions,TValidator>()` (bind + validate + `ValidateOnStart`) + the
pre-host `ConfigPreflight` aggregator (generalizes ScadaBridge's `StartupValidator`, byte-compatible
message); left per-project = each app's options classes + domain rules, and OtOpcUa's
draft/generation-content validation (DB-side `sp_ValidateDraft`; its C# `DraftValidator` is dormant).
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Configuration/`](ZB.MOM.WW.Configuration/)
(.NET 10; single package `ZB.MOM.WW.Configuration`; 42 tests (verified 2026-07-18); `dotnet pack` → 1 nupkg @ 0.1.0).
The implementation plan is at
[`docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-configuration-shared-library.md).
**Adopted across all three apps on 2026-06-01** (OtOpcUa, MxAccessGateway, ScadaBridge) on each repo's
local default branch (`main`/`master`) — merged, **not yet pushed** to remotes (stale → #17); the package was first
published to the Gitea feed. Behaviour-preserving onto `OptionsValidatorBase`/`AddValidatedOptions`
for MxGateway + ScadaBridge (validator messages byte-identical), `StartupValidator` → `ConfigPreflight`
for ScadaBridge, and net-new `Ldap`/`OpcUa` validators for OtOpcUa. Per-app result tracked in
[`components/configuration/GAPS.md`](components/configuration/GAPS.md).
Build/test from `ZB.MOM.WW.Configuration/`: `dotnet test`. Consumer matrix: all three apps consume the
single package; ScadaBridge is the heaviest adopter (per-module validators + `StartupValidator` →
`ConfigPreflight`); OtOpcUa adoption is additive (it has no `IValidateOptions` usage today).
+28
View File
@@ -0,0 +1,28 @@
# ZB.MOM.WW.GalaxyRepository
Shared Galaxy object-hierarchy SQL browse + `galaxy_repository.v1` gRPC service for the **ZB.MOM.WW SCADA family** (consumed by HistorianGateway and mxaccessgw). Build/test from this directory: `dotnet test`.
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Narrative
The Galaxy Repository component normalizes the **Galaxy object-hierarchy SQL browse + reusable gRPC service**
that was previously embedded in `mxaccessgw`. Shared = canonical `galaxy_repository.v1` proto (wire-compatible
with `mxaccessgw`'s existing contract so OtOpcUa's `GalaxyRepositoryClient` is unaffected), the SQL browse
provider (`HierarchySql` / `AttributesSql` validated reverse-engineered queries), in-memory hierarchy cache +
snapshot + deploy-poll refresh `BackgroundService`, `GalaxyHierarchyProjector`, and `AddZbGalaxyRepository` /
`MapZbGalaxyRepository` DI extension. Left per-consumer = section path, subtree auth filtering, and any
app-specific paging defaults.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.GalaxyRepository/`](ZB.MOM.WW.GalaxyRepository/)
(.NET 10; single package `ZB.MOM.WW.GalaxyRepository`; `dotnet pack` → 1 nupkg @ 0.1.0, **published to
the Gitea NuGet feed** `gitea.dohertylan.com/api/packages/dohertj2/nuget`). The design doc is at
[`docs/plans/2026-06-23-historian-gateway-design.md`](docs/plans/2026-06-23-historian-gateway-design.md) (§10, component 1).
**Consumed by HistorianGateway** as a `PackageReference` from that Gitea feed, pinned at `0.2.0` (originally a
cross-repo `ProjectReference` to this scadaproj tree; switched to the feed package 2026-06-24; bumped `0.1.0` → `0.2.0` 2026-06-25).
**mxaccessgw adoption is a tracked follow-on** — once adopted, mxaccessgw's inline Galaxy browse code is replaced
by the shared lib (the `galaxy_repository.v1` wire contract is already identical, so OtOpcUa and ScadaBridge
clients are unaffected). Build/test from `ZB.MOM.WW.GalaxyRepository/`: `dotnet test`.
Consumer matrix: HistorianGateway (initial); mxaccessgw (follow-on adoption).
+29
View File
@@ -2,6 +2,8 @@
Health-check libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central health process or network hop; probes run in-process alongside the application.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #11 (re-verify per-app probe coverage vs the spec). The history below is a record, not a status tracker; file new follow-ups as issues.
The library normalizes the three-tier health endpoint convention (`/health/ready`, `/health/active`, `/healthz`) and provides reusable probe implementations so the three sister projects share a common surface without duplicating probe logic.
**Built at 0.3.0 and published to the Gitea NuGet feed. Adopted by all four apps** — adoption is tracked in `~/Desktop/scadaproj/components/health/GAPS.md`.
@@ -75,3 +77,30 @@ Design documentation:
- `~/Desktop/scadaproj/components/health/spec/SPEC.md` — normalized three-tier target
- `~/Desktop/scadaproj/components/health/shared-contract/ZB.MOM.WW.Health.md` — proposed API (aligned to shipped code)
- `~/Desktop/scadaproj/components/health/current-state/` — per-project current state (code-verified)
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Component-table row as it read in the index
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| Health (readiness / liveness / active-node) | **Adopted** (feed carries `0.1.0`–`0.3.0`, current lib version **`0.3.0`**; **all 4 apps** reference it + wire `MapZbHealth`). **`0.2.0` (2026-07-24)** added an optional per-entry `data` object to the canonical health JSON and made `AkkaClusterHealthCheck` publish its cluster view (`leader`/`selfAddress`/`selfRoles`/`memberCount`/`unreachableCount`) — additive, so pre-0.2.0 payloads still parse. **`0.2.1` (2026-07-24) is a behaviour fix with a live consumer impact:** the active tier now returns **Unhealthy (503)** on a role-member-but-not-leader node instead of `Degraded`, which the spec maps to 200. The old value made `/health/active` answer 200 on *every* node, so it could not distinguish active from standby over HTTP at all — and OtOpcUa's Traefik routes the admin UI by exactly this probe, so its leader-pinning had silently never worked ([`lmxopcua#494`](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/494)). **`0.3.0` (2026-07-24) finished that fix and ENDED a family-wide duplication.** 0.2.1 made the tier a real 503 but still selected by `RoleLeader`; on a rebuilt rig the two orderings diverge live (akka leader `central-1`, oldest admin `central-2` — the singleton host), so it would have pinned Traefik to the node *not* hosting the work. **Leadership is address-ordered (host, then port) and has no relationship to time; singleton placement is age-ordered.** They agree on a freshly-formed cluster — which is why single-node and happy-path tests never caught it — and diverge permanently after any restart. Both Akka apps had already discovered this independently and each written a private replacement rather than use the shared check (ScadaBridge `OldestNodeActiveHealthCheck`, OtOpcUa `ClusterPrimaryHealthCheck`), so the package's entire active-node surface had **zero consumers — not unused but avoided, twice, for the same reason.** 0.3.0 promotes those two into one `ClusterActiveNode` primitive (oldest `Up` member, optional role scope, plus an ordered role-preference so a fused node answers for the role its singletons are pinned to); `ActiveNodeHealthCheck` and `AkkaActiveNodeGate` both delegate to it, and results now carry `activeRole`/`selfAddress`/`activeNode` in the 0.2.0 `data` object so a **standby reports who IS active**. Two further behaviour changes: a node holding **none** of the preferred roles is now **Unhealthy**, not Healthy — the old "not applicable ⇒ Healthy" is exactly what made the tier answer 200 everywhere, and the case is reachable (OtOpcUa's `RoleParser` admits `dev`-only and `cluster-`only nodes); and identity compares `UniqueAddress`, so a node restarted on the same host:port is correctly a different member. **Both apps migrated and their private copies deleted** — OtOpcUa (`feat/health-0.3.0-active-node` @ `88adec04`) also routes `RedundancyStateActor.SelectOldestUpMemberOfRole` through the shared primitive, so the tier and the OPC UA `ServiceLevel` 250/240 split cannot drift; ScadaBridge (`feat/site-node-health` @ `5d4853a1`) routes `ActiveNodeEvaluator` through it, unifying the S&F delivery gate, the heartbeat `IsActive` stamp, the inbound-API gate and the health tier on one rule. ScadaBridge keeps `SitePairActiveNodeHealthCheck` deliberately — it is a thin adapter over the site's own role-scoped `IClusterNodeProvider` (itself now backed by the shared rule), not a second copy of it. mxgw/HistorianGateway have no Akka cluster and are unaffected. **Both app branches are MERGED and PUSHED** (verified 2026-08-07): OtOpcUa `88adec04` is on `origin/master`; ScadaBridge merged via `b3dc17a5` (2026-07-24) on `origin/main`, private-copy deletions included. | Shared `ZB.MOM.WW.Health` lib | [`components/health/`](components/health/) | [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/) |
### Narrative
The health component is fully populated: a normalized [`spec`](components/health/spec/SPEC.md), a
[`shared-contract`](components/health/shared-contract/ZB.MOM.WW.Health.md), three
[`current-state`](components/health/current-state/) docs, and an adoption [`GAPS`](components/health/GAPS.md)
backlog. Shared = three-tier endpoint convention (ready/active/healthz) + canonical JSON writer +
`IActiveNodeGate` seam + `GrpcDependencyHealthCheck` + `AkkaClusterHealthCheck` + `ActiveNodeHealthCheck`
+ `DatabaseHealthCheck<TContext>`; left per-project = which probes each app registers,
orchestrator wiring, and ScadaBridge's distributed health-monitoring pipeline.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Health/`](ZB.MOM.WW.Health/)
(.NET 10; 3 packages — `ZB.MOM.WW.Health`, `ZB.MOM.WW.Health.Akka`, `ZB.MOM.WW.Health.EntityFrameworkCore`;
64 tests (verified 2026-07-18); `dotnet pack` → 3 nupkgs @ 0.1.0).
**Adopted by all four apps** — verified 2026-07-18 (each references the package and wires `MapZbHealth`); the previous "not yet adopted" claim was stale. Per-app probe coverage vs the spec has NOT been re-verified (→ #11) — see [`components/health/GAPS.md`](components/health/GAPS.md).
Build/test from `ZB.MOM.WW.Health/`: `dotnet test`. Consumer matrix: MxAccessGateway → core only;
OtOpcUa & ScadaBridge → all three packages.
File diff suppressed because one or more lines are too long
+67
View File
@@ -0,0 +1,67 @@
# ZB.MOM.WW.Overview
The hosted family dashboard (Blazor Server). Build/test from this directory: `dotnet build ZB.MOM.WW.Overview.slnx`, `dotnet test ZB.MOM.WW.Overview.slnx` (fully offline).
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #14 (re-run the two OtOpcUa-blocked Phase 4 acceptance checks). The history below is a record, not a status tracker; file new follow-ups as issues.
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
Besides the shared libraries, this repo hosts one **application**:
[`ZB.MOM.WW.Overview/`](ZB.MOM.WW.Overview/) — a Blazor Server single-pane dashboard that polls
every registered instance's anonymous `/health/ready` + `/health/active`, caches one snapshot
(pages never poll) and renders per-cluster state including the Akka **leader chip**, which is what
the `ZB.MOM.WW.Health` `0.2.0`/`0.3.0` per-entry `data` work exists to feed. **No login, strictly
read-only, holds no credentials** — a requirement, not an omission; it references no Auth/Audit/
Secrets package. Design + impl plan: `docs/plans/2026-07-22-overview-dashboard-{design,impl-plan}.md`
(ledger 19/20; the open task is Phase 4 live acceptance, recorded in `41013dc` as 7 of 9 design §9
checks passing, 2 blocked on an OtOpcUa rig redeploy → #14).
The `Applications` registry ships **empty** on purpose: a deployment supplying none fails startup
naming the key rather than booting an empty dashboard. Per-deployment overlays are committed —
`appsettings.Docker.json` (the local rigs, joined via external networks) and
`appsettings.Wonder.json`.
**Deployed to `wonder-app-vd03` 2026-08-11** (`main @ 4f97e72`) as that box's **fifth** app:
service `ZbOverview` on **:5320** under the shared NSSM, `ASPNETCORE_ENVIRONMENT=Wonder`,
5/5 instances Up. Two constraints worth carrying to any future box:
- **Publish self-contained** (`-r win-x64 --self-contained true`). wonder has **no .NET 10 runtime**
— every app there carries its own. An FDD publish installs fine and then fails at service start.
- **Registry `BaseUrl` must not put co-located instances on a shared host name.** Leader-address →
instance resolution matches on **host only** (Akka and HTTP ports differ by design), which assumes
distinct hosts per instance — true on the rig, false on a single box, where the fleet-wide scan
lets the first matching entry win the chip for *every* group. On wonder that made ScadaBridge's
**site** card claim `Leader · central`, a different Akka cluster. Fixed by using `127.0.0.1`,
which matches nothing, so each chip falls back to the raw Akka address.
**QA runs an instance on EACH pair node — `wonder-app-vq03:5320` and (since 2026-08-14)
`wonder-app-vq04:5320`** — both watching the same registry, now **10 instances across 4
applications** after the 2026-08-14 VQ04 singleton clone (MxGateway + HistorianGateway added on
vq04; see `deployment.md` §7.1). It is the fastest way to read QA's whole topology.
Its registry uses **real FQDNs**, and that is correct rather than a divergence: the `127.0.0.1`
rule above exists only because VD03 co-locates every instance on one host. QA is a **2-node pair**
(`vq03`/`vq04`) with genuinely distinct hosts, so leader resolution works as designed and the chips
resolve to real names (`node-a (vq03)`). Carry the *reason*, not the literal `127.0.0.1`.
## Commands (from the index)
```bash
# ZB.MOM.WW.Overview (~/Desktop/scadaproj/ZB.MOM.WW.Overview — the hosted family dashboard)
dotnet build ZB.MOM.WW.Overview.slnx
dotnet test ZB.MOM.WW.Overview.slnx # 177 tests, fully offline
cd docker && docker compose up -d --build # local rigs → http://localhost:5320 (start the rigs first)
# wonder deploy artifact — SELF-CONTAINED, the box has no .NET 10 runtime:
dotnet publish src/ZB.MOM.WW.Overview/ZB.MOM.WW.Overview.csproj -c Release -r win-x64 --self-contained true -o <out>
```
**2026-10-01 — BTDB Manager registered on all three dashboards.** `appsettings.Wonder.json` (vd03,
committed) gains one instance `wonder` → `http://127.0.0.1:9876` (management link
`http://10.220.157.247:9876/`); the QA overlay `E:\ApiInstall\Overview\app\appsettings.QA.json` (deployed
only, identical on vq03/vq04) gains `vq03`/`vq04` → `http://wonder-app-vq0N.zmr.zimmer.com:9876`. All
`HasActiveRole: false` (single unclustered Blazor app); `/health/ready` carries one `sql` entry (BT
reachable). The registry is read at startup only, so `ZbOverview` was restarted on each node (previous
files kept as `*.bak-20261001T02xxxx`). Verified: vd03 6/6 Up, vq03 and vq04 12/12 Up.
+4 -1
View File
@@ -25,11 +25,14 @@ The dev rigs publish only some node ports to the host:
| OtOpcUa central-1/2 | container `:9000` | **no** — only Traefik `:9200`, load-balanced across the pair |
| OtOpcUa site-\* (4) | container `:8080` | **no** — not published |
| HistorianGateway | container `:5220` | yes — `localhost:5220` |
| BTDB Manager | container `:9877` | yes — `localhost:9877` |
| MxAccessGateway | windev `:5130` | yes, with the VPN up |
So a host-run dashboard can only see part of the fleet. This stack joins the three rig networks
(`otopcua-dev_default`, `scadabridge-net`, `zb-historiangw_default`, all declared `external`) and
addresses every node by container DNS name, which is how it sees all of them.
addresses every node by container DNS name, which is how it sees all of them. BTDB Manager
(`~/Desktop/BTDB/docker`) sits on `scadabridge-net` too, because its BT database lives on
ScadaBridge's infra MSSQL container, so no fourth network is needed for it.
Ports above were established by probing the running rigs on 2026-07-24, **not** read off the
compose files — the two disagree. In particular OtOpcUa's site nodes serve health on `:8080`, not
@@ -25,6 +25,8 @@
// LocalDb sync re-bind the primary HTTP port)
// ScadaBridge central :5000
// ScadaBridge site :8084 (MetricsPort; MapZbHealth lands here from the site-health work)
// BTDB Manager :9877 (BTDB/docker; single instance, joins scadabridge-net for BT;
// /health/ready is 503 whenever that database is unreachable)
//
// ManagementUrl values stay host-relative (localhost:*) on purpose: they are followed by the
// OPERATOR'S BROWSER, not by this container, so container DNS names would be dead links.
@@ -146,6 +148,18 @@
}
]
},
{
"Name": "BTDB Manager",
"ManagementLabel": "Manager",
"Instances": [
{
"Name": "manager",
"BaseUrl": "http://zb-btdb-manager:9877",
"ManagementUrl": "http://localhost:9877/",
"HasActiveRole": false
}
]
},
{
"Name": "MxAccessGateway",
"ManagementLabel": "Dashboard",
@@ -150,6 +150,21 @@
"TimeoutSeconds": 10
}
]
},
{
"Name": "BTDB Manager",
"ManagementLabel": "Manager",
"Instances": [
{
// HasActiveRole false: a single, unclustered Blazor app — no active/standby role.
// /health/ready carries one "sql" entry (BT on the DEV SQL box reachable) and is 503
// whenever that database is not; /health/active is an empty check set.
"Name": "wonder",
"BaseUrl": "http://127.0.0.1:9876",
"ManagementUrl": "http://10.220.157.247:9876/",
"HasActiveRole": false
}
]
}
]
}
+21
View File
@@ -5,6 +5,27 @@ follows [Keep a Changelog](https://keepachangelog.com/); versions are shared acr
package in the family (see `Directory.Build.props`) even when a given release only touches
one of them.
## 0.6.3 — 2026-09-28
### Added
- **gRPC hub follower sweeps are observable.** `GrpcSecretSyncService` (package
`ZB.MOM.WW.Secrets.Replicator.Grpc`) now logs every sweep outcome instead of only the sweeps
that pull a change:
- `EventId 4` (Debug) — a sweep found nothing new; logged on every in-sync sweep.
- `EventId 5` (Information) — an in-sync heartbeat, at most once per
`SecretsGrpcHubClientOptions.InSyncLogInterval` (new, default 1 hour, validated positive), so
"the loop is alive and still agrees with the hub" is visible at the default log level without
a line every 30 seconds.
- `EventId 6` (Information) — the first successful sweep after one or more failures, naming
the failure count.
- **`ISecretsHubSyncStatus`** — a public, DI-registered read model of the follower loop:
`LastSweepUtc`, `LastSuccessUtc`, `ConsecutiveFailures`, `LastError`. Getters are synchronized
with the writer, so a health check or status page reads a consistent snapshot.
No behaviour change to syncing itself, and no wire or schema change. Only hub followers gain
anything from this release; other consumers can stay on 0.6.2.
## 0.6.2 — 2026-08-11
### Changed
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -5,7 +5,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.6.2</Version>
<Version>0.6.3</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
<PackageReadmeFile>README.md</PackageReadmeFile>
</PropertyGroup>
@@ -150,6 +150,15 @@ public static class SecretsGrpcHubClientExtensions
});
}
// Singleton, and registered for BOTH the concrete type and the public interface: the
// service needs the concrete type (it calls the internal RecordSuccess/RecordFailure), and
// an application observing hub-sync health (a readiness check, a status page) needs only
// the read-only ISecretsHubSyncStatus. TryAddSingleton for the concrete type means the
// interface registration below resolves the SAME instance rather than constructing a second
// one, which would silently split "what the service records" from "what a consumer reads".
services.TryAddSingleton<SecretsHubSyncStatus>();
services.TryAddSingleton<ISecretsHubSyncStatus>(sp => sp.GetRequiredService<SecretsHubSyncStatus>());
services.AddHostedService<GrpcSecretSyncService>();
return services;
@@ -36,6 +36,13 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
private readonly SecretsGrpcHubClientOptions _options;
private readonly ILogger<GrpcSecretSyncService> _logger;
private readonly TimeProvider _timeProvider;
private readonly SecretsHubSyncStatus _status;
// Last time a row actually moved, and last time the in-sync heartbeat was logged — both
// instance state because they describe THIS service's observed history, not anything the hub
// or the local store can answer on demand.
private DateTimeOffset? _lastPullUtc;
private DateTimeOffset? _lastInSyncHeartbeatUtc;
/// <summary>Creates the sync service.</summary>
/// <param name="local">The node's local store — the only thing this service writes to.</param>
@@ -44,13 +51,20 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
/// <param name="options">Hub-client options.</param>
/// <param name="logger">Logger for sweep outcomes and failures.</param>
/// <param name="timeProvider">Clock, injectable so tests need not wait a real interval.</param>
/// <param name="status">
/// The sweep-outcome tracker this service updates. Optional so existing callers that construct
/// this type directly (tests, mainly) keep compiling; a real host resolves the same singleton
/// <c>AddZbSecretsGrpcHubClient</c> registers for <see cref="ISecretsHubSyncStatus"/>, so the two
/// stay the same instance.
/// </param>
public GrpcSecretSyncService(
ISecretStore local,
ISecretsHubReader hub,
ISecretCacheInvalidator? cacheInvalidator,
IOptions<SecretsGrpcHubClientOptions> options,
ILogger<GrpcSecretSyncService> logger,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
SecretsHubSyncStatus? status = null)
{
ArgumentNullException.ThrowIfNull(options);
ArgumentNullException.ThrowIfNull(local);
@@ -59,6 +73,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
_options = options.Value;
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_timeProvider = timeProvider ?? TimeProvider.System;
_status = status ?? new SecretsHubSyncStatus();
_reconciler = new SecretReplicationReconciler(
local,
cacheInvalidator,
@@ -82,11 +97,22 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
}
/// <summary>
/// Runs one pull-only reconcile against the hub.
/// Runs one pull-only reconcile against the hub. Throws on any failure — an unreachable hub, a
/// rejected credential, a deadline — so a direct caller sees the raw outcome. The background
/// loop never calls this directly; it goes through <see cref="SweepAndRecordAsync"/>, which
/// shares this method's reconcile core but also records the outcome and logs it.
/// </summary>
/// <param name="ct">A token to cancel the sweep.</param>
/// <returns>How many rows were pulled from the hub.</returns>
internal async Task<int> SweepAsync(CancellationToken ct)
internal async Task<int> SweepAsync(CancellationToken ct) =>
(await SweepCoreAsync(ct).ConfigureAwait(false)).Pulled;
/// <summary>
/// The reconcile core shared by <see cref="SweepAsync"/> and <see cref="SweepAndRecordAsync"/>:
/// fetches the hub manifest and reconciles the local store against it. Throws on failure —
/// callers that want failures recorded/logged instead do that around this call, not inside it.
/// </summary>
private async Task<(int Pulled, int ManifestCount)> SweepCoreAsync(CancellationToken ct)
{
IReadOnlyList<SecretManifestEntry> hubManifest =
await _hub.GetManifestAsync(ct).ConfigureAwait(false);
@@ -97,20 +123,54 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
// it rather than re-deriving the diff here also keeps the batching: a cold-starting node's
// pull set is central's ENTIRE inventory, and an unchunked GetSecrets would exceed gRPC's
// 4 MB default message cap and then fail identically on every interval forever.
return await _reconciler
int pulled = await _reconciler
.ReconcileAsync(hubManifest, (names, token) => _hub.GetManyAsync(names, token), ct)
.ConfigureAwait(false);
return (pulled, hubManifest.Count);
}
private async Task SweepSafelyAsync(CancellationToken ct)
private Task SweepSafelyAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
/// <summary>
/// Test-only entry point for the same outcome-recording sweep the background loop runs — never
/// throws, always updates <see cref="ISecretsHubSyncStatus"/>, and logs exactly as a real tick
/// would. Exists so a test can drive one sweep at a time without standing up the
/// <see cref="BackgroundService"/> host lifecycle.
/// </summary>
/// <param name="ct">A token to cancel the sweep.</param>
internal Task SweepForTestAsync(CancellationToken ct) => SweepAndRecordAsync(ct);
private async Task SweepAndRecordAsync(CancellationToken ct)
{
DateTimeOffset now = _timeProvider.GetUtcNow();
try
{
int pulled = await SweepAsync(ct).ConfigureAwait(false);
(int pulled, int manifestCount) = await SweepCoreAsync(ct).ConfigureAwait(false);
int priorFailures = _status.RecordSuccess(now);
if (priorFailures > 0)
{
LogSweepRecovered(priorFailures);
}
if (pulled > 0)
{
LogSweepConverged(pulled);
_lastPullUtc = now;
}
else
{
LogSweepInSync(manifestCount);
if (_lastInSyncHeartbeatUtc is null ||
now - _lastInSyncHeartbeatUtc.Value >= _options.InSyncLogInterval)
{
LogSweepInSyncHeartbeat(manifestCount, _lastPullUtc);
_lastInSyncHeartbeatUtc = now;
}
}
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
@@ -124,6 +184,7 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
// has the same correct response: keep serving the local store and try again. Letting any
// of it escape would fault the BackgroundService and take the host down over a transient
// central outage, which is precisely the availability the pull-only topology exists for.
_status.RecordFailure(now, ex);
LogSweepFailed(ex);
}
}
@@ -145,4 +206,36 @@ internal sealed partial class GrpcSecretSyncService : BackgroundService
Level = LogLevel.Warning,
Message = "Discarding replicated secret row {SecretName} that could not be applied locally; the rest of the batch continues.")]
private partial void LogRowDiscarded(Exception exception, string secretName);
/// <summary>
/// Logged at Debug on every sweep that found nothing to pull — the full-cadence signal, for a
/// verbose log level, that the loop is alive and agrees with the hub even between the rare
/// Information-level heartbeats.
/// </summary>
[LoggerMessage(
EventId = 4,
Level = LogLevel.Debug,
Message = "Secrets hub sweep found nothing new; {Count} secret(s) already in sync.")]
private partial void LogSweepInSync(int count);
/// <summary>
/// Logged at Information at most once per <see cref="SecretsGrpcHubClientOptions.InSyncLogInterval"/>
/// — an in-sync sweep is otherwise Debug-only, so this is the periodic proof at the default log
/// level that the follower is still converged rather than silently stuck.
/// </summary>
[LoggerMessage(
EventId = 5,
Level = LogLevel.Information,
Message = "Secrets hub in sync ({Count} secret(s)); last change pulled {LastPullUtc}")]
private partial void LogSweepInSyncHeartbeat(int count, DateTimeOffset? lastPullUtc);
/// <summary>
/// Logged at Information the first time a sweep succeeds after one or more failures — the
/// recovery line that closes out whatever run of <see cref="LogSweepFailed"/> Warnings preceded it.
/// </summary>
[LoggerMessage(
EventId = 6,
Level = LogLevel.Information,
Message = "Secrets hub sync recovered after {Failures} failed sweep(s)")]
private partial void LogSweepRecovered(int failures);
}
@@ -0,0 +1,112 @@
namespace ZB.MOM.WW.Secrets.Replicator.Grpc;
/// <summary>Point-in-time outcome of the hub follower's sweeps. Never carries secret material.</summary>
/// <remarks>
/// Registered as a singleton by <c>AddZbSecretsGrpcHubClient</c> alongside the background sweep
/// service, so a host can surface hub-sync health (a readiness check, a status page) without
/// scraping logs. Every field reflects the outcome of the most recently completed sweep only —
/// there is deliberately no history here, just "what happened last time".
/// </remarks>
public interface ISecretsHubSyncStatus
{
/// <summary>
/// When the most recent sweep finished, whether it succeeded or failed. <see langword="null"/>
/// before the first sweep has completed.
/// </summary>
DateTimeOffset? LastSweepUtc { get; }
/// <summary>
/// When the most recent <em>successful</em> sweep finished. <see langword="null"/> if no sweep
/// has ever succeeded.
/// </summary>
DateTimeOffset? LastSuccessUtc { get; }
/// <summary>
/// The number of sweeps that have failed in a row, ending at the most recent sweep. Reset to
/// zero the moment a sweep succeeds.
/// </summary>
int ConsecutiveFailures { get; }
/// <summary>
/// The message from the most recent sweep failure, or <see langword="null"/> if the most recent
/// sweep succeeded (or none has run yet). This is the exception's type and message only — never
/// a stack trace, and never secret material, since the sweep never has plaintext in scope.
/// </summary>
string? LastError { get; }
}
/// <summary>The mutable implementation the sync service writes to; consumers see only the interface.</summary>
/// <remarks>
/// Writes (<see cref="RecordSuccess"/>/<see cref="RecordFailure"/>) run under <see cref="_gate"/>,
/// and every getter takes the same lock rather than reading its backing field bare — a plain field
/// read races the writer with no synchronization otherwise, and a torn/half-written
/// <see cref="DateTimeOffset"/> (a multi-field struct) or an interleaving that pairs one write's
/// <c>LastSweepUtc</c> with another write's <c>LastError</c> is exactly the inconsistency a status
/// consumer must not see.
/// </remarks>
internal sealed class SecretsHubSyncStatus : ISecretsHubSyncStatus
{
private readonly object _gate = new();
private DateTimeOffset? _lastSweepUtc;
private DateTimeOffset? _lastSuccessUtc;
private int _consecutiveFailures;
private string? _lastError;
/// <inheritdoc/>
public DateTimeOffset? LastSweepUtc
{
get { lock (_gate) { return _lastSweepUtc; } }
}
/// <inheritdoc/>
public DateTimeOffset? LastSuccessUtc
{
get { lock (_gate) { return _lastSuccessUtc; } }
}
/// <inheritdoc/>
public int ConsecutiveFailures
{
get { lock (_gate) { return _consecutiveFailures; } }
}
/// <inheritdoc/>
public string? LastError
{
get { lock (_gate) { return _lastError; } }
}
/// <summary>
/// Records a successful sweep, resetting the failure streak.
/// </summary>
/// <param name="now">The instant the sweep completed.</param>
/// <returns>
/// The consecutive-failure count <em>before</em> this success — the caller uses a non-zero
/// result to decide whether the sweep is a recovery worth logging.
/// </returns>
internal int RecordSuccess(DateTimeOffset now)
{
lock (_gate)
{
int prior = _consecutiveFailures;
_lastSweepUtc = _lastSuccessUtc = now;
_consecutiveFailures = 0;
_lastError = null;
return prior;
}
}
/// <summary>Records a failed sweep, extending the failure streak.</summary>
/// <param name="now">The instant the sweep failed.</param>
/// <param name="ex">The exception the sweep failed with.</param>
internal void RecordFailure(DateTimeOffset now, Exception ex)
{
lock (_gate)
{
_lastSweepUtc = now;
_consecutiveFailures++;
_lastError = ex.GetType().Name + ": " + ex.Message;
}
}
}
@@ -77,6 +77,20 @@ public sealed class SecretsGrpcHubClientOptions
/// </summary>
public TimeSpan CallDeadline { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// How often an in-sync sweep (nothing to pull) is allowed to log an Information-level
/// heartbeat. Defaults to one hour. Must be positive.
/// </summary>
/// <remarks>
/// A converged follower sweeps every <see cref="SyncInterval"/> — 30 seconds by default — and
/// finds nothing new almost every time. Logging that outcome at Information on every sweep
/// would drown the log; staying silent forever would make "the sweep loop is alive and the hub
/// still agrees with this node" unobservable between the rare sweeps that actually pull
/// something. Every sweep still logs at Debug regardless of this interval, so a verbose log
/// level shows the true cadence when it is needed.
/// </remarks>
public TimeSpan InSyncLogInterval { get; set; } = TimeSpan.FromHours(1);
/// <summary>
/// Throws if the options are unusable. Called by the DI extension at registration time so a
/// misconfigured node fails at startup rather than on the first silent sweep failure.
@@ -161,6 +175,13 @@ public sealed class SecretsGrpcHubClientOptions
$"gRPC secrets hub client CallDeadline must be positive " +
$"({sectionPath}:CallDeadline, was {CallDeadline}).");
}
if (InSyncLogInterval <= TimeSpan.Zero)
{
throw new InvalidOperationException(
$"gRPC secrets hub client InSyncLogInterval must be positive " +
$"({sectionPath}:InSyncLogInterval, was {InSyncLogInterval}).");
}
}
/// <summary>The configuration section these options are bound from unless a caller overrides it.</summary>
@@ -0,0 +1,250 @@
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Secrets.Abstractions;
namespace ZB.MOM.WW.Secrets.Replicator.Grpc.Tests;
/// <summary>
/// Covers the hub follower's sweep-outcome observability: the Debug-per-sweep / Information-
/// heartbeat-once-per-interval pair for an in-sync sweep (EventId 4/5), the recovered line the
/// first success after a run of failures logs (EventId 6), and <see cref="ISecretsHubSyncStatus"/>
/// tracking the same outcomes.
/// </summary>
public sealed class GrpcSecretSyncServiceLoggingTests
{
[Fact]
public async Task InSyncSweep_LogsDebugEveryTime_AndInformationHeartbeatOncePerInterval()
{
var time = new FakeTimeProvider(DateTimeOffset.Parse("2026-09-28T00:00:00Z"));
(GrpcSecretSyncService svc, RecordingListLogger logs, _) = Harness.Create(
time, inSyncLogInterval: TimeSpan.FromHours(1), manifestUnchanged: true);
await svc.SweepForTestAsync(CancellationToken.None);
time.Advance(TimeSpan.FromMinutes(30));
await svc.SweepForTestAsync(CancellationToken.None);
time.Advance(TimeSpan.FromMinutes(31));
await svc.SweepForTestAsync(CancellationToken.None);
Assert.Equal(3, logs.Entries.Count(l => l.EventId.Id == 4 && l.Level == LogLevel.Debug));
Assert.Equal(2, logs.Entries.Count(l => l.EventId.Id == 5 && l.Level == LogLevel.Information));
}
[Fact]
public async Task FirstSuccessAfterFailures_LogsRecovered_AndStatusResets()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, failFirst: 2);
await svc.SweepForTestAsync(default);
await svc.SweepForTestAsync(default);
Assert.Equal(2, status.ConsecutiveFailures);
await svc.SweepForTestAsync(default);
Assert.Contains(logs.Entries, l => l.EventId.Id == 6 && l.Message.Contains("after 2 failed"));
Assert.Equal(0, status.ConsecutiveFailures);
Assert.NotNull(status.LastSuccessUtc);
}
[Fact]
public async Task A_failed_sweep_never_logs_EventId6_and_status_records_the_error()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, failFirst: 1);
await svc.SweepForTestAsync(default);
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
Assert.Equal(1, status.ConsecutiveFailures);
Assert.NotNull(status.LastError);
Assert.Null(status.LastSuccessUtc);
}
[Fact]
public async Task An_in_sync_sweep_never_logs_the_recovered_line()
{
var time = new FakeTimeProvider();
(GrpcSecretSyncService svc, RecordingListLogger logs, ISecretsHubSyncStatus status) =
Harness.Create(time, manifestUnchanged: true);
await svc.SweepForTestAsync(default);
await svc.SweepForTestAsync(default);
Assert.DoesNotContain(logs.Entries, l => l.EventId.Id == 6);
Assert.Equal(0, status.ConsecutiveFailures);
Assert.NotNull(status.LastSweepUtc);
}
// Assembles a GrpcSecretSyncService wired to a controllable fake hub, a no-op local store (the
// scenarios here never actually apply a row — an empty hub manifest means the reconciler's pull
// set is always empty), a log-capturing logger, and the internal status tracker the service
// writes to — so a test can both drive sweeps and observe everything they produced.
private static class Harness
{
public static (GrpcSecretSyncService Service, RecordingListLogger Logs, ISecretsHubSyncStatus Status) Create(
FakeTimeProvider time,
TimeSpan? inSyncLogInterval = null,
bool manifestUnchanged = false,
int failFirst = 0)
{
var logger = new RecordingListLogger();
var status = new SecretsHubSyncStatus();
ISecretsHubReader hub = failFirst > 0
? new SequencedFailThenEmptyHubReader(failFirst)
: new EmptyHubReader();
IOptions<SecretsGrpcHubClientOptions> options = Options.Create(new SecretsGrpcHubClientOptions
{
Endpoint = "https://central:5001",
BearerToken = "unused-by-the-fake",
SyncInterval = TimeSpan.FromHours(1),
InSyncLogInterval = inSyncLogInterval ?? TimeSpan.FromHours(1),
});
// manifestUnchanged has no separate fake to select: both readers above always answer an
// empty (therefore unchanged, nothing-to-pull) manifest once they are done failing, so
// the parameter exists to make each call site self-documenting rather than to branch on.
var svc = new GrpcSecretSyncService(
new NoOpLocalStore(),
hub,
cacheInvalidator: null,
options,
logger,
time,
status);
return (svc, logger, status);
}
}
/// <summary>A hub with nothing in it — every sweep is in-sync with zero rows to pull.</summary>
private sealed class EmptyHubReader : ISecretsHubReader
{
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
}
/// <summary>
/// Fails the manifest call with a transport-shaped exception for the first
/// <paramref name="failuresBeforeSuccess"/> calls, then behaves exactly like
/// <see cref="EmptyHubReader"/> forever after.
/// </summary>
private sealed class SequencedFailThenEmptyHubReader(int failuresBeforeSuccess) : ISecretsHubReader
{
private int _remaining = failuresBeforeSuccess;
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct)
{
if (_remaining > 0)
{
_remaining--;
return Task.FromException<IReadOnlyList<SecretManifestEntry>>(
new InvalidOperationException("hub unreachable"));
}
return Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
}
public Task<IReadOnlyList<StoredSecret>> GetManyAsync(
IReadOnlyList<SecretName> names, CancellationToken ct) =>
Task.FromResult<IReadOnlyList<StoredSecret>>([]);
}
/// <summary>
/// A local store that is never actually exercised by these scenarios (the hub manifest is
/// always empty, so the reconciler's pull set is always empty and none of these members are
/// called) beyond <see cref="GetManifestAsync"/>, which the reconciler always calls to compute
/// the pull set.
/// </summary>
private sealed class NoOpLocalStore : ISecretStore
{
public Task<StoredSecret?> GetAsync(SecretName name, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task UpsertAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<bool> DeleteAsync(SecretName name, string? actor, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<IReadOnlyList<SecretMetadata>> ListAsync(bool includeDeleted, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<IReadOnlyList<SecretManifestEntry>> GetManifestAsync(CancellationToken ct) =>
Task.FromResult<IReadOnlyList<SecretManifestEntry>>([]);
public Task ApplyReplicatedAsync(StoredSecret row, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
public Task<bool> ApplyRewrapAsync(
StoredSecret rewrappedRow, byte[] expectedCurrentWrappedDek, CancellationToken ct) =>
throw new NotSupportedException("not exercised by the logging test scenarios.");
}
}
/// <summary>A clock a test advances by hand, so an hourly heartbeat can be tested without waiting an hour.</summary>
/// <remarks>
/// Hand-rolled rather than taken from <c>Microsoft.Extensions.TimeProvider.Testing</c>, matching
/// this project's existing convention (see <c>HubFakeTimeProvider</c> in
/// <c>SecretsHubAuthInterceptorTests.cs</c>) of not taking that package dependency for one small type.
/// </remarks>
internal sealed class FakeTimeProvider : TimeProvider
{
private DateTimeOffset _now;
/// <summary>Creates a clock starting at the current wall-clock time.</summary>
public FakeTimeProvider() : this(DateTimeOffset.UtcNow)
{
}
/// <summary>Creates a clock starting at <paramref name="now"/>.</summary>
/// <param name="now">The instant the clock starts at.</param>
public FakeTimeProvider(DateTimeOffset now) => _now = now;
/// <inheritdoc/>
public override DateTimeOffset GetUtcNow() => _now;
/// <summary>Moves the clock forward by <paramref name="delta"/>.</summary>
/// <param name="delta">The amount of time to advance.</param>
public void Advance(TimeSpan delta) => _now += delta;
}
/// <summary>Captures every log entry's level, event id, and formatted message, across all levels.</summary>
/// <remarks>
/// Distinct from the existing <c>RecordingLogger&lt;T&gt;</c> in <c>TestDoubles.cs</c>, which only
/// captures Information/Warning and neither type captures <see cref="EventId"/> — this suite needs
/// Debug-level entries and event-id assertions that the existing double does not support, and
/// widening it would risk the tests that already depend on its narrower shape.
/// </remarks>
internal sealed class RecordingListLogger : ILogger<GrpcSecretSyncService>
{
/// <summary>Every entry logged, in order.</summary>
public List<(LogLevel Level, EventId EventId, string Message)> Entries { get; } = [];
/// <inheritdoc/>
public IDisposable? BeginScope<TState>(TState state) where TState : notnull => null;
/// <inheritdoc/>
public bool IsEnabled(LogLevel logLevel) => true;
/// <inheritdoc/>
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
ArgumentNullException.ThrowIfNull(formatter);
Entries.Add((logLevel, eventId, formatter(state, exception)));
}
}
@@ -291,11 +291,16 @@ public sealed class GrpcSecretSyncServiceTests : IDisposable
}
[Fact]
public async Task A_quiet_sweep_logs_nothing_at_information()
public async Task A_quiet_sweep_logs_the_in_sync_heartbeat_once_but_no_warning()
{
// A quiet (nothing-to-pull) sweep no longer logs NOTHING at Information: the first-ever
// in-sync sweep always logs the once-per-InSyncLogInterval heartbeat (EventId 5), because
// "never logged before" is one of the two conditions that fires it. See
// GrpcSecretSyncServiceLoggingTests for the interval-gated repeat behaviour on later sweeps.
// What a quiet sweep still never does is warn.
await RunOneStartupSweepAsync(() => _hub.ManifestCallCount > 0);
Assert.Equal(0, _logger.InformationCount);
Assert.Equal(1, _logger.InformationCount);
Assert.Equal(0, _logger.WarningCount);
}
+32 -1
View File
@@ -2,6 +2,8 @@
Observability libraries for the **ZB.MOM.WW SCADA family** (OtOpcUa, MxAccessGateway, ScadaBridge). These are **libraries, not a service** — each package is linked directly into the consuming application at build time. There is no central telemetry process; instrumentation runs in-process alongside the application.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #12 (`AddZbSerilog` min-level governance hook), #13 (OTel collector / OTLP end to end), #17 (stale README text). The history below is a record, not a status tracker; file new follow-ups as issues.
The library normalizes the three-project observability surface: a shared OpenTelemetry Resource driven by a single identity triple (`service.name` / `site.id` / `node.role`), standard instrumentation wiring, Prometheus and OTLP export, and a Serilog bootstrap with enrichers and `TraceContextEnricher` for trace↔log correlation.
**Built at 0.1.0, published to the Gitea NuGet feed, and adopted across all three apps on 2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done beforehand despite the earlier claim. ScadaBridge keeps its `LoggerConfigurationFactory` (min-level governance) and only adds the shared `TraceContextEnricher`; it does not call `AddZbSerilog`. Per-repo result + deferred follow-ons tracked in `~/Desktop/scadaproj/components/observability/GAPS.md`.
@@ -65,7 +67,7 @@ Built at **0.1.0**, published to the Gitea NuGet feed, and **adopted across all
2026-06-01** (branch `feat/adopt-zb-telemetry` per repo, behaviour-preserving). MxAccessGateway's
MEL→Serilog migration and metrics export both landed in this pass (not beforehand, despite the
earlier claim). Deferred follow-ons (MxGateway `ms`→`s` + Meter rename, ScadaBridge app instruments
+ Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog:
+ Site-node HTTP/1.1 metrics listener, OTLP wiring) are tracked in the component backlog (remaining open items are Gitea issues: #12 min-level governance hook, #13 OTLP collector):
- `~/Desktop/scadaproj/components/observability/GAPS.md` — adoption status + deferred follow-ons
@@ -75,3 +77,32 @@ Design documentation:
- `~/Desktop/scadaproj/components/observability/spec/METRIC-CONVENTIONS.md` — metric naming reference
- `~/Desktop/scadaproj/components/observability/shared-contract/ZB.MOM.WW.Telemetry.md` — proposed shared-library API
- `~/Desktop/scadaproj/components/observability/current-state/` — per-project current state (code-verified)
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Narrative
The observability component is fully populated: a normalized [`spec`](components/observability/spec/SPEC.md),
a [`metric-conventions`](components/observability/spec/METRIC-CONVENTIONS.md) reference, a
[`shared-contract`](components/observability/shared-contract/ZB.MOM.WW.Telemetry.md), three
[`current-state`](components/observability/current-state/) docs, and an adoption [`GAPS`](components/observability/GAPS.md)
backlog. Shared = OTel Resource (service.name/site.id/node.role identity triple) + standard instrumentation
(ASP.NET Core, HttpClient, gRPC client, runtime, process) + Prometheus always-on exporter + OTLP opt-in
+ Serilog two-stage bootstrap + SiteId/NodeRole/NodeHostname enrichers + TraceContextEnricher (trace_id/span_id)
+ ILogRedactor seam; left per-project = application Meters/ActivitySources, sink config, per-operation
enrichers, and redaction policies.
The shared library is **built and lives in this repo** at [`ZB.MOM.WW.Telemetry/`](ZB.MOM.WW.Telemetry/)
(.NET 10; 2 packages — `ZB.MOM.WW.Telemetry`, `ZB.MOM.WW.Telemetry.Serilog`; 34 tests (verified 2026-07-18);
`dotnet pack` → 2 nupkgs @ 0.1.0). **Adopted across all three apps on 2026-06-01** (branch
`feat/adopt-zb-telemetry` per repo, behaviour-preserving): `AddZbTelemetry` (Resource + standard
instrumentation + Prometheus `/metrics`) everywhere; OtOpcUa + MxGateway on `AddZbSerilog` (MxGateway's
MEL→Serilog migration + metrics export both landed in this pass — they were *not* actually done
beforehand despite an earlier claim); ScadaBridge keeps its `LoggerConfigurationFactory` (min-level
governance) and only adds the shared `TraceContextEnricher`. Deferred: MxGateway `ms`→`s` + Meter
rename, ScadaBridge app instruments + Site-node HTTP/1.1 metrics listener, OTLP wiring. Per-repo
result tracked in [`components/observability/GAPS.md`](components/observability/GAPS.md).
Build/test from `ZB.MOM.WW.Telemetry/`: `dotnet test`. Consumer matrix: all three apps consume both
packages after adoption (OtOpcUa, MxGateway Server, ScadaBridge Host + any instrumented project).
+1 -1
View File
@@ -154,7 +154,7 @@ lockstep from `Directory.Build.props`.
## Status
**Built at 0.1.0. MxAccessGateway logging adopted (MEL → Serilog migration, on its own branch).
Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** Adoption is tracked in the
Broader OtOpcUa and ScadaBridge telemetry adoption deferred.** (stale → #17) Adoption is tracked in the
component backlog:
- `~/Desktop/scadaproj/components/observability/GAPS.md`
+72
View File
@@ -0,0 +1,72 @@
# ZB.MOM.WW.Theme
Shared UI kit (Razor Class Library — Technical-Light tokens, IBM Plex, side-rail shell, widgets) for the **ZB.MOM.WW SCADA family**; five consumers. Build/test from this directory: `dotnet test`. Component spec + gaps: `../components/ui-theme/`.
**Backlog / open work:** lives in Gitea issues on `dohertj2/scadaproj` (`tea issues list --repo dohertj2/scadaproj`) — currently #9 (roll `0.5.0` to the other consumers), #10 (`layout.css` `calc(100vh - 3.3rem)` review). The history below is a record, not a status tracker; file new follow-ups as issues.
## Published 0.5.0 (2026-11-04)
Published the tested ignitionoee backlog package to the shared Gitea feed on
2026-11-04. Independent download matched the reviewed artifact SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`.
The ignitionoee router deployed this package to CR on 2026-11-04. No other
consumer was deployed in this task (rollout → #9). Do not repack or replace published 0.5.0.
Retains 0.4.2's `.rise` fill-mode fix. Adds the remaining shared application CSS
from ignitionoee (including `td.row-actions`), composable `TechButton.Class` and
`ButtonSize`, plus `ITreeRow`/stateless `TreeTable<TRow>` in `ZB.MOM.WW.Theme`.
Tree styles live once in `layout.css`, with arbitrary-depth indentation and
keyboard-focus styling. Caller-owned filtering/collapse behavior stays intact.
Test harness migrated to bUnit 2.7.2 with AngleSharp 1.5.0 to address the existing
NU1902 advisory without disabling audit. Runtime package dependencies are unchanged.
Local verification: Release build passed with zero warnings/errors; Release tests
passed 60 tests, none skipped. `build/pack.sh` produced the local 0.5.0 nupkg.
All 19 remaining ignitionoee app.css rule blocks are present verbatim in the
shared stylesheet; the Impeccable detector reported no findings on changed UI files.
Browser QA also exposed a pre-existing mobile shell bug: closed navigation hid
the page through Chromium's disclosure wrapper. The wrapper override now applies
at all widths, with an explicit mobile rule hiding only the rail and author
display rules for older browsers. Two regression tests cover CSS placement and
closed-shell component structure; computed mobile/desktop behavior was checked in
the disposable ignitionoee local browser fixture.
Browser QA confirmed the visibility fix and isolated an additional mobile
min-content sizing issue: bounding the direct page to 100% width/max-width with
border-box reduced the page/document from 1055px to the 390px viewport and kept
wide-table scrolling inside its wrapper. That measured constraint is included.
See README's 0.5.0 migration section; deployed consumer validation remains separate
from local source/package/browser verification.
## Index history (migrated from the scadaproj root CLAUDE.md)
> Moved verbatim out of the scadaproj root `CLAUDE.md` on 2026-09-03 so it loads only when needed. **This file is now the authoritative place for this history — append new entries here and keep the root index row short.**
### Component-table row as it read in the index
| Component | Status | Goal | Design | Implementation |
|---|---|---|---|---|
| UI Theme (layout / tokens / components) | Adopted (feed `0.2.0`–`0.4.1`). **`0.4.0` (2026-08-11) upstreamed the Bootstrap `.btn`/`.btn-sm` sizing block** that all **five** consumers had independently written into their own stylesheets (Bootstrap hard-codes `--bs-btn-font-size: 1rem`, oversized against the kit's 0.9rem body) — sized by `--bs-btn-*` variable override **only**, never by redeclaring box properties, because `.btn-group`'s seam/radius machinery reads those same variables and a literal `font-size` at equal specificity silently flattens `.btn-sm` into a padding-only difference (a real defect found in mxgw's local copy). Same release added the `rail-btn-block` full-width rail-foot modifier; **`0.4.1` fixes it** — 0.4.0 shipped `width: auto`, which fills for an `<a>` but shrink-wraps a `<button>` (measured 114px short in the same rail), now `calc(100% - 1.2rem)` to clear `.rail-btn`'s side margins. **Consumers are five, not four** — the ignitionoee router joined. **All five consumers are on `0.4.1` and all five are PUSHED** (2026-08-11): ignitionoee `main @ 38aa5c1`, OtOpcUa `master @ e82881c1`, mxgw `main @ 0a9715d`. The two whose sweeps were not deploy-ready pushed to **branches**, deliberately leaving their default branch untouched: HistorianGateway `dashboard-ui-sweep-theme-0.4.1` — **MERGED to `main` (ff to `e42ff31`) + pushed 2026-08-13**, carrying Theme 0.4.1 + Health 0.3.0 + Secrets 0.6.2 + Auth 0.2.1 and ScadaBridge `ui/central-ui-cleanup-sweep @ 9e243493` (`main` was then `b6f383a2`). **ScadaBridge's caveat is now CLOSED and its sweep is merged, deployed and live** (2026-08-11): the branch landed on `main`, and the two follow-ups `d14e0ee4` + `3eb7df74` fixed exactly the four detail-modal surfaces the caveat named — the modal is now gated on **user intent (the held id)**, never on the row still resolving, so a background refresh that drops the row leaves it mounted and says why the summary is missing instead of unmounting the subtree and disposing Close's own handler mid-click. That disposal *was* the intermittent `GetRequiredEventBindingEntry`, and `NotificationReportDetailModalTests.Modal_StaysOpen_WhenItsRowLeavesThePage` now pins it. **Deployed to wonder-app-vd03: OtOpcUa `e82881c1`, and ScadaBridge `3eb7df74` (2026-08-11 — theme.css/layout.css/nav-state.js served from `_content/ZB.MOM.WW.Theme` and referenced by the pages; all six changed routes 200). HistorianGateway on wonder still runs `e9c31da` = Theme 0.3.1 — its 0.4.1 is now on `main` (merged 2026-08-13) but not yet deployed to the box.** **On bump, TRIM the local `.btn` block, don't delete it** — four of the five were pure sizing, but mxgw's also carried `border-radius`/`font-weight`/`white-space` that a blind delete would have dropped. | Shared `ZB.MOM.WW.Theme` RCL | [`components/ui-theme/`](components/ui-theme/) | [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/) |
### Narrative
The UI-theme component is fully populated: a normalized [`spec`](components/ui-theme/spec/SPEC.md),
a [`design-tokens`](components/ui-theme/spec/DESIGN-TOKENS.md) reference, a
[`shared-contract`](components/ui-theme/shared-contract/ZB.MOM.WW.Theme.md), three
[`current-state`](components/ui-theme/current-state/) docs, and an adoption [`GAPS`](components/ui-theme/GAPS.md)
backlog. Shared = Technical-Light tokens + IBM Plex fonts + side-rail shell + widgets; left
per-project = each app's `site.css` page layout, route content, scoped `.razor.css`.
The shared RCL is **built and lives in this repo** at [`ZB.MOM.WW.Theme/`](ZB.MOM.WW.Theme/)
(.NET 10 Razor Class Library; single package; 48 bUnit tests (re-verified 2026-08-11 at `0.4.1`); feed carries `0.2.0`–`0.4.1`,
**published to the Gitea feed**). The build plan is at
[`docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md`](docs/plans/2026-06-01-zb-mom-ww-theme-shared-library.md);
the adoption plan at [`docs/plans/2026-06-03-ui-theme-adoption.md`](docs/plans/2026-06-03-ui-theme-adoption.md).
**Adopted across all three apps on 2026-06-03** (full canonical cutover, SPEC §7) on each repo's
`feat/adopt-zb-theme` branch — committed + spec/code-reviewed, then **fast-forward-merged into each repo's local
default (master/main) and PUSHED to origin (gitea)** (in sync; `feat/*` kept locally as history): OtOpcUa
→`lmxopcua` `master`@`11de14d`, ScadaBridge `main`@`58352a6`, MxGateway→`mxaccessgw` `main`@`73e54e2`. The `0.1.0 → 0.2.0` bump first promoted nav-expand persistence
into the kit (`NavRailSection.Key`/`data-nav-key` + a localStorage `nav-state.js` enhancer emitted by a new
`<ThemeScripts/>`), so all three apps share one persistence mechanism (OtOpcUa's bespoke cookie/JS-interop nav
island retired); MxGateway additionally gained a net-new Blazor `<LoginCard>` `/login` page over its existing
hardened endpoint. Per-app result in [`components/ui-theme/GAPS.md`](components/ui-theme/GAPS.md).
Build/test from `ZB.MOM.WW.Theme/`: `dotnet test`. Consumer matrix: all three apps consume
the single `ZB.MOM.WW.Theme` package (OtOpcUa AdminUI, MxGateway Server, ScadaBridge Host + CentralUI).
+1 -1
View File
@@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<Version>0.4.1</Version>
<Version>0.5.0</Version>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
</Project>
+3 -1
View File
@@ -3,7 +3,9 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="bunit" Version="1.40.0" />
<PackageVersion Include="bunit" Version="2.7.2" />
<!-- Test DOM parser: direct pin avoids bUnit's vulnerable 1.2.0 minimum. -->
<PackageVersion Include="AngleSharp" Version="1.5.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageVersion Include="xunit" Version="2.9.3" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.4" />
+51 -4
View File
@@ -65,7 +65,8 @@ server-side** before redirecting (open-redirect risk):
| `NavRailSection` | `Title`*, `Expanded` (default `true`), `ChildContent` | CSS-only collapsible `<details>` group; no JS, works in static SSR. |
| `StatusPill` (`StatusState`) | `State`* (`Ok`/`Warn`/`Bad`/`Idle`/`Info`), `ChildContent` | Inline chip. `StatusState` enum is in `ZB.MOM.WW.Theme`. |
| `LoginCard` | `Product`*, `Action` (default `/auth/login`), `ReturnUrl`, `Error`, `ChildContent` | Static form-POST sign-in card. Inject `<AntiforgeryToken/>` via `ChildContent`; validate `ReturnUrl` server-side. |
| `TechButton` (`ButtonVariant`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. `ButtonVariant` enum is in `ZB.MOM.WW.Theme`. |
| `TechButton` (`ButtonVariant`, `ButtonSize`) | `Variant` (`Primary`/`Secondary`/`Danger`/`Ghost`), `Size` (`Default`/`Small`/`Large`), `Class`, `Type`, `Busy`, `ChildContent`, splatted attrs | `Busy` disables the button and shows a spinner. Caller classes are composed with required variant/size classes. |
| `TreeTable<TRow>` (`ITreeRow`) | `Rows`*, `Head`*, `Row`*, `Label`*, `Collapsed`, `OnToggle` | Stateless preorder tree table. The caller filters visible rows and owns expansion state. |
| `TechCard` | `Title`, `Header`, `ChildContent`, `Footer`, `Class` | Panel with optional head/body/footer slots. |
| `TechField` | `Label`*, `Hint`, `Error`, `ChildContent` | Form field wrapper with label, hint text, and inline error. |
@@ -80,7 +81,7 @@ Served at `_content/ZB.MOM.WW.Theme/…` by the ASP.NET static-web-asset pipelin
| Path | Contents |
|---|---|
| `css/theme.css` | Design tokens (`--accent`, `--ok`, `--warn`, …), typography, utility helpers |
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing |
| `css/layout.css` | Side-rail shell layout, collapsible nav, `StatusPill` variants, card/field helpers, Bootstrap `.btn` sizing, shared page/group headers, badges, row actions, info notices, payload blocks and tree-table styles |
**Bootstrap button sizing (new in 0.4.0).** The kit now normalizes `.btn` / `.btn-sm` against its
0.9rem body via `--bs-btn-*` variable overrides, because Bootstrap hard-codes
@@ -128,6 +129,52 @@ so a token-block edit re-themes the whole kit.
```bash
# from ZB.MOM.WW.Theme/
dotnet build -c Release # TreatWarningsAsErrors — expect 0 warnings
dotnet test # 38 bUnit tests
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.1.0.nupkg
dotnet test # 60 tests verified for 0.5.0
./build/pack.sh # → ./artifacts/ZB.MOM.WW.Theme.0.5.0.nupkg
```
## 0.5.0 migration (published 2026-11-04)
The tested package was published to the shared Gitea feed on 2026-11-04.
An independent feed download exactly matched the reviewed artifact (SHA256
`374511ebaaa15be71024051e896c8e7e59a8951b0d51a990fa48fbe378ac6485`).
The ignitionoee router deployed this package to CR on 2026-11-04; other consumer
rollouts remain separate. It retains the 0.4.2 `.rise` animation
fill-mode fix. The published package is immutable; this source documentation
update does not repack or replace it.
The shell now keeps page content visible when mobile navigation is collapsed.
The outer disclosure's `::details-content` wrapper is dissolved at every width;
an explicit mobile closed-state selector hides only the rail. Author display
rules retain the page for older browsers without `::details-content` support.
The menu remains a native keyboard-accessible disclosure without custom JavaScript.
Mobile pages are constrained to the shell width so wide tables scroll inside their
table wrapper instead of widening the whole document.
The remaining ignitionoee `app.css` rules now ship in `layout.css`: page and section
headers/meta, group headings/meta, count badges, `.notice.info`, `.row-actions`,
**`td.row-actions`**, and `.check-data`. Consumers may delete matching local copies
after adopting this package. The element-qualified row-action selector must remain:
the bare class loses to `.data-table td` regardless of stylesheet load order.
Use `Size="ButtonSize.Small"` or `Size="ButtonSize.Large"` on `TechButton`.
`Class="my-action"` and an attribute dictionary's `class` value are composed with
the required button classes. A lowercase `class` supplied in Razor binds to `Class`
as normal Blazor parameter binding. Supplying both spellings as separate component
parameters is unnecessary; use `Class` plus `AdditionalAttributes` when merging two
sources. Explicit `Busy` and `Type` continue to control the underlying attributes.
Implement `ZB.MOM.WW.Theme.ITreeRow` on a consumer's row model (`Key`, `Depth`,
`HasChildren`). Feed `TreeTable<TRow>` visible rows in preorder, with each subtree
contiguous after its parent. `Head` supplies all header cells; `Row` supplies cells
after the component's name cell; `Label` supplies the name. `OnToggle` reports the
stable row key, and the caller supplies a new `Collapsed` state and filters `Rows`.
The component never filters or mutates caller state. Indentation supports arbitrary
depth through `--tt-depth`; styles ship once, with no per-instance stylesheet.
Leaf controls preserve label alignment and omit subtree ARIA attributes. Interactive
expand/collapse requires an interactive Blazor host and Bootstrap Icons for chevrons.
The test harness uses bUnit 2.7.2 and directly pins AngleSharp 1.5.0. This avoids
the vulnerable parser minimum ([GHSA-pgww-w46g-26qg](https://github.com/advisories/GHSA-pgww-w46g-26qg))
and the binary incompatibility between bUnit 1.x and the patched parser. These
dependencies belong to the test project; the Theme runtime package gains none.
@@ -0,0 +1,12 @@
namespace ZB.MOM.WW.Theme;
/// <summary>Bootstrap size modifier for <c>TechButton</c>.</summary>
public enum ButtonSize
{
/// <summary>The theme's standard button size, without a Bootstrap size modifier.</summary>
Default,
/// <summary>Compact button, using <c>btn-sm</c>.</summary>
Small,
/// <summary>Large button, using <c>btn-lg</c>.</summary>
Large,
}
@@ -1,6 +1,6 @@
@namespace ZB.MOM.WW.Theme
@* Components/TechButton.razor *@
<button @attributes="AdditionalAttributes" type="@Type" class="btn @VariantClass" disabled="@Busy">
<button @attributes="AdditionalAttributes" type="@Type" class="@ButtonClass" disabled="@Busy">
@if (Busy) { <span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span> }
@ChildContent
</button>
@@ -12,6 +12,12 @@
/// </summary>
[Parameter] public ButtonVariant Variant { get; set; } = ButtonVariant.Primary;
/// <summary>Bootstrap size modifier. Defaults to the theme's standard button size.</summary>
[Parameter] public ButtonSize Size { get; set; }
/// <summary>Additional CSS classes, composed with the variant, size and unmatched class attribute.</summary>
[Parameter] public string? Class { get; set; }
/// <summary>HTML <c>type</c> attribute (<c>button</c>, <c>submit</c>, <c>reset</c>). Defaults to <c>button</c>.</summary>
[Parameter] public string Type { get; set; } = "button";
@@ -30,6 +36,14 @@
/// </summary>
[Parameter(CaptureUnmatchedValues = true)] public IDictionary<string, object>? AdditionalAttributes { get; set; }
private string ButtonClass => string.Join(" ", new[]
{
"btn", VariantClass,
Size switch { ButtonSize.Small => "btn-sm", ButtonSize.Large => "btn-lg", _ => null },
Class,
AdditionalAttributes?.FirstOrDefault(pair => string.Equals(pair.Key, "class", StringComparison.OrdinalIgnoreCase)).Value?.ToString()
}.Where(value => !string.IsNullOrWhiteSpace(value)));
private string VariantClass => Variant switch
{
ButtonVariant.Secondary => "btn-outline-secondary",
@@ -0,0 +1,88 @@
@namespace ZB.MOM.WW.Theme
@typeparam TRow where TRow : ITreeRow
@* Stateless preorder tree table. The caller filters Rows and owns collapse state.
Shared styles ship once in layout.css, including arbitrary-depth indentation. *@
<div class="table-wrap">
<table class="data-table">
<thead>
<tr>@Head</tr>
</thead>
<tbody>
@foreach (TRow row in Rows)
{
<tr @key="row.Key" class="tt-d@(row.Depth)">
<td class="tt-name" style="@DepthStyle(row)">
<button type="button" class="@TwistClass(row)" disabled="@(!row.HasChildren)"
aria-expanded="@AriaExpanded(row)" aria-label="@TwistLabel(row)"
@onclick="() => OnToggle.InvokeAsync(row.Key)">
<i class="@ChevronClass(row)" aria-hidden="true"></i>
</button>
@Label(row)
</td>
@Row(row)
</tr>
}
</tbody>
</table>
</div>
@code {
/// <summary>
/// The rows to render, in order — already filtered through
/// the caller's visibility policy.
/// </summary>
/// <remarks>
/// The filtering is the caller's so that this component holds no state at all: the page owns
/// <see cref="Collapsed"/>, and what is on screen is a pure function of it.
/// </remarks>
[Parameter, EditorRequired] public IReadOnlyList<TRow> Rows { get; set; } = [];
/// <summary>
/// The header cells — every <c>&lt;th&gt;</c>, including the one over the name column. The
/// component supplies the <c>&lt;thead&gt;</c> and its <c>&lt;tr&gt;</c>.
/// </summary>
[Parameter, EditorRequired] public RenderFragment Head { get; set; } = builder => { };
/// <summary>
/// One row's cells <b>after</b> the name cell: the component renders the name column, the
/// caller renders the rest.
/// </summary>
[Parameter, EditorRequired] public RenderFragment<TRow> Row { get; set; } = _ => builder => { };
/// <summary>The text of a row's name cell, beside its twist.</summary>
[Parameter, EditorRequired] public Func<TRow, string> Label { get; set; } = _ => string.Empty;
/// <summary>
/// The keys of the collapsed rows — read only to draw each twist the right way round.
/// </summary>
/// <remarks>
/// It is the same set the caller filtered <see cref="Rows"/> with, so a row can be collapsed
/// and on screen (that is what a shut branch looks like) but never expanded and hidden.
/// </remarks>
[Parameter] public IReadOnlySet<string> Collapsed { get; set; } = new HashSet<string>(StringComparer.Ordinal);
/// <summary>
/// Raised with a row's <see cref="ITreeRow.Key"/> when its twist is clicked. The caller
/// toggles that key in its own collapse set; this component changes nothing itself.
/// </summary>
[Parameter] public EventCallback<string> OnToggle { get; set; }
private static string DepthStyle(TRow row)
=> "--tt-depth: " + Math.Max(0, row.Depth).ToString(System.Globalization.CultureInfo.InvariantCulture);
private bool IsCollapsed(TRow row) => Collapsed.Contains(row.Key);
private string TwistClass(TRow row) => row.HasChildren ? "tt-twist" : "tt-twist tt-leaf";
private string ChevronClass(TRow row) => IsCollapsed(row) ? "bi bi-chevron-right" : "bi bi-chevron-down";
// Null omits the attribute: a leaf's twist is decoration, and announcing it as a collapsed
// control would promise a subtree that is not there.
private string? AriaExpanded(TRow row)
=> !row.HasChildren ? null : IsCollapsed(row) ? "false" : "true";
private string? TwistLabel(TRow row)
=> !row.HasChildren ? null : (IsCollapsed(row) ? "Expand " : "Collapse ") + Label(row);
}
@@ -0,0 +1,14 @@
namespace ZB.MOM.WW.Theme;
/// <summary>A row in a preorder flat tree, with each subtree contiguous after its parent.</summary>
public interface ITreeRow
{
/// <summary>Stable identity, unique in the table and used for keyed rendering and collapse state.</summary>
string Key { get; }
/// <summary>Nesting depth, starting at zero for a root.</summary>
int Depth { get; }
/// <summary>Whether this row owns a subtree and offers an expand/collapse toggle.</summary>
bool HasChildren { get; }
}
@@ -11,10 +11,12 @@
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>true</IsPackable>
<PackageId>ZB.MOM.WW.Theme</PackageId>
<PackageReadmeFile>README.md</PackageReadmeFile>
<Description>Shared Technical-Light UI kit (tokens, fonts, side-rail shell, widgets) for the ZB.MOM.WW SCADA family.</Description>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<None Include="..\..\README.md" Pack="true" PackagePath="\" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="ZB.MOM.WW.Theme.Tests" />
@@ -1,5 +1,5 @@
/* ZB.MOM.WW.Theme — side-rail + login layout
Tokens live in theme.css; this sheet carries only layout + the side rail. */
Tokens live in theme.css; this sheet carries application layout and shared controls. */
/* ── Bootstrap button sizing ─────────────────────────────────────────────────
Bootstrap hard-codes --bs-btn-font-size: 1rem, which is oversized against
@@ -45,6 +45,12 @@
--bs-btn-padding-y: .25rem;
--bs-btn-padding-x: .6rem;
}
.btn-group-lg > .btn,
.btn-lg {
--bs-btn-font-size: 1rem;
--bs-btn-padding-y: .5rem;
--bs-btn-padding-x: 1rem;
}
/* ── App shell: side rail + page ─────────────────────────────────────────── */
/* The shell is a native <details> disclosure so the narrow-viewport hamburger
@@ -59,6 +65,21 @@
min-height: calc(100vh - 3.3rem);
}
/* A closed shell must hide navigation only, never the page. Modern Chromium
wraps all non-summary children in ::details-content and hides that box while
closed. Dissolve it at EVERY width so both children retain the shell's flex
layout. The mobile closed-rail rule below owns navigation visibility. */
.app-shell::details-content {
display: contents;
content-visibility: visible;
}
/* Older browsers use a UA display rule on closed disclosure children instead
of ::details-content. Author display rules keep the page present there too;
an unsupported pseudo-element rule above is simply ignored. */
.app-shell > main.page { display: block; }
.app-shell > #theme-rail { display: block; }
.app-shell .page {
flex: 1;
min-width: 0;
@@ -72,9 +93,8 @@
}
.app-shell > summary.rail-toggle::-webkit-details-marker { display: none; }
/* Below lg the native <details> shows/hides #theme-rail via [open]; nothing
extra is needed there. On lg+ we force the rail visible (see media query)
and keep the toggle hidden via Bootstrap's d-lg-none. */
/* The native <details> owns its open state; the mobile rule explicitly hides
only #theme-rail when closed. Bootstrap's d-lg-none hides the toggle on lg+. */
/* ── Side rail ───────────────────────────────────────────────────────────── */
.side-rail {
@@ -92,20 +112,6 @@
and force it shown regardless of the <details> open state (the hamburger
toggle is hidden at this width). */
@media (min-width: 992px) {
/* Chromium >=121 wraps a <details>'s content in a generated ::details-content
box that carries content-visibility:hidden while the <details> is closed.
Because our app-shell ships closed by default (SSR, no JS) and the toggle
is d-lg-none here, that wrapper would (a) hide the rail+page entirely on
lg+ and (b) sit between .app-shell and its rail/page children, collapsing
the flex-lg-row layout into a vertical stack. Dissolving the wrapper with
display:contents removes its box (so content-visibility no longer hides the
content AND rail/page become direct flex children of .app-shell again).
Browsers that don't support ::details-content treat this as an invalid
selector and drop the rule, falling back to the legacy force-show below. */
.app-shell::details-content {
display: contents;
}
#theme-rail {
display: block;
position: sticky;
@@ -116,10 +122,16 @@
}
}
/* When the side rail is collapsed under <lg viewports (the <details> is closed)
the native disclosure hides #theme-rail; when open, restore full width on
mobile so the rail spans the viewport. */
/* The disclosure toggles navigation while the page remains usable underneath. */
@media (max-width: 991.98px) {
.app-shell:not([open]) > #theme-rail { display: none; }
/* Native disclosure layout can otherwise size the page to a table's
min-content width. Bound the page so .table-wrap scrolls internally. */
.app-shell > .page {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.side-rail {
width: 100%;
min-width: 100%;
@@ -323,3 +335,79 @@
.login-body { padding: 1.4rem 1.1rem 1.25rem; }
.login-error { margin-bottom: 0.85rem; }
.field-error { font-size: 0.78rem; margin-top: 0.2rem; }
/* Shared application presentation, upstreamed from ignitionoee in 0.5.0. */
/* ── Page header ─────────────────────────────────────────────────────────────
Title left, `.spacer` eating the slack, actions/meta pushed hard right. */
.page-head { display: flex; align-items: baseline; gap: 1rem; flex-wrap: wrap; margin: 0 0 1rem; }
.page-head h1 { font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; margin: 0; text-wrap: balance; }
.page-head .spacer { flex: 1; }
.page-meta { font-family: var(--mono); font-size: 0.78rem; color: var(--ink-soft); }
/* ── Application sections ────────────────────────────────────────────────────
A rule-underlined h2 band; same spacer trick as the page head. */
.app-section { margin-bottom: 1.5rem; }
.app-head {
display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
padding-bottom: 0.4rem; border-bottom: 1px solid var(--rule-strong); margin-bottom: 0.75rem;
}
.app-head h2 { font-size: 1.02rem; font-weight: 600; margin: 0; }
.app-head .spacer { flex: 1; }
.app-meta { font-family: var(--mono); font-size: 0.76rem; color: var(--ink-faint); }
/* ── Group header (per-site issue groups) ────────────────────────────────── */
.group-head { display: flex; align-items: center; gap: 0.55rem; flex-wrap: wrap; margin-bottom: 0.5rem; }
.group-name { font-size: 0.74rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; color: var(--ink-soft); }
.group-meta { font-family: var(--mono); font-size: 0.74rem; color: var(--ink-faint); }
/* ── Per-group count badge ───────────────────────────────────────────────────
Red by default (it counts problems); `.warn` for the softer amber case. */
.count-badge {
display: inline-block; min-width: 1.4rem; text-align: center;
font-family: var(--mono); font-size: 0.72rem; font-weight: 600;
padding: 0.05rem 0.35rem; border-radius: 999px;
color: var(--bad); background: var(--bad-bg); border: 1px solid var(--bad-border);
}
.count-badge.warn { color: var(--warn-ink); background: var(--warn-bg); border-color: var(--warn-border); }
/* ── Inline notice, info variant ─────────────────────────────────────────────
Theme 0.3.1 ships `.notice` (amber) but no variants; `.info` re-tints it to
the on-palette blue wash. `--info-bg`/`--info-border` come from the theme. */
.notice.info { color: var(--accent-deep); background: var(--info-bg); border-color: var(--info-border); }
/* ── Table row-action cell ─────────────────────────────────────────────────
The bare class is what the mockup ships and what a `<span class="row-actions">`
still needs. It is NOT enough on its own: every use in this app is a table
cell, and Theme 0.3.1's `.data-table th, .data-table td { text-align: left }`
is class+element (0-1-1) against this rule's bare class (0-1-0) — specificity
beats load order, so the alignment silently lost everywhere. Observed in a
browser on 2026-08-05 (every row-action cell left-aligned; `nowrap` applied,
`text-align` did not); the cell-level restatement below is the fix.
See docs/recipes/router-ui-render-validation.md, Task 1 Defect 2. */
.row-actions { text-align: right; white-space: nowrap; }
td.row-actions { text-align: right; }
/* ── Preformatted check/payload block ────────────────────────────────────── */
.check-data {
margin: 0.35rem 0.9rem 0.6rem; padding: 0.4rem 0.6rem; background: var(--zebra-bg);
border: 1px solid var(--rule); border-radius: 4px;
font-family: var(--mono); font-size: 0.72rem; color: var(--ink-soft); line-height: 1.6;
overflow-x: auto; white-space: nowrap;
}
.check-data b { color: var(--accent-deep); font-weight: 500; }
/* ── Tree table ──────────────────────────────────────────────────────────────
Name-cell depth is a number emitted by TreeTable; it adds to the table's
standard 0.8rem cell padding and works at every depth. */
.data-table td.tt-name {
font-weight: 600;
padding-left: calc(0.8rem + var(--tt-depth, 0) * 1.2rem);
}
.tt-twist {
background: none; border: none; padding: 0; margin-right: 0.45rem;
color: var(--ink-faint); font-size: 0.75rem; line-height: 1; cursor: pointer;
}
.tt-twist:hover { color: var(--ink); }
.tt-twist:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* A hidden leaf control preserves the width of its siblings' toggles. */
.tt-leaf { visibility: hidden; }
@@ -225,7 +225,7 @@ a:hover { color: var(--accent-deep); text-decoration: underline; }
Add .rise to top-level sections; stagger with inline animation-delay
(.02s, .08s, .14s …) so panels settle in sequence, not all at once. */
@keyframes rise { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
.rise { animation: rise 0.4s ease both; }
.rise { animation: rise 0.4s ease backwards; /* backwards: end keyframe equals natural style, preventing a containing-block trap for position:fixed descendants */ }
/* ════════════════════════════════════════════════════════════════════════════
COMPONENT LIBRARY
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class BrandBarTests : TestContext
public class BrandBarTests : BunitContext
{
[Fact]
public void Renders_product_with_default_mark()
{
var cut = RenderComponent<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
var cut = Render<BrandBar>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Contains("OtOpcUa", cut.Markup);
Assert.NotNull(cut.Find(".brand .mark")); // default glyph when no Logo
}
@@ -13,7 +13,7 @@ public class BrandBarTests : TestContext
[Fact]
public void Custom_logo_replaces_default_mark()
{
var cut = RenderComponent<BrandBar>(p => p
var cut = Render<BrandBar>(p => p
.Add(x => x.Product, "ScadaBridge")
.Add(x => x.Logo, (RenderFragment)(b => b.AddMarkupContent(0, "<img class='logo'/>"))));
Assert.NotNull(cut.Find(".brand .logo"));
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests;
public class CommonControlsTests : TestContext
public class CommonControlsTests : BunitContext
{
[Theory]
[InlineData(ButtonVariant.Primary, "btn-primary")]
@@ -9,7 +9,7 @@ public class CommonControlsTests : TestContext
[InlineData(ButtonVariant.Ghost, "btn-link")]
public void TechButton_maps_variant(ButtonVariant v, string cls)
{
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
var cut = Render<TechButton>(p => p.Add(x => x.Variant, v).AddChildContent("Go"));
var btn = cut.Find("button");
Assert.Contains("btn", btn.ClassList);
Assert.Contains(cls, btn.ClassList);
@@ -18,7 +18,7 @@ public class CommonControlsTests : TestContext
[Fact]
public void TechButton_busy_disables_and_passes_through_attributes()
{
var cut = RenderComponent<TechButton>(p => p
var cut = Render<TechButton>(p => p
.Add(x => x.Busy, true)
.AddUnmatched("id", "save")
.AddChildContent("Save"));
@@ -30,14 +30,14 @@ public class CommonControlsTests : TestContext
[Fact]
public void TechButton_not_busy_is_not_disabled()
{
var cut = RenderComponent<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
var cut = Render<TechButton>(p => p.Add(x => x.Busy, false).AddChildContent("Go"));
Assert.False(cut.Find("button").HasAttribute("disabled"));
}
[Fact]
public void TechCard_renders_title_and_body()
{
var cut = RenderComponent<TechCard>(p => p
var cut = Render<TechCard>(p => p
.Add(x => x.Title, "Drivers")
.AddChildContent("<div class='b'>x</div>"));
Assert.Contains("Drivers", cut.Find(".panel-head").TextContent);
@@ -47,7 +47,7 @@ public class CommonControlsTests : TestContext
[Fact]
public void TechField_renders_label_hint_error()
{
var cut = RenderComponent<TechField>(p => p
var cut = Render<TechField>(p => p
.Add(x => x.Label, "Name")
.Add(x => x.Hint, "required")
.Add(x => x.Error, "missing")
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class LoginCardTests : TestContext
public class LoginCardTests : BunitContext
{
[Fact]
public void Posts_to_action_with_username_password_fields()
{
var cut = RenderComponent<LoginCard>(p => p
var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Action, "/auth/login"));
var form = cut.Find("form");
@@ -19,7 +19,7 @@ public class LoginCardTests : TestContext
[Fact]
public void ReturnUrl_renders_hidden_input()
{
var cut = RenderComponent<LoginCard>(p => p
var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.ReturnUrl, "/clusters"));
var hidden = cut.Find("input[name=returnUrl]");
@@ -29,7 +29,7 @@ public class LoginCardTests : TestContext
[Fact]
public void Error_renders_notice()
{
var cut = RenderComponent<LoginCard>(p => p
var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Error, "Bad credentials"));
Assert.Contains("Bad credentials", cut.Find(".notice").TextContent);
@@ -38,7 +38,7 @@ public class LoginCardTests : TestContext
[Fact]
public void No_returnUrl_no_hidden_input()
{
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
Assert.Empty(cut.FindAll("input[name=returnUrl]"));
}
@@ -47,7 +47,7 @@ public class LoginCardTests : TestContext
[Fact]
public void Product_is_wrapped_in_login_product_span()
{
var cut = RenderComponent<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var cut = Render<LoginCard>(p => p.Add(x => x.Product, "OtOpcUa"));
var product = cut.Find(".login-title .login-product");
Assert.Equal("OtOpcUa", product.TextContent);
Assert.Contains("sign in", cut.Find(".login-title").TextContent);
@@ -57,7 +57,7 @@ public class LoginCardTests : TestContext
[Fact]
public void Heading_overrides_default_heading_when_set()
{
var cut = RenderComponent<LoginCard>(p => p
var cut = Render<LoginCard>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Heading, "Welcome back"));
var title = cut.Find(".login-title");
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class NavRailTests : TestContext
public class NavRailTests : BunitContext
{
[Fact]
public void NavRailItem_renders_rail_link_with_href_and_text()
{
var cut = RenderComponent<NavRailItem>(p => p
var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters"));
var a = cut.Find("a.rail-link");
@@ -17,7 +17,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailItem_wraps_icon_in_rail_ico_span_when_supplied()
{
var cut = RenderComponent<NavRailItem>(p => p
var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters")
.Add(x => x.Icon, (RenderFragment)(b => b.AddMarkupContent(0, "<svg class='ico'/>"))));
@@ -29,7 +29,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailItem_omits_rail_ico_span_when_no_icon()
{
var cut = RenderComponent<NavRailItem>(p => p
var cut = Render<NavRailItem>(p => p
.Add(x => x.Href, "/clusters")
.Add(x => x.Text, "Clusters"));
Assert.Empty(cut.FindAll(".rail-ico"));
@@ -38,7 +38,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_renders_title_and_children_open_by_default()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>"));
var details = cut.Find("details.rail-section");
@@ -52,7 +52,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_summary_aria_expanded_true_when_open()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("true", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -61,7 +61,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_collapsed_when_not_expanded()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.False(cut.Find("details.rail-section").HasAttribute("open"));
@@ -71,7 +71,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_summary_aria_expanded_false_when_collapsed()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Nav").Add(x => x.Expanded, false)
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("false", cut.Find("summary.rail-eyebrow-toggle").GetAttribute("aria-expanded"));
@@ -80,7 +80,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_emits_data_nav_key_slug_from_title_by_default()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Site Calls")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("site-calls", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -89,7 +89,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_emits_explicit_key_when_supplied()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Navigation").Add(x => x.Key, "nav")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("nav", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -98,7 +98,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_whitespace_only_title_yields_empty_data_nav_key()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, " ")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -107,7 +107,7 @@ public class NavRailTests : TestContext
[Fact]
public void NavRailSection_slug_preserves_unicode_letters()
{
var cut = RenderComponent<NavRailSection>(p => p
var cut = Render<NavRailSection>(p => p
.Add(x => x.Title, "Café")
.AddChildContent("<a class='rail-link'>X</a>"));
Assert.Equal("café", cut.Find("details.rail-section").GetAttribute("data-nav-key"));
@@ -39,6 +39,36 @@ public class StaticAssetsTests
public void NavStateScript_ships() =>
Assert.True(File.Exists(Path.Combine(Wwwroot, "js", "nav-state.js")));
[Fact]
public void Shared_layout_ships_row_action_specificity_and_variable_size_modifiers()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
Assert.Matches(@"td\.row-actions\s*\{\s*text-align:\s*right", css);
foreach (var selector in new[] { ".page-head", ".page-meta", ".app-section", ".app-head",
".app-meta", ".group-head", ".group-name", ".group-meta", ".count-badge",
".count-badge.warn", ".notice.info", ".row-actions", ".check-data" })
Assert.Contains(selector, css);
Assert.Contains("var(--tt-depth, 0)", css);
Assert.Matches(@"\.btn-lg\s*\{\s*--bs-btn-font-size:\s*1rem", css);
Assert.Matches(@"\.tt-leaf\s*\{\s*visibility:\s*hidden", css);
}
[Fact]
public void Closed_mobile_shell_hides_only_the_rail_and_keeps_main_content_visible()
{
var css = File.ReadAllText(Path.Combine(Wwwroot, "css", "layout.css"));
css = Regex.Replace(css, @"/\*.*?\*/", "", RegexOptions.Singleline);
var baseRules = css[..css.IndexOf("@media", StringComparison.Ordinal)];
Assert.Matches(@"\.app-shell::details-content\s*\{[^}]*display:\s*contents", baseRules);
Assert.Matches(@"\.app-shell\s*>\s*main\.page\s*\{[^}]*display:\s*block", baseRules);
var mobile = css[css.IndexOf("@media (max-width: 991.98px)", StringComparison.Ordinal)..];
Assert.Matches(@"\.app-shell:not\(\[open\]\)\s*>\s*#theme-rail\s*\{\s*display:\s*none", mobile);
var page = Regex.Match(mobile, @"\.app-shell\s*>\s*\.page\s*\{([^}]*)\}").Groups[1].Value;
Assert.Matches(@"\bwidth:\s*100%", page);
Assert.Matches(@"\bmax-width:\s*100%", page);
Assert.Matches(@"\bbox-sizing:\s*border-box", page);
}
// Theme-002: .chip-idle pairs the idle background with the matching --idle
// foreground token (per DESIGN-TOKENS.md), not --ink-soft.
[Fact]
@@ -1,6 +1,6 @@
namespace ZB.MOM.WW.Theme.Tests;
public class StatusPillTests : TestContext
public class StatusPillTests : BunitContext
{
[Theory]
[InlineData(StatusState.Ok, "chip-ok")]
@@ -10,7 +10,7 @@ public class StatusPillTests : TestContext
[InlineData(StatusState.Info, "chip-info")]
public void Maps_state_to_chip_class(StatusState state, string expected)
{
var cut = RenderComponent<StatusPill>(p => p
var cut = Render<StatusPill>(p => p
.Add(x => x.State, state)
.AddChildContent("Connected"));
var span = cut.Find("span");
@@ -0,0 +1,66 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TechButtonExtensionsTests : BunitContext
{
[Theory]
[InlineData(ButtonSize.Default, null)]
[InlineData(ButtonSize.Small, "btn-sm")]
[InlineData(ButtonSize.Large, "btn-lg")]
public void Size_preserves_variant_and_composes_both_class_inputs(ButtonSize size, string? expected)
{
var cut = Render<TechButton>(p => p
.Add(x => x.Variant, ButtonVariant.Secondary)
.Add(x => x.Size, size)
.Add(x => x.Class, "explicit-action")
.Add(x => x.AdditionalAttributes, new Dictionary<string, object>
{
["class"] = "caller-action", ["aria-label"] = "Save changes"
})
.AddChildContent("Save"));
var button = cut.Find("button");
foreach (var cls in new[] { "btn", "btn-outline-secondary", "explicit-action", "caller-action" })
Assert.Contains(cls, button.ClassList);
Assert.Equal("Save changes", button.GetAttribute("aria-label"));
if (expected is not null) Assert.Contains(expected, button.ClassList);
else
{
Assert.DoesNotContain("btn-sm", button.ClassList);
Assert.DoesNotContain("btn-lg", button.ClassList);
}
}
[Fact]
public void Class_composition_keeps_busy_type_and_click_behavior()
{
var clicks = 0;
var attributes = new Dictionary<string, object>
{
["class"] = "caller", ["disabled"] = false,
["onclick"] = EventCallback.Factory.Create(this, () => clicks++)
};
var cut = Render<TechButton>(p => p
.Add(x => x.Class, "extra")
.Add(x => x.Type, "submit")
.Add(x => x.Busy, true)
.Add(x => x.AdditionalAttributes, attributes)
.AddChildContent("Save"));
Assert.True(cut.Find("button").HasAttribute("disabled"));
Assert.Equal("submit", cut.Find("button").GetAttribute("type"));
Assert.NotNull(cut.Find(".spinner-border"));
Assert.Equal("caller", attributes["class"]);
cut.Render(p => p.Add(x => x.Busy, false));
cut.Find("button").Click();
Assert.Equal(1, clicks);
Assert.DoesNotContain(cut.FindAll("span"), x => x.ClassList.Contains("spinner-border"));
}
[Fact]
public void Lowercase_class_attribute_preserves_required_button_classes()
{
var cut = Render<TechButton>(p => p.AddUnmatched("class", "caller").AddChildContent("Go"));
foreach (var cls in new[] { "btn", "btn-primary", "caller" })
Assert.Contains(cls, cut.Find("button").ClassList);
}
}
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeHeadTests : TestContext
public class ThemeHeadTests : BunitContext
{
[Fact]
public void Emits_theme_and_layout_links_to_content_path()
{
var cut = RenderComponent<ThemeHead>();
var cut = Render<ThemeHead>();
var hrefs = cut.FindAll("link").Select(l => l.GetAttribute("href")).ToList();
Assert.Contains("_content/ZB.MOM.WW.Theme/css/theme.css", hrefs);
Assert.Contains("_content/ZB.MOM.WW.Theme/css/layout.css", hrefs);
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeScriptsTests : TestContext
public class ThemeScriptsTests : BunitContext
{
[Fact]
public void ThemeScripts_emits_nav_state_script_tag()
{
var cut = RenderComponent<ThemeScripts>();
var cut = Render<ThemeScripts>();
var script = cut.Find("script");
Assert.Equal("_content/ZB.MOM.WW.Theme/js/nav-state.js", script.GetAttribute("src"));
Assert.True(script.HasAttribute("defer"));
@@ -1,11 +1,11 @@
namespace ZB.MOM.WW.Theme.Tests;
public class ThemeShellTests : TestContext
public class ThemeShellTests : BunitContext
{
[Fact]
public void Renders_product_nav_and_body()
{
var cut = RenderComponent<ThemeShell>(p => p
var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.Nav, (RenderFragment)(b => b.AddMarkupContent(0, "<a class='rail-link'>N</a>")))
.AddChildContent("<div class='pagebody'>BODY</div>"));
@@ -18,7 +18,7 @@ public class ThemeShellTests : TestContext
[Fact]
public void Accent_sets_css_variable_on_shell_root()
{
var cut = RenderComponent<ThemeShell>(p => p
var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "ScadaBridge")
.Add(x => x.Accent, "#2f855a"));
var shell = cut.Find(".app-shell");
@@ -28,14 +28,14 @@ public class ThemeShellTests : TestContext
[Fact]
public void No_accent_emits_no_style()
{
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "MXAccess Gateway"));
Assert.False(cut.Find(".app-shell").HasAttribute("style"));
}
[Fact]
public void RailFooter_renders_when_supplied()
{
var cut = RenderComponent<ThemeShell>(p => p
var cut = Render<ThemeShell>(p => p
.Add(x => x.Product, "OtOpcUa")
.Add(x => x.RailFooter, (RenderFragment)(b => b.AddMarkupContent(0, "<span class='sess'>S</span>"))));
Assert.NotNull(cut.Find(".rail-foot .sess"));
@@ -47,7 +47,7 @@ public class ThemeShellTests : TestContext
[Fact]
public void Mobile_toggle_is_a_css_only_details_disclosure_not_bootstrap_collapse()
{
var cut = RenderComponent<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OtOpcUa"));
// Root is a <details> whose direct <summary> is the hamburger toggle.
var shell = cut.Find("details.app-shell");
@@ -62,4 +62,18 @@ public class ThemeShellTests : TestContext
Assert.DoesNotContain("data-bs-toggle", shell.OuterHtml);
Assert.DoesNotContain("data-bs-target", shell.OuterHtml);
}
[Fact]
public void Closed_shell_keeps_page_body_separate_from_the_toggled_rail()
{
var cut = Render<ThemeShell>(p => p.Add(x => x.Product, "OEE")
.Add(x => x.Nav, builder => builder.AddMarkupContent(0, "<a href='/oee'>OEE</a>"))
.AddChildContent("<h1>OEE report</h1><input aria-label='Filter' />"));
Assert.False(cut.Find("details.app-shell").HasAttribute("open"));
Assert.NotNull(cut.Find("details.app-shell > #theme-rail > nav"));
Assert.Equal("OEE report", cut.Find("details.app-shell > main.page > h1").TextContent);
Assert.NotNull(cut.Find("details.app-shell > main.page > input"));
Assert.Empty(cut.FindAll("#theme-rail main"));
}
}
@@ -0,0 +1,70 @@
namespace ZB.MOM.WW.Theme.Tests;
public sealed class TreeTableTests : BunitContext
{
private sealed record TestRow(string Key, int Depth, bool HasChildren, string Name) : ITreeRow;
private IRenderedComponent<TreeTable<TestRow>> RenderTreeTable(IReadOnlyList<TestRow> rows,
IReadOnlySet<string>? collapsed = null, Action<string>? toggle = null)
=> Render<TreeTable<TestRow>>(p => p
.Add(x => x.Rows, rows)
.Add(x => x.Collapsed, collapsed ?? new HashSet<string>())
.Add(x => x.Label, row => row.Name)
.Add(x => x.Head, builder => builder.AddMarkupContent(0, "<th>Scope</th><th>Value</th>"))
.Add(x => x.Row, row => builder =>
{
builder.OpenElement(0, "td");
builder.AddContent(1, "Value for " + row.Name);
builder.CloseElement();
})
.Add(x => x.OnToggle, key => toggle?.Invoke(key)));
[Fact]
public void Caller_fragments_and_arbitrary_depth_are_rendered_without_inline_stylesheets()
{
var cut = RenderTreeTable([new("root", 0, true, "Site"), new("deep", 7, false, "Machine <7>")]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Equal(2, cut.FindAll("tbody tr").Count);
Assert.Equal("Value for Machine <7>", cut.FindAll("tbody td")[3].TextContent);
Assert.Contains("Machine <7>", cut.Find(".tt-d7 .tt-name").TextContent);
Assert.Contains("--tt-depth: 7", cut.Find(".tt-d7 .tt-name").GetAttribute("style"));
Assert.Empty(cut.FindAll("style"));
}
[Fact]
public void Leaf_keeps_alignment_without_promising_a_subtree()
{
var cut = RenderTreeTable([new("leaf", 1, false, "Cell")]);
var twist = cut.Find("button");
Assert.Contains("tt-leaf", twist.ClassList);
Assert.True(twist.HasAttribute("disabled"));
Assert.False(twist.HasAttribute("aria-expanded"));
Assert.False(twist.HasAttribute("aria-label"));
Assert.Equal("true", cut.Find("i").GetAttribute("aria-hidden"));
}
[Fact]
public void Toggle_reports_stable_key_and_caller_controls_expansion()
{
string? toggled = null;
var cut = RenderTreeTable([new("site-key", 0, true, "Site")],
new HashSet<string> { "site-key" }, key => toggled = key);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Expand Site", cut.Find("button").GetAttribute("aria-label"));
cut.Find("button").Click();
Assert.Equal("site-key", toggled);
Assert.Equal("false", cut.Find("button").GetAttribute("aria-expanded"));
cut.Render(p => p.Add(x => x.Collapsed, new HashSet<string>()));
Assert.Equal("true", cut.Find("button").GetAttribute("aria-expanded"));
Assert.Equal("Collapse Site", cut.Find("button").GetAttribute("aria-label"));
Assert.Contains("bi-chevron-down", cut.Find("i").ClassList);
}
[Fact]
public void Empty_rows_keep_the_header_and_empty_body()
{
var cut = RenderTreeTable([]);
Assert.Equal(2, cut.FindAll("thead th").Count);
Assert.Empty(cut.FindAll("tbody tr"));
}
}
@@ -4,6 +4,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="bunit" />
<PackageReference Include="AngleSharp" />
<PackageReference Include="Microsoft.NET.Test.Sdk" />
<PackageReference Include="xunit" />
<PackageReference Include="xunit.runner.visualstudio" />
+8
View File
@@ -8,6 +8,11 @@ place to see all outstanding work.
> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not
> edit by hand — edit the findings files and re-run the script.
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work (including new review findings) as an issue.
## How it works
- Reviews are performed one library at a time against a fixed checklist.
@@ -37,6 +42,9 @@ code-reviews/
6 of 6 libraries reviewed. 0 pending findings across all libraries.
Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,
and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.
| Severity | Open findings |
|----------|---------------|
| Critical | 0 |
+8
View File
@@ -97,6 +97,11 @@ def build_readme(libraries, per_library):
add("> Generated by `regen-readme.py` from the per-library `findings.md` files. Do not")
add("> edit by hand — edit the findings files and re-run the script.")
add("")
add("> **Status lives in Gitea.** Open items from this file are tracked as issues on")
add("> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)")
add("> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status")
add("> here; file new work (including new review findings) as an issue.")
add("")
add("## How it works")
add("")
add("- Reviews are performed one library at a time against a fixed checklist.")
@@ -127,6 +132,9 @@ def build_readme(libraries, per_library):
add(f"{reviewed} of {len(libraries)} libraries reviewed. "
f"{len(pending)} pending finding{'s' if len(pending) != 1 else ''} across all libraries.")
add("")
add("Coverage gap: Secrets, LocalDb, GalaxyRepository and Overview have never been reviewed here,")
add("and Auth/Health/Theme have changed substantially since their 2026-06-01 review → #15.")
add("")
add("| Severity | Open findings |")
add("|----------|---------------|")
for sev in ("Critical", "High", "Medium", "Low"):
+8 -3
View File
@@ -51,8 +51,13 @@ Every normalized component is one subfolder of `components/` with this layout:
- **`current-state/<project>/`** is descriptive, not aspirational. It must match the project's
real code, with `file:line` references. Each ends in an **Adoption plan**: what that project
deletes/replaces to reach the spec, and what stays bespoke.
- **`GAPS.md`** is the working backlog: it turns the delta between every `current-state` and the
`spec` into concrete, prioritized items.
- **`GAPS.md`** turns the delta between every `current-state` and the `spec` into concrete,
prioritized items — but **status lives in Gitea** (family standard, 2026-11-06). Open items are
tracked as issues on [`dohertj2/scadaproj`](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
(the 2026-11-06 sweep filed them with label `migrated`); each GAPS file carries a banner and
`→ #N` pointers next to migrated items, and is otherwise a historical/design record. File new gaps
as issues (family labels `type:*`, `priority:p0..p3`, `blocked`, `cross-repo`) and reference them
from GAPS/current-state docs as `#N` — do not track open/closed status in the markdown.
## Workflow to normalize a component
@@ -66,7 +71,7 @@ Every normalized component is one subfolder of `components/` with this layout:
concrete library API in `shared-contract/`. Keep the surface minimal — extract only what is
genuinely common; leave domain-specific logic in the projects.
4. **Log gaps + adoption.** Fill `GAPS.md` with per-project divergences and an adoption/extraction
backlog (priority / effort / risk). Add the per-project "what to delete/replace" to each
backlog (priority / effort / risk), and file each open item as a Gitea issue (`→ #N` in the file). Add the per-project "what to delete/replace" to each
`current-state` doc.
5. **Register.** Add/maintain the component's row in the registry table above.
+5
View File
@@ -1,5 +1,10 @@
# Audit — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Audit` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
+7 -2
View File
@@ -1,5 +1,10 @@
# Auth — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Auth` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -134,5 +139,5 @@ adoption is opt-in per project, one consumer version-bump at a time.
## Decisions still open
- Shared dev base DN value (A3).
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3).
- Shared JWT/refresh helper or not (#7).
- Whether constraints stay opaque `object?` or get a small `IConstraintPolicy` (shared-contract Q3). → #20
- Shared JWT/refresh helper or not (#7). → #20
+5
View File
@@ -1,5 +1,10 @@
# Configuration validation — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to adopt
the shared `ZB.MOM.WW.Configuration` library. The library is **BUILT @ 0.1.0** (42 tests, re-run
green 2026-07-18) at
+8 -3
View File
@@ -1,5 +1,10 @@
# Health — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -13,7 +18,7 @@ reach the shared `ZB.MOM.WW.Health` library. Status legend: ⛔ gap · 🟡 part
> **Caveat on the evidence:** this was verified by `PackageReference` + registration-call grep,
> which proves the library is wired in — *not* that every per-app probe below is registered or
> that the endpoint tiers match the spec. The divergence tables further down were written
> pre-adoption and have **not** been re-verified against current code; treat them as historical
> pre-adoption and have **not** been re-verified against current code (→ #11); treat them as historical
> until someone re-walks them. They also predate HistorianGateway, which is absent from every
> table below.
@@ -181,12 +186,12 @@ Plan + design: [`../../docs/plans/2026-06-01-health-library-adoption.md`](../../
### Deferred (verified ill-fitting on adoption — re-scoped from the original backlog)
- **#4 downstream gRPC dependency probes — DROPPED for now.** Neither repo holds a host-level
- **#4 downstream gRPC dependency probes — DROPPED for now.** (→ #11) Neither repo holds a host-level
`GrpcChannel` to probe: the MxGateway↔worker IPC is **named pipes** (not gRPC), and OtOpcUa's
gateway channel is created **per-driver** from DB config (no DI-level channel). MxGateway readiness
instead probes the SQLite auth store (`AuthStoreHealthCheck`). A real worker/downstream probe needs
a custom non-gRPC check — future work.
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** ScadaBridge's `IActiveNodeGate`
- **#3 `IActiveNodeGate` seam unification (ScadaBridge) — DEFERRED.** (→ #11) ScadaBridge's `IActiveNodeGate`
is `…ScadaBridge.InboundAPI.IActiveNodeGate`, wired into inbound-API endpoint gating — a different
interface from the shared `ZB.MOM.WW.Health.IActiveNodeGate`. Unification touches the InboundAPI
path; its existing `ActiveNodeGate` (logic identical to the shared `AkkaActiveNodeGate`) was kept.
+9 -4
View File
@@ -1,5 +1,10 @@
# Observability — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -17,7 +22,7 @@ reach the shared `ZB.MOM.WW.Telemetry` library. Status legend: ⛔ gap · 🟡 p
> listener, OTLP opt-in wiring) were **all subsequently executed the same day** — see the
> "Follow-ons — DONE 2026-06-01" table at the bottom of this file, which this banner previously
> contradicted (reconciled 2026-08-07). Only the operational items under "Still open" there
> (deploying an OTel collector; further ScadaBridge instruments) remain.
> (deploying an OTel collector → #13; further ScadaBridge instruments → ScadaBridge#81) remain.
## Divergence vs spec
@@ -225,7 +230,7 @@ sister apps in one pass, behaviour-preserving. Each adoption landed on a per-rep
precedence and silently drop the knob. So ScadaBridge keeps the factory and only **adds the shared
`TraceContextEnricher`** to it — gaining trace↔log correlation without regressing the contract. Full
`AddZbSerilog` adoption for ScadaBridge would first require teaching the shared bootstrap to accept a
caller-supplied minimum-level governance hook.
caller-supplied minimum-level governance hook (→ #12).
- **MxGateway keeps `GatewayLogScope` + request-logging middleware as-is.** The Serilog MEL provider
captures MEL `BeginScope` dictionaries as structured properties, so the scope/correlation code keeps
producing the same properties under Serilog. Only the provider swap + the `ILogRedactor` adapter were
@@ -248,7 +253,7 @@ behaviour-preserving except the intentional, no-consumer-yet metric-shape change
**Still open (not code — operational/future):**
- **OTLP is opt-in but unexercised** until an OTel collector endpoint is deployed and the
- **OTLP is opt-in but unexercised** (→ #13) until an OTel collector endpoint is deployed and the
`<App>:Telemetry:Exporter=Otlp` + `:OtlpEndpoint` config is set. The wiring is in place; only a
collector is missing.
- **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking).
- **Further ScadaBridge instruments** beyond the first 4 are additive future work (not blocking). → ScadaBridge#81
+6 -1
View File
@@ -1,5 +1,10 @@
# Secrets — GAPS (adoption backlog)
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
The delta between each project's [`current-state`](current-state/) and the
[`SPEC`](spec/SPEC.md), as prioritized work. The library itself is **built + published +
reference-consumer-proven**; everything here is per-app adoption of an existing lib (not lib
@@ -73,7 +78,7 @@ construction).
every central version a transitive ceiling, demanding `Google.Protobuf`, `Grpc.Net.Client`,
`Microsoft.Data.SqlClient` and `Newtonsoft.Json` bumps. That is a gRPC/data-access change to a
production SCADA platform and deserves its own reviewed commit. **Still open as a follow-up** —
it is the better long-term posture.
it is the better long-term posture. → ScadaBridge#79
- ✅ `0.2.0` **PUBLISHED to the Gitea feed 2026-07-18** (all 5 packages; restore-verified from a
scratch consumer — resolves `SQLitePCLRaw.lib.e_sqlite3 2.1.12`, `Microsoft.Data.SqlClient 6.0.2`,
`Akka.Cluster.Tools 1.5.62`, and scans clean). Merged to `main` @ `15ef1f3`.
+6 -1
View File
@@ -1,5 +1,10 @@
# UI Theme — gaps & adoption backlog
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
Divergence of each project from [`spec/SPEC.md`](spec/SPEC.md), and the ordered backlog to
reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap · 🟡 partial · ✅ matches.
@@ -35,7 +40,7 @@ reach adoption of the `ZB.MOM.WW.Theme` shared RCL. Status legend: ⛔ gap ·
> **MxGateway** shed the dead `.sidebar` block + orphaned `.dashboard-login`/`.login-card` (−106), keeping
> `.app-bar` (still used by `/denied`) + the `.chip` override. Each verified unreferenced before removal; all
> three build clean (0 warn/0 err). OtOpcUa's copy was the notable one — it *overrode* the kit, not just dead code.
> **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review; and ScadaBridge's `Host` consumes the
> **Still deferred:** a kit-side `layout.css` `calc(100vh - 3.3rem)` review (→ #10); and ScadaBridge's `Host` consumes the
> kit only **transitively via `CentralUI`** (no direct `PackageReference`) — builds green, but an implicit dependency.
>
> _Feed note: the same audit re-confirmed `ZB.MOM.WW.Theme 0.2.0` **is** genuinely on the Gitea feed (registration
+27 -8
View File
@@ -27,7 +27,7 @@
| **OtOpcUa `otopcua-dev`** | This Mac | 3 independent Akka clusters (MAIN + SITE-A + SITE-B) sharing one ConfigDb | `http://localhost:9200` |
| **MxAccessGateway** | `windev` (10.100.0.48), Windows | Windows-native gRPC gateway + per-session x86 worker (no Docker) | `http://10.100.0.48:5120` (gRPC) |
| **DEV (VD03)** | `wonder-app-vd03.zmr.zimmer.com` | Single-node, all-in-one: ScadaBridge + OtOpcUa + MxGateway + HistorianGateway + Overview | see `docs/operations/` runbooks |
| **QA (VQ03/VQ04)** | `wonder-app-vq03` + `wonder-app-vq04` (+ `wonder-sql-vq03`) | **2-node HA pair** — ScadaBridge central+site pairs, OtOpcUa pair; singletons on VQ03 | `http://wonder-app-vq03.zmr.zimmer.com:5320` (Overview) |
| **QA (VQ03/VQ04)** | `wonder-app-vq03` + `wonder-app-vq04` (+ `wonder-sql-vq03`) | **2-node HA pair** — ScadaBridge central+site pairs, OtOpcUa pair; MxGateway/HistorianGateway/Overview on BOTH nodes | `http://wonder-app-vq03.zmr.zimmer.com:5320` (Overview) |
The three ScadaBridge stacks share one external Docker network **`scadabridge-net`**; the
OtOpcUa `otopcua-dev` stack runs on its **own** default network (`otopcua-dev_default`) and is
@@ -84,8 +84,8 @@ Host windev
|---|---|---|
| **windev** (Win host) | `ssh windev` | Configured alias; user `dohertj2`, key `id_ed25519`, port 22 |
| **fixture host** | `ssh dohertj2@10.100.0.35` | In `known_hosts`; **no** config alias — pass user explicitly; port 22, key-based |
| **VD03 (DEV)** | `ssh dohertj2@wonder-app-vd03.zmr.zimmer.com -p 2222` | In `known_hosts` on **port 2222** (servecli, not OpenSSH); user/key not pinned in config — confirm before use. Durable key `~/.ssh/servecli_wonder`. **servecli does not survive a reboot** — if it's down, use RDP |
| **VQ03 / VQ04 (QA)** | `psbridge --server wonder-app-vq0{3,4}.zmr.zimmer.com:6150 <cmd>` | **The supported path since 2026-08-14** (servecli `2222` is retired on these boxes — see the banner note below). Root jail `E:\psrem`; see the `psbridge` skill |
| **VD03 / VQ03 / VQ04 (all wonder boxes)** | `winrm ps vd03 '<PowerShell>'` · `winrm smb push vq03 ./x.zip 'E$\psrem-stage\x.zip'` | **The supported path since 2026-09-10** (Claude Code skill `winrm`, `~/.local/bin/winrm`): WinRM **5985/NTLM** for commands (5986 closed), **SMB 445** on the `E$` admin share for files, nothing installed on the box, survives reboots. Profiles `vd03`/`vq03`/`vq04` (FQDN `.zmr.zimmer.com`); the domain password lives only in the macOS Keychain. No second hop under NTLM (use SQL logins in scripts). First full round: all five apps on all three boxes, 2026-09-10 |
| ~~VD03 servecli~~ | `ssh … -p 2222` | **Retired** — plain process, dies on reboot; the psbridge skill/binary were removed the same day (its `server.exe` on `:6150` may still be running on the QA boxes but nothing uses it) |
| **local Linux VMs** | `ssh orb` / `orb` | OrbStack-managed |
> ⚠️ **A TCP-open `2222` does NOT mean SSH works.** On 2026-08-13 all three wonder boxes
@@ -380,10 +380,12 @@ each was established:
| 8082 / 8083 | ✅ | ✅ | scan | ScadaBridge Site Akka / gRPC control plane (h2c — rejects an HTTP/1.1 GET) |
| 5120 / 5130 | ✅ | ✅ | scan | **MxAccessGateway** gRPC / dashboard (VQ04 added 2026-08-14, cloned from VQ03) |
| 5220 / 5221 | ✅ | ✅ | scan | **HistorianGateway** dashboard / gRPC h2c (VQ04 added 2026-08-14) |
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same 10-instance registry) |
| 5320 | ✅ | ✅ | scan | **Overview** dashboard (one per node since 2026-08-14; both watch the same registry — 12 instances since 2026-10-01, BTDB Manager added) |
| 1433 | ✅ | ✗ | scan | SQL Server (**ZB Galaxy repo DB — VQ03 only**; VQ04's gateways point their Galaxy connection strings here) |
| 3893 | ✅ | ✅ | svc | glauth LDAP (`ScadaBridge-LDAP` service, loopback-only, `dc=scadabridge,dc=local` — one per box, configs identical) |
| **6150** | ✅ | ✅ | live | **psbridge** remote-admin bridge (`server.exe`, root jail `E:\psrem`) — **the supported access path**; see §2.1 |
| **5985** | ✅ | ✅ | live | **WinRM** (HTTP/NTLM; 5986 refused) — **the supported access path since 2026-09-10** via the `winrm` skill; see §2.1 |
| **445** | ✅ | ✅ | live | SMB — `E$`/`C$` admin shares for file transfer (`winrm smb push/pull`) |
| 6150 | ⚠️ | ⚠️ | live | psbridge `server.exe` (root jail `E:\psrem`) — **retired 2026-09-10**, superseded by WinRM; may still be listening |
| 2222 | ⚠️ | ⚠️ | scan | servecli SSH — **retired; TCP-open but NOT serving SSH** (see below) |
**Live state at the 2026-08-13 capture** (`8 instances across 4 applications`, all Up; since
@@ -392,12 +394,24 @@ nodes, verified Healthy at setup):
| Application | VQ03 | VQ04 |
|---|---|---|
| OtOpcUa (cluster `MAIN`) | node-a — **Standby** (`active 503`) | node-b — **Active** (`active 200`) |
| OtOpcUa (cluster `MAIN`) | node-a — **Active** since the 2026-09-10 pair restart (was Standby at the 08-13 capture) | node-b — Standby (was Active) |
| ScadaBridge `central` | central-a — **Active** | central-b — Standby |
| ScadaBridge `site` | site-a — **Active** | site-b — Standby |
| MxAccessGateway | Active | Active (since 2026-08-14; independent gateways, no cluster) |
| HistorianGateway | Up | Up (since 2026-08-14) |
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 10-instance registry) |
| Overview | Up (`:5320`) | Up (`:5320`, since 2026-08-14; same 12-instance registry) |
**Deployed builds on QA (2026-09-10 round, all via the `winrm` skill, both nodes):** ScadaBridge
`2b9a7c3c` (all four nodes stopped together, one shared-DB migration 53→55, 2 min 35 s window,
rollback = `F:\MSSQL\BACKUP\ScadaBridge_pre-2b9a7c3c_20260910T121009.bak` on wonder-sql-vq03 because
the old binary cannot read the now-encrypted protocol-config row), OtOpcUa `b8b4c18f` (pair stopped
together, 72 s; LocalDb replication connected afterwards for the first time; QA's config DB holds **no
deployment**, so the address space is empty), MxAccessGateway `4f931e4` Server + `4f931e4b` x86 Worker
(rolling, one stop per box), HistorianGateway `d8d890c` (rolling), Overview `818775b3` (code-identical to
`846b77f`, not redeployed). Every box keeps `app.bak.20260910T*` next to `app\`, staging under
`E:\psrem-stage\<app>-<sha>\`, and a `_DEPLOY-NOTES-20260910T*.txt` per app. Login is enabled on QA
(unlike DEV) and was preserved; every appsettings file was restored hash-identical and no service
environment was edited. ScadaBridge records: `ScadaBridge/docs/deployment/deployment-records/2026-09-10-*.md`.
**The 2026-08-14 VQ04 clone, in one paragraph:** MxAccessGw + HistorianGateway + ZbOverview were
copied from VQ03 (same `E:\ApiInstall\...` layout, NSSM `AppEnvironmentExtra` config, LocalSystem)
@@ -436,10 +450,15 @@ value, and inbound API keys in particular cannot be copied between them ([§9](#
## 9. Wonder boxes: DEV and QA
`wonder-app-vd03.zmr.zimmer.com` (SSH `:2222`) is the **DEV** all-in-one box — single-node
`wonder-app-vd03.zmr.zimmer.com` (reach it with the `winrm` skill, §2.1) is the **DEV** all-in-one box — single-node
ScadaBridge plus OtOpcUa, MxGateway (gRPC `:5120`), HistorianGateway and Overview.
`wonder-app-vq03` + `wonder-app-vq04` are the **QA** pair ([§7.1](#71-qa-port-map-vq03-and-vq04)).
**Both tiers run every repo tip since 2026-09-10** — DEV upgraded 10:45–11:20 (records per app on the
box under `E:\ApiInstall\<App>\_DEPLOY-NOTES-20260910T*.txt`; ScadaBridge record in its repo), QA
11:48–12:22 (§7.1 "Deployed builds on QA"). DEV runs mxgw's Worker rebuilt on windev (the x86 net48
Worker cannot be built on macOS; `ssh windev` is the build box for it).
Neither install is a scripted in-repo flow — the operational procedures live in ScadaBridge
`docs/operations/` (`failover-procedures.md`, `maintenance-procedures.md`,
`inbound-api-key-reissue.md`, `troubleshooting-guide.md`). Treat any service/port specifics not in
@@ -147,7 +147,7 @@ Single page (`/`), `ThemeShell` chassis with its own product name/accent so it r
## 9. Acceptance (v1 definition of done)
- The §4 ScadaBridge site-node health PR is merged and site rows render with full check detail + Active/Standby (no special-cased rows anywhere).
- Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login.
- Registry pointed at the live docker-dev rigs (OtOpcUa 6-node + ScadaBridge local cluster) renders every instance correctly, including: each Active/Standby pair (central **and** site pairs) showing exactly one Active; **each cluster group showing its current leader, and the leader chip moving when the leader node is stopped**; killing a node flips its card to Unreachable within ~2 cadences and back on restart; a Degraded check (e.g. stop a dependency) shows Warn with the failing entry visible; management links open the right app UIs; the page is reachable with no login. (OtOpcUa one-Active + leader-chip checks still to re-run → #14)
- **Cache/staleness proven live:** first page load renders instantly from cache (no poll on request); with auto-refresh paused (or the poller stopped) past `StaleAfterSeconds`, every card flips to Stale, and resumes cleanly.
- Boot fails with a clear `ConfigPreflight` message on a malformed registry.
- `dotnet test` green: options validator, status derivation (incl. flap damping, staleness marking, leader extraction + disagreement flag, JSON parsing golden tests against captured real payloads from all four apps — with and without `data`), snapshot store, and a bUnit render test of `InstanceCard`.
@@ -1,6 +1,11 @@
# VD03 NAM AD LDAP cutover — plan (NOT executed)
**Written 2026-08-13. Status: PLAN ONLY.** Swap every auth-bearing service on
> **Status lives in Gitea.** Open items from this file are tracked as issues on
> [gitea.dohertylan.com/dohertj2/scadaproj](https://gitea.dohertylan.com/dohertj2/scadaproj/issues)
> (label `migrated`, 2026-11-06). This file is a historical/design record — do not track status
> here; file new work as an issue.
**Written 2026-08-13. Status: PLAN ONLY.** Execution (VD03, then QA) → #6; consider the Auth `0.4.1` bump first → #5. Swap every auth-bearing service on
`wonder-app-vd03` from its current login posture (mostly login-disabled) to the real
Zimmer NAM Active Directory, with the service-account password held as a
`${secret:}` reference in each app's encrypted Secrets store — never in config.
@@ -21,7 +26,7 @@ and the wonder deploy records (memory: `wonder-upgrade-program`).
| Overview | ❌ exempt | — | No login **by design** (holds no credentials; references no Auth package) |
Out of scope: windev's OtOpcUa/mxgw instances, the QA pair (VQ03/VQ04 — do it as a
follow-on once VD03 has soaked; QA is where the 2-node/failover behaviour actually gets
follow-on once VD03 has soaked, → #6; QA is where the 2-node/failover behaviour actually gets
exercised), and OtOpcUa's OPC UA data plane (API-key auth since Gitea #520).
> ⚠️ **ignitionoee (router): LDAP setup ON HOLD** — explicit decision 2026-08-13. Its role
+3 -1
View File
@@ -2,9 +2,11 @@
One [GLAuth](https://github.com/glauth/glauth) directory that **all three sister apps use for
dev/test auth** — OtOpcUa, MxAccessGateway, ScadaBridge. It runs as a single container on the
shared Docker host **`10.100.0.35:3893`** (plaintext LDAP). This is the app-neutral source of
shared Docker host **`10.100.0.35:3893`** (plaintext LDAP; LDAPS/StartTLS → #19). This is the app-neutral source of
truth; each app just points its `…Ldap:Server` at `10.100.0.35`.
> Open work on this directory is tracked as Gitea issues on `dohertj2/scadaproj` (e.g. #19 LDAPS).
> Scope: **dev/test only**. Production uses real corporate AD. See
> [`../../docs/plans/2026-06-04-shared-glauth-standardization-design.md`](../../docs/plans/2026-06-04-shared-glauth-standardization-design.md).