using Microsoft.AspNetCore.DataProtection; using QuantEngine.Infrastructure.Data; using QuantEngine.Infrastructure.Repositories; using QuantEngine.Infrastructure.Services; using QuantEngine.Core.Interfaces; using QuantEngine.Application.Services; using QuantEngine.Application.Interfaces; using Serilog; using QuantEngine.Web.Services; using Hangfire; using Npgsql; using FastEndpoints; using FluentValidation; Log.Logger = new LoggerConfiguration() .MinimumLevel.Information() .WriteTo.Console() .WriteTo.File("logs/quantengine-.log", rollingInterval: RollingInterval.Day) .CreateLogger(); try { var builder = WebApplication.CreateBuilder(args); builder.Host.UseSerilog(); // Data Protection: without this, ASP.NET Core derives its key-ring // discriminator from the app's physical content root path. Every // deployment lands in a brand-new directory // (~/deployments/quantengine_{tag}_{hash}/), so the discriminator // changed on every single deploy and every previously-issued auth // cookie became undecryptable -- forcing all users to log in again // after each release. SetApplicationName pins a stable discriminator; // PersistKeysToFileSystem points at a location outside the versioned // deployment folders so the actual key material also survives restarts. var dataProtectionKeysPath = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "quantengine-keys"); builder.Services.AddDataProtection() .SetApplicationName("QuantEngine") .PersistKeysToFileSystem(new DirectoryInfo(dataProtectionKeysPath)); // Authentication & Authorization builder.Services.AddAuthentication(opts => { opts.DefaultAuthenticateScheme = AdminAuthDefaults.Scheme; opts.DefaultChallengeScheme = AdminAuthDefaults.Scheme; }) .AddCookie(AdminAuthDefaults.Scheme, opts => { opts.Cookie.Name = AdminAuthDefaults.CookieName; opts.Cookie.HttpOnly = true; opts.Cookie.SameSite = SameSiteMode.Lax; opts.Cookie.SecurePolicy = builder.Environment.IsProduction() ? CookieSecurePolicy.Always : CookieSecurePolicy.SameAsRequest; opts.LoginPath = "/Account/Login"; opts.AccessDeniedPath = "/Account/AccessDenied"; opts.SlidingExpiration = true; opts.ExpireTimeSpan = TimeSpan.FromHours(12); }); builder.Services.AddAuthorization(options => { options.AddPolicy(AdminAuthDefaults.Scheme, policy => { policy.RequireAuthenticatedUser(); }); }); builder.Services.AddAntiforgery(); // Razor Pages with authorization conventions builder.Services.AddRazorPages(options => { options.Conventions.AuthorizeFolder("/Admin", AdminAuthDefaults.Scheme); options.Conventions.AllowAnonymousToPage("/Account/Login"); options.Conventions.AllowAnonymousToPage("/Account/AccessDenied"); }); // Authentication Services builder.Services.AddScoped(); builder.Services.AddScoped(); // PostgreSQL Dapper Setup var connectionString = builder.Configuration.GetConnectionString("DefaultConnection") ?? throw new InvalidOperationException("Connection string 'DefaultConnection' is required."); var configuredDatabase = new NpgsqlConnectionStringBuilder(connectionString).Database; if (!string.Equals(configuredDatabase, "quantenginedb", StringComparison.OrdinalIgnoreCase)) { throw new InvalidOperationException("QuantEngine must use the quantenginedb PostgreSQL database."); } var dataSource = NpgsqlDataSource.Create(connectionString); builder.Services.AddSingleton(dataSource); builder.Services.AddSingleton(new DbConnectionFactory(dataSource)); builder.Services.AddSingleton(sp => new DbMigrator(connectionString, sp.GetRequiredService>())); // Repository Services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddHttpClient(); // Collection Pipeline Services builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // Hangfire Background Jobs try { var hangfireConnectionString = builder.Configuration.GetConnectionString("HangfireConnection") ?? connectionString; builder.Services.AddHangfireServices(hangfireConnectionString); } catch (Exception ex) { Log.Warning("Hangfire initialization failed: {Message}", ex.Message); } // FluentValidation builder.Services.AddValidatorsFromAssemblyContaining(); // FastEndpoints (for API endpoints) builder.Services.AddFastEndpoints(); var app = builder.Build(); app.UseSerilogRequestLogging(); app.UseFastEndpoints(); // Database migration on startup using (var scope = app.Services.CreateScope()) { var migrator = scope.ServiceProvider.GetRequiredService(); var workspaceRepo = scope.ServiceProvider.GetRequiredService(); var collectionRepo = scope.ServiceProvider.GetRequiredService(); var tokenCache = scope.ServiceProvider.GetRequiredService(); try { migrator.Migrate(); await workspaceRepo.GetAccountsAsync(); await collectionRepo.GetDashboardStateAsync(); await tokenCache.GetCachedTokenAsync("_init_test_"); Log.Information("Database migration and initialization successful"); } catch (Exception ex) { if (!app.Environment.IsDevelopment()) throw; Log.Warning("Database initialization warning (development only): {Message}", ex.Message); } } // Error handling & HSTS if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAntiforgery(); app.UseAuthentication(); app.UseAuthorization(); // Hangfire Dashboard try { app.UseHangfireSetup(app.Services); } catch (Exception ex) { Log.Warning("Hangfire setup failed: {Message}", ex.Message); } // Root redirect: unauthenticated → /Account/Login, authenticated → /Admin/Dashboard app.MapGet("/", context => { if (context.User?.Identity?.IsAuthenticated ?? false) context.Response.Redirect("/Admin/Dashboard"); else context.Response.Redirect("/Account/Login"); return Task.CompletedTask; }); // Login redirect convenience route app.MapGet("/login", context => { context.Response.Redirect("/Account/Login", permanent: false); return Task.CompletedTask; }); app.MapRazorPages(); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Application terminated unexpectedly"); throw; } finally { Log.CloseAndFlush(); }