// SPDX-License-Identifier: Apache-2.0 // Copyright 2026 Aaron K. Clark using System.Data; using System.Reflection; using System.Text.Json; using System.Threading.RateLimiting; using ApplyTrack.Api; using ApplyTrack.Api.Admin; using ApplyTrack.Api.Agent; using ApplyTrack.Api.Agent.Browser; using ApplyTrack.Api.Auth; using ApplyTrack.Api.Crypto; using ApplyTrack.Api.Data; using ApplyTrack.Api.Endpoints; using ApplyTrack.Api.Llm; using ApplyTrack.Api.Materials; using ApplyTrack.Api.Middleware; using ApplyTrack.Api.Notifications; using ApplyTrack.Api.Observability; using ApplyTrack.Api.Agent.Greenhouse; using ApplyTrack.Api.Scrape; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; using Npgsql; // `dotnet ApplyTrack.Api.dll admin `: the operator's CLI (#358), not the web host. if (AdminCli.IsAdmin(args)) { Environment.ExitCode = await AdminCli.RunAsync(args[1..], AdminCli.ConnectionString(), Console.Out, Console.Error); return; } var builder = WebApplication.CreateBuilder(args); // Connection string is config-driven so the one build runs either way: // local dev reads appsettings.json; a container overrides it with the // ConnectionStrings__Postgres environment variable (set by docker-compose). var connectionString = builder.Configuration.GetConnectionString("Postgres") ?? throw new InvalidOperationException( "No 'Postgres' connection string configured (set ConnectionStrings:Postgres " + "or the ConnectionStrings__Postgres environment variable)."); // One pooled data source for the app's lifetime. Registered via a factory (not the // pre-built-instance overload) so the DI container OWNS it and disposes it — and its // connection pool — on shutdown. The instance overload would leak the pool, which the // long-lived prod app tolerates but the test suite (many short-lived hosts) does not. // Default a 30s command timeout unless the connection string already pins one. var postgres = new NpgsqlConnectionStringBuilder(connectionString); if (!postgres.ContainsKey("Command Timeout")) { postgres.CommandTimeout = 30; } connectionString = postgres.ConnectionString; builder.Services.AddSingleton(_ => NpgsqlDataSource.Create(connectionString)); // Per-request plumbing for the tenancy choke-point. A scoped connection (DI disposes // it at request end; Dapper opens/closes it around each command) underpins the repos. // The TenantContext is stamped by TenantMiddleware from the session cookie, and the // per-tenant repos are built pre-scoped to it — endpoints never see a tenant_id or a // raw connection. builder.Services.AddScoped(sp => new ResilientDbConnection( sp.GetRequiredService().CreateConnection(), sp.GetRequiredService>())); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(sp => new ApplicationRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new StatusEventRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); // Interviews and follow-ups (#354), and the scoped tokens behind the calendar feed. builder.Services.AddScoped(sp => new AppEventRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new ApiTokenRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new CriteriaRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new BlacklistRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new PollRequestRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new PollRunRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); // Magic-link delivery. With an SMTP host configured (Email:Host), mail goes out // over SMTP via MailKit — point it at any submission relay (local, your provider, // or a hosted service). With no host set, links are logged to the console so auth // works with zero email config. Deliverability to Gmail/Outlook needs a relay whose // IP has PTR + SPF/DKIM/DMARC — direct-to-MX from a residential IP gets rejected. var emailOptions = builder.Configuration.GetSection("Email").Get() ?? new EmailOptions(); // Fail loudly at boot on a misconfigured SMTP sender (Email:Host set but no usable From // address) rather than throwing an opaque 500 on every magic-link request — see #228. emailOptions.Validate(); // And refuse to mail sign-in links without a pinned public origin (#337). emailOptions.RequirePublicBaseUrl(builder.Configuration["App:PublicBaseUrl"]); builder.Services.AddSingleton(emailOptions); if (emailOptions.IsConfigured) builder.Services.AddSingleton(); else builder.Services.AddSingleton(); // Materials engine (cover-letter drafting). The LLM endpoint is an OpenAI-compatible // server chosen entirely by config — a free local model (Ollama/vLLM) or any hosted // provider — so résumé data can stay on-prem and per-draft cost is the operator's // dial. Instance defaults come from the `Llm` config section; a tenant may override // them, and a tenant's own API key is encrypted at rest with the operator's master key. var llmOptions = builder.Configuration.GetSection("Llm").Get() ?? new LlmOptions(); builder.Services.AddSingleton(llmOptions); // Encryption at rest is on for every deploy: the operator's key, else one generated and // kept in a key file on first run. Refuses to start with neither — see SecretKeySource. var secretKey = SecretKeySource.Resolve(builder.Configuration); builder.Services.AddSingleton(new SecretProtector(secretKey.Key, secretKey.Previous)); builder.Services.AddHttpClient(); // The operator's endpoint: its deadline is the call's own cancellation (#352), not the // 100-second HttpClient default, which would cut a slow local model short. builder.Services.AddHttpClient("llm", c => c.Timeout = Timeout.InfiniteTimeSpan); builder.Services.AddSingleton(); builder.Services.AddScopedResponseCompression(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(sp => new ResumeRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); builder.Services.AddScoped(sp => new LlmSettingsRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddScoped(sp => new LlmUsageRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, llmOptions.DailyCapPerTenant)); builder.Services.AddScoped(sp => new CoverLetterRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); // Contacts and the interaction log (#360): personal data, sealed at rest. builder.Services.AddScoped(sp => new ContactRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); builder.Services.AddScoped(sp => new AppNoteRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); // The editor's Autofill button: server-side fetch of a job-posting URL (SSRF-guarded; // its own pinned HttpClient, so not from the factory) + the JobPosting/OG parser. builder.Services.AddSingleton(); // Agentic auto-apply. The per-tenant settings and audit trail are always served (the // SPA's Settings · Agent tab and the on-demand verdict endpoint use them); the // unattended worker itself is registered only when Agent__Enabled=true — the // intended shape is a second container from this image with that set and no port. var agentOptions = builder.Configuration.GetSection("Agent").Get() ?? new AgentOptions(); builder.Services.AddSingleton(agentOptions); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(sp => new AgentSettingsRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new AgentEventRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new AgentPacketRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); // The answer bank: every screening question the agent has met, and what it says. builder.Services.AddScoped(sp => new AnswerBankRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); // Step 3: the packet. Greenhouse's public Job Board API is the one ATS form we can // read without a browser or an employer key; host pinned here, no tenant URL. builder.Services.AddHttpClient(GreenhouseBoard.ClientName, c => { c.BaseAddress = new Uri("https://boards-api.greenhouse.io/"); c.Timeout = TimeSpan.FromSeconds(15); c.DefaultRequestHeaders.UserAgent.ParseAdd("OSApplyTrack/1.17 (+https://github.com/CryptoJones/OSApplyTrack)"); }); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); // The "moo": per-tenant Telegram notification when a packet is ready to submit. The // host is pinned in code (no tenant-controlled URL, so no SSRF guard). RemoveAllLoggers: // the factory's default handler logs the request URI, which carries the bot token. builder.Services.AddHttpClient(TelegramNotifier.ClientName, c => { c.BaseAddress = new Uri("https://api.telegram.org/"); c.Timeout = TimeSpan.FromSeconds(15); }).RemoveAllLoggers(); builder.Services.AddSingleton(); // The candidate's mailbox, for the security code Greenhouse emails after a click. builder.Services.AddSingleton(); builder.Services.AddScoped(sp => new MailboxSettingsRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService(), sp.GetRequiredService>())); // The candidate's own sign-ins on account-only ATSs (SAP SuccessFactors, #216). builder.Services.AddScoped(sp => new BoardAccountRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService(), sp.GetRequiredService>())); // Email as the second channel (#355): the same SMTP sender, to the account's own address. builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(sp => new NotificationSettingsRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService(), sp.GetRequiredService>())); // Step 4: the browser. Its own container (see docker/browser), reached over ws://; // unset means no browser and the copy-and-open path. The submitter itself is // harmless to register everywhere — it refuses to run unless configured. var browserOptions = builder.Configuration.GetSection("Browser").Get() ?? new BrowserOptions(); builder.Services.AddSingleton(browserOptions); // What a request may promise: a browser here, or on an agent worker whose heartbeat // (agent_workers) is fresh — the shipped shape, where only the agent has the endpoint. builder.Services.AddScoped(sp => new BrowserAvailability(browserOptions, sp.GetRequiredService())); builder.Services.AddSingleton(); // The worker takes the seam, so a worker-level test can stand a fake browser in (#192). builder.Services.AddSingleton(sp => sp.GetRequiredService()); builder.Services.AddSingleton(); builder.Services.AddScoped(sp => new SubmitRequestRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId)); builder.Services.AddScoped(sp => new AgentEvidenceRepo( sp.GetRequiredService(), sp.GetRequiredService().TenantId, sp.GetRequiredService())); if (agentOptions.Enabled) { var agentConnectionString = connectionString; builder.Services.AddSingleton(sp => new AgentWorker( agentConnectionString, agentOptions, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), browserOptions, sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService(), sp.GetRequiredService())); builder.Services.AddHostedService(sp => sp.GetRequiredService()); } // Opt-in OpenTelemetry and /metrics (#359): nothing is registered, and nothing leaves the // process, unless OTEL_EXPORTER_OTLP_ENDPOINT or Metrics__Port is set. builder.AddApplyTrackTelemetry(TelemetryOptions.From(builder.Configuration), agentOptions.Enabled ? "applytrack-agent" : "applytrack-api"); // The daily retention sweep (#350): old screenshots' bytes, dead sessions and magic-link // tokens, stale audit events, and — only when opted in — old dedup keys. The migrating // (owner) container runs it; a Migrations:Mode=wait worker's role cannot delete. var retentionOptions = builder.Configuration.GetSection("Retention").Get() ?? new RetentionOptions(); builder.Services.AddSingleton(retentionOptions); if (retentionOptions.Enabled && !string.Equals(builder.Configuration["Migrations:Mode"], "wait", StringComparison.OrdinalIgnoreCase)) builder.Services.AddHostedService(); // The daily applied-jobs digest (#336): the same container, the one that sends sign-in mail. if (!string.Equals(builder.Configuration["Migrations:Mode"], "wait", StringComparison.OrdinalIgnoreCase)) builder.Services.AddHostedService(); // The daily follow-up and interview reminder (#354): the same container, every channel. if (!string.Equals(builder.Configuration["Migrations:Mode"], "wait", StringComparison.OrdinalIgnoreCase)) builder.Services.AddHostedService(); // The JSON contract the SPA depends on: C# PascalCase <-> snake_case JSON // (ContactEmail <-> contact_email), case-insensitive on the way in. The dictionary // key policy is deliberately left unset so map keys — status names, lane names, // source ids — pass through verbatim. builder.Services.ConfigureHttpJsonOptions(o => { o.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; o.SerializerOptions.PropertyNameCaseInsensitive = true; }); // Per-IP throttles on the two abuse-prone unauthenticated/expensive routes: // magic-link requests (email spam / user enumeration probing) and the on-demand // poll (each one fans out to external job boards). Other routes are unmetered. // UseForwardedHeaders middleware already populates RemoteIpAddress from // X-Forwarded-For, so we read that instead of parsing the raw header. static string ClientPartition(HttpContext ctx) => ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown"; const int UploadPermits = 10; const int DraftPermits = 10; const int TokenPermits = ApiTokenRepo.RequestsPerMinute; static bool IsDraftRoute(HttpContext ctx) => ctx.GetEndpoint()?.Metadata.GetMetadata()?.PolicyName == "draft"; static bool IsUploadRoute(HttpRequest req) => req.Method == HttpMethods.Post && (req.Path == "/api/resume/upload" || req.Path == "/api/account/import"); builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddPolicy("auth", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = 10, Window = TimeSpan.FromMinutes(5) })); options.AddPolicy("poll", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = 15, Window = TimeSpan.FromMinutes(1) })); // Cover-letter drafting fans out to the LLM (cost + latency) — throttle harder. options.AddPolicy("draft", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = DraftPermits, Window = TimeSpan.FromMinutes(5) })); // A test notification is an outbound message to the tenant's own chat — small budget. options.AddPolicy("notify", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(5) })); // The calendar feed (#354) is read with a token, not a session: a calendar client polls it // every so often, so a generous budget, but a bound on guessing all the same. options.AddPolicy("calendar", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = 30, Window = TimeSpan.FromMinutes(1) })); // Each scrape is an outbound fetch of an arbitrary site — same budget as poll. options.AddPolicy("scrape", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = 15, Window = TimeSpan.FromMinutes(1) })); // Résumé PDF upload and account import parse a multi-megabyte body each (#344): // metered per IP here, and per tenant by the global limiter below, so neither a // botnet on one account nor many accounts behind one address can hammer them. options.AddPolicy("upload", ctx => RateLimitPartition.GetFixedWindowLimiter( ClientPartition(ctx), _ => new FixedWindowRateLimiterOptions { PermitLimit = UploadPermits, Window = TimeSpan.FromMinutes(5) })); // The model-backed routes ("draft") get the same per-tenant partition (#346): the per-IP // budget alone lets one account spread across addresses spend the operator's LLM key. // The daily per-tenant cap on that key is LlmUsageRepo. var tenantLimiter = PartitionedRateLimiter.Create(ctx => ctx.RequestServices.GetRequiredService().UserId is not { } tenantId ? RateLimitPartition.GetNoLimiter("") : IsUploadRoute(ctx.Request) ? RateLimitPartition.GetFixedWindowLimiter($"upload:{tenantId}", _ => new FixedWindowRateLimiterOptions { PermitLimit = UploadPermits, Window = TimeSpan.FromMinutes(5) }) : IsDraftRoute(ctx) ? RateLimitPartition.GetFixedWindowLimiter($"draft:{tenantId}", _ => new FixedWindowRateLimiterOptions { PermitLimit = DraftPermits, Window = TimeSpan.FromMinutes(5) }) : RateLimitPartition.GetNoLimiter("")); // Each personal API token (#356) gets its own budget on top: a runaway script is cut off // without throttling its owner's browser, and a leaked token can't hammer the API. var tokenLimiter = PartitionedRateLimiter.Create(ctx => ctx.RequestServices.GetRequiredService().TokenId is { } tokenId ? RateLimitPartition.GetFixedWindowLimiter($"token:{tokenId}", _ => new FixedWindowRateLimiterOptions { PermitLimit = TokenPermits, Window = TimeSpan.FromMinutes(1) }) : RateLimitPartition.GetNoLimiter("")); options.GlobalLimiter = PartitionedRateLimiter.CreateChained(tenantLimiter, tokenLimiter); }); var app = builder.Build(); // Bring the schema up to date on startup — the cross-runtime contract both the // .NET API and the Python poller share. // MigrationTimeoutSeconds (default 60) — increase for slower databases / many // migrations; the same timeout applies to every script the run executes. var migrationTimeout = TimeSpan.FromSeconds(TimeoutConfiguration.PositiveTimeoutSeconds( builder.Configuration["MigrationTimeoutSeconds"], defaultValue: 60)); // Migrations:Mode=wait — for a container whose DB role cannot migrate (the agent // worker under its least-privilege role): wait for the owner container to, instead. app.Logger.LogInformation("secrets key: {Source}", secretKey.Source); if (string.Equals(builder.Configuration["Migrations:Mode"], "wait", StringComparison.OrdinalIgnoreCase)) Migrator.WaitUntilCurrent(connectionString, TimeSpan.FromMinutes(5)); else { // AGENT_DB_PASSWORD / POLLER_DB_PASSWORD: the agent's and the poller's own // least-privilege roles, created (or re-passworded) here on every boot (#345). Migrator.Upgrade(connectionString, migrationTimeout, new Dictionary { [Migrator.AgentRole] = builder.Configuration["AGENT_DB_PASSWORD"], [Migrator.PollerRole] = builder.Configuration["POLLER_DB_PASSWORD"], }); // Seal what an older release stored in the clear, and finish any key rotation. Only // the migrating container: the agent's least-privilege role has no business rewriting // résumés, and the sweep is idempotent so one runner is enough. await AtRestEncryptor.SweepAsync(connectionString, app.Services.GetRequiredService(), app.Logger); } // The Prometheus scrape (#359), on its own port only, ahead of the session gate. app.UseApplyTrackMetricsEndpoint(); // Honor forwarded client/protocol data only from a known reverse proxy. ASP.NET // Core's loopback defaults cover same-host Caddy/nginx/`tailscale serve`; container // gateways and remote proxies must be named under ForwardedHeaders:KnownProxies or // KnownNetworks. An untrusted direct caller's headers are ignored, so they cannot // forge Request.IsHttps or rotate the per-IP rate-limit partition. app.UseForwardedHeaders(ForwardedHeadersConfiguration.Create(builder.Configuration)); // Right after UseForwardedHeaders: warn once if a proxy's X-Forwarded-Proto=https was // dropped because the peer isn't trusted, which silently disables Secure cookies + HSTS // (#229). Names the peer to trust; never trusts the header itself. app.UseMiddleware(); // Stamp CSP + the other hardening headers on every response. After UseForwardedHeaders // so Request.IsHttps is accurate (HSTS only when actually behind HTTPS). app.UseMiddleware(); // Catch the domain exceptions first so every downstream handler can throw them and // get the FastAPI-compatible {"detail": "..."} body + status the SPA expects. app.UseMiddleware(); // Bound ordinary JSON mutations before Minimal API model binding. Account import // and résumé PDF upload keep their separate, larger purpose-built limits. app.UseMiddleware(); // Serve the vanilla-JS SPA verbatim from wwwroot (index.html as the default doc). // Static files short-circuit before the tenancy middleware, so the shell loads // without a session and the SPA's own login gate handles the 401s on /api. // Brotli/gzip (#352) for the SPA's static files and the three big polled lists only. // Not every response: compressing one that reflects attacker input beside a secret is // what BREACH exploits, and these carry the tenant's own data, never a token. app.UseWhen(ResponseCompressionScope.Applies, a => a.UseResponseCompression()); app.UseDefaultFiles(); // The SPA's script URLs never change between releases, so browsers must revalidate // (a cheap 304 on the ETag) instead of running a heuristically cached old app.js. app.UseStaticFiles(new StaticFileOptions { OnPrepareResponse = ctx => ctx.Context.Response.Headers.CacheControl = "no-cache", }); // The tenancy choke-point: resolve the session -> tenant, enforce auth on /api. app.UseMiddleware(); // Enforce the per-route rate-limit policies declared above (RequireRateLimiting). app.UseRateLimiter(); // Enforce request-body size limits on the endpoints that accept large uploads: a // declared Content-Length over the cap is refused before the body is read, and a // chunked body is capped as it streams (#344), so it works under TestServer too. // After auth and the rate limiter, so an anonymous or throttled caller never gets a // chunked body buffered on its behalf. app.UseWhen(ctx => ctx.Request.Method == HttpMethods.Post && ctx.Request.Path == "/api/scrape", branch => branch.Use(RequestBodyCap.Enforce(64L * 1024))); app.UseWhen(ctx => ctx.Request.Method == HttpMethods.Post && ctx.Request.Path == "/api/account/import", branch => branch.Use(RequestBodyCap.Enforce(AccountEndpoints.MaxImportBytes))); app.UseWhen(ctx => ctx.Request.Method == HttpMethods.Post && ctx.Request.Path == "/api/resume/upload", branch => branch.Use(RequestBodyCap.Enforce(ResumePdfImporter.MaxPdfBytes + 16L * 1024))); // The running build, read from the assembly rather than a hard-coded constant so it can // only ever report what is actually deployed. InformationalVersion carries a "+" // suffix on CI builds; the header shows the bare version, so trim it once here. var buildVersion = Assembly.GetExecutingAssembly() .GetCustomAttribute()?.InformationalVersion ?? ""; buildVersion = buildVersion.Split('+')[0]; if (buildVersion.Length == 0) buildVersion = "unknown"; // Liveness: cheap and static, so a transient DB blip never trips it into a restart loop. // It also carries the build version. This is the one endpoint the SPA can read before // login, which is what lets the header show a version on the sign-in screen too. // The version on an unauthenticated surface is an accepted disclosure (#232): the pre-login // header badge (#111) needs it, and it is read live from the running assembly, not a stale // constant, so it is worth more as an accurate signal than it costs as a fingerprint. app.MapGet("/health", () => Results.Ok(new { status = "ok", version = buildVersion })); // Readiness: actually touch Postgres so an orchestrator can gate traffic on a working // DB. Deliberately a separate path from liveness — a DB hiccup should drain traffic, // not kill the pod. 503 (not an exception) on failure so it reads as "not ready" cleanly. // The connected/disconnected flag is an accepted disclosure (#232): it is the point of a // readiness probe. The nginx version banner, the third item in that audit, is turned off at // the edge (`server_tokens off;`) rather than in the app — see README, Security & hardening. app.MapGet("/health/ready", async (NpgsqlDataSource db, CancellationToken ct) => { try { await using var conn = await db.OpenConnectionAsync(ct); await using var cmd = conn.CreateCommand(); cmd.CommandText = "SELECT 1"; await cmd.ExecuteScalarAsync(ct); return Results.Ok(new { status = "ready", database = "connected" }); } catch { return Results.Json( new { status = "unavailable", database = "disconnected" }, statusCode: StatusCodes.Status503ServiceUnavailable); } }); app.MapAppsEndpoints(); app.MapBlacklistEndpoints(); app.MapCriteriaEndpoints(); app.MapAuthEndpoints(); app.MapAccountEndpoints(); app.MapMaterialsEndpoints(); app.MapScrapeEndpoints(); app.MapAgentEndpoints(); app.MapPacketEndpoints(); app.MapAnswersEndpoints(); app.MapNotificationsEndpoints(); app.MapSubmitEndpoints(); app.MapBoardAccountEndpoints(); app.MapReadyEndpoints(); app.MapPipelineEndpoints(); app.MapErrorsEndpoints(); app.MapRemindersEndpoints(); app.MapContactsEndpoints(); app.MapFollowUpEndpoints(); app.Run(); // Exposed so the test project's WebApplicationFactory can boot the app. public partial class Program;