# dragoman
A web server for scholarly metadata with full [DOI Resolution](https://www.doi.org/the-identifier/resources/factsheets/doi-resolution-documentation) and [DOI content negotiation](https://citation.doi.org/docs.html). Send a DOI as the URL path; receive a redirect to the landing page or metadata in any supported format depending on the `Accept` header.
## Installation
### Prerequisites
- Rust 1.75+ ([rustup.rs](https://rustup.rs))
### Install
```bash
cargo install dragoman
```
This builds a release binary and installs it to `~/.cargo/bin/dragoman`. Make sure `~/.cargo/bin` is on your `PATH` (the Rust installer adds this automatically).
## Local SQLite database
dragoman can serve metadata directly from a local SQLite database in the [commonmeta](https://commonmeta.org) format, bypassing the live Crossref/DataCite APIs. This dramatically reduces latency and API load for high-traffic deployments.
### Database format
The database is a SQLite3 file with a single `works` table whose columns map one-to-one to the commonmeta v1.0 schema. The `id` column is the canonical DOI URL (e.g. `https://doi.org/10.5281/zenodo.1234`). Complex fields (contributors, references, …) are stored as JSON text.
You can build a database from any commonmeta-supported source using the [commonmeta](https://github.com/front-matter/commonmeta-rs) CLI.
## Running the server
### Start
```bash
# Default port 3456
dragoman start
# Custom port
dragoman start --port 8080
# With a local database
dragoman start --db /data/commonmeta-2026-06-15.sqlite3
# Write a PID file so the process can be stopped later
dragoman start --pid-file /tmp/dragoman.pid
# All options together
dragoman start --port 8080 --db /data/commonmeta-2026-06-15.sqlite3 --pid-file /tmp/dragoman.pid
```
### TLS
The `--domain` flag controls whether and how dragoman serves HTTPS.
| *(not set)* | Plain HTTP on `127.0.0.1:<port>`. Use this behind a reverse proxy. |
| `localhost` | HTTPS on `127.0.0.1:<port>` using a locally-trusted certificate from [mkcert](https://github.com/FiloSottile/mkcert). |
| any public hostname | HTTPS on `0.0.0.0:<tls-port>` with automatic certificate provisioning via [Let's Encrypt](https://letsencrypt.org). Requires `--email`. |
#### Local development (mkcert)
Install mkcert once and add its CA to your trust store:
```bash
brew install mkcert # macOS; see https://github.com/FiloSottile/mkcert for Linux/Windows
mkcert -install
```
Then start dragoman with `--domain localhost`:
```bash
dragoman start --domain localhost
```
dragoman runs `mkcert` automatically on the first start; subsequent starts reuse the cached certificate. Certificates are stored in `--acme-cache` (default `/var/lib/dragoman/acme`).
#### Production (Let's Encrypt)
dragoman provisions and renews certificates automatically using the [ACME TLS-ALPN-01 challenge](https://datatracker.ietf.org/doc/html/rfc8737). No separate HTTP challenge server or reverse proxy is needed.
```bash
dragoman start \
--port 80 \
--domain commonmeta.org \
--email admin@example.org
```
- Binds **HTTPS on `--tls-port`** (default 443) with automatic certificate provisioning.
- Binds **HTTP on `--port`** with a permanent redirect to `https://commonmeta.org/…`.
- Stores certificates in `--acme-cache` (default `/var/lib/dragoman/acme`) so they survive restarts.
Use `--acme-staging` while testing to avoid Let's Encrypt rate limits:
```bash
dragoman start \
--port 80 \
--domain commonmeta.org \
--email admin@example.org \
--acme-staging
```
Options can also be supplied as environment variables (flags take precedence):
```bash
PORT=8080 COMMONMETA_DB=/data/commonmeta-2026-06-15.sqlite3 RUST_LOG=dragoman=debug dragoman start
```
During development you can use `cargo run` in place of the installed binary:
```bash
# Run from the project root — the sqlite3 file in the root is loaded by filename
cargo run -- start --db commonmeta-2026-06-15.sqlite3
# Or with a full path
cargo run -- start --db /data/commonmeta-2026-06-15.sqlite3
```
### Error: port already in use
If the chosen port is already in use, the server logs an error and exits:
```text
ERROR dragoman: failed to bind port=3456 error=Address already in use (os error 48)
```
Choose a different port with `--port` or stop the process that holds the port.
### Database file not found
If the resolved database path does not exist, dragoman logs a warning and starts without a local database, falling back to the live API for all requests:
```text
WARN dragoman: sqlite file not found, running without local database path=…
```
To use a local database, place the SQLite file at the platform default path or set `COMMONMETA_DB` to its location.
### Stop
```bash
# Stop using the default PID file location (/tmp/dragoman.pid)
dragoman stop
# Stop using a custom PID file
dragoman stop --pid-file /var/run/dragoman.pid
```
`dragoman stop` sends `SIGTERM` to the running process. The server handles the signal gracefully: it finishes in-flight requests and removes the PID file before exiting. Pressing `Ctrl-C` has the same effect.
## CLI reference
```text
dragoman <COMMAND>
Commands:
start Start the server (runs in the foreground)
stop Stop a running server by sending SIGTERM to its PID file
help Print help
dragoman start [OPTIONS]
-p, --port <PORT> TCP port to listen on [env: PORT] [default: 3456]
-d, --db <PATH> Local commonmeta SQLite3 database [env: COMMONMETA_DB]
--pid-file <PATH> Write PID to this file on startup [env: DRAGOMAN_PID_FILE]
--domain <DOMAIN> Domain for HTTPS. Use `localhost` for local dev (mkcert) or a public hostname for Let's Encrypt [env: DRAGOMAN_DOMAIN]
--email <EMAIL> ACME contact email for Let's Encrypt (required for public domains) [env: DRAGOMAN_EMAIL]
--acme-cache <PATH> Directory for ACME certificate storage [env: DRAGOMAN_ACME_CACHE] [default: /var/lib/dragoman/acme]
--acme-staging Use Let's Encrypt staging (testing only) [env: DRAGOMAN_ACME_STAGING]
--tls-port <PORT> HTTPS port when --domain is set [env: DRAGOMAN_TLS_PORT] [default: 443]
dragoman stop [OPTIONS]
--pid-file <PATH> PID file to read [env: DRAGOMAN_PID_FILE] [default: /tmp/dragoman.pid]
```
## Environment variables
All flags can also be supplied as environment variables; flags take precedence. See [`env.example`](env.example) for an annotated template.
| `PORT` | `3456` | TCP port to listen on. |
| `COMMONMETA_DB` | *(platform default)* | Path to a local commonmeta SQLite3 database. Served before falling back to the live API. Platform defaults: macOS → `~/Library/Application Support/commonmeta/commonmeta.sqlite3`; Linux → `/var/lib/commonmeta/commonmeta.sqlite3`. |
| `DRAGOMAN_CACHE_DB` | *(platform default)* | Path to the dragoman cache database for API fallback results. Platform defaults: macOS → `~/Library/Application Support/commonmeta/cache.sqlite3`; Linux → `/var/lib/commonmeta/cache.sqlite3`. |
| `DRAGOMAN_PID_FILE` | *(none)* | Path for the PID file written by `start` and read by `stop`. |
| `DRAGOMAN_DOMAIN` | *(none)* | Domain for HTTPS. `localhost` → mkcert; public hostname → Let's Encrypt. Unset → plain HTTP on `127.0.0.1` (reverse proxy mode). |
| `DRAGOMAN_EMAIL` | *(none)* | ACME contact email. Required when `DRAGOMAN_DOMAIN` is a public hostname. |
| `DRAGOMAN_ACME_CACHE` | *(platform default)* | Directory for ACME/mkcert certificates. Platform defaults: macOS → `~/Library/Application Support/dragoman/acme`; Linux → `/var/lib/dragoman/acme`. |
| `DRAGOMAN_ACME_STAGING` | *(false)* | Set to any non-empty value to use the Let's Encrypt staging environment. |
| `DRAGOMAN_TLS_PORT` | `443` / `3456` | HTTPS port when `DRAGOMAN_DOMAIN` is set. Defaults to `3456` for `localhost`, `443` for public domains. |
| `DRAGOMAN_VIKTORIALOGS_URL` | *(none)* | VictoriaLogs OTLP/HTTP endpoint. When unset, logs go to stdout only. |
| `DRAGOMAN_METRICS_ALLOW` | *(none)* | Comma-separated IPs allowed to scrape `/metrics`. When unset, the route is not registered. |
| `RUST_LOG` | `dragoman=info` | Log filter (see [`tracing-subscriber`](https://docs.rs/tracing-subscriber)). Use `dragoman=debug` for per-request cache hits. |
## DOI Resolution
dragoman implements the [Handle System resolution protocol](https://www.doi.org/the-identifier/resources/factsheets/doi-resolution-documentation). Two entry points are available for each PID:
| `/{path}` | Render the entity page (HTML · Svelte UI) or content-negotiate (API) |
| `/api/handles/{path}` | Handle resolver: redirect to URL of record, content negotiation, or `?noredirect` |
### Entity page — `/{path}`
Visiting a PID in a browser renders the dragoman entity page (Svelte UI with rich metadata). Sending an API `Accept` header to the same URL triggers content negotiation instead (see [Content negotiation](#content-negotiation)).
```bash
# Open entity page in a browser
open http://localhost:3456/10.5281/zenodo.1089100
# Works for ROR organisation IDs and ORCID person IDs too
open http://localhost:3456/027k65916
open http://localhost:3456/0000-0001-5727-2427
```
Paths that are not recognised PIDs redirect to the search page:
```bash
# Non-PID path → 307 to /?q=<path>
curl -v http://localhost:3456/machine-learning
```
### Handle resolver — `/api/handles/{path}`
The `/api/handles/` endpoint is the programmatic resolution interface. Three outcomes are possible depending on the request:
#### Redirect to URL of record (default)
Without a recognised `Accept` header, `/api/handles/{path}` redirects to the PID's URL of record:
| DOI | URL from metadata (`data.url`); fallback `https://doi.org/{doi}` |
| ROR | Organisation website (from database); fallback `https://ror.org/{id}` |
| ORCID | `https://orcid.org/{id}` |
```bash
# Follow the redirect to the landing page
curl -L http://localhost:3456/api/handles/10.5281/zenodo.1089100
# Inspect the redirect target without following
curl -s -o /dev/null -w "%{redirect_url}" http://localhost:3456/api/handles/10.5281/zenodo.1089100
# https://zenodo.org/record/1089100
```
#### View entity page — `?noredirect`
The `?noredirect` parameter redirects the browser to `/{path}` to render the entity page:
```bash
curl -v "http://localhost:3456/api/handles/10.5281/zenodo.1089100?noredirect"
# 307 → http://localhost:3456/10.5281/zenodo.1089100
```
#### Content negotiation at `/api/handles/{path}`
Send a recognised `Accept` header to receive metadata in a specific format.
##### DOI
```bash
curl -H "Accept: application/x-bibtex" \
http://localhost:3456/api/handles/10.5281/zenodo.1089100
curl -H "Accept: application/vnd.commonmeta+json" \
http://localhost:3456/api/handles/10.5281/zenodo.1089100
```
See [Supported formats](#supported-formats) for the full list. The `?format=` override is also accepted.
##### ROR (organisations)
| `application/json` | `ror` | ROR native JSON |
| `application/vnd.commonmeta+json` | `commonmeta` | commonmeta JSON |
```bash
# ROR native JSON
curl -H "Accept: application/json" \
http://localhost:3456/api/handles/027k65916
# commonmeta JSON
curl -H "Accept: application/vnd.commonmeta+json" \
http://localhost:3456/api/handles/027k65916
# ?format= override
curl "http://localhost:3456/api/handles/027k65916?format=ror"
```
##### ORCID (people)
| `application/vnd.orcid+json` | `orcid` | commonmeta JSON *(native ORCID JSON planned)* |
| `application/json` | `orcid` | commonmeta JSON *(native ORCID JSON planned)* |
| `application/vnd.commonmeta+json` | `commonmeta` | commonmeta JSON |
```bash
# ORCID JSON (currently returns commonmeta JSON; native ORCID JSON planned)
curl -H "Accept: application/vnd.orcid+json" \
http://localhost:3456/api/handles/0000-0001-5727-2427
# commonmeta JSON
curl -H "Accept: application/vnd.commonmeta+json" \
http://localhost:3456/api/handles/0000-0001-5727-2427
# ?format= override
curl "http://localhost:3456/api/handles/0000-0001-5727-2427?format=orcid"
```
## Usage
### Content negotiation
Send an `Accept` header to `/{path}` to receive metadata for a DOI without a browser redirect.
#### BibTeX
```bash
curl -H "Accept: application/x-bibtex" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### RIS
```bash
curl -H "Accept: application/x-research-info-systems" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### CSL (Citeproc) JSON
```bash
curl -H "Accept: application/vnd.citationstyles.csl+json" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### Crossref
```bash
curl -H "Accept: application/vnd.crossref+json" \
http://localhost:3456/10.1016/j.jaci.2019.09.015
```
#### Crossref XML
```bash
curl -H "Accept: application/vnd.crossref.unixref+xml" \
http://localhost:3456/10.1016/j.jaci.2019.09.015
```
#### DataCite
```bash
curl -H "Accept: application/vnd.datacite.datacite+json" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### Schema.org JSON-LD
```bash
curl -H "Accept: application/vnd.schemaorg.ld+json" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### InvenioRDM
```bash
curl -H "Accept: application/vnd.inveniordm.v1+json" \
http://localhost:3456/10.5281/zenodo.1089100
```
#### Formatted citation
`text/x-bibliography` accepts optional `style=` and `locale=` parameters. Style names come from the [CSL style repository](https://github.com/citation-style-language/styles); locale codes from the [CSL locales repository](https://github.com/citation-style-language/locales).
```bash
# APA (default)
curl -H "Accept: text/x-bibliography; style=apa" \
http://localhost:3456/10.5281/zenodo.1089100
# Vancouver in French
curl -H "Accept: text/x-bibliography; style=vancouver; locale=fr-FR" \
http://localhost:3456/10.5281/zenodo.1089100
```
### Query parameter overrides
Use `?format=` instead of an `Accept` header:
```bash
curl "http://localhost:3456/10.5281/zenodo.1089100?format=bibtex"
curl "http://localhost:3456/10.5281/zenodo.1089100?format=citation&style=apa&locale=de-DE"
```
### Format-specific URL prefixes
Two URL prefixes return a fixed format without requiring an `Accept` header or `?format=` parameter:
| `/dois/{doi}` | DataCite JSON | `application/vnd.datacite.datacite+json` |
| `/works/{doi}` | Crossref JSON | `application/vnd.crossref+json` |
```bash
curl http://localhost:3456/dois/10.1371/journal.pcbi.1000204
curl http://localhost:3456/works/10.1371/journal.pcbi.1000204
```
These routes are equivalent to `/{doi}?format=datacite` and `/{doi}?format=crossref` respectively. The `?source=` override is still accepted if you need to force a specific registration agency.
## Supported formats
### DOI formats (works)
| `application/x-bibtex` | `bibtex` | |
| `text/x-bibliography` | `citation` | `style=` and `locale=` params |
| `application/vnd.commonmeta+json` | `commonmeta` | |
| `application/vnd.crossref+json` | `crossref` | |
| `application/vnd.crossref.unixref+xml` | `crossref_xml` | |
| `application/vnd.crossref.unixsd+xml` | `crossref_xml` | alias |
| `application/vnd.citationstyles.csl+json` | `csl` | |
| `application/vnd.datacite.datacite+json` | `datacite` | |
| `application/vnd.datacite.datacite+xml` | `datacite_xml` | |
| `application/vnd.inveniordm.v1+json` | `inveniordm` | |
| `application/x-research-info-systems` | `ris` | |
| `application/vnd.schemaorg.ld+json` | `schemaorg` | |
| `text/html` / *(absent)* at `/{path}` | — | Svelte entity page |
| `text/html` / *(absent)* at `/api/handles/{path}` | — | 307 redirect to URL of record |
### ROR formats (organisations) — `/api/handles/{id}` only
| `application/json` | `ror` | ROR native JSON |
| `application/vnd.commonmeta+json` | `commonmeta` | |
### ORCID formats (people) — `/api/handles/{id}` only
| `application/vnd.orcid+json` | `orcid` | commonmeta JSON *(native ORCID JSON planned)* |
| `application/json` | `orcid` | commonmeta JSON *(native ORCID JSON planned)* |
| `application/vnd.commonmeta+json` | `commonmeta` | |
## HTTP status codes
| 200 | Metadata returned |
| 307 | Redirect (entity page, URL of record, or search) |
| 404 | PID not found or path not recognised |
| 406 | Requested content type not supported |
| 502 | Upstream API error |
## Deployment (macOS)
### Installation via Homebrew
dragoman can be installed from the [front-matter Homebrew tap](https://github.com/front-matter/homebrew-tap):
```bash
brew tap front-matter/tap
brew install dragoman
```
This builds dragoman from source (requires Rust, installed automatically as a build dependency) and places the binary at `$(brew --prefix)/bin/dragoman`.
#### Place the database
The platform default path on macOS is `~/Library/Application Support/commonmeta/commonmeta.sqlite3`. Place the database there and no further configuration is needed:
```bash
mkdir -p "$HOME/Library/Application Support/commonmeta"
cp commonmeta.sqlite3 "$HOME/Library/Application Support/commonmeta/commonmeta.sqlite3"
```
To use a different path, set `COMMONMETA_DB` in the launchd plist or pass `--db` on the command line.
#### Run as a background service (launchd)
```bash
# Start at login and keep alive
brew services start dragoman
# Check status
brew services info dragoman
# View logs
tail -f "$(brew --prefix)/var/log/dragoman.log"
# Stop the service
brew services stop dragoman
```
`brew services start` installs a launchd plist in `~/Library/LaunchAgents/` and starts the service immediately. It restarts automatically on crash and at login.
To run as a system-level daemon (starts at boot, not tied to a user login), use `sudo brew services start dragoman`. This installs the plist in `/Library/LaunchDaemons/` instead.
#### Configuration
To change the port or other settings, edit the service environment variables and restart:
```bash
# Open the generated plist for editing
open "$(brew --prefix)/opt/dragoman/homebrew.mxcl.dragoman.plist"
brew services restart dragoman
```
### Manual installation (without Homebrew)
```bash
# Install Rust if not already installed
cargo install dragoman
sudo install -m 755 ~/.cargo/bin/dragoman /opt/homebrew/bin/dragoman
```
> **Intel Macs:** replace `/opt/homebrew` with `/usr/local` in all paths below.
#### Run as a launchd daemon
The bundled `com.front-matter.dragoman.plist` targets Apple Silicon paths.
```bash
sudo mkdir -p /opt/homebrew/var/dragoman /opt/homebrew/var/log
sudo cp commonmeta.sqlite3 /opt/homebrew/var/dragoman/commonmeta.sqlite3
sudo cp com.front-matter.dragoman.plist /Library/LaunchDaemons/
sudo launchctl load -w /Library/LaunchDaemons/com.front-matter.dragoman.plist
```
Check logs:
```bash
tail -f /opt/homebrew/var/log/dragoman.log
```
Stop and disable:
```bash
sudo launchctl unload -w /Library/LaunchDaemons/com.front-matter.dragoman.plist
```
### Updating
#### With Homebrew
```bash
brew upgrade dragoman
brew services restart dragoman
```
#### Manual
```bash
cargo install dragoman
sudo install -m 755 ~/.cargo/bin/dragoman /opt/homebrew/bin/dragoman
sudo launchctl kickstart -k system/com.front-matter.dragoman
```
---
## Deployment (Debian / systemd)
This section covers running dragoman as a persistent system service on a Debian 13 server.
### 1. Build the binary
On the server, install Rust and install the binary:
```bash
cargo install dragoman
sudo install -m 755 ~/.cargo/bin/dragoman /usr/local/bin/dragoman
```
Or cross-compile locally and copy the binary:
```bash
# macOS → Linux x86-64 (requires cross)
cargo install cross
cross build --release --target x86_64-unknown-linux-gnu
scp target/x86_64-unknown-linux-gnu/release/dragoman user@server:/tmp/dragoman
ssh user@server 'sudo install -m 755 /tmp/dragoman /usr/local/bin/dragoman'
```
### 2. Create system user and directories
```bash
sudo useradd --system --no-create-home --shell /usr/sbin/nologin dragoman
sudo mkdir -p /var/lib/dragoman /var/lib/commonmeta /etc/dragoman
sudo chown dragoman:dragoman /var/lib/dragoman /var/lib/commonmeta
```
### 3. Place the SQLite database
The platform default path on Linux is `/var/lib/commonmeta/commonmeta.sqlite3`. Place the database there and no further configuration is needed:
```bash
sudo cp commonmeta.sqlite3 /var/lib/commonmeta/commonmeta.sqlite3
sudo chown dragoman:dragoman /var/lib/commonmeta/commonmeta.sqlite3
```
To use a different path, set `COMMONMETA_DB` in the environment file instead.
### 4. Create the environment file
Copy the bundled example and edit it for your deployment:
```bash
sudo cp env.example /etc/dragoman/env
sudo chmod 640 /etc/dragoman/env
sudo chown root:dragoman /etc/dragoman/env
sudo nano /etc/dragoman/env # uncomment and fill in the variables you need
```
For a production server with Let's Encrypt, at minimum set:
```bash
DRAGOMAN_DOMAIN=commonmeta.org
DRAGOMAN_EMAIL=admin@example.org
```
See [Environment variables](#environment-variables) for the full reference.
### 5. Install and enable the systemd unit
`dragoman.service` is included in the source repository but not installed by `cargo install`. Download it directly:
```bash
sudo curl -fsSL https://codeberg.org/front-matter/dragoman/raw/branch/main/dragoman.service \
-o /etc/systemd/system/dragoman.service
sudo systemctl daemon-reload
sudo systemctl enable --now dragoman
```
Or, if you have the source checked out locally:
```bash
sudo cp dragoman.service /etc/systemd/system/dragoman.service
sudo systemctl daemon-reload
sudo systemctl enable --now dragoman
```
Check the service is running:
```bash
sudo systemctl status dragoman
sudo journalctl -u dragoman -f
```
### Updating the binary
```bash
cargo install dragoman
sudo install -m 755 ~/.cargo/bin/dragoman /usr/local/bin/dragoman
sudo systemctl restart dragoman
```
### Updating the database
The database file can be replaced while the service is running without restarting. dragoman opens a new read-only SQLite connection per request, so an atomic rename takes effect immediately for new requests while in-flight requests finish against the old file.
Copy the new file to a staging path on the same filesystem, then rename it over the live file:
```bash
sudo cp commonmeta-new.sqlite3 /var/lib/commonmeta/commonmeta_new.sqlite3
sudo chown dragoman:dragoman /var/lib/commonmeta/commonmeta_new.sqlite3
sudo mv /var/lib/commonmeta/commonmeta_new.sqlite3 /var/lib/commonmeta/commonmeta.sqlite3
```
If the source database uses WAL mode (has accompanying `-wal`/`-shm` files), produce a clean single-file snapshot first with `VACUUM INTO` before copying:
```bash
sqlite3 /path/to/source.sqlite3 "VACUUM INTO '/tmp/commonmeta_clean.sqlite3'"
```
### Reverse proxy
When running behind a reverse proxy, do **not** set `--domain`. Let the proxy handle TLS termination; dragoman listens on plain HTTP at `127.0.0.1:3456` (or whatever `--port` you set).
## Telemetry
### Logs — VictoriaLogs
Set `--viktorialogs-url` (or `DRAGOMAN_VIKTORIALOGS_URL`) to the VictoriaLogs [OTLP/HTTP endpoint](https://docs.victoriametrics.com/victorialogs/data-ingestion/opentelemetry/). All `tracing` events are shipped in batches; the exporter is flushed on graceful shutdown.
```bash
dragoman start \
--viktorialogs-url http://localhost:9428/insert/opentelemetry/v1/logs
```
Without the flag, logs are written to stdout only (the default).
### Metrics — VictoriaMetrics / Grafana
Set `--metrics-allow` (or `DRAGOMAN_METRICS_ALLOW`) to a comma-separated list of IP addresses permitted to scrape the `/api/metrics` endpoint. The route is only registered when this flag is set; all other callers receive `403 Forbidden`.
```bash
dragoman start --metrics-allow 127.0.0.1,10.0.0.5
# curl http://localhost:3456/api/metrics # works from 127.0.0.1
```
The `/api/metrics` endpoint is served on the same port as the main application. The client IP is resolved from the `X-Real-IP` header first, then the first address in `X-Forwarded-For`, making it compatible with nginx, Caddy, and Traefik.
Metrics recorded:
| `http_requests_total` | `method`, `route`, `status` | Request count by route and HTTP status code |
| `http_request_duration_seconds` | `method`, `route` | Request latency histogram |
Routes are normalised to templates (`/dois/:doi`, `/works/:doi`, `/orcid/:id`, `/{pid}`) to keep cardinality low.
## License
MIT