Router · ASP.NET Endpoint Bridge

Status


Problem Statement

StellaOps Router routes external HTTP requests (via Gateway) to internal microservices over binary transports (TCP/TLS/Messaging). Most StellaOps microservices are ASP.NET Core WebServices that register routes via standard ASP.NET endpoint registration (controllers or minimal APIs).

Current behavior: StellaOps.Microservice discovers and registers only Router-native endpoints implemented via [StellaEndpoint] handlers. If a service has only ASP.NET endpoints, it either:

Desired behavior: Treat ASP.NET endpoint registration as the single source of truth and automatically bridge it to Router endpoint registration.


Design Goals

GoalDescription
Single source of truthASP.NET endpoint registration defines what the service exposes
Full ASP.NET fidelityAuthorization, filters, model binding, and all ASP.NET features work correctly
DeterminismEndpoint discovery produces stable ordering and normalized paths
Security alignmentAuthorization metadata flows to Router RequiringClaims
Opt-in integrationServices explicitly enable the bridge via Program.cs
Offline-firstNo runtime network dependency for discovery/dispatch

Non-Goals (v0.1)

FeatureReason
SignalR/WebSocket supportDifferent protocol semantics
gRPC endpoint bridgingDifferent protocol
Streaming request bodiesRouter SDK buffering limitation
Custom route constraintsComplexity; document as limitation
API versioning (header/query)Complexity; use path-based versioning
Automatic schema generationDepends on source generators; future enhancement

Architecture Overview

┌─────────────────────────────────────────────────────────────────────┐
│                         ASP.NET WebService                          │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │  Program.cs                                                    │  │
│  │  ┌─────────────────────────────────────────────────────────┐  │  │
│  │  │  builder.Services.AddStellaRouterBridge(options => {    │  │  │
│  │  │      options.ServiceName = "scanner";                   │  │  │
│  │  │      options.Version = "1.0.0";                         │  │  │
│  │  │  });                                                    │  │  │
│  │  │  app.UseStellaRouterBridge();                           │  │  │
│  │  └─────────────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────────────┘  │
│                                                                     │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │  StellaOps.Microservice.AspNetCore                            │  │
│  │  ┌─────────────────────┐  ┌─────────────────────────────────┐ │  │
│  │  │ Discovery Provider  │  │ Request Dispatcher              │ │  │
│  │  │ ─────────────────── │  │ ─────────────────────────────── │ │  │
│  │  │ • EndpointDataSource│  │ • RequestFrame → HttpContext    │ │  │
│  │  │ • Metadata extraction│  │ • ASP.NET pipeline execution   │ │  │
│  │  │ • Route normalization│  │ • ResponseFrame capture        │ │  │
│  │  │ • Auth claim mapping │  │ • DI scope management          │ │  │
│  │  └──────────┬──────────┘  └──────────────┬──────────────────┘ │  │
│  │             │                            │                     │  │
│  │             ▼                            ▼                     │  │
│  │  ┌─────────────────────────────────────────────────────────┐  │  │
│  │  │  Router SDK (StellaOps.Microservice)                    │  │  │
│  │  │  • HELLO registration with discovered endpoints         │  │  │
│  │  │  • Request routing to dispatcher                        │  │  │
│  │  └─────────────────────────────────────────────────────────┘  │  │
│  └───────────────────────────────────────────────────────────────┘  │
│                                                                     │
│  ┌───────────────────────────────────────────────────────────────┐  │
│  │  ASP.NET Endpoints (unchanged)                                │  │
│  │  • Minimal APIs: app.MapGet("/api/...", handler)              │  │
│  │  • Controllers: [ApiController] with [HttpGet], etc.          │  │
│  │  • Route groups: app.MapGroup("/api").MapScannerEndpoints()   │  │
│  └───────────────────────────────────────────────────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘
                                    │
                                    │ Router Transport (TCP/TLS/Messaging)
                                    ▼
                    ┌───────────────────────────────┐
                    │  StellaOps.Gateway.WebService │
                    │  • HELLO processing           │
                    │  • Endpoint routing           │
                    │  • OpenAPI aggregation        │
                    └───────────────────────────────┘

Component Design

1. StellaRouterBridgeOptions

Configuration for the bridge, specified in Program.cs:

public sealed class StellaRouterBridgeOptions
{
    // === Required: Service Identity ===
    public required string ServiceName { get; set; }
    public required string Version { get; set; }
    public required string Region { get; set; }
    public string? InstanceId { get; set; } // Auto-generated if null

    // === Authorization Mapping ===
    public AuthorizationMappingStrategy AuthorizationMapping { get; set; }
        = AuthorizationMappingStrategy.Hybrid;
    public MissingAuthorizationBehavior OnMissingAuthorization { get; set; }
        = MissingAuthorizationBehavior.RequireExplicit;

