Reference

UseTraxApiDashboard

Package Trax.Api.DashboardNamespaces Trax.Api.Dashboard.Extensions, Trax.Api.Dashboard.ConfigurationSource ApiDashboardEndpointExtensions.cs, TraxApiDashboardOptions.cs

Serves the Trax operations dashboard, the single-page app Trax.Api.Dashboard carries, at a path of the host (/trax/dashboard by default). It is an extension method on IEndpointRouteBuilder, in ApiDashboardEndpointExtensions, so it is called on the built WebApplication, or on a route group.

using Trax.Api.Dashboard.Extensions;
using Trax.Api.GraphQL.Extensions;
 
builder.Services.AddTraxGraphQL(graphql => graphql.RequireAuthorization());
builder.Services.AddAuthorization(o =>
    o.AddPolicy("TraxAdmin", p => p.RequireRole("Admin")));
// A scheme the browser carries on a page load, such as a sign-in cookie, gates the app.
 
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.UseTraxGraphQL();
app.UseTraxApiDashboard(dashboard => dashboard.RequirePolicy("TraxAdmin"));

Signature

public static IEndpointConventionBuilder UseTraxApiDashboard(
    this IEndpointRouteBuilder endpoints,
    Action<TraxApiDashboardOptions> configure,
    string path = "/trax/dashboard"
)
ParameterDescription
configureChooses the authorization posture, and optionally the GraphQL path. Required.
pathWhere the app is served. Leading and trailing slashes are normalized; / and a path with route parameters are refused, because everything under the path belongs to the app.

It returns the endpoint's convention builder, so the host can add conventions of its own (RequireHost, rate limiting, a further RequireAuthorization). They add to the posture; they do not replace it. AllowAnonymous() would replace it, since ASP.NET Core skips every policy on an endpoint that carries it, so chaining it after RequirePolicy or RequireRoles makes the host fail at startup, naming AllowAnonymousShell(). After AllowAnonymousShell() it is allowed, and opts the shell out of a fallback policy too.

It refuses to start without a posture

The dashboard is an admin UI for queueing, running and cancelling trains, so the host has to say who may load it, in configure:

TraxApiDashboardOptionsEffect
RequirePolicy("<name>")Every request for the app (its page, any route of it, every file it loads) requires the named policy. The policy must be registered with AddAuthorization, or the call throws at startup.
RequireRoles("<role>", ...)Every request for the app requires one of the roles. Combined with RequirePolicy, both apply.
AllowAnonymousShell()The app adds no authorization of its own, and a warning is logged on every start. A fallback policy or an ingress rule in front of it still applies.

With none of them, UseTraxApiDashboard throws InvalidOperationException naming the three, and AllowAnonymousShell() together with either of the others throws as a contradiction. A policy or roles posture also fails at startup when the host has no authentication scheme to challenge a caller with, when the policy names a scheme that is not registered, or when the default forbid scheme is not registered, because every refusal would otherwise be a 500. The rule is recorded in Trax.Api/docs/adr/0039.

The posture gates the app, not the data. The app holds no data. Everything it shows or changes is a request to the Trax GraphQL endpoint, authorized there by the endpoint's own policy and the operations gate, whatever the posture here. That is why AllowAnonymousShell() exists: it opens the page and its scripts, nothing more.

Use a scheme the browser carries. Loading a page sends cookies, never an X-Api-Key or Authorization header, so a policy whose only scheme is an API key or a bearer token refuses every page load. Gate the app with a cookie or OpenID Connect sign-in, for example RequirePolicy(OidcDefaults.PolicyName) after AddTraxOidcAuth, or, when the host authenticates only by key or token, use AllowAnonymousShell() with the host's network or ingress as the gate.

How the app authenticates

Once loaded, the app asks the operator for an API key or a bearer token and sends it on every GraphQL request (X-Api-Key, or Authorization: Bearer) and in the subscription socket's connection_init payload (apiKey or authToken), which the GraphQL endpoint's socket interceptor reads. The endpoint is on the same origin as the app, so CORS is not involved, and a socket from the host's own origin passes the GraphQL endpoint's origin check.

The credential is held in memory only. The app keeps it in the page for as long as the page is open and never writes it to localStorage, sessionStorage, IndexedDB or a cookie. Nothing else on the origin can read it later, and the operator pastes it again after every page load: a reload, a new tab, or a deep link opened from elsewhere. Closing the tab, or signing out of the shell, leaves nothing behind, and the next person at a shared browser is asked for their own key. A tab left open keeps working until it is closed or reloaded, because the key, not the shell's sign-in, authorizes its GraphQL requests; Change credential forgets it at once.

Earlier versions stored the credential in localStorage. The app deletes whatever they left there when it loads, without using it.

A host that signs the browser in asks for no key. When every request the browser makes already carries who is asking (a cookie or OpenID Connect sign-in, or a proxy in front that adds identity headers), call HostAuthenticates() beside RequirePolicy or RequireRoles. The app then shows no credential prompt and no Change credential, sends no X-Api-Key, Authorization header or connection_init credential, and the GraphQL endpoint authorizes each request by the host's own scheme, so that scheme must also be one the endpoint's policy accepts. HostAuthenticates() together with AllowAnonymousShell() throws: with an anonymous shell nothing gates the page or vouches that the endpoint will know the caller.

