HTTP API

Your controllers are plain ASP.NET Core (Server Setup). Altruist adds the parts every public API needs and nobody wants to write twice: one error shape, hardening (forwarded headers, security headers, body limits) and rate limits that hold across a fleet. Each part is off until you configure it, so existing apps answer exactly as before.

altruist:
  server:
    http:
      errors:
        format: simple
      hardening:
        server-header: false
        forwarded-headers: { enabled: true }
        security-headers: { enabled: true, hsts-max-age-seconds: 31536000 }
        api: { path: /api, no-store: true, max-body-bytes: 16384 }
      rate-limit:
        policies:
          api:  { permit: 600, window-seconds: 60, path: /api }
          auth: { permit: 10,  window-seconds: 60 }

Errors

With errors:format: simple every failure of the API has the same body:

{ "error": "username_taken", "message": "That username is taken.", "field": "username" }

error is a stable code for your client, message is safe to show, field (camelCase, omitted when there is none) points at the input. Throw an HttpApiException wherever a request cannot be served:

using Altruist.Http;

if (await accounts.ExistsAsync(name))
    throw new HttpApiException(409, "username_taken", "That username is taken.", field: "username");

if (!wallet.CanAfford(price))
    throw new HttpApiException(409, "not_enough_gold", "You need more gold.");

throw new HttpApiException(429, "cooldown", "Try again soon.", retryAfterSeconds: 30);   // + Retry-After: 30

Derive your own exception from it to keep factories in one place (public sealed class GameApiException : HttpApiException).

What Altruist answers for you:

SituationStatuserror
HttpApiExceptionits status (+ Retry-After)its code
Invalid model ([ApiController] validation, bad query values)400invalid_request, field = the first invalid field; none for malformed JSON
Body larger than the server's limit413payload_too_large
JWT bearer: no / invalid / expired token401unauthorized
JWT bearer: authenticated but not allowed403forbidden
Rate limit (below)429 (+ Retry-After)rate_limited
The client aborted the request499, no body—
Any other exception500internal (logged; the exception never reaches the client)

Change the texts per code with errors:messages (e.g. rate-limited: "Slow down a little."). Register your own IAuthChallengeWriter to answer 401 / 403 differently. HttpErrorWriter (a singleton) writes the same bodies from your own middleware.

With format: none (the default) ASP.NET's own answers stay: problem details for invalid models, unhandled exceptions become 500s (the developer page in Development).

Hardening

altruist:server:http:hardening runs first in the pipeline — before the fleet relay, routing, authentication and the shields — in this order: forwarded headers, response headers, path rate limits, API body limit.

KeyDefaultEffect
server-headertruefalse drops Kestrel's Server header
max-body-bytesKestrel's 30 MBrequest body limit for every request
forwarded-headers:enabledfalsetrust X-Forwarded-For / -Proto from known proxies: ClientIp(), Request.IsHttps and secure cookies see the real client
forwarded-headers:forward-limit1how many proxy hops to unwrap (e.g. 2 for tunnel → dev server → app)
forwarded-headers:known-proxiesloopbackproxy addresses to trust besides loopback
security-headers:enabledfalseX-Content-Type-Options: nosniff, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Cross-Origin-Resource-Policy: same-origin on every response
security-headers:hsts-max-age-seconds0Strict-Transport-Security: max-age=… on HTTPS requests
api:path—the API's path prefix for the keys below
api:content-security-policy—CSP for API responses, e.g. default-src 'none'; frame-ancestors 'none'; base-uri 'none'
api:no-storefalseCache-Control: no-store on API responses (an action may still set its own)
api:max-body-bytes—larger bodies get 413 payload_too_large, declared (Content-Length) or streamed

Note:

Only enable forwarded headers when the app is reachable only through your proxy (bind to localhost or a private network). Otherwise any client can claim any address and walk around per-address limits.

Rate Limits

Name a policy in config and put it on controllers or actions with [RateLimit("name")] — the same attribute that assigns portal buckets:

altruist:
  server:
    http:
      rate-limit:
        store: backplane          # memory (default) | backplane
        policies:
          api:     { permit: 600, window-seconds: 60, path: /api }           # every request under /api
          auth:    { permit: 10,  window-seconds: 60 }                        # per client address
          crates:  { permit: 60,  window-seconds: 60, partition: principal }  # per signed-in user
          mail:    { permit: 3,   window-seconds: 3600, partition: principal }
[ApiController, Route("api/auth"), AllowAnonymous]
public sealed class AuthController : ControllerBase
{
    [HttpPost("register"), RateLimit("auth")]
    public Task<IActionResult> Register(RegisterRequest body) => ...;

    [HttpPost("login"), RateLimit("auth")]      // shares the 10 per minute with register
    public Task<IActionResult> Login(LoginRequest body) => ...;
}
  • Partition: ip (default; HttpContext.ClientIp()) or principal (User.PrincipalId(), the address for anonymous requests). Policies on a controller and its action both apply.
  • When: after authorization and model validation (a 401 or an invalid body does not use up the budget), before the action runs.
  • Rejected: 429 rate_limited with Retry-After (seconds); with errors:format: none a bare 429.
  • path: the policy also applies to every request under that path, in the hardening middleware (before the API body limit).
  • Unknown names do not limit (a warning is logged once), like unconfigured portal buckets.

From your code

Some limits must count after your own checks — a "resend verification email" endpoint should say already verified rather than too many requests. Inject IRequestRateLimiter and hit the policy where it belongs:

public async Task ResendAsync(string accountId)
{
    var account = await RequireAccountAsync(accountId);
    if (account.EmailVerified)
        throw new HttpApiException(409, "already_verified", "Your email is already verified.");

    var limit = await limits.HitAsync("mail", accountId);     // or HitAsync("mail", HttpContext)
    if (!limit.Allowed)
        throw new HttpApiException(429, "rate_limited", "Check your inbox first.", retryAfterSeconds: limit.RetryAfterSeconds);
    ...
}

One limit for the whole fleet

storeWindowsSeveral servers
memory (default)exact sliding windowseach server counts on its own: N servers allow N × the limit
backplanefixed windows aligned to the clock (Retry-After = until the window ends)counted once in the fleet backplane (Redis)

With backplane, the counters live in Redis under hashed keys. While the backplane is process-local (no fleet:redis) or unreachable, each server falls back to its own memory windows and retries Redis after 10 seconds, so a Redis outage never blocks a request.