    // === YAML Overrides ===
    public string? YamlConfigPath { get; set; }

    // === Metadata Extraction ===
    public bool ExtractSchemas { get; set; } = true;
    public bool ExtractOpenApiMetadata { get; set; } = true;

    // === Route Handling ===
    public UnsupportedConstraintBehavior OnUnsupportedConstraint { get; set; }
        = UnsupportedConstraintBehavior.WarnAndStrip;
    public Func<RouteEndpoint, bool>? EndpointFilter { get; set; }

    // === Publication carve-out (see "Excluded path prefixes" below) ===
    public IList<string> ExcludedPathPrefixes { get; set; } = new List<string>
    {
        "/health", "/healthz", "/readyz", "/livez",
        "/metrics", "/swagger", "/openapi", "/.well-known/openapi"
    };
    public bool IncludeExcludedPathsInRouter { get; set; }

    // === Defaults ===
    public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30);
}

public enum AuthorizationMappingStrategy
{
    YamlOnly,           // Only use YAML overrides
    AspNetMetadataOnly, // Only use ASP.NET metadata
    Hybrid              // ASP.NET + YAML (YAML wins on conflict)
}

public enum MissingAuthorizationBehavior
{
    RequireExplicit,    // Fail if no auth metadata
    AllowAuthenticated, // Allow with empty claims (authenticated required)
    WarnAndAllow        // Log warning, allow with empty claims
}

public enum UnsupportedConstraintBehavior
{
    Fail,           // Fail discovery on unsupported constraint
    WarnAndStrip,   // Log warning, strip constraint
    SilentStrip     // Strip without warning
}

2. AspNetEndpointDescriptor

Extended endpoint descriptor with full ASP.NET metadata:

public sealed record AspNetEndpointDescriptor
{
    // === Core Identity ===
    public required string ServiceName { get; init; }
    public required string Version { get; init; }
    public required string Method { get; init; }
    public required string Path { get; init; }
    public TimeSpan DefaultTimeout { get; init; } = TimeSpan.FromSeconds(30);
    public bool SupportsStreaming { get; init; }

    // === Authorization ===
    public IReadOnlyList<ClaimRequirement> RequiringClaims { get; init; } = [];
    public IReadOnlyList<string> AuthorizationPolicies { get; init; } = [];
    public IReadOnlyList<string> Roles { get; init; } = [];
    public bool AllowAnonymous { get; init; }
    public AuthorizationSource AuthorizationSource { get; init; }

    // === Parameters ===
    public IReadOnlyList<ParameterDescriptor> Parameters { get; init; } = [];

    // === Responses ===
    public IReadOnlyList<ResponseDescriptor> Responses { get; init; } = [];

    // === OpenAPI ===
    public string? OperationId { get; init; }
    public string? Summary { get; init; }
    public string? Description { get; init; }
    public IReadOnlyList<string> Tags { get; init; } = [];

    // === Schema ===
    public EndpointSchemaInfo? SchemaInfo { get; init; }

    // === Internal ===
    internal RouteEndpoint? OriginalEndpoint { get; init; }
    internal string? OriginalRoutePattern { get; init; }

    /// <summary>
    /// Convert to standard EndpointDescriptor for HELLO payload.
    /// </summary>
    public EndpointDescriptor ToEndpointDescriptor() => new()
    {
        ServiceName = ServiceName,
        Version = Version,
        Method = Method,
        Path = Path,
        DefaultTimeout = DefaultTimeout,
        SupportsStreaming = SupportsStreaming,
        RequiringClaims = RequiringClaims,
        SchemaInfo = SchemaInfo
    };
}

public sealed record ParameterDescriptor
{
    public required string Name { get; init; }
    public required ParameterSource Source { get; init; }
    public required Type Type { get; init; }
    public bool IsRequired { get; init; } = true;
    public object? DefaultValue { get; init; }
    public string? Description { get; init; }
}

public enum ParameterSource { Route, Query, Header, Body, Services }

public sealed record ResponseDescriptor
{
    public required int StatusCode { get; init; }
    public Type? ResponseType { get; init; }
    public string? Description { get; init; }
    public string? ContentType { get; init; } = "application/json";
}

public enum AuthorizationSource { None, AspNetMetadata, YamlOverride, Hybrid }

3. IAspNetEndpointDiscoveryProvider

Discovery provider interface:

public interface IAspNetEndpointDiscoveryProvider : IEndpointDiscoveryProvider
{
    /// <summary>
    /// Discover ASP.NET endpoints with full metadata.
    /// </summary>
    IReadOnlyList<AspNetEndpointDescriptor> DiscoverAspNetEndpoints();
}

