Elarion

Hosting

Wire the module bootstrapper, handler registry, and JSON-RPC endpoint into an ASP.NET Core host.

The host turns generated registration code into a running JSON-RPC endpoint. It owns composition and transport; modules own application logic.

Host project references

<ItemGroup>
  <!-- Elarion bundles the source generator; reference it directly so the generator runs in the host
       (analyzer assets are not transitive). -->
  <PackageReference Include="Elarion" Version="0.3.1" />
  <PackageReference Include="Elarion.JsonRpc" Version="0.3.1" />
  <PackageReference Include="Elarion.AspNetCore" Version="0.3.1" />
  <ProjectReference Include="..\MyApp.Application\MyApp.Application.csproj" />
</ItemGroup>

Generated host partial

A single assembly attribute tells the generator to emit the cross-module wiring in the host assembly:

using Elarion.AspNetCore;

[assembly: GenerateModuleBootstrapper]

ElarionBootstrapper gains extension methods on their natural receivers so the host wires modules fluently: services.AddElarion(configuration), endpoints.MapElarionEndpoints(configuration), registry.RegisterHandlers(configuration), configuration.GetMcpMetadata(), configuration.GetAllJsonTypeInfoResolvers(), and configuration.IsModuleEnabled(name) — plus the parameterless ElarionBootstrapper.GetAllModuleNames() and per-module transport extension methods (Map{Module}Http, Add{Module}Handlers) and the parameterless Get{Module}McpMetadata for every module that owns [HttpEndpoint] / [Handler] handlers.

All transports are module-scoped and feature-flag-gated through the bootstrapper: a module disabled with Modules:{Name}:Enabled = false has its services, validation metadata, MapEndpoints, [HttpEndpoint] routes, [Handler] JSON-RPC methods, and MCP tools all left unmapped, so a disabled module disappears across every surface. Core modules are always mapped.

Composing the host

var builder = WebApplication.CreateSlimBuilder(args);

builder.Services.AddDbContext<AppDbContext>(/* provider setup */);
builder.Services.AddScoped<DbContext>(sp => sp.GetRequiredService<AppDbContext>());

// Scheduled-job descriptors are registered per module by AddElarion below; the host picks the runtime.
builder.Services.AddElarionScheduler(builder.Configuration);
builder.Services.AddMyApp_ApplicationResiliencePolicies();
builder.AddMyAppPlatformCapabilities();
// Also contributes every enabled module's source-generated JSON context to the canonical serializer.
builder.Services.AddElarion(builder.Configuration);

// Registers JSON-RPC options and the frozen JsonRpcDispatcher singleton, gated per module. Serialization
// comes from the canonical IElarionJsonSerialization — no hand-built JsonSerializerOptions is threaded in.
builder.Services.AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers);

var app = builder.Build();

app.MapElarionEndpoints(app.Configuration);
app.MapElarionJsonRpc();

app.Run();

See Serialization for the canonical IElarionJsonSerialization and ConfigureElarionJson.

AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers) is the config-aware overload: it resolves IConfiguration when the dispatcher singleton is built and registers only the enabled modules' handlers. The JSON-RPC adapter then serves only the operations flagged HandlerTransports.JsonRpc. MapElarionEndpoints does the same gating for module MapEndpoints and generated [HttpEndpoint] routes — including hooks the host declares on behalf of a web-free module assembly with [ModuleEndpoints]. It maps them all into one prefix-less route group and returns it, so app.MapElarionEndpoints(cfg).RequireAuthorization() guards those endpoints at once while [AllowAnonymous] handlers stay open — the JSON-RPC and MCP endpoints are mapped separately and take their own conventions (see HTTP endpoints › Public handlers stay public). To expose the same handlers over MCP (a thin adapter over the same handler bus, equally gated), pair builder.Configuration.GetMcpMetadata() with the same ElarionBootstrapper.RegisterHandlers registry via AddElarionMcp — see MCP server.

The dispatcher is frozen after registration. Freezing produces an immutable, thread-safe map and lets the build-time schema exporter read a complete, deterministic method set. A missing, unfrozen, or empty dispatcher is treated as an error rather than producing a stale schema.

The exporter reads the dispatcher as configured at build time. Because feature modules are enabled by default, the schema stays the complete contract unless a module is explicitly disabled in the configuration the schema build sees — make sure every module is enabled for the schema build if you need the full contract regardless of runtime gating.

Modules are the only hosting path

Every transport — services, HTTP, JSON-RPC, and MCP — is wired through [GenerateModuleBootstrapper], so it all stays feature-flag-gated together. There is no flat, ungated map: a host that conceptually has "no modules" simply declares a single core [AppModule] (core modules map unconditionally), which gives the same always-on behavior while keeping one consistent path. Handlers whose namespace falls under no module are reported (ELHTTP003/ELRPC001) so they are never silently dropped.

The bootstrapper discovers modules and handlers both from referenced module assemblies (through their per-assembly Elarion manifests) and from the host compilation itself, so the project layout is your choice: the multi-project layout shown above, or a single project where Program, the [AppModule] declarations, and every handler live in one csproj with [assembly: GenerateModuleBootstrapper]. Both wire identically.

What the host still owns

Mapping the endpoint does not move platform concerns into the framework. The host remains responsible for:

  • authentication and authorization policy setup,
  • cross-origin request protection when browsers authenticate with a cookie (see below),
  • database provider configuration and migrations,
  • concrete platform capability providers,
  • middleware order,
  • health checks, telemetry exporters, and deployment integration,
  • JSON-RPC endpoint publication and transport-specific error mapping,
  • the wording of the answer to unexpected exceptions, through ConfigureElarionUnexpectedErrors (every transport already catches them and answers with a support reference).

