docs(comments): strip internal task/milestone/bundle bookkeeping from code comments

Remove project bookkeeping citations from shipped code comments across the
solution: hyphenated task IDs (WP-14, StoreAndForward-025), milestone/task/
issue refs (M3, Task 4, Audit Log #23, #21), Bundle X task-bundle labels,
and C/D/K/S/T phase labels.

Comment text only — no code logic, string/log literals, or XML-doc structure
changed. Genuine descriptions are preserved (only the citation is stripped),
and technical lookalikes are retained (UTF-8, SHA-256, T00:00:00, M365,
UTC-5, pre-C4/pre-C5 schema versions). Flagged by the new CommentChecker
TaskReferenceInComment / TrackingReferenceInComment checks plus targeted
grep passes; full solution builds clean, append-only guard tests pass.
This commit is contained in:
Joseph Doherty
2026-07-07 11:03:26 -04:00
parent 67005ca4c0
commit 9cff87fe85
435 changed files with 2338 additions and 2547 deletions
@@ -26,15 +26,15 @@ public sealed class InboundApiEndpoint
}
/// <summary>
/// WP-1: POST /api/{methodName} endpoint registration.
/// WP-2: Method routing and parameter validation.
/// WP-3: Script execution on central.
/// WP-5: Error handling — 401, 403, 400, 500.
/// POST /api/{methodName} endpoint registration.
/// Method routing and parameter validation.
/// Script execution on central.
/// Error handling — 401, 403, 400, 500.
/// </summary>
public static class EndpointExtensions
{
/// <summary>
/// Auth re-arch (A+B), InboundAPI-011 successor: the single message used for
/// Auth re-arch (A+B), successor: the single message used for
/// BOTH "method not found" and "key not in scope for this method" so the two
/// outcomes are indistinguishable to the caller. A caller holding any valid key
/// must not be able to enumerate which method names exist by observing a
@@ -57,7 +57,7 @@ public static class EndpointExtensions
public static IEndpointRouteBuilder MapInboundAPI(this IEndpointRouteBuilder endpoints)
{
endpoints.MapPost("/api/{methodName}", HandleInboundApiRequest)
// InboundAPI-006 / InboundAPI-008: active-node gating + request body
// Active-node gating + request body
// size cap are enforced by the endpoint filter before the handler runs.
.AddEndpointFilter<InboundApiEndpointFilter>();
return endpoints;
@@ -96,14 +96,14 @@ public static class EndpointExtensions
if (!verification.Succeeded)
{
// WP-5: 401 for any verifier failure. The failure reason is
// 401 for any verifier failure. The failure reason is
// discriminated for our own logs/telemetry but NEVER surfaced to the
// caller — every reason maps to the one generic message so the auth
// stage (missing vs unknown-key vs revoked vs secret-mismatch) is not
// leaked.
ScadaBridgeTelemetry.RecordInboundApiRequest("<unauthorized>");
// WP-5: Failures-only logging.
// Failures-only logging.
logger.LogWarning(
"Inbound API auth failure for method {Method}: {Failure} (status 401)",
methodName, verification.Failure);
@@ -122,7 +122,7 @@ public static class EndpointExtensions
// byte-identical (status + message), so a caller holding a valid key
// cannot probe which method names exist.
//
// Review #3 (scope-check-before-DB-lookup): the in-memory scope check runs
// The in-memory scope check runs
// FIRST and the DB GetMethodByNameAsync only runs when the caller is in
// scope. This removes a timing oracle — a not-in-scope caller no longer
// pays a DB round-trip whose latency could distinguish "valid scope but no
@@ -130,7 +130,7 @@ public static class EndpointExtensions
// the reject path. The combined `method == null || !inScope` guard still
// emits the single identical 403 body, preserving enumeration-safety.
//
// Review #2 (scope case-policy): scope strings ARE the method names, and
// Scope strings ARE the method names, and
// identity.Scopes.Contains(methodName) is an ordinal, case-SENSITIVE
// comparison (HashSet<string> with the default StringComparer.Ordinal). A
// scope must therefore match the registered method name's casing EXACTLY —
@@ -161,18 +161,18 @@ public static class EndpointExtensions
// set of configured API methods — never the raw caller-supplied route value.
ScadaBridgeTelemetry.RecordInboundApiRequest(method.Name);
// Audit Log (#23 M4 Bundle D): publish the verified key's display name so
// Publish the verified key's display name so
// AuditWriteMiddleware can populate AuditEvent.Actor in its finally
// block. Done AFTER auth+authz succeeded — auth failures leave the
// slot empty and the middleware records the row with Actor=null.
httpContext.Items[AuditWriteMiddleware.AuditActorItemKey] =
identity.DisplayName;
// WP-2: Deserialize and validate parameters
// Deserialize and validate parameters
JsonElement? body = null;
try
{
// InboundAPI-020: the content-type sniff must be case-insensitive — a
// The content-type sniff must be case-insensitive — a
// request with `application/JSON` or `Application/Json` is still JSON
// and must enter the body-parsing path. The previous case-sensitive
// `Contains("json")` silently skipped JSON deserialization for any
@@ -193,7 +193,7 @@ public static class EndpointExtensions
statusCode: 400);
}
// M9-T32b: thread the JSON-Schema $ref resolution seam into parameter
// Thread the JSON-Schema $ref resolution seam into parameter
// validation so a method whose ParameterDefinitions use a {"$ref":"lib:Name"}
// resolves the reference at RUNTIME (not just at deploy time). The shared-schema
// library is pre-loaded ONCE per request into an in-memory map (the seam the
@@ -214,12 +214,12 @@ public static class EndpointExtensions
statusCode: 400);
}
// WP-3: Execute the method's script
// Execute the method's script
var timeout = method.TimeoutSeconds > 0
? TimeSpan.FromSeconds(method.TimeoutSeconds)
: options.DefaultMethodTimeout;
// Audit Log #23 (ParentExecutionId): the inbound request's per-request
// The inbound request's per-request
// ExecutionId was minted early by AuditWriteMiddleware and stashed on
// HttpContext.Items. Thread it into the executor so a routed
// Route.To(...).Call(...) carries it as RouteToCallRequest.ParentExecutionId
@@ -237,7 +237,7 @@ public static class EndpointExtensions
if (!scriptResult.Success)
{
// InboundAPI-004: a client-aborted request is not a script failure.
// A client-aborted request is not a script failure.
// Do not pollute the failure log (reserved for genuine script errors)
// and do not attempt to write a 500 body to an already-gone connection.
if (httpContext.RequestAborted.IsCancellationRequested)
@@ -245,7 +245,7 @@ public static class EndpointExtensions
return Results.Empty;
}
// WP-5: 500 for script failures, safe error message
// 500 for script failures, safe error message
logger.LogWarning(
"Inbound API script failure for method {Method}: {Error}",
methodName, scriptResult.ErrorMessage);
@@ -3,18 +3,18 @@ using ZB.MOM.WW.ScadaBridge.ScriptAnalysis;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-005: Enforces the ScadaBridge script trust model on inbound API method
/// Enforces the ScadaBridge script trust model on inbound API method
/// scripts before they are compiled into executable handlers.
///
/// This class is now a thin shim that delegates to the shared, authoritative
/// <see cref="ScriptTrustValidator.FindViolations"/> implemented in
/// <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c> (M3.4). The unified validator runs
/// <c>ZB.MOM.WW.ScadaBridge.ScriptAnalysis</c>. The unified validator runs
/// both a semantic symbol pass (catching alias / <c>global::</c> / <c>using static</c>
/// escapes) and the reflection-gateway + <c>dynamic</c> / <c>Activator</c> syntactic
/// hardening that previously lived exclusively in this file.
///
/// <para>
/// InboundAPI-015: a purely namespace-textual deny-list is bypassable because
/// A purely namespace-textual deny-list is bypassable because
/// reflection is reachable through members of <em>permitted</em> types that never
/// spell a forbidden namespace, e.g.
/// <c>typeof(string).Assembly.GetType("System.IO.File")</c>. The shared validator
@@ -22,8 +22,8 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// hardening — <c>GetType</c>, <c>Assembly</c>, <c>GetMethod</c>, <c>InvokeMember</c>,
/// <c>CreateInstance</c>, and the <c>dynamic</c> keyword are all rejected. This
/// remains hardening of a best-effort static check, <strong>not</strong> a true sandbox
/// (see the security notes in <c>code-reviews/InboundAPI/findings.md</c>,
/// InboundAPI-015). The check is defence-in-depth; genuine containment needs a
/// (see the security notes in <c>code-reviews/InboundAPI/findings.md</c>).
/// The check is defence-in-depth; genuine containment needs a
/// runtime boundary (restricted <c>AssemblyLoadContext</c> / curated reference set /
/// out-of-process sandbox).
/// </para>
@@ -1,7 +1,7 @@
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-008: abstraction the inbound API endpoint uses to determine whether
/// Abstraction the inbound API endpoint uses to determine whether
/// this node is the active (cluster-leader) central node.
///
/// The design states the inbound API is "Central cluster only (active node)" and
@@ -7,7 +7,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// The production implementation (<see cref="CommunicationServiceInstanceRouter"/>)
/// delegates to <c>ZB.MOM.WW.ScadaBridge.Communication.CommunicationService</c>; the interface
/// exists so <see cref="RouteHelper"/>/<see cref="RouteTarget"/> can be unit tested
/// without a live actor system (InboundAPI-017).
/// without a live actor system.
/// </summary>
public interface IInstanceRouter
{
@@ -12,11 +12,11 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
///
/// <list type="bullet">
/// <item><description>
/// InboundAPI-008 — active-node gating. The inbound API is central-active-node-only;
/// Active-node gating. The inbound API is central-active-node-only;
/// a standby node returns HTTP 503 so it never executes method scripts.
/// </description></item>
/// <item><description>
/// InboundAPI-006 — request body size cap. Oversized bodies are rejected with HTTP
/// Request body size cap. Oversized bodies are rejected with HTTP
/// 413 before being buffered into a <c>JsonDocument</c>.
/// </description></item>
/// </list>
@@ -47,7 +47,7 @@ public sealed class InboundApiEndpointFilter : IEndpointFilter
{
var httpContext = context.HttpContext;
// InboundAPI-008: refuse to serve the inbound API on a standby central node.
// Refuse to serve the inbound API on a standby central node.
// The gate is optional — when no IActiveNodeGate is registered (non-clustered
// host / tests) the API is served, preserving prior behaviour.
var gate = httpContext.RequestServices.GetService<IActiveNodeGate>();
@@ -60,7 +60,7 @@ public sealed class InboundApiEndpointFilter : IEndpointFilter
statusCode: StatusCodes.Status503ServiceUnavailable);
}
// InboundAPI-006: cap the request body size. Reject an over-limit body up
// Cap the request body size. Reject an over-limit body up
// front via Content-Length; also lower the per-request max body size so a
// chunked/unknown-length stream is cut off by Kestrel as it is read.
var maxBytes = _options.MaxRequestBodyBytes;
@@ -3,7 +3,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
public class InboundApiOptions
{
/// <summary>
/// Default cap on the inbound API request body, in bytes (InboundAPI-006).
/// Default cap on the inbound API request body, in bytes.
/// </summary>
public const long DefaultMaxRequestBodyBytes = 1L * 1024 * 1024; // 1 MiB
@@ -11,7 +11,7 @@ public class InboundApiOptions
public TimeSpan DefaultMethodTimeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// InboundAPI-006: maximum accepted request body size for <c>POST /api/{methodName}</c>.
/// Maximum accepted request body size for <c>POST /api/{methodName}</c>.
/// Requests whose body exceeds this are rejected with HTTP 413 before being
/// buffered into a <see cref="System.Text.Json.JsonDocument"/>. The inbound API
/// has no rate limiting (a deliberate design choice), so an explicit, modest cap
@@ -23,7 +23,7 @@ public class InboundApiOptions
/// Server-side HMAC pepper for inbound-API bearer credentials, bound from
/// <c>ScadaBridge:InboundApi:ApiKeyPepper</c>.
/// <para>
/// Auth re-arch (C5): the legacy SQL Server hashing path that consumed this
/// Auth re-arch: the legacy SQL Server hashing path that consumed this
/// property was retired. The pepper itself is still required — the shared
/// ZB.MOM.WW.Auth.ApiKeys verifier reads the SAME configuration key
/// (<c>PepperSecretName</c> in the Host composition root points at it) to pepper
@@ -13,7 +13,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// by name and never reference <c>System.Data</c>.
///
/// <para>
/// <b>SQL-injection protection (InboundAPI-026).</b> Statement text is authored by the
/// <b>SQL-injection protection.</b> Statement text is authored by the
/// (design-time) method script, but every <em>value</em> is bound as a named SQL
/// parameter (anonymous-object properties become <c>@</c>-prefixed parameters via
/// <see cref="AddParameters"/>) and is NEVER string-concatenated into the command text.
@@ -24,13 +24,13 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// </para>
///
/// <para>
/// <b>Reads and writes are both permitted</b> (InboundAPI-026 design decision): the move-in
/// <b>Reads and writes are both permitted</b>: the move-in
/// integration needs to record results, not just read them. Use <see cref="QueryAsync"/>
/// / <see cref="QuerySingleAsync{T}"/> for reads and <see cref="ExecuteAsync"/> for writes.
/// </para>
///
/// <para>
/// <b>Async + deadline-bound (InboundAPI-027).</b> Every call uses the async ADO.NET path
/// <b>Async + deadline-bound.</b> Every call uses the async ADO.NET path
/// end-to-end (no <c>.GetAwaiter().GetResult()</c> blocking a pool thread) and honours the
/// executing method's deadline token on the command itself, with a <see cref="DbCommand.CommandTimeout"/>
/// backstop derived from the method timeout — so a slow query is bounded by the method
@@ -103,7 +103,7 @@ public sealed class InboundDatabaseHelper
/// <summary>
/// Executes a write statement (INSERT/UPDATE/DELETE/DDL) and returns the number of
/// rows affected. Writes are authorized for inbound API scripts (InboundAPI-026);
/// rows affected. Writes are authorized for inbound API scripts;
/// values are still bound as parameters, never concatenated.
/// </summary>
/// <param name="connectionName">Name of a connection configured on the central database gateway.</param>
@@ -122,7 +122,7 @@ public sealed class InboundDatabaseHelper
{
var cmd = conn.CreateCommand();
cmd.CommandText = sql;
// InboundAPI-027: a CommandTimeout backstop derived from the method timeout so a
// A CommandTimeout backstop derived from the method timeout so a
// slow query cannot outrun the method deadline even if the provider does not
// honour token cancellation mid-statement.
if (_commandTimeoutSeconds > 0) cmd.CommandTimeout = _commandTimeoutSeconds;
@@ -13,25 +13,25 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-3: Executes the C# script associated with an inbound API method.
/// Executes the C# script associated with an inbound API method.
/// Compiles method scripts via Roslyn and caches compiled delegates.
/// </summary>
public class InboundScriptExecutor
{
private readonly ILogger<InboundScriptExecutor> _logger;
// InboundAPI-001: this executor is registered as a singleton and its handler cache
// This executor is registered as a singleton and its handler cache
// is read and written from concurrent ASP.NET request threads. A plain Dictionary is
// not safe for concurrent read/write, so a ConcurrentDictionary is used throughout.
private readonly ConcurrentDictionary<string, Func<InboundScriptContext, Task<object?>>> _scriptHandlers = new();
// InboundAPI-009: a script that fails to compile (or violates the trust model)
// A script that fails to compile (or violates the trust model)
// is recorded here so it is compiled at most once. Without this, every subsequent
// request for a broken method re-runs the expensive Roslyn compilation — a CPU
// amplification vector since the inbound API has no rate limiting. The entry is
// cleared whenever the method is (re)compiled via CompileAndRegister.
//
// InboundAPI-024: bound the cache so a spam attack of unique method names cannot
// Bound the cache so a spam attack of unique method names cannot
// grow it without bound. Once the cap is reached new bad-method records are
// dropped — the cache is just a fast-fail optimisation; the per-request DB
// lookup remains the correctness path.
@@ -39,14 +39,14 @@ public class InboundScriptExecutor
private readonly ConcurrentDictionary<string, byte> _knownBadMethods = new();
/// <summary>
/// InboundAPI-024 diagnostic helper — returns the current size of the
/// Diagnostic helper — returns the current size of the
/// known-bad-methods cache so tests can assert the cap is honoured. Internal
/// so the cache itself stays an implementation detail.
/// </summary>
internal int KnownBadMethodCount => _knownBadMethods.Count;
/// <summary>
/// InboundAPI-024: records <paramref name="methodName"/> in the known-bad-methods
/// Records <paramref name="methodName"/> in the known-bad-methods
/// cache only if the cache has not reached <see cref="KnownBadMethodsCap"/>.
/// Once full, new records are dropped (paying the cheap recompile next time
/// rather than leaking memory under a unique-name flood). Existing entries are
@@ -120,9 +120,9 @@ public class InboundScriptExecutor
var (handler, compileErrors) = Compile(method);
if (handler == null)
{
// InboundAPI-009: record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request. InboundAPI-024:
// routed through the capped TryRecordBadMethod helper so the cache
// Record the failure so the lazy-compile path does not
// keep recompiling a broken script on every request. Routed
// through the capped TryRecordBadMethod helper so the cache
// cannot grow without bound under a flood of unique method names.
errors = compileErrors;
TryRecordBadMethod(method.Name);
@@ -145,7 +145,7 @@ public class InboundScriptExecutor
/// <summary>
/// Compiles a single API method script into an executable handler. Returns
/// <c>null</c> when the script is missing, fails to compile, or violates the
/// script trust model (InboundAPI-005). Does not mutate the handler cache.
/// script trust model. Does not mutate the handler cache.
/// </summary>
private (Func<InboundScriptContext, Task<object?>>? Handler, IReadOnlyList<string> Errors) Compile(ApiMethod method)
{
@@ -155,7 +155,7 @@ public class InboundScriptExecutor
return (null, new[] { "Method has no script code." });
}
// InboundAPI-005: enforce the script trust model before compiling. Roslyn
// Enforce the script trust model before compiling. Roslyn
// scripting performs no API allow/deny-listing, so forbidden namespaces must
// be rejected statically or the script could reach the host process.
var violations = ForbiddenApiChecker.FindViolations(method.Script);
@@ -225,7 +225,7 @@ public class InboundScriptExecutor
/// <param name="timeout">Timeout duration for script execution.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <param name="parentExecutionId">
/// Audit Log #23 (ParentExecutionId): the inbound API request's per-request
/// ParentExecutionId: the inbound API request's per-request
/// <c>ExecutionId</c> (minted early by <c>AuditWriteMiddleware</c> and stashed
/// on <c>HttpContext.Items</c>). When supplied, a routed
/// <c>Route.To(...).Call(...)</c> inside the script carries it as
@@ -245,7 +245,7 @@ public class InboundScriptExecutor
// Every call site passes it by named argument (parentExecutionId:).
Guid? parentExecutionId = null)
{
// InboundAPI-004: keep the timeout source and the request-abort source
// Keep the timeout source and the request-abort source
// separable. A single linked CTS makes a genuine client disconnect
// indistinguishable from a method timeout, so a normal disconnect would be
// logged and reported as "Script execution timed out". Use a dedicated
@@ -255,7 +255,7 @@ public class InboundScriptExecutor
cancellationToken, timeoutCts.Token);
// IpsenMES MoveIn: expose the scoped, parameterized Database helper to the script
// (reads + writes — InboundAPI-026). Resolve the gateway from a fresh DI scope
// (reads + writes). Resolve the gateway from a fresh DI scope
// (declared outside the try so it lives until after the script handler runs and is
// disposed here) so a scoped IDatabaseGateway is honoured. GetService (not
// GetRequiredService) so a method that never touches Database still runs even when
@@ -283,22 +283,22 @@ public class InboundScriptExecutor
try
{
var gateway = scope?.ServiceProvider.GetService<IDatabaseGateway>();
// InboundAPI-027: pass the method timeout so the helper derives a
// Pass the method timeout so the helper derives a
// CommandTimeout backstop and forwards cts.Token to every async DB call —
// a slow query is then bounded by the method deadline.
var dbHelper = new InboundDatabaseHelper(gateway, cts.Token, timeout);
// InboundAPI-016: bind the route helper to the method deadline so a
// Bind the route helper to the method deadline so a
// routed Route.To(...).Call(...) inherits the method-level timeout
// without the script having to thread the context token by hand.
//
// InboundAPI-029: also pass the raw request-abort token separately so
// Also pass the raw request-abort token separately so
// Route.To(...).WaitForAttribute(...) can be bounded by its WAIT timeout
// (not the generic method deadline) while still being cancelled by a
// client disconnect — see RouteTarget.WaitForAttribute.
//
// Audit Log #23 (ParentExecutionId): also bind the inbound request's
// ExecutionId so a routed call carries it as ParentExecutionId — the
// Also bind the inbound request's ExecutionId (ParentExecutionId) so a
// routed call carries it as ParentExecutionId — the
// spawned site script execution points back at this inbound request.
var context = new InboundScriptContext(
parameters,
@@ -310,7 +310,7 @@ public class InboundScriptExecutor
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
{
// InboundAPI-009: a method already known to fail compilation must not
// A method already known to fail compilation must not
// be recompiled on every request — short-circuit before Roslyn runs.
if (_knownBadMethods.ContainsKey(method.Name))
return new InboundScriptResult(false, null, "Script compilation failed for this method");
@@ -322,7 +322,7 @@ public class InboundScriptExecutor
if (compiled == null)
{
// Cache the failure so the next request short-circuits above.
// InboundAPI-024: routed through TryRecordBadMethod so the
// Routed through TryRecordBadMethod so the
// cache is bounded under a flood of unique method names.
TryRecordBadMethod(method.Name);
return new InboundScriptResult(false, null, "Script compilation failed for this method");
@@ -336,7 +336,7 @@ public class InboundScriptExecutor
? JsonSerializer.Serialize(result)
: null;
// M9-T32b: thread the JSON-Schema $ref resolution seam into return
// Thread the JSON-Schema $ref resolution seam into return
// validation so a method whose ReturnDefinition uses a {"$ref":"lib:Name"}
// resolves the reference at RUNTIME (not just at deploy time). The
// shared-schema library is pre-loaded ONCE from the per-execution DI scope
@@ -349,7 +349,7 @@ public class InboundScriptExecutor
var resolveRef = await SchemaRefResolver.BuildAsync(
sharedSchemaRepo, [method.ReturnDefinition], cts.Token);
// InboundAPI-014: validate the script's return value against the
// Validate the script's return value against the
// method's declared ReturnDefinition. A method whose script returns a
// shape inconsistent with its definition must not silently emit a
// malformed 200 — surface it as a script failure (500) and log.
@@ -366,7 +366,7 @@ public class InboundScriptExecutor
}
catch (OperationCanceledException)
{
// InboundAPI-004: distinguish a genuine method timeout from a client
// Distinguish a genuine method timeout from a client
// abort. Only the timeout CTS firing is a real timeout; if the caller's
// request token fired, the client disconnected — do not pollute the
// timeout log (reserved for genuine script-execution timeouts).
@@ -382,7 +382,7 @@ public class InboundScriptExecutor
catch (Exception ex)
{
_logger.LogError(ex, "Script execution failed for method {Method}", method.Name);
// WP-5: Safe error message, no internal details
// Safe error message, no internal details
return new InboundScriptResult(false, null, "Internal script error");
}
finally
@@ -15,7 +15,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
/// <summary>
/// Audit Log #23 (M4 Bundle D, T7) — emits one <see cref="AuditChannel.ApiInbound"/>
/// Emits one <see cref="AuditChannel.ApiInbound"/>
/// row per inbound API request via <see cref="ICentralAuditWriter"/> covering the
/// full set of response shapes:
///
@@ -39,7 +39,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI.Middleware;
/// for Bearer API-key callers), so the handler stashes the resolved API key name on
/// <see cref="HttpContext.Items"/> under <see cref="AuditActorItemKey"/> after
/// <c>IApiKeyVerifier.VerifyAsync</c> succeeds. The middleware reads it in
/// its <c>finally</c> block. Phase 3: when no API-key name is stashed, the actor is
/// its <c>finally</c> block. When no API-key name is stashed, the actor is
/// sourced from the authenticated <em>interactive</em> principal via
/// <see cref="IAuditActorAccessor"/> (a cookie/LDAP-authenticated inbound user,
/// keyed off the canonical username claim). On auth failures (401/403) the actor is
@@ -80,7 +80,7 @@ public sealed class AuditWriteMiddleware
public const string AuditActorItemKey = "ZB.MOM.WW.ScadaBridge.InboundAPI.AuditActor";
/// <summary>
/// Audit Log #23 (ParentExecutionId): <see cref="HttpContext.Items"/> key under
/// ParentExecutionId: <see cref="HttpContext.Items"/> key under
/// which this middleware stashes the inbound request's per-request
/// <c>ExecutionId</c> (a <see cref="Guid"/>) at the very start of the request.
/// The id is minted ONCE and shared: the endpoint handler reads it to thread it
@@ -106,14 +106,14 @@ public sealed class AuditWriteMiddleware
/// <param name="logger">Logger for this middleware.</param>
/// <param name="options">Live-reloadable audit log options, read per-request.</param>
/// <param name="actorAccessor">
/// Phase 3 (optional): resolves the audit <see cref="AuditEvent.Actor"/> from the
/// Resolves the audit <see cref="AuditEvent.Actor"/> from the
/// authenticated principal on a cookie/LDAP-authenticated inbound request. Optional
/// so existing tests (and any composition without the accessor registered) still
/// construct the middleware; when absent, actor resolution falls back to the
/// stashed API-key name only.
/// </param>
/// <param name="ceilingHitsCounter">
/// M5.3 (T7, optional): incremented whenever an inbound request or response
/// Incremented whenever an inbound request or response
/// body is truncated at <see cref="AuditLogOptions.InboundMaxBytes"/>. Optional
/// so existing tests and composition roots without the central health snapshot
/// wired still construct without the counter; a NoOp is used when absent.
@@ -149,7 +149,7 @@ public sealed class AuditWriteMiddleware
var opts = _options.CurrentValue;
var cap = opts.InboundMaxBytes;
// Audit Log #23 (ParentExecutionId): mint the inbound request's per-request
// ParentExecutionId: mint the inbound request's per-request
// ExecutionId ONCE, here at the start of the request, and stash it on
// HttpContext.Items. Two consumers share this single id:
// (a) the endpoint handler reads it to thread onto a routed
@@ -166,7 +166,7 @@ public sealed class AuditWriteMiddleware
// of the pipeline for us — but we also rewind to position 0 after our
// own read so the very next reader starts from the top.
//
// InboundAPI-019: skip EnableBuffering for bodyless requests (a known
// Skip EnableBuffering for bodyless requests (a known
// empty Content-Length or a method that conventionally carries no body —
// GET / HEAD / DELETE / TRACE / OPTIONS). The FileBufferingReadStream
// wrapper EnableBuffering installs allocates an internal buffer regardless
@@ -176,7 +176,7 @@ public sealed class AuditWriteMiddleware
// returns (null, false) for the bodyless case anyway, so the audit row
// is unchanged.
//
// M5.3 (T7): check if the matched method/target has SkipBodyCapture set.
// Check if the matched method/target has SkipBodyCapture set.
// The route value is resolved BEFORE the pipeline runs (route matching
// has already bound {methodName} at this point), so we can skip the
// EnableBuffering allocation and body read up front.
@@ -224,7 +224,7 @@ public sealed class AuditWriteMiddleware
// original sink; this just pulls back the bounded UTF-8 string.
ctx.Response.Body = originalResponseBody;
var (capturedResponseBody, capturedResponseTruncated) = captureStream.GetCapturedBody();
// M5.3 (T7): if SkipBodyCapture is set, discard the captured response
// If SkipBodyCapture is set, discard the captured response
// body (the request body was never captured above). The row + headers
// still emit with null RequestSummary / ResponseSummary.
// Truncation flags are also cleared so ceiling-hit counter is not
@@ -279,14 +279,14 @@ public sealed class AuditWriteMiddleware
var actor = isAuthFailure ? null : ResolveActor(ctx);
var methodName = ResolveMethodName(ctx);
// M5.3 (T7): increment the ceiling-hits counter once per request
// Increment the ceiling-hits counter once per request
// that hit the cap on EITHER the request or response body.
if (requestTruncated || responseTruncated)
{
try { _ceilingHitsCounter.Increment(); } catch { /* swallow per §7 */ }
}
// M5.3 (T7): capture request headers into Extra JSON alongside the
// Capture request headers into Extra JSON alongside the
// existing remoteIp / userAgent provenance fields. The header
// collection is run through the SAME header-redaction list
// (AuditLogOptions.HeaderRedactList) that the ScadaBridgeAuditRedactor
@@ -325,7 +325,7 @@ public sealed class AuditWriteMiddleware
occurredAtUtc: DateTime.UtcNow,
actor: actor,
target: methodName,
// Audit Log #23: the per-request execution id minted ONCE at the
// The per-request execution id minted ONCE at the
// start of the request (InvokeAsync) and stashed on
// HttpContext.Items. The same id is threaded onto a routed
// RouteToCallRequest.ParentExecutionId by the endpoint handler,
@@ -347,7 +347,7 @@ public sealed class AuditWriteMiddleware
// Central direct-write — no site-local forwarding state (not a
// canonical field).
// InboundAPI-018: fire-and-forget the writer so the user-facing
// Fire-and-forget the writer so the user-facing
// response stays non-blocking (alog.md §13 — audit emission must
// NEVER abort or delay the user request), but observe the returned
// Task so an asynchronous fault is logged instead of vanishing into
@@ -368,7 +368,7 @@ public sealed class AuditWriteMiddleware
}
/// <summary>
/// InboundAPI-018: observe the audit writer's returned <see cref="Task"/>
/// Observe the audit writer's returned <see cref="Task"/>
/// so a fault that surfaces ASYNCHRONOUSLY (e.g. a DB timeout deep in the
/// central audit pipeline) is logged at Warning rather than dropped into
/// <see cref="TaskScheduler.UnobservedTaskException"/>. Stays
@@ -398,7 +398,7 @@ public sealed class AuditWriteMiddleware
}
/// <summary>
/// InboundAPI-019: decides whether the request is likely to carry a body, so the
/// Decides whether the request is likely to carry a body, so the
/// caller can skip <see cref="HttpRequestRewindExtensions.EnableBuffering(HttpRequest)"/>
/// (and the associated <c>FileBufferingReadStream</c> allocation) on requests that
/// definitely won't have one. Returns <c>true</c> when <see cref="HttpRequest.ContentLength"/>
@@ -533,7 +533,7 @@ public sealed class AuditWriteMiddleware
}
/// <summary>
/// Audit Log #23 (ParentExecutionId): reads the inbound request's per-request
/// ParentExecutionId: reads the inbound request's per-request
/// <c>ExecutionId</c> that <see cref="InvokeAsync"/> minted and stashed on
/// <see cref="HttpContext.Items"/> under <see cref="InboundExecutionIdItemKey"/>.
/// Throws <see cref="InvalidOperationException"/> if the slot is absent — for a
@@ -563,7 +563,7 @@ public sealed class AuditWriteMiddleware
/// <see cref="HttpContext.Items"/> after successful key auth (the
/// key-authenticated path — the canonical identity of an API-key caller);</description></item>
/// <item><description>otherwise the authenticated <em>interactive</em> principal
/// resolved through <see cref="IAuditActorAccessor"/> (Phase 3 — a
/// resolved through <see cref="IAuditActorAccessor"/> (a
/// cookie/LDAP-authenticated inbound user, sourced from the canonical username
/// claim). The accessor reads the ambient <see cref="HttpContext.User"/>, so the
/// fall-through here only fires when no API-key name was stashed;</description></item>
@@ -571,7 +571,7 @@ public sealed class AuditWriteMiddleware
/// principal back as an actor.</description></item>
/// </list>
/// The accessor is optional (constructor default <c>null</c>); when absent only
/// the stashed API-key name is consulted, preserving the pre-Phase-3 behaviour.
/// the stashed API-key name is consulted, preserving the previous behaviour.
/// </summary>
private string? ResolveActor(HttpContext ctx)
{
@@ -582,7 +582,7 @@ public sealed class AuditWriteMiddleware
return name;
}
// Phase 3: an interactive cookie/LDAP-authenticated inbound user records
// An interactive cookie/LDAP-authenticated inbound user records
// their real identity as Actor. Returns null for the key-authenticated
// and auth-failure paths (no authenticated interactive principal), so the
// existing API-key/auth-failure behaviour is preserved.
@@ -4,12 +4,12 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-2: Validates and deserializes a JSON request body against a method's
/// Validates and deserializes a JSON request body against a method's
/// parameter definitions. Extended type system: Boolean, Integer, Float,
/// String, Object, List.
///
/// <para>
/// InboundAPI-M2.6: validation is now RECURSIVE and type-aware for the
/// Validation is now RECURSIVE and type-aware for the
/// extended <c>Object</c> / <c>List</c> types. Declared object fields are
/// validated against their declared (nested) types, list elements against the
/// declared element type, and scalars at any depth against the extended type —
@@ -31,7 +31,7 @@ public static class ParameterValidator
/// <param name="body">The parsed JSON request body; null or undefined if no body was supplied.</param>
/// <param name="parameterDefinitions">JSON Schema describing the method's parameters (an object schema), or null/empty when no parameters are defined. The legacy flat-array form is also accepted.</param>
/// <param name="resolveRef">
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
/// Optional JSON-Schema <c>$ref</c> resolution seam mapping a
/// <c>{"$ref":"lib:Name"}</c> reference target's name to the referenced schema JSON
/// (or <c>null</c> when the library entry does not exist). The endpoint pre-loads the
/// shared-schema library (backed by <c>ISharedSchemaRepository</c>) and supplies it
@@ -46,7 +46,7 @@ public static class ParameterValidator
string? parameterDefinitions,
Func<string, string?>? resolveRef = null)
{
// M9-T32b: parse through the ref-COLLECTING path. A {"$ref":"lib:Name"} that the
// Parse through the ref-COLLECTING path. A {"$ref":"lib:Name"} that the
// resolver can satisfy is resolved inline; a dangling/cyclic/over-depth ref is
// collected (not thrown) so the runtime returns a descriptive "could not be
// resolved" message instead of an opaque "Invalid parameter definitions" — the
@@ -119,7 +119,7 @@ public static class ParameterValidator
}
/// <summary>
/// M9-T32b: renders the unresolved <c>{"$ref":"lib:Name"}</c> references for a clear,
/// Renders the unresolved <c>{"$ref":"lib:Name"}</c> references for a clear,
/// descriptive runtime error — the bare <c>lib:</c>-qualified pointer name stays
/// separate from any parenthesised reason (cyclic/over-depth), so the message reads
/// e.g. <c>schema(s) 'lib:Foo' (cyclic reference)</c> rather than embedding the
@@ -137,7 +137,7 @@ public static class ParameterValidator
/// arrays to <see cref="List{T}"/>.
///
/// <para>
/// InboundAPI-#55: coercion is RECURSIVE. Nested object fields and list
/// Coercion is RECURSIVE. Nested object fields and list
/// elements are coerced to typed CLR values too (string/long/double/bool,
/// or nested Dictionary/List), so NO raw <see cref="JsonElement"/> survives
/// anywhere in the returned graph. Previously the <c>object</c> case used
@@ -254,7 +254,7 @@ public static class ParameterValidator
{
// Declared object/array element: recurse via Materialize so each
// element follows its declared (nested) schema — nested scalars
// become typed CLR values, never raw JsonElement (#55).
// become typed CLR values, never raw JsonElement.
var list = new List<object?>(element.GetArrayLength());
foreach (var e in element.EnumerateArray()) list.Add(Materialize(e, items));
return list;
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// InboundAPI-014: validates a method script's return value against the method's
/// Validates a method script's return value against the method's
/// declared <c>ReturnDefinition</c>. <c>Component-InboundAPI.md</c> ("Return Value
/// Definition" / "Response Format") states the success body has "fields matching
/// the return value definition"; this is the response-side mirror of
@@ -18,7 +18,7 @@ namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// </para>
///
/// <para>
/// InboundAPI-M2.6: validation is RECURSIVE and type-aware — declared object
/// Validation is RECURSIVE and type-aware — declared object
/// fields are validated against their declared (nested) types, list elements
/// against the declared element type, and scalars at any depth — with
/// path-qualified errors. The recursion is shared with
@@ -37,7 +37,7 @@ public static class ReturnValueValidator
/// <param name="resultJson">The JSON-serialized script return value to validate.</param>
/// <param name="returnDefinition">JSON Schema describing the method's return value, or null/empty to skip validation. The legacy flat-array form is also accepted.</param>
/// <param name="resolveRef">
/// M9-T32b: optional JSON-Schema <c>$ref</c> resolution seam mapping a
/// Optional JSON-Schema <c>$ref</c> resolution seam mapping a
/// <c>{"$ref":"lib:Name"}</c> reference target's name to the referenced schema JSON
/// (or <c>null</c> when the library entry does not exist). The executor pre-loads the
/// shared-schema library (backed by <c>ISharedSchemaRepository</c>) and supplies it
@@ -58,7 +58,7 @@ public static class ReturnValueValidator
return ReturnValidationResult.Valid();
}
// M9-T32b: parse through the ref-COLLECTING path so a {"$ref":"lib:Name"} the
// Parse through the ref-COLLECTING path so a {"$ref":"lib:Name"} the
// resolver can satisfy is resolved inline, and a dangling/cyclic/over-depth ref is
// surfaced as a descriptive "could not be resolved" message rather than an opaque
// "Invalid return definition" from a swallowed JsonException.
@@ -5,11 +5,11 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
/// Route.To() helper for cross-site calls from inbound API scripts.
/// Resolves instance to site, routes via <see cref="IInstanceRouter"/>, blocks until
/// response or timeout. Site unreachable returns error (no store-and-forward).
///
/// InboundAPI-016: the helper carries the executing method's <see cref="CancellationToken"/>
/// The helper carries the executing method's <see cref="CancellationToken"/>
/// (the method-level timeout). Routed calls inherit that deadline by default, so a
/// natural script — <c>Route.To("inst").Call("doWork", p)</c> — is timeout-bounded
/// without the script having to thread a token explicitly.
@@ -49,7 +49,7 @@ public class RouteHelper
}
/// <summary>
/// InboundAPI-016: returns a <see cref="RouteHelper"/> whose routed calls inherit
/// Returns a <see cref="RouteHelper"/> whose routed calls inherit
/// <paramref name="deadlineToken"/> (the executing method's timeout) by default.
/// <see cref="InboundScriptExecutor"/> calls this when it builds the script
/// context so the method timeout actually covers routed calls, as the design doc
@@ -61,7 +61,7 @@ public class RouteHelper
new(_instanceLocator, _instanceRouter, deadlineToken, _requestAbortedToken, _parentExecutionId);
/// <summary>
/// InboundAPI-029: returns a <see cref="RouteHelper"/> carrying the raw request-abort
/// Returns a <see cref="RouteHelper"/> carrying the raw request-abort
/// token (a client disconnect) <em>separately</em> from the method deadline. Most
/// routed calls remain bounded by the method deadline (which already incorporates the
/// abort), but <see cref="RouteTarget.WaitForAttribute"/> uses this token so its wait
@@ -74,7 +74,7 @@ public class RouteHelper
new(_instanceLocator, _instanceRouter, _deadlineToken, requestAbortedToken, _parentExecutionId);
/// <summary>
/// Audit Log #23 (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
/// Audit Log (ParentExecutionId): returns a <see cref="RouteHelper"/> whose
/// routed <see cref="RouteTarget.Call"/> requests carry
/// <paramref name="parentExecutionId"/> as <see cref="RouteToCallRequest.ParentExecutionId"/>.
/// For an inbound API request this is the inbound request's own per-request
@@ -100,7 +100,7 @@ public class RouteHelper
}
/// <summary>
/// WP-4: Represents a route target (an instance) for cross-site calls.
/// Represents a route target (an instance) for cross-site calls.
/// </summary>
public class RouteTarget
{
@@ -118,7 +118,7 @@ public class RouteTarget
/// <param name="instanceLocator">Service to resolve the site id for the instance.</param>
/// <param name="instanceRouter">Service to route cross-site calls.</param>
/// <param name="deadlineToken">Cancellation token representing the method-level deadline.</param>
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout (InboundAPI-029).</param>
/// <param name="requestAbortedToken">Raw client-disconnect token, independent of the method timeout.</param>
/// <param name="parentExecutionId">Optional parent execution id for audit correlation on routed calls.</param>
internal RouteTarget(
string instanceCode,
@@ -141,7 +141,7 @@ public class RouteTarget
/// perspective. <paramref name="parameters"/> may be a dictionary or an
/// anonymous object (<c>new { name = "Bob" }</c>) — see <see cref="ScriptArgs"/>.
///
/// InboundAPI-016: when <paramref name="cancellationToken"/> is not supplied the
/// When <paramref name="cancellationToken"/> is not supplied the
/// routed call inherits the executing method's timeout, so the call is bounded by
/// the method-level deadline with no token argument.
/// </summary>
@@ -158,7 +158,7 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): stamp the spawning execution's id
// Audit Log (ParentExecutionId): stamp the spawning execution's id
// (the inbound API request's ExecutionId) so the routed site script
// records this call's parent. CorrelationId above is a separate concern
// — the per-operation lifecycle id, freshly minted per routed call.
@@ -205,7 +205,7 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// Audit Log (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed reads can record this read's parent. Symmetric
// with RouteToCallRequest so script authors get the same correlation
@@ -233,8 +233,8 @@ public class RouteTarget
/// comparison.
///
/// <para>
/// InboundAPI-029: unlike the other routed calls (which inherit the method deadline
/// per InboundAPI-016), the wait is bounded by <paramref name="timeout"/> — the WAIT
/// Unlike the other routed calls (which inherit the method deadline
/// by default), the wait is bounded by <paramref name="timeout"/> — the WAIT
/// timeout — NOT the generic method deadline. The SITE enforces <paramref name="timeout"/>
/// and returns <c>Matched=false</c> when it elapses; the local token is built from a
/// per-wait CTS (the wait timeout plus a small grace), an explicit caller token, and
@@ -254,12 +254,12 @@ public class RouteTarget
TimeSpan timeout,
CancellationToken cancellationToken = default)
{
// InboundAPI-031: do NOT impose a local wait-timeout backstop here. The site
// Do NOT impose a local wait-timeout backstop here. The site
// enforces the wait `timeout` and returns Matched=false when it elapses, and the
// cluster Ask in CommunicationService.RouteToWaitForAttributeAsync already bounds
// the round trip by `timeout + IntegrationTimeout` — the authoritative backstop
// for a missing site response. A local CTS of `timeout + small grace` (the prior
// InboundAPI-029 approach) was TIGHTER than that round-trip budget, so a
// approach) was TIGHTER than that round-trip budget, so a
// slow-but-valid timed-out response could be cancelled into an exception instead
// of the spec-mandated `false`. Link ONLY the client-disconnect token and an
// explicit caller token — NOT the method deadline — so a client abort still
@@ -269,7 +269,7 @@ public class RouteTarget
var token = linked.Token;
var siteId = await ResolveSiteAsync(token);
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// Audit Log (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed waits can record this wait's parent. CorrelationId
// is the per-operation lifecycle id, freshly minted per routed wait.
@@ -320,7 +320,7 @@ public class RouteTarget
var siteId = await ResolveSiteAsync(token);
var correlationId = Guid.NewGuid().ToString();
// Audit Log #23 (ParentExecutionId): mirrors the Call path — stamp the
// Audit Log (ParentExecutionId): mirrors the Call path — stamp the
// spawning inbound request's ExecutionId so future site-side audit
// emission for routed writes can record this write's parent. Symmetric
// with RouteToCallRequest so script authors get the same correlation
@@ -339,7 +339,7 @@ public class RouteTarget
}
/// <summary>
/// InboundAPI-016: a routed call with no explicit token inherits the executing
/// A routed call with no explicit token inherits the executing
/// method's deadline. An explicitly supplied token (for a tighter bound) wins.
/// </summary>
private CancellationToken Effective(CancellationToken explicitToken) =>
@@ -4,7 +4,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
namespace ZB.MOM.WW.ScadaBridge.InboundAPI;
/// <summary>
/// M9-T32b: builds the synchronous JSON-Schema <c>$ref</c> resolution seam the
/// Builds the synchronous JSON-Schema <c>$ref</c> resolution seam the
/// InboundAPI RUNTIME validators (<see cref="ParameterValidator"/> /
/// <see cref="ReturnValueValidator"/>) consume, backed by the central shared-schema
/// library (<see cref="ISharedSchemaRepository"/>).
@@ -11,7 +11,7 @@ public static class ServiceCollectionExtensions
/// <returns>The same <see cref="IServiceCollection"/> to allow chaining.</returns>
public static IServiceCollection AddInboundAPI(this IServiceCollection services)
{
// Auth re-arch (C5): inbound authentication is handled by the shared
// Inbound authentication is handled by the shared
// ZB.MOM.WW.Auth.ApiKeys verifier (Bearer sbk_<keyId>_<secret>), registered by
// AddZbApiKeyAuth in the Host composition root. The legacy ApiKeyValidator +
// peppered IApiKeyHasher and the SQL Server ApiKey store were retired, so this
@@ -19,11 +19,11 @@ public static class ServiceCollectionExtensions
services.AddSingleton<InboundScriptExecutor>();
services.AddScoped<RouteHelper>();
// InboundAPI-017: routed calls go through the IInstanceRouter seam; the
// Routed calls go through the IInstanceRouter seam; the
// production implementation delegates to CommunicationService.
services.AddScoped<IInstanceRouter, CommunicationServiceInstanceRouter>();
// InboundAPI-006 / InboundAPI-008: endpoint filter enforcing the request
// Endpoint filter enforcing the request
// body size cap and active-node gating for POST /api/{methodName}.
services.AddSingleton<InboundApiEndpointFilter>();