v0.14.0

 ## Added
    - Checks can be reordered by dragging their cards on the admin checks page. Dragging is off
      while a search or type filter is active, because the server takes the whole list. The admin
      checks page, the blue team checks page and the scoreboard all list checks in this order, and
      a new check goes last
    - `POST /api/checks/reorder` takes `check_ids`, every check id in the new order, and needs
      BLACK or higher. A list with a duplicate, an unknown id or a missing check is a 400
    - A Delete button on the check edit page, behind a confirmation
    - `/api/health` says whether this process's scheduler holds the lock (`leader`) or is waiting
      (`standby`)
    - Login is rate limited per client IP to 1 request a second with a burst of 10. Over the
      limit it returns a 429, and the login page tells the user to wait. The limiter is in memory
      per replica, so N replicas allow N times that rate
    - Cross-origin protection on every API route, using Go's `http.CrossOriginProtection` with the
      allowed URLs as trusted origins. It refuses cross-site form posts, which CORS and
      SameSite=Lax both let through. Clients that send no `Origin` or `Sec-Fetch-Site`, such as
      curl and Bruno, are unaffected
    - Security headers. The backend sends `X-Content-Type-Options: nosniff` and a CSP whose
      `frame-ancestors` lists only the trusted origins, so the frontend can still frame inject
      PDFs. The frontend sends `nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: same-origin`
      and a CSP that blocks framing, plugins and `<base>` changes, and drops `X-Powered-By`. The
      prod swarm override adds a one-year HSTS header on the HTTPS routers

 ## Changed
    - The JWT cookie is HttpOnly and SameSite=Lax, so script on the page can't read the token. A
      second cookie, `hermes_session`, holds the token's claims as unsigned base64url JSON for the
      frontend to read. The backend never reads it, and every access check still runs on the JWT.
      Anyone signed in before the upgrade has no `hermes_session` cookie and has to sign in again
    - The backend accepts only HS256 tokens that carry an expiry. It moved from the unmaintained
      `dgrijalva/jwt-go` to `golang-jwt/jwt/v5`
    - Every backend process runs both the scheduler and the API. `HERMES_ROLE` is gone, and
      `just setup-environment` no longer writes it. Replicas that lose the scheduler lock wait as
      standbys. The scheduler's separate health server is gone, since `/api/health` covers it.
      One database pool, opened at startup, serves both
    - Pausing, resuming and resetting the engine take effect right away. Every write of the engine
      state sends `NOTIFY engine_state`, which wakes a paused scheduler. A reset also sends
      `NOTIFY engine_reset`, which cancels the round in flight and makes every API reload its
      cached scoreboard. Each API checks the engine state every second, so clients see a pause or
      resume made through any replica
    - The scheduler's round-info message carries only the round id. Each API reads that round
      back from the database, so a message that arrives after a reset can't bring a deleted round
      back
    - `/api/round/info` sends each check's `check_id`, `user_id`, `status` and `success` and
      nothing else. Check names, output, error text and timings are gone from it, and the
      scoreboard cell tooltips no longer show output or errors
    - `checks.toml` has a `sort_order` on every check. Import refuses a check with no
      `sort_order`, a negative one, or one an earlier check in the file already uses. A merge
      import puts the file's checks after the existing ones and keeps their relative order.
      Exports from v0.13.1 and earlier have no `sort_order`, so add one to each check before
      importing them
    - A destructive users import is refused with a 400 unless the importing account is in
      `users.toml`. Once it commits, the importer's session ends and they sign in again with the
      password from the export
    - Importing a `checks.toml` with no checks, a `users.toml` with no users or a `settings.toml`
      with nothing in it is no longer an error. A destructive checks import still needs a
      `checks` key, so `checks = []` clears every check
    - Bulk import caps the zip at 512 MB, both as uploaded and as decompressed. Each PDF inside
      is held to the PDF size limit and every other file to 10 MB, counted on the decompressed
      bytes rather than the sizes the zip claims. The zip is read from the upload instead of
      being copied into memory first
    - `docker-compose.yml` publishes Traefik's ports 80 and 443 in host mode. Ingress mode NATs
      every client to one gateway address, which would put every user in the same login
      rate-limit bucket. The backend takes the client IP from `X-Forwarded-For`, which Traefik
      overwrites
    - echo package bumped to 5.4.0
    - Existing databases need the new `Checks.sort_order` column added by hand. A fresh database
      gets it from setup:

          ALTER TABLE Checks ADD COLUMN sort_order INTEGER;
          UPDATE Checks c SET sort_order = s.n - 1
              FROM (SELECT id, row_number() OVER (ORDER BY id) AS n FROM Checks) s
              WHERE c.id = s.id;
          ALTER TABLE Checks ALTER COLUMN sort_order SET NOT NULL,
              ADD CONSTRAINT checks_sort_order_key UNIQUE (sort_order);

 ## Fixed
    - The public scoreboard showed check output and error text in its tooltips, and network
      errors in that text could include a minion's IP. Minions now send their own non-loopback
      addresses with each result, and the scheduler replaces them with `[minion]` in output and
      errors, chain steps included, before anything is stored
    - A scoreboard refreshed from the database could include points from a round that was still
      running. It now shows the latest round past its `ends_at`, with scores as they stood when
      that round ended
    - After an engine reset, the scheduler kept collecting results for a round that no longer
      existed, then backed off
    - Each new scoreboard connection re-sent the current round, engine state and remaining time
      to every connected client. Now only the new client gets them
    - A bulk import with destructive users and injects together failed, because the injects were
      credited to the importer's old account id, which the users import had just deleted
    - A bulk import zip had no size limit, so a small upload could decompress into gigabytes
    - A username containing a NUL byte made login return a 500 instead of the usual 401
    - Confirm dialogs could be confirmed twice while the first request was still running. Their
      buttons are now disabled until it finishes
    - The Service and Score labels on the default scoreboard drew over the mobile nav bar