app.UseTraxApiDashboard(dashboard => dashboard
    .RequirePolicy(OidcDefaults.PolicyName)
    .HostAuthenticates());

It needs the operations namespace

Every page of the app reads operations, so UseTraxApiDashboard throws at startup unless the GraphQL builder exposes it with ExposeOperationQueries(), and ExposeOperationMutations() for the app's actions, each behind the gate the builder then asks for. Exposing them needs the operations services AddScheduler registers.

With persisted operations

A host that runs persisted operations registers the app's own documents when it starts, under trax_console_<name>_<hash>, and names each document's id in the runtime config it writes into the app's page. The app then sends every request by its id, so it keeps working on a host that requires persisted operations and refuses document text. The persisted-operation pages still send text: they are reached through persistedOperations, which runs as text behind GateManagement.

What the host servesWhat the app does
No persisted operationsSends document text, as before.
Persisted operations, required or notSends each registered document by its id.
A document this host cannot serve, such as an operations mutation on a host that exposes only the queriesLeaves it out and logs which and why when it starts. The app sends that one as text, which a host that requires persisted operations refuses, so only the pages that use it fail.

Registering a document changes which documents may run, not who may run them: every request still passes the operations gate. The decision is Trax.Api/docs/adr/0042.

What it serves

RequestAnswer
{path}A redirect to {path}/, keeping the query string.
{path}/ and any other path, a route of the app, such as {path}/executions/42, {path}/trains/Trax.Samples.Orders.IShipOrderTrain or {path}/manifests/nightly.logThe app's page, with Cache-Control: private, no-cache and an ETag. It is served behind the posture, so no shared cache may keep it.
{path}/assets/<name>-<hash>.js and the app's other filesThe file, with its content type. Files under assets/ are named by their content and cached for a year as immutable: private under RequirePolicy or RequireRoles, or whenever the caller was signed in, so a shared cache never hands a gated file to someone else; public only for an anonymous shell nobody signed in to. The other files are revalidated.
Any other path under {path}/assets/404. A missing script is never answered with the page.

GET and HEAD are served, and a matching If-None-Match is answered with 304. Any other method is answered 405 with Allow: GET, HEAD.

The page is written for the path it is served at: a <base href> at the app's base path, which the app's relative asset URLs resolve against, and the runtime config it reads at startup (the GraphQL path, and with persisted operations the id of each of the app's documents), both inserted first in its <head>. So one build works under any path, a route group, or a UsePathBase prefix.

GraphQL path

TraxApiDashboardOptions.GraphQLPath is the path of the Trax GraphQL endpoint the app talks to, on the host's own origin. When it is not set, the app talks to the path UseTraxGraphQL mapped, whether it was called before or after UseTraxApiDashboard, or to /trax/graphql when the host maps the endpoint some other way. A value that is not a path on the host, such as an absolute URL, is refused at startup.

using Trax.Api.Dashboard.Extensions;
 
app.UseTraxApiDashboard(dashboard =>
{
    dashboard.RequireRoles("Admin");
    dashboard.GraphQLPath = "/api/graphql";
}, path: "/ops");

UseTraxApiDashboard throws at startup when AddTraxGraphQL() was not called.

Security headers

Every response to a request under the dashboard's path carries these, including the refusals the posture answers before the app is reached (a 401, a 403, a sign-in redirect) and a 405. The host's other routes are not touched.

HeaderValue
Content-Security-Policydefault-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self' wss://<host>; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none' (plus ws://<host> when the request is not HTTPS)
X-Content-Type-Optionsnosniff
Referrer-Policyno-referrer
X-Frame-OptionsDENY

The app runs under that policy as built: no inline script or style element, no eval, and its fonts are files rather than inline data. A Host header that is not a plain host name is left out of connect-src.

Moving from UseTraxDashboard

The Blazor dashboard (Trax.Dashboard) is removed, and UseTraxApiDashboard is its replacement. A host that called AddTraxDashboard(...) and UseTraxDashboard():

  1. References Trax.Api.Dashboard instead of Trax.Dashboard, and drops <RequiresAspNetWebAssets> unless something else in the host needs it.
  2. Deletes both calls, and any IDashboardSettingsService it registered: the console keeps its preferences in the browser.
  3. Calls AddTraxGraphQL with ExposeOperationQueries(), ExposeOperationMutations() and the gate they require (GateOperations(...) or RequireAuthorization(...)), since every page reads operations, and maps UseTraxGraphQL().
  4. Maps UseTraxApiDashboard(...) with the posture the Blazor dashboard had: RequirePolicy and RequireRoles carry over, on a scheme the browser carries. AllowAnonymousDashboard() becomes AllowAnonymousShell(); a host whose only credentials are API keys or bearer tokens, as the Trax samples are, uses it inside an IsDevelopment() check, and the operator pastes a key that passes the operations gate.
  5. Moves links from /trax/... to /trax/dashboard/...; the console names its pages for what they hold (/runs/42, /schedules/7).

The longer version, with the failures a moved host meets, is Moving from the Blazor dashboard.

Remarks

  • The package carries the built app. A build of Trax.Api.Dashboard from the Trax repository embeds it only when Trax.Api/src/Trax.Api.Dashboard/ClientApp has its npm packages installed, and UseTraxApiDashboard refuses to start with a build that has none, saying so.
  • The footer shows the Trax version the API host runs, read from the GraphQL endpoint.