4. IAuthorizationClaimMapper

Authorization-to-claims mapping interface:

public interface IAuthorizationClaimMapper
{
    /// <summary>
    /// Map ASP.NET authorization metadata to Router claim requirements.
    /// </summary>
    Task<AuthorizationMappingResult> MapAsync(
        RouteEndpoint endpoint,
        CancellationToken cancellationToken = default);
}

public sealed record AuthorizationMappingResult
{
    public IReadOnlyList<ClaimRequirement> Claims { get; init; } = [];
    public IReadOnlyList<string> Policies { get; init; } = [];
    public IReadOnlyList<string> Roles { get; init; } = [];
    public bool AllowAnonymous { get; init; }
    public AuthorizationSource Source { get; init; }
}

5. IAspNetRouterRequestDispatcher

Request dispatch interface (the matched endpoint delegate runs, not the complete HTTP middleware pipeline):

public interface IAspNetRouterRequestDispatcher
{
    /// <summary>
    /// Dispatch a Router request frame to its matched ASP.NET endpoint delegate.
    /// </summary>
    Task<ResponseFrame> DispatchAsync(
        RequestFrame request,
        CancellationToken cancellationToken = default);
}

Verified against source 00f64ca4d165fc54e7c291795b65326869b7627d (2026-08-31), with the full Router ASP.NET suite112/112: the service dispatcher uses ASP.NET’s template matcher for literals, parameters, catch-alls and complex segments such as {version:int}:activate. It then applies the existing route constraints and endpoint ranking before invoking the handler. A suffix is part of the route, not a literalized parameter name; malformed or overflow integer values do not invoke the activation handler. Matching does not replace service authorization or tenant endpoint filters.

Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -Class '*AspNetRouterRequestDispatcherTests' -BuildProjectReferences -Restore. The complex-segment cases check actual parameter binding, suffix/method/constraint rejection, encoded values and optional separators; the adjacent cases retain signed-envelope, cancellation and catch-all guards. A new service image is required to exercise the changed dispatcher live.

Endpoint Discovery Algorithm

Step 1: Enumerate Endpoints

var endpoints = endpointDataSource.Endpoints
    .OfType<RouteEndpoint>()
    .Where(e => e.Metadata.GetMetadata<HttpMethodMetadata>() is not null)
    .Where(options.EndpointFilter ?? (_ => true))
    .Where(e => !IsExcludedPath(Normalize(e.RoutePattern)));  // ExcludedPathPrefixes

Excluded path prefixes

ExcludedPathPrefixes withholds matching paths from the HELLO payload only. The dispatcher matches against the raw ASP.NET EndpointDataSource, so an excluded path is unpublished, never unservable — a gateway-side change to how these paths are reached needs no service-side change. Matching is StartsWith, and IncludeExcludedPathsInRouter disables the filter wholesale.

Under the 2026-08-28 owner ruling every microservice auto-publishes, so this list is the estate’s only structural carve-out and every entry needs a recorded reason. The retained entries share one: they are per-instance or flat-namespace signals the gateway’s unhinted global resolver cannot own. Published endpoints resolve without a service hint, and identical templates are ranked by specificity, then health, then last heartbeat — so 30 services publishing GET /healthz would hand the gateway whichever service heartbeated most recently. /.well-known/openapi is on the list for the same reason (seven services declare that identical template, the gateway owns the path as a system path, and OpenApiAggregator fetches each document by direct HTTP).

The rest of /.well-known is publishable and must not be excluded: a bare /.well-known entry withheld Authority’s GET /.well-known/openid-configuration — an endpoint written specifically so the bridge could publish OIDC discovery, since OpenIddict serves it from middleware that EndpointDataSource cannot see — and left policy-engine’s /.well-known/risk-profile-schema unreachable. Removed 2026-08-28. Narrow any new entry to the exact colliding sub-prefix; a broad prefix silently swallows domain endpoints beneath it.

Step 2: Extract Metadata per Endpoint

For each RouteEndpoint:

  1. HTTP Method: From HttpMethodMetadata
  2. Path: Normalize route pattern (see below)
  3. Authorization: From IAuthorizeData, IAllowAnonymous
  4. Parameters: From route pattern + parameter binding metadata
  5. Responses: From IProducesResponseTypeMetadata
  6. OpenAPI: From IEndpointNameMetadata, IEndpointSummaryMetadata, ITagsMetadata

Step 3: Normalize Route Pattern

