api/ApplyTrack.Api/Program.cs
Rama main · réplica de lectura del repositorio original.
// 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 <command>`: 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<IDbConnection>(sp =>
new ResilientDbConnection(
sp.GetRequiredService<NpgsqlDataSource>().CreateConnection(),
sp.GetRequiredService<ILogger<ResilientDbConnection>>()));
builder.Services.AddScoped<TenantContext>();
builder.Services.AddScoped<UserRepo>();
builder.Services.AddScoped<SessionRepo>();
builder.Services.AddScoped<MagicTokenRepo>();
builder.Services.AddScoped(sp => new ApplicationRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new StatusEventRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
// Interviews and follow-ups (#354), and the scoped tokens behind the calendar feed.
builder.Services.AddScoped(sp => new AppEventRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new ApiTokenRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new CriteriaRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new BlacklistRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new PollRequestRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new PollRunRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().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<EmailOptions>() ?? 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<IEmailSender, SmtpEmailSender>();
else
builder.Services.AddSingleton<IEmailSender, ConsoleEmailSender>();
// 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<LlmOptions>() ?? 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<ILlmClient, OpenAiCompatibleLlmClient>();
builder.Services.AddScopedResponseCompression();
builder.Services.AddSingleton<CoverLetterDrafter>();
builder.Services.AddSingleton<FollowUpDrafter>();
builder.Services.AddSingleton<IFollowUpMail, MailKitFollowUpMail>();
builder.Services.AddScoped(sp => new ResumeRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
builder.Services.AddScoped(sp => new LlmSettingsRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>(),
sp.GetRequiredService<ILogger<LlmSettingsRepo>>()));
builder.Services.AddScoped(sp => new LlmUsageRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
llmOptions.DailyCapPerTenant));
builder.Services.AddScoped(sp => new CoverLetterRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
// Contacts and the interaction log (#360): personal data, sealed at rest.
builder.Services.AddScoped(sp => new ContactRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
builder.Services.AddScoped(sp => new AppNoteRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
// 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<JobPageFetcher>();
// 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<AgentOptions>() ?? new AgentOptions();
builder.Services.AddSingleton(agentOptions);
builder.Services.AddSingleton<StructuredCompleter>();
builder.Services.AddSingleton<FitJudge>();
builder.Services.AddSingleton<LeadEvaluator>();
builder.Services.AddScoped(sp => new AgentSettingsRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new AgentEventRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new AgentPacketRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
// The answer bank: every screening question the agent has met, and what it says.
builder.Services.AddScoped(sp => new AnswerBankRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
// 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<GreenhouseBoard>();
builder.Services.AddSingleton<AnswerDrafter>();
builder.Services.AddSingleton<PacketBuilder>();
// 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<INotifier, TelegramNotifier>();
// The candidate's mailbox, for the security code Greenhouse emails after a click.
builder.Services.AddSingleton<ISecurityCodeSource, ImapSecurityCodeSource>();
builder.Services.AddScoped(sp => new MailboxSettingsRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>(), sp.GetRequiredService<ILogger<MailboxSettingsRepo>>()));
// The candidate's own sign-ins on account-only ATSs (SAP SuccessFactors, #216).
builder.Services.AddScoped(sp => new BoardAccountRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>(), sp.GetRequiredService<ILogger<BoardAccountRepo>>()));
// Email as the second channel (#355): the same SMTP sender, to the account's own address.
builder.Services.AddSingleton<EmailNotifier>();
builder.Services.AddSingleton<PacketReadyNotifier>();
builder.Services.AddScoped(sp => new NotificationSettingsRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>(),
sp.GetRequiredService<ILogger<NotificationSettingsRepo>>()));
// 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<BrowserOptions>() ?? 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<IDbConnection>()));
builder.Services.AddSingleton<BrowserSubmitter>();
// The worker takes the seam, so a worker-level test can stand a fake browser in (#192).
builder.Services.AddSingleton<IBrowserSubmitter>(sp => sp.GetRequiredService<BrowserSubmitter>());
builder.Services.AddSingleton<FormDiscoverer>();
builder.Services.AddScoped(sp => new SubmitRequestRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId));
builder.Services.AddScoped(sp => new AgentEvidenceRepo(
sp.GetRequiredService<IDbConnection>(), sp.GetRequiredService<TenantContext>().TenantId,
sp.GetRequiredService<SecretProtector>()));
if (agentOptions.Enabled)
{
var agentConnectionString = connectionString;
builder.Services.AddSingleton(sp => new AgentWorker(
agentConnectionString, agentOptions, sp.GetRequiredService<LlmOptions>(),
sp.GetRequiredService<SecretProtector>(), sp.GetRequiredService<LeadEvaluator>(),
sp.GetRequiredService<PacketBuilder>(), sp.GetRequiredService<PacketReadyNotifier>(),
browserOptions, sp.GetRequiredService<IBrowserSubmitter>(),
sp.GetRequiredService<ILoggerFactory>(), sp.GetRequiredService<ISecurityCodeSource>(),
sp.GetRequiredService<INotifier>()));
builder.Services.AddHostedService(sp => sp.GetRequiredService<AgentWorker>());
}
// 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<RetentionOptions>() ?? new RetentionOptions();
builder.Services.AddSingleton(retentionOptions);
if (retentionOptions.Enabled
&& !string.Equals(builder.Configuration["Migrations:Mode"], "wait", StringComparison.OrdinalIgnoreCase))
builder.Services.AddHostedService<RetentionWorker>();
// 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<DailyDigestWorker>();
// 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<ReminderWorker>();
// 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<EnableRateLimitingAttribute>()?.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<HttpContext, string>(ctx =>
ctx.RequestServices.GetRequiredService<TenantContext>().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<HttpContext, string>(ctx =>
ctx.RequestServices.GetRequiredService<TenantContext>().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<string, string?>
{
[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<SecretProtector>(), 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<ForwardedProtoDiagnosticMiddleware>();
// Stamp CSP + the other hardening headers on every response. After UseForwardedHeaders
// so Request.IsHttps is accurate (HSTS only when actually behind HTTPS).
app.UseMiddleware<SecurityHeadersMiddleware>();
// 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<ApiExceptionMiddleware>();
// 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<JsonBodyLimitMiddleware>();
// 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<TenantMiddleware>();
// 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 "+<commit>"
// suffix on CI builds; the header shows the bare version, so trim it once here.
var buildVersion = Assembly.GetExecutingAssembly()
.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.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<Program> can boot the app.
public partial class Program;