Files
shelfmark/docs/api-access.md
T
splitsec2 755f28a6a4 feat(api): add a read-only API key and a /api/stats endpoint (#1419)
This is the read-only API key from #1410, where you said to go ahead.

I run Shelfmark behind Homepage and wanted more on the dashboard tile
than up or down, without putting an admin API key in the dashboard's
config. This adds a second key, `SHELFMARK_API_KEY_READONLY`, that can
read one new endpoint.

`GET /api/stats` returns counts only: books added over the last 7 and 30
days by format, the queue, requests by outcome, and download failures
over the last 7 days. No titles and no user names. The read-only key,
the admin key or an admin session can read it.

The read-only key gets a 403 on every other path and on any write,
including a POST to `/api/stats` itself, and it never gets a session or
a cookie. If a request carries both keys, the admin one wins, so a
proxy's own `Authorization` header can't downgrade a correct
`X-Api-Key`. Unset means off, like the existing key. The endpoint exists
either way, but without a key only an admin session can read it.

It also adds a `db_path` property on `UserDB`, so the stats module can
open the database read-only.

Tests cover the key scope, the 403s for the read-only key, both keys at
once, who can reach the endpoint, and the counters. The full suite
passes, and I broke each rule on purpose to check a test catches it. A
version of this has been running on my own install behind Homepage.

If you'd rather have the stats in a different shape or under a different
path, I'm happy to change it.
2026-10-02 22:45:48 -04:00

3.8 KiB

API access with an API key

Shelfmark's web interface is driven entirely by a JSON API under /api/. Set the SHELFMARK_API_KEY environment variable and scripts, dashboards and assistants can call the same API without a browser session. Browser logins keep working exactly as before: it is cookie or key.

Set the key

environment:
  SHELFMARK_API_KEY: "a-long-random-secret"

Generate something long and random (for example openssl rand -base64 32). A request carrying the key acts as an admin: the first admin user in Shelfmark's user database. Create an admin before relying on the key in any install that has none yet (for example an OIDC-only install). Without an admin user, the key still authenticates as an admin identity with no user row, and routes that need one (requests, activity) answer 403. To rotate, change the variable and restart. Unset it and the feature is off. When the instance runs with no authentication configured (AUTH_METHOD=none), the key is simply unnecessary.

Send the key

Either header works, and both are checked, so the key can be sent in X-Api-Key behind a reverse proxy that sets its own Authorization header.

curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active
curl -s -H "X-Api-Key: $SHELFMARK_API_KEY" https://shelfmark.example.com/api/downloads/active

A request that carries the key is authenticated by the key alone. Session cookies are ignored and none are set. A bearer value that is not the configured key is ignored and the request continues with normal session authentication, so reverse proxies that forward their own tokens are unaffected; without a valid session such a request gets the usual 401 {"error": "Unauthorized"}. A database error while resolving the admin returns 500 {"error": "Authentication error"} — never anonymous access. /api/auth/check reflects the browser session only and ignores the key, so use /api/status to verify a key.

Examples

Search, then look up releases, then queue one (the same calls the web UI makes):

curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
  "https://shelfmark.example.com/api/metadata/search?query=dune%20frank%20herbert"
# -> {"books":[{"provider":"hardcover","provider_id":"427363", ...}]}

curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" \
  "https://shelfmark.example.com/api/releases?provider=hardcover&book_id=427363&content_type=ebook"
# -> {"releases":[{"source":"direct_download","source_id":"...", ...}], ...}

curl -s -X POST -H "Authorization: Bearer $SHELFMARK_API_KEY" -H "Content-Type: application/json" \
  -d @release.json https://shelfmark.example.com/api/releases/download
# release.json = one object from "releases" (source and source_id are required)

curl -s -H "Authorization: Bearer $SHELFMARK_API_KEY" https://shelfmark.example.com/api/status

Security notes

  • The key is compared in constant time and is never logged.
  • Keyed requests never set cookies and ignore any cookie sent with them.
  • WebSocket (live activity) connections do not accept the key; poll /api/status instead.
  • The key is a root-equivalent credential: an admin can configure a custom post-download script that the server executes, so treat it like a root password and send it only over HTTPS.

Read-only key and dashboard stats

Set SHELFMARK_API_KEY_READONLY to a second secret for dashboards and monitors. It can read one endpoint, GET /api/stats, and nothing else. Writes and every other path return 403, and the key never gets a session. The admin key reaches /api/stats too.

curl -s -H "X-Api-Key: $SHELFMARK_API_KEY_READONLY" https://shelfmark.example.com/api/stats

The response has counts only: books added in the last 7 and 30 days by format, the queue, requests by outcome, and download failures in the last 7 days. No titles and no user names.