ForgeTrust.AppSurface.Web
0.2.0-preview.4
dotnet add package ForgeTrust.AppSurface.Web --version 0.2.0-preview.4
NuGet\Install-Package ForgeTrust.AppSurface.Web -Version 0.2.0-preview.4
<PackageReference Include="ForgeTrust.AppSurface.Web" Version="0.2.0-preview.4" />
<PackageVersion Include="ForgeTrust.AppSurface.Web" Version="0.2.0-preview.4" />
<PackageReference Include="ForgeTrust.AppSurface.Web" />
paket add ForgeTrust.AppSurface.Web --version 0.2.0-preview.4
#r "nuget: ForgeTrust.AppSurface.Web, 0.2.0-preview.4"
#:package ForgeTrust.AppSurface.Web@0.2.0-preview.4
#addin nuget:?package=ForgeTrust.AppSurface.Web&version=0.2.0-preview.4&prerelease
#tool nuget:?package=ForgeTrust.AppSurface.Web&version=0.2.0-preview.4&prerelease
AppSurface Web
The ForgeTrust.AppSurface.Web package provides the bootstrapping logic for building ASP.NET Core applications using the AppSurface module system. It sits on top of the compilation concepts defined in ForgeTrust.AppSurface.Core.
Overview
The easiest way to get started is by using the WebApp static entry point. This provides a default setup that works for most applications.
await WebApp<MyRootModule>.RunAsync(args);
For more advanced use cases where you need to customize the startup lifecycle beyond what the options provide, you can extend WebStartup<TModule>.
Release Guidance
AppSurface ships as a coordinated package family. Before installing this package from a prerelease feed, check the package chooser and release hub for current release risk, migration guidance, and readiness.
Key Abstractions
WebApp
The primary entry point for web applications. It handles creating the internal startup class and running the application. It provides a generic overload WebApp<TModule> for standard usage and WebApp<TStartup, TModule> if you have a custom startup class.
IAppSurfaceWebModule
Modules that want to participate in the web startup lifecycle should implement this interface. It extends IAppSurfaceHostModule and adds web-specific hooks:
ConfigureWebOptions: Modify the globalWebOptions(e.g., enable MVC, configure CORS).ConfigureWebApplication: Register pre-routing middleware usingIApplicationBuilder(e.g., exception handling or package-owned static-file middleware that must run before routing).ConfigureEndpointAwareMiddleware: Register middleware that must run after routing has selected an endpoint and before endpoints execute (e.g., root/host-ownedapp.UseAuthentication()andapp.UseAuthorization()).ConfigureEndpoints: Map endpoints usingIEndpointRouteBuilder(e.g.,endpoints.MapGet("/", ...)).
WebStartup
The base class for the application bootstrapping logic. While WebApp uses a generic version of this internally, you can extend it if you need deep customization of the host builder or service configuration logic.
Features
PWA application badging
AppSurface Web includes a default-off, privacy-safe browser rail for application-icon badge requests:
options.Pwa.Badging.Enabled = true;
With <appsurface:pwa-head /> in the page head, applications can call AppSurface.Pwa.badging.set(count) or .clear(). Successful calls resolve to "accepted" or "unsupported"; invalid counts and native failures reject with sanitized ASPWAJS040–ASPWAJS042 errors. "accepted" means only that the native request resolved and never proves a visible icon badge. When offline or push already activates the shared worker, the same API is installed in that worker after normal service-worker activation. Badging alone does not create a worker, own an attention count, request permission, change the push payload, or add CLI verification.
Start with the PWA badging quick start, then use the executable accessible proof. The full guide documents configuration, sanitized ASPWAJS040–042 failures, PathBase, activation lag, privacy boundaries, and unsupported-browser behavior.
Health and Readiness Probes
AppSurface Web maps public platform probe endpoints by default:
/healthruns every registered ASP.NET Core health check./readyruns only checks tagged withAppSurfaceHealthCheckTags.Ready.
Both endpoints return minimal plain-text aggregate status (Healthy, Degraded, or Unhealthy), set no-store headers,
and return 200 only for Healthy. Degraded and Unhealthy return 503 so Kubernetes, Aspire, Docker, and similar
platforms can remove an instance from traffic. The endpoints are excluded from API Explorer/OpenAPI by default because
they are platform probes, not application API operations.
If no checks are tagged with AppSurfaceHealthCheckTags.Ready, /ready reports Healthy once the web app has started.
Add ready-tagged checks for dependencies that must be available before the app receives traffic.
3-minute health and readiness path
Register normal ASP.NET Core health checks from your module or host. Tag startup-critical checks with
AppSurfaceHealthCheckTags.Ready:
using ForgeTrust.AppSurface.Web;
using Microsoft.Extensions.Diagnostics.HealthChecks;
public sealed class MyRootModule : IAppSurfaceWebModule
{
public void ConfigureServices(StartupContext context, IServiceCollection services)
{
services.AddHealthChecks()
.AddCheck(
"database",
() => HealthCheckResult.Healthy("Database connection accepted."),
tags: [AppSurfaceHealthCheckTags.Ready]);
}
}
Run the app and verify the default routes:
curl -i http://127.0.0.1:5000/health
curl -i http://127.0.0.1:5000/ready
curl -I http://127.0.0.1:5000/ready
Use the endpoints as platform probes:
livenessProbe:
httpGet:
path: /health
port: http
periodSeconds: 10
timeoutSeconds: 2
readinessProbe:
httpGet:
path: /ready
port: http
periodSeconds: 5
timeoutSeconds: 2
Customize or disable the routes through WebOptions.Health:
await WebApp<MyRootModule>.RunAsync(
args,
options =>
{
options.Health.HealthPath = "/internal/live";
options.Health.ReadyPath = "/internal/ready";
// options.Health.Enabled = false; // Use this when the host owns probe endpoints directly.
});
Important behavior:
- Probe paths must be app-root-relative paths without route parameters, query strings, fragments, whitespace, or traversal segments.
HealthPathandReadyPathmust be distinct after normalization.- AppSurface fails startup if a module or
WebOptions.MapEndpointsalready mapped the configured probe path. Move the existing endpoint, changeWebOptions.Healthpaths, or disable AppSurface health endpoints. - The endpoints are intentionally public and unauthenticated so infrastructure probes can reach them. Keep them behind normal platform controls such as cluster-local networking, ingress allowlists, or rate limiting when they are exposed beyond private service traffic.
- Public probes expose only aggregate status. Use authenticated support surfaces, such as Config audit diagnostics, for detailed operator evidence.
- The product-readiness lab's
/readinessendpoint is an example-local report endpoint. It is separate from the reusable AppSurface Web/readyplatform probe contract.
Named Canary Endpoints
Named canaries are a preview primitive for protected, application-owned deploy evidence. They are not liveness,
readiness, general diagnostics, dogfood approval, customer readiness, or launch approval. Keep platform traffic decisions
on the /health and /ready probes. A named canary answers one narrower question once:
does the application currently hold acceptable proof for this registered workflow, deploy marker, and freshness boundary?
AppSurface owns registration, the fixed protected route, request validation, and status mapping. Your application owns the proof query. The deploy caller triggers synthetic work before evaluation and owns polling, retries, and total timeout. Issue #624 adds a privacy-safe evidence envelope, #625 adds caller-side polling, and #626 adds a neutral end-to-end example. Until that operator rail is proved, this API remains preview. A separate aggregate snapshot follow-up will compile multiple checks with bounded concurrency and deadlines after the safe envelope exists; #623 evaluates one registered name per request.
5-minute named canary path
This path assumes the host already has authentication. If it does not, first choose a host-owned scheme and policy using the AppSurface Auth adoption ladder. Named canaries never install an authentication scheme or define who an operator is.
Start from this compile-only evaluator skeleton. Replace its placeholder result with an application-owned lookup that
uses context.Marker and context.FreshSince; the evaluator must inspect existing proof and must not trigger the workflow:
using ForgeTrust.AppSurface.Web;
public sealed class ForwardingCanaryEvaluator : IAppSurfaceCanaryEvaluator
{
public ValueTask<AppSurfaceCanaryResult> EvaluateAsync(
AppSurfaceCanaryEvaluationContext context,
CancellationToken cancellationToken)
{
// Compile-only placeholder: query application-owned proof using context.Marker and context.FreshSince.
return ValueTask.FromResult(
new AppSurfaceCanaryResult(AppSurfaceCanaryStatus.Pending));
}
}
The placeholder always returns Pending and is not a working deploy proof. Replace it with your application-owned query:
return Pass when matching proof at or after the requested freshness boundary is acceptable, Pending while matching proof
may still arrive, Fail for a completed negative outcome, Stale for proof older than the boundary, and NotConfigured when
the proof dependency is intentionally unavailable.
Register the evaluator and require the inputs your proof query needs. Registration alone exposes no route:
public void ConfigureServices(StartupContext context, IServiceCollection services)
{
services.AddAuthorization(options =>
options.AddPolicy("DeployOperators", policy => policy.RequireAuthenticatedUser()));
services.AddAppSurfaceCanary<ForwardingCanaryEvaluator>(
"forwarding.alpha-evidence",
canary =>
{
canary.RequireMarker();
canary.RequireFreshSince();
});
}
Run host-owned authentication and authorization in the endpoint-aware phase, then map the fixed route family explicitly:
public void ConfigureEndpointAwareMiddleware(StartupContext context, IApplicationBuilder app)
{
app.UseAuthentication();
app.UseAuthorization();
}
public void ConfigureEndpoints(StartupContext context, IEndpointRouteBuilder endpoints)
{
endpoints.MapAppSurfaceCanaries("DeployOperators");
}
Evaluate one registered name with the dedicated headers and an operator credential:
curl --fail-with-body \
-H "Authorization: Bearer $DEPLOY_OPERATOR_TOKEN" \
-H "X-AppSurface-Canary-Marker: deploy-42" \
-H "X-AppSurface-Canary-Fresh-Since: 2026-07-12T12:00:00Z" \
https://app.example.com/_appsurface/canaries/forwarding.alpha-evidence
The response is status-only in #623:
{"name":"forwarding.alpha-evidence","status":"pending"}
Status and HTTP contract
MapAppSurfaceCanaries maps one GET route, /_appsurface/canaries/{name}, for the whole named registry. It is excluded
from API Explorer/OpenAPI and every package-owned response sets Cache-Control: no-store and Pragma: no-cache.
| Evaluator status | Meaning for a caller | Default HTTP status |
|---|---|---|
pass |
Current proof is acceptable. | 200 |
pending |
Acceptable proof may still arrive. | 503 |
fail |
Current proof demonstrates failure. | 503 |
stale |
Proof exists but predates the requested boundary. | 503 |
not-configured |
The registered evaluator cannot inspect an intentionally unavailable proof dependency. | 503 |
The default AppSurfaceCanaryCompletedResponseMode.StatusCode makes deployment checks work without parsing JSON.
Authenticated diagnostic/reporting consumers can explicitly opt into AlwaysOk; they must then parse status because
every completed result returns 200:
public void ConfigureEndpoints(StartupContext context, IEndpointRouteBuilder endpoints)
{
endpoints.MapAppSurfaceCanaries(
"DeployOperators",
options => options.CompletedResponseMode = AppSurfaceCanaryCompletedResponseMode.AlwaysOk);
}
This option changes only the HTTP status. It never changes the evaluator contract or JSON. Clients should parse JSON,
use status as the decision field, and ignore unknown properties so the #624 envelope can grow additively.
Registration and request reference
- Names are 1-128 lowercase characters in dot-separated segments. A segment starts and ends with a letter or digit and
may contain internal hyphens, for example
forwarding.alpha-evidence. Matching is exact and ordinal. DisplayNamedefaults to the registered name,Descriptiondefaults tonulland is limited to 512 characters, andTagsis an ordinal set of 1-64 character lowercase letter/digit/internal-hyphen values. These values are immutable registry metadata and are not returned by the #623 endpoint.RequireMarker()andRequireFreshSince()are idempotent. Optional inputs still reach the evaluator when supplied.- Marker values are opaque: AppSurface preserves the value delivered by ASP.NET Core, including internal whitespace,
but HTTP clients and servers may normalize leading or trailing field-value whitespace. Avoid whitespace-significant
markers. Control characters and repeated values are rejected, the maximum is 256 UTF-8 bytes, and a blank optional
marker becomes
null. - Freshness accepts strict RFC 3339 timestamps with seconds, a
Zor numeric offset, and zero to seven fractional digits. It rejects surrounding whitespace, offset-free values, and invalid calendar or offset values, then normalizes to UTC. AddAppSurfaceCanary<TEvaluator>uses a host registration of the concrete evaluator when one already exists. Otherwise it adds the evaluator as transient. Singleton, scoped, and transient overrides are supported; one request resolves the concrete evaluator exactly once from its request scope.- An unknown route name returns
404.not-configuredis different: the name is registered and its evaluator ran. - One request performs one evaluation. Request abort cancellation propagates. The package adds no evaluator timeout, retry, polling loop, cache, fan-out, trigger, or readiness effect.
Authorization and route sharp edges
Mapping requires a nonblank host-owned policy and registered ASP.NET Core authorization services. Native policy
resolution remains authoritative, including dynamic policy providers. Authentication and UseAuthorization() must run
after UseRouting() and before the endpoint executes; AppSurface's
ConfigureEndpointAwareMiddleware hook is the intended phase. A missing policy or missing/misordered
authorization middleware fails closed with 500 before evaluator invocation. Native schemes still own challenges,
forbids, and cookie redirects.
Never append AllowAnonymous to the returned RouteHandlerBuilder or remove its required named-policy metadata. The
adapter detects either weakening and returns a safe ASCAN113 500 before name lookup or evaluation. Map named canaries
at the application root, not through a route group: the route must remain exactly /_appsurface/canaries/{name}. The
entire /_appsurface/canaries namespace is reserved while the mapper is active; startup fails with ASCAN115 if the
framework route is relocated or a literal, parameterized, constrained, or controller route exists at that path or below
it. Routes outside that exact namespace, such as /_appsurface/canaries-extra, are unaffected.
Diagnostic rescue table
Problem responses contain only fixed title, status, code, problem, cause, fix, and docsLink fields. The
canary adapter uses package-owned JSON settings rather than the host-global ProblemDetails customizer, so host callbacks
cannot append trace identifiers or request-derived fields. Responses never echo marker/freshness values, registered-name
inventories, exception messages, stacks, or trace identifiers. Host-local logs for evaluation failures contain only a
stable event, canary name, diagnostic code, and exception type.
| Code | Problem | Likely cause | Fix |
|---|---|---|---|
ASCAN101 |
Invalid registration | Name, display metadata, description, or tag grammar is invalid. | Correct the registration callback before building the host. |
ASCAN102 |
Duplicate name | More than one registration used the same exact name. | Keep one registration per name. |
ASCAN111 |
Invalid mapping policy | The supplied policy name is blank. | Pass a nonblank host-owned policy name. |
ASCAN112 |
No registrations | The route was mapped without any named canaries. | Register at least one typed evaluator first. |
ASCAN113 |
Unsafe/missing authorization | Authorization services are absent, anonymous metadata bypassed the policy, or the required named-policy metadata was removed. | Register authorization, remove AllowAnonymous, and retain the mapped named policy. |
ASCAN114 |
Repeated mapping | The fixed route family was mapped more than once. | Map it exactly once. |
ASCAN115 |
Fixed/reserved route conflict | Mapping through a route group relocated the fixed route, or a host endpoint overlaps the canary namespace. | Map on the application root and move host routes outside /_appsurface/canaries. |
ASCAN116 |
Invalid response mode | The mapper callback assigned an undefined enum value. | Choose StatusCode or AlwaysOk. |
ASCAN201 |
Required header missing | A registration-required marker or freshness value is blank/absent. | Supply the named header and retry. |
ASCAN202 |
Header invalid | A header is repeated, malformed, unsafe, or too large. | Follow the marker or strict freshness rules above. |
ASCAN203 |
Canary not found | The exact route name is not registered. | Register it or correct the lowercase name. |
ASCAN301 |
Evaluation failed | Activation failed, the evaluator threw/canceled independently, or returned null. | Inspect host-local evaluator diagnostics; caller retry policy remains external. |
PWA Install and Push-Worker Foundation
AppSurface Web provides independent PWA install, offline, and push-worker capabilities from WebOptions.Pwa: a manifest endpoint, MVC/Razor head tags, development diagnostics, an explicit starter offline strategy, safe default notification handlers, and an inert registration helper. Every capability is disabled by default. Enabling push does not request permission, create a subscription, choose recipients, or send a notification. Add the optional ForgeTrust.AppSurface.Web.Push package for protected subscription intake, VAPID, encrypted one-attempt sending, and stale-subscription cleanup while retaining app-owned custody and product policy.
3-minute PWA install path
Configure install metadata in the existing Web package:
await WebApp<MyRootModule>.RunAsync(
args,
options =>
{
options.Mvc = options.Mvc with { MvcSupportLevel = MvcSupport.ControllersWithViews };
options.Pwa.Enabled = true;
options.Pwa.Name = "Contoso Field Notes";
options.Pwa.ShortName = "Field Notes";
options.Pwa.ThemeColor = "#2563eb";
options.Pwa.BackgroundColor = "#ffffff";
options.Pwa.Icons.Add(new PwaIcon { Source = "/icons/app-192.png", Sizes = "192x192", Type = "image/png" });
options.Pwa.Icons.Add(new PwaIcon { Source = "/icons/app-512.png", Sizes = "512x512", Type = "image/png" });
});
Add the head helper to your MVC layout:
<appsurface:pwa-head />
Run the app on HTTPS or localhost, then verify the install contract:
appsurface pwa verify --base-url https://app.example.com --entry-path /account/resume --expect-display standalone --expect-icon 192x192 --expect-icon 512x512
Pwa.Enabled controls install metadata only. Pwa.Offline.Enabled and Pwa.Push.Enabled independently activate one shared worker, so an existing PWA can adopt push handlers without replacing its install experience. Push-only mode installs no fetch listener and creates no cache. When push is enabled, the head helper loads an external script exposing window.AppSurface.Pwa.register(); the application decides when to call it.
For push-only and combined quick starts, the complete option/payload/helper contract, ownership boundaries, migration guidance, browser caveats, and CLI proof flow, see PWA install and push-worker support. For executable combined proof, run examples/web-pwa-install.
Middleware Lifecycle
AppSurface Web keeps middleware and endpoint mapping in three explicit phases:
ConfigureWebApplicationruns before static files, routing, AppSurface CORS, and endpoint execution. Use it for middleware that does not need endpoint metadata.ConfigureEndpointAwareMiddlewareruns afterUseRouting()and AppSurface-managed CORS, before endpoint execution. At request time, middleware here can inspectHttpContext.GetEndpoint(), route values, and endpoint metadata for matched endpoints.ConfigureEndpointsmaps module endpoints.WebOptions.MapEndpointsmaps direct host endpoints in the same endpoint-routing phase.
Global ASP.NET Core authentication and authorization middleware should be registered once from the root or host integration module:
public sealed class MyRootModule : IAppSurfaceWebModule
{
public void ConfigureServices(StartupContext context, IServiceCollection services)
{
services.AddAuthentication(/* scheme configuration */);
services.AddAuthorization();
}
public void ConfigureEndpointAwareMiddleware(StartupContext context, IApplicationBuilder app)
{
app.UseAuthentication();
app.UseAuthorization();
}
}
The root/host module's endpoint-aware middleware runs before dependency feature modules, so feature middleware can rely on root-owned authentication having already run. Feature modules should only register endpoint-aware middleware they own; they should not each install global authentication or authorization middleware. Apps that need bespoke pipeline policy beyond AppSurface's module hooks can use a custom WebStartup<TModule>.
Endpoint-aware middleware must tolerate HttpContext.GetEndpoint() being null for unmatched requests. Do not call UseRouting, UseCors, UseEndpoints, or map endpoints from this hook.
MVC and Controllers
Support for MVC approaches can be configured via WebOptions:
- None: For pure Minimal APIs (default).
- Controllers: For Web APIs using controllers (
AddControllers). - ControllersWithViews: For traditional MVC apps with views.
- Full: Full MVC support.
CORS
Built-in support for CORS configuration:
- Enforced Origin Safety: When
EnableCorsis true, you MUST specify at least one origin inAllowedOrigins, unless running in Development withEnableAllOriginsInDevelopmentenabled (the default). IfAllowedOriginsis empty in production or whenEnableAllOriginsInDevelopmentis disabled, the application will throw a startup exception to prevent unintended security openness (verified by testsEmptyOrigins_WithEnableCors_ThrowsExceptionandEnableAllOriginsInDevelopment_AllowsAnyOrigin). A literal origin wildcard (["*"]) is also rejected outside Development for AppSurface-managed CORS; use explicit browser origins such as["https://app.example.com"]. - Development Convenience:
EnableAllOriginsInDevelopment(enabled by default) automatically allows any origin when the environment isDevelopment. WhenAllowedHeadersandAllowedMethodsare empty or omitted, theDefaultCorsPolicyalso allows any header and method for local convenience; configured values keep those header and method restrictions enforced even in Development. - Origin Wildcards Are Not All The Same:
AllowedOrigins = ["*"]means every origin and is only available through the development compatibility path.AllowedOrigins = ["https://*.example.com"]is ASP.NET Core wildcard-subdomain matching and remains supported. Host binding wildcards such ashttp://*:5000decide which network interfaces Kestrel listens on; they do not make browser CORS responses public. - Header and Method Control:
AllowedHeadersandAllowedMethodsdefault to empty arrays in production, so AppSurface does not silently allow every preflight header and method once CORS is enabled. Set each collection to the browser contract the app actually supports, for example["Content-Type", "X-Request-Id"]and[HttpMethods.Get, HttpMethods.Post]. Use["*"]only when a production app intentionally wantsAllowAnyHeader()orAllowAnyMethod(). - Default Policy: Configures a policy named "DefaultCorsPolicy" (configurable) and automatically registers the CORS middleware.
await WebApp<MyRootModule>.RunAsync(
args,
options =>
{
options.Cors.EnableCors = true;
options.Cors.AllowedOrigins = ["https://app.example.com"];
options.Cors.AllowedHeaders = ["Content-Type", "X-Request-Id"];
options.Cors.AllowedMethods = [HttpMethods.Get, HttpMethods.Post];
});
The production default is deliberately stricter than older AppSurface previews: AllowedOrigins decides which browser
frontends may read cross-origin responses, while AllowedHeaders and AllowedMethods decide which preflighted browser
requests are accepted from those origins. Keep them explicit for production APIs, especially when credentials are
allowed. Migrate older permissive production configuration from:
options.Cors.EnableCors = true;
options.Cors.AllowedOrigins = ["*"];
to:
options.Cors.EnableCors = true;
options.Cors.AllowedOrigins = ["https://app.example.com"];
For an intentionally public API that must own wildcard CORS in production, do not use the AppSurface-managed policy for that surface. Register and apply the host's ASP.NET Core CORS policy directly so the public contract is visible in the application's own startup code.
Endpoint Routing
Modules can define their own endpoints, making it easy to slice features vertically ("Vertical Slice Architecture").
Config Audit HTTP Diagnostics
MapAppSurfaceConfigAuditDiagnostics maps an authenticated GET endpoint that returns the active host's sanitized
ConfigAuditReport JSON. The endpoint is support-sensitive operator evidence capture: it can expose provider names,
configuration keys, source paths, diagnostics, and redaction metadata even though values are already sanitized by
ForgeTrust.AppSurface.Config.
The endpoint is never mapped automatically. The default route is
/_appsurface/config/audit (AppSurfaceConfigAuditDiagnosticsDefaults.DefaultRoute), and hosts can pass a custom route
when their operations model keeps diagnostics under a different path.
Config Audit HTTP in 5 minutes
Add the Config module, configure normal ASP.NET Core authentication and authorization, install the authorization middleware, then opt in to the endpoint:
public sealed class MyRootModule : IAppSurfaceWebModule
{
public void RegisterDependentModules(ModuleDependencyBuilder builder)
{
builder.AddModule<AppSurfaceConfigModule>();
}
public void ConfigureServices(StartupContext context, IServiceCollection services)
{
services.AddAuthentication(/* host scheme */);
services.AddAuthorization(options =>
{
options.AddPolicy("ConfigAuditRead", policy => policy.RequireAuthenticatedUser());
});
}
public void ConfigureHostBeforeServices(StartupContext context, IHostBuilder builder)
{
}
public void ConfigureHostAfterServices(StartupContext context, IHostBuilder builder)
{
}
public void ConfigureEndpointAwareMiddleware(StartupContext context, IApplicationBuilder app)
{
app.UseAuthentication();
app.UseAuthorization();
}
public void ConfigureEndpoints(StartupContext context, IEndpointRouteBuilder endpoints)
{
endpoints.MapAppSurfaceConfigAuditDiagnostics("ConfigAuditRead");
}
}
Capture the report only under your support policy:
curl -H "Authorization: Bearer $SUPPORT_TOKEN" \
https://app.example.com/_appsurface/config/audit \
> production.config-audit.json
Compare captured host snapshots later with the Config package's captured-snapshot diff workflow: Config diff in 10 minutes.
Important behavior:
- Mapping validates a non-null endpoint builder plus non-blank route and policy names at startup.
- The endpoint maps
GETonly, appliesRequireAuthorization(policyName), and is hidden from API Explorer/OpenAPI by default withExcludeFromDescription(). - Successful responses and AppSurface-owned setup/runtime failures set
Cache-Control: no-storeandPragma: no-cache. - Missing Config services, missing environment services, blank environment names, reporter failures, and report JSON
failures return safe
500ProblemDetails withproblem,cause,fix, anddocsLinkextensions. - Native ASP.NET Core authorization still owns challenge and forbid behavior. Cookie hosts may redirect on unauthorized
requests unless the host's auth scheme is configured for API-style
401/403responses. - Missing authorization policies or missing authorization middleware fail closed through ASP.NET Core's own runtime behavior before the handler runs.
- AppSurface Web does not add rate limiting. Put host-owned rate limiting, network controls, audit logging, or break-glass workflow around this route when support access requires it.
For custom JSON options, ProblemDetails shape, discovery behavior, rate limiting, or auth response formatting, write a
host-owned MapGet endpoint over IConfigAuditReporter instead of using this package mapper.
Browser Status Pages
AppSurface Web includes conventional browser-facing pages for empty 401, 403, and 404 status responses. The feature is designed for human browser requests: it keeps the original HTTP status code, shows recovery-oriented HTML, and leaves JSON/API responses alone.
Browser status pages in 2 minutes
If your app already uses MVC views, keep the default Auto mode:
public void ConfigureWebOptions(StartupContext context, WebOptions options)
{
options.Mvc = options.Mvc with { MvcSupportLevel = MvcSupport.ControllersWithViews };
}
If your app starts with controllers only but still wants the browser pages, opt in explicitly:
public void ConfigureWebOptions(StartupContext context, WebOptions options)
{
options.Mvc = options.Mvc with { MvcSupportLevel = MvcSupport.Controllers };
options.Errors.UseConventionalBrowserStatusPages();
}
Preview the built-in pages while the app is running:
| Status | Preview URL |
|---|---|
401 |
/_appsurface/errors/401 |
403 |
/_appsurface/errors/403 |
404 |
/_appsurface/errors/404 |
Override any page with the conventional app or shared Razor Class Library path:
| Status | Override path |
|---|---|
401 |
~/Views/Shared/401.cshtml |
403 |
~/Views/Shared/403.cshtml |
404 |
~/Views/Shared/404.cshtml |
Use BrowserStatusPageModel in overrides:
@model ForgeTrust.AppSurface.Web.BrowserStatusPageModel
<h1>HTTP @Model.StatusCode</h1>
<p>@Model.OriginalPath</p>
Mode behavior:
| Mode | Behavior |
|---|---|
BrowserStatusPageMode.Auto |
Enables browser status pages only when MVC support already includes views. |
BrowserStatusPageMode.Enabled |
Always enables browser status pages and upgrades MVC support to controllers with views when needed. |
BrowserStatusPageMode.Disabled |
Leaves status-code handling fully to the app or other middleware. |
Important behavior:
- Only empty
401,403, and404responses fromGETorHEADrequests that accepttext/htmlorapplication/xhtml+xmlare re-executed. - JSON, non-HTML, non-empty, and non-GET/HEAD responses keep their original API-friendly behavior.
- The framework fallback is app-agnostic. It includes a generic home recovery link and does not inspect product-specific route families such as
/docs. - Apps that need domain-specific recovery, such as documentation search for stale docs links, should add
~/Views/Shared/404.cshtmland render links from their own route contracts. - Static export remains conservative: RazorWire CLI probes
/_appsurface/errors/404and writes only404.html; it does not emit401.htmlor403.html. In CDN mode, that404.htmlpage is validated and rewritten with the rest of the static output. The fallbackReturn homelink is markeddata-rw-export-ignoreso apps that do not export/can still publish a valid conventional404.html. - Production
500exception pages are intentionally separate from browser status pages and must be enabled withUseConventionalExceptionPage().
Conventional Production 500 Pages
AppSurface Web can also own a safe browser-facing production 500 page for unhandled exceptions. This is off by default because exception handling is usually application policy. Opt in through WebOptions.Errors.UseConventionalExceptionPage() when a normal MVC-style app wants a generic HTML failure page without writing exception middleware by hand.
await WebApp<MyRootModule>.RunAsync(
args,
options => options.Errors.UseConventionalExceptionPage());
The conventional exception page uses ASP.NET Core exception handling, not status-code pages. That distinction matters: status-code pages can render empty 404 or 500 responses, but they do not catch thrown exceptions. AppSurface registers the exception handler early enough to catch module middleware and endpoint failures, then renders a Razor view only for requests that accept text/html or application/xhtml+xml.
- The default view returns HTTP
500, generic recovery copy, a home link, and a request id that operators can correlate with logs. - The default
ExceptionPageModelcontains onlyStatusCodeandRequestId; it does not expose exception messages, stack traces, headers, cookies, route values, or form fields. - Applications can override the page with
~/Views/Shared/500.cshtml. Keep the page generic and support-oriented. Do not readIExceptionHandlerFeature, request headers, cookies, route values, or form values unless the app has a deliberate reviewed disclosure policy. - Development keeps its existing developer exception behavior. AppSurface does not install the conventional production handler when
StartupContext.IsDevelopmentis true. - API-only apps, JSON problem-details APIs, tenant-specific error pages, or apps with telemetry-first exception middleware should leave this disabled and register their own exception handling.
- Once ASP.NET Core has started a response, exception handling cannot replace it with the conventional page. Design streaming endpoints so failures are reported through the stream protocol rather than relying on a late 500 page.
Executable error-page proof
Use the focused web error-page proof when you want executable evidence rather than API reference prose:
bash examples/web-error-pages/verify.sh
The proof starts a local production-mode app, verifies browser HTML for empty 401, 403, 404, and thrown 500 paths, verifies API requests do not receive browser HTML, and checks that synthetic request sentinels are absent from the production 500 response body.
Configuration and Port Overrides
The web application supports standard ASP.NET Core configuration sources (command-line arguments, environment variables, and appsettings.json).
Deterministic Development Port Default
When an AppSurface web application starts in Development without explicit endpoint configuration, AppSurface Web chooses a deterministic localhost-only fallback URL based on the current workspace path. That gives each local worktree a stable URL instead of every development environment fighting for the same hard-coded dev port.
- Use this default for local
dotnet runconvenience when you do not care about a specific port ahead of time. - AppSurface treats
--environment Development,ASPNETCORE_ENVIRONMENT=Development, andDOTNET_ENVIRONMENT=Developmentas development for both the deterministic port resolver and module-levelStartupContext.IsDevelopmentdecisions. Command-line environment parsing is shared withDefaultEnvironmentProvider, so duplicate--environmentkeys use the last valid value. - Override it any time with
--port,--urls,ASPNETCORE_URLS/URLS,ASPNETCORE_HTTP_PORTS/DOTNET_HTTP_PORTS/HTTP_PORTS,ASPNETCORE_HTTPS_PORTS/DOTNET_HTTPS_PORTS/HTTPS_PORTS,urls/http_ports/https_portsin appsettings, orKestrel:Endpointsin appsettings/environment variables. - Treat the startup log as the source of truth for the selected local URL.
- The automatic fallback and
--portshortcut bind onlyhttp://localhost:{port}. Add--all-hoststo the--portshortcut, or pass an explicit wildcard URL, only when you intentionally need LAN/container access.
Port Overrides
You can override the application's listening port using several methods:
Command-Line: Use
--port(localhost-only shortcut),--portwith--all-hosts, or--urls.dotnet run -- --port 5001 # OR dotnet run -- --port 5001 --all-hosts # OR dotnet run -- --urls "http://localhost:5001"Environment Variables: Set
ASPNETCORE_URLS.export ASPNETCORE_URLS="http://localhost:5001" dotnet runApp Settings: Configure
urlsinappsettings.json.{ "urls": "http://localhost:5001" }Kestrel Endpoints: Configure named endpoints when you need protocol, certificate, or endpoint-specific settings.
{ "Kestrel": { "Endpoints": { "Http": { "Url": "http://localhost:5001" } } } }
The --port flag is a convenience shortcut that maps to http://localhost:{port}. Add --all-hosts to map the same port to http://localhost:{port};http://*:{port} when all-interface access is intentional. The * host is a wildcard binding and can expose the preview host beyond the local machine. If both --port and --urls are provided, --port takes precedence.
[!TIP]
If you rely on the deterministic development-port fallback, different worktrees on the same machine will get different stable ports. If you need a predictable shared URL for docs, QA, or CI instructions, pass --port or --urls explicitly instead of depending on the fallback.
Startup Watchdog
AppSurface Web fails fast when a host does not complete startup within WebOptions.StartupTimeout. The default is 10 seconds. This catches pre-bind stalls where the process is alive but Kestrel has not started listening, including sandbox restrictions, package layout issues, static web asset discovery hangs, and hosted services that block startup.
When the watchdog fires, AppSurface logs the observed startup phase, current directory, application base directory, static web asset mode, endpoint-related startup arguments, and any known Codex sandbox markers such as CODEX_SANDBOX. If a Codex sandbox is detected, try the same command outside the sandbox or with the runner's approved unsandboxed/escalated permission before debugging package layout or hosted-service startup.
Configure or disable the watchdog through WebOptions:
await WebApp<MyRootModule>.RunAsync(
args,
options => options.StartupTimeout = TimeSpan.FromSeconds(60));
Set StartupTimeout to null only when the host intentionally performs long-running pre-bind work. Values at or below zero are invalid; use null instead of TimeSpan.Zero when disabling the guard. The watchdog stops checking once startup completes, so it does not limit normal request processing or long-running background work after the host is listening.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- CliWrap (>= 3.10.1)
- ForgeTrust.AppSurface.Config (>= 0.2.0-preview.4)
- ForgeTrust.AppSurface.Core (>= 0.2.0-preview.4)
NuGet packages (3)
Showing the top 3 NuGet packages that depend on ForgeTrust.AppSurface.Web:
| Package | Downloads |
|---|---|
|
ForgeTrust.RazorWire
ForgeTrust.RazorWire package for AppSurface application composition. |
|
|
ForgeTrust.AppSurface.Web.OpenApi
ForgeTrust.AppSurface.Web.OpenApi package for AppSurface application composition. |
|
|
ForgeTrust.AppSurface.Web.Push
Safe opt-in Web Push subscription and delivery rail for AppSurface hosts. |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.2.0-preview.4 | 42 | 7/18/2026 |
| 0.2.0-preview.2 | 543 | 7/3/2026 |
| 0.2.0-preview.1 | 326 | 6/28/2026 |
| 0.1.0 | 143 | 7/2/2026 |
| 0.1.0-rc.4 | 288 | 6/16/2026 |
| 0.1.0-rc.3 | 195 | 6/8/2026 |
| 0.1.0-rc.2 | 92 | 6/3/2026 |
| 0.1.0-rc.1 | 80 | 5/31/2026 |
| 0.1.0-preview.4 | 78 | 5/25/2026 |
| 0.1.0-preview.3 | 77 | 5/20/2026 |
| 0.1.0-preview.2 | 84 | 5/14/2026 |