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).
CSRF and cookie authentication
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:
| Surface | Without 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 routes | Not protected: these accept simple requests, and generated form routes are .DisableAntiforgery(). |
WebSocket endpoints: MapElarionConnectionSocket<THandler>() (WebSocket endpoint), a hand-written UseWebSockets route, a SignalR hub | Not 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 state | Not 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 authenticationCross-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-originornone(the user typed the address) passes.Sec-Fetch-Site: same-siteorcross-sitepasses only whenOriginis one ofAllowedOrigins.same-siteis refused on purpose: a sibling subdomain receives the sameSameSite=Laxcookies but is not the application.- Without
Sec-Fetch-Site, anOriginpasses when it is the request's own origin (scheme andHostas the host sees them) or one ofAllowedOrigins;Origin: nulland more than oneOriginheader 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_postcallback, 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.
Capabilities
Choose the opt-in platform pieces you need — transports, policy, messaging, realtime, coordination, persistence, and operations.
Serialization
One canonical JsonSerializerOptions that every Elarion subsystem reads, composed from per-module source-generated JSON contexts for AOT-friendly serialization.