Host wiring
This page gives you a complete Program.cs and explains each part. Copy it, then remove what you do not use. The reference host in the repository, src/Beacon.SampleProject/Program.cs, follows the same shape.
Configuration
Section titled “Configuration”Two values are required before anything starts. Keep both out of source control: user-secrets in development, a secret store or environment variables everywhere else.
| Key | Environment variable | Value |
|---|---|---|
ConnectionStrings:BeaconContext | ConnectionStrings__BeaconContext | Connection string of Beacon’s metadata database |
Beacon:EncryptionKey | Beacon__EncryptionKey | Base64 of at least 32 random bytes: openssl rand -base64 32 |
dotnet user-secrets initdotnet user-secrets set "ConnectionStrings:BeaconContext" "Host=localhost;Database=beacon;Username=beacon;Password=…"dotnet user-secrets set "Beacon:EncryptionKey" "$(openssl rand -base64 32)"The database must exist; Beacon creates its schema and tables in it (see Metadata database).
Beacon checks the encryption key in every environment and refuses to start when it is missing, shorter than 32 bytes, not random-looking (all printable ASCII, or fewer than 16 distinct byte values), a published example value, or an unexpanded ${…} placeholder. Outside Development you also need Beacon:DataProtection:ApplicationName (see Data Protection). The configuration reference lists every other key.
The complete Program.cs
Section titled “The complete Program.cs”This host keeps Beacon’s metadata in PostgreSQL, monitors PostgreSQL, signs users in with Beacon’s own user store (plus optional OIDC), runs jobs on Warp, and exposes the MCP server. BeaconScheduler and BeaconClaimsTransformation are small classes you add to your project. See Background jobs and the claims transformation below.
using Beacon.AI; // AddBeaconAIusing Beacon.Api; // AddBeaconApiServicesusing Beacon.Api.Authentication; // cookie/OIDC/JWT/Data Protection helpers + middlewareusing Beacon.Api.Endpoints; // AddBeaconApiAuthorization, MapBeaconApi, MapLoginEndpoints, MapSetupEndpointsusing Beacon.Connector.PostgreSql; // AddPostgreSqlConnectorusing Beacon.Core; // AddBeaconServices, BeaconConfigurationusing Beacon.Core.Authentication.Providers; // DatabaseAuthenticationProviderusing Beacon.Core.PostgreSql; // UsePostgreSqlusing Beacon.MCP; // AddBeaconMcp, MapBeaconMcpusing Beacon.MCP.Discovery; // MapMcpDiscovery, UseMcpResourceMetadataChallengeusing Beacon.UI; // MapBeaconUi, BeaconUiEndpointRouteBuilderExtensions.BasePath
var builder = WebApplication.CreateBuilder(args);const string beaconBasePath = BeaconUiEndpointRouteBuilderExtensions.BasePath; // "/beacon"
// AI work (documentation, ask) can run for minutes; keep server and HttpClient timeouts long.builder.WebHost.ConfigureKestrel(x =>{ x.Limits.KeepAliveTimeout = TimeSpan.FromMinutes(5); x.Limits.RequestHeadersTimeout = TimeSpan.FromMinutes(5);});builder.Services.AddHttpClient().ConfigureHttpClientDefaults(x => x.ConfigureHttpClient(y => y.Timeout = TimeSpan.FromMinutes(5)));
// Your job runner (here: Warp). See "Background jobs".builder.Services.AddBeaconWarp(builder.Configuration);
// Claims transformation + SignalR user id provider.builder.Services.AddBeaconHostInfrastructure<BeaconClaimsTransformation>();
// 1. Beacon core, connectors, metadata database (UsePostgreSql ends the chain).builder.Services.AddBeaconServices(builder.Configuration, options => { options.AddBeaconScheduler<BeaconScheduler>(); // required options.BaseUrl = "https://beacon.example.com/beacon"; // deep links in notifications; include /beacon options.UseAI = true; // needs AddBeaconAI below options.Authorization.Enabled = true; options.Authentication.EnableLoginForm = true; options.AddAuthenticationProvider<DatabaseAuthenticationProvider>(); options.EnableUserManagement(); // options.EnableApprovalWorkflow(x => x.RequireApprovedVersionToRun = true); // four-eyes, opt-in // options.AddEmailAdapter<SmtpEmailAdapter>(); // your IEmailAdapter }) .AddPostgreSqlConnector() .UsePostgreSql(); // ConnectionStrings:BeaconContext
// 2. Sign-in.builder.Services.AddBeaconCookieAuthentication(beaconBasePath);builder.Services.AddBeaconDataProtection(builder.Configuration, builder.Environment); // Outside Development chain .ProtectKeysWithCertificate(...) or .ProtectKeysWithAzureKeyVault(...) here.builder.Services.AddBeaconOidcAuthentication(builder.Configuration); // no-op unless Beacon:Authentication:Oidc:Enabled
// 3. AI and MCP (optional).builder.Services.AddBeaconAI(builder.Configuration);builder.Services.AddBeaconMcp();
// 4. REST API, policies, realtime, antiforgery, OpenAPI.builder.Services.AddAntiforgery(x =>{ x.HeaderName = "X-XSRF-TOKEN"; x.Cookie.Name = ".Beacon.Antiforgery";});builder.Services.AddBeaconApiAuthorization(); // BeaconApi, Admin, Execute-scope and permission policiesbuilder.Services.AddBeaconApiServices(); // SignalR hub + notifiers; x => x.Realtime = false to turn offbuilder.Services.AddOpenApi();
var app = builder.Build();var beaconConfiguration = app.Services.GetRequiredService<BeaconConfiguration>();
// Beacon does not migrate itself: create the schema and apply migrations at startup.Beacon.Core.ServiceConfiguration.UseBeacon(app.Services, createSchema: true);
// ── Middleware: the order below is load-bearing ──────────────────────────app.UseHttpsRedirection(); // see "HTTPS redirection" below for local MCP over HTTPapp.UseStaticFiles(); // serves the UI bundleapp.UseMcpResourceMetadataChallenge(); // adds resource_metadata to 401s from /beacon/mcpapp.UseMiddleware<ApiKeyAuthMiddleware>(); // Authorization: Bearer sk-sem_…app.UseBeaconJwtBearerAuthentication(); // bearer JWTs (no-op unless enabled)app.UseAuthentication();app.UseMiddleware<BeaconCookieAuthMiddleware>(); // must run before UseAuthorizationapp.UseAuthorization();app.UseMiddleware<LoginFormAuthMiddleware>(beaconConfiguration, beaconBasePath);app.UseMiddleware<FirstRunSetupMiddleware>(beaconConfiguration, beaconBasePath);app.UseAntiforgery();
// ── Endpoints ────────────────────────────────────────────────────────────app.MapOpenApi(); // /openapi/v1.jsonapp.MapBeaconApi(); // /beacon/api/* and the /beacon/api/hub SignalR hubapp.MapLoginEndpoints(beaconBasePath, beaconConfiguration); // /beacon/api/auth/login, logout, sso/challengeapp.MapSetupEndpoints(beaconBasePath); // /beacon/api/setup/* (first-run admin)app.MapBeaconMcp(); // /beacon/mcp, Execute scope requiredapp.MapMcpDiscovery(); // /.well-known/oauth-protected-resource, server cardapp.MapBeaconUi(); // React app at /beacon
await app.StartBeaconWarpAsync(); // your job runner's startup (see "Background jobs")app.Run();AddBeaconWarp and StartBeaconWarpAsync are two small extension methods you write once; Background jobs shows them. With another job runner, replace both lines.
AddOpenApi() and MapOpenApi() need a direct reference to Microsoft.AspNetCore.OpenApi in your project (dotnet add package Microsoft.AspNetCore.OpenApi); getting it only through Moberg.Beacon.Api fails the build with CS9137. Drop both lines if you do not want an OpenAPI document.
What each part does
Section titled “What each part does”AddBeaconServices
Section titled “AddBeaconServices”public static BeaconBuilder AddBeaconServices( this IServiceCollection services, IConfiguration configuration, Action<BeaconConfiguration> beaconConfiguration)It registers Beacon’s core services and returns a BeaconBuilder. Chain connectors and host extensions on the builder, and finish with UsePostgreSql(...) or UseSqlServer(...). Those return IServiceCollection, so they end the chain.
builder.Services.AddBeaconServices(builder.Configuration, options => { /* … */ }) .AddPostgreSqlConnector() // connectors: BeaconBuilder → BeaconBuilder .ExposeDbContext<ShopDbContext>(…) // host extensions: BeaconBuilder → BeaconBuilder .UsePostgreSql(); // metadata DB: BeaconBuilder → IServiceCollection (last)BeaconConfiguration is set in code through the lambda; it is not bound from appsettings.json.
| Option | Default | What it does |
|---|---|---|
AddBeaconScheduler<T>() | None (required) | Your IBeaconScheduler. Without one, AddBeaconServices throws Implementation of IBeaconScheduler is required. |
BaseUrl | null | Public URL of the UI, including /beacon (e.g. https://beacon.example.com/beacon). Notification messages link to {BaseUrl}/notifications/{id}. Editable later in Admin settings. |
UseAI | false | Turns AI features on. Also call AddBeaconAI. |
Authorization.Enabled | false | Role-based authorization. Turn it on in every real deployment. |
AddAuthorizationProvider<T>() | user store when user management is on, otherwise allow-all | Custom IBeaconAuthorizationProvider (global read/write ceiling per user). |
Authentication.EnableLoginForm | false | Beacon’s login page and the redirect of signed-out browsers to /beacon/login. |
AddAuthenticationProvider<T>() | user store when user management is on | Password check. DatabaseAuthenticationProvider uses Beacon’s user store. |
Authentication.CookieExpirationHours / RememberMeExpirationDays | 24 / 30 | Requested cookie lifetime; always capped by the session’s absolute lifetime (12 h by default). |
Authentication.LoginRedirectPath | /beacon/home | Where the login page sends a user after sign-in. |
EnableUserManagement(x => …) | off | Beacon’s user store: users, roles, first-run setup, password policy (MinimumPasswordLength 8, RequirePasswordComplexity true, AllowInternalUsers true). |
EnableApprovalWorkflow(x => …) | off | Four-eyes review of query changes and, by default, migration jobs, Custom SQL rules and recipient destinations. See Approvals. |
AddEmailAdapter<T>() | none | Your IEmailAdapter. Without one there is no email channel. |
SavedQueryTools.NamedToolLimit | 25 | Above this many published saved queries, MCP lists two generic tools instead of one tool per query. |
Only two parts of user management are also read from configuration: Beacon:UserManagement:SetupToken and Beacon:UserManagement:LocalLogin:*.
The metadata database
Section titled “The metadata database”UsePostgreSql() and UseSqlServer() read ConnectionStrings:BeaconContext. Overloads take a connection string, or, on PostgreSQL, your own NpgsqlDataSource. The schema is always beacon. Details: Metadata database.
Authentication helpers
Section titled “Authentication helpers”| Call | What it does |
|---|---|
AddBeaconHostInfrastructure<TClaimsTransformation>() | Registers your claims transformation and the SignalR user-id provider. |
AddBeaconCookieAuthentication(basePath) | The Beacon.Auth cookie (HttpOnly, SameSite=Lax, Secure on HTTPS), server-side session revalidation, JSON 401/403 for API and MCP calls instead of redirects. Pass "/beacon", so the login path is /beacon/login. |
AddBeaconDataProtection(configuration, environment) | Stores the Data Protection key ring in Beacon’s database. Skip it if your host already configures Data Protection. |
AddBeaconOidcAuthentication(configuration) | OIDC sign-in from Beacon:Authentication:Oidc (does nothing unless Enabled). |
AddBeaconJwtAuthentication(jwt => …) | Bearer JWTs for the REST API and MCP (code-configured). |
All options are on Sign-in & identity.
API services
Section titled “API services”| Call | What it does |
|---|---|
AddBeaconApiAuthorization() | Registers the BeaconApi, BeaconApiAdmin and Execute-scope policies plus one policy per functional permission. Required by MapBeaconApi and MapBeaconMcp. |
AddBeaconApiServices() | SignalR, the login rate limiter, notifiers. Options: Realtime (default true), ConfigureSignalR (e.g. a Redis backplane), ExternalLogin (when your app owns sign-in). |
AddAntiforgery(...) | The UI sends the antiforgery token in X-XSRF-TOKEN; it gets it from GET /beacon/api/csrf. API-key and bearer callers are exempt. |
// Realtime off: no SignalR, no hub route; the UI simply does not connect.builder.Services.AddBeaconApiServices(x => x.Realtime = false);
// Several instances: a SignalR backplane.builder.Services.AddBeaconApiServices(x => x.ConfigureSignalR = signalR => signalR.AddStackExchangeRedis(redisConnectionString));
// Your app owns sign-in: the UI sends signed-out users to your login page.builder.Services.AddBeaconApiServices(x => x.ExternalLogin = new ExternalLoginOptions { LoginUrl = "/account/login", ReturnUrlParameter = "returnUrl" });The middleware pipeline
Section titled “The middleware pipeline”The order is load-bearing. Each step depends on the ones before it:
| # | Middleware | Why it sits here |
|---|---|---|
| 1 | UseForwardedHeaders (behind a proxy) | Everything after it must see the client’s address and scheme: the login throttle is per IP, the cookie’s Secure flag follows the scheme. |
| 2 | UseHttpsRedirection | API keys travel in headers; never accept them over plain HTTP outside development. |
| 3 | UseStaticFiles | Serves the UI bundle before authentication runs. |
| 4 | Your exception handler (optional) | Turns Beacon’s exceptions into problem+json. See below. |
| 5 | UseMcpResourceMetadataChallenge | Decorates every 401 from /beacon/mcp with the RFC 9728 resource_metadata pointer; must precede the middleware that writes those 401s. |
| 6 | ApiKeyAuthMiddleware | Authenticates Authorization: Bearer sk-sem_… before cookie auth could redirect an MCP client to a login page. |
| 7 | UseBeaconJwtBearerAuthentication | Bearer JWTs (Entra and other OIDC access tokens). |
| 8 | UseAuthentication | Your own schemes. |
| 9 | BeaconCookieAuthMiddleware | Fills HttpContext.User from the Beacon.Auth cookie before authorization evaluates policies. |
| 10 | UseAuthorization | Endpoint policies. |
| 11 | LoginFormAuthMiddleware | Redirects signed-out browsers to /beacon/login (only when EnableLoginForm). |
| 12 | FirstRunSetupMiddleware | Until the first admin exists, redirects requests (/beacon/api/health and /openapi/v1.json included) to /beacon/setup (only with user management). |
| 13 | UseAntiforgery | After authentication, so tokens are issued for the signed-in user. |
Endpoints
Section titled “Endpoints”| Call | Maps |
|---|---|
MapBeaconApi() | /beacon/api/* (cookie or API key, BeaconApi policy, antiforgery and permission filters) and the SignalR hub /beacon/api/hub. Migration endpoints only when Beacon:Migrations:Enabled. |
MapLoginEndpoints("/beacon", configuration) | POST /beacon/api/auth/login (only when password sign-in is offered), POST /beacon/api/auth/logout, GET /beacon/api/auth/sso/challenge. |
MapSetupEndpoints("/beacon") | GET /beacon/api/setup/status, POST /beacon/api/setup/superadmin, GET /beacon/api/setup/roles. Extension on WebApplication. Map it only with user management on. |
MapBeaconMcp() | /beacon/mcp with the Execute-scope policy. Never map MCP yourself with MapMcp(...) and a bare RequireAuthorization(). |
MapMcpDiscovery() | Anonymous /.well-known/oauth-protected-resource, /.well-known/oauth-protected-resource/beacon/mcp, /.well-known/mcp/server-card.json. |
MapBeaconUi() | The React app at /beacon and its client routes. Your app keeps /. |
MapOpenApi() | Your OpenAPI document, which includes Beacon’s endpoints. Optional. |
The full route list is on Routes & endpoints.
The claims transformation
Section titled “The claims transformation”AddBeaconHostInfrastructure<T>() needs an IClaimsTransformation. With Beacon’s user store, this one adds the user id and name claims and resolves a global role from the store by the session’s external id, never from a name the user typed. It behaves like the reference host’s SampleClaimsTransformation; Beacon’s own role constants are internal, so it carries the role names and levels itself:
using System.Security.Claims;using Beacon.Core.Authorization;using Beacon.Core.Models.UserManagement;using Beacon.Core.Services;using Microsoft.AspNetCore.Authentication;
public sealed class BeaconClaimsTransformation(IUserManagementService users) : IClaimsTransformation{ public async Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal) { if (principal.Identity is not ClaimsIdentity identity || !identity.IsAuthenticated) { return principal; }
var name = identity.Name ?? "unknown"; if (!identity.HasClaim(x => x.Type == BeaconClaims.UserId)) { identity.AddClaim(new Claim(BeaconClaims.UserId, name)); }
if (!identity.HasClaim(x => x.Type == BeaconClaims.UserName)) { identity.AddClaim(new Claim(BeaconClaims.UserName, name)); }
var externalId = identity.FindFirst(ClaimTypes.NameIdentifier)?.Value; if (identity.HasClaim(x => x.Type == BeaconClaims.Role) || string.IsNullOrWhiteSpace(externalId)) { return principal; }
var user = await users.GetUserByExternalIdAsync(externalId); var role = user is { IsEnabled: true } ? ResolveRole(user) : null; if (role != null) { identity.AddClaim(new Claim(BeaconClaims.Role, role)); }
return principal; }
private static string? ResolveRole(BeaconUserData user) { if (user.IsSuperAdmin) { return "Admin"; }
if (user.Roles.Count == 0) { return null; }
// Role levels: Admin 3, Editor 2, Viewer 1. Functional roles (level 0) carry no global role. var level = user.Roles.Max(x => x.Level); return level switch { >= 3 => "Admin", 2 => "Editor", 1 => "Viewer", _ => null, }; }}Error responses
Section titled “Error responses”Beacon’s handlers throw InvalidOperationException for business-rule violations, BeaconException for domain errors and SettingLockedException when a deployment lock refuses a change; access refusals are already mapped to 403/404 by MapBeaconApi. Map the rest to problem+json for /beacon/api so the UI shows the message instead of a generic 500:
using Beacon.Core.Models;
app.UseWhen(x => x.Request.Path.StartsWithSegments("/beacon/api"), branch => branch.Use(async (context, next) =>{ try { await next(context); } catch (Exception ex) when (!context.Response.HasStarted && !context.RequestAborted.IsCancellationRequested) { var (status, title, type) = ex switch { UnauthorizedAccessException or BeaconForbiddenException => (403, "Forbidden", "/errors/forbidden"), SettingLockedException x => (409, x.Message, "/errors/setting-locked"), InvalidOperationException x => (400, x.Message, "/errors/invalid-operation"), BeaconException x => (400, x.Message, "/errors/beacon"), _ => (500, "Internal server error", "/errors/internal"), }; context.Response.StatusCode = status; await context.Response.WriteAsJsonAsync(new { type, title, status }, (System.Text.Json.JsonSerializerOptions?)null, "application/problem+json"); }}));Place it after UseStaticFiles and before the authentication middleware, so their exceptions are mapped too.
Data Protection
Section titled “Data Protection”The Beacon.Auth cookie and antiforgery tokens are protected with ASP.NET Core Data Protection, which belongs to your host:
- Your host already configures Data Protection (shared key ring, Azure Blob, Redis): skip
AddBeaconDataProtection. - Otherwise call
AddBeaconDataProtection(configuration, environment). It keeps the key ring in Beacon’s database (data_protection_keys) under the application nameBeacon:DataProtection:ApplicationName, which is required outsideDevelopmentand takes one name per environment.
Outside Development the key ring must be encrypted at rest or the host does not start. Chain a protector:
builder.Services.AddBeaconDataProtection(builder.Configuration, builder.Environment) .ProtectKeysWithCertificate(certificate); // or .ProtectKeysWithAzureKeyVault(keyId, credential)Beacon:DataProtection:AllowUnencryptedKeys=true acknowledges an unencrypted ring instead (logged as a warning). With encryption on, keys that were stored unencrypted are revoked at startup, so everyone signs in once more.
HTTPS redirection
Section titled “HTTPS redirection”UseHttpsRedirection belongs in every deployment: API keys travel in headers. On a development machine running plain HTTP, local MCP clients connect over HTTP, so the reference host skips the redirect for /beacon/mcp in Development only:
if (app.Environment.IsDevelopment()){ app.UseWhen(x => !x.Request.Path.StartsWithSegments("/beacon/mcp"), y => y.UseHttpsRedirection());}else{ app.UseHttpsRedirection();}Minimal variants
Section titled “Minimal variants”No MCP, no AI. Drop the Moberg.Beacon.AI and Moberg.Beacon.MCP packages, UseAI, AddBeaconAI, AddBeaconMcp, UseMcpResourceMetadataChallenge, MapBeaconMcp and MapMcpDiscovery.
Your app owns sign-in. Leave user management and the login form off, set BeaconApiOptions.ExternalLogin, and make sure your authentication produces a principal with ClaimTypes.NameIdentifier and a Beacon role claim (Admin, Editor or Viewer). Without Beacon’s user store, only the Admin role claim grants functional permissions unless Beacon:Authorization:Roles:HonourHostRoleClaims is true. See Sign-in & identity.
SQL Server metadata. Replace Moberg.Beacon.Core.PostgreSql with Moberg.Beacon.Core.SqlServer and UsePostgreSql() with UseSqlServer(). Warp needs PostgreSQL for its own tables; with SQL Server, use another runner or give Warp a PostgreSQL database.