public static string NormalizeRoutePattern(RoutePattern pattern)
{
    var raw = pattern.RawText ?? BuildFromSegments(pattern);

    // 1. Ensure leading slash
    if (!raw.StartsWith('/'))
        raw = "/" + raw;

    // 2. Strip constraints: {id:int} → {id}
    raw = Regex.Replace(raw, @"\{(\*?)([A-Za-z0-9_]+)(:[^}]+)?\}", "{$2}");

    // 3. Normalize catch-all: {**path} → {path}
    raw = raw.Replace("**", "", StringComparison.Ordinal);

    // 4. Remove trailing slash
    raw = raw.TrimEnd('/');

    // 5. Empty path becomes "/"
    return string.IsNullOrEmpty(raw) ? "/" : raw;
}

Step 4: Deterministic Ordering

Sort endpoints for stable HELLO payloads:

var ordered = endpoints
    .OrderBy(e => e.Path, StringComparer.OrdinalIgnoreCase)
    .ThenBy(e => GetMethodOrder(e.Method))
    .ThenBy(e => e.OriginalEndpoint?.DisplayName ?? "");

static int GetMethodOrder(string method) => method.ToUpperInvariant() switch
{
    "GET" => 0,
    "POST" => 1,
    "PUT" => 2,
    "PATCH" => 3,
    "DELETE" => 4,
    "OPTIONS" => 5,
    "HEAD" => 6,
    _ => 7
};

Authorization Mapping

Mapping Rules

ASP.NET MetadataRouter Mapping
[Authorize] (no args)Empty RequiringClaims (authenticated required)
[Authorize(Policy = "X")]Resolve policy → claims via IAuthorizationPolicyProvider
[Authorize(Roles = "A,B")]One role requirement with AllowedValues = ["A", "B"]
[AllowAnonymous]AllowAnonymous = true, empty claims
.RequireAuthorization("Policy")Same as [Authorize(Policy)]

Policy Resolution

Each claim/role requirement preserves its own alternatives: values inside one requirement are OR, while separate requirements (including separate attributes and multiple direct AuthorizationPolicy metadata entries) remain AND. Both mapping entry points process all direct policy entries, not only the last one. The flattened Roles list is descriptive metadata, not the enforcement list.

public async Task<IReadOnlyList<ClaimRequirement>> ResolvePolicyAsync(
    string policyName,
    IAuthorizationPolicyProvider policyProvider)
{
    var policy = await policyProvider.GetPolicyAsync(policyName);
    if (policy is null)
        return [];

    var claims = new List<ClaimRequirement>();

    foreach (var requirement in policy.Requirements)
    {
        switch (requirement)
        {
            case ClaimsAuthorizationRequirement claimsReq:
                claims.Add(MapAlternatives(claimsReq.ClaimType, claimsReq.AllowedValues));
                break;

            case RolesAuthorizationRequirement rolesReq:
                claims.Add(MapAlternatives(ClaimTypes.Role, rolesReq.AllowedRoles));
                break;

            // Other requirement types: log warning, continue
        }
    }

    return claims;
}

static ClaimRequirement MapAlternatives(string type, IEnumerable<string>? alternatives)
{
    var values = alternatives?.Distinct(StringComparer.Ordinal)
        .OrderBy(value => value, StringComparer.Ordinal).ToArray() ?? [];
    if (values.Length == 1 && values[0] is { } value)
        return new ClaimRequirement { Type = type, Value = value };
    return new ClaimRequirement { Type = type, AllowedValues = values.Length > 0 ? values : null };
}

Null or empty allowed-value collections mean claim presence; empty strings and literal whitespace inside a nonempty collection are not removed. A nonempty [null] alternative list remains nonmatching, including after endpoint JSON serialization; it must not become scalar Value = null (presence). Custom StellaOps scope requirements retain their explicit ALL/ANY behavior.

The source-level regression proof is PublishedAuthorizationSemanticsTests, which compares real ASP.NET policy composition/authorization with the mapped, JSON-roundtripped endpoint and the Gateway claims store/middleware. This is not a live publication receipt: producers must carry the corrected mapper and republish metadata; existing Authority and YAML override precedence is unchanged.

Unmappable requirements fail OPEN at the gateway (ROA-1)

The “other requirement types: log warning, continue” arm above is not benign. On the router path the host’s UseAuthorization() middleware never runs — the dispatcher invokes the endpoint delegate directly (see Request Dispatch below) — so the gateway’s AuthorizationMiddleware is the only enforcement point, and it enforces exactly the claim requirements this mapper published. A policy built with RequireAssertion(...) yields an opaque AssertionRequirement: nothing is published, the gateway sees zero claim requirements, and any authenticated caller passes. Measured live 2026-09-04: GET /api/v1/opsmemory/decisions returned 200 to a token carrying only openid profile while a typed policy on the same gateway returned 403 to the same token.

