diff --git a/src/EDNexus.Ebs/Program.cs b/src/EDNexus.Ebs/Program.cs index 217fa32..37fe155 100644 --- a/src/EDNexus.Ebs/Program.cs +++ b/src/EDNexus.Ebs/Program.cs @@ -14,429 +14,479 @@ using Microsoft.AspNetCore.RateLimiting; using Microsoft.Extensions.Options; -var builder = WebApplication.CreateBuilder(args); - -builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(TwitchEbsOptions.SectionName)) - // Fail fast at startup (ids/secrets present, a decodable Extension Secret, an https redirect URI) - // rather than throwing a raw FormatException from inside a request handler the first time a state - // update comes in, or sending viewers to a Twitch error page. See TwitchEbsOptionsValidator. - .ValidateOnStart(); -builder.Services.AddSingleton, TwitchEbsOptionsValidator>(); -builder.Services - .AddOptions() - .Bind(builder.Configuration.GetSection(EbsOptions.SectionName)) - // Includes the snapshot-age floor: the desktop app refreshes an unchanged live card on a fixed - // schedule, so a shorter limit would take live cards down between refreshes. See EbsOptionsValidator. - .ValidateOnStart(); -builder.Services.AddSingleton, EbsOptionsValidator>(); - -// Behind the documented reverse proxy the TCP peer is the proxy, so without this every viewer would -// share one rate-limit bucket (and the client address in any log would be the proxy's). The proxy's -// X-Forwarded-For is trusted ONLY when the peer is a configured proxy, and for one hop. Resolved -// through IOptions so test/host overrides are honoured. -builder.Services - .AddOptions() - .Configure>((forwarded, ebsOptions) => - { - var ebs = ebsOptions.Value; - forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - forwarded.ForwardLimit = 1; - - // ASP.NET trusts loopback by default; replace that with the explicit list (or the defaults). - forwarded.KnownProxies.Clear(); - forwarded.KnownIPNetworks.Clear(); - foreach (var proxy in ebs.TrustedProxies) - if (IPAddress.TryParse(proxy, out var address)) forwarded.KnownProxies.Add(address); - - var networks = ebs.TrustedProxyNetworks; - if (networks.Length == 0 && ebs.TrustedProxies.Length == 0) - networks = EbsOptions.DefaultTrustedProxyNetworks; - foreach (var network in networks) - if (System.Net.IPNetwork.TryParse(network, out var parsed)) forwarded.KnownIPNetworks.Add(parsed); - }); +// The test host (WebApplicationFactory) needs a refused start to surface as an exception, so the +// configuration-error exit below applies everywhere except the Testing environment. +var environmentName = string.Empty; -builder.Services.AddSingleton(TimeProvider.System); -builder.Services.AddSingleton(); -// Signs the EBS's own OUTBOUND JWT for the Helix PubSub call. /api/update-state authenticates via the -// EBS-issued long-lived broadcaster token (IBroadcasterTokenStore); no inbound Twitch JWT is accepted. -builder.Services.AddSingleton(); - -// Durable state (see EbsOptions.StorageProvider): broadcaster tokens + Twitch grants and each -// channel's last published state live in one SQLite file so a crash/restart/redeploy doesn't log -// every broadcaster out. Twitch tokens are encrypted with Data Protection, whose key ring must -// itself be persisted — otherwise every restart would mint a fresh key and orphan the ciphertext. -// Everything resolves through IOptions (not builder.Configuration) so test/host overrides applied -// after this point are honoured. -builder.Services.AddDataProtection().SetApplicationName("EDNexus.Ebs"); -builder.Services - .AddOptions() - .Configure, IHostEnvironment, ILoggerFactory>((keys, ebs, env, loggerFactory) => - { - if (ebs.Value.StorageProvider != EbsStorageProvider.Sqlite) - return; // in-memory state dies with the process anyway; the default key ring is fine. +try +{ + var builder = WebApplication.CreateBuilder(args); + environmentName = builder.Environment.EnvironmentName; + + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(TwitchEbsOptions.SectionName)) + // Fail fast at startup (ids/secrets present, a decodable Extension Secret, an https redirect URI) + // rather than throwing a raw FormatException from inside a request handler the first time a state + // update comes in, or sending viewers to a Twitch error page. See TwitchEbsOptionsValidator. + .ValidateOnStart(); + builder.Services.AddSingleton, TwitchEbsOptionsValidator>(); + builder.Services + .AddOptions() + .Bind(builder.Configuration.GetSection(EbsOptions.SectionName)) + // Includes the snapshot-age floor: the desktop app refreshes an unchanged live card on a fixed + // schedule, so a shorter limit would take live cards down between refreshes. See EbsOptionsValidator. + .ValidateOnStart(); + builder.Services.AddSingleton, EbsOptionsValidator>(); + + // Behind the documented reverse proxy the TCP peer is the proxy, so without this every viewer would + // share one rate-limit bucket (and the client address in any log would be the proxy's). The proxy's + // X-Forwarded-For is trusted ONLY when the peer is a configured proxy, and for one hop. Resolved + // through IOptions so test/host overrides are honoured. + builder.Services + .AddOptions() + .Configure>((forwarded, ebsOptions) => + { + var ebs = ebsOptions.Value; + forwarded.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; + forwarded.ForwardLimit = 1; + + // ASP.NET trusts loopback by default; replace that with the explicit list (or the defaults). + forwarded.KnownProxies.Clear(); + forwarded.KnownIPNetworks.Clear(); + foreach (var proxy in ebs.TrustedProxies) + if (IPAddress.TryParse(proxy, out var address)) forwarded.KnownProxies.Add(address); + + var networks = ebs.TrustedProxyNetworks; + if (networks.Length == 0 && ebs.TrustedProxies.Length == 0) + networks = EbsOptions.DefaultTrustedProxyNetworks; + foreach (var network in networks) + if (System.Net.IPNetwork.TryParse(network, out var parsed)) forwarded.KnownIPNetworks.Add(parsed); + }); + + builder.Services.AddSingleton(TimeProvider.System); + builder.Services.AddSingleton(); + // Signs the EBS's own OUTBOUND JWT for the Helix PubSub call. /api/update-state authenticates via the + // EBS-issued long-lived broadcaster token (IBroadcasterTokenStore); no inbound Twitch JWT is accepted. + builder.Services.AddSingleton(); + + // Durable state (see EbsOptions.StorageProvider): broadcaster tokens + Twitch grants and each + // channel's last published state live in one SQLite file so a crash/restart/redeploy doesn't log + // every broadcaster out. Twitch tokens are encrypted with Data Protection, whose key ring must + // itself be persisted — otherwise every restart would mint a fresh key and orphan the ciphertext. + // Everything resolves through IOptions (not builder.Configuration) so test/host overrides applied + // after this point are honoured. + builder.Services.AddDataProtection().SetApplicationName("EDNexus.Ebs"); + builder.Services + .AddOptions() + .Configure, IHostEnvironment, ILoggerFactory>((keys, ebs, env, loggerFactory) => + { + if (ebs.Value.StorageProvider != EbsStorageProvider.Sqlite) + return; // in-memory state dies with the process anyway; the default key ring is fine. - var configured = ebs.Value.ResolveDataProtectionKeysDirectory(env.ContentRootPath); - DataProtectionKeyRingMove.MoveIfNeeded( - ebs.Value.ResolveDefaultDataProtectionKeysDirectory(env.ContentRootPath), configured, - loggerFactory.CreateLogger(nameof(DataProtectionKeyRingMove))); + var configured = ebs.Value.ResolveDataProtectionKeysDirectory(env.ContentRootPath); + DataProtectionKeyRingMove.MoveIfNeeded( + ebs.Value.ResolveDefaultDataProtectionKeysDirectory(env.ContentRootPath), configured, + loggerFactory.CreateLogger(nameof(DataProtectionKeyRingMove))); - var keysDirectory = Directory.CreateDirectory(configured); - keys.XmlRepository = new FileSystemXmlRepository(keysDirectory, loggerFactory); + var keysDirectory = Directory.CreateDirectory(configured); + keys.XmlRepository = new FileSystemXmlRepository(keysDirectory, loggerFactory); + }); + builder.Services.AddSingleton(sp => + { + var ebs = sp.GetRequiredService>().Value; + var dataDirectory = Directory.CreateDirectory(ebs.ResolveDataDirectory(sp.GetRequiredService().ContentRootPath)); + return new EbsDatabase(Path.Combine(dataDirectory.FullName, EbsDatabase.FileName)); }); -builder.Services.AddSingleton(sp => -{ - var ebs = sp.GetRequiredService>().Value; - var dataDirectory = Directory.CreateDirectory(ebs.ResolveDataDirectory(sp.GetRequiredService().ContentRootPath)); - return new EbsDatabase(Path.Combine(dataDirectory.FullName, EbsDatabase.FileName)); -}); -builder.Services.AddSingleton(sp => -{ - var ebs = sp.GetRequiredService>().Value; - var time = sp.GetRequiredService(); - return ebs.StorageProvider == EbsStorageProvider.Sqlite - ? new SqliteChannelStateStore(sp.GetRequiredService(), time, ebs.ChannelStateMaxAge) - : new InMemoryChannelStateStore(time, ebs.ChannelStateMaxAge); -}); -builder.Services.AddSingleton(sp => - sp.GetRequiredService>().Value.StorageProvider == EbsStorageProvider.Sqlite - ? new SqliteBroadcasterTokenStore( - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService(), - sp.GetRequiredService>()) - : new InMemoryBroadcasterTokenStore(sp.GetRequiredService())); - -builder.Services.AddHttpClient(client => -{ - // A hanging Helix call shouldn't be able to tie up a request indefinitely (the default - // HttpClient timeout is 100s); combined with the per-channel rate limit below, this bounds how - // long a single misbehaving/slow call can hold resources. - client.Timeout = TimeSpan.FromSeconds(10); -}); -builder.Services.AddHttpClient(client => -{ - // Same reasoning as above, and a login/refresh waits on this: fail in seconds, not the 100s default. - client.Timeout = TimeSpan.FromSeconds(15); -}); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); - -// CORS: only the extension's own Twitch-hosted iframe (https://*.ext-twitch.tv) — plus, for local -// development, the Twitch Developer Rig or any origin explicitly listed in Ebs:AdditionalAllowedFrontendOrigins -// — may read GET /api/initial-state/{channelId} from a browser context. -var additionalFrontendOrigins = new HashSet( - builder.Configuration.GetSection(EbsOptions.SectionName).Get()?.AdditionalAllowedFrontendOrigins ?? [], - StringComparer.OrdinalIgnoreCase); -builder.Services.AddCors(options => -{ - options.AddPolicy("extension-frontend", policy => policy - .SetIsOriginAllowed(origin => IsAllowedFrontendOrigin(origin, additionalFrontendOrigins)) - .WithMethods("GET") - .AllowAnyHeader() - // A browser hides every response header outside the CORS-safelisted set from the extension's - // script unless it is exposed; the frontend honours Retry-After on a 429 from initial-state. - .WithExposedHeaders("Retry-After")); -}); - -// Rate limiting: throttle state updates and initial-state reads per broadcaster channel, so a -// misbehaving desktop client (or a burst of viewers) cannot exceed Twitch's own PubSub quota or -// exhaust EBS resources. The channel id is authenticated by AuthenticateBroadcasterMiddleware, which -// runs BEFORE UseRateLimiter — the partition-key callback below runs at routing time, before the -// endpoint delegate's body, so the channel id cannot come from anything set inside the handler -// itself (that would silently degrade every request to per-IP partitioning instead of per-channel). -builder.Services.AddRateLimiter(options => -{ - options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; - // Tell the client how long to wait, rather than leaving it to guess and retry into the same window. - options.OnRejected = (context, _) => + builder.Services.AddSingleton(sp => + { + var ebs = sp.GetRequiredService>().Value; + var time = sp.GetRequiredService(); + return ebs.StorageProvider == EbsStorageProvider.Sqlite + ? new SqliteChannelStateStore(sp.GetRequiredService(), time, ebs.ChannelStateMaxAge) + : new InMemoryChannelStateStore(time, ebs.ChannelStateMaxAge); + }); + builder.Services.AddSingleton(sp => + sp.GetRequiredService>().Value.StorageProvider == EbsStorageProvider.Sqlite + ? new SqliteBroadcasterTokenStore( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService>()) + : new InMemoryBroadcasterTokenStore(sp.GetRequiredService())); + + builder.Services.AddHttpClient(client => { - if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) - context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); - // A throttled answer must never be replayed from a shared cache to someone who is not throttled. - context.HttpContext.Response.Headers.CacheControl = "no-store"; - return ValueTask.CompletedTask; - }; - - options.AddPolicy("update-state", httpContext => + // A hanging Helix call shouldn't be able to tie up a request indefinitely (the default + // HttpClient timeout is 100s); combined with the per-channel rate limit below, this bounds how + // long a single misbehaving/slow call can hold resources. + client.Timeout = TimeSpan.FromSeconds(10); + }); + builder.Services.AddHttpClient(client => + { + // Same reasoning as above, and a login/refresh waits on this: fail in seconds, not the 100s default. + client.Timeout = TimeSpan.FromSeconds(15); + }); + builder.Services.AddHostedService(); + builder.Services.AddHostedService(); + + // CORS: only the extension's own Twitch-hosted iframe (https://*.ext-twitch.tv) — plus, for local + // development, the Twitch Developer Rig or any origin explicitly listed in Ebs:AdditionalAllowedFrontendOrigins + // — may read GET /api/initial-state/{channelId} from a browser context. + var additionalFrontendOrigins = new HashSet( + builder.Configuration.GetSection(EbsOptions.SectionName).Get()?.AdditionalAllowedFrontendOrigins ?? [], + StringComparer.OrdinalIgnoreCase); + builder.Services.AddCors(options => { - var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; - var partitionKey = httpContext.Items.TryGetValue("ChannelId", out var channelId) && channelId is string id - ? id - : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + options.AddPolicy("extension-frontend", policy => policy + .SetIsOriginAllowed(origin => IsAllowedFrontendOrigin(origin, additionalFrontendOrigins)) + .WithMethods("GET") + .AllowAnyHeader() + // A browser hides every response header outside the CORS-safelisted set from the extension's + // script unless it is exposed; the frontend honours Retry-After on a 429 from initial-state. + .WithExposedHeaders("Retry-After")); + }); - return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + // Rate limiting: throttle state updates and initial-state reads per broadcaster channel, so a + // misbehaving desktop client (or a burst of viewers) cannot exceed Twitch's own PubSub quota or + // exhaust EBS resources. The channel id is authenticated by AuthenticateBroadcasterMiddleware, which + // runs BEFORE UseRateLimiter — the partition-key callback below runs at routing time, before the + // endpoint delegate's body, so the channel id cannot come from anything set inside the handler + // itself (that would silently degrade every request to per-IP partitioning instead of per-channel). + builder.Services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + // Tell the client how long to wait, rather than leaving it to guess and retry into the same window. + options.OnRejected = (context, _) => { - PermitLimit = Math.Max(1, ebsOptions.UpdateStateRateLimit), - Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.UpdateStateRateLimitWindowSeconds)), - QueueLimit = 0, + if (context.Lease.TryGetMetadata(MetadataName.RetryAfter, out var retryAfter)) + context.HttpContext.Response.Headers.RetryAfter = ((int)Math.Ceiling(retryAfter.TotalSeconds)).ToString(System.Globalization.CultureInfo.InvariantCulture); + // A throttled answer must never be replayed from a shared cache to someone who is not throttled. + context.HttpContext.Response.Headers.CacheControl = "no-store"; + return ValueTask.CompletedTask; + }; + + options.AddPolicy("update-state", httpContext => + { + var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; + var partitionKey = httpContext.Items.TryGetValue("ChannelId", out var channelId) && channelId is string id + ? id + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = Math.Max(1, ebsOptions.UpdateStateRateLimit), + Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.UpdateStateRateLimitWindowSeconds)), + QueueLimit = 0, + }); }); - }); - // Separate from update-state so switching the card off straight after a publish is never - // rejected by that publish's window — a clear is the privacy-critical request of the two. - options.AddPolicy("clear-state", httpContext => - { - var partitionKey = httpContext.Items.TryGetValue("ChannelId", out var channelId) && channelId is string id - ? id - : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + // Separate from update-state so switching the card off straight after a publish is never + // rejected by that publish's window — a clear is the privacy-critical request of the two. + options.AddPolicy("clear-state", httpContext => { - PermitLimit = 10, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 0, + var partitionKey = httpContext.Items.TryGetValue("ChannelId", out var channelId) && channelId is string id + ? id + : httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = 10, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 0, + }); }); - }); - // Per client address — the forwarded one, since UseForwardedHeaders runs first — so each viewer - // gets their own budget instead of every viewer sharing the proxy's. - options.AddPolicy("initial-state", httpContext => - { - var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; - var partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + // Per client address — the forwarded one, since UseForwardedHeaders runs first — so each viewer + // gets their own budget instead of every viewer sharing the proxy's. + options.AddPolicy("initial-state", httpContext => { - PermitLimit = Math.Max(1, ebsOptions.InitialStateRateLimit), - Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.InitialStateRateLimitWindowSeconds)), - QueueLimit = 0, + var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; + var partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = Math.Max(1, ebsOptions.InitialStateRateLimit), + Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.InitialStateRateLimitWindowSeconds)), + QueueLimit = 0, + }); }); - }); - // The four unauthenticated /oauth/* routes share one per-IP budget. - options.AddPolicy(OAuthEndpoints.RateLimitPolicy, httpContext => - { - var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; - var partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; - return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + // The four unauthenticated /oauth/* routes share one per-IP budget. + options.AddPolicy(OAuthEndpoints.RateLimitPolicy, httpContext => { - PermitLimit = Math.Max(1, ebsOptions.OAuthRateLimit), - Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.OAuthRateLimitWindowSeconds)), - QueueLimit = 0, + var ebsOptions = httpContext.RequestServices.GetRequiredService>().Value; + var partitionKey = httpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown"; + return RateLimitPartition.GetFixedWindowLimiter(partitionKey, _ => new FixedWindowRateLimiterOptions + { + PermitLimit = Math.Max(1, ebsOptions.OAuthRateLimit), + Window = TimeSpan.FromSeconds(Math.Max(1, ebsOptions.OAuthRateLimitWindowSeconds)), + QueueLimit = 0, + }); }); }); -}); -// Only fall back to the configured Ebs:Port when nothing else (ASPNETCORE_URLS, --urls, launch -// profile, etc.) has already told Kestrel what to bind to. -var urlsAlreadyConfigured = builder.Configuration["urls"] is not null - || Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is not null; -if (!urlsAlreadyConfigured && !builder.Environment.IsEnvironment("Testing")) -{ - var configuredPort = builder.Configuration.GetSection(EbsOptions.SectionName).Get()?.Port ?? 8787; - builder.WebHost.UseUrls($"http://0.0.0.0:{configuredPort}"); -} + // Only fall back to the configured Ebs:Port when nothing else (ASPNETCORE_URLS, --urls, launch + // profile, etc.) has already told Kestrel what to bind to. + var urlsAlreadyConfigured = builder.Configuration["urls"] is not null + || Environment.GetEnvironmentVariable("ASPNETCORE_URLS") is not null; + if (!urlsAlreadyConfigured && !builder.Environment.IsEnvironment("Testing")) + { + var configuredPort = builder.Configuration.GetSection(EbsOptions.SectionName).Get()?.Port ?? 8787; + builder.WebHost.UseUrls($"http://0.0.0.0:{configuredPort}"); + } -var app = builder.Build(); + environmentName = builder.Environment.EnvironmentName; // the test host applies its environment late + var app = builder.Build(); -// Open the database (creating the data directory and migrating the schema) now, so a bad -// Ebs:DataDirectory or an unreadable/too-new database fails the deploy instead of the first request. -app.Services.GetRequiredService(); -app.Services.GetRequiredService(); + // Open the database (creating the data directory and migrating the schema) now, so a bad + // Ebs:DataDirectory or an unreadable/too-new database fails the deploy instead of the first request. + app.Services.GetRequiredService(); + app.Services.GetRequiredService(); -// First, so every later middleware (and the rate limiter's partition key) sees the real client address. -app.UseForwardedHeaders(); + // First, so every later middleware (and the rate limiter's partition key) sees the real client address. + app.UseForwardedHeaders(); -// Cap the request body before anything reads it. Kestrel enforces the limit as the body streams (so a -// chunked upload is cut off too); the Content-Length check refuses an honest oversized request without -// reading a byte, and is what a TestServer host (no Kestrel feature) relies on. -app.Use(async (context, next) => -{ - if ((HttpMethods.IsPost(context.Request.Method) || HttpMethods.IsDelete(context.Request.Method)) - && (context.Request.Path.StartsWithSegments("/api/update-state") || context.Request.Path.StartsWithSegments("/oauth"))) + // Cap the request body before anything reads it. Kestrel enforces the limit as the body streams (so a + // chunked upload is cut off too); the Content-Length check refuses an honest oversized request without + // reading a byte, and is what a TestServer host (no Kestrel feature) relies on. + app.Use(async (context, next) => { - var limit = context.RequestServices.GetRequiredService>().Value.MaxRequestBodyBytes; - if (context.Request.ContentLength > limit) + if ((HttpMethods.IsPost(context.Request.Method) || HttpMethods.IsDelete(context.Request.Method)) + && (context.Request.Path.StartsWithSegments("/api/update-state") || context.Request.Path.StartsWithSegments("/oauth"))) { - await Results.Problem( - $"The request body exceeds the {limit} byte limit.", - statusCode: StatusCodes.Status413PayloadTooLarge) - .ExecuteAsync(context).ConfigureAwait(false); - return; - } + var limit = context.RequestServices.GetRequiredService>().Value.MaxRequestBodyBytes; + if (context.Request.ContentLength > limit) + { + await Results.Problem( + $"The request body exceeds the {limit} byte limit.", + statusCode: StatusCodes.Status413PayloadTooLarge) + .ExecuteAsync(context).ConfigureAwait(false); + return; + } - var bodyLimit = context.Features.Get(); - if (bodyLimit is { IsReadOnly: false }) - bodyLimit.MaxRequestBodySize = limit; - } + var bodyLimit = context.Features.Get(); + if (bodyLimit is { IsReadOnly: false }) + bodyLimit.MaxRequestBodySize = limit; + } - await next().ConfigureAwait(false); -}); + await next().ConfigureAwait(false); + }); -app.UseCors("extension-frontend"); + app.UseCors("extension-frontend"); -// Authenticates /api/update-state (via the EBS-issued long-lived broadcaster token — see -// IBroadcasterTokenStore) and stores the verified channel id in HttpContext.Items BEFORE -// UseRateLimiter runs its partition-key callback (rate-limiting middleware evaluates the policy at -// routing time — before the endpoint delegate's body executes — so setting Items from inside the -// handler, as this used to do, was always too late to affect partitioning for that same request). -app.Use(async (context, next) => -{ - if ((HttpMethods.IsPost(context.Request.Method) || HttpMethods.IsDelete(context.Request.Method)) - && context.Request.Path.Equals("/api/update-state", StringComparison.OrdinalIgnoreCase)) + // Authenticates /api/update-state (via the EBS-issued long-lived broadcaster token — see + // IBroadcasterTokenStore) and stores the verified channel id in HttpContext.Items BEFORE + // UseRateLimiter runs its partition-key callback (rate-limiting middleware evaluates the policy at + // routing time — before the endpoint delegate's body executes — so setting Items from inside the + // handler, as this used to do, was always too late to affect partitioning for that same request). + app.Use(async (context, next) => { - var tokenStore = context.RequestServices.GetRequiredService(); - // Taking a card down needs no Twitch grant (PubSub is signed with the extension secret), and - // a broadcaster whose grant lapsed must still be able to stop being shown. - var requireGrant = !HttpMethods.IsDelete(context.Request.Method); - if (TryAuthenticateBroadcaster(context.Request, tokenStore, requireGrant, out var channelId, out var failure)) - { - context.Items["ChannelId"] = channelId; - } - else + if ((HttpMethods.IsPost(context.Request.Method) || HttpMethods.IsDelete(context.Request.Method)) + && context.Request.Path.Equals("/api/update-state", StringComparison.OrdinalIgnoreCase)) { - context.Items["BroadcasterAuthFailure"] = failure; + var tokenStore = context.RequestServices.GetRequiredService(); + // Taking a card down needs no Twitch grant (PubSub is signed with the extension secret), and + // a broadcaster whose grant lapsed must still be able to stop being shown. + var requireGrant = !HttpMethods.IsDelete(context.Request.Method); + if (TryAuthenticateBroadcaster(context.Request, tokenStore, requireGrant, out var channelId, out var failure)) + { + context.Items["ChannelId"] = channelId; + } + else + { + context.Items["BroadcasterAuthFailure"] = failure; + } } - } - await next().ConfigureAwait(false); -}); + await next().ConfigureAwait(false); + }); -app.UseRateLimiter(); + app.UseRateLimiter(); -// Anyone who lands on the bare EBS host (e.g. following the OAuth redirect URI's origin) gets the -// project site rather than a 404. -app.MapGet("/", () => Results.Redirect("https://signal-thread.github.io/EDNexus/")); + // Anyone who lands on the bare EBS host (e.g. following the OAuth redirect URI's origin) gets the + // project site rather than a 404. + app.MapGet("/", () => Results.Redirect("https://signal-thread.github.io/EDNexus/")); -app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); + app.MapGet("/healthz", () => Results.Ok(new { status = "ok" })); -app.MapOAuthEndpoints(); + app.MapOAuthEndpoints(); -app.MapPost("/api/update-state", async ( - HttpRequest httpRequest, - UpdateStateRequest body, - IChannelStateStore stateStore, - ITwitchPubSubClient pubSubClient, - IBroadcasterTokenStore tokenStore, - ChannelLocks channelLocks, - IOptions ebsOptions, - ILogger logger, - CancellationToken cancellationToken) => - { - // Authenticated by the middleware above (long-lived, EBS-issued token minted at - // POST /oauth/token — not a Twitch/Extension JWT). The channel id is resolved server-side - // from the token, never taken from the request body, so a compromised client cannot spoof - // another broadcaster's channel. - if (httpRequest.HttpContext.Items["ChannelId"] is not string channelId) + app.MapPost("/api/update-state", async ( + HttpRequest httpRequest, + UpdateStateRequest body, + IChannelStateStore stateStore, + ITwitchPubSubClient pubSubClient, + IBroadcasterTokenStore tokenStore, + ChannelLocks channelLocks, + IOptions ebsOptions, + ILogger logger, + CancellationToken cancellationToken) => { - return (IResult?)httpRequest.HttpContext.Items["BroadcasterAuthFailure"] ?? Results.Unauthorized(); - } + // Authenticated by the middleware above (long-lived, EBS-issued token minted at + // POST /oauth/token — not a Twitch/Extension JWT). The channel id is resolved server-side + // from the token, never taken from the request body, so a compromised client cannot spoof + // another broadcaster's channel. + if (httpRequest.HttpContext.Items["ChannelId"] is not string channelId) + { + return (IResult?)httpRequest.HttpContext.Items["BroadcasterAuthFailure"] ?? Results.Unauthorized(); + } - PubSubBroadcastRequest pubSubRequest; - try - { - pubSubRequest = PubSubBroadcastRequest.Create(channelId, body.State, ebsOptions.Value.MaxStatePayloadBytes); - } - catch (InvalidPubSubStateException ex) - { - // A missing/null/non-object "state" — a client error, not the 500 an undefined JsonElement used to cause. - return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); - } - catch (PubSubPayloadTooLargeException ex) - { - return Results.Problem(ex.Message, statusCode: StatusCodes.Status413PayloadTooLarge); - } + PubSubBroadcastRequest pubSubRequest; + try + { + pubSubRequest = PubSubBroadcastRequest.Create(channelId, body.State, ebsOptions.Value.MaxStatePayloadBytes); + } + catch (InvalidPubSubStateException ex) + { + // A missing/null/non-object "state" — a client error, not the 500 an undefined JsonElement used to cause. + return Results.Problem(ex.Message, statusCode: StatusCodes.Status400BadRequest); + } + catch (PubSubPayloadTooLargeException ex) + { + return Results.Problem(ex.Message, statusCode: StatusCodes.Status413PayloadTooLarge); + } - // What was size-checked is the compact serialisation, so that is what is stored and broadcast — - // not the request's original whitespace, which the limit never saw. - using var compactState = JsonDocument.Parse(pubSubRequest.Message); + // What was size-checked is the compact serialisation, so that is what is stored and broadcast — + // not the request's original whitespace, which the limit never saw. + using var compactState = JsonDocument.Parse(pubSubRequest.Message); - bool published; - using (await channelLocks.AcquireAsync(channelId, cancellationToken).ConfigureAwait(false)) - { - // Authenticated again now that this channel is ours alone: a sign-out (POST /oauth/revoke) that - // finished while this request waited has revoked the token, and storing the snapshot after that - // clear would leave a public card with no credential able to take it down. - if (!TryAuthenticateBroadcaster(httpRequest, tokenStore, requireValidTwitchGrant: true, out var stillAuthorizedChannel, out var failure) - || stillAuthorizedChannel != channelId) + bool published; + using (await channelLocks.AcquireAsync(channelId, cancellationToken).ConfigureAwait(false)) { - return failure ?? Results.Unauthorized(); + // Authenticated again now that this channel is ours alone: a sign-out (POST /oauth/revoke) that + // finished while this request waited has revoked the token, and storing the snapshot after that + // clear would leave a public card with no credential able to take it down. + if (!TryAuthenticateBroadcaster(httpRequest, tokenStore, requireValidTwitchGrant: true, out var stillAuthorizedChannel, out var failure) + || stillAuthorizedChannel != channelId) + { + return failure ?? Results.Unauthorized(); + } + + stateStore.Set(channelId, compactState.RootElement); + published = await pubSubClient.BroadcastAsync(channelId, compactState.RootElement, cancellationToken).ConfigureAwait(false); } - stateStore.Set(channelId, compactState.RootElement); - published = await pubSubClient.BroadcastAsync(channelId, compactState.RootElement, cancellationToken).ConfigureAwait(false); - } + if (!published) + { + logger.LogWarning("Failed to publish state update for channel {ChannelId} to Twitch PubSub.", channelId); + return Results.Problem("Failed to publish the update to Twitch PubSub.", statusCode: StatusCodes.Status502BadGateway); + } - if (!published) + return Results.Ok(new + { + published = true, + channelId, + bytes = System.Text.Encoding.UTF8.GetByteCount(pubSubRequest.Message), + }); + }) + .RequireRateLimiting("update-state"); + + // The broadcaster switched the card off or signed out: forget the stored snapshot so + // /api/initial-state stops serving it to anyone who asks, and tell viewers already watching to hide it. + app.MapDelete("/api/update-state", async ( + HttpRequest httpRequest, + IChannelStateStore stateStore, + ITwitchPubSubClient pubSubClient, + IBroadcasterTokenStore tokenStore, + ChannelLocks channelLocks, + CancellationToken cancellationToken) => { - logger.LogWarning("Failed to publish state update for channel {ChannelId} to Twitch PubSub.", channelId); - return Results.Problem("Failed to publish the update to Twitch PubSub.", statusCode: StatusCodes.Status502BadGateway); - } + if (httpRequest.HttpContext.Items["ChannelId"] is not string channelId) + { + return (IResult?)httpRequest.HttpContext.Items["BroadcasterAuthFailure"] ?? Results.Unauthorized(); + } - return Results.Ok(new - { - published = true, - channelId, - bytes = System.Text.Encoding.UTF8.GetByteCount(pubSubRequest.Message), - }); - }) - .RequireRateLimiting("update-state"); - -// The broadcaster switched the card off or signed out: forget the stored snapshot so -// /api/initial-state stops serving it to anyone who asks, and tell viewers already watching to hide it. -app.MapDelete("/api/update-state", async ( - HttpRequest httpRequest, - IChannelStateStore stateStore, - ITwitchPubSubClient pubSubClient, - IBroadcasterTokenStore tokenStore, - ChannelLocks channelLocks, - CancellationToken cancellationToken) => - { - if (httpRequest.HttpContext.Items["ChannelId"] is not string channelId) - { - return (IResult?)httpRequest.HttpContext.Items["BroadcasterAuthFailure"] ?? Results.Unauthorized(); - } + bool viewersTold; + using (await channelLocks.AcquireAsync(channelId, cancellationToken).ConfigureAwait(false)) + { + // Still the channel's live credential? A token revoked while this waited must not clear + // a card the broadcaster has since published again under a new sign-in. + if (!TryAuthenticateBroadcaster(httpRequest, tokenStore, requireValidTwitchGrant: false, out var stillAuthorizedChannel, out var failure) + || stillAuthorizedChannel != channelId) + { + return failure ?? Results.Unauthorized(); + } + + viewersTold = await ChannelStateClearing.ClearAsync(channelId, stateStore, pubSubClient, cancellationToken).ConfigureAwait(false); + } - bool viewersTold; - using (await channelLocks.AcquireAsync(channelId, cancellationToken).ConfigureAwait(false)) - { - // Still the channel's live credential? A token revoked while this waited must not clear - // a card the broadcaster has since published again under a new sign-in. - if (!TryAuthenticateBroadcaster(httpRequest, tokenStore, requireValidTwitchGrant: false, out var stillAuthorizedChannel, out var failure) - || stillAuthorizedChannel != channelId) + // A 502, like the POST's, when viewers already watching were not told: the snapshot is gone + // either way, and a retry is harmless, so the client tries again rather than leave them the card. + if (!viewersTold) { - return failure ?? Results.Unauthorized(); + httpRequest.HttpContext.Response.Headers[ChannelStateClearing.SnapshotRemovedHeader] = "true"; + return Results.Problem( + "The card was removed, but Twitch PubSub did not deliver the offline message to viewers already watching.", + statusCode: StatusCodes.Status502BadGateway); } - viewersTold = await ChannelStateClearing.ClearAsync(channelId, stateStore, pubSubClient, cancellationToken).ConfigureAwait(false); - } + return Results.NoContent(); + }) + .RequireRateLimiting("clear-state"); - // A 502, like the POST's, when viewers already watching were not told: the snapshot is gone - // either way, and a retry is harmless, so the client tries again rather than leave them the card. - if (!viewersTold) + app.MapGet("/api/initial-state/{channelId}", (HttpContext httpContext, string channelId, IChannelStateStore stateStore, IOptions ebsOptions) => { - httpRequest.HttpContext.Response.Headers[ChannelStateClearing.SnapshotRemovedHeader] = "true"; - return Results.Problem( - "The card was removed, but Twitch PubSub did not deliver the offline message to viewers already watching.", - statusCode: StatusCodes.Status502BadGateway); - } + if (!stateStore.TryGet(channelId, out var state)) + { + return Results.NotFound(new { message = "No state has been published for this channel yet." }); + } - return Results.NoContent(); - }) - .RequireRateLimiting("clear-state"); + // Lets a CDN/proxy/browser absorb a burst of viewers; live changes arrive by PubSub, so a few + // seconds of staleness is invisible. The CORS middleware adds Vary: Origin, so a shared cache + // keeps one copy per allowed origin instead of replaying one origin's Access-Control-Allow-Origin. + var cacheSeconds = ebsOptions.Value.InitialStateCacheSeconds; + if (cacheSeconds > 0) + { + httpContext.Response.Headers.CacheControl = $"public, max-age={cacheSeconds}"; + } -app.MapGet("/api/initial-state/{channelId}", (HttpContext httpContext, string channelId, IChannelStateStore stateStore, IOptions ebsOptions) => - { - if (!stateStore.TryGet(channelId, out var state)) - { - return Results.NotFound(new { message = "No state has been published for this channel yet." }); - } + return Results.Ok(state); + }) + .RequireRateLimiting("initial-state") + .RequireCors("extension-frontend"); - // Lets a CDN/proxy/browser absorb a burst of viewers; live changes arrive by PubSub, so a few - // seconds of staleness is invisible. The CORS middleware adds Vary: Origin, so a shared cache - // keeps one copy per allowed origin instead of replaying one origin's Access-Control-Allow-Origin. - var cacheSeconds = ebsOptions.Value.InitialStateCacheSeconds; - if (cacheSeconds > 0) - { - httpContext.Response.Headers.CacheControl = $"public, max-age={cacheSeconds}"; - } + app.Run(); +} +catch (OptionsValidationException ex) when (environmentName != "Testing") +{ + return ReportInvalidConfiguration(ex.Failures); +} +catch (InvalidOperationException ex) when (environmentName != "Testing" && FindConfigurationConversionFailure(ex) is not null) +{ + // A typed setting that cannot be converted (for example Ebs__MaxStatePayloadBytes=abc) is thrown by the + // configuration binder as a plain InvalidOperationException, not as a validation failure. + return ReportInvalidConfiguration([FindConfigurationConversionFailure(ex)!]); +} - return Results.Ok(state); - }) - .RequireRateLimiting("initial-state") - .RequireCors("extension-frontend"); +return 0; -app.Run(); +// Missing or invalid settings are an operator mistake, not a crash. Say exactly what to fix and exit +// with a configuration error code, instead of an unhandled-exception stack trace that also sets off +// crash reporters (e.g. Datadog crash tracking) for something that is not a bug. Covers everything from +// building the host through starting it: options validate the first time they are read, which can be +// during service setup as well as at start. +static int ReportInvalidConfiguration(IEnumerable failures) +{ + Console.Error.WriteLine("EDNexus EBS cannot start: the configuration is invalid."); + foreach (var failure in failures) Console.Error.WriteLine(" - " + failure); + Console.Error.WriteLine( + "Set the missing values as environment variables (for example Twitch__ClientId and Twitch__ExtensionId) " + + "or in the .env file; see .env.example and the README."); + return 78; // EX_CONFIG +} + +// The configuration binder's message for a value that cannot be converted to the setting's type. Only +// that failure is treated as a configuration error; any other InvalidOperationException stays a crash. +static string? FindConfigurationConversionFailure(Exception? exception) +{ + for (var current = exception; current is not null; current = current.InnerException) + { + if (current is InvalidOperationException && + current.Message.StartsWith("Failed to convert configuration value", StringComparison.Ordinal)) + return current.Message; + } + + return null; +} static bool IsAllowedFrontendOrigin(string origin, HashSet additionalOrigins) { diff --git a/src/EDNexus.Ebs/README.md b/src/EDNexus.Ebs/README.md index 4df406f..70144e2 100644 --- a/src/EDNexus.Ebs/README.md +++ b/src/EDNexus.Ebs/README.md @@ -319,3 +319,31 @@ Fly.io, etc.). Wherever it runs, mount a persistent volume at `/data` (see [Pers **Production caveat:** SQLite suits the single EBS instance we deploy. A multi-instance (horizontally scaled) deployment needs a shared backing store, e.g. Postgres or Redis, behind `IBroadcasterTokenStore` / `IChannelStateStore`, plus a shared Data Protection key ring. + +## Troubleshooting + +### The container exits immediately with "configuration is invalid" + +The service checks its settings at start-up and refuses to run with a missing or invalid one, rather than +failing later at the first Twitch login. The log lists each problem, then the process exits with code `78` +(a configuration error, not a crash), and `restart: unless-stopped` will keep retrying until the settings are +fixed. The usual causes: + +| Message | Fix | +|---|---| +| `Twitch:ClientId must be set` | Set `Twitch__ClientId` (the extension's Client ID from the Twitch developer console) | +| `Twitch:ExtensionId must be set` | Set `Twitch__ExtensionId` (usually the same value as the Client ID) | +| `Twitch:ClientSecret must be set` / `Twitch:ExtensionSecret ...` | Set the two secrets; the extension secret is base64 and must decode to at least 16 bytes | +| `Twitch:OAuthRedirectUri ...` | Use an absolute `https://` URL (plain `http` only for localhost) that matches the one registered with Twitch | + +Settings come from the `.env` file next to `docker-compose.yml` (`env_file: .env`). Start from `.env.example`, +which already contains the public Client ID and Extension ID, and fill in the two secrets. A `.env` that only +contains the secrets is the most common way to hit this. + +After editing `.env`, **recreate** the container: `docker compose up -d --force-recreate`. A plain +`docker restart` / `docker compose restart` (and the `restart: unless-stopped` retries) reuse the environment the +container was created with, so the corrected values are not picked up and the service stays down. + +A value that cannot be converted to its type (for example `Ebs__MaxStatePayloadBytes=abc`) is reported the same +way, naming the setting, with the same exit code. + diff --git a/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs b/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs index 790e03e..5404e3b 100644 --- a/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs +++ b/tests/EDNexus.Ebs.Tests/EbsHardeningTests.cs @@ -395,7 +395,7 @@ private static IConfiguration ConfigurationWith(string key, string? value) => }).Build(); // The validation rules, exercised through the same Bind + validator registration Program uses but - // without booting a host: a host that fails to start surfaces through WebApplicationFactory's + // without booting a host (StartupFailureTests runs the real service end to end): a host that fails to start surfaces through WebApplicationFactory's // deferred-host plumbing, which now and then reports an ObjectDisposedException instead of the // validation failure, so a theory over every rule must not depend on that. [Theory] @@ -420,24 +420,6 @@ public void Invalid_configuration_is_refused_at_startup(string key, string? valu Assert.Contains(expectedInMessage, validation.Message); } - [Fact] - public void Invalid_configuration_stops_the_host_from_starting() - { - // One end-to-end check that the validators are wired to run at startup (ValidateOnStart). The - // framework's failed-start reporting is racy (see above), so look for the validation failure - // across a few attempts rather than on the first. - Exception? last = null; - for (var attempt = 0; attempt < 5; attempt++) - { - using var factory = new EbsHostFactory(new() { ["Twitch:ExtensionId"] = "" }); - last = Record.Exception(() => factory.CreateClient()); - Assert.NotNull(last); - if (last.ToString().Contains("Twitch:ExtensionId")) return; - } - - Assert.Fail($"The host never reported the invalid ExtensionId; last error: {last}"); - } - [Theory] [InlineData("https://ebs.example.com/oauth/callback")] [InlineData("http://localhost:8787/oauth/callback")] // Twitch allows http for localhost diff --git a/tests/EDNexus.Ebs.Tests/StartupFailureTests.cs b/tests/EDNexus.Ebs.Tests/StartupFailureTests.cs new file mode 100644 index 0000000..650f55b --- /dev/null +++ b/tests/EDNexus.Ebs.Tests/StartupFailureTests.cs @@ -0,0 +1,105 @@ +using System.Diagnostics; +using Xunit; + +namespace EDNexus.Ebs.Tests; + +/// +/// An operator who gets a setting wrong must see a clear message and a configuration exit code, not an +/// unhandled-exception crash (which also trips crash reporters in the container). The failure happens in +/// Program's entry point, so these run the real service as a child process. +/// +public class StartupFailureTests +{ + private sealed record Outcome(int ExitCode, string StdErr); + + /// Runs the service with a valid configuration plus ; null when it cannot be run standalone. + private static async Task RunAsync(Dictionary overrides) + { + var dll = typeof(Program).Assembly.Location; + if (!File.Exists(Path.ChangeExtension(dll, ".runtimeconfig.json"))) + return null; // not runnable as a standalone process in this layout + + var temp = Path.Combine(Path.GetTempPath(), "ebs-startup-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(temp); + try + { + var psi = new ProcessStartInfo("dotnet", $"\"{dll}\"") + { + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + psi.Environment["ASPNETCORE_URLS"] = "http://127.0.0.1:0"; + psi.Environment["ASPNETCORE_ENVIRONMENT"] = "Production"; + psi.Environment["Ebs__DataDirectory"] = Path.Combine(temp, "data"); + psi.Environment["Ebs__DataProtectionKeysDirectory"] = Path.Combine(temp, "keys"); + psi.Environment["Twitch__ClientId"] = "test-client-id"; + psi.Environment["Twitch__ExtensionId"] = "test-extension-id"; + psi.Environment["Twitch__ClientSecret"] = "test-client-secret"; + psi.Environment["Twitch__ExtensionSecret"] = EbsHostFactory.ExtensionSecret; + psi.Environment["Twitch__OAuthRedirectUri"] = "https://ebs.example.com/oauth/callback"; + foreach (var (key, value) in overrides) psi.Environment[key] = value; + + using var process = Process.Start(psi)!; + var stderr = process.StandardError.ReadToEndAsync(); + var stdout = process.StandardOutput.ReadToEndAsync(); + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60)); + try + { + await process.WaitForExitAsync(timeout.Token); + } + catch (OperationCanceledException) + { + process.Kill(entireProcessTree: true); + Assert.Fail("The service neither started nor failed within 60 s."); + } + + var error = await stderr; + await stdout; + return new Outcome(process.ExitCode, error); + } + finally + { + try { Directory.Delete(temp, recursive: true); } catch { /* best effort */ } + } + } + + [Fact] + public async Task Missing_twitch_ids_exit_with_a_clear_message_instead_of_crashing() + { + var outcome = await RunAsync(new() { ["Twitch__ClientId"] = "", ["Twitch__ExtensionId"] = "" }); + if (outcome is null) return; + + Assert.Equal(78, outcome.ExitCode); + Assert.Contains("cannot start", outcome.StdErr); + Assert.Contains("Twitch:ClientId", outcome.StdErr); + Assert.Contains("Twitch:ExtensionId", outcome.StdErr); + Assert.DoesNotContain("Unhandled exception", outcome.StdErr); + } + + [Fact] + public async Task An_invalid_ebs_setting_that_is_read_while_the_host_is_built_is_also_reported_cleanly() + { + // The storage services read EbsOptions while the host is still being built, before it runs, so + // this validation failure surfaces earlier than the Twitch one above. + var outcome = await RunAsync(new() { ["Ebs__MaxStatePayloadBytes"] = "6000" }); + if (outcome is null) return; + + Assert.Equal(78, outcome.ExitCode); + Assert.Contains("Ebs:MaxStatePayloadBytes", outcome.StdErr); + Assert.DoesNotContain("Unhandled exception", outcome.StdErr); + } + + [Fact] + public async Task A_setting_that_cannot_be_converted_to_its_type_is_reported_cleanly() + { + var outcome = await RunAsync(new() { ["Ebs__MaxStatePayloadBytes"] = "not-a-number" }); + if (outcome is null) return; + + Assert.Equal(78, outcome.ExitCode); + Assert.Contains("cannot start", outcome.StdErr); + Assert.Contains("Ebs:MaxStatePayloadBytes", outcome.StdErr); + Assert.DoesNotContain("Unhandled exception", outcome.StdErr); + } +}