using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Options; using Serilog; using Tau.Acuvim.Portal.Configuration; using Tau.Acuvim.Portal.Constants; using Tau.Acuvim.Portal.Data; using Tau.Acuvim.Portal.Domain.Identity; using Tau.Acuvim.Portal.Endpoints; using Tau.Acuvim.Portal.Services; Log.Logger = new LoggerConfiguration() .WriteTo.Console() .CreateBootstrapLogger(); try { var builder = WebApplication.CreateBuilder(args); builder.Configuration.Sources.Insert(0, new Microsoft.Extensions.Configuration.Json.JsonConfigurationSource { Path = "appsettings.template.json", Optional = false, ReloadOnChange = false }); builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: true); builder.Host.UseSerilog((ctx, lc) => lc .ReadFrom.Configuration(ctx.Configuration) .WriteTo.Console()); builder.Services.Configure(builder.Configuration.GetSection(ApplicationOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(DatabaseOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(TimescaleDbOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(GrafanaOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(WhiteLabelOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(AuthenticationOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(MonitoringOptions.SectionName)); builder.Services.Configure(builder.Configuration.GetSection(FleetIngestOptions.SectionName)); var applicationOptions = builder.Configuration.GetSection(ApplicationOptions.SectionName).Get() ?? new ApplicationOptions(); var authOptions = builder.Configuration.GetSection(AuthenticationOptions.SectionName).Get() ?? new AuthenticationOptions(); var databaseOptions = builder.Configuration.GetSection(DatabaseOptions.SectionName).Get() ?? new DatabaseOptions(); var timescaleOptions = builder.Configuration.GetSection(TimescaleDbOptions.SectionName).Get() ?? new TimescaleDbOptions(); var whiteLabelOptions = builder.Configuration.GetSection(WhiteLabelOptions.SectionName).Get() ?? new WhiteLabelOptions(); var fleetIngestOptions = builder.Configuration.GetSection(FleetIngestOptions.SectionName).Get() ?? new FleetIngestOptions(); RunModeGuards.ValidateConfig(applicationOptions, databaseOptions, fleetIngestOptions); var resolution = ConnectionStringResolver.Resolve(databaseOptions, timescaleOptions, builder.Environment); Log.Information("RunMode={RunMode}, database connection resolved via {Source}", applicationOptions.RunMode, resolution.Source); builder.Services.AddSingleton(new DatabaseResolutionInfo(resolution.Source)); // ────────────────────────────────────────────────────────────────────── // DbContext + Identity registration — branches on RunMode. // Only one context is registered; the other never resolves. // ────────────────────────────────────────────────────────────────────── if (applicationOptions.RunMode == RunMode.Client) { builder.Services.AddDbContext(options => options.UseNpgsql(resolution.ConnectionString)); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services .AddIdentity(ConfigureIdentity(authOptions)) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); } else { // RLS infrastructure — middleware + bootstrapper explicitly SET the Postgres // session var on the AdminDbContext connection (Npgsql pooling makes a // DbConnectionInterceptor unreliable; see Services/CustomerFilterExtensions.cs). builder.Services.AddSingleton(); builder.Services.AddDbContext(options => options.UseNpgsql(resolution.ConnectionString)); builder.Services.AddScoped(sp => sp.GetRequiredService()); builder.Services .AddIdentity(ConfigureIdentity(authOptions)) .AddEntityFrameworkStores() .AddDefaultTokenProviders(); } builder.Services.ConfigureApplicationCookie(options => { options.Cookie.Name = authOptions.CookieName; options.Cookie.HttpOnly = true; options.Cookie.SameSite = SameSiteMode.Lax; options.Cookie.SecurePolicy = builder.Environment.IsDevelopment() ? CookieSecurePolicy.SameAsRequest : CookieSecurePolicy.Always; options.ExpireTimeSpan = TimeSpan.FromHours(8); options.SlidingExpiration = true; options.Events.OnRedirectToLogin = ctx => { ctx.Response.StatusCode = StatusCodes.Status401Unauthorized; return Task.CompletedTask; }; options.Events.OnRedirectToAccessDenied = ctx => { ctx.Response.StatusCode = StatusCodes.Status403Forbidden; return Task.CompletedTask; }; }); builder.Services.AddAuthorization(options => { options.AddPolicy(Policies.AdminOnly, policy => policy.RequireRole(Roles.Admin)); options.AddPolicy(Policies.AnyAdmin, policy => policy.RequireRole(Roles.Admin, Roles.RestrictedAdmin)); }); var keyRingPath = builder.Configuration["DataProtection:KeyRing"] ?? "/data/keys"; if (Directory.Exists(keyRingPath)) { builder.Services.AddDataProtection() .PersistKeysToFileSystem(new DirectoryInfo(keyRingPath)) .SetApplicationName("Tau.Acuvim.Portal"); } // ────────────────────────────────────────────────────────────────────── // Service registration — split by mode. // ────────────────────────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); if (applicationOptions.RunMode == RunMode.Client) { builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); if (fleetIngestOptions.Enabled) { builder.Services.AddHttpClient(c => { c.Timeout = TimeSpan.FromSeconds(30); }); builder.Services.AddHostedService(); } } else { builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); } builder.Services.AddHealthChecks() .AddNpgSql(resolution.ConnectionString, name: "database", tags: new[] { "ready" }); builder.Services.AddEndpointsApiExplorer(); builder.Services.AddSwaggerGen(c => { c.SwaggerDoc("v1", new() { Title = $"Tau Acuvim Portal API ({applicationOptions.RunMode})", Version = "v1" }); }); builder.Services.AddCors(options => { options.AddPolicy("DevSpa", policy => policy .WithOrigins("http://localhost:5174") .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials()); }); var app = builder.Build(); if (app.Environment.IsProduction()) { var resolved = app.Services.GetRequiredService>().Value; if (string.Equals(resolved.DefaultAdminPassword, "ChangeMe123!", StringComparison.Ordinal)) { throw new InvalidOperationException( "Authentication:DefaultAdminPassword is still the template default. " + "Set it via env var Authentication__DefaultAdminPassword before running in Production."); } } await RunModeGuards.ValidateDatabaseShapeAsync(app.Services, applicationOptions.RunMode); if (databaseOptions.MigrateOnStartup) { using var scope = app.Services.CreateScope(); if (applicationOptions.RunMode == RunMode.Client) { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); var timescale = scope.ServiceProvider.GetRequiredService(); await timescale.EnsureHypertablesAsync(); } else { var db = scope.ServiceProvider.GetRequiredService(); await db.Database.MigrateAsync(); var fleetTimescale = scope.ServiceProvider.GetRequiredService(); await fleetTimescale.EnsureAsync(); } var bootstrapper = scope.ServiceProvider.GetRequiredService(); await bootstrapper.SeedAsync(); } Directory.CreateDirectory(whiteLabelOptions.LogoStoragePath); app.UseSerilogRequestLogging(); app.Use(async (context, next) => { context.Response.Headers["X-Content-Type-Options"] = "nosniff"; context.Response.Headers["X-Frame-Options"] = "SAMEORIGIN"; context.Response.Headers["Referrer-Policy"] = "strict-origin-when-cross-origin"; await next(); }); if (app.Environment.IsDevelopment()) { app.UseCors("DevSpa"); app.UseSwagger(); app.UseSwaggerUI(c => c.SwaggerEndpoint("/swagger/v1/swagger.json", $"Tau Acuvim Portal API ({applicationOptions.RunMode})")); } else { app.UseHsts(); } app.UseDefaultFiles(); app.UseStaticFiles(); app.UseStaticFiles(new StaticFileOptions { FileProvider = new PhysicalFileProvider(Path.GetFullPath(whiteLabelOptions.LogoStoragePath)), RequestPath = "/branding-assets" }); app.UseAuthentication(); app.UseAuthorization(); // RLS filter resolution — only when running as the Admin stack. Sits AFTER auth // so HttpContext.User is populated. The CustomerFilterInterceptor reads from // RlsContext when EF opens connections. if (applicationOptions.RunMode == RunMode.Admin) { app.UseMiddleware(); } // ────────────────────────────────────────────────────────────────────── // Endpoint mapping — shared first, then mode-specific. // ────────────────────────────────────────────────────────────────────── app.MapAuthEndpoints(); app.MapAdminUserEndpoints(); app.MapBrandingEndpoints(); app.MapGrafanaEndpoints(); app.MapAdminConfigEndpoints(); app.MapAppInfoEndpoints(); if (applicationOptions.RunMode == RunMode.Client) { app.MapRatesEndpoints(); app.MapAdminRatesEndpoints(); app.MapSitesEndpoints(); app.MapMeasurementsEndpoints(); app.MapDashboardEndpoints(); } else { app.MapAdminCustomersEndpoints(); app.MapAdminCustomerAccessEndpoints(); app.MapFleetIngestEndpoints(); app.MapFleetDashboardEndpoints(); } app.MapHealthChecks("/health", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = _ => false }).AllowAnonymous(); app.MapHealthChecks("/health/ready", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions { Predicate = check => check.Tags.Contains("ready") }).AllowAnonymous(); app.MapFallbackToFile("index.html"); app.Run(); } catch (Exception ex) { Log.Fatal(ex, "Portal terminated unexpectedly"); } finally { Log.CloseAndFlush(); } static Action ConfigureIdentity(AuthenticationOptions authOptions) => options => { options.Password.RequireDigit = true; options.Password.RequireLowercase = true; options.Password.RequireUppercase = true; options.Password.RequireNonAlphanumeric = false; options.Password.RequiredLength = 8; options.User.RequireUniqueEmail = true; options.SignIn.RequireConfirmedEmail = authOptions.RequireConfirmedEmail; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15); options.Lockout.MaxFailedAccessAttempts = 5; };