Compare commits

...

15 Commits

Author SHA1 Message Date
Joseph Doherty e45c4af91d docs(plans): inbound-api-key-shared-store task ledger — all 14 tasks complete
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:39:06 -04:00
Joseph Doherty 551f753580 docs(deployment): planned record for moving QA (vq03/vq04) and prod (vd03) central inbound key stores to SQL Server; plan doc no longer spells out the dev sa password
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:33:38 -04:00
Joseph Doherty f47ceb55b1 docs: inbound API key store — shared SQL Server provider on central pairs (spec, runbook + central-pairs procedure, DBA DDL copy, topology, env inventory, CLAUDE.md, register row 108 closed with rig proof, row 109 Secrets backoff follow-up, Delmia spec §8)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:31:21 -04:00
Joseph Doherty a83cc8613d chore(docker): both rigs' centrals on the shared SQL Server inbound key store; delete sync-inbound-api-keys.sh (superseded); verify-delmia-notifier.sh preflight is now the proof that both nodes see a key minted on one — drill green with no file copy, takeover 28 s
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:31:21 -04:00
Joseph Doherty d6fd8c400c feat(host): StartupValidator warns when a clustered central keeps per-node inbound API keys; rejects an unknown ApiKeyStore:Provider at boot; Program.cs logs CollectWarnings once Serilog is up
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:26:34 -04:00
Joseph Doherty 06be0b9e64 feat(host): inbound API key store provider — SqlServer shares one zbauth row set across the central pair; one-shot SQLite import at boot (file renamed .imported, one retry on a concurrent-peer PK race, failure never blocks boot)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 23:26:03 -04:00
Joseph Doherty afe59a5518 chore(host): pin ZB.MOM.WW.Auth 0.3.0 + ApiKeys.SqlServer; InboundApiKeyStoreOptions (Provider Sqlite|SqlServer, ReadProvider fails the boot on an unknown value)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 22:46:11 -04:00
Joseph Doherty 08405c1ca1 chore(gitignore): fix worktree ignore typo (.claire/ -> .claude/worktrees/)
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 14:02:35 -04:00
Joseph Doherty 081e57f166 docs: Delmia rig stand-in + notifier verification results (A-G incl. active-central kill/takeover 27 s, rejoin); register rows 107 (prod DelmiaRecv path) + 108 (per-node inbound API key store breaks central failover); CLAUDE.md bullet; plan + task file
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:55:22 -04:00
Joseph Doherty f4363e0b4b fix(delmia-notifier)+chore(docker): review follow-ups — trim the env key like the file key, log which source supplied the key (never the key), hoist the not-configured message; README warns never to commit a filled-in appsettings.json; design-doc body lines updated; seed-delmia-api.sh no longer hides CLI errors and notes key names are not unique
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:52:56 -04:00
Joseph Doherty 83f7bbfb50 test(docker): verify-delmia-notifier.sh — notifier end-to-end on the rig incl. standby relay, active-central kill/takeover and rejoin; sync-inbound-api-keys.sh mirrors the per-node key store
First run (docker stop) found two things: SIGTERM is a graceful CoordinatedShutdown handover (takeover 0 s), so the drill now uses docker kill; and inbound API keys are a PER-NODE SQLite store (/app/data/inbound-api-keys.sqlite on each central's own volume, written by whichever node serves the management call), so the key minted through the active node did not exist on the survivor and every post-takeover call answered 401 KeyNotFound. The sync script is a rig-only workaround; the verify script's preflight refuses to start unless both nodes list the key. Second run: A/B/E/F YES, C/G NO, D 503 in the takeover window, takeover 27 s, victim rejoined as standby.

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:52:56 -04:00
Joseph Doherty 27eb598987 chore(docker): seed-delmia-api.sh — DelmiaRecipeDownload inbound method (prod script) + scoped delmia-notifier API key
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:43:21 -04:00
Joseph Doherty 5466cab920 chore(docker): seed-delmia-sim.sh — in-memory SimMachine + composed DelmiaReceiver stand-in on site-a with a ValueChange simulator playing the Galaxy side
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:43:21 -04:00
Joseph Doherty 8adb09a083 feat(delmia-notifier): optional ScadaBridge:ApiKey in appsettings.json as a fallback to SCADABRIDGE_API_KEY (env still wins); README/env inventory/design row updated
The 2026-06-26 design kept the key env-only. The file is now an explicit fallback: SCADABRIDGE_API_KEY (non-blank) -> ScadaBridge:ApiKey (non-blank, trimmed) -> not configured. Env-only stays the recommended production posture; the failure message names both sources.

Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:43:21 -04:00
Joseph Doherty 7dc30085be docs(api-specs): capture the as-deployed DelmiaRecipeDownload contract from prod vd03 (method, site handshake, keys, observed calls); link from the legacy dnc spec
Claude-Session: https://claude.ai/code/session_013L3XdmVii9i5sY4FChU8pB
2026-09-02 12:43:21 -04:00
41 changed files with 3408 additions and 38 deletions
+1 -1
View File
@@ -51,7 +51,7 @@ docker-env2/*/data/
/*secret*
# Claude tool worktrees (NF7)
.claire/
.claude/worktrees/
# Sister-project deployment artifacts (not part of this solution)
/deploy/
+2
View File
@@ -119,6 +119,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- SMS delivery adapter (T9/T10, 2026-06-19): `SmsNotificationDeliveryAdapter` — Twilio REST, no SDK, HTTP Basic auth (`AccountSid:AuthToken`), one `POST .../Messages.json` per recipient. `SmsConfiguration` entity (`AccountSid`, `AuthToken` encrypted via Data Protection, `FromNumber`/`MessagingServiceSid`, `ApiBaseUrl`, timeout/retry) stored centrally; managed Admin-only via CLI `notification sms list|update` + Central UI `/notifications/sms`. Central-only (never deployed to sites). Per-recipient rollup: all-accepted → Delivered; any-transient → retry/park; mix → delivered-to-good + note; all-permanent → Park. `SmsConfiguration` (Auth Token in SecretsBlock) travels in Transport bundles via `--sms-configs`.
- Inbound API: `POST /api/{methodName}`, `X-API-Key` header, flat JSON, extended type system (Object, List).
- **Inbound API on a STANDBY central node RELAYS to the active peer (2026-08-28)** instead of answering 503 `STANDBY_NODE`. Method scripts/`Route.To()` still run only on the active node (`IActiveNodeGate`); the standby's `InboundApiEndpointFilter` hands the request to `IStandbyRequestForwarder` (`HttpStandbyRequestForwarder`, plain HTTP, verbatim method/path/query/body/headers, peer response replayed unchanged) when `ScadaBridge:InboundApi:StandbyForwardPeerEndpoints` lists the *other* node's HTTP base URL — a static appsettings list, because the Akka member address carries the remoting port, not the HTTP one. Empty list = old 503 behaviour. Peers are tried in order; a peer 503 stamped `X-ScadaBridge-Standby: 1`, a connection failure or a `StandbyForwardTimeout` (60 s, must exceed the longest method timeout) expiry each move to the next, all failing → 503 `ACTIVE_NODE_UNREACHABLE`. **One-hop loop guard:** relayed requests carry `X-ScadaBridge-Forwarded` and are never relayed again. The 413 body cap runs *before* the relay, and the standby's `AuditWriteMiddleware` skips its own row for a relayed call (`InboundApiEndpointFilter.RelayedToActiveItemKey`) so it is audited once, on the node that executed it. Both rigs (`docker/`, `docker-env2/`) configure it on both central nodes; QA/prod appsettings are user-held and need the key added by hand. Spec: `Component-InboundAPI.md` → *Standby forwarding*.
- **DelmiaNotifier rig stand-in + the per-node inbound-key-store trap (2026-09-02).** `docker/seed-delmia-sim.sh` (in-memory `SimMachine` composing `DelmiaReceiverSim` as `DelmiaReceiver`, a `ValueChange` script on the ROOT template playing the Galaxy side) + `seed-delmia-api.sh` (prod's `DelmiaRecipeDownload` method + a scoped key) + `verify-delmia-notifier.sh` (notifier A–G incl. an active-central `docker kill`, takeover measured 27 s, rejoin as standby). The site script is a **static-attribute port** of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, so it uses per-attribute `SetAsync` + `WaitAsync`. Two facts the drill established: (1) `docker stop` is a graceful CoordinatedShutdown handover (takeover 0 s) — crash drills must `docker kill`; (2) **inbound API keys live in a PER-NODE SQLite file** (`ScadaBridge:InboundApi:ApiKeyStore:SqlitePath`, each central's own volume), written by whichever node serves the management call, so a key exists on one node only and **a central failover makes every inbound client 401** — the standby-relay path only works because the standby forwards before authenticating — resolved by register row 108 / the shared store; `sync-inbound-api-keys.sh` is deleted. Results: `docs/api-specs/Delmia-Recipe-Download-API.md` §8.
### Templates & Deployment
- Pre-deployment validation includes semantic checks (call targets, argument types, trigger operand types).
@@ -179,6 +180,7 @@ spec for each is `docs/requirements/Component-<Name>.md`, and `README.md` carrie
- LDAP failure: new logins fail; active sessions continue with current roles.
- Load balancer in front of central UI; cookie-embedded JWT + shared Data Protection keys for failover transparency.
- Two-person MxGateway secured writes (M7): two new global roles — `Operator` (initiates) + `Verifier` (approves) — added alongside the canonical `Administrator`/`Designer`/`Deployer`/`Viewer`, with `RequireOperator`/`RequireVerifier` policies. An Operator submits a secured write from the Central UI Secured Writes page (`/operations/secured-writes`); it stays a `Pending` `PendingSecuredWrite` row until a *distinct* Verifier approves it (no-self-approval enforced server-side in the ManagementActor, plus a compare-and-swap race guard). Approval relays a `WriteTagRequest` to the site MxGateway; MxGateway-protocol connections only; each lifecycle event (submit/approve/reject/execute) emits a best-effort `AuditChannel.SecuredWrite` / `AuditKind.SecuredWrite*` central-direct-write row sharing the row id as `CorrelationId`. (SecuredWrite audit rows stamp `SourceNode` via `ICentralAuditWriter`/`INodeIdentityProvider`.) Pending secured writes expire server-side after a configurable TTL (`ManagementServiceOptions.SecuredWritePendingTtl`, default 24 h): an overdue `Pending` row is CAS'd to `Expired` (never relayed) — enforced at approve/reject and swept opportunistically on list (arch-review S2, `AuditKind.SecuredWriteExpire`).
- **Inbound API key store — per node by default, shared on central pairs (2026-09-02, register row 108).** The shared `ZB.MOM.WW.Auth.ApiKeys` library stores inbound keys in **SQLite per node** unless `ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, which points both central nodes at the same `zbauth` schema in the configuration database (library `ZB.MOM.WW.Auth.ApiKeys.SqlServer` 0.3.0) — under `SqlServer`, create/revoke/rotate/scope changes are cluster-wide immediately; a node's existing SQLite file is imported once at boot (insert-where-absent, safe on both nodes of a pair) and renamed `*.imported`. Central pairs **MUST** run `Provider=SqlServer` — a clustered Central left on `Sqlite` boots fine but `StartupValidator` logs a Warning, because a failover otherwise 401s every inbound client until keys are re-issued on the survivor. Both docker rigs (`docker/`, `docker-env2/`) run `SqlServer`; QA (`vq03`/`vq04`) and prod (`vd03`) appsettings are user-held and their rollout is a deployment record, not yet applied. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`; runbook: `docs/operations/inbound-api-key-reissue.md` → *Central pairs*.
### Cluster & Failover
- **`auto-down` downing strategy (decision 2026-07-21 — availability over partition-safety).** Akka's `AutoDowning` provider, `auto-down-unreachable-after` = 15s: the leader among the REACHABLE members downs the unreachable peer, so a hard crash of EITHER node (active/oldest included) fails over to the survivor in ~25s. Accepted trade: a real partition → dual-active until an operator restarts one side. `keep-oldest` remains a supported `SplitBrainResolverStrategy` value (partition-safe, but an oldest-crash is a total outage — Akka's `down-if-alone` only rescues a side with ≥2 members, proven live + in 1.5.62 source). Decision record: `docs/plans/2026-07-21-auto-down-availability-decision.md`. **Ignore the boot-time Akka warning claiming "keep-majority will be used instead"** — drill-verified benign (2026-08-15, decision-doc postscript): the substitution does not happen with the explicit `AutoDowning` provider-class `BuildHocon` sets. But `AutoDowning` IS deprecated, so **every Akka.NET version bump must re-run `DRILL_MODE=active bash docker/failover-drill.sh`** (register row 70).
+5 -4
View File
@@ -98,10 +98,11 @@
<PackageVersion Include="ZB.MOM.WW.MxGateway.Client" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.MxGateway.Contracts" Version="0.2.0" />
<PackageVersion Include="ZB.MOM.WW.Configuration" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.2.1" />
<PackageVersion Include="ZB.MOM.WW.Auth.Abstractions" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.Ldap" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Auth.AspNetCore" Version="0.3.0" />
<PackageVersion Include="ZB.MOM.WW.Audit" Version="0.1.0" />
<PackageVersion Include="ZB.MOM.WW.Theme" Version="0.4.1" />
<PackageVersion Include="ZB.MOM.WW.Secrets" Version="0.6.2" />
+12
View File
@@ -31,6 +31,12 @@ services:
# pepper per the "different per environment" guidance; real deployments inject a
# true secret out-of-band, never from source control. Both Central nodes share it.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (mirrors docker/docker-compose.yml's G-4 T5 secrets
# cleanup) so that app-config file carries no plaintext credentials. Env overrides
@@ -88,6 +94,12 @@ services:
# pepper per the "different per environment" guidance; real deployments inject a
# true secret out-of-band, never from source control. Both Central nodes share it.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-env2-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (mirrors docker/docker-compose.yml's G-4 T5 secrets
# cleanup) so that app-config file carries no plaintext credentials. Env overrides
+5
View File
@@ -38,6 +38,8 @@ Local Docker deployment of the full ScadaBridge cluster topology: a 2-node centr
Runs the web UI (Blazor Server), Template Engine, Deployment Manager, Security, Inbound API, Management Service, and Health Monitoring. Connects to MS SQL for configuration and machine data, LDAP for authentication, and SMTP for notifications.
Both central nodes run `ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"` (`docker-compose.yml`), so inbound API keys live in ONE shared `zbauth` schema in `ScadaBridgeConfig` rather than a per-node SQLite file — a key minted through either node survives a central failover and a revoke is cluster-wide (design 2026-09-02, deferred-work register row 108). Each node's legacy `/app/data/inbound-api-keys.sqlite` was imported once at the first boot on this provider and renamed `*.imported`. The former `sync-inbound-api-keys.sh` file-copy workaround is gone; `verify-delmia-notifier.sh`'s preflight now doubles as the proof that both nodes see the same keys.
### Site Clusters (active/standby each)
Each site cluster runs Site Runtime, Data Connection Layer, Store-and-Forward, and Site Event Logging. Sites connect to OPC UA for device data and to the central cluster via Akka.NET remoting. Each site node also hosts a gRPC streaming server (port 8083) that central nodes connect to for real-time attribute value and alarm state streams. Deployed configurations and S&F buffers are stored in local SQLite databases per node.
@@ -95,6 +97,9 @@ docker/
├── deploy.sh # Build + deploy all containers
├── seed-sites.sh # Create test sites with Akka + gRPC addresses
├── seed-live-probe.sh # Seed one deployable live-data instance on site-a (stream traffic)
├── seed-delmia-sim.sh # Seed the in-memory Delmia machine stand-in (DelmiaSim01) on site-a
├── seed-delmia-api.sh # Seed the DelmiaRecipeDownload inbound method + a scoped API key (token printed once)
├── verify-delmia-notifier.sh # DelmiaNotifier end-to-end vs the rig: standby relay, active-central crash/takeover, rejoin
├── teardown.sh # Stop and remove containers
├── central-node-a/
│ ├── appsettings.Central.json # Central node A configuration
+12
View File
@@ -99,6 +99,12 @@ services:
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
@@ -160,6 +166,12 @@ services:
# out-of-band (env/secret store), never from source control — see
# docs/operations/inbound-api-key-reissue.md. Both Central nodes share one pepper.
ScadaBridge__InboundApi__ApiKeyPepper: "dev-only-insecure-pepper-docker-cluster-0001"
# Shared inbound API key store (design 2026-09-02, register row 108): both centrals read and
# write ONE zbauth schema in the configuration database, so a key minted on either node
# survives a central failover and a revoke is cluster-wide. The SqlServer connection string
# is deliberately NOT set: it defaults to ScadaBridge__Database__ConfigurationDb below.
# Each node's legacy per-node SQLite file is imported once at boot and renamed *.imported.
ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"
# DEV-ONLY local-cluster secrets — NOT real secrets. Relocated out of the mounted
# appsettings.Central.json (G-4 T5 secrets cleanup) so that app-config file carries no
# plaintext credentials. Env overrides layer over JSON BEFORE the ${secret:} pre-host
+47
View File
@@ -0,0 +1,47 @@
#!/bin/bash
set -euo pipefail
# Seed the DelmiaRecipeDownload inbound method (prod's script verbatim, minus its dead `suf`
# line — see docs/api-specs/Delmia-Recipe-Download-API.md §4) and one API key scoped to it.
# Prints the one-time token as DELMIA_API_KEY=... — capture it; it cannot be retrieved again.
# Not idempotent (method name must be unique): delete with `api-method delete --id`. Key display
# names are NOT unique — re-running mints another key with the same name; delete the stale one with
# `security api-key delete --key-id`.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
KEY_NAME="${KEY_NAME:-delmia-notifier}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
read -r -d '' METHOD_CODE <<'CS' || true
try {
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var code = (P("MachineCode") as string ?? "").Trim();
if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" };
return await Route.To(code).Call("ProcessRecipeDownload", new {
DownloadPath = P("DownloadPath"),
WorkOrderNumber = P("WorkOrderNumber"),
PartNumber = P("PartNumber"),
JobStepNumber = P("JobStepNumber"),
Username = P("Username"),
});
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
echo "1/2 Creating inbound method DelmiaRecipeDownload (timeout 45 s)..."
METHOD_ID=$("$CLI" api-method create "${A[@]}" --name DelmiaRecipeDownload --timeout 45 \
--parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--script "$METHOD_CODE" | json_id)
echo " id=$METHOD_ID name=DelmiaRecipeDownload"
echo "2/2 Creating API key '$KEY_NAME' scoped to DelmiaRecipeDownload..."
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods DelmiaRecipeDownload)
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])' 2>/dev/null || echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)
echo "DELMIA_API_KEY=$TOKEN"
+112
View File
@@ -0,0 +1,112 @@
#!/bin/bash
set -euo pipefail
# Seed an IN-MEMORY stand-in for a vd03-style machine with a composed DelmiaReceiver on
# site-a, so DelmiaNotifier can be exercised end-to-end on the docker rig without a Galaxy.
#
# What it creates (all static attributes — no data connection, no bindings):
# - Template DelmiaReceiverSim — the ten DelmiaReceiver attributes from prod
# (docs/api-specs/Delmia-Recipe-Download-API.md §5), ReadyFlag defaulting to true.
# - Template SimMachine — composes DelmiaReceiverSim as `DelmiaReceiver` and carries
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes).
# * DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag) —
# plays the Galaxy object: accepts unless DownloadPath ends in ".bad" or the work
# order is blank, writes RecipeProcessResult/Text, clears the trigger flag, then
# sets RecipeProcessedFlag=true.
# - Instance DelmiaSim01 on site-a (id 1), deployed.
#
# Not idempotent: names must be unique. To re-seed:
# scadabridge instance delete --id <id>; scadabridge template delete --id <SimMachine id>;
# scadabridge template delete --id <DelmiaReceiverSim id>
# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; central healthy on :9000.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
SITE_ID="${SITE_ID:-1}"
INSTANCE_NAME="${INSTANCE_NAME:-DelmiaSim01}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
echo "1/6 Creating template DelmiaReceiverSim..."
RECV_ID=$("$CLI" template create "${A[@]}" --name DelmiaReceiverSim \
--description "In-memory stand-in for the Galaxy DelmiaReceiver object (ten static attributes)" | json_id)
echo " id=$RECV_ID"
echo "2/6 Adding the ten receiver attributes (static)..."
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessedFlag --data-type Boolean --value false --description "Cleared by the caller, set true by the receiver when done" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessResult --data-type Boolean --value false --description "Outcome -> Result" > /dev/null
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name "$s" --data-type String --description "Recipe-download payload / outcome text" > /dev/null
done
echo "3/6 Creating template SimMachine + composing DelmiaReceiver..."
MACHINE_ID=$("$CLI" template create "${A[@]}" --name SimMachine \
--description "vd03-style machine stand-in: composes DelmiaReceiverSim as DelmiaReceiver; in-memory only" | json_id)
"$CLI" template composition add "${A[@]}" --template-id "$MACHINE_ID" --composed-template-id "$RECV_ID" --instance-name DelmiaReceiver > /dev/null
echo " id=$MACHINE_ID"
echo "4/6 Adding ProcessRecipeDownload (Call) — prod handshake on static attributes..."
read -r -d '' PROCESS_CODE <<'CS' || true
// Port of prod CvdReactor.ProcessRecipeDownload for STATIC attributes: WriteBatchAndWaitAsync only
// accepts data-sourced attributes, so the payload is written with SetAsync and the handshake waited
// with WaitAsync (same trigger/response flags, same 25 s budget, same result mapping).
var recv = Children["DelmiaReceiver"];
System.Func<object?,bool> IsTrue = v => { var s = v?.ToString(); return (v is bool b && b) || s == "1" || string.Equals(s, "true", System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
if (!IsTrue(await recv.Attributes.GetAsync("ReadyFlag")))
return new { Result = false, ResultText = "Receiver ready flag not set to true" };
try {
await recv.Attributes.SetAsync("RecipeProcessedFlag", false);
await recv.Attributes.SetAsync("DownloadPath", P("DownloadPath"));
await recv.Attributes.SetAsync("WorkOrderNumber", P("WorkOrderNumber"));
await recv.Attributes.SetAsync("PartNumber", P("PartNumber"));
await recv.Attributes.SetAsync("JobStepNumber", P("JobStepNumber"));
await recv.Attributes.SetAsync("Username", P("Username"));
await recv.Attributes.SetAsync("RecipeDownloadFlag", true);
if (!await recv.Attributes.WaitAsync("RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25)))
return new { Result = false, ResultText = "Timeout waiting for recipe to be processed" };
return new {
Result = IsTrue(await recv.Attributes.GetAsync("RecipeProcessResult")),
ResultText = (await recv.Attributes.GetAsync("RecipeProcessResultText"))?.ToString() ?? ""
};
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name ProcessRecipeDownload --trigger-type Call \
--parameters '{"type":"object","properties":{"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--code "$PROCESS_CODE" > /dev/null
echo "5/6 Adding DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag)..."
read -r -d '' SIM_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver object. Fires on every write to RecipeDownloadFlag (root-scope
// trigger, canonical name) and acts only on the rising edge; its own reset to false re-triggers
// this script, which then returns at the guard.
var recv = Children["DelmiaReceiver"];
var flag = await recv.Attributes.GetAsync("RecipeDownloadFlag");
var fs = flag?.ToString();
if (!((flag is bool b && b) || fs == "1" || string.Equals(fs, "true", System.StringComparison.OrdinalIgnoreCase))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var ok = path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null;
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name DelmiaReceiverSimulator --trigger-type ValueChange \
--trigger-config '{"attributeName":"DelmiaReceiver.RecipeDownloadFlag"}' --code "$SIM_CODE" > /dev/null
echo "6/6 Creating + deploying instance $INSTANCE_NAME on site $SITE_ID..."
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$MACHINE_ID" --site-id "$SITE_ID" | json_id)
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID"
echo ""
echo "DELMIA_SIM_TEMPLATE_ID=$RECV_ID SIM_MACHINE_TEMPLATE_ID=$MACHINE_ID INSTANCE_ID=$INSTANCE_ID INSTANCE_NAME=$INSTANCE_NAME"
echo "Verify: $CLI debug snapshot --id $INSTANCE_ID --url http://localhost:9000 --username multi-role --password password"
+94
View File
@@ -0,0 +1,94 @@
#!/bin/bash
set -euo pipefail
# End-to-end DelmiaNotifier verification against the docker rig, including an ACTIVE central
# crash (docker kill = SIGKILL, so auto-down failover is exercised, not CoordinatedShutdown's
# graceful handover) + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and
# DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh).
#
# HISTORY (2026-09-02): the first run of this script found that inbound API keys were a PER-NODE
# SQLite store, so the key minted through the active node did not exist on the standby and every
# call after a takeover answered 401 KeyNotFound (deferred-work register row 108; the rig carried
# a sync-inbound-api-keys.sh file-copy workaround). Both rig centrals now run
# ScadaBridge__InboundApi__ApiKeyStore__Provider=SqlServer (one shared zbauth schema in the config
# DB), so the preflight below — which lists the key via EACH node's own management API — is no
# longer a gate that needs a script to satisfy: it is the proof that a key minted on one node is
# visible on the other without any file copy.
#
# The notifier is run as the managed build (dotnet WWNotifier.dll) from a scratch copy so the
# API key is written to THAT copy's appsettings.json (ScadaBridge:ApiKey) and never into the
# repo. BaseUrls lists the two central nodes DIRECTLY (not Traefik) so the notifier's own
# connect-failure failover is what gets exercised.
#
# Expected outcomes:
# A baseline (active listed first) -> YES
# B standby listed first (standby relays) -> YES (StandbyForwardPeerEndpoints)
# C bad key -> NO (HTTP 401, no failover: 401 is an answer)
# D active KILLED, before takeover completes -> NO or YES — informational only. A NO here is the
# documented connect-failure-only design (standby answers 503 ACTIVE_NODE_UNREACHABLE).
# E active KILLED, after survivor /health/active=200 -> YES (first URL refused -> failover)
# F victim restarted and rejoined (memberCount=2) -> YES
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
: "${DELMIA_API_KEY:?export DELMIA_API_KEY=sbk_... first}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="${RUN_DIR:-${TMPDIR:-/tmp}/delmia-notifier-run}"
MACHINE="${MACHINE:-DelmiaSim01}"
A_URL=http://localhost:9001; B_URL=http://localhost:9002
echo "== build notifier + stage a runnable copy in $RUN_DIR"
dotnet build "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier" -c Debug -nologo -v q
rm -rf "$RUN_DIR"; mkdir -p "$RUN_DIR"; cp -R "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/bin/Debug/net10.0/." "$RUN_DIR/"
write_cfg() { # $1 = BaseUrls, $2 = ApiKey
printf '{"ScadaBridge":{"BaseUrls":"%s","TimeoutSeconds":30,"LogPath":"delmia-notifier.log","ApiKey":"%s"}}\n' "$1" "$2" > "$RUN_DIR/appsettings.json"
}
notify() { # $1 = label, $2 = expected YES|NO, rest = extra args; prints stdout + exit code
local label=$1 expect=$2; shift 2
local out rc; set +e; out=$(env -u SCADABRIDGE_API_KEY dotnet "$RUN_DIR/WWNotifier.dll" -m "$MACHINE" -d 'C:\recipes\wo1.nc' -w "WO-$label" -p PN-1 -s 0100 -u drill "$@" 2>/dev/null); rc=$?; set -e
local first; first=$(echo "$out" | head -1)
printf '%-3s stdout=%-4s exit=%-3s | %s\n' "$label" "$first" "$rc" "$(echo "$out" | sed -n 2p)"
[ "$first" = "$expect" ] || { echo " ^^ EXPECTED $expect"; return 1; }
}
active() { curl -sf -o /dev/null "$A_URL/health/active" && echo a || { curl -sf -o /dev/null "$B_URL/health/active" && echo b; }; }
url_of() { [ "$1" = a ] && echo "$A_URL" || echo "$B_URL"; }
container_of() { [ "$1" = a ] && echo scadabridge-central-a || echo scadabridge-central-b; }
other() { [ "$1" = a ] && echo b || echo a; }
wait_for() { # $1 url $2 path $3 seconds
local i; for i in $(seq 1 "$3"); do curl -sf -o /dev/null "$1$2" && return 0; sleep 1; done; return 1; }
ACT=$(active); STB=$(other "$ACT")
echo "== active=central-$ACT standby=central-$STB"
echo "== preflight/proof: the key minted on the active node must be visible from BOTH nodes (shared store, no file copy)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
KEY_ID="${DELMIA_API_KEY#sbk_}"; KEY_ID="${KEY_ID%%_*}"
for n in a b; do
if "$CLI" security api-key list --url "$(url_of "$n")" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}" | grep -q "\"keyId\":\"$KEY_ID\""; then echo " central-$n: key $KEY_ID present"
else echo "FAIL: central-$n does not see key $KEY_ID — is ScadaBridge__InboundApi__ApiKeyStore__Provider=SqlServer set on both centrals (docker-compose.yml)?" >&2; exit 1; fi
done
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES
echo "== C bad key"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "sbk_bogus_bogus"; notify C NO || true
write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"
echo "== D/E KILL the ACTIVE central ($(container_of "$ACT"))"; docker kill "$(container_of "$ACT")" > /dev/null
T0=$(date +%s)
echo "-- D immediate attempt (informational):"; notify D NO || notify D YES || true
echo "-- waiting for survivor $(url_of "$STB")/health/active (auto-down ~25-30 s)..."
wait_for "$(url_of "$STB")" /health/active 90 || { echo "FAIL: survivor never became active"; exit 1; }
echo " takeover after $(( $(date +%s) - T0 )) s"
echo "== E after takeover (first URL refused -> failover)"; notify E YES
echo "== F restart victim + wait for rejoin"; docker start "$(container_of "$ACT")" > /dev/null
T1=$(date +%s)
wait_for "$(url_of "$ACT")" /health/ready 120 || { echo "FAIL: victim never ready"; exit 1; }
for i in $(seq 1 60); do
curl -s "$(url_of "$STB")/health/ready" | grep -q '"memberCount":2,"unreachableCount":0' && break; sleep 2
done
echo " victim ready + memberCount=2 on survivor after $(( $(date +%s) - T1 )) s; victim /health/active=$(curl -s -o /dev/null -w '%{http_code}' "$(url_of "$ACT")/health/active") (503 = rejoined as standby)"
notify F YES
echo "== G reject path"; notify G NO -d 'C:\recipes\x.bad' || true
echo "== done. Notifier log: $RUN_DIR/delmia-notifier.log"
@@ -0,0 +1,275 @@
# DelmiaRecipeDownload — ScadaBridge Inbound API spec (as deployed)
**Captured:** 2026-09-02 from the live **prod** management API on `wonder-app-vd03` (method id 4,
API keys, the `CvdReactor` template's `ProcessRecipeDownload` script, the `DelmiaReceiver` template,
and the inbound audit log). **QA (`vq03`/`vq04`) was NOT read** — its management API requires real
LDAP credentials (prod runs with `DisableLogin`); re-capture QA with `scadabridge api-method get --id …`
once credentials are to hand. The definition is config-as-data in the central ConfigDb (not in git), so
this file is the durable record; the legacy predecessor is documented in
[`../former-api-specs/dnc/Delmia-Integration-API.md`](../former-api-specs/dnc/Delmia-Integration-API.md)
(Surface B), and the client that calls it is the `WWNotifier.exe` drop-in
([`DelmiaNotifier`](../../src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md)).
## 1. Endpoint
| Item | Value |
|---|---|
| Method / path | `POST /api/DelmiaRecipeDownload` |
| Auth | `X-API-Key: sbk_<keyId>_<secret>` **or** `Authorization: Bearer sbk_<keyId>_<secret>` (Authorization wins if both) |
| Content type | `application/json`, flat PascalCase object |
| Server timeout | **45 s** (`TimeoutSeconds` on the method); the site-side handshake wait inside it is **25 s** |
| Prod base URL | `http://wonder-app-vd03.zmr.zimmer.com:8085` (single central node) |
| QA base URLs | `http://10.220.158.2:8085` (vq03), `http://10.220.158.3:8085` (vq04) — list **both** in the notifier's `BaseUrls`; a standby central relays to the active peer |
Unauthenticated / bad-key calls return **401** `{"error":"Invalid or missing API key"}` (audit kind
`InboundAuthFailure`). General Inbound API status semantics (413 body cap, 503 `STANDBY_NODE` /
`ACTIVE_NODE_UNREACHABLE`, unknown method) are in
[`Component-InboundAPI.md`](../requirements/Component-InboundAPI.md).
## 2. Request
Deployed `ParameterDefinitions` (JSON Schema, verbatim — note `required` is **empty**; the
script itself rejects a missing/blank `MachineCode`):
```json
{"type":"object","properties":{
"MachineCode":{"type":"string"},
"DownloadPath":{"type":"string"},
"WorkOrderNumber":{"type":"string"},
"PartNumber":{"type":"string"},
"JobStepNumber":{"type":"string"},
"Username":{"type":"string"}},
"required":[]}
```
| Field | Notifier flag | Meaning | Where it lands on the site |
|---|---|---|---|
| `MachineCode` | `-m` | **Routing key** — must equal an instance `UniqueName` exactly (trimmed). Prod today: `Z28061`, `Z28062`, `Z28061Sim`. | `Route.To(MachineCode)` |
| `DownloadPath` | `-d` | Path where the recipe/NC file was placed | `DelmiaReceiver.DownloadPath` |
| `WorkOrderNumber` | `-w` | Work order | `DelmiaReceiver.WorkOrderNumber` |
| `PartNumber` | `-p` | Part / item number | `DelmiaReceiver.PartNumber` |
| `JobStepNumber` | `-s` | Job step / sequence op | `DelmiaReceiver.JobStepNumber` |
| `Username` | `-u` | Operator (audit only) | `DelmiaReceiver.Username` |
Example (a real call taken from the prod audit log):
```bash
curl -s -X POST http://wonder-app-vd03.zmr.zimmer.com:8085/api/DelmiaRecipeDownload \
-H "X-API-Key: $SCADABRIDGE_API_KEY" -H "Content-Type: application/json" \
-d '{"MachineCode":"Z28061Sim","DownloadPath":"C:\\recipes\\test.nc","WorkOrderNumber":"WO-TEST-001","PartNumber":"PN-TEST","JobStepNumber":"0100","Username":"delmia-sim-test"}'
```
## 3. Response
Deployed `ReturnDefinition`: `{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}`
**HTTP 200 is returned for every business outcome** — success is `Result == true`, never the status
code. `ResultText` values the deployed scripts can produce:
| `Result` | `ResultText` | Produced by |
|---|---|---|
| `true` | echo of `DelmiaReceiver.RecipeProcessResultText` (often `""`) | Galaxy object acknowledged the download |
| `false` | `DelmiaReceiver.RecipeProcessResultText` | Galaxy object rejected the download (`RecipeProcessResult` false) |
| `false` | `MachineCode is required` | central script, blank `MachineCode` |
| `false` | `Receiver ready flag not set to true` | site script, `ReadyFlag` not true (**or unreadable — see §7**) |
| `false` | `Timeout waiting for recipe to be processed` | site script, `RecipeProcessedFlag` not true within 25 s |
| `false` | `RecipeDownload failed: <exception>` | either script — e.g. unknown instance, site unreachable, `Script 'ProcessRecipeDownload' on instance 'X' failed: …` |
## 4. Processing flow
```
Delmia ─► WWNotifier.exe ─► POST /api/DelmiaRecipeDownload (central, active node)
│ inbound method script (id 4): validate MachineCode
▼
Route.To(MachineCode).Call("ProcessRecipeDownload", {5 fields})
│ gRPC central→site, lands on the instance actor
▼
CvdReactor.ProcessRecipeDownload (site Call script) on the composed DelmiaReceiver child:
1. read ReadyFlag → must be true, else "Receiver ready flag not set to true"
2. write RecipeProcessedFlag = false
3. WriteBatchAndWaitAsync({DownloadPath, WorkOrderNumber, PartNumber, JobStepNumber, Username},
trigger RecipeDownloadFlag = true,
wait RecipeProcessedFlag == true, timeout 25 s)
4. read RecipeProcessResult / RecipeProcessResultText → {Result, ResultText}
```
Both scripts as deployed on prod (verbatim):
<details><summary>Central inbound method script (id 4, timeout 45 s)</summary>
```csharp
try {
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var code = (P("MachineCode") as string ?? "").Trim();
if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" };
var suf = code.Substring(code.Length - 1);
return await Route.To(code).Call("ProcessRecipeDownload", new {
DownloadPath = P("DownloadPath"),
WorkOrderNumber = P("WorkOrderNumber"),
PartNumber = P("PartNumber"),
JobStepNumber = P("JobStepNumber"),
Username = P("Username"),
});
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
```
</details>
<details><summary>Site script <code>CvdReactor.ProcessRecipeDownload</code> (template 1, script id 4, trigger <code>call</code>)</summary>
```csharp
// Generic DELMIA recipe-download — performed DIRECTLY on the composed DelmiaReceiver child
// (mirrors how MesMoveIn writes the MESReceiver child) instead of delegating via CallScript.
// No side logic; the receiver's own DelmiaReceiver.Download method holds the same handshake.
var recv = Children["DelmiaRecv"];
System.Func<object,bool> IsTrue = v => { var s=v?.ToString(); return (v is bool b && b)||s=="1"||string.Equals(s,"true",System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
if (!IsTrue(recv.Attributes["ReadyFlag"]))
return new { Result=false, ResultText="Receiver ready flag not set to true" };
try {
recv.Attributes["RecipeProcessedFlag"] = false;
var data = new System.Collections.Generic.Dictionary<string, object?> {
{ "DownloadPath", P("DownloadPath") },
{ "WorkOrderNumber", P("WorkOrderNumber") },
{ "PartNumber", P("PartNumber") },
{ "JobStepNumber", P("JobStepNumber") },
{ "Username", P("Username") },
};
if (!await recv.Attributes.WriteBatchAndWaitAsync(data, "RecipeDownloadFlag", true, "RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25)))
return new { Result=false, ResultText="Timeout waiting for recipe to be processed" };
return new {
Result = IsTrue(recv.Attributes["RecipeProcessResult"]),
ResultText = recv.Attributes["RecipeProcessResultText"]?.ToString() ?? ""
};
} catch (Exception ex) {
return new { Result=false, ResultText="RecipeDownload failed: " + ex.Message };
}
```
</details>
## 5. Galaxy-side contract — the `DelmiaReceiver` template (id 12; composed clone id 26)
All ten attributes are **data-sourced over the site `MxGateway` connection**, bound per instance to the
reactor-level Galaxy object (prod: `DelmiaReceiver_038` on Z28061, `_039` on Z28062, `_037` on
Z28061Sim; `Left_00x.DelmiaReceiver` / `Right_00x.DelmiaReceiver` back the unused side compositions).
| Attribute | Type | Direction | Role in the handshake |
|---|---|---|---|
| `ReadyFlag` | Boolean | Galaxy → SB | Precondition; must be true or the call is refused |
| `RecipeDownloadFlag` | Boolean | SB → Galaxy | **Trigger** — set true after the five data fields are written |
| `RecipeProcessedFlag` | Boolean | both | SB clears to false first; Galaxy sets true when done (waited ≤ 25 s) |
| `RecipeProcessResult` | Boolean | Galaxy → SB | Outcome → `Result` |
| `RecipeProcessResultText` | String | Galaxy → SB | Outcome text → `ResultText` |
| `DownloadPath`, `WorkOrderNumber`, `PartNumber`, `JobStepNumber`, `Username` | String | SB → Galaxy | Payload, written as one batch before the trigger |
## 6. API keys scoped to this method (prod, names only — secrets are shown once at creation)
`mes`, `mes api key`, `delmia-mes-receivers`, `TestKey`, `JOE_TEST_KEY` — all enabled, all also
scoped to the MES methods (`MesMoveIn`, `MesMoveOut`, `MesSampleData`, `SimpleAlarmStatusRequest`,
most also `IpsenMESMoveIn`). `delmia-mes-receivers` is the one whose name says it is the Delmia
caller's key; the audit `actor` column shows which key served each call.
## 7. Observed behaviour and open issues (from the prod audit log, 2026-06-17 → 2026-06-26)
Nine calls total, all test traffic (actors `delmia-mes-receivers`, `delmia-sim-test`), none since
2026-06-26 — **no production Delmia traffic has hit this method yet.** Every 200 answered
`Result=false`: mostly `Receiver ready flag not set to true`, one
`Script 'ProcessRecipeDownload' … The given key 'side' was not present` from an earlier script
revision, and one `MachineCode must end with A (Left) or B (Right)` from a retired left/right variant
(the current script routes on the whole `MachineCode`; the leftover `suf` variable is unused).
1. **Likely broken since the 2026-06-27 rename — verify before go-live.** The site script still
addresses the child as `Children["DelmiaRecv"]`, but the `CvdReactor` composition was renamed
`DelmiaRecv` → `DelmiaReceiver` that day (`../deployment/deployment-records/2026-06-27-wonder-app-vd03.md`
§2b), so the flattened paths are `DelmiaReceiver.*`. `ChildrenAccessor` just concatenates the
name into a path and `GetAttribute` on an unknown path logs a warning and yields null, so
`IsTrue(null)` is false and **every call will answer `Receiver ready flag not set to true`
regardless of the real flag**. No call has been made since the rename, so this is inferred from
the code, not observed. Fix = change that one line to `Children["DelmiaReceiver"]` on the
template (`scadabridge template script update`) and redeploy the three CvdReactor instances;
check QA's copy of the script for the same string.
2. **Not idempotent.** The design requires idempotency on `(MachineCode, DownloadPath, WorkOrderNumber)`
because the notifier retries on timeout/connect failure (at-least-once). The deployed script
re-runs the full handshake on a duplicate, so a retry after a slow first attempt will trigger a
second download on the Galaxy object.
3. `required` is empty in the parameter schema; only `MachineCode` is enforced, in script. Missing
payload fields are written to the receiver as null/empty.
4. QA definitions unread (credentials) — assumed identical (same build lineage), not verified.
## 8. Docker rig stand-in and notifier verification (2026-09-02)
The local rig (`docker/`, central-a `:9001` / central-b `:9002` / Traefik `:9000`, site-a = site id 1)
carries an in-memory stand-in for a vd03-style machine so the whole path can be exercised without a
Galaxy. Seeded by CLI only, no DB edits:
- `docker/seed-delmia-sim.sh` — template **`DelmiaReceiverSim`** (the ten §5 attributes, all **static**,
`ReadyFlag` defaulting to `true`) composed into **`SimMachine`** as `DelmiaReceiver`; instance
**`DelmiaSim01`** on site-a, deployed. `SimMachine` carries two scripts:
- `ProcessRecipeDownload` (`Call`) — the §4 handshake ported to static attributes. `WriteBatchAndWaitAsync`
**rejects static attributes** (`InstanceActor.HandleWriteAttributeBatch`: "not a data-sourced
attribute"), so the port writes the payload with per-attribute `SetAsync` and waits with `WaitAsync`
— same trigger/response flags, same 25 s budget, same result mapping. It addresses
`Children["DelmiaReceiver"]` (the post-rename name; contrast §7 item 1).
- `DelmiaReceiverSimulator` (`ValueChange` on `DelmiaReceiver.RecipeDownloadFlag`) — plays the Galaxy
object: on the rising edge it accepts unless `DownloadPath` ends in `.bad` or the work order is blank,
writes `RecipeProcessResult`/`RecipeProcessResultText`, clears the trigger flag, then sets
`RecipeProcessedFlag = true`. It lives on the **root** template because composed scripts' trigger
configs are not prefixed at flattening, and `ScriptActor` matches `ValueChange` by exact canonical name.
- `docker/seed-delmia-api.sh` — the §4 method verbatim (minus its dead `suf` line), timeout 45 s, plus one key
(`delmia-notifier`) scoped to it; the token is printed once.
- ~~`docker/sync-inbound-api-keys.sh` — required before any central failover test, see below.~~
**Superseded 2026-09-02 by register row 108 / the shared SQL Server key store**
(`ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, now set on both rig centrals): the script
is deleted and `verify-delmia-notifier.sh` needs no file copy before a failover run — both nodes
read the same `zbauth` rows.
- `docker/verify-delmia-notifier.sh` — runs the managed `WWNotifier.dll` from a scratch copy whose
`appsettings.json` carries the key (`ScadaBridge:ApiKey`), with `BaseUrls` listing the two central nodes
directly (not Traefik) so the notifier's own connect-failure failover is what gets exercised.
Whole-path latency through Traefik: a `curl` call returns in under 100 ms (simulator fires on the same
node, no device round-trip).
### Verified outcomes (second run, 2026-09-02 16:51 UTC)
| Case | Setup | Result | Audit row (`ApiInbound`) |
|------|-------|--------|--------------------------|
| A | active (central-a) first in `BaseUrls` | `YES` | `InboundRequest` on `central-a` |
| B | standby (central-b) first — standby relays | `YES` | **one** row, on `central-a` (the relay does not double-audit) |
| C | bad key | `NO` + `HTTP 401` | `InboundAuthFailure` on `central-a` |
| D | active `docker kill`ed, attempt inside the takeover window | `NO` + `HTTP 503` (`ACTIVE_NODE_UNREACHABLE` from the not-yet-active survivor) — expected, connect-failure-only failover | none (infrastructure answer) |
| E | survivor `/health/active` = 200 — takeover measured **27 s** after the kill | `YES` (`:9001` refused → `:9002` 200) | `InboundRequest` on `central-b` |
| F | victim restarted, `memberCount=2`, victim `/health/active` = 503 (rejoined as standby) | `YES` (victim relays) | `InboundRequest` on `central-b` |
| G | `DownloadPath` = `C:\recipes\x.bad` | `NO` + `Simulated reject: bad path or missing work order` | `InboundRequest` on `central-b`, `Result=false` |
The notifier's first-line contract, exit codes (`0` / `-1`), and the reason line on `NO` behaved as the
README states throughout.
### Two findings from the first run (kept, not "fixed")
1. **`docker stop` is not a crash.** SIGTERM triggers CoordinatedShutdown's graceful singleton handover —
the survivor was active **0 s** after the stop. The drill uses `docker kill` so auto-down's ~25–30 s
takeover is what gets measured (`docker/failover-drill.sh` already does the same).
2. **Inbound API keys are a per-node SQLite store, so a central failover invalidates every client's key.**
Keys live in `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` (`/app/data/inbound-api-keys.sqlite`, on
each central's own data volume), and key admin (`ManagementActor`, a per-node `ActorOf` reached from that
node's `/management` endpoints) writes only the node that serves the call — through Traefik that is the
active node. After the first run's takeover, central-b answered **401 `KeyNotFound`** to the key
central-a had minted; its `security api-key list` was `[]`. The standby-relay case (B) passes only
because the standby forwards **before** authenticating. The same asymmetry means a key revoked on one
node stays valid on the other. Rig workaround at the time: `docker/sync-inbound-api-keys.sh`
checkpointed central-a's store on the host (`sqlite3`) and replaced central-b's (target stopped for the
copy; both rig nodes share one pepper, so the hashes verify on either node); the verify script's
preflight refused to start unless both nodes listed the key.
**Superseded 2026-09-02 (same day) by register row 108.** `ZB.MOM.WW.Auth` 0.3.0 adds a SQL Server
provider (`ZB.MOM.WW.Auth.ApiKeys.SqlServer`); both rig centrals now run
`ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`, sharing one `zbauth` row set in the
configuration database, so a key exists on both nodes the instant it's created and a revoke is
cluster-wide immediately. `docker/sync-inbound-api-keys.sh` is deleted; a re-run of this drill needs
no file copy before the failover step. Re-measured 2026-09-03 without the
workaround: A/B/E/F `YES`, C/G `NO`, active-central `docker kill` takeover **28 s** (the earlier run measured 27 s with the file copy), victim rejoined as standby 2 s after restart. Prod `wonder-app-vd03` is single-node and unaffected; the QA pair
(`vq03`/`vq04`) and prod still run `Provider=Sqlite` pending a deployment record.
Deferred-work register row 107 records §7 item 1 (`Children["DelmiaRecv"]`) as the prod follow-up.
+9
View File
@@ -0,0 +1,9 @@
# API Specs (as deployed)
Point-in-time captures of ScadaBridge's **own** externally-called contracts — Inbound API methods and
the site-side handshakes behind them — pulled from a live environment's management API. Method
definitions are config-as-data in the central ConfigDb (not source-controlled), so these files are the
durable record; each states which environment it was captured from and when. Legacy/predecessor
system specs live in [`../former-api-specs/`](../former-api-specs).
- [`Delmia-Recipe-Download-API.md`](Delmia-Recipe-Download-API.md) — `POST /api/DelmiaRecipeDownload` (DELMIA Apriso recipe-download notification), prod `wonder-app-vd03`, 2026-09-02.
@@ -0,0 +1,117 @@
# 2026-09-XX — QA (vq03/vq04) + prod (vd03) → shared inbound API key store (PLANNED)
**Status: PLANNED — not yet run.** This record is a script for the operator to execute on the
ZB servers and then fill in (date/time, log lines, results). Everything below the header table
is what is *expected* to happen, per the design and the runbook it must agree with; blanks are
marked `_____`.
**Build:** branch `inbound-api-key-shared-store` @ `<merge commit>` (not yet merged to `main`).
**Delta from the boxes' previous build:** library-only + one config key — pins
`ZB.MOM.WW.Auth` 0.2.1 → **0.3.0** (adds the `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package) and adds
`ScadaBridge:InboundApi:ApiKeyStore:Provider`. `git diff --stat <prev>..<merge commit> -- '**/Migrations/*'`
expected empty — no ScadaBridge EF migration; the new `zbauth` schema is created by the library's
own migrator (`SqlServerApiKeyStoreMigrator`), not `dotnet ef`. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`
· register row 108 (`docs/plans/2026-07-08-deferred-work-register.md`). Runbook this record must
agree with: `docs/operations/inbound-api-key-reissue.md` → *Central pairs*.
## Config change (both central nodes on each pair)
`appsettings.Central.json`, under `ScadaBridge:InboundApi`, add:
```json
"ApiKeyStore": {
"Provider": "SqlServer"
}
```
No other key changes — `SqlServer:ConnectionString` is deliberately **left unset**, so it
defaults to `ScadaBridge:Database:ConfigurationDb` (never a `${secret:}` reference — the
connection string is the same one already used for method definitions and the config DB). No
new secret is distributed.
## Procedure (build/stage recipe per the 2026-08-21 records)
1. `dotnet publish` Host + CLI `-c Release -r win-x64 --self-contained true`; zip; `psbridge push
--compress` to vq04 (digest verified); `Expand-Archive`; robocopy `/MIR` to
`\\wonder-app-vq03\E$\psrem-stage\<merge commit>` and `\\wonder-app-vd03\E$\psrem-stage\<merge commit>`
(file count + ProductVersion checked, per the existing recipe).
2. **Pre-check (QA only, before the swap):** confirm the app's SQL login has `CREATE SCHEMA`/
`CREATE TABLE` rights on the QA configuration database. If not, have a DBA run
`docs/operations/sql/zbauth-schema.sql` against it now — the migrator then finds the
`zbauth.schema_version` row already present and skips DDL on both nodes' first boot.
3. **QA `_____`** — Site pair stopped, Central pair stopped (both boxes **together** — a
clustered Central left with one node on `SqlServer` and one still importing would leave the
pair briefly disagreeing on where keys live), `app`/`cli` renamed to `*.bak.<timestamp>` on
both boxes, staged copy in, each box's three `appsettings*.json` restored **and then edited**
to add the `ApiKeyStore.Provider` block above, Central pair started (+20 s), Site pair started
(+45 s).
4. **Prod `_____`** — same on vd03 (single node): backup `*.bak.<timestamp>`, same appsettings
edit, Central started, no site-pair step (`vd03` central is single-node; its site pair is
unaffected by this change and follows the same stop/start cadence as any other binary swap on
that box).
## Verification
Expect the following, matching `docs/operations/inbound-api-key-reissue.md` §*Central pairs*
steps 5–6:
| Check | Expected | Result |
|---|---|---|
| 10.220.158.2:8085 / :8084 (vq03) `/health/ready` | 200 Healthy, members=2 | `_____` |
| 10.220.158.3:8085 / :8084 (vq04) `/health/ready` | 200 Healthy, members=2 | `_____` |
| vd03 localhost:8085 / :8084 `/health/ready` | 200 Healthy, members=1 | `_____` |
| Boot log, QA node that imports first | `Inbound API key store: imported … N keys inserted` (or the docs/operations wording, `grep -i "api-key store import\|zbauth"`) | `_____` |
| Boot log, QA other node | `… keys already present` (0 inserted — its own file's keys already landed via the first node's insert-where-absent import, or it imports its own disjoint set) | `_____` |
| `inbound-api-keys.sqlite.imported` (+ `-wal.imported`/`-shm.imported`) present next to each QA node's data | present on both boxes | `_____` |
| `scadabridge security api-key list` against `.2:8085` | matches `.3:8085` (same key set) | `_____` |
| `scadabridge security api-key list` against `.3:8085` | matches `.2:8085` | `_____` |
| Prod boot log | `Inbound API key store: imported … KeysInserted=5` for the five existing keys (`mes`, `mes api key`, `delmia-mes-receivers`, `TestKey`, `JOE_TEST_KEY`) | `_____` |
| Prod `security api-key list` before vs. after | unchanged | `_____` |
| Boot warning **absent** on QA post-change | (was present pre-change: `Startup configuration warning: Inbound API keys use the per-node SQLite store on a 2-node central cluster; …`) — confirm it no longer logs | `_____` |
| Failover check, no file copy: mint a key via the active node, `curl -X POST /api/DelmiaRecipeDownload` with it aimed at the **non-active** node's port | 200, not 401 (standby relays; both nodes now verify against the same SQL rows even without a relay) | `_____` |
| Optional real failover: stop the active node, repeat the same call against the new active | 200 | `_____` |
| QA takeover time during the optional real failover | rig reference: `docker kill` takeover 28 s | `_____ s` |
Post-swap ERR/FTL log scan (all boxes, post-boot window): `_____`
## Rollback
Per host: stop the pair (QA: both boxes together) → revert `Provider` (delete the `ApiKeyStore`
block from `appsettings.Central.json`, or set it back to `Sqlite`) → rename
`inbound-api-keys.sqlite.imported` (and its `-wal.imported`/`-shm.imported` siblings) back to
`inbound-api-keys.sqlite` (`-wal`/`-shm`) on both nodes → start the pair. No SQL rows need
removing — the SQLite provider never reads the `zbauth` schema.
**Caveat:** any key minted, revoked, or rotated *while* on `SqlServer` is not in the SQLite
file and is lost on rollback. Before rolling back, list `zbauth.api_keys` (via
`scadabridge security api-key list` against either node, or a direct query using the
`SqlServer:Schema` above) and re-mint anything created after the cutover once the pair is back
on SQLite.
## Quarantine
Leave the `*.imported` files in place for **30 days** after the change is confirmed stable on
both QA and prod, then delete them (all three: `.imported`, `-wal.imported`, `-shm.imported`)
from every node's data volume. Do not delete them before that window — they are the only
rollback path for keys as of the cutover moment.
## Results
| Step | Date/time (local) | Node | Outcome | Log line |
|---|---|---|---|---|
| Pre-check: SQL login DDL rights | `_____` | — | `_____` | `_____` |
| QA stop (Site + Central, both boxes) | `_____` | vq03 + vq04 | `_____` | — |
| QA swap + appsettings edit | `_____` | vq03 | `_____` | — |
| QA swap + appsettings edit | `_____` | vq04 | `_____` | — |
| QA start (Central, both boxes) | `_____` | vq03 + vq04 | `_____` | `_____` |
| QA start (Site, both boxes) | `_____` | vq03 + vq04 | `_____` | — |
| QA import — first node | `_____` | `_____` | `_____` | `_____` |
| QA import — second node | `_____` | `_____` | `_____` | `_____` |
| Prod stop | `_____` | vd03 | `_____` | — |
| Prod swap + appsettings edit | `_____` | vd03 | `_____` | — |
| Prod start | `_____` | vd03 | `_____` | `_____` |
| Prod import | `_____` | vd03 | `_____` | `_____` |
| Failover relay check (no file copy) | `_____` | `_____` | `_____` | — |
| Optional real failover | `_____` | `_____` | `_____` takeover `_____ s` | — |
**Sign-off:** `_____` (name, date, "verified per §Verification above, no rollback needed")
+8
View File
@@ -112,6 +112,14 @@ Both central nodes share state through:
- **SQL Server**: All configuration, deployment records, templates, and audit logs.
- **JWT signing key**: Same `JwtSigningKey` in both nodes' configuration.
- **Data Protection keys**: Shared key ring (stored in SQL Server or shared file path).
- **Inbound API keys**: shared **only** when `ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer`
(design 2026-09-02, register row 108) — both nodes then read/write the same `zbauth` schema in the
configuration database, so create/revoke/rotate/scope changes are cluster-wide immediately. The
library default, `Provider = Sqlite`, stores keys in a **per-node** file instead; running `Sqlite`
on a central pair is a documented misconfiguration — `StartupValidator` logs a boot-time Warning
because a failover then 401s every inbound client until keys are re-issued on the survivor. Both
docker rigs run `SqlServer`; see `docs/operations/inbound-api-key-reissue.md` → *Central pairs* to
migrate an existing pair.
### Load Balancer
+2
View File
@@ -16,3 +16,5 @@ this folder holds only the **outgoing/legacy** systems' specs.
Wonderware recipe-download notification (`~/Desktop/delmiaintegration`).
Drop the relevant spec documents into the matching subfolder.
ScadaBridge's **own** externally-called contracts, captured as deployed, live in [`../api-specs/`](../api-specs).
@@ -203,4 +203,5 @@ never implemented. Shapes, for reference: `MachineInfo` = `MachineKey`/`MachineI
call if pulled into ScadaBridge).
This file documents the **legacy** `delmiaintegration` contracts for reference/parity during that
migration.
migration. The **as-deployed ScadaBridge contract** (method definition, site handshake, keys, observed
calls) is captured in [`../../api-specs/Delmia-Recipe-Download-API.md`](../../api-specs/Delmia-Recipe-Download-API.md).
+30 -1
View File
@@ -40,7 +40,10 @@ environment variables, or your secret store):
| Key | Value | Notes |
|---|---|---|
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. |
| `ScadaBridge:InboundApi:ApiKeyStore:SqlitePath` | Filesystem path to the SQLite key store | Defaults to `<content-root>/data/inbound-api-keys.sqlite` if unset. Choose a durable, backed-up path on a writable volume. Used when `Provider` is `Sqlite` (the default) and, when `Provider=SqlServer`, as the source file `ImportFromSqlitePath` imports once at boot. |
| `ScadaBridge:InboundApi:ApiKeyStore:Provider` | `Sqlite` (default) or `SqlServer` | `SqlServer` shares one row set across a central pair (design 2026-09-02, register row 108). Case-insensitive; an unrecognized value fails startup. |
| `ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString` | Defaults to `ScadaBridge:Database:ConfigurationDb` | Only used when `Provider=SqlServer`. Must be a literal or env override, **never** a `${secret:}` reference — the secrets store is itself SQL-backed. |
| `ScadaBridge:InboundApi:ApiKeyStore:ImportFromSqlitePath` | Defaults to `SqlitePath`'s default | Only used when `Provider=SqlServer`. The one-shot import source; renamed `*.imported` after a successful import. |
| `ScadaBridge:InboundApi:ApiKeyPepper` | A strong, random string, **≥ 16 characters** | **DIFFERENT per environment.** Keep it secret (secret store, not source control). This is the HMAC pepper that binds every stored key to this deployment; it is also the verifier's pepper secret. |
Notes:
@@ -52,6 +55,32 @@ Notes:
(`ScadaBridge:InboundApi:ApiKeyStore:RunMigrationsOnStartup = true`); these are
wired by the Host and normally need no operator change.
### Central pairs
A central pair (QA `vq03`/`vq04`, both docker rigs) should run `Provider=SqlServer` — under
the `Sqlite` default a key exists on whichever node served the management call, and a failover
401s every inbound client until keys are re-issued on the survivor (`StartupValidator` warns
about this at boot). To move an existing pair over:
1. Stop **both** central nodes together.
2. Set `ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer` on both nodes.
3. If the app's SQL login lacks CREATE SCHEMA/TABLE rights, have a DBA run
`docs/operations/sql/zbauth-schema.sql` against the configuration database first — the
migrator then finds the version row already present and skips DDL.
4. Start both nodes together.
5. Check the import log line on each node (`grep -i "api-key store import\|zbauth"` in the
node's log) — one node reports the keys it inserted, the other reports the ones it found
already present (skipped); together they cover the union of both nodes' prior SQLite files.
6. Run `security api-key list` against each node's management endpoint and confirm both show
the same keys.
7. After a quarantine period (once you're confident the import is correct and no rollback is
needed), delete the `*.imported` SQLite files (and their `-wal`/`-shm` siblings) from both
nodes' volumes.
**Rollback:** revert `Provider` to `Sqlite` (or remove it) and rename the `*.imported` files
back to their original names on both nodes; no SQL Server rows need to be removed — the SQLite
code path never reads them.
---
## 3. Database migration step
+73
View File
@@ -0,0 +1,73 @@
-- Copy of ZB.MOM.WW.Auth's docs/sql/zbauth-schema.sql as shipped in library version 0.3.0.
-- Original lives at ~/Desktop/scadaproj/ZB.MOM.WW.Auth/docs/sql/zbauth-schema.sql — if the two
-- drift, that repo is authoritative; re-copy it here rather than editing this file independently.
-- ZB.MOM.WW.Auth.ApiKeys.SqlServer — shared API-key schema, version 1.
--
-- For DBAs whose application login lacks DDL rights. The host normally provisions this itself at
-- startup (SqlServerApiKeyStoreMigrator, which runs the identical statements and is safe to run
-- concurrently from every node); where the app login may only read and write rows, run this script
-- once against the target database instead. The migrator then finds the version row already at the
-- version it supports and does nothing further beyond re-stamping applied_utc.
--
-- The schema name is hard-coded to zbauth here — the default of SqlServerApiKeyOptions.Schema. If a
-- deployment overrides that option, substitute the name in every bracketed identifier below.
--
-- Kept byte-for-byte consistent with SqlServerAuthSchema.CreateSchemaDdl apart from that
-- substitution and the literal version row at the end; change both together.
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'zbauth')
EXEC(N'CREATE SCHEMA [zbauth]');
IF OBJECT_ID(N'[zbauth].[schema_version]', N'U') IS NULL
CREATE TABLE [zbauth].[schema_version] (
id INT NOT NULL CONSTRAINT PK_zbauth_schema_version PRIMARY KEY CHECK (id = 1),
version INT NOT NULL,
applied_utc DATETIMEOFFSET(7) NOT NULL
);
IF OBJECT_ID(N'[zbauth].[api_keys]', N'U') IS NULL
CREATE TABLE [zbauth].[api_keys] (
key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_zbauth_api_keys PRIMARY KEY,
key_prefix NVARCHAR(16) NOT NULL,
secret_hash VARBINARY(64) NOT NULL,
display_name NVARCHAR(256) NOT NULL,
scopes NVARCHAR(MAX) NOT NULL,
constraints NVARCHAR(MAX) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
last_used_utc DATETIMEOFFSET(7) NULL,
revoked_utc DATETIMEOFFSET(7) NULL,
expires_utc DATETIMEOFFSET(7) NULL
);
IF OBJECT_ID(N'[zbauth].[api_key_audit]', N'U') IS NULL
CREATE TABLE [zbauth].[api_key_audit] (
audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_zbauth_api_key_audit PRIMARY KEY,
key_id NVARCHAR(64) NULL,
event_type NVARCHAR(64) NOT NULL,
remote_address NVARCHAR(64) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
details NVARCHAR(MAX) NULL
);
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'ix_api_keys_revoked_utc'
AND object_id = OBJECT_ID(N'[zbauth].[api_keys]'))
CREATE INDEX ix_api_keys_revoked_utc
ON [zbauth].[api_keys] (revoked_utc);
IF NOT EXISTS (
SELECT 1 FROM sys.indexes
WHERE name = N'ix_api_key_audit_key_id_created_utc'
AND object_id = OBJECT_ID(N'[zbauth].[api_key_audit]'))
CREATE INDEX ix_api_key_audit_key_id_created_utc
ON [zbauth].[api_key_audit] (key_id, created_utc);
MERGE [zbauth].[schema_version] AS target
USING (SELECT 1 AS id, 1 AS version, SYSDATETIMEOFFSET() AS applied_utc) AS source
ON target.id = source.id
WHEN MATCHED THEN
UPDATE SET version = source.version, applied_utc = source.applied_utc
WHEN NOT MATCHED THEN
INSERT (id, version, applied_utc) VALUES (1, source.version, source.applied_utc);
@@ -22,7 +22,7 @@ stdout contract, same exit-code semantics — so Delmia's invocation and output
| Auth | `X-API-Key: <key>` header |
| CLI/output | Exact parity with legacy `WWNotifier` (flags + `YES`/`NO` + exit code) |
| Config | `appsettings.json` next to the exe (URLs, timeout, optional log path) |
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file |
| Secret | API key from env var `SCADABRIDGE_API_KEY` — never in a file **(relaxed 2026-09-02: optional `ScadaBridge:ApiKey` file fallback; env still wins)** |
| Packaging | Self-contained **Native AOT**, `win-x64` (fast startup for per-invocation use) |
| Failover | Comma-list of base URLs; advance **only on connect failure** |
| Location | New project `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier`, in `ZB.MOM.WW.ScadaBridge.slnx` |
@@ -78,8 +78,9 @@ Zero third-party NuGet dependencies (BCL only) to keep the Native-AOT surface mi
`/api/DelmiaRecipeDownload` is appended by the app; each entry is a base URL only.
- `TimeoutSeconds` — per-attempt `HttpClient.Timeout` (default 30).
- `LogPath` — optional diagnostic log file (relative to the exe). Omit to log to stderr only.
- **API key** comes from `SCADABRIDGE_API_KEY`. If unset/empty → `NO` + "API key not configured",
exit `-1`, no attempt. The key is never read from or written to a file.
- **API key** comes from `SCADABRIDGE_API_KEY` first; since 2026-09-02 an optional `ScadaBridge:ApiKey`
in `appsettings.json` is the fallback when the variable is unset/blank (env wins). If neither is set →
`NO` + "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", exit `-1`, no attempt.
## Request / response
@@ -170,7 +171,7 @@ cross-platform):
- Result mapping: `Result true/false` and non-2xx → correct stdout + exit code.
- Failover loop (via the HTTP seam / a fake handler): connect-failure advances; first responding node
is final; `Result==false` does **not** advance; all-unreachable → `NO` + last error.
- Missing/empty `SCADABRIDGE_API_KEY` → fail-fast, no attempt.
- Missing/empty `SCADABRIDGE_API_KEY` **and** blank `ScadaBridge:ApiKey` → fail-fast, no attempt; env-over-file precedence pinned (2026-09-02).
- Manual live smoke against `wonder-app-vd03` (`/api/DelmiaRecipeDownload`) from the Windows build,
mirroring the earlier `curl` verification.
@@ -99,6 +99,9 @@ All 7 fix-now items landed via PLAN-04/05/06/07/08 (verified in review 08 round
| 104 | ~~**Existing site LocalDb files stay `auto_vacuum=NONE`; `PRAGMA incremental_vacuum` after a cap trim is a no-op on them**~~ **RESOLVED 2026-08-20 — recipe shipped and applied to the rig.** New `docker/vacuum-site-localdb.sh` (stop both nodes of a pair together → `PRAGMA auto_vacuum=INCREMENTAL; VACUUM` each volume with integrity checks before/after → start both; `DRY_RUN=1` reports) plus the topology-guide section *Site LocalDb files created before 2026-08-20 need a one-off offline VACUUM*. Run on all three rig pairs: all six files `auto_vacuum 0 → 2`, `integrity_check=ok`, ~11 % smaller (site-a 20.3 → 18.1 MB), site-a replication back `Connected=true`/backlog 0 with no watermark or schema change — the VACUUM was replication-invisible as predicted (ledger keys by declared PK; oplog `seq` is an `INTEGER PRIMARY KEY AUTOINCREMENT`). The same maintenance surfaced the unrelated row 106. Original: | Arch-review 2026-08-19 P4-6 (Low-wave T4, `47f03fe6`) | `auto_vacuum` is only honoured on a table-less database, and `LocalDbSchema.EnsureCreated` had always created LocalDb's bookkeeping tables before `SiteEventLogSchema.Apply` ran — so the pragma had never once taken effect on any real file. The fix (`SiteLocalDbSetup.EnsureAutoVacuumOnFreshFile`, run before `AddZbLocalDb`'s lazy singleton constructs `SqliteLocalDb`) only helps **fresh** files; an unconditional `VACUUM` against a live ~1 GB replicated file was explicitly rejected (long exclusive lock on the active node; the rewrite is CDC-invisible but blocks the sync pump). Every node on the docker rig today predates the fix, so freed `site_events` pages there are reused, not returned to the OS — the storage cap (row 81) still bounds the data volume, only the file size stops shrinking. | Operator-scheduled `VACUUM` in a maintenance window (both nodes of a pair stopped together), or when a site's file is next recreated. |
| 105 | ~~**Design constraint (not a defect): a LocalDb-replicated table must never be the parent of a row-mutating `ON DELETE` foreign key (`CASCADE`/`SET NULL`/`SET DEFAULT`) from another table**~~ **PINNED 2026-08-20.** Enforcement moved from the first replicated boot to the build: `SiteLocalDbReplicatedTableFkGuardTests` (Host.Tests) builds the real site schema via `OnReady` on an unreplicated config (so 0.3.3's own guard can't mask which FK is at fault), walks `SiteLocalDbSetup.ReplicatedTables` (now `internal`), asserts each exists and has no inbound row-mutating `ON DELETE` FK with the same `sqlite_master JOIN pragma_foreign_key_list` query LocalDb uses, and carries a negative control proving the checker finds a cascading child. The constraint itself stands — the row is kept as its record. Original: | Arch-review 2026-08-19 P4-7 → LocalDb 0.3.3 (scadaproj `122940c` + review follow-up `9663279`, Low-wave T5) | `LwwApplier` upserts with `INSERT OR REPLACE` under `foreign_keys=ON`, so the implicit delete fires the child action while CDC capture is suppressed — the child rows change on the applying node only and the pair diverges silently. 0.3.3 makes `RegisterReplicated` refuse such a parent at registration (clear exception naming the child FK), which is a BOOT failure for the site node, so this is enforced, not advisory. None of the ten `SiteLocalDbSetup.ReplicatedTables` has any FK today (zero `REFERENCES` on the live rig file). Model cross-table cleanup as explicit application deletes on each table (as the 3-table deploy cascade already does), never as SQLite FK actions. | Whenever a schema change adds a foreign key between site tables, or an eleventh table joins `ReplicatedTables`. |
| 106 | ~~**LocalDb: a converged pair re-streams a FULL snapshot on EVERY reconnect once the sender's oplog has been ack-pruned to empty and the receiver's watermark is 0**~~ **RESOLVED 2026-08-20 — LocalDb 0.3.4 (scadaproj `75c1897`), published and pinned.** `SnapshotStreamer.SendAsync` now stamps `as_of_seq = max(MAX(seq), last_acked_seq)`; pinned by `WipedPeer_SnapshotLiftsItsWatermark_SoTheNextReconnectSendsNoSecondSnapshot` (fails on 0.3.3 exactly as the rig did: watermark 0, expected 3). Live proof on the rig: the first restart on 0.3.4 sent one last full snapshot stamped `as_of_seq 149` (was 0) and node-a's `last_applied_remote_seq` went 0 → 149; a second restart of the pair exchanged **zero** snapshots, `Connected=true`, backlog 0. Original: | Found 2026-08-20 while verifying row 104 on the rig (site-a-a received the same 127 k-row full snapshot at both the 13:09 deploy restart and the 13:25 VACUUM restart) | Sender-side defect in `SnapshotStreamer.SendAsync`: `as_of_seq = MAX(seq)` of the oplog, which is **0** once the pair has converged and ack-pruning emptied it. The receiver's `SetLastAppliedRemoteSeqAsync` is MAX-guarded (no regression — good), but a peer whose watermark is genuinely 0 (site-a-a was rebuilt after the 2026-08-15 corruption; any wiped/re-imaged node is the same) can never be lifted by an `as_of_seq 0` snapshot, so the 0.1.2 empty-oplog horizon rule (`streamableFrom = last_acked_seq + 1`) sees `0 < 149` as a gap on every handshake and re-streams everything. Rig state: node-b `last_acked_seq=149`, oplog empty; node-a `last_applied_remote_seq=0`. Not a divergence (LWW absorbs the re-delivered rows) but a cost/availability bug — ~1 s and 127 k rows here, minutes and a full-file read on a 1 GB production pair, on every reconnect, until the sender happens to write a new row. Fix is sender-side and wire-compatible: stamp `as_of_seq = max(MAX(seq), last_acked_seq)` (a full snapshot taken now covers every delta ever pruned below `last_acked_seq`), so the receiver's MAX-guarded watermark lifts to 149 and the loop ends; add a pinning test (converged+pruned sender, zero-watermark receiver → exactly one snapshot across two handshakes). Ships as LocalDb 0.3.4. | Next LocalDb release (0.3.4) — small, well-understood, and worth doing before any production pair is rebuilt from its peer. |
| 107 | **Prod `CvdReactor.ProcessRecipeDownload` still addresses `Children["DelmiaRecv"]` after the 2026-06-27 `DelmiaRecv` → `DelmiaReceiver` composition rename** — inferred from the deployed script (`docs/api-specs/Delmia-Recipe-Download-API.md` §7 item 1): `ChildrenAccessor` concatenates the name into a path, an unknown path yields null, so every call answers `Receiver ready flag not set to true` regardless of the real flag. No call has hit prod since the rename, so this is inferred, not observed. The docker rig's stand-in (`docker/seed-delmia-sim.sh`) uses `Children["DelmiaReceiver"]` and the same handshake answers correctly, which is the working reference. | Found 2026-09-02 while capturing the as-deployed spec; QA copy unread (credentials) | Separate change with its own deployment record: `scadabridge template script update` on the `CvdReactor` template + redeploy Z28061/Z28062/Z28061Sim, then a live `WWNotifier` call. | Before the first production Delmia recipe download. |
| 108 | ~~**Inbound API keys are a PER-NODE SQLite store — a central failover invalidates every inbound client's key**~~ **RESOLVED 2026-09-02 — `ZB.MOM.WW.Auth` 0.3.0 (new `ZB.MOM.WW.Auth.ApiKeys.SqlServer` package) + ScadaBridge `ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer`.** A central pair now shares one row set: both nodes' key stores, admin stores, and audit stores point at the same `zbauth` schema in the configuration database (connection string defaults to `ScadaBridge:Database:ConfigurationDb` — never a `${secret:}` reference, to avoid a circular dependency on the SQL-backed secrets store), so create/revoke/rotate/scope changes are cluster-wide immediately instead of per-node. Each node's existing SQLite file is imported once at boot (insert-where-absent, so both nodes importing is safe and the result is the union) and renamed `*.imported`. `Provider` defaults to `Sqlite` (unchanged behavior for single-node installs, MxAccessGateway, HistorianGateway); a clustered Central left on `Sqlite` now gets a boot-time `StartupValidator` Warning naming this design instead of silently keeping the gap. Both docker rigs (`docker/`, `docker-env2/`) run `Provider=SqlServer` on both centrals; `docker/sync-inbound-api-keys.sh` is deleted, superseded by the shared store. Rig proof 2026-09-03 (`docker/`, both centrals redeployed on `Provider=SqlServer`): both nodes imported their SQLite file at the same second with no PK race — central-a `1 key / 264 audit rows inserted`, central-b `0 / 26 inserted, 1 / 264 already present` (the union), files renamed `*.imported`; a key minted via `:9001` answered `200` via `:9002`, `--enabled false` via `:9001` made both nodes answer `401` and `:9002`'s own list showed `enabled=False`, re-enable via `:9002` restored `200` on `:9001`, delete via `:9002` made `:9001` answer `401`; `verify-delmia-notifier.sh` with a freshly minted key and NO file copy: A/B/E/F `YES`, C/G `NO`, active-central `docker kill` takeover **28 s**, rejoin as standby in 2 s. `docker-env2` (second pair, `ScadaBridgeConfig2`): both centrals booted, `zbauth` v1 created, empty files imported. QA (`vq03`/`vq04`) and prod (`vd03`) still run `Provider=Sqlite` — their move is a planned deployment record (`docs/deployment/deployment-records/2026-09-XX-zb-servers-inbound-key-store.md`), not yet applied. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`. Original: | `docs/api-specs/Delmia-Recipe-Download-API.md` §8; `docker/sync-inbound-api-keys.sh` header | Fix is an architecture decision, not a patch: move the store to the shared SQL Server (as Secrets 0.5.0 did for its central store) or replicate the file; either way revocation must become cluster-wide. Needs the `ZB.MOM.WW.Auth.ApiKeys` library (scadaproj), not just this repo. | Before any 2-node central goes live with inbound API clients; before the next arch-review wave. |
| 109 | **`ZB.MOM.WW.Secrets.Replicator.SqlServer`'s store migrator retries its DDL with a flat `100 * attempt` backoff and no jitter — a latent concurrent-boot flake, open in Secrets** *(Low)*. Porting that migrator's retry loop verbatim for the Auth 0.3.0 SQL Server API-key store (register row 108) reproduced an intermittent exhaustion — about once in sixty 8-way concurrent-boot trials the five flat-backoff retries ran out before a DDL/lock conflict cleared; adding jitter to the Auth port's backoff took it to 0 failures in 180 trials. Secrets' own migrator has no concurrent-migrate test, so the same defect is presumed still open there — it just hasn't been exercised at 8-way concurrency, and a ScadaBridge central pair is only ever 2-way, so this is unlikely to bite in practice. | Found 2026-09-02 while implementing `docs/plans/2026-09-02-inbound-api-key-shared-store.md` (Auth 0.3.0 `SqlServerApiKeyStoreMigrator`) | Add jitter to `SqlServerSecretsStoreMigrator`'s backoff in `~/Desktop/scadaproj/ZB.MOM.WW.Secrets` (mirror the Auth fix) and add a `Migrate_Concurrently_FromEightTasks_AllSucceed`-shaped live test to catch a regression. Lives entirely in scadaproj, not this repo. | Next time Secrets' SQL Server migrator is touched, or if a wider-than-2-way concurrent boot of a Secrets-backed cluster is ever planned. |
| 68 | ~~**`deploy.sh` run from a git worktree silently rehomes every node's bind-mount data dir**~~ **RESOLVED 2026-08-15 (guard added in the same close-out).** The compose files' bind mounts are relative (`./site-a-node-a/data`), so a deploy from a worktree checkout mounts the worktree's copies — the rig keeps running and looks healthy, but its SQLite stores split from the main checkout's and everything written there is orphaned by the next deploy from main. Bit the rows-46–53 close-out live: the seeding agent's `rig-probe-1` deploy persisted into worktree-homed dirs and "vanished" after the orchestrator's redeploy (no tombstone, no product bug — LWW/tombstone forensics ruled out deletion; the row sat in `.claude/worktrees/…/docker/site-a-node-a/data/site-localdb.db` the whole time). Both `docker/deploy.sh` and `docker-env2/deploy.sh` now refuse to run from a `.claude/worktrees/` path (override: `SCADABRIDGE_ALLOW_WORKTREE_DEPLOY=1`). | Found during the rows-46–53 close-out | Closed 2026-08-15. | — |
## Resolved (verified against the code 2026-07-10)
@@ -0,0 +1,582 @@
# Delmia Simulated Machine + Notifier Rig Verification — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Stand up an in-memory (static-attribute) stand-in for a vd03-style machine with a composed `DelmiaReceiver` on the local docker rig, wire the `DelmiaRecipeDownload` inbound method and a scoped API key to it, let `DelmiaNotifier` read that key from `appsettings.json`, and prove the notifier end-to-end on the 2-node central cluster including an active-node crash and rejoin.
**Architecture:** Two templates seeded by CLI only (no DB edits): `DelmiaReceiverSim` (the ten receiver attributes, all static — no data connection) composed into `SimMachine` as `DelmiaReceiver`. The Galaxy side of the handshake is replaced by a root-scope `ValueChange` script on `SimMachine` that reacts to `DelmiaReceiver.RecipeDownloadFlag`, so the same call script shape as prod runs against pure in-memory tags. The central inbound method is the prod script verbatim (minus its dead `suf` line). The notifier gains an optional `ScadaBridge:ApiKey` config field with the existing `SCADABRIDGE_API_KEY` env var taking precedence, and a bash verification script drives the whole thing against `docker/` (central-a `:9001`, central-b `:9002`, Traefik `:9000`).
**Tech Stack:** ScadaBridge CLI (`scadabridge`), bash, .NET 10 (`DelmiaNotifier` + xUnit tests), docker compose rig under `docker/`.
**Facts the implementer must not re-derive** (verified 2026-09-02):
- `WriteBatchAndWaitAsync` **rejects static attributes** (`InstanceActor.HandleWriteAttributeBatch`: "Attribute 'X' is not a data-sourced attribute"). The rig script therefore uses `SetAsync` per attribute + `WaitAsync`, which both work on static attributes. Do not "fix" this in the runtime — out of scope.
- A static write always publishes `AttributeValueChanged` (no equality short-circuit), and `ScriptActor` matches a `ValueChange` trigger by **exact** canonical name (`valueTrigger.AttributeName == changed.AttributeName`). Composed scripts' trigger configs are **not** prefixed at flattening (alarms are), so the simulator script lives on the **root** template with `attributeName` = `DelmiaReceiver.RecipeDownloadFlag`.
- `WaitAsync` has a fast path when the current value already matches, so the simulator finishing before the wait registers is not a race.
- Trigger types are matched case-insensitively as strings: `Call`, `ValueChange`, `Interval`, `Conditional`, `Expression`. Trigger config key is `attributeName`.
- CLI global options (`--url`, `--username`, `--password`, `--format=json`) must come **after** the subcommand. Rig creds: `--username multi-role --password password`. Write loops in **bash** (zsh does not word-split unquoted variables).
- `security api-key create --format=json` returns `{ "keyId": …, "token": "sbk_…" }`; the token is shown once.
- Rig: site-a is `--site-id 1`; Traefik `http://localhost:9000` → active central; direct nodes `:9001` (central-a) and `:9002` (central-b); both centrals have `StandbyForwardPeerEndpoints` set, so a standby **relays** inbound calls to the active peer. Auto-down failover of a crashed active takes ~25–30 s (`docker/failover-drill.sh`).
- The notifier's failover is **connect-failure-only**: any HTTP answer (incl. 503) is final. During the takeover window a standby that cannot reach its peer answers 503 `ACTIVE_NODE_UNREACHABLE` → the notifier prints `NO`. That is expected and must be documented, not "fixed".
- Prod's deployed method + site script are recorded in `docs/api-specs/Delmia-Recipe-Download-API.md`; prod's site script still references `Children["DelmiaRecv"]` (a known, separate defect). The rig script uses `Children["DelmiaReceiver"]`.
**Model assignment summary** (see each task's `Model:` line): Sonnet for scripted CLI seeding, config plumbing, and docs; Opus for the live rig verification and the failover drill (judgment under a live system) and for reviewing the secret-handling change; Fable (this session) for the final integration review.
---
### Task 0: Preflight — rig healthy, CLI built, notifier tests green
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (everything depends on it)
**Model:** sonnet
**Files:**
- (none edited)
**Step 1: Confirm the rig is up and which node is active**
Run:
```bash
for p in 9000 9001 9002; do printf "$p ready="; curl -s -m 5 -o /dev/null -w "%{http_code}" http://localhost:$p/health/ready; printf " active="; curl -s -m 5 -o /dev/null -w "%{http_code}\n" http://localhost:$p/health/active; done
```
Expected: all three `ready=200`; exactly one of `9001`/`9002` shows `active=200`, the other `503`; `9000 active=200`. If a node is down, run `bash docker/deploy.sh` first and re-check.
**Step 2: Build the CLI and the notifier, run the notifier tests**
Run:
```bash
cd /Users/dohertj2/Desktop/ScadaBridge
dotnet build src/ZB.MOM.WW.ScadaBridge.CLI -c Debug 2>&1 | tail -3
dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5
```
Expected: `Build succeeded` and `Passed!` with 0 failed. Note the CLI binary path used by every later task: `src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge`.
**Step 3: Confirm the names this plan creates are free on the rig**
Run:
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge; A="--url http://localhost:9000 --format=json --username multi-role --password password"
bash -c "$CLI template list $A | jq -r '.[].name' | grep -E '^(DelmiaReceiverSim|SimMachine)$'; $CLI api-method list $A | jq -r '.[].name' | grep -E '^DelmiaRecipeDownload$'; $CLI instance list $A | jq -r '.[].uniqueName' | grep -E '^DelmiaSim01$'; echo done"
```
Expected: only `done` printed. If any name is taken, delete it (`instance delete --id`, `template delete --id`, `api-method delete --id`) before continuing — the seed script is not idempotent by design.
---
### Task 1: `DelmiaNotifier` — optional `ApiKey` in `appsettings.json`, env var wins
**Classification:** small (single-project logic change, but it relaxes a documented secret-handling decision — reviewer must weigh in)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 2, Task 3
**Model:** sonnet (implement) → **opus** code review, with the explicit question "is the env-over-file precedence and the README wording adequate for a key that now may live on disk?"
**Design note (state this in the commit):** the 2026-06-26 design deliberately kept the key env-only. The user has asked for the key in `appsettings.json`. Keep env-only as the *recommended* production posture, add the file as a fallback, and keep the precedence deterministic: `SCADABRIDGE_API_KEY` (if non-blank) → `ScadaBridge:ApiKey` (if non-blank) → not configured. The failure message names both sources.
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/NotifierConfig.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/ConfigLoader.cs`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/Program.cs:42-47`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/appsettings.json`
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` (the `## Secret — SCADABRIDGE_API_KEY` section and the config JSON block)
- Modify: `env_variables.md` (§5 `SCADABRIDGE_API_KEY` row — "env-only by design" is no longer true)
- Modify: `docs/plans/2026-06-26-delmia-recipe-notifier-design.md` (the `Secret` row of the key-decisions table: add "superseded 2026-09-02: file fallback added, env still wins")
- Test: `tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests/ConfigLoaderTests.cs`
**Step 1: Write the failing tests** (append to `ConfigLoaderTests.cs`)
```csharp
[Fact]
public void Load_reads_ApiKey_from_ScadaBridge_section()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\",\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_file", cfg.ScadaBridge.ApiKey);
}
[Fact]
public void ResolveApiKey_prefers_env_over_config()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_env" : null, cfg));
}
[Fact]
public void ResolveApiKey_falls_back_to_config_when_env_unset_or_blank()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" sbk_file \"}}");
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => null, cfg));
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg));
}
[Fact]
public void ResolveApiKey_returns_null_when_neither_source_set()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" \"}}");
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, cfg));
}
```
Also update the two existing `ResolveApiKey_*` tests to pass a config: `ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig())` etc. (the one-argument overload is removed, not kept — YAGNI).
**Step 2: Run tests to verify they fail**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5`
Expected: build error `'ScadaBridgeSection' does not contain a definition for 'ApiKey'` / no overload for `ResolveApiKey` taking 2 arguments.
**Step 3: Implement**
`NotifierConfig.cs` — add to `ScadaBridgeSection`:
```csharp
/// <summary>Optional inbound API key. <c>SCADABRIDGE_API_KEY</c> in the environment takes precedence; this is the file fallback (design 2026-06-26, relaxed 2026-09-02).</summary>
public string? ApiKey { get; set; }
```
`ConfigLoader.cs` — replace `ResolveApiKey`:
```csharp
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/> (trimmed); null when neither is set. Env accessor is injected for testability.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg)
{
var env = envGet(ApiKeyEnvVar);
if (!string.IsNullOrWhiteSpace(env)) return env;
var file = cfg.ScadaBridge.ApiKey;
return string.IsNullOrWhiteSpace(file) ? null : file.Trim();
}
```
`Program.cs` lines 42–47 become:
```csharp
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg);
if (apiKey is null)
{
log.Write("API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)");
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)", stdout);
}
```
`appsettings.json` — add `"ApiKey": ""` after `LogPath` (empty = not set; **never commit a real token**).
README: in the config JSON block add `"ApiKey": ""`; rewrite the `## Secret` section:
> The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first (recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in `appsettings.json` when the variable is unset or blank. If neither is set → `NO` + `API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt. If you use the file, restrict its ACL to the account DELMIA runs under.
`env_variables.md` §5: change the "In appsettings?" cell to `Yes — optional \`ScadaBridge:ApiKey\` fallback (2026-09-02); env wins` and the Purpose cell's "Read only from the environment, never a file" to "Environment first, file fallback".
Design doc table row `| Secret | API key from env var … — never in a file |` → append ` **(relaxed 2026-09-02: optional \`ScadaBridge:ApiKey\` file fallback; env still wins)**`.
**Step 4: Run tests to verify they pass**
Run: `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests 2>&1 | tail -5`
Expected: `Passed!`, 0 failed, total = previous count + 4. Also `dotnet build src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -warnaserror` succeeds (the project treats warnings as errors; the XML doc comments above are required).
**Step 5: Commit**
```bash
git add src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests env_variables.md docs/plans/2026-06-26-delmia-recipe-notifier-design.md
git commit -m "feat(delmia-notifier): optional ScadaBridge:ApiKey in appsettings.json as a fallback to SCADABRIDGE_API_KEY (env still wins); README/env inventory/design row updated"
```
---
### Task 2: `docker/seed-delmia-sim.sh` — templates, composition, scripts, instance, deploy
**Classification:** standard (new script + two template scripts that must compile under the trust policy)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 1, Task 3
**Model:** sonnet
**Files:**
- Create: `docker/seed-delmia-sim.sh`
- Modify: `docker/README.md:96-97` (add the new script to the tree listing, one line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# Seed an IN-MEMORY stand-in for a vd03-style machine with a composed DelmiaReceiver on
# site-a, so DelmiaNotifier can be exercised end-to-end on the docker rig without a Galaxy.
#
# What it creates (all static attributes — no data connection, no bindings):
# - Template DelmiaReceiverSim — the ten DelmiaReceiver attributes from prod
# (docs/api-specs/Delmia-Recipe-Download-API.md §5), ReadyFlag defaulting to true.
# - Template SimMachine — composes DelmiaReceiverSim as `DelmiaReceiver` and carries
# * ProcessRecipeDownload (Call) — prod's handshake ported to per-attribute
# SetAsync/WaitAsync (WriteBatchAndWaitAsync rejects static attributes).
# * DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag) —
# plays the Galaxy object: accepts unless DownloadPath ends in ".bad" or the work
# order is blank, writes RecipeProcessResult/Text, clears the trigger flag, then
# sets RecipeProcessedFlag=true.
# - Instance DelmiaSim01 on site-a (id 1), deployed.
#
# Not idempotent: names must be unique. To re-seed:
# scadabridge instance delete --id <id>; scadabridge template delete --id <SimMachine id>;
# scadabridge template delete --id <DelmiaReceiverSim id>
# Prerequisites: bash docker/deploy.sh; docker/seed-sites.sh; central healthy on :9000.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
SITE_ID="${SITE_ID:-1}"
INSTANCE_NAME="${INSTANCE_NAME:-DelmiaSim01}"
json_id() { python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])'; }
echo "1/6 Creating template DelmiaReceiverSim..."
RECV_ID=$("$CLI" template create "${A[@]}" --name DelmiaReceiverSim \
--description "In-memory stand-in for the Galaxy DelmiaReceiver object (ten static attributes)" | json_id)
echo " id=$RECV_ID"
echo "2/6 Adding the ten receiver attributes (static)..."
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name ReadyFlag --data-type Boolean --value true --description "Precondition; true = receiver accepts downloads" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeDownloadFlag --data-type Boolean --value false --description "Trigger: set true after the payload is written" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessedFlag --data-type Boolean --value false --description "Cleared by the caller, set true by the receiver when done" > /dev/null
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name RecipeProcessResult --data-type Boolean --value false --description "Outcome -> Result" > /dev/null
for s in DownloadPath WorkOrderNumber PartNumber JobStepNumber Username RecipeProcessResultText; do
"$CLI" template attribute add "${A[@]}" --template-id "$RECV_ID" --name "$s" --data-type String --description "Recipe-download payload / outcome text" > /dev/null
done
echo "3/6 Creating template SimMachine + composing DelmiaReceiver..."
MACHINE_ID=$("$CLI" template create "${A[@]}" --name SimMachine \
--description "vd03-style machine stand-in: composes DelmiaReceiverSim as DelmiaReceiver; in-memory only" | json_id)
"$CLI" template composition add "${A[@]}" --template-id "$MACHINE_ID" --composed-template-id "$RECV_ID" --instance-name DelmiaReceiver > /dev/null
echo " id=$MACHINE_ID"
echo "4/6 Adding ProcessRecipeDownload (Call) — prod handshake on static attributes..."
read -r -d '' PROCESS_CODE <<'CS' || true
// Port of prod CvdReactor.ProcessRecipeDownload for STATIC attributes: WriteBatchAndWaitAsync only
// accepts data-sourced attributes, so the payload is written with SetAsync and the handshake waited
// with WaitAsync (same trigger/response flags, same 25 s budget, same result mapping).
var recv = Children["DelmiaReceiver"];
System.Func<object?,bool> IsTrue = v => { var s = v?.ToString(); return (v is bool b && b) || s == "1" || string.Equals(s, "true", System.StringComparison.OrdinalIgnoreCase); };
System.Func<string,object?> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
if (!IsTrue(await recv.Attributes.GetAsync("ReadyFlag")))
return new { Result = false, ResultText = "Receiver ready flag not set to true" };
try {
await recv.Attributes.SetAsync("RecipeProcessedFlag", false);
await recv.Attributes.SetAsync("DownloadPath", P("DownloadPath"));
await recv.Attributes.SetAsync("WorkOrderNumber", P("WorkOrderNumber"));
await recv.Attributes.SetAsync("PartNumber", P("PartNumber"));
await recv.Attributes.SetAsync("JobStepNumber", P("JobStepNumber"));
await recv.Attributes.SetAsync("Username", P("Username"));
await recv.Attributes.SetAsync("RecipeDownloadFlag", true);
if (!await recv.Attributes.WaitAsync("RecipeProcessedFlag", true, System.TimeSpan.FromSeconds(25)))
return new { Result = false, ResultText = "Timeout waiting for recipe to be processed" };
return new {
Result = IsTrue(await recv.Attributes.GetAsync("RecipeProcessResult")),
ResultText = (await recv.Attributes.GetAsync("RecipeProcessResultText"))?.ToString() ?? ""
};
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name ProcessRecipeDownload --trigger-type Call \
--parameters '{"type":"object","properties":{"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--code "$PROCESS_CODE" > /dev/null
echo "5/6 Adding DelmiaReceiverSimulator (ValueChange on DelmiaReceiver.RecipeDownloadFlag)..."
read -r -d '' SIM_CODE <<'CS' || true
// Plays the Galaxy DelmiaReceiver object. Fires on every write to RecipeDownloadFlag (root-scope
// trigger, canonical name) and acts only on the rising edge; its own reset to false re-triggers
// this script, which then returns at the guard.
var recv = Children["DelmiaReceiver"];
var flag = await recv.Attributes.GetAsync("RecipeDownloadFlag");
var fs = flag?.ToString();
if (!((flag is bool b && b) || fs == "1" || string.Equals(fs, "true", System.StringComparison.OrdinalIgnoreCase))) return null;
var path = (await recv.Attributes.GetAsync("DownloadPath"))?.ToString() ?? "";
var wo = (await recv.Attributes.GetAsync("WorkOrderNumber"))?.ToString() ?? "";
var ok = path.Length > 0 && wo.Length > 0 && !path.EndsWith(".bad", System.StringComparison.OrdinalIgnoreCase);
await recv.Attributes.SetAsync("RecipeProcessResult", ok);
await recv.Attributes.SetAsync("RecipeProcessResultText", ok ? "Simulated download accepted for " + wo : "Simulated reject: bad path or missing work order");
await recv.Attributes.SetAsync("RecipeDownloadFlag", false);
await recv.Attributes.SetAsync("RecipeProcessedFlag", true);
return null;
CS
"$CLI" template script add "${A[@]}" --template-id "$MACHINE_ID" --name DelmiaReceiverSimulator --trigger-type ValueChange \
--trigger-config '{"attributeName":"DelmiaReceiver.RecipeDownloadFlag"}' --code "$SIM_CODE" > /dev/null
echo "6/6 Creating + deploying instance $INSTANCE_NAME on site $SITE_ID..."
INSTANCE_ID=$("$CLI" instance create "${A[@]}" --name "$INSTANCE_NAME" --template-id "$MACHINE_ID" --site-id "$SITE_ID" | json_id)
"$CLI" instance deploy "${A[@]}" --id "$INSTANCE_ID"
echo ""
echo "DELMIA_SIM_TEMPLATE_ID=$RECV_ID SIM_MACHINE_TEMPLATE_ID=$MACHINE_ID INSTANCE_ID=$INSTANCE_ID INSTANCE_NAME=$INSTANCE_NAME"
echo "Verify: $CLI debug snapshot --id $INSTANCE_ID --url http://localhost:9000 --username multi-role --password password"
```
**Step 2: Make it executable and run it against the rig**
Run: `chmod +x docker/seed-delmia-sim.sh && bash docker/seed-delmia-sim.sh 2>&1 | tail -15`
Expected: six numbered lines, a deploy reply without `"error"`, and the final `DELMIA_SIM_TEMPLATE_ID=… INSTANCE_ID=…` line. Record `INSTANCE_ID` for Task 4.
If `template script add` fails with a trust-policy or compile error, the message names the offending symbol — fix the script text, do **not** widen the trust policy. If `instance deploy` reports a pre-deployment validation failure mentioning bindings, a data-source was set by mistake: static attributes must be created **without** `--data-source`.
**Step 3: Prove the handshake works site-side before any HTTP is involved**
Run (bash):
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge
$CLI debug snapshot --id <INSTANCE_ID> --url http://localhost:9000 --format=json --username multi-role --password password | jq '.attributes // . | with_entries(select(.key|startswith("DelmiaReceiver.")))'
```
Expected: ten `DelmiaReceiver.*` entries, `ReadyFlag` true, `RecipeProcessedFlag` false. (The snapshot JSON shape is the CLI's — adapt the `jq` path to what comes back; the point is that all ten attributes exist under the `DelmiaReceiver.` prefix.)
**Step 4: Add the script to the docker tree listing**
`docker/README.md` line 97: after the `seed-live-probe.sh` row add
`├── seed-delmia-sim.sh # Seed the in-memory Delmia machine stand-in (DelmiaSim01) on site-a`
**Step 5: Commit**
```bash
git add docker/seed-delmia-sim.sh docker/README.md
git commit -m "chore(docker): seed-delmia-sim.sh — in-memory SimMachine + composed DelmiaReceiver stand-in on site-a with a ValueChange simulator playing the Galaxy side"
```
---
### Task 3: `docker/seed-delmia-api.sh` — inbound method + scoped API key
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 1, Task 2
**Model:** sonnet
**Files:**
- Create: `docker/seed-delmia-api.sh`
- Modify: `docker/README.md` (one tree-listing line under the Task 2 line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# Seed the DelmiaRecipeDownload inbound method (prod's script verbatim, minus its dead `suf`
# line — see docs/api-specs/Delmia-Recipe-Download-API.md §4) and one API key scoped to it.
# Prints the one-time token as DELMIA_API_KEY=... — capture it; it cannot be retrieved again.
# Not idempotent (method name must be unique): delete with `api-method delete --id`.
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CLI="$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge"
A=(--url "${SCADABRIDGE_URL:-http://localhost:9000}" --format=json --username "${SCADABRIDGE_USERNAME:-multi-role}" --password "${SCADABRIDGE_PASSWORD:-password}")
KEY_NAME="${KEY_NAME:-delmia-notifier}"
read -r -d '' METHOD_CODE <<'CS' || true
try {
System.Func<string, object> P = k => Parameters.ContainsKey(k) ? Parameters[k] : null;
var code = (P("MachineCode") as string ?? "").Trim();
if (string.IsNullOrEmpty(code)) return new { Result = false, ResultText = "MachineCode is required" };
return await Route.To(code).Call("ProcessRecipeDownload", new {
DownloadPath = P("DownloadPath"),
WorkOrderNumber = P("WorkOrderNumber"),
PartNumber = P("PartNumber"),
JobStepNumber = P("JobStepNumber"),
Username = P("Username"),
});
} catch (Exception ex) {
return new { Result = false, ResultText = "RecipeDownload failed: " + ex.Message };
}
CS
echo "1/2 Creating inbound method DelmiaRecipeDownload (timeout 45 s)..."
METHOD_JSON=$("$CLI" api-method create "${A[@]}" --name DelmiaRecipeDownload --timeout 45 \
--parameters '{"type":"object","properties":{"MachineCode":{"type":"string"},"DownloadPath":{"type":"string"},"WorkOrderNumber":{"type":"string"},"PartNumber":{"type":"string"},"JobStepNumber":{"type":"string"},"Username":{"type":"string"}},"required":[]}' \
--return-def '{"type":"object","properties":{"Result":{"type":"boolean"},"ResultText":{"type":"string"}},"required":["Result","ResultText"]}' \
--script "$METHOD_CODE")
echo " $(echo "$METHOD_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print("id=%s name=%s" % (d["id"], d["name"]))')"
echo "2/2 Creating API key '$KEY_NAME' scoped to DelmiaRecipeDownload..."
KEY_JSON=$("$CLI" security api-key create "${A[@]}" --name "$KEY_NAME" --methods DelmiaRecipeDownload 2>/dev/null)
TOKEN=$(echo "$KEY_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["token"])')
echo "DELMIA_API_KEY=$TOKEN"
```
> Note: `security api-key create` prints the token via `PrintCreatedKey` in table mode; with `--format=json` the raw `{keyId, token}` document is emitted and parsed above. If the json branch still prints the human lines, grep the `sbk_` token instead: `TOKEN=$(echo "$KEY_JSON" | grep -o 'sbk_[A-Za-z0-9_-]*' | head -1)`.
**Step 2: Run it and keep the token OUT of git**
Run: `chmod +x docker/seed-delmia-api.sh && bash docker/seed-delmia-api.sh`
Expected: `id=<n> name=DelmiaRecipeDownload` then `DELMIA_API_KEY=sbk_…`. Save the token to the scratchpad only (e.g. `$SCRATCH/delmia.env` with `export DELMIA_API_KEY=…`). Never write it into `src/**/appsettings.json`.
**Step 3: Smoke the method with curl (no notifier yet)**
```bash
curl -s -X POST http://localhost:9000/api/DelmiaRecipeDownload -H "X-API-Key: $DELMIA_API_KEY" -H "Content-Type: application/json" \
-d '{"MachineCode":"DelmiaSim01","DownloadPath":"C:\\recipes\\wo1.nc","WorkOrderNumber":"WO-1","PartNumber":"PN-1","JobStepNumber":"0100","Username":"curl"}'
```
Expected: `{"Result":true,"ResultText":"Simulated download accepted for WO-1"}`. Then the reject path (`"DownloadPath":"C:\\recipes\\x.bad"`) → `Result:false`, `Simulated reject…`; and a bogus key → HTTP 401 `{"error":"Invalid or missing API key"}`. If the first call returns `Timeout waiting for recipe to be processed`, the simulator did not fire: check `scadabridge debug snapshot` for `DelmiaReceiver.RecipeDownloadFlag` (should have been reset to false) and the site-a-a container log for a compile error on `DelmiaReceiverSimulator`.
**Step 4: Commit**
```bash
git add docker/seed-delmia-api.sh docker/README.md
git commit -m "chore(docker): seed-delmia-api.sh — DelmiaRecipeDownload inbound method (prod script) + scoped delmia-notifier API key"
```
---
### Task 4: `docker/verify-delmia-notifier.sh` — notifier end-to-end + central failover drill
**Classification:** high-risk (drives `docker stop` on a live rig node; interprets failover timing)
**Estimated implement time:** ~5 min to write, ~6 min to run (two drills × ~1 min settle)
**Parallelizable with:** none (needs Tasks 1–3)
**Model:** **opus** — run interactively, read the outputs, and decide whether a `NO` is an expected takeover-window answer or a real fault before proceeding.
**Files:**
- Create: `docker/verify-delmia-notifier.sh`
- Modify: `docker/README.md` (one tree-listing line)
**Step 1: Write the script**
```bash
#!/bin/bash
set -euo pipefail
# End-to-end DelmiaNotifier verification against the docker rig, including an ACTIVE central
# crash + rejoin. Requires: seed-delmia-sim.sh + seed-delmia-api.sh already run, and
# DELMIA_API_KEY exported (the token printed by seed-delmia-api.sh).
#
# The notifier is run as the managed build (dotnet WWNotifier.dll) from a scratch copy so the
# API key is written to THAT copy's appsettings.json (ScadaBridge:ApiKey — Task 1) and never
# into the repo. BaseUrls lists the two central nodes DIRECTLY (not Traefik) so the notifier's
# own connect-failure failover is what gets exercised.
#
# Expected outcomes:
# A baseline (active listed first) -> YES
# B standby listed first (standby relays) -> YES (StandbyForwardPeerEndpoints)
# C bad key -> NO (HTTP 401, no failover: 401 is an answer)
# D active STOPPED, before takeover completes -> NO or YES — informational only. A NO here is the
# documented connect-failure-only design (standby answers 503 ACTIVE_NODE_UNREACHABLE).
# E active STOPPED, after survivor /health/active=200 -> YES (first URL refused -> failover)
# F victim restarted and rejoined (members=2) -> YES
# G reject path (DownloadPath *.bad) -> NO + "Simulated reject"
: "${DELMIA_API_KEY:?export DELMIA_API_KEY=sbk_... first}"
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"; PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
RUN_DIR="${RUN_DIR:-${TMPDIR:-/tmp}/delmia-notifier-run}"
MACHINE="${MACHINE:-DelmiaSim01}"
A_URL=http://localhost:9001; B_URL=http://localhost:9002
echo "== build notifier + stage a runnable copy in $RUN_DIR"
dotnet build "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier" -c Debug -nologo -v q
rm -rf "$RUN_DIR"; mkdir -p "$RUN_DIR"; cp -R "$PROJECT_ROOT/src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/bin/Debug/net10.0/." "$RUN_DIR/"
write_cfg() { # $1 = BaseUrls, $2 = ApiKey
printf '{"ScadaBridge":{"BaseUrls":"%s","TimeoutSeconds":30,"LogPath":"delmia-notifier.log","ApiKey":"%s"}}\n' "$1" "$2" > "$RUN_DIR/appsettings.json"
}
notify() { # $1 = label, $2 = expected YES|NO, rest = extra args; prints stdout + exit code
local label=$1 expect=$2; shift 2
local out rc; set +e; out=$(env -u SCADABRIDGE_API_KEY dotnet "$RUN_DIR/WWNotifier.dll" -m "$MACHINE" -d 'C:\recipes\wo1.nc' -w "WO-$label" -p PN-1 -s 0100 -u drill "$@" 2>/dev/null); rc=$?; set -e
local first; first=$(echo "$out" | head -1)
printf '%-3s stdout=%-4s exit=%-3s | %s\n' "$label" "$first" "$rc" "$(echo "$out" | sed -n 2p)"
[ "$first" = "$expect" ] || { echo " ^^ EXPECTED $expect"; return 1; }
}
active() { curl -sf -o /dev/null "$A_URL/health/active" && echo a || { curl -sf -o /dev/null "$B_URL/health/active" && echo b; }; }
url_of() { [ "$1" = a ] && echo "$A_URL" || echo "$B_URL"; }
container_of() { [ "$1" = a ] && echo scadabridge-central-a || echo scadabridge-central-b; }
other() { [ "$1" = a ] && echo b || echo a; }
wait_for() { # $1 url $2 path $3 seconds
local i; for i in $(seq 1 "$3"); do curl -sf -o /dev/null "$1$2" && return 0; sleep 1; done; return 1; }
ACT=$(active); STB=$(other "$ACT")
echo "== active=central-$ACT standby=central-$STB"
echo "== A baseline (active first)"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"; notify A YES
echo "== B standby first (relay)"; write_cfg "$(url_of "$STB"),$(url_of "$ACT")" "$DELMIA_API_KEY"; notify B YES
echo "== C bad key"; write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "sbk_bogus_bogus"; notify C NO || true
write_cfg "$(url_of "$ACT"),$(url_of "$STB")" "$DELMIA_API_KEY"
echo "== D/E stop the ACTIVE central ($(container_of "$ACT"))"; docker stop "$(container_of "$ACT")" > /dev/null
T0=$(date +%s)
echo "-- D immediate attempt (informational):"; notify D NO || notify D YES || true
echo "-- waiting for survivor $(url_of "$STB")/health/active (auto-down ~25-30 s)..."
wait_for "$(url_of "$STB")" /health/active 90 || { echo "FAIL: survivor never became active"; exit 1; }
echo " takeover after $(( $(date +%s) - T0 )) s"
echo "== E after takeover (first URL refused -> failover)"; notify E YES
echo "== F restart victim + wait for rejoin"; docker start "$(container_of "$ACT")" > /dev/null
wait_for "$(url_of "$ACT")" /health/ready 120 || { echo "FAIL: victim never ready"; exit 1; }
for i in $(seq 1 60); do curl -s "$(url_of "$STB")/health/ready" | grep -q '"members":2\|members=2\|Up.*Up' && break; sleep 2; done
notify F YES
echo "== G reject path"; notify G NO -d 'C:\recipes\x.bad' || true
echo "== done. Notifier log: $RUN_DIR/delmia-notifier.log"
```
> The `members` probe in F is deliberately loose — check what `/health/ready` actually prints on this build (`curl -s localhost:9001/health/ready`) and tighten the grep to the real field before relying on it.
**Step 2: Run it**
```bash
chmod +x docker/verify-delmia-notifier.sh
export DELMIA_API_KEY=sbk_… # from Task 3
bash docker/verify-delmia-notifier.sh 2>&1 | tee "$SCRATCH/verify-delmia.log"
```
Expected: A, B, E, F print `stdout=YES exit=0`; C and G print `stdout=NO`; D is informational (record which it was and the takeover seconds). The victim rejoins as **standby** (`/health/active` 503 on it, 200 on the survivor). Any other outcome is a real finding — capture the notifier log line (`attempt <url>: …`) and the central log (`docker logs --since 5m scadabridge-central-<x> | grep -i delmia`) before changing anything.
**Step 3: Confirm the audit trail**
```bash
CLI=src/ZB.MOM.WW.ScadaBridge.CLI/bin/Debug/net10.0/scadabridge
$CLI audit query --channel ApiInbound --since 15m --url http://localhost:9000 --format=json --username multi-role --password password | grep DelmiaRecipeDownload | jq -c '{t:.occurredAtUtc, kind, status, http:.httpStatus, node:.sourceNode, actor, resp:.responseSummary}'
```
Expected: one `InboundRequest` row per YES/NO business answer, **exactly one row for B** (the standby relay must not double-audit — `InboundApiEndpointFilter.RelayedToActiveItemKey`), an `InboundAuthFailure` for C, and `sourceNode` showing the node that executed each call (B and E should show the node that was active at the time).
**Step 4: Commit the script**
```bash
git add docker/verify-delmia-notifier.sh docker/README.md
git commit -m "test(docker): verify-delmia-notifier.sh — notifier end-to-end on the rig incl. standby relay, active-central crash/takeover and rejoin"
```
---
### Task 5: Record results + spec/doc updates
**Classification:** trivial (docs only)
**Estimated implement time:** ~4 min
**Parallelizable with:** none (needs Task 4's numbers)
**Model:** sonnet
**Files:**
- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` (new §8 "Docker rig stand-in", pointing at the two seed scripts, the static-attribute port, and the verified outcomes A–G with the measured takeover time)
- Modify: `src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md` (`## Manual smoke test` → add the rig recipe: run the three scripts, `export DELMIA_API_KEY`, expected YES/NO table)
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` (one new row: "prod `ProcessRecipeDownload` still addresses `Children[\"DelmiaRecv\"]` after the 2026-06-27 rename — fix + redeploy Z28061/Z28062/Z28061Sim; QA unverified" — only if a row for it does not already exist)
- Modify: `CLAUDE.md` — in the `### Templates & Deployment` / DelmiaNotifier context, one sentence: "Rig stand-in: `docker/seed-delmia-sim.sh` + `seed-delmia-api.sh` + `verify-delmia-notifier.sh` (static-attribute port of the prod handshake — `WriteBatchAndWaitAsync` rejects static attributes, use `SetAsync`/`WaitAsync`)."
**Step 1: Write the sections** using the numbers from Task 4's log (takeover seconds, D's outcome, audit row count).
**Step 2: Check no stale claims remain**
Run: `grep -rn "never read from or written to a file\|env-only by design" src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier docs env_variables.md CLAUDE.md`
Expected: no hits (Task 1 removed the README/env ones; anything left here is stale and must be updated).
**Step 3: Commit**
```bash
git add docs/api-specs/Delmia-Recipe-Download-API.md src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier/README.md docs/plans/2026-07-08-deferred-work-register.md CLAUDE.md
git commit -m "docs: Delmia rig stand-in + notifier verification results (A-G incl. active-central takeover); register row for prod DelmiaRecv path defect"
```
---
### Task 6: Final integration review
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** **fable** (this session) — read `git log --oneline main..HEAD` + `git diff main`, re-run `dotnet test tests/ZB.MOM.WW.ScadaBridge.DelmiaNotifier.Tests`, and check specifically: (1) no `sbk_` token anywhere in the diff (`git diff main | grep -n 'sbk_[A-Za-z0-9]'` must return only the placeholder in the bad-key test), (2) the env-over-file precedence is asserted by a test, (3) the seed scripts leave the rig in a state `docker/README.md` describes, (4) Task 4's log shows E and F as YES with the victim rejoined as standby. Then decide with the user whether to push (`/pushit`) — the plan does not push.
---
## Out of scope (deliberately)
- Fixing prod vd03's `Children["DelmiaRecv"]` defect or making the prod method idempotent — recorded in the deferred-work register (Task 5); separate change with its own deployment record.
- Extending `WriteBatchAndWaitAsync` to static attributes.
- Site-pair failover (the user asked for the 2-node **central** cluster); `docker/failover-drill.sh` already covers the general case.
- Native AOT publish of the notifier (Windows-only); the managed build is what the rig exercises.
@@ -0,0 +1,69 @@
{
"planPath": "docs/plans/2026-09-02-delmia-sim-rig-verification.md",
"tasks": [
{
"id": 0,
"subject": "Task 0: Preflight \u2014 rig healthy, CLI built, notifier tests green",
"status": "completed",
"model": "sonnet"
},
{
"id": 1,
"subject": "Task 1: DelmiaNotifier \u2014 optional ApiKey in appsettings.json, env var wins",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet",
"review": "opus"
},
{
"id": 2,
"subject": "Task 2: docker/seed-delmia-sim.sh \u2014 templates, composition, scripts, instance, deploy",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet"
},
{
"id": 3,
"subject": "Task 3: docker/seed-delmia-api.sh \u2014 inbound method + scoped API key",
"status": "completed",
"blockedBy": [
0
],
"model": "sonnet"
},
{
"id": 4,
"subject": "Task 4: docker/verify-delmia-notifier.sh \u2014 notifier end-to-end + central failover drill",
"status": "completed",
"blockedBy": [
1,
2,
3
],
"model": "opus"
},
{
"id": 5,
"subject": "Task 5: Record results + spec/doc updates",
"status": "completed",
"blockedBy": [
4
],
"model": "sonnet"
},
{
"id": 6,
"subject": "Task 6: Final integration review",
"status": "completed",
"blockedBy": [
5
],
"model": "fable"
}
],
"lastUpdated": "2026-09-02T17:10:00Z"
}
@@ -0,0 +1,118 @@
# Design — Shared SQL Server store for inbound API keys (central pair)
**Date:** 2026-09-02 · **Status:** Proposed · **Register:** row 108 (`docs/plans/2026-07-08-deferred-work-register.md`) · **Plan:** `2026-09-02-inbound-api-key-shared-store.md`
## Problem
Inbound API keys (`POST /api/{methodName}`, `Bearer sbk_<keyId>_<secret>`) are stored by the shared
`ZB.MOM.WW.Auth.ApiKeys` library in a **per-node SQLite file**
(`ScadaBridge:InboundApi:ApiKeyStore:SqlitePath`, `/app/data/inbound-api-keys.sqlite` on the rig,
`app\data\inbound-api-keys.sqlite` on the Windows installs). Key admin runs on whichever central node
serves the management call — through Traefik that is always the active node — so **a key exists on
exactly one node**. Consequences, all observed or verified on 2026-09-02:
- After an active-central crash the survivor answers **401 `KeyNotFound`** to every inbound client
(`docker/verify-delmia-notifier.sh`, first run). The rig now needs `docker/sync-inbound-api-keys.sh`
to copy the file by hand before a failover drill can pass.
- A key **revoked** on one node stays valid on the other. Revocation is not cluster-wide.
- The standby-relay path only works because the standby forwards *before* authenticating.
- Prod `wonder-app-vd03` is single-node and unaffected; the QA pair `vq03`/`vq04` has the gap today.
- `Component-InboundAPI.md` §Storage still says keys live in the configuration database — stale since
the 2026-06-02 re-arch (`RetireInboundApiKeyStore` dropped the SQL table and moved keys to SQLite).
The library ships **only** a SQLite store (`Sqlite/SqliteApiKeyStore`, `SqliteApiKeyAdminStore`,
`SqliteApiKeyAuditStore`, `SqliteAuthStoreMigrator`). There is no provider to switch to. This is
**not** the secrets store: `ZB.MOM.WW.Secrets` already has a shared SQL Server store for central
(`Secrets 0.5.0`, `ZbSecretsHub`), and `${secret:}` values are unaffected.
## Decision
**Move central's inbound key store to the shared SQL Server (the configuration database, own schema
`zbauth`) by adding a SQL Server provider to the shared library, mirroring what Secrets 0.5.0 did.**
SQLite stays the library default and the right answer for single-process consumers
(MxAccessGateway, HistorianGateway, a single-node ScadaBridge). Every reader and writer of a ScadaBridge
central pair then sees one row per key: create, revoke, rotate, scope edits and `last_used` are
cluster-wide by construction, and there is nothing to replicate or reconcile.
Alternatives rejected:
| Option | Why not |
|---|---|
| Replicate the SQLite file (rig script, robocopy, LocalDb CDC) | Copy-then-restart is operator-driven and still per-node between copies; LocalDb is the *site* replication engine and central does not host it; a mid-copy revoke is lost. |
| Shared SQLite on a network share | SQLite over SMB/UNC is unsafe under concurrent writers; QA nodes are separate boxes. |
| Central-to-central Akka replication of key rows | New replication mechanism for one table when a shared HA database already exists at central. |
| Keys as `${secret:}` entries in the secrets hub | Verification needs `last_used`, revoke/rotate/scope semantics and an audit log — a key/value secret store is the wrong shape. |
## Library change — `ZB.MOM.WW.Auth` 0.2.1 → **0.3.0** (`~/Desktop/scadaproj/ZB.MOM.WW.Auth`)
Additive for every existing consumer; the four existing packages bump in lockstep and a fifth is added.
1. **`Abstractions`** — new `IApiKeyStoreMigrator { Task MigrateAsync(CancellationToken ct); }`
(same shape as Secrets' `ISecretsStoreMigrator`).
2. **`ApiKeys`** — `SqliteAuthStoreMigrator : IApiKeyStoreMigrator`; `AddZbApiKeyAuth` registers
`IApiKeyStoreMigrator` with `TryAdd` (SQLite) so a provider registered *before* it displaces every
store — the exact mechanism `AddZbSecretsSqlServerStore` uses; `ApiKeyMigrationHostedService` and
`ApiKeyAdminCommands` depend on the interface. `ApiKeyAdminCommands` **keeps** its current ctor
(delegating), so MxAccessGateway/HistorianGateway/ScadaBridge compile unchanged at 0.3.0.
3. **New package `ZB.MOM.WW.Auth.ApiKeys.SqlServer`** (references `ApiKeys` for `ScopeSerializer`
and the hasher; adds `Microsoft.Data.SqlClient` 6.0.2, the family-pinned version):
- `SqlServerApiKeyOptions { ConnectionString (required), Schema = "zbauth" }`, validated at start.
- `SqlServerAuthSchema` — T-SQL for `[zbauth].[schema_version]`, `[zbauth].[api_keys]`,
`[zbauth].[api_key_audit]` and the two indexes (see plan Task 3 for the DDL). Column types map
the SQLite schema one-to-one: `key_id nvarchar(64) PK`, `secret_hash varbinary(64)`
(HMAC-SHA256 = 32 bytes), `scopes nvarchar(max)` in the existing `ScopeSerializer` format,
`*_utc datetimeoffset(7)`.
- `SqlServerApiKeyStoreMigrator : IApiKeyStoreMigrator` — idempotent `IF NOT EXISTS` DDL with the
**same retry set Secrets learned live on the central pair's first boot** (deadlock 1205, lost
CREATE 2714/1913, PK 2627): both central nodes run it at every startup, concurrently.
- `SqlServerApiKeyStore`, `SqlServerApiKeyAdminStore`, `SqlServerApiKeyAuditStore` — parameterised
`SqlCommand` ports of the SQLite stores, same semantics (`FindActiveByKeyId` = not revoked;
`SetEnabled(false)` = revoke; `Rotate` preserves expiry; `List` ordered by `created_utc`).
- `SqliteApiKeyStoreImporter.ImportAsync(sqlitePath, ct)` — one-shot copy of `api_keys` and
`api_key_audit` into SQL Server, **insert-where-not-exists on `key_id`** (audit rows keyed by
`(key_id, event_type, created_utc)`), returns counts. Idempotent, so both nodes of a pair may run
it and the result is the **union** of their files.
- `AddZbApiKeyAuthSqlServer(config, apiKeySectionPath, sqlServerSectionPath)` — registers the SQL
implementations, then calls `AddZbApiKeyAuth`.
- Tests: contract tests shared with SQLite via a small abstract fixture, run against a live SQL
Server only when `AUTH_SQLSERVER_CONNSTR` is set (`Skip.IfNot`, as Secrets does with
`SECRETS_SQLSERVER_CONNSTR`); the rig's `infra/` MSSQL serves.
## ScadaBridge change
- **Config** (`ScadaBridge:InboundApi:ApiKeyStore`): new `Provider` = `Sqlite` (default) | `SqlServer`;
`SqlServer:ConnectionString` **defaults to `ScadaBridge:Database:ConfigurationDb`** when empty (no
new secret to distribute — the pair already shares that database); `SqlServer:Schema` = `zbauth`;
`ImportFromSqlitePath` defaults to the current `SqlitePath` default.
- **Host composition** (`Program.cs`, central branch): `Provider == SqlServer` →
`AddZbApiKeyAuthSqlServer`, else `AddZbApiKeyAuth` as today. `ApiKeyAdminCommands` is composed over
`IApiKeyStoreMigrator`. A new hosted service `InboundApiKeyStoreImportService` runs **after** the
library migration: if `Provider == SqlServer` and the SQLite file exists, import it and rename it
`.imported` (the `SiteLocalDbLegacyMigrator` pattern — run once, keep the original).
- **`StartupValidator`**: a central node whose Akka seed list names more than one node and whose
`Provider` is `Sqlite` logs a **Warning** naming this design (availability-first, matching the
auto-down decision — not a hard fail, so an un-reconfigured QA still boots).
- **Docker rig**: both centrals get `ScadaBridge__InboundApi__ApiKeyStore__Provider=SqlServer`; the
connection string falls back to the shared `ScadaBridgeConfig` database on `scadabridge-mssql`.
`docker/sync-inbound-api-keys.sh` is **deleted** once `verify-delmia-notifier.sh` passes without it.
- **Windows installs**: appsettings are user-held. QA (`vq03`/`vq04`): stop both centrals **together**,
add `Provider`, start both — the first to boot creates `zbauth` and imports its file, the second
imports its own (union). Prod `vd03`: same change for consistency (single node, import once). If the
app login lacks DDL rights, a DBA runs `docs/operations/sql/zbauth-schema.sql` first; the migrator
finds the version row and skips DDL. Recorded per environment in a deployment record.
## Behaviour after the change
| Case | Before | After |
|---|---|---|
| Key created on active, active crashes | survivor: 401 for every caller | survivor verifies against the same rows: 200 |
| Key revoked on one node | still valid on the peer | revoked everywhere immediately |
| `last_used_utc` | per node | one value, whichever node verified last |
| SQL Server unreachable | inbound auth unaffected (local file) | inbound auth fails **closed** (401/500) — same blast radius as the config DB today, since method scripts already need it |
| Single-node install with `Provider=Sqlite` | unchanged | unchanged |
## Out of scope
- Site nodes (they host no inbound API). MxAccessGateway/HistorianGateway stay on SQLite.
- Encrypting `secret_hash` at rest (it is a peppered HMAC; the pepper stays in config, never in SQL).
- Retiring the per-node SQLite code path from the library.
@@ -0,0 +1,591 @@
# Inbound API Key Shared SQL Server Store — Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans to implement this plan task-by-task.
**Goal:** Give the shared `ZB.MOM.WW.Auth.ApiKeys` library a SQL Server provider, point both ScadaBridge central nodes at one `zbauth` schema in the configuration database, import each node's existing SQLite keys once, prove a central failover no longer 401s inbound callers on the docker rig, and retire the rig's file-copy workaround.
**Architecture:** Two repos, strictly ordered. First `~/Desktop/scadaproj/ZB.MOM.WW.Auth` (0.2.1 → 0.3.0): an `IApiKeyStoreMigrator` abstraction, SQLite implementing it, and a new package `ZB.MOM.WW.Auth.ApiKeys.SqlServer` whose `AddZbApiKeyAuthSqlServer` registers SQL Server stores *before* `AddZbApiKeyAuth` so the library's `TryAdd` calls leave them in place (the Secrets 0.5.0 mechanism). Then ScadaBridge pins 0.3.0, adds `ApiKeyStore:Provider`, composes the SQL provider on central, runs a one-shot SQLite import at boot, and flips the docker rig. Design: `docs/plans/2026-09-02-inbound-api-key-shared-store-design.md`.
**Tech Stack:** .NET 10, `Microsoft.Data.SqlClient` 6.0.2 (family pin), xUnit + `Skip.IfNot` live gating, Gitea NuGet feed (`build/push.sh`), docker rig (`docker/`, MSSQL from `infra/`).
**Facts the implementer must not re-derive** (verified 2026-09-02):
- Library layout: `src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs` holds every contract (`ApiKeyOptions`, `IApiKeyStore`, `IApiKeyAdminStore`, `IApiKeyAuditStore`, records). SQLite lives in `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/` (`SqliteAuthSchema` v3, `SqliteAuthStoreMigrator`, three stores, `ScopeSerializer` **public**, `SqliteValueParsing` internal). DI: `DependencyInjection/ApiKeyServiceCollectionExtensions.cs` + `ApiKeyMigrationHostedService` (takes `SqliteAuthStoreMigrator` concretely). `Admin/ApiKeyAdminCommands` ctor takes `SqliteAuthStoreMigrator` concretely — MxAccessGateway, HistorianGateway and ScadaBridge all construct it that way, so that ctor **must survive**.
- Versioning is lockstep from `Directory.Build.props` (`<Version>0.2.1</Version>`); packages are packed to `artifacts/` by `build/pack.sh` and pushed by `build/push.sh` (`GITEA_NUGET_SOURCE`, `GITEA_NUGET_KEY`). `README.md` carries the changelog and still says "current release is 0.1.4" — stale, fix it.
- Secrets' SQL Server pattern to copy: `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/src/ZB.MOM.WW.Secrets.Replicator.SqlServer/` — `SecretsSqlServerConnectionFactory`, `SqlServerSecretsSchema` (`CREATE SCHEMA` via `EXEC`, `IF NOT EXISTS` tables), `SqlServerSecretsStoreMigrator` (retry on 1205/2714/1913/2627, `MaxMigrationRetries = 5`), and the DI extension's "register before `AddZbSecrets`" comment. Live tests: `tests/ZB.MOM.WW.Secrets.Replicator.SqlServer.Tests/Live/LiveSqlServerFixture.cs` gated on `SECRETS_SQLSERVER_CONNSTR`.
- HMAC-SHA256 hashes are 32 bytes (`ApiKeySecretHasher.Hash`). Scopes are serialised by `ScopeSerializer.Serialize/Deserialize` — reuse it, do not invent a second format.
- ScadaBridge touchpoints: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:427-470` (store defaults + `AddZbApiKeyAuth` + `ApiKeyAdminCommands` composition), `Directory.Packages.props:101-104` (four `0.2.1` pins), `tests/ZB.MOM.WW.ScadaBridge.Security.Tests/LibraryInboundApiKeyAdminTests.cs`, `tests/ZB.MOM.WW.ScadaBridge.InboundAPI.Tests/EndpointExtensionsTests.cs:753-758` and `EndpointContentTypeTests.cs:157` (construct `ApiKeyAdminCommands` with the SQLite migrator — keep compiling via the retained ctor).
- Rig: both centrals reach `scadabridge-mssql` with `ScadaBridge__Database__ConfigurationDb` (env in `docker/docker-compose.yml`, user `scadabridge_app`, which already created the `zbsecrets` schema in `ZbSecretsHub` on 2026-08-07, so it holds DDL rights). Local MSSQL for library tests: `Server=localhost,1433;User Id=sa;Password=<the infra/ MSSQL_SA_PASSWORD>;TrustServerCertificate=true` (`infra/docker-compose.yml`).
- Register row 108 already records the defect; row 107 is the unrelated `DelmiaRecv` item. Do not add a new row — close 108.
- The executing session's `docker/sync-inbound-api-keys.sh` and the preflight in `docker/verify-delmia-notifier.sh` exist because of this defect; Task 14 removes the script and the preflight's dependency on it.
**Model assignment:** Opus for the library core (schema, migrator race handling, stores, importer) and the ScadaBridge Host composition; Sonnet for the abstraction plumbing, tests that mirror existing ones, config/docs; Opus for the rig verification; Fable (this session) for the final cross-repo review and the publish decision.
---
## Part A — `~/Desktop/scadaproj/ZB.MOM.WW.Auth` (library 0.3.0)
All Part A commands run from `~/Desktop/scadaproj/ZB.MOM.WW.Auth`. Commit in the **scadaproj** repo (it is a plain directory of that repo, no nested `.git`).
### Task 1: `IApiKeyStoreMigrator` abstraction + SQLite implements it
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none (everything in Part A builds on it)
**Model:** sonnet
**Files:**
- Modify: `src/ZB.MOM.WW.Auth.Abstractions/ApiKeys/ApiKeyContracts.cs` (append)
- Modify: `src/ZB.MOM.WW.Auth.ApiKeys/Sqlite/SqliteAuthStoreMigrator.cs:14`
- Modify: `src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyMigrationHostedService.cs:13-14`
- Modify: `src/ZB.MOM.WW.Auth.ApiKeys/DependencyInjection/ApiKeyServiceCollectionExtensions.cs:64-68`
- Modify: `src/ZB.MOM.WW.Auth.ApiKeys/Admin/ApiKeyAdminCommands.cs:36-73`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/ApiKeyServiceCollectionExtensionsTests.cs`
**Step 1: Write the failing test** (append to `ApiKeyServiceCollectionExtensionsTests.cs`, following the file's existing container-building helper)
```csharp
[Fact]
public void AddZbApiKeyAuth_RegistersSqliteMigratorAsIApiKeyStoreMigrator()
{
using ServiceProvider sp = BuildProvider(); // the file's existing helper that calls AddZbApiKeyAuth over a temp SqlitePath
Assert.IsType<SqliteAuthStoreMigrator>(sp.GetRequiredService<IApiKeyStoreMigrator>());
Assert.Same(sp.GetRequiredService<SqliteAuthStoreMigrator>(), sp.GetRequiredService<IApiKeyStoreMigrator>());
}
[Fact]
public void AddZbApiKeyAuth_LeavesPreRegisteredMigratorInPlace()
{
var services = new ServiceCollection();
var fake = new FakeMigrator();
services.AddSingleton<IApiKeyStoreMigrator>(fake);
services.AddZbApiKeyAuth(MinimalConfig(), "ApiKeys"); // the file's existing config helper
using ServiceProvider sp = services.BuildServiceProvider();
Assert.Same(fake, sp.GetRequiredService<IApiKeyStoreMigrator>());
}
private sealed class FakeMigrator : IApiKeyStoreMigrator
{
public Task MigrateAsync(CancellationToken ct) => Task.CompletedTask;
}
```
**Step 2: Run to verify it fails**
Run: `dotnet test tests/ZB.MOM.WW.Auth.ApiKeys.Tests --filter "FullyQualifiedName~ApiKeyServiceCollectionExtensionsTests" 2>&1 | tail -5`
Expected: build error — `IApiKeyStoreMigrator` does not exist.
**Step 3: Implement**
`ApiKeyContracts.cs` (append):
```csharp
/// <summary>
/// Creates or upgrades the API-key store schema to the version this build supports. Each store
/// provider (SQLite, SQL Server) supplies one; hosts run it at startup and before admin commands.
/// Implementations must be idempotent and safe to run concurrently from several nodes.
/// </summary>
public interface IApiKeyStoreMigrator
{
/// <summary>Migrates the store schema.</summary>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>A task that completes when the schema is current.</returns>
Task MigrateAsync(CancellationToken ct);
}
```
`SqliteAuthStoreMigrator.cs`: `public sealed class SqliteAuthStoreMigrator(AuthSqliteConnectionFactory connectionFactory) : IApiKeyStoreMigrator` (add `using ZB.MOM.WW.Auth.Abstractions.ApiKeys;`).
`ApiKeyMigrationHostedService.cs`: change the primary-ctor parameter type to `IApiKeyStoreMigrator migrator`.
`ApiKeyServiceCollectionExtensions.cs` — replace the migrator block:
```csharp
// Migrator: the SQLite implementation is the default; a provider package that registers
// IApiKeyStoreMigrator (and the three stores) BEFORE this call displaces it — that is the
// whole mechanism by which AddZbApiKeyAuthSqlServer swaps the backend.
services.TryAddSingleton(sp =>
new SqliteAuthStoreMigrator(sp.GetRequiredService<AuthSqliteConnectionFactory>()));
services.TryAddSingleton<IApiKeyStoreMigrator>(sp => sp.GetRequiredService<SqliteAuthStoreMigrator>());
```
`ApiKeyAdminCommands.cs`: field becomes `private readonly IApiKeyStoreMigrator _migrator;`; add a **new** ctor taking `IApiKeyStoreMigrator migrator` with the same body; the existing ctor taking `SqliteAuthStoreMigrator` stays and calls `this(options, adminStore, auditStore, pepperProvider, (IApiKeyStoreMigrator)migrator, clock)`. XML-doc both.
**Step 4: Run all library tests**
Run: `dotnet test ZB.MOM.WW.Auth.slnx 2>&1 | tail -5`
Expected: `Passed!`, 0 failed (the two new tests included).
**Step 5: Commit** (in `~/Desktop/scadaproj`)
```bash
git add ZB.MOM.WW.Auth/src ZB.MOM.WW.Auth/tests
git commit -m "feat(auth.apikeys): IApiKeyStoreMigrator abstraction — SQLite implements it, DI registers it via TryAdd so a provider registered first wins; ApiKeyAdminCommands accepts the interface (old ctor kept)"
```
---
### Task 2: New package skeleton `ZB.MOM.WW.Auth.ApiKeys.SqlServer` + options + connection factory
**Classification:** small
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** sonnet
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/ZB.MOM.WW.Auth.ApiKeys.SqlServer.csproj`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyOptions.cs`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/AuthSqlServerConnectionFactory.cs`
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests.csproj`
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyOptionsTests.cs`
- Modify: `ZB.MOM.WW.Auth.slnx` (add both projects), `Directory.Packages.props` (add `Microsoft.Data.SqlClient` **6.0.2**, `Xunit.SkippableFact` if the Secrets tests use it — check `~/Desktop/scadaproj/ZB.MOM.WW.Secrets/Directory.Packages.props` and copy the exact package/version)
**Step 1: csproj** — copy `src/ZB.MOM.WW.Auth.ApiKeys/ZB.MOM.WW.Auth.ApiKeys.csproj`, then: `PackageId` `ZB.MOM.WW.Auth.ApiKeys.SqlServer`; `Description` "SQL Server API-key store for the ZB.MOM.WW SCADA family — one shared row set for clustered hosts (central pairs)."; `ProjectReference` to `..\ZB.MOM.WW.Auth.ApiKeys\ZB.MOM.WW.Auth.ApiKeys.csproj` (brings Abstractions, `ScopeSerializer`, hasher); `PackageReference Include="Microsoft.Data.SqlClient"`; drop `Microsoft.Data.Sqlite` from this csproj (it is transitive). Same `InternalsVisibleTo` pattern for the new test project.
**Step 2: Failing tests** (`SqlServerApiKeyOptionsTests.cs`)
```csharp
public sealed class SqlServerApiKeyOptionsTests
{
[Fact]
public void Validate_RejectsMissingConnectionString() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = " " }.Validate());
[Fact]
public void Validate_RejectsUnsafeSchemaName() =>
Assert.Throws<InvalidOperationException>(() => new SqlServerApiKeyOptions { ConnectionString = "Server=x", Schema = "zb;auth" }.Validate());
[Fact]
public void Schema_DefaultsToZbauth() =>
Assert.Equal("zbauth", new SqlServerApiKeyOptions().Schema);
}
```
**Step 3: Implement**
```csharp
namespace ZB.MOM.WW.Auth.ApiKeys.SqlServer;
/// <summary>Options for the shared SQL Server API-key store (section supplied by the host, e.g. <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer</c>).</summary>
public sealed class SqlServerApiKeyOptions
{
/// <summary>Full SQL Server connection string. Required. Never a <c>${secret:}</c> reference on hosts whose secret store is itself SQL-backed.</summary>
public string ConnectionString { get; set; } = string.Empty;
/// <summary>Schema that owns the three tables. Letters, digits and underscore only (it is interpolated into DDL).</summary>
public string Schema { get; set; } = "zbauth";
/// <summary>Fails fast on a blank connection string or an unsafe schema identifier.</summary>
/// <exception cref="InvalidOperationException">The options are not usable.</exception>
public void Validate()
{
if (string.IsNullOrWhiteSpace(ConnectionString))
throw new InvalidOperationException("SQL Server API-key store requires a ConnectionString.");
if (string.IsNullOrEmpty(Schema) || !Schema.All(c => char.IsAsciiLetterOrDigit(c) || c == '_') || Schema.Length > 64)
throw new InvalidOperationException($"SQL Server API-key store schema name '{Schema}' is not a safe identifier.");
}
}
```
`AuthSqlServerConnectionFactory(SqlServerApiKeyOptions options)` mirrors `SecretsSqlServerConnectionFactory`: `Validate()` in the ctor, `OpenConnectionAsync(ct)` returning an opened `SqlConnection`, and a `Schema` property.
**Step 4: Run** `dotnet test tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests` → 3 passed. `dotnet build ZB.MOM.WW.Auth.slnx` → succeeded.
**Step 5: Commit**
```bash
git add ZB.MOM.WW.Auth
git commit -m "feat(auth.apikeys.sqlserver): package skeleton, options (schema default zbauth, validated) and connection factory"
```
---
### Task 3: Schema + migrator (concurrent-boot safe)
**Classification:** high-risk (DDL run concurrently by both central nodes at every boot)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** opus
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerAuthSchema.cs`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStoreMigrator.cs`
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/LiveSqlServerFixture.cs` (copy of Secrets' fixture, env var **`AUTH_SQLSERVER_CONNSTR`**, creates a throwaway database `zbauth_test_<random>` per run and drops it on dispose)
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreMigratorLiveTests.cs`
**Step 1: Failing live tests** (all `[SkippableFact]` + `Skip.IfNot(LiveSqlServer.IsEnabled, …)` exactly as Secrets does)
- `Migrate_OnEmptyDatabase_CreatesSchemaTablesAndVersionRow` — asserts `sys.schemas` has `zbauth`, the three tables exist, `schema_version.version == SqlServerAuthSchema.CurrentVersion`.
- `Migrate_Twice_IsIdempotent`.
- `Migrate_Concurrently_FromEightTasks_AllSucceed` — `Task.WhenAll` of 8 migrators on a virgin database; this is the case that bit Secrets live.
- `Migrate_WhenStoredVersionIsNewer_Throws` — write `version = 99`, expect `AuthStoreMigrationException` (reuse the existing exception type from `SqliteAuthStoreMigrator.cs`).
**Step 2: Run** with `AUTH_SQLSERVER_CONNSTR="Server=localhost,1433;User Id=sa;Password=<the infra/ MSSQL_SA_PASSWORD>;TrustServerCertificate=true"` (start `infra/` first: `docker compose -f ~/Desktop/ScadaBridge/infra/docker-compose.yml up -d scadabridge-mssql`). Expected: compile failure (types missing). Without the variable the tests must report **Skipped**, not failed.
**Step 3: Implement**
`SqlServerAuthSchema.CurrentVersion = 1`. DDL (`{schema}` interpolated from the validated option only):
```sql
IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'{schema}') EXEC(N'CREATE SCHEMA [{schema}]');
IF OBJECT_ID(N'[{schema}].[schema_version]', N'U') IS NULL
CREATE TABLE [{schema}].[schema_version] (
id INT NOT NULL CONSTRAINT PK_{schema}_schema_version PRIMARY KEY CHECK (id = 1),
version INT NOT NULL,
applied_utc DATETIMEOFFSET(7) NOT NULL);
IF OBJECT_ID(N'[{schema}].[api_keys]', N'U') IS NULL
CREATE TABLE [{schema}].[api_keys] (
key_id NVARCHAR(64) NOT NULL CONSTRAINT PK_{schema}_api_keys PRIMARY KEY,
key_prefix NVARCHAR(16) NOT NULL,
secret_hash VARBINARY(64) NOT NULL,
display_name NVARCHAR(256) NOT NULL,
scopes NVARCHAR(MAX) NOT NULL,
constraints NVARCHAR(MAX) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
last_used_utc DATETIMEOFFSET(7) NULL,
revoked_utc DATETIMEOFFSET(7) NULL,
expires_utc DATETIMEOFFSET(7) NULL);
IF OBJECT_ID(N'[{schema}].[api_key_audit]', N'U') IS NULL
CREATE TABLE [{schema}].[api_key_audit] (
audit_id BIGINT IDENTITY(1,1) NOT NULL CONSTRAINT PK_{schema}_api_key_audit PRIMARY KEY,
key_id NVARCHAR(64) NULL,
event_type NVARCHAR(64) NOT NULL,
remote_address NVARCHAR(64) NULL,
created_utc DATETIMEOFFSET(7) NOT NULL,
details NVARCHAR(MAX) NULL);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'ix_api_keys_revoked_utc' AND object_id = OBJECT_ID(N'[{schema}].[api_keys]'))
CREATE INDEX ix_api_keys_revoked_utc ON [{schema}].[api_keys] (revoked_utc);
IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'ix_api_key_audit_key_id_created_utc' AND object_id = OBJECT_ID(N'[{schema}].[api_key_audit]'))
CREATE INDEX ix_api_key_audit_key_id_created_utc ON [{schema}].[api_key_audit] (key_id, created_utc);
MERGE [{schema}].[schema_version] AS t USING (SELECT 1 AS id) AS s ON t.id = s.id
WHEN NOT MATCHED THEN INSERT (id, version, applied_utc) VALUES (1, @version, SYSDATETIMEOFFSET());
```
`SqlServerApiKeyStoreMigrator : IApiKeyStoreMigrator` — port `SqlServerSecretsStoreMigrator` **including its retry loop and the four error numbers verbatim**, its "stored version newer than supported → throw" check, and its `MigrateOnceAsync` structure (Serializable transaction). Keep the class comment that explains why the retry exists (cite the 2026-08-07 central-pair first boot).
Also ship the same DDL as a standalone file for DBAs: `docs/sql/zbauth-schema.sql` in the library repo (plain `zbauth`, `@version` replaced by the literal), referenced from the README.
**Step 4: Run** the live tests with the env var → 4 passed; without → 4 skipped. `dotnet test ZB.MOM.WW.Auth.slnx` still green.
**Step 5: Commit**
```bash
git commit -am "feat(auth.apikeys.sqlserver): zbauth schema v1 + concurrent-boot-safe migrator (retry on 1205/2714/1913/2627, newer-version fail-closed); live tests gated on AUTH_SQLSERVER_CONNSTR"
```
---
### Task 4: The three SQL Server stores (contract tests shared with SQLite)
**Classification:** high-risk (auth data path)
**Estimated implement time:** ~5 min per store — split into 4a store, 4b admin store, 4c audit store if the executor's clock says so
**Parallelizable with:** none
**Model:** opus
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyStore.cs`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAdminStore.cs`
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqlServerApiKeyAuditStore.cs`
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqlServerApiKeyStoreLiveTests.cs`, `SqlServerApiKeyAdminStoreLiveTests.cs`, `SqlServerApiKeyAuditStoreLiveTests.cs`
**Step 1: Failing tests** — port every `[Fact]` in `tests/ZB.MOM.WW.Auth.ApiKeys.Tests/SqliteApiKeyStoreTests.cs` and `SqliteApiKeyAdminStoreTests.cs` one-for-one to `[SkippableFact]`s against the live fixture (same names, same assertions, `SampleRecord`/`AssertRecordEqual` helpers copied). Add for the audit store: `Append_ThenListRecent_ReturnsNewestFirstWithinLimit`. Add one SQL-specific case: `FindByKeyId_RoundTripsDateTimeOffsetToTheTick` (SQLite stores text; SQL Server must not truncate to milliseconds).
**Step 2: Run** → compile failure.
**Step 3: Implement** — parameterised `SqlCommand`s, one connection per operation via the factory, `ConfigureAwait(false)`, `ScopeSerializer` for `scopes`, `DateTimeOffset` bound as `SqlDbType.DateTimeOffset`. Semantics **must match SQLite exactly** — read each SQLite method before porting:
- `FindActiveByKeyIdAsync` = `revoked_utc IS NULL` (expiry is the verifier's job, not the store's).
- `MarkUsedAsync` = `UPDATE … SET last_used_utc = @when WHERE key_id = @id` (no row → no-op).
- `CreateAsync` = plain `INSERT` (a duplicate `key_id` throws, as SQLite does).
- `RevokeAsync`/`SetEnabledAsync(false)` set `revoked_utc = @when` only when currently NULL and return whether a row changed; `SetEnabledAsync(true)` clears it. `RotateAsync` updates `secret_hash` only (preserves `expires_utc`). `DeleteAsync` returns rows-affected > 0. `ListAsync` ordered by `created_utc, key_id`.
- Audit `ListRecentAsync(limit)` = `SELECT TOP (@limit) … ORDER BY audit_id DESC`.
**Step 4: Run** live tests → all pass; SQLite suite unchanged.
**Step 5: Commit** — `feat(auth.apikeys.sqlserver): IApiKeyStore/IApiKeyAdminStore/IApiKeyAuditStore over SQL Server, semantics pinned by the SQLite contract tests re-run live`
---
### Task 5: One-shot SQLite → SQL Server importer
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 6
**Model:** opus
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/SqliteApiKeyStoreImporter.cs`
- Create: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/Live/SqliteApiKeyStoreImporterLiveTests.cs`
**Step 1: Failing tests**
- `Import_CopiesKeysAndAuditRows_AndReturnsCounts` — seed a temp SQLite via `SqliteAuthStoreMigrator` + `SqliteApiKeyAdminStore` (2 keys, one revoked, one with expiry) + 3 audit rows; import; assert SQL Server `ListAsync` shows both with identical fields and audit count 3.
- `Import_Twice_IsIdempotent` — second run returns `Inserted = 0`, `Skipped = 2`.
- `Import_UnionsWithExistingRows` — SQL already has key C; SQLite has A and B; result has A, B, C and no row of C was modified (compare `last_used_utc`).
- `Import_MissingFile_ReturnsZeroWithoutThrowing`.
**Step 2: Run** → compile failure.
**Step 3: Implement**
```csharp
/// <summary>Result of a one-shot import.</summary>
public sealed record ApiKeyImportResult(int KeysInserted, int KeysSkipped, int AuditInserted, int AuditSkipped, bool SourceFound);
/// <summary>
/// Copies every key and audit row from a per-node SQLite store into the shared SQL Server store,
/// insert-where-not-exists (keys by key_id; audit rows by key_id + event_type + created_utc), so it is
/// idempotent and every node of a pair may run it: the result is the UNION of the nodes' files. A row
/// that already exists in SQL Server is never modified — a revoke recorded on one node but not the
/// other therefore survives only if the revoked copy is imported first; operators reconcile by listing.
/// </summary>
public sealed class SqliteApiKeyStoreImporter(AuthSqlServerConnectionFactory sql)
{
public async Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct) { … }
}
```
Read the SQLite side with a raw `SqliteConnection` (`Mode=ReadOnly`) and the column list from `SqliteApiKeyStore`, parse timestamps exactly like `SqliteValueParsing` (make that class `internal` + `InternalsVisibleTo` the SqlServer project, or duplicate the two-line parser — prefer `InternalsVisibleTo`). Insert with `IF NOT EXISTS (SELECT 1 … WHERE key_id = @id) INSERT …` per row inside one transaction.
**Step 4: Run** → 4 passed live. **Step 5: Commit** — `feat(auth.apikeys.sqlserver): SqliteApiKeyStoreImporter — idempotent insert-where-not-exists copy of keys + audit (union across a pair)`
---
### Task 6: DI extension `AddZbApiKeyAuthSqlServer` + README/changelog + version 0.3.0
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 5
**Model:** sonnet
**Files:**
- Create: `src/ZB.MOM.WW.Auth.ApiKeys.SqlServer/DependencyInjection/SqlServerApiKeyServiceCollectionExtensions.cs`
- Test: `tests/ZB.MOM.WW.Auth.ApiKeys.SqlServer.Tests/SqlServerApiKeyServiceCollectionExtensionsTests.cs` (no live DB needed — resolves types only)
- Modify: `Directory.Build.props` (`0.2.1` → `0.3.0`), `README.md` (Packages table row, Consumer Matrix column, "current release" line, new `### 0.3.0` changelog entry above `### 0.1.4`)
**Step 1: Failing test**
```csharp
[Fact]
public void AddZbApiKeyAuthSqlServer_DisplacesSqliteStoresAndMigrator()
{
var cfg = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> {
["ApiKeys:TokenPrefix"] = "sbk", ["ApiKeys:PepperSecretName"] = "Pepper", ["Pepper"] = "0123456789abcdef",
["ApiKeys:SqlServer:ConnectionString"] = "Server=unused;TrustServerCertificate=true" }).Build();
var services = new ServiceCollection();
services.AddZbApiKeyAuthSqlServer(cfg, "ApiKeys", "ApiKeys:SqlServer");
using var sp = services.BuildServiceProvider();
Assert.IsType<SqlServerApiKeyStore>(sp.GetRequiredService<IApiKeyStore>());
Assert.IsType<SqlServerApiKeyAdminStore>(sp.GetRequiredService<IApiKeyAdminStore>());
Assert.IsType<SqlServerApiKeyAuditStore>(sp.GetRequiredService<IApiKeyAuditStore>());
Assert.IsType<SqlServerApiKeyStoreMigrator>(sp.GetRequiredService<IApiKeyStoreMigrator>());
Assert.NotNull(sp.GetRequiredService<SqliteApiKeyStoreImporter>());
Assert.NotNull(sp.GetRequiredService<IApiKeyVerifier>());
}
```
**Step 3: Implement**
```csharp
public static IServiceCollection AddZbApiKeyAuthSqlServer(
this IServiceCollection services, IConfiguration config, string apiKeySectionPath, string sqlServerSectionPath)
{
services.Configure<SqlServerApiKeyOptions>(config.GetSection(sqlServerSectionPath));
services.TryAddSingleton(sp => new AuthSqlServerConnectionFactory(sp.GetRequiredService<IOptions<SqlServerApiKeyOptions>>().Value));
// Registered BEFORE AddZbApiKeyAuth so its TryAdd calls find these and leave them alone.
services.TryAddSingleton<IApiKeyStore>(sp => new SqlServerApiKeyStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAdminStore>(sp => new SqlServerApiKeyAdminStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<IApiKeyAuditStore>(sp => new SqlServerApiKeyAuditStore(sp.GetRequiredService<AuthSqlServerConnectionFactory>()));
services.TryAddSingleton<SqlServerApiKeyStoreMigrator>();
services.TryAddSingleton<IApiKeyStoreMigrator>(sp => sp.GetRequiredService<SqlServerApiKeyStoreMigrator>());
services.TryAddSingleton<SqliteApiKeyStoreImporter>();
return services.AddZbApiKeyAuth(config, apiKeySectionPath);
}
```
Note `AddZbApiKeyAuth` still registers `AuthSqliteConnectionFactory` from `ApiKeyOptions.SqlitePath` — harmless (lazy, never opened) and it keeps the importer's source path in one option. Document that in the extension's XML comment.
README changelog entry `### 0.3.0 — SQL Server API-key store (ScadaBridge central pair)`: the abstraction, the new package, `AddZbApiKeyAuthSqlServer`, the importer, "SQLite remains default; `ApiKeyAdminCommands` old ctor retained; consumers on SQLite need no code change to move to 0.3.0", and the `docs/sql/zbauth-schema.sql` pointer. Fix "current release is **0.1.4**" → **0.3.0**.
**Step 4: Run** `dotnet test ZB.MOM.WW.Auth.slnx` green; `bash build/pack.sh` produces five `0.3.0` nupkgs in `artifacts/`.
**Step 5: Commit** — `feat(auth)!: 0.3.0 — ZB.MOM.WW.Auth.ApiKeys.SqlServer package (AddZbApiKeyAuthSqlServer, importer), README/changelog` (the `!` is for the new package/version, not an API break).
---
### Task 7: Publish 0.3.0 to the Gitea feed + umbrella index
**Classification:** trivial (but outward-facing — **confirm with the user before pushing**)
**Estimated implement time:** ~3 min
**Parallelizable with:** none
**Model:** sonnet (the push itself is the user's call)
**Files:**
- Modify: `~/Desktop/scadaproj/CLAUDE.md` (Auth row in the shared-libs table: feed carries `0.3.0`, new `ApiKeys.SqlServer` package, ScadaBridge is its first consumer)
**Steps:** `export GITEA_NUGET_SOURCE=… GITEA_NUGET_KEY=…; bash build/push.sh` (needs the user's token — ask, do not guess). Verify: `dotnet nuget list source` then `curl -s "$GITEA_NUGET_SOURCE" | head -c 200` and `dotnet package search ZB.MOM.WW.Auth.ApiKeys.SqlServer --source dohertj2-gitea` from the ScadaBridge checkout shows `0.3.0`. Commit the index change in scadaproj.
---
## Part B — ScadaBridge (`~/Desktop/ScadaBridge`)
### Task 8: Pin 0.3.0 and add `ApiKeyStore:Provider` options
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** none
**Model:** sonnet
**Files:**
- Modify: `Directory.Packages.props:101-104` (four pins → `0.3.0`, add `<PackageVersion Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" Version="0.3.0" />`)
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/ZB.MOM.WW.ScadaBridge.Host.csproj` (add `<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" />`)
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreOptions.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreOptionsTests.cs`
**Step 1: Failing tests** — `Provider_DefaultsToSqlite`, `Provider_ParsesCaseInsensitively("sqlserver" → SqlServer)`, `Provider_RejectsUnknownValue` (validation error names the two legal values).
**Step 3: Implement**
```csharp
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>Which backend holds inbound API keys. Bound from <c>ScadaBridge:InboundApi:ApiKeyStore</c> alongside the library's own <c>ApiKeyOptions</c>.</summary>
public enum InboundApiKeyStoreProvider { Sqlite, SqlServer }
/// <summary>Host-side selector for the inbound API key store. <c>Sqlite</c> is per-node (single-node installs); <c>SqlServer</c> shares one row set across a central pair (design 2026-09-02).</summary>
public sealed class InboundApiKeyStoreOptions
{
public const string SectionPath = "ScadaBridge:InboundApi:ApiKeyStore";
public InboundApiKeyStoreProvider Provider { get; set; } = InboundApiKeyStoreProvider.Sqlite;
/// <summary>SQLite file to import once when <see cref="Provider"/> is SqlServer; defaults to the library's <c>SqlitePath</c>. Renamed <c>*.imported</c> afterwards.</summary>
public string? ImportFromSqlitePath { get; set; }
}
```
Bind with `ValidateOnStart` (enum binding already rejects unknown names; add a `Validate` that produces the friendly message).
**Step 4:** `dotnet build ZB.MOM.WW.ScadaBridge.slnx` (restores 0.3.0 — Task 7 must have pushed) + the new tests green. **Step 5: Commit** — `chore(host): pin ZB.MOM.WW.Auth 0.3.0 + ApiKeys.SqlServer; InboundApiKeyStoreOptions (Provider Sqlite|SqlServer)`
---
### Task 9: Compose the provider in `Program.cs` + import hosted service
**Classification:** high-risk (auth composition on central)
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** opus
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/Program.cs:427-470`
- Create: `src/ZB.MOM.WW.ScadaBridge.Host/InboundApiKeyStoreImportService.cs`
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/CompositionRootTests.cs` (extend the existing central-composition test: with `Provider=SqlServer` and a dummy connection string, `IApiKeyStore` resolves to `SqlServerApiKeyStore` and `IApiKeyStoreMigrator` to the SQL migrator; with the default, both resolve to the SQLite types)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/InboundApiKeyStoreImportServiceTests.cs` (fake importer: file present → `ImportAsync` called once and file renamed `.imported`; file absent → not called; `Provider=Sqlite` → service inert)
**Step 3: Implement** (`Program.cs`, replacing lines 447-450 and the `ApiKeyAdminCommands` migrator argument):
```csharp
var storeOptions = builder.Configuration.GetSection(InboundApiKeyStoreOptions.SectionPath).Get<InboundApiKeyStoreOptions>() ?? new();
if (storeOptions.Provider == InboundApiKeyStoreProvider.SqlServer)
{
// Shared store for a central pair (design 2026-09-02). The connection string defaults to the
// configuration database — the pair already shares it — under its own `zbauth` schema.
const string sqlSection = apiKeyStoreSection + ":SqlServer";
if (string.IsNullOrWhiteSpace(builder.Configuration[$"{sqlSection}:ConnectionString"]))
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{ [$"{sqlSection}:ConnectionString"] = builder.Configuration["ScadaBridge:Database:ConfigurationDb"] });
builder.Services.AddZbApiKeyAuthSqlServer(builder.Configuration, apiKeyStoreSection, sqlSection);
builder.Services.AddHostedService<InboundApiKeyStoreImportService>();
}
else
{
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
}
…
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>(), // was SqliteAuthStoreMigrator
```
`InboundApiKeyStoreImportService : IHostedService` — registered **after** `AddZbApiKeyAuthSqlServer` so it starts after the library's migration hosted service (hosted services start in registration order); resolves `SqliteApiKeyStoreImporter`, `IOptions<InboundApiKeyStoreOptions>`, `IOptions<ApiKeyOptions>` (for the default path), `ILogger`. `StartAsync`: path = `ImportFromSqlitePath ?? ApiKeyOptions.SqlitePath`; if `File.Exists` → `ImportAsync`, log Information with the counts, `File.Move(path, path + ".imported")` (also move `-wal`/`-shm` if present). Any exception → log **Error** and continue (keys already in SQL keep working; a failed import must not take the node down). Keep the `${secret:}` caveat from the docker compose comment: the connection string must be a literal or an env override, never a secret reference resolved by the SQL-backed secrets store (circularity).
**Step 4:** `dotnet test tests/ZB.MOM.WW.ScadaBridge.Host.Tests` green; `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green (the InboundAPI and Security tests still construct `ApiKeyAdminCommands` with the SQLite migrator — the retained ctor keeps them compiling; do not rewrite them).
**Step 5: Commit** — `feat(host): inbound API key store provider — SqlServer shares one zbauth row set across the central pair; one-shot SQLite import at boot (file renamed .imported)`
---
### Task 10: `StartupValidator` warning for a clustered central on the per-node store
**Classification:** small
**Estimated implement time:** ~4 min
**Parallelizable with:** Task 11, Task 12
**Model:** sonnet
**Files:**
- Modify: `src/ZB.MOM.WW.ScadaBridge.Host/StartupValidator.cs` (near the existing inbound-API pepper check at ~line 80)
- Test: `tests/ZB.MOM.WW.ScadaBridge.Host.Tests/StartupValidatorTests.cs`
Rule: Central role AND `ScadaBridge:Cluster:SeedNodes` (whatever key the validator already reads for the self-first check) lists ≥ 2 nodes AND `Provider == Sqlite` → **Warning** `"Inbound API keys use the per-node SQLite store on a {n}-node central cluster; a failover will reject every inbound client until keys are re-issued. Set ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)."` Not an error (availability-first, like auto-down). Tests: warns for 2 seeds + Sqlite; silent for 1 seed; silent for SqlServer.
Commit — `feat(host): StartupValidator warns when a clustered central keeps per-node inbound API keys`
---
### Task 11: Docker rig on the shared store; retire the file-copy workaround
**Classification:** standard
**Estimated implement time:** ~5 min (+ ~3 min rebuild)
**Parallelizable with:** Task 10, Task 12
**Model:** opus
**Files:**
- Modify: `docker/docker-compose.yml` (both central `environment:` blocks: `ScadaBridge__InboundApi__ApiKeyStore__Provider: "SqlServer"` with a comment pointing at the design; connection string deliberately NOT set — falls back to `ScadaBridge__Database__ConfigurationDb`)
- Modify: `docker-env2/docker-compose.yml` (same for its two centrals — that rig is also a pair)
- Delete: `docker/sync-inbound-api-keys.sh`
- Modify: `docker/verify-delmia-notifier.sh` (header lines 9-13 + the preflight: keep the "both nodes list the key" check — it is now the *proof*, not a gate that needs a script — and reword the comment)
- Modify: `docker/README.md` (remove the sync script line if listed; note the provider)
**Steps:**
1. Edit compose + scripts. `bash docker/deploy.sh` (rebuilds the image with 0.3.0 and recreates the pair).
2. Watch both central logs for the import: `docker logs scadabridge-central-a 2>&1 | grep -i "api-key store import\|zbauth"` — expect one node reporting `KeysInserted=1` (the `delmia-notifier` key) and the other `KeysInserted=0, KeysSkipped=1` (both files hold the same key after the earlier sync), and `inbound-api-keys.sqlite.imported` on both volumes (`docker exec scadabridge-central-a ls /app/data`).
3. Confirm one row set: `docker exec scadabridge-mssql /opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P 'ScadaBridge_Dev1#' -d ScadaBridgeConfig -Q "SELECT key_id, display_name, revoked_utc FROM zbauth.api_keys"` (adapt the tools path to what the image has). Both `scadabridge security api-key list` via `:9001` and `:9002` show the same keys.
4. **Cluster-wide revoke proof:** `security api-key create --name revoke-probe --methods DelmiaRecipeDownload` via `:9001`; `curl` the method through `:9002` with it → 200; `security api-key update --key-id <id> --enabled false` via `:9001`; the same curl via `:9002` → **401**. Then `delete` it.
5. Run `export DELMIA_API_KEY=…; bash docker/verify-delmia-notifier.sh` **without any file copy** → A, B, E, F `YES`; C, G `NO`. Record the takeover seconds.
6. `docker-env2`: `bash docker-env2/deploy.sh`, confirm both of its centrals boot and one imports.
Commit — `chore(docker): both rigs' centrals on the shared SQL Server inbound key store; delete sync-inbound-api-keys.sh (superseded); verify script proves both nodes see the key without copying`
---
### Task 12: Documentation + register + umbrella index
**Classification:** trivial (docs only, but wide)
**Estimated implement time:** ~5 min
**Parallelizable with:** Task 10, Task 11
**Model:** sonnet
**Files:**
- Modify: `docs/requirements/Component-InboundAPI.md` §Storage (lines 23-24: keys live in the `ZB.MOM.WW.Auth.ApiKeys` store — SQLite per node by default, or the shared `zbauth` schema in the configuration database on a central pair via `Provider=SqlServer`; revoke/rotate/scope are cluster-wide only under SqlServer) and the Dependencies line 309.
- Modify: `docs/operations/inbound-api-key-reissue.md` §2 config table (add `Provider`, `SqlServer:ConnectionString` default, `ImportFromSqlitePath`) + a new §"Central pairs" paragraph: stop both together, set `Provider`, start both, check the import log line on each, `security api-key list` from each node, then delete the `.imported` files after a quarantine.
- Create: `docs/operations/sql/zbauth-schema.sql` (copy of the library's DDL for DBAs whose app login lacks DDL rights; the migrator then finds the version row and skips).
- Modify: `docs/deployment/topology-guide.md` (central-pair section: the key store is shared under SqlServer; SQLite on a pair is a documented misconfiguration the validator warns about).
- Modify: `env_variables.md` (rows for `ScadaBridge__InboundApi__ApiKeyStore__Provider`, `…__SqlServer__ConnectionString`, `…__ImportFromSqlitePath`).
- Modify: `CLAUDE.md` → *Security & Auth*: one bullet — inbound key store per node by default; central pairs MUST run `Provider=SqlServer` (shared `zbauth` schema in the config DB, import once at boot, revoke cluster-wide); rig does; QA/prod appsettings are user-held (deployment record).
- Modify: `docs/plans/2026-07-08-deferred-work-register.md` row 108 → resolved (library 0.3.0 + host provider + rig proof; QA/vd03 rollout pending as a deployment record).
- Modify: `docs/api-specs/Delmia-Recipe-Download-API.md` §8 (the note about the sync script → superseded).
- Modify: `../scadaproj/CLAUDE.md` ScadaBridge entry (inbound keys on the shared store; Auth 0.3.0 consumer).
Check: `grep -rn "sync-inbound-api-keys" docs docker CLAUDE.md` → only historical mentions in the register/plan. Commit — `docs: inbound API key store — shared SQL Server provider on central pairs (spec, runbook, topology, env inventory, CLAUDE.md, register row 108 closed)`
---
### Task 13: Deployment record template for QA (vq03/vq04) and prod (vd03)
**Classification:** trivial
**Estimated implement time:** ~3 min
**Parallelizable with:** none (after Task 12)
**Model:** sonnet
**Files:**
- Create: `docs/deployment/deployment-records/2026-09-XX-zb-servers-inbound-key-store.md` (a **planned** record with the exact steps and blanks for results — the user runs it; appsettings on those boxes are user-held)
Content: build/stage per the 2026-08-21 recipe; **QA:** stop both centrals together → add `"ApiKeyStore": { "Provider": "SqlServer" }` under `ScadaBridge:InboundApi` in both `appsettings.Central.json` → if `scadabridge_app` lacks DDL rights run `docs/operations/sql/zbauth-schema.sql` on the QA config DB first → start both → expect one `KeysInserted=N` line and one `KeysSkipped` line → `security api-key list` from `10.220.158.2:8085` and `.3:8085` agree → `sync`-free failover check with a curl against `DelmiaRecipeDownload` from the non-active node. **Prod vd03:** single node, same change (`KeysInserted=5` expected: `mes`, `mes api key`, `delmia-mes-receivers`, `TestKey`, `JOE_TEST_KEY`). Rollback: revert `Provider`, rename `.imported` back — no SQL rows need removing (the SQLite path ignores them).
Commit — `docs(deployment): planned record for moving QA/prod central inbound key stores to SQL Server`
---
### Task 14: Final cross-repo review
**Classification:** standard
**Estimated implement time:** ~5 min
**Parallelizable with:** none
**Model:** **fable** (this session)
Check: (1) scadaproj `git log` shows Tasks 1-7 committed and `artifacts/` holds five `0.3.0` packages that match the feed; (2) ScadaBridge `git diff main` contains no connection string or token (`grep -n "Password=\|sbk_[A-Za-z0-9]" <diff>` → only the dev-only compose values that were already committed); (3) `dotnet test ZB.MOM.WW.ScadaBridge.slnx` green; (4) Task 11 step 4 (revoke visible on the peer) and step 5 (drill without copying) are recorded with numbers; (5) MxAccessGateway and HistorianGateway still build against 0.3.0 without edits (`dotnet build` each after bumping their pins in a scratch branch — do **not** commit there; report only); (6) the rig's `sync-inbound-api-keys.sh` is gone and nothing references it. Then ask the user about pushing both repos.
---
## Out of scope
- Site nodes, MxAccessGateway, HistorianGateway (SQLite stays their store).
- Hard-failing a clustered central on `Provider=Sqlite` (warning only; revisit after QA is on SqlServer).
- Encrypting `secret_hash` at rest; the pepper never enters SQL.
- Fixing register row 107 (prod `DelmiaRecv`) — separate change.
@@ -0,0 +1,133 @@
{
"planPath": "docs/plans/2026-09-02-inbound-api-key-shared-store.md",
"tasks": [
{
"id": 1,
"subject": "Task 1: IApiKeyStoreMigrator abstraction + SQLite implements it (scadaproj)",
"status": "completed",
"model": "sonnet"
},
{
"id": 2,
"subject": "Task 2: ZB.MOM.WW.Auth.ApiKeys.SqlServer skeleton + options + connection factory",
"status": "completed",
"blockedBy": [
1
],
"model": "sonnet"
},
{
"id": 3,
"subject": "Task 3: zbauth schema + concurrent-boot-safe migrator",
"status": "completed",
"blockedBy": [
2
],
"model": "opus"
},
{
"id": 4,
"subject": "Task 4: SqlServerApiKeyStore / AdminStore / AuditStore (contract tests live)",
"status": "completed",
"blockedBy": [
3
],
"model": "opus"
},
{
"id": 5,
"subject": "Task 5: SqliteApiKeyStoreImporter (idempotent union)",
"status": "completed",
"blockedBy": [
4
],
"model": "opus"
},
{
"id": 6,
"subject": "Task 6: AddZbApiKeyAuthSqlServer DI + README/changelog + version 0.3.0",
"status": "completed",
"blockedBy": [
4
],
"model": "sonnet"
},
{
"id": 7,
"subject": "Task 7: Publish 0.3.0 to the Gitea feed + umbrella index (user confirms push)",
"status": "completed",
"blockedBy": [
5,
6
],
"model": "sonnet"
},
{
"id": 8,
"subject": "Task 8: ScadaBridge pins 0.3.0 + InboundApiKeyStoreOptions (Provider)",
"status": "completed",
"blockedBy": [
7
],
"model": "sonnet"
},
{
"id": 9,
"subject": "Task 9: Program.cs provider composition + InboundApiKeyStoreImportService",
"status": "completed",
"blockedBy": [
8
],
"model": "opus"
},
{
"id": 10,
"subject": "Task 10: StartupValidator warning for clustered central on SQLite keys",
"status": "completed",
"blockedBy": [
9
],
"model": "sonnet"
},
{
"id": 11,
"subject": "Task 11: Docker rigs on the shared store; delete sync-inbound-api-keys.sh; drill without copying",
"status": "completed",
"blockedBy": [
9
],
"model": "opus"
},
{
"id": 12,
"subject": "Task 12: Docs, runbook, DBA SQL, env inventory, CLAUDE.md, register row 108, umbrella index",
"status": "completed",
"blockedBy": [
9
],
"model": "sonnet"
},
{
"id": 13,
"subject": "Task 13: Planned deployment record for QA vq03/vq04 and prod vd03",
"status": "completed",
"blockedBy": [
12
],
"model": "sonnet"
},
{
"id": 14,
"subject": "Task 14: Final cross-repo review (fable)",
"status": "completed",
"blockedBy": [
10,
11,
12,
13
],
"model": "fable"
}
],
"lastUpdated": "2026-09-02T18:40:27Z"
}
+26 -2
View File
@@ -21,7 +21,31 @@ Central cluster only. Method scripts execute on the **active** central node; a *
## API Key Management
### Storage
- API keys are stored in the **configuration database (MS SQL)**.
- API keys are stored in the shared `ZB.MOM.WW.Auth.ApiKeys` library's store — **SQLite per node by
default**, or the shared `zbauth` schema in the **configuration database (MS SQL)** on a central
pair when `Provider=SqlServer` (see *Key store provider* below). Revoke/rotate/scope changes are
cluster-wide immediately only under `SqlServer`; under `Sqlite` they apply to whichever node served
the management call.
### Key store provider
Config section `ScadaBridge:InboundApi:ApiKeyStore` (design 2026-09-02, register row 108):
| Key | Default | Notes |
|---|---|---|
| `Provider` | `Sqlite` | `Sqlite` (per node) or `SqlServer` (shared across a central pair). Case-insensitive; an unrecognized value fails startup via `StartupValidator`. |
| `SqlServer:ConnectionString` | `ScadaBridge:Database:ConfigurationDb` | Must be a literal or env override, **never** a `${secret:}` reference — the secrets store is itself SQL-backed, so resolving one through the other would be circular. |
| `SqlServer:Schema` | `zbauth` | Owns `schema_version`, `api_keys`, `api_key_audit`. |
| `SqlServer:CommandTimeout` | 30 s | |
| `ImportFromSqlitePath` | the library's `SqlitePath` default | SQLite file imported **once**, at boot, when `Provider=SqlServer`; insert-where-absent, so both nodes of a pair may import their own file and the result is the union. On success the file is renamed `*.imported` (plus `-wal`/`-shm`); on failure the import logs Error and the node keeps booting on the keys already in SQL. |
A Central node with ≥2 seed nodes still running `Provider=Sqlite` logs a boot-time Warning (not a hard
fail — availability-first, matching the auto-down decision) naming this design, because a failover
will otherwise 401 every inbound client until keys are re-issued. Both docker rigs (`docker/`,
`docker-env2/`) run `Provider=SqlServer`; QA/prod are a separate deployment record. See
`docs/operations/inbound-api-key-reissue.md` → *Central pairs* for the reissue/migration runbook and
`docs/operations/sql/zbauth-schema.sql` for the DDL a DBA runs by hand when the app login lacks
CREATE SCHEMA/TABLE rights.
### Key Properties
- **Name/Label**: Human-readable identifier for the key (e.g., "MES-Production", "RecipeManager-Dev").
@@ -306,7 +330,7 @@ A standby now **relays** the request to the active peer instead (`InboundApiEndp
## Dependencies
- **Configuration Database (MS SQL)**: Stores API keys and method definitions.
- **Configuration Database (MS SQL)**: Stores method definitions, and — under `Provider=SqlServer` — API keys in the `zbauth` schema (see *Key store provider* above). Under the `Sqlite` default, API keys live in a per-node SQLite file instead.
- **Communication Layer**: Routes requests to sites when method implementations need site data.
- **Security & Auth**: API key validation (separate from LDAP/AD — API uses key-based auth).
- **Configuration Database (via IAuditService)**: All API key and method definition changes are audit logged.
+4 -1
View File
@@ -75,6 +75,9 @@ that is the same variable, not a separate one).
| `ScadaBridge__Security__JwtSigningKey` | Yes — `${secret:security/scadabridge/jwt-signing-key}` in `appsettings.Central.json` | `ScadaBridge:Security:JwtSigningKey` | HMAC-SHA256 signing key for the cookie-embedded session JWT. | ≥32-char secret | Yes on Central |
| `ScadaBridge__Security__Ldap__ServiceAccountPassword` | Yes — `${secret:ldap/scadabridge/service-account-password}` in `appsettings.Central.json` | `ScadaBridge:Security:Ldap:ServiceAccountPassword` | LDAP bind-account password. (Renamed from the old flat `...__LdapServiceAccountPassword`.) | the bind password | Yes on Central if LDAP bind is used |
| `ScadaBridge__InboundApi__ApiKeyPepper` | No — env/secret only (not in any shipped appsettings) | `ScadaBridge:InboundApi:ApiKeyPepper` | Per-environment pepper for the inbound API-key peppered-HMAC verifier. Hard Central startup requirement since 2026-06-03. | ≥16-char secret, unique per environment | Yes on Central |
| `ScadaBridge__InboundApi__ApiKeyStore__Provider` | Yes — `docker/docker-compose.yml` and `docker-env2/docker-compose.yml` set `SqlServer` on both centrals | `ScadaBridge:InboundApi:ApiKeyStore:Provider` | Which backend holds inbound API keys: `Sqlite` (per node, library default) or `SqlServer` (shared `zbauth` schema across a central pair — design 2026-09-02, register row 108). | `Sqlite` \| `SqlServer` (case-insensitive) | No — defaults to `Sqlite`; a clustered Central left on `Sqlite` logs a boot Warning |
| `ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString` | No — falls back to `ScadaBridge:Database:ConfigurationDb` when unset | `ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString` | Connection string for the shared `zbauth` schema. Only read when `Provider=SqlServer`. | connection string — literal or env override, **never** a `${secret:}` reference (the secrets store is itself SQL-backed) | No — only meaningful under `Provider=SqlServer` |
| `ScadaBridge__InboundApi__ApiKeyStore__ImportFromSqlitePath` | No — env/secret only | `ScadaBridge:InboundApi:ApiKeyStore:ImportFromSqlitePath` | SQLite file imported once at boot when `Provider=SqlServer`; renamed `*.imported` after a successful import. | filesystem path; defaults to the library's `SqlitePath` default | No |
| `ScadaBridge__Communication__GrpcPsk` | Yes — `${secret:SB-GRPC-PSK-site-1}` in `appsettings.Site.json` | `ScadaBridge:Communication:GrpcPsk` | **Site side** of the per-site gRPC control-plane PSK. StartupValidator refuses to boot a Site node without it; both pair nodes carry the same value. | per-site secret (e.g. `dev-grpc-psk-docker-site-a`); prod `${secret:SB-GRPC-PSK-<siteId>}` | Yes on Site |
| `ScadaBridge__Communication__SitePsks__<siteId>` | No in shipped src appsettings (central normally resolves the `SB-GRPC-PSK-<siteId>` secret); present only in the docker-compose env blocks | `ScadaBridge:Communication:SitePsks:<siteId>` | **Central side** of the same key: the value central presents when dialling that site. Must equal the site's `GrpcPsk`. Override for hosts without a master secret store. | matches the site's `GrpcPsk` | On Central, either this **or** the `SB-GRPC-PSK-<siteId>` store secret |
@@ -114,7 +117,7 @@ Resolution order per value: command-line flag → env var → `~/.scadabridge/co
| Variable | Scope | In appsettings? | Purpose | Potential values | Required |
|---|---|---|---|---|---|
| `SCADABRIDGE_API_KEY` | Runtime (client) | No — the notifier's `appsettings.json` holds only `BaseUrls`/`TimeoutSeconds`/`LogPath`; the key is **env-only by design** | Inbound API key sent as `X-API-Key`. Read only from the environment, never a file. Missing/empty → prints `API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |
| `SCADABRIDGE_API_KEY` | Runtime (client) | Yes — optional `ScadaBridge:ApiKey` fallback (2026-09-02); env wins | Inbound API key sent as `X-API-Key`. Environment first, file fallback (`ScadaBridge:ApiKey`, used only when the variable is unset/blank). Neither set → prints `API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt. | e.g. `sbk_...` | Yes |
> This is also why `System.Environment` is wholly forbidden in the script trust model — it would let a
> site/inbound script exfiltrate `SCADABRIDGE_API_KEY` and other host env secrets.
@@ -34,12 +34,41 @@ internal static class ConfigLoader
return baseUrls.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <summary>Resolve the API key from <c>SCADABRIDGE_API_KEY</c>; null/whitespace → null. Env accessor is injected for testability.</summary>
/// <summary>Human-readable name of the environment source, for diagnostics (never carries key material).</summary>
public const string ApiKeySourceEnvironment = "environment (SCADABRIDGE_API_KEY)";
/// <summary>Human-readable name of the file source, for diagnostics (never carries key material).</summary>
public const string ApiKeySourceFile = "appsettings.json (ScadaBridge:ApiKey)";
/// <summary>Resolve the API key: <c>SCADABRIDGE_API_KEY</c> wins when non-blank, otherwise <c>ScadaBridge:ApiKey</c> from <paramref name="cfg"/>; both trimmed; null when neither is set. Env accessor is injected for testability.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <returns>The resolved API key, or <c>null</c> if unset/whitespace.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet)
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg) =>
ResolveApiKey(envGet, cfg, out _);
/// <summary>Same as <see cref="ResolveApiKey(Func{string, string?}, NotifierConfig)"/> but also reports which source supplied the key, so a stale environment variable silently shadowing an edited file is diagnosable from the log.</summary>
/// <param name="envGet">Accessor used to read an environment variable by name.</param>
/// <param name="cfg">The loaded configuration, consulted only when the environment variable is unset/blank.</param>
/// <param name="source"><see cref="ApiKeySourceEnvironment"/>, <see cref="ApiKeySourceFile"/>, or <c>null</c> when no source yielded a key.</param>
/// <returns>The resolved API key, or <c>null</c> if neither source yields a non-blank value.</returns>
public static string? ResolveApiKey(Func<string, string?> envGet, NotifierConfig cfg, out string? source)
{
var key = envGet(ApiKeyEnvVar);
return string.IsNullOrWhiteSpace(key) ? null : key;
var env = envGet(ApiKeyEnvVar);
if (!string.IsNullOrWhiteSpace(env))
{
source = ApiKeySourceEnvironment;
return env.Trim();
}
var file = cfg.ScadaBridge.ApiKey;
if (!string.IsNullOrWhiteSpace(file))
{
source = ApiKeySourceFile;
return file.Trim();
}
source = null;
return null;
}
}
@@ -17,4 +17,7 @@ internal sealed class ScadaBridgeSection
/// <summary>Optional diagnostic log file (relative to the exe); null/omitted → stderr only.</summary>
public string? LogPath { get; set; }
/// <summary>Optional inbound API key. <c>SCADABRIDGE_API_KEY</c> in the environment takes precedence; this is the file fallback (design 2026-06-26, relaxed 2026-09-02).</summary>
public string? ApiKey { get; set; }
}
@@ -5,6 +5,9 @@ internal static class Program
/// <summary>Entry point: loads config, sends the recipe-download notification with failover across the configured base URLs, and reports the legacy YES/NO stdout contract.</summary>
/// <param name="args">Command-line arguments parsed into the recipe download payload.</param>
/// <returns>A task that resolves to the process exit code.</returns>
/// <summary>Stdout reason + log line when neither key source is set; one literal so the two cannot drift.</summary>
private const string ApiKeyNotConfigured = "API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)";
public static async Task<int> Main(string[] args)
{
var stdout = Console.Out;
@@ -39,13 +42,15 @@ internal static class Program
return Reporter.Report(false, "no BaseUrls configured", stdout);
}
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable);
var apiKey = ConfigLoader.ResolveApiKey(Environment.GetEnvironmentVariable, cfg, out var apiKeySource);
if (apiKey is null)
{
log.Write("API key not configured (SCADABRIDGE_API_KEY)");
return Reporter.Report(false, "API key not configured (SCADABRIDGE_API_KEY)", stdout);
log.Write(ApiKeyNotConfigured);
return Reporter.Report(false, ApiKeyNotConfigured, stdout);
}
log.Write("api key source: " + apiKeySource);
using var http = new HttpClient { Timeout = TimeSpan.FromSeconds(cfg.ScadaBridge.TimeoutSeconds) };
var sender = new LoggingRecipeSender(new HttpRecipeSender(http, apiKey), log);
@@ -6,7 +6,7 @@ recipe / NC-program download to notify the plant that a recipe was placed at a p
It is the modern, drop-in replacement for the legacy `WWNotifier` (see
[`../../docs/former-api-specs/dnc/Delmia-Integration-API.md`](../../docs/former-api-specs/dnc/Delmia-Integration-API.md),
*Surface B*), repointed from the old Wonderware `/notify` receiver to the **ScadaBridge Inbound API**
method `DelmiaRecipeDownload`. The command-line flags, the `YES`/`NO` stdout contract, and the
method `DelmiaRecipeDownload` (as-deployed spec: [`docs/api-specs/Delmia-Recipe-Download-API.md`](../../docs/api-specs/Delmia-Recipe-Download-API.md)). The command-line flags, the `YES`/`NO` stdout contract, and the
exit-code semantics are **unchanged**, so Delmia's call site and output parsing need no changes — only
the executable on disk and its `appsettings.json` are swapped.
@@ -70,7 +70,8 @@ Placed next to the exe (copied to the output on build/publish):
"ScadaBridge": {
"BaseUrls": "http://host-a:8085,http://host-b:8085",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
"LogPath": "logs/delmia-notifier.log",
"ApiKey": ""
}
}
```
@@ -83,14 +84,20 @@ Placed next to the exe (copied to the output on build/publish):
- **`TimeoutSeconds`** — per-attempt HTTP timeout (default `30`).
- **`LogPath`** — optional diagnostic log file, relative to the exe. Omit to log to stderr only.
## Secret — `SCADABRIDGE_API_KEY`
## Secret — `SCADABRIDGE_API_KEY` (or `ScadaBridge:ApiKey`)
The inbound API key is read **only** from the environment variable `SCADABRIDGE_API_KEY` and is sent
as the `X-API-Key` header. It is never read from or written to a file. If unset/empty → `NO` +
`API key not configured (SCADABRIDGE_API_KEY)`, exit `-1`, no HTTP attempt.
The inbound API key is sent as `X-API-Key`. It is resolved from **`SCADABRIDGE_API_KEY`** first
(recommended for production — the key never touches disk), falling back to **`ScadaBridge:ApiKey`** in
`appsettings.json` when the variable is unset or blank (a blank file value counts as unset too; both
values are trimmed). The diagnostic log records which source supplied the key — never the key itself — so
a stale environment variable silently shadowing an edited file is visible. If neither is set → `NO` +
`API key not configured (SCADABRIDGE_API_KEY or ScadaBridge:ApiKey)`, exit `-1`, no HTTP attempt.
Set it on the machine/account that DELMIA runs under (e.g. a system/user environment variable, or the
service account's environment).
Set the variable on the machine/account that DELMIA runs under (e.g. a system/user environment
variable, or the service account's environment). If you use the file instead, restrict its ACL to the
account DELMIA runs under — the key is stored in plain text there. Fill `ApiKey` in **only on the deployed
copy**: the repo's `appsettings.json` is tracked and copied to the build output, so a key pasted there to
test locally is one `git add` away from being committed. It must stay `""` in source control.
## Building & publishing (Native AOT, `win-x64`)
@@ -103,7 +110,8 @@ dotnet publish src/ZB.MOM.WW.ScadaBridge.DelmiaNotifier -c Release -r win-x64
# → bin/Release/net10.0/win-x64/publish/WWNotifier.exe (+ appsettings.json)
```
Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY`, and
Copy `WWNotifier.exe` **and** `appsettings.json` to the Delmia host, set `SCADABRIDGE_API_KEY` (or fill in
`ScadaBridge:ApiKey` on that copy), and
point `BaseUrls` at the central node(s).
> Managed `dotnet build` / `dotnet test` run cross-platform and are the development/CI gate; only the
@@ -121,3 +129,28 @@ $env:SCADABRIDGE_API_KEY = "sbk_..."
This mirrors the earlier `curl` verification of `POST /api/DelmiaRecipeDownload` with the
`X-API-Key` header.
### Docker rig recipe (no Galaxy needed)
The local rig (`docker/`) carries an in-memory stand-in for a vd03-style machine, so the whole
path — notifier → inbound method → site `Call` script → simulated `DelmiaReceiver` handshake — runs
on a laptop, including a 2-node central failover. Verified 2026-09-02
(`docs/api-specs/Delmia-Recipe-Download-API.md` §8):
```bash
bash docker/seed-delmia-sim.sh # SimMachine/DelmiaReceiverSim templates + DelmiaSim01 on site-a, deployed
bash docker/seed-delmia-api.sh # DelmiaRecipeDownload method + scoped key; prints DELMIA_API_KEY=sbk_... ONCE
export DELMIA_API_KEY=sbk_...
bash docker/sync-inbound-api-keys.sh # keys are a per-node store: mirror central-a -> central-b (register row 108)
bash docker/verify-delmia-notifier.sh # runs the managed build from a scratch copy; the key goes in THAT copy's appsettings.json
```
| Case | Setup | Expected |
|------|-------|----------|
| A | active node first in `BaseUrls` | `YES` |
| B | standby first (standby relays to the active) | `YES` |
| C | bad key | `NO` + `HTTP 401` (an answer is final — no failover) |
| D | active `docker kill`ed, attempt inside the takeover window | `NO` + `HTTP 503` — expected: connect-failure-only failover, the survivor is not yet active |
| E | survivor `/health/active` = 200 (~27 s) | `YES` (first URL refused → second URL) |
| F | victim restarted and rejoined as standby | `YES` (victim relays) |
| G | `DownloadPath` ending in `.bad` | `NO` + `Simulated reject: bad path or missing work order` |
@@ -2,6 +2,7 @@
"ScadaBridge": {
"BaseUrls": "http://localhost:9000",
"TimeoutSeconds": 30,
"LogPath": "logs/delmia-notifier.log"
"LogPath": "logs/delmia-notifier.log",
"ApiKey": ""
}
}
@@ -0,0 +1,165 @@
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.SqlServer;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>
/// Seam over <see cref="SqliteApiKeyStoreImporter"/> — the library type is sealed and has no
/// interface, so this is what lets <see cref="InboundApiKeyStoreImportService"/> be unit-tested
/// without a SQL Server.
/// </summary>
internal interface IInboundApiKeyStoreImporter
{
/// <summary>Copies every key and audit row from the SQLite file at <paramref name="sqlitePath"/> into the shared store.</summary>
/// <param name="sqlitePath">Path of the per-node SQLite API-key file.</param>
/// <param name="ct">A token to cancel the import.</param>
/// <returns>Per-category insert/skip counts.</returns>
Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct);
}
/// <summary>The production seam implementation: a straight delegation to the library importer.</summary>
/// <param name="inner">The library importer, resolved from the SQL Server API-key registration.</param>
internal sealed class LibraryInboundApiKeyStoreImporter(SqliteApiKeyStoreImporter inner)
: IInboundApiKeyStoreImporter
{
/// <inheritdoc/>
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
=> inner.ImportAsync(sqlitePath, ct);
}
/// <summary>
/// One-shot migration of a central node's legacy PER-NODE inbound API-key SQLite file into the
/// shared SQL Server store, run at boot when
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> (design 2026-09-02, deferred-work
/// register row 108). Before the shared store existed, a key minted through the management API was
/// written only to whichever central node served that call, so a failover left every inbound client
/// 401ing; importing both nodes' files into the one shared store is what closes that gap for keys
/// that already exist.
/// </summary>
/// <remarks>
/// <para>
/// The source is <see cref="InboundApiKeyStoreOptions.ImportFromSqlitePath"/> when set,
/// otherwise the library's own <see cref="ApiKeyOptions.SqlitePath"/> — which still points
/// at the node's pre-existing file even under the SQL Server provider, since
/// <c>AddZbApiKeyAuthSqlServer</c> keeps that option bound. A missing file is the normal
/// steady state and costs nothing.
/// </para>
/// <para>
/// The import itself is idempotent (insert-where-absent per key and per audit row), so both
/// nodes of a pair may run it and the shared store ends up holding the union of their files.
/// Both booting at once can still race under read-committed and produce a primary-key
/// violation (SQL Server error <c>2627</c>) — that ONE error is retried once, because on the
/// second pass the peer's rows are simply seen and skipped. Any other failure is not retried.
/// </para>
/// <para>
/// On success the file is renamed <c>*.imported</c> (with its <c>-wal</c>/<c>-shm</c>
/// siblings) so the next boot does no work. Overwriting a stale <c>*.imported</c> is
/// deliberate: the newer file is the one that was just imported, and keeping the older copy
/// would make the next boot's rename fail forever.
/// </para>
/// <para>
/// <b>No failure here takes the node down.</b> Every exception — import or rename — is logged
/// at Error and swallowed: keys already in the shared store keep authenticating, and the
/// file is left in place so the next boot retries. A host that refused to start on a
/// half-reachable database would turn a recoverable data-migration problem into an outage.
/// </para>
/// <para>
/// Registered AFTER <c>AddZbApiKeyAuthSqlServer</c> in <c>Program.cs</c> so it starts after
/// the library's own migration hosted service (hosted services start in registration order)
/// and the <c>zbauth</c> schema therefore exists before the first insert.
/// </para>
/// </remarks>
/// <param name="importer">The SQLite → shared-store copier.</param>
/// <param name="storeOptions">Host-side key-store selector (provider + optional explicit import path).</param>
/// <param name="apiKeyOptions">The library's API-key options, whose <c>SqlitePath</c> is the default source.</param>
/// <param name="logger">Logger for the import outcome.</param>
internal sealed class InboundApiKeyStoreImportService(
IInboundApiKeyStoreImporter importer,
IOptions<InboundApiKeyStoreOptions> storeOptions,
IOptions<ApiKeyOptions> apiKeyOptions,
ILogger<InboundApiKeyStoreImportService> logger) : IHostedService
{
/// <summary>SQL Server "Violation of PRIMARY KEY constraint" — the only error worth one retry.</summary>
private const int PrimaryKeyViolation = 2627;
/// <inheritdoc/>
public async Task StartAsync(CancellationToken cancellationToken)
{
var configured = storeOptions.Value.ImportFromSqlitePath;
var path = string.IsNullOrWhiteSpace(configured) ? apiKeyOptions.Value.SqlitePath : configured;
if (string.IsNullOrWhiteSpace(path) || !File.Exists(path))
{
logger.LogDebug(
"Inbound API key store: no SQLite key store to import (path {Path}).",
string.IsNullOrWhiteSpace(path) ? "<unset>" : path);
return;
}
try
{
var result = await ImportWithOneRetryAsync(path, cancellationToken).ConfigureAwait(false);
logger.LogInformation(
"Inbound API key store: imported {Path} into the shared SQL Server store — " +
"{KeysInserted} keys inserted, {KeysSkipped} keys already present, " +
"{AuditInserted} audit rows inserted, {AuditSkipped} audit rows already present, " +
"source found: {SourceFound}.",
path, result.KeysInserted, result.KeysSkipped,
result.AuditInserted, result.AuditSkipped, result.SourceFound);
RenameImported(path);
}
catch (Exception ex)
{
// Deliberately broad and deliberately not rethrown — see the class remarks.
logger.LogError(
ex,
"Inbound API key store: importing {Path} into the shared SQL Server store failed. " +
"Keys already in the shared store keep working; the file is left in place and the " +
"next boot will retry.",
path);
}
}
/// <inheritdoc/>
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
private async Task<ApiKeyImportResult> ImportWithOneRetryAsync(string path, CancellationToken ct)
{
try
{
return await importer.ImportAsync(path, ct).ConfigureAwait(false);
}
catch (SqlException ex) when (ex.Number == PrimaryKeyViolation)
{
logger.LogWarning(
ex,
"Inbound API key store: importing {Path} hit a primary-key violation — the peer node " +
"is most likely importing its own file concurrently. Retrying once; the second pass " +
"sees the peer's rows and skips them.",
path);
return await importer.ImportAsync(path, ct).ConfigureAwait(false);
}
}
/// <summary>
/// Renames the imported file and its WAL/SHM siblings to <c>*.imported</c> so the next boot is a
/// no-op, overwriting any stale copy left by an earlier run.
/// </summary>
/// <param name="path">The file that was just imported.</param>
private static void RenameImported(string path)
{
foreach (var suffix in new[] { string.Empty, "-wal", "-shm" })
{
var source = path + suffix;
if (File.Exists(source))
{
File.Move(source, source + ".imported", overwrite: true);
}
}
}
}
@@ -0,0 +1,36 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host;
/// <summary>Which backend holds inbound API keys. Bound from <c>ScadaBridge:InboundApi:ApiKeyStore</c> alongside the library's own <c>ApiKeyOptions</c>.</summary>
public enum InboundApiKeyStoreProvider { Sqlite, SqlServer }
/// <summary>Host-side selector for the inbound API key store. <c>Sqlite</c> is per-node (single-node installs); <c>SqlServer</c> shares one row set across a central pair (design 2026-09-02).</summary>
public sealed class InboundApiKeyStoreOptions
{
public const string SectionPath = "ScadaBridge:InboundApi:ApiKeyStore";
public InboundApiKeyStoreProvider Provider { get; set; } = InboundApiKeyStoreProvider.Sqlite;
/// <summary>SQLite file to import once when <see cref="Provider"/> is SqlServer; defaults to the library's <c>SqlitePath</c>. Renamed <c>*.imported</c> afterwards.</summary>
public string? ImportFromSqlitePath { get; set; }
/// <summary>Reads <c>Provider</c> from configuration (case-insensitive). Throws <see cref="InvalidOperationException"/> naming the legal values on an unknown one, so a typo fails the boot with a readable message instead of silently falling back to SQLite.</summary>
public static InboundApiKeyStoreProvider ReadProvider(IConfiguration configuration)
{
var value = configuration[$"{SectionPath}:Provider"];
if (string.IsNullOrWhiteSpace(value))
{
return InboundApiKeyStoreProvider.Sqlite;
}
if (Enum.TryParse<InboundApiKeyStoreProvider>(value, ignoreCase: true, out var parsed)
&& Enum.IsDefined(parsed))
{
return parsed;
}
throw new InvalidOperationException(
$"{SectionPath}:Provider '{value}' is not recognised; use 'Sqlite' or 'SqlServer'.");
}
}
+46 -2
View File
@@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.ApiKeys.DependencyInjection;
using ZB.MOM.WW.Auth.ApiKeys.SqlServer.DependencyInjection;
using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.Health;
using ZB.MOM.WW.Health.Akka;
@@ -161,6 +162,12 @@ try
{
Log.Information("Starting ScadaBridge host as {Role} on {Hostname}", nodeRole, nodeHostname);
// Non-fatal preflight findings (StartupValidator.CollectWarnings) are logged here rather than
// inside Validate because the validator runs before Serilog exists. Today: a clustered Central
// still on the per-node SQLite inbound API key store (design 2026-09-02, register row 108).
foreach (var warning in StartupValidator.CollectWarnings(configuration))
Log.Warning("Startup configuration warning: {Warning}", warning);
if (nodeRole.Equals("Central", StringComparison.OrdinalIgnoreCase))
{
var builder = WebApplication.CreateBuilder(args);
@@ -434,6 +441,14 @@ try
// value already supplied via appsettings/env wins (AddInMemoryCollection
// is registered last, but only fills keys the operator did not set
// because we read the existing value first).
//
// Which BACKEND holds those keys is chosen by
// ScadaBridge:InboundApi:ApiKeyStore:Provider (InboundApiKeyStoreOptions):
// Sqlite (default) is the per-node file described above; SqlServer shares one
// row set across the central pair, so a key minted on either node survives a
// failover (design 2026-09-02, deferred-work register row 108). SqlitePath is
// still bound under SqlServer — it names the legacy per-node file that
// InboundApiKeyStoreImportService copies in once.
const string apiKeyStoreSection = "ScadaBridge:InboundApi:ApiKeyStore";
var configuredSqlitePath = builder.Configuration[$"{apiKeyStoreSection}:SqlitePath"];
var apiKeyStoreDefaults = new Dictionary<string, string?>
@@ -447,7 +462,36 @@ try
};
builder.Configuration.AddInMemoryCollection(apiKeyStoreDefaults);
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
var apiKeyStoreProvider = InboundApiKeyStoreOptions.ReadProvider(builder.Configuration);
builder.Services.Configure<InboundApiKeyStoreOptions>(
builder.Configuration.GetSection(InboundApiKeyStoreOptions.SectionPath));
if (apiKeyStoreProvider == InboundApiKeyStoreProvider.SqlServer)
{
// Shared store for a central pair (design 2026-09-02, register row 108). The connection
// string defaults to the configuration database — the pair already shares it — under
// the library's own `zbauth` schema, so no new database or secret is needed. It must be
// a literal or an env override, NEVER a ${secret:} reference: the secrets store is
// itself SQL-backed and resolves before this point, so a secret-backed key-store string
// would be circular.
const string sqlSection = apiKeyStoreSection + ":SqlServer";
if (string.IsNullOrWhiteSpace(builder.Configuration[$"{sqlSection}:ConnectionString"]))
{
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
[$"{sqlSection}:ConnectionString"] = builder.Configuration["ScadaBridge:Database:ConfigurationDb"],
});
}
builder.Services.AddZbApiKeyAuthSqlServer(builder.Configuration, apiKeyStoreSection, sqlSection);
builder.Services.AddSingleton<IInboundApiKeyStoreImporter, LibraryInboundApiKeyStoreImporter>();
// Registered AFTER AddZbApiKeyAuthSqlServer so it starts after the library's migration
// hosted service (hosted services start in registration order) — the schema exists first.
builder.Services.AddHostedService<InboundApiKeyStoreImportService>();
}
else
{
builder.Services.AddZbApiKeyAuth(builder.Configuration, apiKeyStoreSection);
}
// Inbound-API key re-arch, additive: expose the library admin facade
// (ApiKeyAdminCommands) and the app-side management seam (IInboundApiKeyAdmin)
@@ -463,7 +507,7 @@ try
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.IApiKeyPepperProvider>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(),
sp.GetRequiredService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>(),
TimeProvider.System));
builder.Services.AddSingleton<
ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Security.IInboundApiKeyAdmin,
@@ -88,7 +88,16 @@ public static class StartupValidator
// baseline used elsewhere.
.Require("ScadaBridge:InboundApi:ApiKeyPepper",
value => !string.IsNullOrEmpty(value) && value.Length >= 16,
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)"))
"is required and must be at least 16 characters for Central (backs the inbound API-key peppered-HMAC verifier)")
// The inbound API key store provider (design 2026-09-02). Not required — an
// unset value defaults to Sqlite (InboundApiKeyStoreOptions.ReadProvider) — but
// when it IS set, it must be one of the two legal values so a typo fails fast at
// boot instead of throwing later out of ReadProvider once the host is already up.
.Require("ScadaBridge:InboundApi:ApiKeyStore:Provider",
value => string.IsNullOrWhiteSpace(value)
|| (Enum.TryParse<InboundApiKeyStoreProvider>(value, ignoreCase: true, out var provider)
&& Enum.IsDefined(provider)),
"must be 'Sqlite' or 'SqlServer' when set"))
// SeedNodes count (unconditional, after SiteId).
.Require("ScadaBridge:Cluster:SeedNodes",
_ => seedNodes != null && seedNodes.Count >= 2,
@@ -211,6 +220,28 @@ public static class StartupValidator
.ThrowIfInvalid();
}
/// <summary>Non-fatal configuration findings. Collected separately from <see cref="Validate"/> because this runs before the logger exists; Program.cs logs each entry at Warning once Serilog is configured.</summary>
public static IReadOnlyList<string> CollectWarnings(IConfiguration configuration)
{
var role = configuration.GetSection("ScadaBridge:Node")["Role"];
var seedNodes = configuration.GetSection("ScadaBridge:Cluster:SeedNodes").Get<List<string>>();
var provider = InboundApiKeyStoreOptions.ReadProvider(configuration);
if (role == "Central"
&& seedNodes is { Count: >= 2 }
&& provider == InboundApiKeyStoreProvider.Sqlite)
{
return new[]
{
$"Inbound API keys use the per-node SQLite store on a {seedNodes.Count}-node central "
+ "cluster; a failover will reject every inbound client until keys are re-issued. Set "
+ "ScadaBridge:InboundApi:ApiKeyStore:Provider=SqlServer (design 2026-09-02)."
};
}
return Array.Empty<string>();
}
/// <summary>
/// Extracts the TCP port from an Akka seed-node address of the form
/// <c>akka.tcp://system@host:port</c>. Returns <c>-1</c> when no port can be parsed.
@@ -32,6 +32,7 @@
<PackageReference Include="ZB.MOM.WW.Health" />
<PackageReference Include="ZB.MOM.WW.Health.Akka" />
<PackageReference Include="ZB.MOM.WW.Health.EntityFrameworkCore" />
<PackageReference Include="ZB.MOM.WW.Auth.ApiKeys.SqlServer" />
<PackageReference Include="ZB.MOM.WW.Telemetry" />
<PackageReference Include="ZB.MOM.WW.Telemetry.Serilog" />
<!-- Host/App.razor uses the kit's <ThemeHead/>/<ThemeScripts/> directly; declare the
@@ -38,13 +38,67 @@ public class ConfigLoaderTests
[Fact]
public void ResolveApiKey_returns_null_when_unset_or_whitespace()
{
Assert.Null(ConfigLoader.ResolveApiKey(_ => null));
Assert.Null(ConfigLoader.ResolveApiKey(_ => " "));
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig()));
Assert.Null(ConfigLoader.ResolveApiKey(_ => " ", new NotifierConfig()));
}
[Fact]
public void ResolveApiKey_returns_value_when_present()
{
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null));
Assert.Equal("sbk_x", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_x" : null, new NotifierConfig()));
}
[Fact]
public void Load_reads_ApiKey_from_ScadaBridge_section()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\",\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_file", cfg.ScadaBridge.ApiKey);
}
[Fact]
public void ResolveApiKey_prefers_env_over_config()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(n => n == "SCADABRIDGE_API_KEY" ? "sbk_env" : null, cfg));
}
[Fact]
public void ResolveApiKey_falls_back_to_config_when_env_unset_or_blank()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" sbk_file \"}}");
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => null, cfg));
Assert.Equal("sbk_file", ConfigLoader.ResolveApiKey(_ => " ", cfg));
}
[Fact]
public void ResolveApiKey_trims_env_value_like_the_file_value()
{
Assert.Equal("sbk_env", ConfigLoader.ResolveApiKey(_ => " sbk_env \t", new NotifierConfig()));
}
[Fact]
public void ResolveApiKey_reports_which_source_supplied_the_key()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\"sbk_file\"}}");
ConfigLoader.ResolveApiKey(_ => "sbk_env", cfg, out var fromEnv);
ConfigLoader.ResolveApiKey(_ => null, cfg, out var fromFile);
ConfigLoader.ResolveApiKey(_ => null, new NotifierConfig(), out var none);
Assert.Equal(ConfigLoader.ApiKeySourceEnvironment, fromEnv);
Assert.Equal(ConfigLoader.ApiKeySourceFile, fromFile);
Assert.Null(none);
}
[Fact]
public void Load_leaves_ApiKey_null_when_absent()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"BaseUrls\":\"http://x\"}}");
Assert.Null(cfg.ScadaBridge.ApiKey);
}
[Fact]
public void ResolveApiKey_returns_null_when_neither_source_set()
{
var cfg = ConfigLoader.Load("{\"ScadaBridge\":{\"ApiKey\":\" \"}}");
Assert.Null(ConfigLoader.ResolveApiKey(_ => null, cfg));
}
}
@@ -364,6 +364,26 @@ public class CentralCompositionRootTests : IDisposable
/// site writer chain is registered on central but by design never resolved there, and
/// events written to it would dead-end in a local file no forwarder ever drains.
/// </summary>
/// <summary>
/// Register row 108: the inbound API-key store is a per-node SQLite file UNLESS
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider</c> says <c>SqlServer</c>. This pins the
/// default half — an unset Provider must leave the existing SQLite composition byte-identical,
/// including registering NO import hosted service (the one-shot SQLite→SQL copy is meaningless,
/// and would rename the live key file out from under the node, on a SQLite-backed node).
/// </summary>
[Fact]
public void Central_InboundApiKeyStore_DefaultsToSqlite()
{
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteApiKeyStore>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore>());
Assert.IsType<ZB.MOM.WW.Auth.ApiKeys.Sqlite.SqliteAuthStoreMigrator>(
_factory.Services.GetService<ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator>());
var hostedServices = _factory.Services.GetServices<IHostedService>();
Assert.DoesNotContain(hostedServices, s => s is InboundApiKeyStoreImportService);
Assert.Null(_factory.Services.GetService<IInboundApiKeyStoreImporter>());
}
[Fact]
public void Central_SharedAuditSeam_IsTheCentralDirectWriteBridge()
{
@@ -373,6 +393,214 @@ public class CentralCompositionRootTests : IDisposable
}
}
/// <summary>
/// Register row 108 — the shared-store half of the inbound API-key composition. With
/// <c>ScadaBridge:InboundApi:ApiKeyStore:Provider = SqlServer</c> both central nodes must resolve
/// the SQL Server-backed stores (one shared row set) instead of each node's own SQLite file, so a
/// key minted on one node authenticates on the other after a failover.
/// </summary>
/// <remarks>
/// <para>
/// The Provider (and, in the derived fixture, the explicit connection string) is supplied as
/// a process-wide ENVIRONMENT variable rather than through
/// <c>ConfigureAppConfiguration</c>: <c>Program</c> reads this key while it is still
/// registering services, and only the pre-host configuration builder — json + environment —
/// is populated at that point. Same reason <see cref="CentralDbTestEnvironment"/> supplies
/// its values that way, and the same reason this class must sit in
/// <see cref="HostBootCollection"/>.
/// </para>
/// <para>
/// There is no SQL Server in a unit test, so the two hosted services that would dial one at
/// host start are removed: the library's own migration service (which creates the
/// <c>zbauth</c> schema) and <see cref="InboundApiKeyStoreImportService"/>. Everything under
/// test is a registration shape, which needs no live database — every store is resolved,
/// never called.
/// </para>
/// </remarks>
[Collection(HostBootCollection.Name)]
public class CentralSqlServerKeyStoreCompositionRootTests : IDisposable
{
/// <summary>Set by the derived fixture to pin an explicit connection string instead of the ConfigurationDb default.</summary>
private protected const string ExplicitConnectionString =
"Server=explicit-host,1433;Database=ExplicitKeyStore;Trusted_Connection=true;TrustServerCertificate=true";
private const string ProviderEnvKey = "ScadaBridge__InboundApi__ApiKeyStore__Provider";
private const string ConnectionStringEnvKey =
"ScadaBridge__InboundApi__ApiKeyStore__SqlServer__ConnectionString";
private readonly WebApplicationFactory<Program> _factory;
private readonly string? _previousEnv;
private readonly string? _previousProvider;
private readonly string? _previousConnectionString;
private readonly CentralDbTestEnvironment _dbEnv;
/// <summary>The provider the built host actually resolved services from.</summary>
private protected IServiceProvider Services => _factory.Services;
private protected CentralSqlServerKeyStoreCompositionRootTests(string? explicitConnectionString)
{
_previousEnv = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT");
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", "Central");
_previousProvider = Environment.GetEnvironmentVariable(ProviderEnvKey);
Environment.SetEnvironmentVariable(ProviderEnvKey, "SqlServer");
_previousConnectionString = Environment.GetEnvironmentVariable(ConnectionStringEnvKey);
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, explicitConnectionString);
_dbEnv = new CentralDbTestEnvironment();
_factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.ConfigureAppConfiguration((_, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ScadaBridge:Node:NodeName"] = "central-a",
["ScadaBridge:Node:NodeHostname"] = "localhost",
["ScadaBridge:Node:RemotingPort"] = "0",
["ScadaBridge:Cluster:SeedNodes:0"] = "akka.tcp://scadabridge@localhost:2551",
["ScadaBridge:Cluster:SeedNodes:1"] = "akka.tcp://scadabridge@localhost:2552",
["ScadaBridge:Database:SkipMigrations"] = "true",
["ScadaBridge:Security:JwtSigningKey"] = "test-signing-key-must-be-at-least-32-chars-long!",
["ScadaBridge:Security:Ldap:Server"] = "localhost",
["ScadaBridge:Security:Ldap:Port"] = "3893",
["ScadaBridge:Security:Ldap:Transport"] = "None",
["ScadaBridge:Security:Ldap:AllowInsecure"] = "true",
["ScadaBridge:Security:Ldap:SearchBase"] = "dc=zb,dc=local",
["ScadaBridge:Security:Ldap:ServiceAccountDn"] = "cn=admin,dc=zb,dc=local",
["ScadaBridge:InboundApi:ApiKeyPepper"] = "test-inbound-api-key-pepper-at-least-32-chars!",
});
});
builder.UseSetting("ScadaBridge:Node:Role", "Central");
builder.UseSetting("ScadaBridge:Database:SkipMigrations", "true");
builder.ConfigureServices(services =>
{
var descriptorsToRemove = services
.Where(d =>
d.ServiceType == typeof(DbContextOptions<ScadaBridgeDbContext>) ||
d.ServiceType == typeof(DbContextOptions) ||
d.ServiceType == typeof(ScadaBridgeDbContext) ||
d.ServiceType.FullName?.Contains("EntityFrameworkCore") == true)
.ToList();
foreach (var d in descriptorsToRemove)
services.Remove(d);
services.AddDbContext<ScadaBridgeDbContext>(options =>
options.UseInMemoryDatabase($"SqlServerKeyStoreCompositionRootTests_{Guid.NewGuid()}"));
AkkaHostedServiceRemover.RemoveAkkaHostedServiceOnly(services);
// No SQL Server here: drop the two hosted services that would dial one at
// host start. ApiKeyMigrationHostedService is internal to the Auth library,
// so it is matched by name.
var sqlDialingHostedServices = services
.Where(d => d.ServiceType == typeof(IHostedService)
&& (d.ImplementationType?.Name == "ApiKeyMigrationHostedService"
|| d.ImplementationType == typeof(InboundApiKeyStoreImportService)))
.ToList();
foreach (var d in sqlDialingHostedServices)
services.Remove(d);
});
});
_ = _factory.Server;
}
public CentralSqlServerKeyStoreCompositionRootTests()
: this(explicitConnectionString: null)
{
}
public void Dispose()
{
try
{
_factory.Dispose();
}
finally
{
Environment.SetEnvironmentVariable("DOTNET_ENVIRONMENT", _previousEnv);
Environment.SetEnvironmentVariable(ProviderEnvKey, _previousProvider);
Environment.SetEnvironmentVariable(ConnectionStringEnvKey, _previousConnectionString);
_dbEnv.Dispose();
}
}
[Theory]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAdminStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAdminStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyAuditStore),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyAuditStore))]
[InlineData(typeof(ZB.MOM.WW.Auth.Abstractions.ApiKeys.IApiKeyStoreMigrator),
typeof(ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyStoreMigrator))]
public void Central_SqlServerProvider_ResolvesSharedStores(Type serviceType, Type expectedImplementation)
{
var service = Services.GetService(serviceType);
Assert.NotNull(service);
Assert.IsType(expectedImplementation, service);
}
/// <summary>
/// The connection string defaults to the configuration database the pair already shares, under
/// the library's own <c>zbauth</c> schema — so enabling the shared store needs no new database,
/// no new secret and no new appsettings key beyond the Provider switch.
/// </summary>
[Fact]
public virtual void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
Assert.False(string.IsNullOrWhiteSpace(options.ConnectionString));
Assert.Equal("zbauth", options.Schema);
}
/// <summary>
/// The admin facade must be composable against the shared store too — its migrator argument is
/// the provider-agnostic <c>IApiKeyStoreMigrator</c>, not the SQLite concrete type, which is
/// exactly the registration that would have thrown here had the switch been left half-done.
/// </summary>
[Fact]
public void Central_SqlServerProvider_ResolvesAdminCommandsAndImporter()
{
Assert.NotNull(Services.GetService<ZB.MOM.WW.Auth.ApiKeys.Admin.ApiKeyAdminCommands>());
Assert.NotNull(Services.GetService<IInboundApiKeyStoreImporter>());
}
}
/// <summary>
/// An operator who puts the shared key store in its own database sets
/// <c>ScadaBridge:InboundApi:ApiKeyStore:SqlServer:ConnectionString</c> explicitly; that value must
/// win over the ConfigurationDb default fill-in, which is only ever applied to a blank key.
/// </summary>
[Collection(HostBootCollection.Name)]
public sealed class CentralSqlServerKeyStoreExplicitConnectionStringTests
: CentralSqlServerKeyStoreCompositionRootTests
{
public CentralSqlServerKeyStoreExplicitConnectionStringTests()
: base(ExplicitConnectionString)
{
}
/// <inheritdoc/>
[Fact]
public override void Central_SqlServerProvider_ConnectionStringDefaultsToConfigurationDb()
{
var options = Services
.GetRequiredService<IOptions<ZB.MOM.WW.Auth.ApiKeys.SqlServer.SqlServerApiKeyOptions>>().Value;
var configuration = Services.GetRequiredService<IConfiguration>();
Assert.Equal(ExplicitConnectionString, options.ConnectionString);
Assert.NotEqual(configuration["ScadaBridge:Database:ConfigurationDb"], options.ConnectionString);
}
}
/// <summary>
/// Verifies every expected DI service resolves from the Site composition root.
/// Uses the extracted SiteServiceRegistration.Configure() so the test always
@@ -0,0 +1,262 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.ApiKeys;
using ZB.MOM.WW.Auth.ApiKeys.SqlServer;
using ZB.MOM.WW.ScadaBridge.Host;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
/// <summary>
/// Unit tests for <see cref="InboundApiKeyStoreImportService"/> — the one-shot copy of a node's
/// legacy per-node SQLite key file into the shared SQL Server store (register row 108).
/// The real importer is replaced by <see cref="FakeImporter"/> so every case runs without a
/// SQL Server: the behaviour under test is path selection, the rename-on-success, the single
/// PK-violation retry, and the "never take the node down" exception posture.
/// </summary>
public sealed class InboundApiKeyStoreImportServiceTests : IDisposable
{
private readonly string _dir;
public InboundApiKeyStoreImportServiceTests()
{
_dir = Path.Combine(Path.GetTempPath(), $"sb_apikey_import_{Guid.NewGuid():N}");
Directory.CreateDirectory(_dir);
}
public void Dispose()
{
try
{
Directory.Delete(_dir, recursive: true);
}
catch (IOException)
{
// Best-effort temp cleanup; a locked file must not fail the suite.
}
}
[Fact]
public async Task StartAsync_FilePresent_ImportsThenRenamesFileAndSidecars()
{
var path = WriteFile("inbound-api-keys.sqlite");
WriteFile("inbound-api-keys.sqlite-wal");
WriteFile("inbound-api-keys.sqlite-shm");
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.Equal(new[] { path }, importer.Calls);
Assert.False(File.Exists(path));
Assert.True(File.Exists(path + ".imported"));
Assert.False(File.Exists(path + "-wal"));
Assert.True(File.Exists(path + "-wal.imported"));
Assert.False(File.Exists(path + "-shm"));
Assert.True(File.Exists(path + "-shm.imported"));
}
[Fact]
public async Task StartAsync_FileAbsent_DoesNotCallImporter()
{
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite"));
await service.StartAsync(CancellationToken.None);
Assert.Empty(importer.Calls);
}
[Fact]
public async Task StartAsync_BlankPath_DoesNotCallImporter()
{
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: " ");
await service.StartAsync(CancellationToken.None);
Assert.Empty(importer.Calls);
}
[Fact]
public async Task StartAsync_ImportFromSqlitePath_OverridesApiKeyOptionsPath()
{
var apiKeyPath = WriteFile("api-key-options.sqlite");
var overridePath = WriteFile("explicit-override.sqlite");
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: apiKeyPath, importFrom: overridePath);
await service.StartAsync(CancellationToken.None);
Assert.Equal(new[] { overridePath }, importer.Calls);
Assert.True(File.Exists(apiKeyPath)); // untouched
Assert.True(File.Exists(overridePath + ".imported"));
}
[Fact]
public async Task StartAsync_ImporterThrows_DoesNotThrowAndLeavesFileInPlace()
{
var path = WriteFile("inbound-api-keys.sqlite");
var importer = new FakeImporter { ThrowOnCall = _ => new InvalidOperationException("boom") };
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.Single(importer.Calls);
Assert.True(File.Exists(path)); // left for the next boot to retry
Assert.False(File.Exists(path + ".imported"));
}
[Fact]
public async Task StartAsync_PrimaryKeyViolation_RetriesOnceThenSucceeds()
{
var path = WriteFile("inbound-api-keys.sqlite");
var importer = new FakeImporter
{
ThrowOnCall = call => call == 1 ? FabricateSqlException("duplicate key", number: 2627) : null,
};
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.Equal(2, importer.Calls.Count);
Assert.True(File.Exists(path + ".imported"));
}
[Fact]
public async Task StartAsync_PrimaryKeyViolationTwice_DoesNotThrowAndLeavesFileInPlace()
{
var path = WriteFile("inbound-api-keys.sqlite");
var importer = new FakeImporter
{
ThrowOnCall = _ => FabricateSqlException("duplicate key", number: 2627),
};
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.Equal(2, importer.Calls.Count);
Assert.True(File.Exists(path));
}
[Fact]
public async Task StartAsync_NonPrimaryKeySqlException_DoesNotRetry()
{
var path = WriteFile("inbound-api-keys.sqlite");
var importer = new FakeImporter
{
ThrowOnCall = _ => FabricateSqlException("login failed", number: 18456),
};
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.Single(importer.Calls);
Assert.True(File.Exists(path));
}
[Fact]
public async Task StartAsync_StaleImportedFilePresent_IsOverwritten()
{
var path = WriteFile("inbound-api-keys.sqlite", content: "fresh");
WriteFile("inbound-api-keys.sqlite.imported", content: "stale");
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: path);
await service.StartAsync(CancellationToken.None);
Assert.False(File.Exists(path));
Assert.Equal("fresh", File.ReadAllText(path + ".imported"));
}
[Fact]
public async Task StopAsync_IsANoOp()
{
var importer = new FakeImporter();
var service = CreateService(importer, sqlitePath: Path.Combine(_dir, "missing.sqlite"));
await service.StopAsync(CancellationToken.None);
Assert.Empty(importer.Calls);
}
// --- helpers ---
private string WriteFile(string name, string content = "x")
{
var path = Path.Combine(_dir, name);
File.WriteAllText(path, content);
return path;
}
private static InboundApiKeyStoreImportService CreateService(
IInboundApiKeyStoreImporter importer, string sqlitePath, string? importFrom = null)
=> new(
importer,
Options.Create(new InboundApiKeyStoreOptions
{
Provider = InboundApiKeyStoreProvider.SqlServer,
ImportFromSqlitePath = importFrom,
}),
Options.Create(new ApiKeyOptions { SqlitePath = sqlitePath }),
NullLogger<InboundApiKeyStoreImportService>.Instance);
private sealed class FakeImporter : IInboundApiKeyStoreImporter
{
private int _calls;
public List<string> Calls { get; } = new();
/// <summary>Given the 1-based call number, returns the exception to throw, or null to succeed.</summary>
public Func<int, Exception?>? ThrowOnCall { get; init; }
public Task<ApiKeyImportResult> ImportAsync(string sqlitePath, CancellationToken ct)
{
Calls.Add(sqlitePath);
var call = ++_calls;
var toThrow = ThrowOnCall?.Invoke(call);
if (toThrow is not null)
{
return Task.FromException<ApiKeyImportResult>(toThrow);
}
return Task.FromResult(new ApiKeyImportResult(1, 0, 2, 0, SourceFound: true));
}
}
/// <summary>
/// Fabricates a <see cref="Microsoft.Data.SqlClient.SqlException"/> with a given message and
/// error number via the driver's internal <c>CreateException</c> factory (the type has no
/// public constructor). Same recipe as <c>DatabaseGatewayTests.FabricateSqlException</c>;
/// duplicated because the two suites share no test-support assembly.
/// </summary>
private static Microsoft.Data.SqlClient.SqlException FabricateSqlException(string message, int number)
{
var errorCtor = typeof(Microsoft.Data.SqlClient.SqlError).GetConstructors(
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)
.First(c => c.GetParameters().Length == 8
&& c.GetParameters()[7].ParameterType == typeof(Exception));
var sqlError = (Microsoft.Data.SqlClient.SqlError)errorCtor.Invoke(
new object?[] { number, (byte)0, (byte)0, "server", message, "procedure", 0, null });
var collectionCtor = typeof(Microsoft.Data.SqlClient.SqlErrorCollection).GetConstructors(
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic).First();
var collection = (Microsoft.Data.SqlClient.SqlErrorCollection)collectionCtor.Invoke(Array.Empty<object?>());
typeof(Microsoft.Data.SqlClient.SqlErrorCollection)
.GetMethod("Add", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)!
.Invoke(collection, new object?[] { sqlError });
var createException = typeof(Microsoft.Data.SqlClient.SqlException).GetMethod(
"CreateException",
System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.NonPublic,
new[] { typeof(Microsoft.Data.SqlClient.SqlErrorCollection), typeof(string) })!;
return (Microsoft.Data.SqlClient.SqlException)createException.Invoke(
null, new object?[] { collection, "6.0.0" })!;
}
}
@@ -0,0 +1,70 @@
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.ScadaBridge.Host.Tests;
public class InboundApiKeyStoreOptionsTests
{
private static IConfiguration BuildConfig(Dictionary<string, string?> values)
{
return new ConfigurationBuilder()
.AddInMemoryCollection(values)
.Build();
}
[Fact]
public void Provider_DefaultsToSqlite()
{
Assert.Equal(InboundApiKeyStoreProvider.Sqlite, new InboundApiKeyStoreOptions().Provider);
var configuration = new ConfigurationBuilder().Build();
Assert.Equal(InboundApiKeyStoreProvider.Sqlite, InboundApiKeyStoreOptions.ReadProvider(configuration));
}
[Theory]
[InlineData("sqlserver", InboundApiKeyStoreProvider.SqlServer)]
[InlineData("SQLSERVER", InboundApiKeyStoreProvider.SqlServer)]
[InlineData("SqlServer", InboundApiKeyStoreProvider.SqlServer)]
[InlineData("sqlite", InboundApiKeyStoreProvider.Sqlite)]
public void Provider_ParsesCaseInsensitively(string value, InboundApiKeyStoreProvider expected)
{
var configuration = BuildConfig(new Dictionary<string, string?>
{
[$"{InboundApiKeyStoreOptions.SectionPath}:Provider"] = value,
});
Assert.Equal(expected, InboundApiKeyStoreOptions.ReadProvider(configuration));
}
[Theory]
[InlineData("Postgres")]
[InlineData("7")]
public void Provider_RejectsUnknownValue(string value)
{
var configuration = BuildConfig(new Dictionary<string, string?>
{
[$"{InboundApiKeyStoreOptions.SectionPath}:Provider"] = value,
});
var ex = Assert.Throws<InvalidOperationException>(() => InboundApiKeyStoreOptions.ReadProvider(configuration));
Assert.Contains("Sqlite", ex.Message);
Assert.Contains("SqlServer", ex.Message);
}
[Fact]
public void Binds_ImportFromSqlitePath()
{
var configuration = BuildConfig(new Dictionary<string, string?>
{
[$"{InboundApiKeyStoreOptions.SectionPath}:Provider"] = "SqlServer",
[$"{InboundApiKeyStoreOptions.SectionPath}:ImportFromSqlitePath"] = "/app/data/inbound-api-keys.db",
});
var options = configuration.GetSection(InboundApiKeyStoreOptions.SectionPath).Get<InboundApiKeyStoreOptions>();
Assert.NotNull(options);
Assert.Equal(InboundApiKeyStoreProvider.SqlServer, options!.Provider);
Assert.Equal("/app/data/inbound-api-keys.db", options.ImportFromSqlitePath);
}
}
@@ -694,6 +694,91 @@ public class StartupValidatorTests
Assert.Null(ex);
}
[Fact]
public void CollectWarnings_WarnsForTwoSeedCentralOnSqlite()
{
// Design 2026-09-02: the inbound API key store is per-node SQLite by default, so a
// 2+-node central cluster is one failover away from rejecting every inbound client.
var config = BuildConfig(ValidCentralConfig());
var warnings = StartupValidator.CollectWarnings(config);
var warning = Assert.Single(warnings);
Assert.Contains("2-node", warning);
Assert.Contains("SQLite", warning);
Assert.Contains("Provider=SqlServer", warning);
}
[Fact]
public void CollectWarnings_WarnsWhenProviderExplicitlySqlite()
{
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "sqlite";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Single(warnings);
}
[Fact]
public void CollectWarnings_SilentForSqlServer()
{
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SqlServer";
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Empty(warnings);
}
[Fact]
public void CollectWarnings_SilentForSingleSeedCentral()
{
// CollectWarnings is independent of Validate's own >=2-seeds requirement — it should
// simply not fire the failover warning when there is nothing to fail over to.
var values = ValidCentralConfig();
values.Remove("ScadaBridge:Cluster:SeedNodes:1");
var config = BuildConfig(values);
var warnings = StartupValidator.CollectWarnings(config);
Assert.Empty(warnings);
}
[Fact]
public void CollectWarnings_SilentForSite()
{
var config = BuildConfig(ValidSiteConfig());
var warnings = StartupValidator.CollectWarnings(config);
Assert.Empty(warnings);
}
[Fact]
public void Validate_RejectsUnknownApiKeyStoreProvider()
{
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "Postgres";
var config = BuildConfig(values);
var ex = Assert.Throws<InvalidOperationException>(() => StartupValidator.Validate(config));
Assert.Contains("ScadaBridge:InboundApi:ApiKeyStore:Provider", ex.Message);
}
[Fact]
public void Validate_AcceptsProviderCaseInsensitively()
{
var values = ValidCentralConfig();
values["ScadaBridge:InboundApi:ApiKeyStore:Provider"] = "SQLSERVER";
var config = BuildConfig(values);
var ex = Record.Exception(() => StartupValidator.Validate(config));
Assert.Null(ex);
}
[Fact]
public void MultipleErrors_AllReported()
{