Rules that follow:

YAML Override Merge

When AuthorizationMappingStrategy.Hybrid:

public IReadOnlyList<ClaimRequirement> MergeWithYaml(
    IReadOnlyList<ClaimRequirement> aspNetClaims,
    EndpointOverrideConfig? yamlOverride)
{
    if (yamlOverride?.RequiringClaims is not { Count: > 0 } yamlClaims)
        return aspNetClaims;

    // YAML completely replaces ASP.NET claims when specified
    return yamlClaims;
}

Request Dispatch

Dispatch Flow

RequestFrame (from Router)
        │
        ▼
┌───────────────────────────────────────┐
│ 1. Create DI Scope                    │
│    var scope = CreateAsyncScope()     │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 2. Build HttpContext                  │
│    • Method, Path, QueryString        │
│    • RawTarget (IHttpRequestFeature)  │
│    • Headers (including identity)     │
│    • Body stream                      │
│    • RequestServices = scope.Provider │
│    • CancellationToken wiring         │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 3. Match Endpoint                     │
│    Use ASP.NET EndpointSelector       │
│    Preserves constraints/precedence   │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 4. Populate Identity                  │
│    Map X-StellaOps-* headers to       │
│    ClaimsPrincipal on HttpContext     │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 5. Execute RequestDelegate            │
│    Runs full ASP.NET pipeline:        │
│    • Endpoint filters                 │
│    • Authorization filters            │
│    • Model binding                    │
│    • Handler execution                │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 6. Capture Response                   │
│    • Status code                      │
│    • Headers (filtered)               │
│    • Body bytes (buffered)            │
└───────────────────────────────────────┘
        │
        ▼
┌───────────────────────────────────────┐
│ 7. Dispose Scope                      │
│    await scope.DisposeAsync()         │
└───────────────────────────────────────┘
        │
        ▼
ResponseFrame (to Router)

Raw request target (IHttpRequestFeature.RawTarget)

Kestrel exposes the request target exactly as received (path plus query, percent-encoding intact) through IHttpRequestFeature.RawTarget, and HttpRequest.Path is the once-decoded form (%2F kept, everything else decoded). Endpoints that must decode a route segment exactly once, such as the Vulnerabilities Hub’s product-key routes whose keys carry canonical PURL escapes (%252F), read RawTarget instead of the double-decoded route value and fail closed without it. Before RAR-12 (2026-09-02) the dispatcher built a DefaultHttpContext with no RawTarget at all, so every such read answered HTTP 400 The raw route target is unavailable. through the Router while the direct Kestrel read passed.

Contract: the gateway sends the wire target in RequestFrame.RawTarget (envelope field rawTarget, optional), route prefix translated the same way as RequestFrame.Path. The dispatcher populates IHttpRequestFeature.RawTarget from it, and from RequestFrame.Path (query included) when the gateway sent none. HttpRequest.Path and QueryString still come from RequestFrame.Path, so route matching in the service is unchanged. See the Router architecture dossier (“The raw request target travels beside the path”) for the gateway side.

Verified against source b44e7c9ca3a7d2ca4fb265a2889d7e982a7fc5c2 (2026-09-02). Re-verify with pwsh ./tools/scripts/test-targeted-xunit.ps1 -Project src/Router/__Tests/StellaOps.Router.AspNet.Tests/StellaOps.Router.AspNet.Tests.csproj -Method '*RawTarget*' -BuildProjectReferences -Restore (two cases: the wire form with %252F/%253A reaches the endpoint byte for byte; a frame without a raw target falls back to the frame path). A service exercises the change live only after its image is rebuilt with the new StellaOps.Microservice.AspNetCore.

HttpContext Construction

