using System.Text; using System.Threading.RateLimiting; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.RateLimiting; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; using Microsoft.Extensions.Options; using OTSSignsOrchestrator.Server.Api; using OTSSignsOrchestrator.Server.Auth; using OTSSignsOrchestrator.Server.Clients; using OTSSignsOrchestrator.Server.Data; using OTSSignsOrchestrator.Server.Hubs; using OTSSignsOrchestrator.Server.Reports; using OTSSignsOrchestrator.Server.Services; using OTSSignsOrchestrator.Server.Webhooks; using OTSSignsOrchestrator.Server.Workers; using Refit; using Quartz; using OTSSignsOrchestrator.Server.Jobs; using OTSSignsOrchestrator.Server.Health; using OTSSignsOrchestrator.Server.Health.Checks; using Serilog; using Stripe; var builder = WebApplication.CreateBuilder(args); // ── Serilog ────────────────────────────────────────────────────────────────── builder.Host.UseSerilog((context, config) => config.ReadFrom.Configuration(context.Configuration)); // ── EF Core — PostgreSQL ───────────────────────────────────────────────────── builder.Services.AddDbContext(options => options.UseNpgsql(builder.Configuration.GetConnectionString("OrchestratorDb"))); // ── JWT Authentication ────────────────────────────────────────────────────── builder.Services.Configure(builder.Configuration.GetSection(JwtOptions.Section)); var jwtKey = builder.Configuration[$"{JwtOptions.Section}:Key"] ?? throw new InvalidOperationException("Jwt:Key must be configured."); builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) .AddJwtBearer(options => { options.TokenValidationParameters = new TokenValidationParameters { ValidateIssuer = true, ValidateAudience = true, ValidateLifetime = true, ValidateIssuerSigningKey = true, ValidIssuer = builder.Configuration[$"{JwtOptions.Section}:Issuer"] ?? "OTSSignsOrchestrator", ValidAudience = builder.Configuration[$"{JwtOptions.Section}:Audience"] ?? "OTSSignsOrchestrator", IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwtKey)), ClockSkew = TimeSpan.FromSeconds(30), }; // Allow SignalR to receive the JWT via query string options.Events = new JwtBearerEvents { OnMessageReceived = context => { var accessToken = context.Request.Query["access_token"]; var path = context.HttpContext.Request.Path; if (!string.IsNullOrEmpty(accessToken) && path.StartsWithSegments("/hubs")) context.Token = accessToken; return Task.CompletedTask; }, }; }); builder.Services.AddAuthorization(options => { options.AddPolicy("CustomerPortal", policy => policy.RequireClaim("customer_id")); }); // ── Application services ──────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.Configure(builder.Configuration.GetSection(EmailOptions.Section)); builder.Services.AddSingleton(); // ── Report services ───────────────────────────────────────────────────────── builder.Services.AddScoped(); builder.Services.AddScoped(); // ── Provisioning pipelines + worker ───────────────────────────────────────── 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.AddHostedService(); // ── External API clients ──────────────────────────────────────────────────── builder.Services.AddHttpClient(); builder.Services.AddSingleton(); builder.Services.Configure( builder.Configuration.GetSection(AuthentikOptions.Section)); builder.Services.AddRefitClient() .ConfigureHttpClient((sp, client) => { var opts = sp.GetRequiredService>().Value; client.BaseAddress = new Uri(opts.BaseUrl.TrimEnd('/')); client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", opts.ApiToken); }); // ── Stripe ────────────────────────────────────────────────────────────────── StripeConfiguration.ApiKey = builder.Configuration["Stripe:SecretKey"]; // ── Health check engine + individual checks ───────────────────────────────── builder.Services.AddHostedService(); 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.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); // AuthentikGlobalHealthCheck also registered as concrete type for the Quartz job builder.Services.AddScoped(); // ── Quartz scheduler ───────────────────────────────────────────────────────── builder.Services.AddQuartz(q => { var certExpiryKey = new JobKey("byoi-cert-expiry-global", "byoi-cert-expiry"); q.AddJob(opts => opts.WithIdentity(certExpiryKey).StoreDurably()); q.AddTrigger(opts => opts .ForJob(certExpiryKey) .WithIdentity("byoi-cert-expiry-global-trigger", "byoi-cert-expiry") .WithSimpleSchedule(s => s .WithIntervalInHours(24) .RepeatForever()) .StartNow()); // Authentik global health check — every 2 minutes var authentikHealthKey = new JobKey("authentik-global-health", "health-checks"); q.AddJob(opts => opts.WithIdentity(authentikHealthKey).StoreDurably()); q.AddTrigger(opts => opts .ForJob(authentikHealthKey) .WithIdentity("authentik-global-health-trigger", "health-checks") .WithSimpleSchedule(s => s .WithIntervalInMinutes(2) .RepeatForever()) .StartNow()); // Daily screen snapshot — 2 AM UTC var dailySnapshotKey = new JobKey("daily-snapshot", "snapshots"); q.AddJob(opts => opts.WithIdentity(dailySnapshotKey).StoreDurably()); q.AddTrigger(opts => opts .ForJob(dailySnapshotKey) .WithIdentity("daily-snapshot-trigger", "snapshots") .WithCronSchedule("0 0 2 * * ?")); // Scheduled reports — weekly (Monday 08:00 UTC) + monthly (1st 08:00 UTC) var reportJobKey = new JobKey("scheduled-report", "reports"); q.AddJob(opts => opts.WithIdentity(reportJobKey).StoreDurably()); q.AddTrigger(opts => opts .ForJob(reportJobKey) .WithIdentity("weekly-report-trigger", "reports") .UsingJobData(ScheduledReportJob.IsMonthlyKey, false) .WithCronSchedule("0 0 8 ? * MON *")); q.AddTrigger(opts => opts .ForJob(reportJobKey) .WithIdentity("monthly-report-trigger", "reports") .UsingJobData(ScheduledReportJob.IsMonthlyKey, true) .WithCronSchedule("0 0 8 1 * ? *")); }); builder.Services.AddQuartzHostedService(q => q.WaitForJobsToComplete = true); // ── SignalR ────────────────────────────────────────────────────────────────── builder.Services.AddSignalR(); // ── Rate limiting ──────────────────────────────────────────────────────────── builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = 429; options.AddFixedWindowLimiter("fixed", limiter => { limiter.PermitLimit = 60; limiter.Window = TimeSpan.FromMinutes(1); }); options.AddSlidingWindowLimiter("signup", limiter => { limiter.PermitLimit = 3; limiter.Window = TimeSpan.FromMinutes(10); limiter.SegmentsPerWindow = 2; }); }); var app = builder.Build(); // ── Middleware ──────────────────────────────────────────────────────────────── app.UseSerilogRequestLogging(); app.UseRateLimiter(); app.UseAuthentication(); app.UseAuthorization(); // ── Auth endpoints (no auth required) ──────────────────────────────────────── app.MapPost("/api/auth/login", async (LoginRequest req, OperatorAuthService auth) => { try { var (jwt, refresh) = await auth.LoginAsync(req.Email, req.Password); return Results.Ok(new { token = jwt, refreshToken = refresh }); } catch (UnauthorizedAccessException) { return Results.Unauthorized(); } }); app.MapPost("/api/auth/refresh", async (RefreshRequest req, OperatorAuthService auth) => { try { var jwt = await auth.RefreshAsync(req.RefreshToken); return Results.Ok(new { token = jwt }); } catch (UnauthorizedAccessException) { return Results.Unauthorized(); } }); // ── Signup endpoints (no auth) ────────────────────────────────────────────── app.MapSignupEndpoints(); // ── Stripe webhook (no auth, no rate limit) ───────────────────────────────── app.MapStripeWebhook(); // ── Fleet + Jobs REST endpoints (auth required) ───────────────────────────── app.MapFleetEndpoints(); // ── Customer Portal BYOI endpoints (customer JWT required) ────────────── app.MapCustomerPortalEndpoints(); // ── SignalR hub ───────────────────────────────────────────────────────────── app.MapHub("/hubs/fleet"); app.Run(); // ── Request DTOs for auth endpoints ───────────────────────────────────────── public record LoginRequest(string Email, string Password); public record RefreshRequest(string RefreshToken);