Server¶
ASP.NET Core 10, minimal APIs, two files that matter:
| File | Holds |
|---|---|
server/Program.cs |
DI, the middleware pipeline, and the game-data endpoints |
server/AdminAuth.cs |
Everything auth — covered in Auth and sessions |
Program.cs reads top to bottom as the whole request pipeline. Start there.
Project setup¶
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>server</RootNamespace>
Nullable reference types are on, so string? versus string in a request
record is a real signal about what may be missing. Three packages:
| Package | Version | For |
|---|---|---|
Npgsql.EntityFrameworkCore.PostgreSQL |
10.0.3 | The Postgres provider |
Microsoft.EntityFrameworkCore.Design |
10.0.12 | dotnet ef tooling (build-time only) |
Isopoh.Cryptography.Argon2 |
2.0.0 | Password hashing |
Startup, in order¶
1. The DbContext¶
builder.Services.AddDbContext<server.Models.Db>(o =>
o.UseNpgsql(builder.Configuration.GetConnectionString("Db")));
Registers Db as a scoped service, so any handler can take it as a parameter
and get one per request. See
Database for which connection
string wins.
2. Forwarded headers¶
builder.Services.Configure<ForwardedHeadersOptions>(o =>
{
o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto;
o.KnownIPNetworks.Clear();
o.KnownProxies.Clear();
});
Behind Caddy, every request would otherwise appear to come from Caddy. That
would break the per-IP login rate limit (one shared bucket for the world) and
the session cookie's Secure flag (Caddy speaks plain HTTP internally, so
request.IsHttps would be false).
Clearing the known-proxy lists means "trust whatever sent this". That is only
safe because nothing but Caddy can reach the app — which is why compose.yaml
binds the app's port to 127.0.0.1.
3. The rate limiter¶
o.AddPolicy("admin-login", context => RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions { PermitLimit = 5, Window = TimeSpan.FromMinutes(1) }));
Five login attempts per minute per IP, fixed window. Applied to one endpoint
only, with .RequireRateLimiting("admin-login").
The 429 has no body
Unlike the hand-written 400/401/403 responses, which return
{ "error": "…" }, the limiter's rejection is an empty 429. Clients must
not assume a JSON body — web/src/api.ts handles this explicitly.
4. Migrate and seed¶
using (var scope = app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<server.Models.Db>();
db.Database.Migrate();
await server.AdminAuth.SeedOwner(db, app.Configuration, app.Logger);
}
Runs before the app serves traffic. The explicit scope is needed because Db is
scoped and there is no request to hang one off yet.
5. Middleware¶
UseForwardedHeaders must stay first
Both the rate-limit partition and request.IsHttps read the values it
rewrites. Move it after UseRateLimiter() and the limiter silently buckets
every request under Caddy's IP.
The endpoint pattern¶
A handler is a lambda. It takes what it needs by injection, queries, returns:
app.MapGet("/api/players", async (server.Models.Db db) =>
await db.Players.ToListAsync()).RequireAdmin();
app.MapPost("/api/players", async (server.Models.Db db, server.Models.Player p) =>
{
db.Players.Add(p);
await db.SaveChangesAsync();
return Results.Ok(p);
}).RequireAdmin();
No controllers, no repository interfaces, no service layer. A Player parameter
is bound from the JSON body automatically.
TelemetryEvent is the one with server-side behaviour — the client's timestamp
is always discarded:
Current endpoints¶
| Path | Methods | Guard |
|---|---|---|
/Health |
GET | None — public through Caddy |
/api/players |
GET, POST | .RequireAdmin() |
/api/characters |
GET, POST | .RequireAdmin() |
/api/events |
GET, POST | .RequireAdmin() |
/api/admin/* |
various | See Auth |
These paths are going to change
/api/players is intended to become /api/player (singular), and the game
data endpoints are meant to be publicly reachable with their own auth
rather than admin-gated. Neither has happened. Code against what is there
today; see Practices.
Static files and the SPA fallback¶
The last three lines of Program.cs, and the order is critical:
app.UseDefaultFiles(); // rewrites / to /index.html
app.UseStaticFiles(); // serves anything in wwwroot
app.MapFallbackToFile("index.html"); // unknown paths -> the Vue app
These come after every API route, which is what makes the arrangement work: static-file middleware skips any request that already matched an endpoint.
Never map / to an endpoint
It would shadow the entire UI, because the route would match before
UseDefaultFiles() ever ran. There is a comment in Program.cs saying
exactly this. If you need a root-ish API route, put it under /api/.
MapFallbackToFile is what makes client-side routing work: a browser asking for
/admins directly gets index.html, and vue-router takes it from there.
Adding an endpoint¶
- Pick the guard.
.RequireAdmin()for any signed-in admin;RequireOwner(db, http)inside the handler when only the owner should pass. Nothing is unauthenticated by default. - Register it before the static-file calls. Anywhere above
UseDefaultFiles(). - Return a typed result.
Results.Ok(...),Results.NoContent(),Results.BadRequest(new { error = "…" }). Keep the{ error }shape —web/src/api.tsreads that field. - Does it need to be public? Then it also needs a
handleblock incaddy/Caddyfile, and admin-session auth is the wrong gate for it. - Update the API reference in the same commit.
A worked example, from AdminAuth.cs:
app.MapGet("/api/admin/admins", async (Db db, HttpContext http) =>
{
var (_, error) = await RequireOwner(db, http);
if (error is not null) return error;
// Materialised before projecting so RoleName stays reusable -- EF can't
// translate a local method into SQL. The table is tiny, so this is fine.
var admins = await db.AdminUsers.OrderBy(a => a.Id).ToListAsync();
return Results.Ok(admins.Select(a => new
{
id = a.Id,
username = a.Username,
role = RoleName(a.Role),
createdAt = a.CreatedAt,
disabledAt = a.DisabledAt,
}));
});
Two things to copy: the guard runs first and returns early, and the projection
decides the JSON shape explicitly rather than serialising the entity. The
latter is what keeps pw_hash from ever reaching a response.
Configuration¶
Standard ASP.NET precedence — later wins:
appsettings.jsonappsettings.Development.json(only whenASPNETCORE_ENVIRONMENT=Development)- Environment variables
Which is how ConnectionStrings__Db from a compose file overrides the dev JSON
inside a container. The double underscore is the nesting separator.
| Variable | Read by | Purpose |
|---|---|---|
ConnectionStrings__Db |
AddDbContext |
The Postgres connection string |
HAKUTAKU_ADMIN_PASSWORD |
SeedOwner |
First owner's password, first start only |
ASPNETCORE_ENVIRONMENT |
The framework | Development loads the dev JSON |
Dev ports come from server/Properties/launchSettings.json: 5008 for HTTP,
5009 for the HTTPS profile. Vite proxies to 5008.
Known rough edges¶
- A bad
playerIdreturns a bare500. The foreign-key violation is not caught and turned into a400. - No validation layer. Only auth does input checking, by hand.
- List endpoints return the whole table. No paging, no filtering, no
PUTorDELETEon game data. data.csstill opens with "This is just sample code". It is not; the comment is stale and the backend owner has it.- No tests. The Jenkins pipeline has no test stage because there is nothing to run.