fix(inbound-api): resolve InboundAPI-014..017 — return-value validation, reflection-gateway hardening, deadline-bound routed calls, RouteHelper test coverage
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Communication;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IInstanceRouter"/> implementation. Delegates every routed
|
||||
/// call to <see cref="CommunicationService"/>, which dispatches to the target
|
||||
/// site cluster via the central communication actor.
|
||||
/// </summary>
|
||||
public sealed class CommunicationServiceInstanceRouter : IInstanceRouter
|
||||
{
|
||||
private readonly CommunicationService _communicationService;
|
||||
|
||||
public CommunicationServiceInstanceRouter(CommunicationService communicationService)
|
||||
{
|
||||
_communicationService = communicationService;
|
||||
}
|
||||
|
||||
public Task<RouteToCallResponse> RouteToCallAsync(
|
||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken) =>
|
||||
_communicationService.RouteToCallAsync(siteId, request, cancellationToken);
|
||||
|
||||
public Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken) =>
|
||||
_communicationService.RouteToGetAttributesAsync(siteId, request, cancellationToken);
|
||||
|
||||
public Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken) =>
|
||||
_communicationService.RouteToSetAttributesAsync(siteId, request, cancellationToken);
|
||||
}
|
||||
@@ -15,6 +15,21 @@ namespace ScadaLink.InboundAPI;
|
||||
/// sandbox — so a script can fully-qualify any referenced type. This static check
|
||||
/// walks the script syntax tree and rejects any reference to a forbidden namespace,
|
||||
/// whether reached through a <c>using</c> directive or a fully-qualified name.
|
||||
///
|
||||
/// <para>
|
||||
/// InboundAPI-015: 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 walker therefore
|
||||
/// also rejects a curated set of reflection-gateway member names (<c>GetType</c>,
|
||||
/// <c>Assembly</c>, <c>GetMethod</c>, <c>InvokeMember</c>, <c>CreateInstance</c>, …)
|
||||
/// and the <c>dynamic</c> keyword. This is hardening of a best-effort static check,
|
||||
/// <strong>not</strong> a true sandbox — a determined script author may still find
|
||||
/// a vector the syntax walker cannot see (see the security notes in
|
||||
/// <c>code-reviews/InboundAPI/findings.md</c>, InboundAPI-015). The check is
|
||||
/// defence-in-depth; genuine containment needs a runtime boundary (restricted
|
||||
/// <c>AssemblyLoadContext</c> / curated reference set / out-of-process sandbox).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ForbiddenApiChecker
|
||||
{
|
||||
@@ -42,6 +57,40 @@ public static class ForbiddenApiChecker
|
||||
"System.Threading.Tasks",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-015: member names that are reflection gateways. Reaching any of
|
||||
/// these — even off a permitted type such as <c>typeof(string)</c> or a plain
|
||||
/// <c>string</c> — lets a script escape the namespace deny-list (obtain an
|
||||
/// arbitrary <c>Type</c>, load an assembly, late-bind a method). They are
|
||||
/// rejected regardless of the receiver expression. <c>Invoke</c> is deliberately
|
||||
/// excluded because <c>Action</c>/<c>Func</c> delegate invocation is legitimate;
|
||||
/// the reflection <c>MethodInfo.Invoke</c> path is already cut off by rejecting
|
||||
/// the <c>GetMethod</c>/<c>GetConstructor</c> that produces the <c>MethodInfo</c>.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ForbiddenMemberNames = new(StringComparer.Ordinal)
|
||||
{
|
||||
"GetType", // object.GetType() / Type.GetType(string) — yields a System.Type
|
||||
"GetTypeInfo", // -> TypeInfo (reflection)
|
||||
"Assembly", // Type.Assembly — yields a System.Reflection.Assembly
|
||||
"Module", // Type.Module / MethodBase.Module
|
||||
"CreateInstance", // Activator.CreateInstance / Assembly.CreateInstance
|
||||
"InvokeMember", // Type.InvokeMember — late-bound dispatch
|
||||
"GetMethod",
|
||||
"GetMethods",
|
||||
"GetConstructor",
|
||||
"GetConstructors",
|
||||
"GetField",
|
||||
"GetFields",
|
||||
"GetProperty",
|
||||
"GetProperties",
|
||||
"GetMember",
|
||||
"GetMembers",
|
||||
"GetRuntimeMethod",
|
||||
"GetRuntimeMethods",
|
||||
"MethodHandle", // RuntimeMethodHandle escape
|
||||
"TypeHandle",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Analyses the script source and returns the list of trust-model violations.
|
||||
/// An empty list means the script is acceptable.
|
||||
@@ -115,7 +164,42 @@ public static class ForbiddenApiChecker
|
||||
return;
|
||||
}
|
||||
|
||||
// InboundAPI-015: reject reflection-gateway members regardless of the
|
||||
// receiver. typeof(string).Assembly.GetType("System.IO.File") never
|
||||
// spells a forbidden namespace, but '.Assembly' and '.GetType' do
|
||||
// appear here as the accessed member name.
|
||||
var memberName = node.Name.Identifier.ValueText;
|
||||
if (ForbiddenMemberNames.Contains(memberName))
|
||||
{
|
||||
_violations.Add($"forbidden reflection member access '.{memberName}'");
|
||||
// Still descend: the receiver may contain a further violation.
|
||||
}
|
||||
|
||||
base.VisitMemberAccessExpression(node);
|
||||
}
|
||||
|
||||
public override void VisitIdentifierName(IdentifierNameSyntax node)
|
||||
{
|
||||
// InboundAPI-015: 'dynamic' widens late-bound member access that the
|
||||
// static walker cannot see through — reject its use outright. The
|
||||
// 'dynamic' contextual keyword surfaces as an identifier name.
|
||||
if (node.Identifier.ValueText == "dynamic")
|
||||
{
|
||||
_violations.Add("forbidden use of the 'dynamic' keyword");
|
||||
return;
|
||||
}
|
||||
|
||||
// InboundAPI-015: a bare reference to the reflection entry-point types
|
||||
// (e.g. 'Activator', 'Type') as an identifier. 'Activator' has no
|
||||
// non-reflection use; flag it. ('Type' as an identifier is too broad
|
||||
// to flag here — the gateway members above already cut off its use.)
|
||||
if (node.Identifier.ValueText == "Activator")
|
||||
{
|
||||
_violations.Add("forbidden reflection type reference 'Activator'");
|
||||
return;
|
||||
}
|
||||
|
||||
base.VisitIdentifierName(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
22
src/ScadaLink.InboundAPI/IInstanceRouter.cs
Normal file
22
src/ScadaLink.InboundAPI/IInstanceRouter.cs
Normal file
@@ -0,0 +1,22 @@
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// Seam over the cross-site routing transport used by <see cref="RouteHelper"/>.
|
||||
/// The production implementation (<see cref="CommunicationServiceInstanceRouter"/>)
|
||||
/// delegates to <c>ScadaLink.Communication.CommunicationService</c>; the interface
|
||||
/// exists so <see cref="RouteHelper"/>/<see cref="RouteTarget"/> can be unit tested
|
||||
/// without a live actor system (InboundAPI-017).
|
||||
/// </summary>
|
||||
public interface IInstanceRouter
|
||||
{
|
||||
Task<RouteToCallResponse> RouteToCallAsync(
|
||||
string siteId, RouteToCallRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<RouteToGetAttributesResponse> RouteToGetAttributesAsync(
|
||||
string siteId, RouteToGetAttributesRequest request, CancellationToken cancellationToken);
|
||||
|
||||
Task<RouteToSetAttributesResponse> RouteToSetAttributesAsync(
|
||||
string siteId, RouteToSetAttributesRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -174,7 +174,10 @@ public class InboundScriptExecutor
|
||||
|
||||
try
|
||||
{
|
||||
var context = new InboundScriptContext(parameters, route, cts.Token);
|
||||
// InboundAPI-016: 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.
|
||||
var context = new InboundScriptContext(parameters, route.WithDeadline(cts.Token), cts.Token);
|
||||
|
||||
if (!_scriptHandlers.TryGetValue(method.Name, out var handler))
|
||||
{
|
||||
@@ -202,6 +205,19 @@ public class InboundScriptExecutor
|
||||
? JsonSerializer.Serialize(result)
|
||||
: null;
|
||||
|
||||
// InboundAPI-014: 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.
|
||||
var returnValidation = ReturnValueValidator.Validate(resultJson, method.ReturnDefinition);
|
||||
if (!returnValidation.IsValid)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"API method {Method} return value rejected: {Error}",
|
||||
method.Name, returnValidation.ErrorMessage);
|
||||
return new InboundScriptResult(false, null, "Method return value did not match its return definition");
|
||||
}
|
||||
|
||||
return new InboundScriptResult(true, resultJson, null);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
|
||||
144
src/ScadaLink.InboundAPI/ReturnValueValidator.cs
Normal file
144
src/ScadaLink.InboundAPI/ReturnValueValidator.cs
Normal file
@@ -0,0 +1,144 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014: 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
|
||||
/// <see cref="ParameterValidator"/>.
|
||||
///
|
||||
/// <para>
|
||||
/// The return definition is a JSON array of <see cref="ReturnFieldDefinition"/>
|
||||
/// (the same <c>{name,type}</c> shape as a parameter definition). A method whose
|
||||
/// <c>ReturnDefinition</c> is null/empty is unconstrained — its return value is
|
||||
/// serialized as-is (backward compatible). Primitive fields (Boolean / Integer /
|
||||
/// Float / String) are type-checked; the extended <c>Object</c>/<c>List</c> types
|
||||
/// are shape-checked only (object vs. array), consistent with how
|
||||
/// <see cref="ParameterValidator"/> treats inbound extended types.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public static class ReturnValueValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the serialized script result JSON against the method's return
|
||||
/// definition. Returns <see cref="ReturnValidationResult.Valid"/> when no
|
||||
/// definition is configured or the result conforms to it.
|
||||
/// </summary>
|
||||
public static ReturnValidationResult Validate(string? resultJson, string? returnDefinition)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(returnDefinition))
|
||||
{
|
||||
// No declared return shape — the script's return value is unconstrained.
|
||||
return ReturnValidationResult.Valid();
|
||||
}
|
||||
|
||||
List<ReturnFieldDefinition> fields;
|
||||
try
|
||||
{
|
||||
fields = JsonSerializer.Deserialize<List<ReturnFieldDefinition>>(
|
||||
returnDefinition,
|
||||
new JsonSerializerOptions { PropertyNameCaseInsensitive = true })
|
||||
?? [];
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ReturnValidationResult.Invalid(
|
||||
"Invalid return definition in method configuration");
|
||||
}
|
||||
|
||||
if (fields.Count == 0)
|
||||
{
|
||||
return ReturnValidationResult.Valid();
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(resultJson))
|
||||
{
|
||||
return ReturnValidationResult.Invalid(
|
||||
"Method declares a return structure but the script returned no value");
|
||||
}
|
||||
|
||||
JsonElement root;
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(resultJson);
|
||||
root = doc.RootElement.Clone();
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return ReturnValidationResult.Invalid("Script return value is not valid JSON");
|
||||
}
|
||||
|
||||
if (root.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return ReturnValidationResult.Invalid(
|
||||
"Method declares a return structure but the script did not return an object");
|
||||
}
|
||||
|
||||
var errors = new List<string>();
|
||||
foreach (var field in fields)
|
||||
{
|
||||
if (!root.TryGetProperty(field.Name, out var value))
|
||||
{
|
||||
errors.Add($"missing return field '{field.Name}'");
|
||||
continue;
|
||||
}
|
||||
|
||||
var typeError = CheckFieldType(value, field.Type, field.Name);
|
||||
if (typeError != null)
|
||||
errors.Add(typeError);
|
||||
}
|
||||
|
||||
return errors.Count > 0
|
||||
? ReturnValidationResult.Invalid(
|
||||
$"Return value does not match the declared return definition: {string.Join("; ", errors)}")
|
||||
: ReturnValidationResult.Valid();
|
||||
}
|
||||
|
||||
private static string? CheckFieldType(JsonElement value, string declaredType, string fieldName)
|
||||
{
|
||||
// A null value satisfies any field type — the script may legitimately omit
|
||||
// optional data; only a missing field (handled by the caller) is an error.
|
||||
if (value.ValueKind == JsonValueKind.Null)
|
||||
return null;
|
||||
|
||||
var ok = declaredType.ToLowerInvariant() switch
|
||||
{
|
||||
"boolean" => value.ValueKind is JsonValueKind.True or JsonValueKind.False,
|
||||
"integer" => value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out _),
|
||||
"float" => value.ValueKind == JsonValueKind.Number,
|
||||
"string" => value.ValueKind == JsonValueKind.String,
|
||||
"object" => value.ValueKind == JsonValueKind.Object,
|
||||
"list" => value.ValueKind == JsonValueKind.Array,
|
||||
_ => true, // unknown declared type — do not block the response
|
||||
};
|
||||
|
||||
return ok ? null : $"return field '{fieldName}' must be {declaredType}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-014: one field of a method's declared return structure — the
|
||||
/// deserialized form of an entry in <c>ApiMethod.ReturnDefinition</c>. Defined in
|
||||
/// this module (not Commons) because the inbound API is currently its only consumer.
|
||||
/// </summary>
|
||||
public sealed class ReturnFieldDefinition
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = "String";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Result of validating a script return value against a method's return definition.
|
||||
/// </summary>
|
||||
public sealed class ReturnValidationResult
|
||||
{
|
||||
public bool IsValid { get; private init; }
|
||||
public string ErrorMessage { get; private init; } = string.Empty;
|
||||
|
||||
public static ReturnValidationResult Valid() => new() { IsValid = true };
|
||||
|
||||
public static ReturnValidationResult Invalid(string message) =>
|
||||
new() { IsValid = false, ErrorMessage = message };
|
||||
}
|
||||
@@ -1,34 +1,58 @@
|
||||
using ScadaLink.Commons.Interfaces.Services;
|
||||
using ScadaLink.Commons.Messages.InboundApi;
|
||||
using ScadaLink.Commons.Types;
|
||||
using ScadaLink.Communication;
|
||||
|
||||
namespace ScadaLink.InboundAPI;
|
||||
|
||||
/// <summary>
|
||||
/// WP-4: Route.To() helper for cross-site calls from inbound API scripts.
|
||||
/// Resolves instance to site, routes via CommunicationService, blocks until response or timeout.
|
||||
/// Site unreachable returns error (no store-and-forward).
|
||||
/// 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 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.
|
||||
/// </summary>
|
||||
public class RouteHelper
|
||||
{
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly CommunicationService _communicationService;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
|
||||
public RouteHelper(
|
||||
IInstanceLocator instanceLocator,
|
||||
CommunicationService communicationService)
|
||||
IInstanceRouter instanceRouter)
|
||||
: this(instanceLocator, instanceRouter, CancellationToken.None)
|
||||
{
|
||||
}
|
||||
|
||||
private RouteHelper(
|
||||
IInstanceLocator instanceLocator,
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken)
|
||||
{
|
||||
_instanceLocator = instanceLocator;
|
||||
_communicationService = communicationService;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-016: 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
|
||||
/// requires.
|
||||
/// </summary>
|
||||
public RouteHelper WithDeadline(CancellationToken deadlineToken) =>
|
||||
new(_instanceLocator, _instanceRouter, deadlineToken);
|
||||
|
||||
/// <summary>
|
||||
/// Creates a route target for the specified instance.
|
||||
/// </summary>
|
||||
public RouteTarget To(string instanceCode)
|
||||
{
|
||||
return new RouteTarget(instanceCode, _instanceLocator, _communicationService);
|
||||
return new RouteTarget(instanceCode, _instanceLocator, _instanceRouter, _deadlineToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,36 +63,43 @@ public class RouteTarget
|
||||
{
|
||||
private readonly string _instanceCode;
|
||||
private readonly IInstanceLocator _instanceLocator;
|
||||
private readonly CommunicationService _communicationService;
|
||||
private readonly IInstanceRouter _instanceRouter;
|
||||
private readonly CancellationToken _deadlineToken;
|
||||
|
||||
internal RouteTarget(
|
||||
string instanceCode,
|
||||
IInstanceLocator instanceLocator,
|
||||
CommunicationService communicationService)
|
||||
IInstanceRouter instanceRouter,
|
||||
CancellationToken deadlineToken)
|
||||
{
|
||||
_instanceCode = instanceCode;
|
||||
_instanceLocator = instanceLocator;
|
||||
_communicationService = communicationService;
|
||||
_instanceRouter = instanceRouter;
|
||||
_deadlineToken = deadlineToken;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calls a script on the remote instance. Synchronous from API caller's
|
||||
/// 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
|
||||
/// routed call inherits the executing method's timeout, so the call is bounded by
|
||||
/// the method-level deadline with no token argument.
|
||||
/// </summary>
|
||||
public async Task<object?> Call(
|
||||
string scriptName,
|
||||
object? parameters = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var token = Effective(cancellationToken);
|
||||
var siteId = await ResolveSiteAsync(token);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToCallRequest(
|
||||
correlationId, _instanceCode, scriptName, ScriptArgs.Normalize(parameters), DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToCallAsync(
|
||||
siteId, request, cancellationToken);
|
||||
var response = await _instanceRouter.RouteToCallAsync(siteId, request, token);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
@@ -97,14 +128,14 @@ public class RouteTarget
|
||||
IEnumerable<string> attributeNames,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var token = Effective(cancellationToken);
|
||||
var siteId = await ResolveSiteAsync(token);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToGetAttributesRequest(
|
||||
correlationId, _instanceCode, attributeNames.ToList(), DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToGetAttributesAsync(
|
||||
siteId, request, cancellationToken);
|
||||
var response = await _instanceRouter.RouteToGetAttributesAsync(siteId, request, token);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
@@ -135,14 +166,14 @@ public class RouteTarget
|
||||
IReadOnlyDictionary<string, string> attributeValues,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var siteId = await ResolveSiteAsync(cancellationToken);
|
||||
var token = Effective(cancellationToken);
|
||||
var siteId = await ResolveSiteAsync(token);
|
||||
var correlationId = Guid.NewGuid().ToString();
|
||||
|
||||
var request = new RouteToSetAttributesRequest(
|
||||
correlationId, _instanceCode, attributeValues, DateTimeOffset.UtcNow);
|
||||
|
||||
var response = await _communicationService.RouteToSetAttributesAsync(
|
||||
siteId, request, cancellationToken);
|
||||
var response = await _instanceRouter.RouteToSetAttributesAsync(siteId, request, token);
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
@@ -151,6 +182,13 @@ public class RouteTarget
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// InboundAPI-016: 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) =>
|
||||
explicitToken.CanBeCanceled ? explicitToken : _deadlineToken;
|
||||
|
||||
private async Task<string> ResolveSiteAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var siteId = await _instanceLocator.GetSiteIdForInstanceAsync(_instanceCode, cancellationToken);
|
||||
|
||||
@@ -10,6 +10,10 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<InboundScriptExecutor>();
|
||||
services.AddScoped<RouteHelper>();
|
||||
|
||||
// InboundAPI-017: 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
|
||||
// body size cap and active-node gating for POST /api/{methodName}.
|
||||
services.AddSingleton<InboundApiEndpointFilter>();
|
||||
|
||||
Reference in New Issue
Block a user