public async Task<ResponseFrame> DispatchAsync(
    RequestFrame request,
    CancellationToken cancellationToken)
{
    await using var scope = _serviceProvider.CreateAsyncScope();

    var httpContext = new DefaultHttpContext
    {
        RequestServices = scope.ServiceProvider
    };

    // Link cancellation
    var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken,
        httpContext.RequestAborted);
    httpContext.RequestAborted = linkedCts.Token;

    // Populate request
    var httpRequest = httpContext.Request;
    httpRequest.Method = request.Method;
    (httpRequest.Path, httpRequest.QueryString) = ParsePathAndQuery(request.Path);

    // The wire target, so exactly-once decoders (IHttpRequestFeature.RawTarget readers) work
    // through the Router; the frame path is the fallback for a gateway that sent none.
    httpContext.Features.GetRequiredFeature<IHttpRequestFeature>().RawTarget =
        string.IsNullOrWhiteSpace(request.RawTarget) ? request.Path : request.RawTarget;

    foreach (var (key, value) in request.Headers)
    {
        httpRequest.Headers[key] = value;
    }

    if (request.Body is { Length: > 0 })
    {
        httpRequest.Body = new MemoryStream(request.Body);
        httpRequest.ContentLength = request.Body.Length;
    }

    // Set trace identifier
    httpContext.TraceIdentifier = request.CorrelationId;

    // Populate identity from headers
    PopulateIdentity(httpContext, request.Headers);

    // Match and execute endpoint
    var endpoint = await MatchEndpointAsync(httpContext);
    if (endpoint is null)
    {
        return CreateNotFoundResponse(request.CorrelationId);
    }

    httpContext.SetEndpoint(endpoint);

    // Capture response
    var responseBody = new MemoryStream();
    httpContext.Response.Body = responseBody;

    try
    {
        await endpoint.RequestDelegate!(httpContext);
    }
    catch (Exception ex)
    {
        return CreateErrorResponse(request.CorrelationId, ex);
    }

    // Build response frame
    return new ResponseFrame
    {
        CorrelationId = request.CorrelationId,
        StatusCode = httpContext.Response.StatusCode,
        Headers = CaptureResponseHeaders(httpContext.Response),
        Body = responseBody.ToArray()
    };
}

Identity Population

Map Gateway-provided identity headers to ClaimsPrincipal:

private void PopulateIdentity(HttpContext httpContext, IReadOnlyDictionary<string, string> headers)
{
    var claims = new List<Claim>();

    if (headers.TryGetValue("X-StellaOps-Actor", out var actor) && !string.IsNullOrEmpty(actor))
    {
        claims.Add(new Claim(ClaimTypes.NameIdentifier, actor));
        claims.Add(new Claim(StellaOpsClaimTypes.Subject, actor));
    }

    if (headers.TryGetValue("X-StellaOps-TenantId", out var tenant) && !string.IsNullOrEmpty(tenant))
    {
        claims.Add(new Claim(StellaOpsClaimTypes.Tenant, tenant));
    }

    if (headers.TryGetValue("X-StellaOps-Scopes", out var scopes) && !string.IsNullOrEmpty(scopes))
    {
        foreach (var scope in scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries))
        {
            claims.Add(new Claim(StellaOpsClaimTypes.ScopeItem, scope));
        }
    }

    if (claims.Count > 0)
    {
        var identity = new ClaimsIdentity(claims, "StellaRouter");
        httpContext.User = new ClaimsPrincipal(identity);
    }
}

Program.cs Integration

Service Registration

public static class StellaRouterBridgeExtensions
{
    public static IServiceCollection AddStellaRouterBridge(
        this IServiceCollection services,
        Action<StellaRouterBridgeOptions> configure)
    {
        var options = new StellaRouterBridgeOptions
        {
            ServiceName = "",
            Version = "",
            Region = ""
        };
        configure(options);
        ValidateOptions(options);

        services.AddSingleton(options);
        services.AddSingleton<IAuthorizationClaimMapper, DefaultAuthorizationClaimMapper>();
        services.AddSingleton<IAspNetEndpointDiscoveryProvider, AspNetCoreEndpointDiscoveryProvider>();
        services.AddSingleton<IAspNetRouterRequestDispatcher, AspNetRouterRequestDispatcher>();

        // Register as IEndpointDiscoveryProvider for Router SDK integration
        services.AddSingleton<IEndpointDiscoveryProvider>(sp =>
            sp.GetRequiredService<IAspNetEndpointDiscoveryProvider>());

        // Wire into Router SDK
        services.AddStellaMicroservice(microserviceOptions =>
        {
            microserviceOptions.ServiceName = options.ServiceName;
            microserviceOptions.Version = options.Version;
            microserviceOptions.Region = options.Region;
            microserviceOptions.InstanceId = options.InstanceId ?? Guid.NewGuid().ToString();
        });

        return services;
    }

    public static IApplicationBuilder UseStellaRouterBridge(this IApplicationBuilder app)
    {
        // Ensure EndpointDataSource is available (after UseRouting)
        var endpointDataSource = app.ApplicationServices
            .GetService<EndpointDataSource>()
            ?? throw new InvalidOperationException(
                "UseStellaRouterBridge must be called after UseRouting()");

        // Discovery happens on first Router HELLO
        // Dispatch is handled by Router SDK

        return app;
    }
}

YAML Override Format

The existing microservice.yaml format is extended:

microservice:
  serviceName: scanner
  version: "1.0.0"
  region: "${REGION:default}"

