Repository navigation
Expand file tree
/
Copy pathProgram.cs
More file actions
621 lines (556 loc) · 30.2 KB
/
Copy pathProgram.cs
File metadata and controls
621 lines (556 loc) · 30.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
using Cluckwork.Api;
using Cluckwork.Api.Cli;
using Cluckwork.Api.Endpoints.ClientErrors;
using Cluckwork.Api.Hosting;
using Cluckwork.Api.Hosting.Modules;
using Cluckwork.Api.Middleware;
using Cluckwork.Api.Modules.Access.Auth;
using Cluckwork.Api.Modules.Access.Me;
using Cluckwork.Api.Modules.Access.OAuth;
using Cluckwork.Api.Modules.Access.Users;
using Cluckwork.Api.Modules.Commerce.Catalog;
using Cluckwork.Api.Modules.Commerce.Customers;
using Cluckwork.Api.Modules.Commerce.Sales;
using Cluckwork.Api.Modules.EggOperations.DailyEntries;
using Cluckwork.Api.Modules.EggOperations.EggGrades;
using Cluckwork.Api.Modules.EggOperations.Stock;
using Cluckwork.Api.Modules.Farm.Accounts;
using Cluckwork.Api.Modules.Finance.Expenses;
using Cluckwork.Api.Modules.FlockManagement.Flocks;
using Cluckwork.Api.Modules.GeneralInventory.Inventory;
using Cluckwork.Api.Modules.GeneralInventory.Water;
using Cluckwork.Api.Modules.Insights.Audit;
using Cluckwork.Api.Modules.Insights.Export;
using Cluckwork.Api.Modules.Insights.Reports;
using Cluckwork.Api.Security;
using Cluckwork.Api.Validation;
using Cluckwork.Infrastructure.Persistence;
using Cluckwork.Infrastructure.RateLimiting;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Diagnostics;
using Microsoft.AspNetCore.Http.Metadata;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Serilog;
// #266 — the `healthcheck` verb is the container HEALTHCHECK probe. It only GETs
// the already-running serving process's /health/ready over loopback, so — unlike
// the ICliCommand verbs, which operate ON the built host via
// CliDispatcher after Build() — it needs no host, DI, DB or config. Dispatch it
// HERE, before anything is built, so a 30s HEALTHCHECK never pays a full app
// startup, re-validates the connection string, or re-logs boot warnings on every
// tick. (The other verbs can't move up: they require the built host's services.)
if (args is [HealthCheckCliCommand.Verb, ..])
return await HealthCheckCliCommand.RunAsync(args);
// #347 — decide ONCE what this process was started to be. Every role-scoped boot
// guard below takes this rather than relying on where its statement sits
// relative to the CLI dispatch. `healthcheck` is a one-shot verb too and is
// classified as one (see ProcessRoles.OneShotVerbs); its early return above is
// a startup-cost optimisation, not what makes it safe.
var processRole = ProcessRoles.From(args);
var builder = WebApplication.CreateBuilder(args);
var telemetry = builder.Services.AddCluckworkTelemetry(
builder.Configuration, builder.Environment, processRole);
var persistence = builder.Services.AddCluckworkPersistence(
builder.Configuration,
builder.Environment);
var identity = builder.Services.AddCluckworkIdentity(
builder.Configuration, builder.Environment, processRole);
var dataProtection = builder.Services.AddCluckworkDataProtection(
builder.Configuration, builder.Environment, processRole);
var rateLimiting = builder.Services.AddCluckworkRateLimiting(
builder.Configuration, processRole);
builder.Services.AddCluckworkEdgeSecurity(rateLimiting.TrustedProxies);
var sharedState = builder.Services.AddCluckworkSharedState(builder.Configuration, processRole);
// #545 — the account-scoped report concurrency cap. After shared state (it
// resolves IConnectionMultiplexer when Redis is configured) and after rate
// limiting (permit limit). Reuse the namespace that registered shared state;
// binding configuration again here could send the cap to a different keyspace.
builder.Services.AddCluckworkReportConcurrencyCap(
rateLimiting.Options.ReportsConcurrency.PermitLimit, sharedState.Redis.KeyNamespace);
builder.Services.AddCluckworkModules(builder.Configuration);
// #398 — ASP.NET Core's minimal-API parameter binding defaults
// RouteHandlerOptions.ThrowOnBadRequest to true only in Development; outside
// it (Testing, Production — everywhere this app actually runs client
// traffic) a binding failure (malformed JSON, a fractional quantity into an
// `int`, an unparseable date/guid, …) is swallowed INSIDE the framework's
// generated code: it sets Response.StatusCode itself and returns WITHOUT
// throwing, so UseExceptionHandler's `/error` mapping below never runs at
// all — the client gets a bare 400 with an EMPTY body, not even a generic
// ProblemDetails. Force the throw in every environment so this app's own
// `/error` handler is always the thing shaping the response, instead of that
// environment-conditional framework default. (Only ONE test factory —
// RequestLoggingFactory — used to carry its own copy of this override, added
// because ITS test specifically needed the real throw; this registration
// supersedes it globally, so that copy was removed.)
builder.Services.Configure<Microsoft.AspNetCore.Routing.RouteHandlerOptions>(
o => o.ThrowOnBadRequest = true);
// --- OpenAPI ---
builder.Services.AddOpenApi();
builder.Services.AddCluckworkHealthChecks();
builder.Services.AddCluckworkJobs();
// ----------------------------------------------------------------
var app = builder.Build();
// #262 — replay the connection-string TLS warnings once, now that a logger exists (a
// floor violation already failed the boot above during configuration). Logged before the
// CLI dispatch so the host-backed one-shot verbs surface it too.
foreach (var connectionStringWarning in persistence.ConnectionStringWarnings)
app.Logger.LogWarning("{ConnectionStringWarning}", connectionStringWarning);
// #794 — which certificate encrypts the key ring, so an operator can match it to the
// one they issued. The fingerprint only; never the PEM or the key.
if (dataProtection.CertificateSha256 is { } certificateSha256)
app.Logger.LogInformation(
"Data Protection key ring encrypted with the certificate whose SHA-256 fingerprint is {CertificateSha256}",
certificateSha256);
// #260/#319 — Production boot guards for the SERVING process's security posture.
// Deliberately called BEFORE the CLI dispatch below: what spares the one-shot
// verbs is the role check inside, not this call's position, and putting it here
// is the standing demonstration of that (#347). See Hosting/ServingBootGuards.cs.
ServingBootGuards.EnsureServingConfiguration(
processRole, app.Environment, builder.Configuration, rateLimiting);
// One-off operator commands run then EXIT
// before the web host starts — Kestrel and the hosted services never run for
// these. Each lives in Cluckwork.Api.Cli; the dispatcher returns the exit
// code, or null when no CLI verb matched (a normal serving start). Extracted
// from the ~180 inline lines that used to sit here (#288).
if (await CliDispatcher.TryRunAsync(app, args) is int cliExitCode)
return cliExitCode;
// #347 — reaching here with a OneShot role is impossible by construction, so
// this line exists to make that a checked fact rather than a comment. The only
// verb in OneShotVerbs that CliDispatcher cannot handle is `healthcheck`, and
// it returned at the top of this file long before now; if it ever stops doing
// so, execution would otherwise fall through into app.Run() and a health probe
// would try to become a server. Classifying healthcheck as OneShot is what makes
// that direction fail OPEN (guards skipped) where the retired IsCliInvocation
// failed closed, so the invariant that kept it safe now needs enforcing.
if (processRole is ProcessRole.OneShot)
throw new InvalidOperationException(
$"'{args[0]}' is a one-shot verb (ProcessRoles.OneShotVerbs) but nothing dispatched it, so "
+ "the serving host was about to start instead. A verb was added to the role registry "
+ "without a matching CliDispatcher command, or healthcheck's early return above was "
+ "moved or made conditional.");
// One boot line makes export misconfiguration observable — a typo'd env var
// name otherwise silently disables the whole pipeline (#226 review).
if (telemetry.TraceEndpoint is not null)
app.Logger.LogInformation(
"OTLP export enabled: traces -> {OtlpTraceEndpoint}, metrics -> {OtlpMetricsEndpoint} ({OtlpProtocol})",
telemetry.TraceEndpoint,
telemetry.MetricsEndpoint,
telemetry.Protocol);
else
app.Logger.LogInformation("OTLP export disabled (Otlp:Endpoint not set)");
// ----------------------------------------------------------------
// --- Startup: apply migrations (idempotent) ---
// Database:MigrateOnStartup (default true, but Production sets it false —
// #263) gates schema DDL. #283 — there is no runtime seeder to run after it:
// the base reference data (roles, default egg grades, the default account)
// ships AS PART OF the migrations themselves via raw migrationBuilder.Sql with
// WHERE NOT EXISTS guards (NOT EF's InsertData/HasData), so a freshly migrated
// database is already usable with no further boot-time step and no Seed:*
// config. The first admin is provisioned separately, out of band, by the
// one-shot `bootstrap-admin` command (never a serving-boot side effect — see
// Cli/BootstrapAdminCliCommand.cs).
{
using var startupScope = app.Services.CreateScope();
var sp = startupScope.ServiceProvider;
if (builder.Configuration.GetValue("Database:MigrateOnStartup", true))
await sp.GetRequiredService<AppDbContext>().Database.MigrateAsync();
}
// Resolve the real client IP and scheme from a trusted proxy's forwarded
// headers before anything reads RemoteIpAddress or Request.Scheme (the rate
// limiter, HTTPS redirection and HSTS all depend on it) — #143/#144.
app.UseForwardedHeaders();
// Security response headers on every response (#144). Outermost after the
// forwarded headers so it also covers static files and error responses.
app.UseSecurityHeaders();
// #312 — default private/no-store Cache-Control on every response (API reads,
// writes, auth, validation, errors, exports). Same outermost placement as
// UseSecurityHeaders and for the same reason: it must also cover the
// exception-handler re-execution and any response produced below, while still
// letting a downstream stage's own deliberate Cache-Control (static assets,
// the SPA fallback, the farm logo's revalidate policy) win via TryAdd.
app.UseDefaultResponseCaching();
// HSTS outside Development — only meaningful now the forwarded proto is trusted
// so Request.IsHttps reflects the real client scheme.
if (!app.Environment.IsDevelopment())
app.UseHsts();
app.UseExceptionHandler(new ExceptionHandlerOptions
{
AllowStatusCode404Response = true,
ExceptionHandlingPath = "/error"
});
if (app.Environment.IsDevelopment())
app.MapOpenApi();
app.UseHttpsRedirection();
// Serve the built SPA (copied to wwwroot in the Docker image). Static assets are
// public — mounted before auth. API routes and the SPA fallback are wired below.
// #141 — hashed /assets/* are immutable-forever, index.html revalidates, so a
// fronting CDN (and browsers) can cache aggressively without serving a stale app.
//
// #873 — index.html is NOT one of those static assets any more. It carries a
// per-response CSP nonce, so it is read and split once here and written by
// SpaShell below; `null` means there is no built SPA (Development, the test
// host), and everything then behaves exactly as it did before.
var spaShell = SpaShell.Load(app.Environment);
if (spaShell is not null)
app.UseSpaShell(spaShell);
app.UseStaticFiles(new StaticFileOptions
{
OnPrepareResponse = StaticAssetCaching.ApplyCacheHeaders,
// #874 review (local Codex pass) — null (the middleware's own default,
// env.WebRootFileProvider) when there is no built SPA to template in the
// first place; wrapped only when SpaShell owns index.html, so the raw file
// can never leak through an alternate path spelling UseSpaShell's exact
// compare misses (e.g. a raw "GET //index.html").
FileProvider = spaShell is not null
? new IndexHtmlHidingFileProvider(app.Environment.WebRootFileProvider)
: null
});
// One structured completion line per request (#214): method, path, status,
// elapsed — the request's TraceId rides on every event via Serilog. Mounted
// after static files so hashed-asset hits don't flood the log; health probes
// are demoted below Information for the same reason.
app.UseSerilogRequestLogging(options =>
{
// Pin the middleware to THIS host's logger. Its default is the process-wide
// static Log.Logger, which any co-hosted Serilog app (the integration-test
// suite runs many) reassigns and disposes — completions would silently go
// to another host's pipeline.
options.Logger = app.Services.GetRequiredService<Serilog.ILogger>();
// No dedicated BadHttpRequestException arm here (#398 review, Codex): a
// 400 binding failure is now caught by UseCluckworkBindingFailureResponse
// immediately below, which does NOT rethrow, so by the time a completion
// reaches this callback it is either (a) an ordinary sub-500 response
// with `exception` null — falls through to Information via the existing
// status-code check, no special-casing needed — or (b) a genuine
// unhandled exception (anything that middleware didn't catch, including
// a non-400 BadHttpRequestException like the #309 413/415 cases), which
// correctly stays Error. Adding an arm for the exception TYPE here would
// be dead code: nothing reaching this point is a 400
// BadHttpRequestException anymore.
options.GetLevel = (httpContext, _, exception) =>
// Health first — a FAILING probe (503 during a DB outage) must not
// escalate to Error while orchestrators poll every few seconds. The
// exception-handler re-execution at /error is demoted too: the
// original request already logged its Error completion, a second
// line would double-count every failed request.
httpContext.Request.Path.StartsWithSegments("/health")
|| httpContext.Features.Get<IExceptionHandlerFeature>() is not null
? Serilog.Events.LogEventLevel.Verbose
: exception is not null || httpContext.Response.StatusCode >= 500
? Serilog.Events.LogEventLevel.Error
: Serilog.Events.LogEventLevel.Information;
// #493 — the entity-scoped audit history feature's success metric: is the
// per-record "Audit history" link actually being used? Range check, not
// an exact 200 — the endpoint only returns Results.Ok today, but a metric
// in Program.cs shouldn't be coupled to one endpoint's exact status
// literal. Known, accepted limitation (not fixed here — would need
// response-body inspection, disproportionate for a one-line log
// enrichment): this also counts a syntactically valid but non-matching
// entityId (all-zeros, or a cross-tenant read — AuditTests.cs's own
// Viewer_NeverCrossesTenants proves that returns 200 with an empty
// array too) as a "successful scoped read." It measures requests, not
// distinct users or sessions — the request logs carry no actor identity
// today, and adding one is out of scope for this ticket.
options.EnrichDiagnosticContext = (diagnosticContext, httpContext) =>
{
var isEntityScopedAuditRead =
httpContext.Request.Path == "/api/v1/audit"
&& httpContext.Request.Query.ContainsKey("entityId")
&& httpContext.Response.StatusCode is >= 200 and < 300;
if (isEntityScopedAuditRead)
diagnosticContext.Set("EntityScopedAuditRequest", true);
};
});
// #398 review (Codex) — must be registered IMMEDIATELY after
// UseSerilogRequestLogging (i.e. INSIDE it), so a JSON-binding failure is
// caught and answered here, before Serilog's own exception handling ever
// sees it. Full reasoning in Hosting/BindingFailureResponse.cs. Does not
// change the relative order of anything already below it.
app.UseCluckworkBindingFailureResponse();
// Endpoint rate-limit policies (#143) — no global limiter, only routes that
// opt in via RequireRateLimiting are affected.
app.UseRateLimiter();
// #309 — enforce the per-endpoint request-body byte caps (the auth/credential
// endpoints opt in via WithMaxRequestBodyBytes). Placed AFTER Serilog request
// logging and the rate limiter — NOT right after UseExceptionHandler — so a
// declared-oversize body (the cheapest attack: no need to stream anything) is
// still logged (#214's one-line-per-request contract) and still consumes a
// login rate-limit permit (#143) before this middleware returns early; an
// earlier placement let an attacker flood oversized bodies at unlimited rate
// while every legitimate-sized attempt was throttled. Still ahead of
// auth/tenant/idempotency/binding/the PBKDF2 hasher — routing has already run
// (this is well after UseExceptionHandler), so the matched endpoint's metadata
// is available, and an over-limit body is refused before any of that work.
app.UseCluckworkRequestBodyLimit();
// #442 — the farm-logo upload cap, same reasoning as the middleware above:
// registered before IdempotencyMiddleware so an oversized body is rejected
// before idempotency buffers the whole thing to hash it. Can't reuse
// UseCluckworkRequestBodyLimit's own metadata directly because FarmLogoOptions.
// MaxUploadBytes is IOptionsSnapshot (per-request, config-reloadable), not the
// compile-time long that mechanism's metadata carries. See
// Hosting/FarmLogoRequestBodyCap.cs.
app.UseFarmLogoRequestBodyCap();
// #179 — same reasoning, for the farm banner's own (larger) cap.
app.UseFarmBannerRequestBodyCap();
app.UseAuthentication();
// #532 — must sit between authentication and tenant resolution. It blanks the
// ambient principal for endpoints marked IgnoresAmbientPrincipal (/auth/login),
// so a bearer for one farm cannot resolve a tenant while the request
// authenticates against another. Without it, a farm-A bearer posting a farm-B
// login bypasses the #128 account lockout entirely — see the middleware.
app.UseMiddleware<AmbientPrincipalMiddleware>();
app.UseMiddleware<TenantResolutionMiddleware>();
// #388 — flock-scope resolution, after tenant/user resolution and before the
// credential gate. Touches no credential state; position pinned by
// CredentialEpochMiddlewareOrderTests.
app.UseMiddleware<FlockScopeResolutionMiddleware>();
app.UseMiddleware<CredentialEpochMiddleware>();
// #283 — the first-run "you must set a new password" gate. BEFORE
// UseAuthorization (deliberately) so it applies uniformly regardless of which
// AuthPolicies tier an endpoint carries, and before idempotency so a blocked
// write never consumes a key.
app.UseMiddleware<MustChangePasswordMiddleware>();
// Authorization must run BEFORE idempotency: the replay path returns cached
// responses without invoking the endpoint, so a role-denied caller replaying
// an admin's key must hit the 403 first (codex review of PR #78).
app.UseAuthorization();
app.UseMiddleware<IdempotencyMiddleware>();
// --- Endpoint groups (URL versioned: /api/v1/...) ---
app.MapGroup("/api/v1/auth")
.WithTags("Auth")
// Rate-limit policies are applied per endpoint inside MapAuthEndpoints:
// login and refresh differ, and authenticated /logout is not limited (#143).
.MapAuthEndpoints();
app.MapGroup("/api/v1/flocks")
.WithTags("Flocks")
.RequireAuthorization()
.MapFlockEndpoints();
app.MapGroup("/api/v1/egg-grades")
.WithTags("EggGrades")
.RequireAuthorization()
.MapEggGradeEndpoints();
// Product catalog + packed-unit conversions (#97): writes admin-gated inside.
app.MapGroup("/api/v1/products")
.WithTags("Catalog")
.RequireAuthorization()
.MapProductEndpoints();
app.MapGroup("/api/v1/egg-unit-conversions")
.WithTags("Catalog")
.RequireAuthorization()
.MapEggUnitConversionEndpoints();
// Money data — admin end to end (#87), reads included.
app.MapGroup("/api/v1/expense-categories")
.WithTags("Expenses")
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapExpenseCategoryEndpoints();
app.MapGroup("/api/v1/expenses")
.WithTags("Expenses")
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapExpenseEndpoints();
app.MapGroup("/api/v1/account")
.WithTags("Account")
.RequireAuthorization()
.MapAccountEndpoints()
.MapFarmLogoEndpoints()
.MapFarmBannerEndpoints();
// #45 — user-scoped sibling of /account: identity comes from the JWT, not the
// farm. DEFAULT policy (not a named one) so every role, ReadOnly included, can
// read their own identity and language preference.
app.MapGroup("/api/v1/me")
.WithTags("Me")
.RequireAuthorization()
.MapMeEndpoints();
app.MapGroup("/api/v1/inventory")
.WithTags("Inventory")
.RequireAuthorization()
.MapInventoryEndpoints();
app.MapGroup("/api/v1/water-usage")
.WithTags("WaterUsage")
.RequireAuthorization()
.MapWaterUsageEndpoints();
app.MapGroup("/api/v1/daily-entries")
.WithTags("DailyEntries")
.RequireAuthorization()
.MapDailyEntryEndpoints();
app.MapGroup("/api/v1/stock")
.WithTags("Stock")
.RequireAuthorization()
.MapStockEndpoints();
app.MapGroup("/api/v1/customers")
.WithTags("Customers")
.RequireAuthorization()
.MapCustomerEndpoints()
.MapCustomerBalanceEndpoints();
app.MapGroup("/api/v1/sales")
.WithTags("Sales")
.RequireAuthorization()
.MapSaleEndpoints()
.MapOrderPaymentEndpoints();
// Payments are the Sales role's job (spec §5.1, #103): Owner/Manager/Sales.
// The rest of the money tier (expenses, money reports, audit, export) stays
// Owner/Manager.
app.MapGroup("/api/v1/payments")
.WithTags("Payments")
.RequireAuthorization(AuthPolicies.SalesAccess)
.MapPaymentEndpoints();
// Reports (#91): production is open; the money routes carry their own
// AdminOnly inside the group.
app.MapGroup("/api/v1/reports")
.WithTags("Reports")
.RequireAuthorization()
.MapReportEndpoints();
// Audit trail (#93): read-only, admin-only.
app.MapGroup("/api/v1/audit")
.WithTags("Audit")
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapAuditEndpoints();
// User management is the OWNER's alone (#103) — Managers run the farm, the
// Owner decides who works on it.
app.MapGroup("/api/v1/users")
.WithTags("Users")
.RequireAuthorization(AuthPolicies.OwnerOnly)
.MapUserEndpoints();
// Manual backup (#95): CSV export, read-only, admin-only.
app.MapGroup("/api/v1/export")
.WithTags("Export")
.RequireAuthorization(AuthPolicies.AdminOnly)
.MapExportEndpoints();
// #795 — OpenIddict answers the token endpoint itself; only authorization reaches an endpoint.
if (identity.OAuthServer)
app.MapGroup("/api/v1/oauth")
.WithTags("OAuth")
.MapOAuthEndpoints();
// #217 — browser error reports. Anonymous (the login screen can crash too);
// the endpoint carries its own per-IP rate limit and size cap inside.
app.MapGroup("/api/v1/client-errors")
.WithTags("ClientErrors")
.MapClientErrorEndpoints();
// Health: live = the process runs (no checks); ready = dependencies too.
app.MapHealthChecks("/health/live", new Microsoft.AspNetCore.Diagnostics.HealthChecks.HealthCheckOptions
{
Predicate = _ => false
});
app.MapHealthChecks("/health/ready");
app.Map("/error", (HttpContext context) =>
{
var exception = context.Features.Get<IExceptionHandlerFeature>()?.Error;
return exception switch
{
// #398 — a JSON-binding failure (a fractional quantity into an `int`
// field, an unparseable date/guid, malformed JSON syntax, …) throws
// this deep inside minimal-API's generated body-reader, BEFORE any
// FluentValidation validator or handler runs. bad.Message names the
// exact parameter/type it failed to bind (e.g. "Failed to read
// parameter \"AddOrderItemRequest request\" from the request body as
// JSON.") and its InnerException carries the raw System.Text.Json /
// FormatException detail — both are framework internals, never meant
// for an end user, and echoing them (as this branch used to) leaked
// parameter/type names straight into the SPA's error banner. Route
// THIS specific case — always StatusCode 400 by construction, the
// same default every JSON-binding BadHttpRequestException carries —
// through the SAME ValidationProblem shape every other 400 in this
// app uses (ValidationResponse.BindingFailureProblem), instead of
// echoing the exception text.
//
// #398 review (Codex) — this branch is now a BACKSTOP, not the
// primary path: UseCluckworkBindingFailureResponse
// (Hosting/BindingFailureResponse.cs) intercepts the overwhelming
// majority of these before they ever reach here, specifically so
// Serilog's request-logging middleware (which sits between it and
// here) never sees the exception and mis-logs the completion as a
// 500 at Error for what the client correctly receives as a 400. This
// mapping stays — unreachable only in the ordinary case — for any
// binding failure that somehow bypasses that middleware (e.g. the
// response had already started).
//
// A NON-400 BadHttpRequestException (413 from the #309 request-body
// cap, 415 from an unrecognised Content-Type, …) falls through to the
// unchanged branch below: RequestBodyLimit.cs's WriteBodyTooLargeAsync
// depends on THIS handler staying byte-identical to its own 413 shape
// for the (rare, non-JSON-bound-endpoint) case that reaches /error
// instead of being short-circuited there — don't fold that case into
// the 400-only ValidationProblem mapping above.
// Same body-vs-query decision as the primary path, via the one shared
// helper: a bodyless GET whose typed query parameter failed to bind must
// not be told its body was malformed, and a caller who omitted a
// REQUIRED body must not be told the problem was a query parameter
// (#398 review rounds 4 and 5).
BadHttpRequestException { StatusCode: StatusCodes.Status400BadRequest } =>
ValidationResponse.BindingFailureProblem(
BindingFailureResponse.ConcernsRequestBody(context)),
BadHttpRequestException bad => Results.Problem(
detail: bad.Message,
statusCode: bad.StatusCode,
title: "Invalid request body"),
DbUpdateConcurrencyException => Results.Conflict(new ProblemDetails
{
Title = "Concurrency conflict",
Detail = "The resource was modified by another request. Reload the current state and retry.",
Status = StatusCodes.Status409Conflict
}),
// Unique-constraint and FK violations (e.g. concurrent insert of the same natural key).
DbUpdateException => Results.Problem(
detail: "The request conflicts with existing data.",
statusCode: StatusCodes.Status409Conflict,
title: "Data conflict"),
// Locking paths (confirm/void) share one canonical lock order so this
// shouldn't fire; if it ever does, it's a retryable conflict, not a 500.
// 40001 is a serialization failure — reachable from the snapshot-
// isolated reads (the export's RepeatableRead transaction). Postgres
// could not order this transaction against a concurrent one, which is
// a retryable conflict, not a fault.
Npgsql.PostgresException { SqlState: "40P01" or "40001" } => Results.Problem(
detail: "The request conflicted with a concurrent operation. Retry.",
statusCode: StatusCodes.Status409Conflict,
title: "Concurrency conflict"),
_ => Results.Problem()
};
});
// An unknown /api/* path must 404 as an API error — NOT fall through to the SPA
// fallback below (which would return index.html, i.e. a 200 text/html page, and
// #141 would then stamp a Cache-Control header on an /api response). Real
// endpoints have literal segments and outrank this catch-all; the SPA fallback
// is a bare non-file catch-all, so this /api-prefixed one wins for /api paths.
app.Map("/api/{**rest}", () => Results.Problem(
statusCode: StatusCodes.Status404NotFound, title: "Not found"))
.ExcludeFromDescription();
// #266 — same guard for /health/*: an unknown health path must 404, NOT fall
// through to the SPA fallback (which returns index.html as a 200 text/html page).
// Otherwise a removed/renamed /health/ready would be silently shadowed by a
// 200 — and the container HEALTHCHECK probe (which accepts any 2xx) would report
// a dead app HEALTHY. The literal /health/live + /health/ready above outrank this
// catch-all; only unmatched /health/* paths hit it.
app.Map("/health/{**rest}", () => Results.Problem(
statusCode: StatusCodes.Status404NotFound, title: "Not found"))
.ExcludeFromDescription();
// SPA client-side routing: any non-API, non-file request falls back to
// index.html. Lowest route priority, so the /api/v1 endpoints and /health above
// always match first. No-op in dev (no wwwroot) — dev uses the Vite server.
// #141 — the fallback ALWAYS serves index.html, so it unconditionally emits
// no-cache: a new deploy propagates immediately even through a fronting CDN,
// and a missing /assets/x can never be pinned immutable.
if (spaShell is not null)
{
// GET/HEAD only (#874 review, local Codex pass): the StaticFileMiddleware +
// MapFallbackToFile pair this replaced served only GET/HEAD (the former
// skips other methods outright, the latter carries its own GET/HEAD
// HttpMethodMetadata) — measured directly against that pre-#873 pipeline,
// which answers 405 with "Allow: GET, HEAD" for e.g. POST / or DELETE
// /index.html. Without this metadata spaShell.WriteAsync has no method
// restriction of its own and those same requests get a 200 HTML shell.
// A named local function rather than the method group: the #872 adapter-reach
// walk resolves a handler through its declaring type, and a method group on
// a local variable has none it can see.
Task WriteSpaShell(HttpContext context) => spaShell.WriteAsync(context);
app.MapFallback(WriteSpaShell)
.WithMetadata(new HttpMethodMetadata([HttpMethods.Get, HttpMethods.Head]));
}
else
app.MapFallbackToFile("index.html", new StaticFileOptions
{
OnPrepareResponse = StaticAssetCaching.AlwaysRevalidateHeader
});
app.Run();
return 0;
// Exposes Program for WebApplicationFactory in integration tests
public partial class Program { }