24f288655f
Validators (Pushes and Pull Requests) / WBS & Audit Validations (push) Has been skipped
Validators (Pushes and Pull Requests) / .NET Contracts (push) Has been skipped
Validators (Pushes and Pull Requests) / UI & Storage Validation (push) Failing after 14s
Validators (Pushes and Pull Requests) / Core Validators & Database Setup (push) Failing after 23s
Validators (Pushes and Pull Requests) / Database & Schema Validation (push) Successful in 13s
Validators (Pushes and Pull Requests) / CI Workflow Lint (push) Failing after 11s
Validators (Pushes and Pull Requests) / Notify PR Results (push) Has been skipped
Validators (Pushes and Pull Requests) / Calibration & Performance (push) Has been skipped
Validators (Pushes and Pull Requests) / Operational Report & Decision Packet (push) Has been skipped
Validators (Pushes and Pull Requests) / Security & Secrets (push) Successful in 12s
227 lines
8.8 KiB
C#
227 lines
8.8 KiB
C#
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();
|
|
|
|
// Dapper has no built-in handler for System.DateOnly (params or result mapping) — register once globally.
|
|
Dapper.SqlMapper.AddTypeHandler(new DateOnlyTypeHandler());
|
|
|
|
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<IIpLockoutService, IpLockoutService>();
|
|
builder.Services.AddScoped<AuthService>();
|
|
|
|
// 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<IDbConnectionFactory>(new DbConnectionFactory(dataSource));
|
|
builder.Services.AddSingleton(sp => new DbMigrator(connectionString, sp.GetRequiredService<ILogger<DbMigrator>>()));
|
|
|
|
// Repository Services
|
|
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
|
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
|
builder.Services.AddScoped<INormalizedLearningStore, NormalizedLearningStore>();
|
|
builder.Services.AddScoped<ILearningDatasetReader, LearningDatasetReader>();
|
|
builder.Services.AddScoped<DecisionLearningService>();
|
|
builder.Services.AddScoped<LearningDatasetService>();
|
|
builder.Services.AddSingleton<GatherTradingDataParser>();
|
|
builder.Services.AddScoped<JsonSeedIngestionService>();
|
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
|
builder.Services.AddScoped<HistoryIngestionService>();
|
|
builder.Services.AddScoped<CollectionRepository>();
|
|
builder.Services.AddScoped<ICollectionReadRepository>(sp => sp.GetRequiredService<CollectionRepository>());
|
|
builder.Services.AddScoped<ICollectionWriteRepository>(sp => sp.GetRequiredService<CollectionRepository>());
|
|
builder.Services.AddSingleton<ICollectionSchemaInitializer, CollectionSchemaInitializer>();
|
|
builder.Services.AddScoped<ICollectionReadModelService, CollectionReadModelService>();
|
|
builder.Services.AddSingleton<IRuntimeAuditTrailService, RuntimeAuditTrailService>();
|
|
builder.Services.AddScoped<ITokenCache, PostgresTokenCache>();
|
|
builder.Services.AddHttpClient<IKisApiClient, KisApiClient>();
|
|
|
|
// Collection Pipeline Services
|
|
builder.Services.AddScoped<SourcePriorityResolver>();
|
|
builder.Services.AddScoped<PriceDataNormalizer>();
|
|
builder.Services.AddScoped<ICollectionOrchestrator, KisDataCollectionOrchestrator>();
|
|
builder.Services.AddScoped<IPriceHistoryReader, PriceHistoryReader>();
|
|
builder.Services.AddOptions<SchedulerServiceOptions>();
|
|
builder.Services.AddHostedService<CollectionBootstrapHostedService>();
|
|
|
|
// 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<QuantEngine.Infrastructure.Repositories.WorkspaceRepository>();
|
|
|
|
// FastEndpoints (for API endpoints)
|
|
builder.Services.AddFastEndpoints();
|
|
|
|
var app = builder.Build();
|
|
|
|
app.UseSerilogRequestLogging();
|
|
app.UseFastEndpoints();
|
|
|
|
// Non-blocking Safe Database Initialization & Migration Check
|
|
using (var scope = app.Services.CreateScope())
|
|
{
|
|
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
|
|
var collectionSchemaInitializer = scope.ServiceProvider.GetRequiredService<ICollectionSchemaInitializer>();
|
|
|
|
try
|
|
{
|
|
await collectionSchemaInitializer.InitializeAsync();
|
|
|
|
// Execute DbUp migrations in isolated safe block
|
|
migrator.Migrate();
|
|
Log.Information("✅ Database schema migration (DbUp) check successful");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Crucial: DbUp failure should log critical warning but NOT crash the Web Application service startup
|
|
Log.Error(ex, "⚠️ Database migration (DbUp) encounter warning or delay. Service proceeding in fallback readiness state.");
|
|
}
|
|
}
|
|
|
|
// 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 → Vue 3 SPA /templates
|
|
app.MapGet("/", context =>
|
|
{
|
|
if (context.User?.Identity?.IsAuthenticated ?? false)
|
|
context.Response.Redirect("/templates");
|
|
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.MapFallbackToFile("index.html");
|
|
|
|
|
|
app.Run();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Log.Fatal(ex, "Application terminated unexpectedly");
|
|
throw;
|
|
}
|
|
finally
|
|
{
|
|
Log.CloseAndFlush();
|
|
}
|