endpoints:
  # Override by method + path
  - method: POST
    path: /api/reports
    timeoutSeconds: 60
    supportsStreaming: false
    requiringClaims:
      - type: "scanner.reports.read"
      # Replaces any ASP.NET-derived claims for this endpoint

  # Endpoint with no authorization (explicitly allow authenticated)
  - method: GET
    path: /api/health
    requiringClaims: [] # Empty = authenticated only, no specific claims

  # Override specific claim type mapping
  - method: DELETE
    path: /api/scans/{id}
    requiringClaims:
      - type: "role"
        value: "scanner-admin"
      - type: "scanner.scans.delete"

ASP.NET Feature Support Matrix

Fully Supported

FeatureDiscoveryDispatchNotes
Minimal APIs (MapGet, etc.)✓✓Primary use case
Controllers ([ApiController])✓✓Full support
Route groups (MapGroup)✓✓Path composition
[Authorize] attribute✓✓Claims extraction
[AllowAnonymous]✓✓Explicit anonymous
.RequireAuthorization()✓✓Policy resolution
[FromBody] binding✓ (type)✓JSON deserialization
[FromRoute] binding✓✓Path parameters
[FromQuery] binding✓✓Query parameters
[FromHeader] binding✓✓Header values
[FromServices] injectionN/A✓DI resolution
.Produces<T>()✓N/ASchema metadata
.WithName() / .WithSummary()✓N/AOpenAPI metadata
.WithTags()✓N/AGrouping
Endpoint filtersN/A✓Filter pipeline
CancellationTokenN/A✓From Router frame
Route constraints ({id:int})✓ (stripped)✓ASP.NET matcher
Catch-all routes ({**path})✓✓Normalized

Not Supported (v0.1)

FeatureReasonWorkaround
SignalR hubsDifferent protocolUse native ASP.NET
gRPC servicesDifferent protocolUse native gRPC
Streaming request bodiesSDK limitationUse IRawStellaEndpoint
Custom constraintsComplexityUse standard constraints
API versioning (header/query)ComplexityPath-based versioning
IFormFile uploadsNot bufferedUse raw endpoint

Error Handling

Discovery Errors

ConditionBehaviorConfiguration
No authorization metadataFail discoveryOnMissingAuthorization = RequireExplicit
Unsupported constraintLog warning, stripOnUnsupportedConstraint = WarnAndStrip
Duplicate endpointsLog warning, keep firstAlways
Invalid route patternSkip endpoint, log errorAlways

Dispatch Errors

ConditionResponse
No matching endpoint404 Not Found
Authorization failure403 Forbidden
Model binding failure400 Bad Request
Handler exception500 Internal Server Error
CancellationNo response (connection closed)

Troubleshooting

Common Issues

1. Endpoints Not Discovered

Symptom: Gateway shows 0 endpoints for service, or specific endpoints missing.

Causes & Solutions:

CauseSolution
UseStellaRouterBridge() called before MapControllers()Call UseStellaRouterBridge() after all endpoint registration
Route filtered by EndpointFilterCheck filter logic, ensure endpoint matches
Path sits under an ExcludedPathPrefixes entryMatching is StartsWith, so a broad prefix swallows endpoints beneath it — narrow the entry rather than adding an exception
Missing [Authorize] with RequireExplicitAdd authorization or change MissingAuthorizationBehavior

Debug:

// Enable discovery logging
builder.Logging.AddFilter("StellaOps.Microservice.AspNetCore", LogLevel.Debug);

2. Authorization Claims Not Extracted

Symptom: Endpoints registered but RequiringClaims is empty when it shouldn’t be.

Causes & Solutions:

CauseSolution
[Authorize] without policy/rolesAdd explicit policy or roles
Policy not registeredRegister policy with AddAuthorization()
AuthorizationMappingStrategy.YamlOnly setUse Hybrid or AspNetMetadataOnly
Custom policy doesn’t have claim requirementsUse claims-based policies

Debug:

// Log authorization mapping
var mapper = app.Services.GetRequiredService<IAuthorizationClaimMapper>();
var result = await mapper.MapAsync(endpoint);
Console.WriteLine($"Claims: {string.Join(", ", result.Claims)}");

3. Request Dispatch Fails with 404

Symptom: Gateway routes request but microservice returns 404.

Causes & Solutions:

CauseSolution
Path parameters not matchedVerify parameter names in route pattern
Method mismatchVerify HTTP method matches endpoint
Route constraint rejected valueUse standard constraints that bridge supports
Catch-all route not handledEnsure {**path} is normalized correctly

Debug:

# Check registered endpoints
curl http://localhost:5000/.well-known/stella-endpoints

4. Model Binding Errors