A browser attaches some credentials on its own: an ASP.NET Core Identity cookie, the session cookie of an authenticating reverse proxy (Cloudflare Access, Google IAP, oauth2-proxy, …), cached HTTP Basic/Negotiate credentials, or a client certificate. A page on another site can then make the browser send a state-changing request that carries them (cross-site request forgery). A bearer token the client adds in code is not exposed this way, so a host whose browsers only send Authorization: Bearer … needs none of this section.

The browser sends a cross-origin request without asking first (no CORS preflight) only when it is a "simple" request: GET, HEAD, or POST with a text/plain, application/x-www-form-urlencoded, or multipart/form-data body (or none). Anything else, including a JSON body, is preflighted, and the browser sends it only when the host's CORS policy allows that origin. What that means for each surface:

SurfaceWithout further setup
MapElarionJsonRpc() (/rpc)Protected: a request whose Content-Type is not JSON (or missing) is refused with 415 before the body is read.
MapElarionMcp() (/mcp)Protected: the MCP transport refuses a non-JSON body with 415.
[HttpEndpoint] with a JSON body (the POST/PUT/PATCH default)Protected: a non-JSON body is a 415.
[HttpEndpoint] form and file routes, MapElarionBlobUploads(), a POST that binds only from the route or query, hand-written routesNot protected: these accept simple requests, and generated form routes are .DisableAntiforgery().
WebSocket endpoints: MapElarionConnectionSocket<THandler>() (WebSocket endpoint), a hand-written UseWebSockets route, a SignalR hubNot protected: the handshake is a GET that the browser sends cross-site with its cookies and without CORS, and the open socket can then send and read anything (Cross-Site WebSocket Hijacking). The cross-origin protection covers it.
MapElarionClientEvents(), MapElarionStream<T>() (server-sent events)Read-only GETs: a page on another origin can make the browser open one, but can read the events only when the host's CORS policy allows that origin.
Any GET that changes stateNot protected: don't write one.

The JSON content-type requirement on /rpc

JsonRpcOptions.RequireJsonContentType (default true) accepts application/json and application/*+json, with or without a charset. The generated TypeScript client always sends Content-Type: application/json; a hand-written caller such as curl -d must send it too (-H 'content-type: application/json'). The refusal is HTTP 415 with a JSON-RPC invalid_request envelope. Turn the requirement off only for a host whose callers cannot send the header and whose browsers never carry an ambient credential:

builder.Services.AddElarionJsonRpc(ElarionBootstrapper.RegisterHandlers,
    o => o.RequireJsonContentType = false);   // only without cookie authentication

Cross-origin protection middleware

For the remaining surfaces, and as defense in depth for all of them, install the cross-origin protection. It needs no token in the client and no identity, so it goes before authentication and routing:

var app = builder.Build();

app.UseForwardedHeaders();                       // behind a proxy: so the request sees its public origin
app.UseElarionCrossOriginProtection(o => {
    o.AllowedOrigins.Add("https://app.example.com");   // a separate front-end origin, if any
    o.ExemptPathPrefixes.Add("/signin-oidc");          // the identity provider's form_post callback
});
app.UseAuthentication();
app.UseAuthorization();

app.MapElarionEndpoints(app.Configuration);
app.MapElarionJsonRpc();

For every method except GET, HEAD, OPTIONS, and TRACE, and for every WebSocket handshake, it reads the Fetch Metadata header Sec-Fetch-Site that every current browser sends, and falls back to Origin for older ones:

  • Sec-Fetch-Site: same-origin or none (the user typed the address) passes.
  • Sec-Fetch-Site: same-site or cross-site passes only when Origin is one of AllowedOrigins. same-site is refused on purpose: a sibling subdomain receives the same SameSite=Lax cookies but is not the application.
  • Without Sec-Fetch-Site, an Origin passes when it is the request's own origin (scheme and Host as the host sees them) or one of AllowedOrigins; Origin: null and more than one Origin header are refused.
  • A request with neither header passes: it does not come from a browser page, so it carries no browser credential. Server-side rendering, scripts, and server-to-server calls are unaffected.

A WebSocket handshake is a GET whose Upgrade header names websocket; the middleware reads the header itself, so it works before UseWebSockets and before routing. Browsers send Origin on every handshake, so a cross-site one is refused with 403 before the socket is accepted, while a device or server client that sends no Origin connects as before. An HTTP/2 handshake is a CONNECT, which is never a safe method. Every other GET (including server-sent events) is unaffected.

A refusal is HTTP 403 with a ProblemDetails body whose code is cross_origin.refused (CrossOriginProtectionErrorCodes.Refused); a registered IProblemDetailsService writes it, so host customization applies. AllowedOrigins entries are validated at startup (scheme://host[:port], no path).

Two things to get right:

  • Behind a reverse proxy, the request's own origin is what the host sees. Apply forwarded headers before the middleware, or list the public origin in AllowedOrigins; otherwise an older browser's same-origin write is compared against the internal address and refused. The failure is closed, never open.
  • Exempt only what must receive a cross-site post (an OpenID Connect form_post callback, a payment provider's return) or what authenticates every request with its own non-ambient credential. Exempt prefixes match whole path segments.

The middleware does not replace CORS. A separate front-end origin still needs a CORS policy for the browser to send JSON, and an origin that CORS allows to send credentials should also be in AllowedOrigins. ASP.NET Core's own antiforgery is token-based and remains an option for form posts; this protection covers every route without one.

On this page