Symptom: Requests return 400 Bad Request with binding errors.

Causes & Solutions:

CauseSolution
[FromBody] type mismatchVerify request body matches expected type
Required parameter missingInclude all [FromRoute]/[FromQuery] parameters
[FromHeader] not populatedHeaders forwarded via X-StellaOps-Header-*
Complex type not deserializedVerify JSON serialization settings

Debug:

// Enable model binding logging
builder.Logging.AddFilter("Microsoft.AspNetCore.Mvc.ModelBinding", LogLevel.Debug);

5. Identity Not Populated

Symptom: User.Identity is null or claims missing in endpoint handler.

Causes & Solutions:

CauseSolution
Gateway not forwarding identityVerify Gateway identity-header-policy configured
Missing X-StellaOps-UserId headerGateway must send identity headers
UseAuthentication() not calledCall before UseStellaRouterBridge()
Custom claims not mappedUse ClaimsPrincipalBuilder for custom claims

Debug:

app.MapGet("/debug/identity", (HttpContext ctx) =>
    new {
        IsAuthenticated = ctx.User.Identity?.IsAuthenticated,
        Name = ctx.User.Identity?.Name,
        Claims = ctx.User.Claims.Select(c => new { c.Type, c.Value })
    });

6. Performance Issues

Symptom: High latency or memory usage.

Causes & Solutions:

CauseSolution
HttpContext allocation per requestPool contexts (internal implementation)
Large request bodies bufferedUse streaming endpoints for large payloads
Discovery runs too frequentlyDiscovery runs once at startup; cache is stable
Many endpoints slow startupDiscovery is O(n) but runs once

Metrics to monitor:

7. YAML Override Not Applied

Symptom: Claims from YAML file not appearing in endpoint registration.

Causes & Solutions:

CauseSolution
YamlConfigPath not setSet options.YamlConfigPath = "router.yaml"
File not foundUse absolute path or verify relative path
Path pattern doesn’t matchYAML paths are case-insensitive, verify pattern
AuthorizationMappingStrategy.AspNetMetadataOnlyUse YamlOnly or Hybrid

Example YAML:

# router.yaml
endpoints:
  - path: "/api/admin/**"
    requiringClaims:
      - type: "Role"
        value: "admin"

Diagnostic Endpoints

The bridge adds optional diagnostic endpoints (development only):

EndpointDescription
/.well-known/stella-endpointsLists all discovered endpoints
/.well-known/stella-bridge-statusShows bridge configuration and health

Enable in development:

builder.Services.AddStellaRouterBridge(options =>
{
    options.EnableDiagnosticEndpoints = builder.Environment.IsDevelopment();
    // ...
});

Logging Categories

Configure logging for troubleshooting:

{
  "Logging": {
    "LogLevel": {
      "StellaOps.Microservice.AspNetCore.Discovery": "Debug",
      "StellaOps.Microservice.AspNetCore.Authorization": "Debug",
      "StellaOps.Microservice.AspNetCore.Dispatch": "Information"
    }
  }
}

Testing Strategy

Unit Tests

  1. Discovery determinism: Same endpoints → same descriptor order
  2. Route normalization: Constraints stripped, paths normalized
  3. Authorization mapping: Policies → claims correctly
  4. Metadata extraction: All ASP.NET metadata captured

Integration Tests

  1. Minimal API dispatch: Route parameters, query, body binding
  2. Controller dispatch: Attribute routing, model binding
  3. Authorization flow: Claims checked, 403 on failure
  4. Filter execution: Endpoint filters run correctly
  5. Error mapping: Exceptions → correct status codes

End-to-End Tests

  1. HELLO registration: Bridge endpoints appear in Gateway
  2. Gateway routing: HTTP request → Router → ASP.NET → response
  3. OpenAPI aggregation: Bridged endpoints in Gateway OpenAPI

Migration Guide

From HTTP-Only Service

// Before: HTTP only
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();

var app = builder.Build();
app.MapControllers();
await app.RunAsync();

// After: HTTP + Router bridge
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllers();
builder.Services.AddStellaRouterBridge(options =>
{
    options.ServiceName = "myservice";
    options.Version = "1.0.0";
    options.Region = "default";
});
builder.Services.AddMessagingTransportClient(); // Add transport

var app = builder.Build();
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseStellaRouterBridge(); // Enable bridge
app.MapControllers();
await app.RunAsync();

From Dual Registration (HTTP + [StellaEndpoint])

  1. Remove [StellaEndpoint] handler classes
  2. Add AddStellaRouterBridge() configuration
  3. Add UseStellaRouterBridge() middleware
  4. Add/update microservice.yaml for claim overrides
  5. Remove duplicate endpoint registrations