diff --git a/docs-developers/architecture/cross-repo-contract.md b/docs-developers/architecture/cross-repo-contract.md
index edb08c0..12d75cf 100644
--- a/docs-developers/architecture/cross-repo-contract.md
+++ b/docs-developers/architecture/cross-repo-contract.md
@@ -156,10 +156,12 @@ and `app.json` together, or pairing breaks.
version.
**Server:** `handleServerInfo` advertises `admin_ui`, `web_player`, `upload`,
-`transcode`, `websocket`, plus the server version (`api.Version`, stamped from the
-release tag via ldflags). `transcode` reflects ffmpeg availability; `web_player`
-reflects whether `/web` is populated. (`upload` and `websocket` are reserved for
-**planned** phases - `POST /uploads` and WebSocket sync are not shipped.)
+`transcode`, `websocket`, `api_keys`, plus the server version (`api.Version`,
+stamped from the release tag via ldflags). `transcode` reflects ffmpeg
+availability; `web_player` reflects whether `/web` is populated; `api_keys`
+reflects that the server accepts user-minted API keys. (`upload` and `websocket`
+are reserved for **planned** phases - `POST /uploads` and WebSocket sync are not
+shipped.)
**Frontend:** the `ServerInfo` type; feature gating and the "connected server
version" display key off it.
diff --git a/docs-developers/contributing/cross-repo-changes.md b/docs-developers/contributing/cross-repo-changes.md
index 638d0ce..fc8bb4a 100644
--- a/docs-developers/contributing/cross-repo-changes.md
+++ b/docs-developers/contributing/cross-repo-changes.md
@@ -102,8 +102,8 @@ each side gated by its own CI.
Any shipped app build must be able to talk to any server version, so features
are negotiated, never assumed. `GET /api/v1/server` advertises capability flags -
-`admin_ui`, `web_player`, `upload`, `transcode`, `websocket` - plus the server
-version.
+`admin_ui`, `web_player`, `upload`, `transcode`, `websocket`, `api_keys` - plus
+the server version.
Adding a feature that isn't universally available is a two-repo pattern:
diff --git a/docs-developers/frontend/overview.md b/docs-developers/frontend/overview.md
index 1fc7a17..8093810 100644
--- a/docs-developers/frontend/overview.md
+++ b/docs-developers/frontend/overview.md
@@ -85,7 +85,7 @@ handful of standalone screens.
| `/library/[libraryId]/[...path]` | `(app)/library/[libraryId]/[...path].tsx` | Nested folder browse - same `BrowseScreen`, the catch-all segments become the folder `path` (helpers in `src/lib/paths.ts`). |
| `/book/[libraryId]/[...path]` | `(app)/book/[libraryId]/[...path].tsx` | Book detail: play/resume, download control, chapters, bookmarks, notes, listening history, other versions of the same book. |
| `/downloads` | `(app)/downloads.tsx` | Downloaded books + storage used ([Offline](offline.md)). |
-| `/settings` | `(app)/settings.tsx` | Playback tunables, language, theme, connections, self-service password/recovery, sign-out. |
+| `/settings` | `(app)/settings.tsx` | Playback tunables, language, theme, connections, self-service password/recovery, per-server API keys (capability-gated), sign-out. |
| `/player` | `src/app/player.tsx` | The full player, presented as a full-screen modal above the shell. Accepts `libraryId`/`path` (+ optional `position`/`track`) params and gates playback start on the chapters query settling. |
| `/finished` | `src/app/finished.tsx` | The end-credits screen shown when a book finishes (or from the player's menu). A root modal sibling of the player; renders `EndCredits` with an "up next" suggestion. See [Playback](playback.md#ending-a-book-end-credits-and-up-next). |
| `/connect` layout | `src/app/connect/_layout.tsx` | Onboarding stack. An **authenticated** user is bounced home unless they are adding another server (`?add=1`, a pairing `?token=`, or a sign-in mid-flow via `pendingServerUrl`) - the app supports multiple simultaneous server connections. |
diff --git a/docs-developers/server/api/index.md b/docs-developers/server/api/index.md
index 0fffee2..5a18802 100644
--- a/docs-developers/server/api/index.md
+++ b/docs-developers/server/api/index.md
@@ -76,6 +76,17 @@ since the user is present and can mint another. See
[Auth & security](../auth-and-security.md) for the trust model behind codes,
tokens, and hashes.
+A user can also mint a **personal API key** (`POST /auth/tokens`) - a
+non-expiring bearer credential for headless integrations such as dashboards and
+cron. It is presented in the same `Authorization: Bearer …` header and
+authenticates exactly like a session token, **acting as its owner**: an admin's
+key satisfies the admin-role check, a regular user's does not. Pairing tokens
+are never accepted as a bearer credential, and an API key is never valid for
+`/auth/exchange`. It also **cannot mint a fresh durable credential** - an API-key
+caller is refused (403) on the credential-minting routes (create key, recovery,
+pair, set password), so revoking a leaked key cuts off everything it could reach.
+See the [reference](reference.md#personal-api-keys).
+
### Media requests: `?token=` - media GETs only
`GET /libraries/{id}/cover` and `GET /libraries/{id}/stream` accept the session
@@ -104,7 +115,7 @@ Status mapping is consistent across handlers:
|---|---|
| `400` | malformed body / unknown JSON field, missing or invalid parameter (`path is required`, `invalid cursor`, non-integer `{id}`), path escaping the library root, domain validation (`mode must be "book" or "collection"`, admin needs a password, password too short) |
| `401` | missing/invalid/expired token, bad credentials, invalid auth code, wrong `current_password` |
-| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on password/recovery routes, bad setup token |
+| `403` | authenticated but not allowed: no share grants the library or path, `admin only`, demo accounts on the self-service routes (password/recovery/API keys), an API key on a credential-minting route (create key/recovery/pair/password), bad setup token |
| `404` | library/user/share/invite not found, `no book at that path`, feature not configured (demo mode off, well-known files unset) |
| `409` | conflicts: `name already taken` (library/share), last-enabled-admin guard, setup already completed |
| `429` | a rate limiter tripped (see below) |
@@ -185,7 +196,8 @@ a `limit` and no pagination.
"web_player": true,
"transcode": true,
"upload": false,
- "websocket": false
+ "websocket": false,
+ "api_keys": true
},
"auth": { "methods": ["auth_code", "password"] },
"demo": { "enabled": false }
@@ -194,9 +206,10 @@ a `limit` and no pagination.
Clients **must** feature-gate on these flags rather than probing endpoints:
`transcode` reflects whether ffmpeg is configured (without it, `?transcode=1`
-is 503), `web_player` whether `/web` is mounted, `upload`/`websocket` are
-roadmap phases that will flip on when they land. `demo.enabled` drives the
-"Try the demo" affordance.
+is 503), `web_player` whether `/web` is mounted, `api_keys` whether the server
+supports user-minted [API keys](reference.md#personal-api-keys),
+`upload`/`websocket` are roadmap phases that will flip on when they land.
+`demo.enabled` drives the "Try the demo" affordance.
## Rate limiting
@@ -209,7 +222,7 @@ client IP. Tripping any of them returns **429** with an error envelope.
| Login lockout | `POST /auth/login` | 10 *failed* attempts per 15 min per IP; a success resets the counter |
| Redeem lockout | `POST /auth/redeem` | 10 *failed* attempts per 15 min per IP; a success resets |
| Demo cap | `POST /demo/session` | at most 5 demo sessions per IP per 15 min, metered at admission (failures count too) |
-| Account mutations | `POST /auth/password`, `POST /auth/recovery` | at most 10 attempts per IP per 15 min, metered at admission |
+| Account mutations | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke) | at most 10 attempts per IP per 15 min, metered at admission |
**Client IP resolution:** `X-Forwarded-For` is honored only when the direct
peer is inside a `trusted_proxies` CIDR (config); otherwise the TCP peer address
diff --git a/docs-developers/server/api/reference.md b/docs-developers/server/api/reference.md
index 8b1ea87..cb7ee61 100644
--- a/docs-developers/server/api/reference.md
+++ b/docs-developers/server/api/reference.md
@@ -9,7 +9,10 @@ in the [API conventions](index.md) page and are not repeated per endpoint.
**Auth legend** - *Public*: no token. *Session*: bearer session token.
*Session (media)*: session token via header **or** `?token=` query parameter.
-*Admin*: session token + `admin` role.
+*Admin*: session token + `admin` role. A personal **API key** authenticates as
+its owner anywhere a *Session* token does - so an admin's key also satisfies
+*Admin* - with one carve-out: it is refused on the credential-minting routes (see
+[Personal API keys](#personal-api-keys)).
All `/api/v1` bodies and responses are JSON. Timestamps are RFC 3339. Remember
that empty list fields may serialize as `null`.
@@ -32,7 +35,8 @@ else and gate features on the flags.
"web_player": true,
"transcode": true,
"upload": false,
- "websocket": false
+ "websocket": false,
+ "api_keys": true
},
"auth": { "methods": ["auth_code", "password"] },
"demo": { "enabled": false }
@@ -47,7 +51,8 @@ pairing exchange (`POST /auth/exchange`) and login/demo responses, so a client h
it the moment it pairs. `version` is stamped from the release tag (`"dev"` for local builds).
`transcode` is true only when ffmpeg is configured; `web_player` only when the
`/web` mount is populated; `upload` and `websocket` are reserved for future
-phases and currently always false.
+phases and currently always false. `api_keys` is true on servers that support
+user-minted [personal API keys](#personal-api-keys).
### `GET /healthz` · `GET /api/v1/healthz`
@@ -219,9 +224,10 @@ omitted when there is none. `role` is `"admin"` or `"user"`.
## Self-service account
-Both mutating routes here share a rate limit (10 attempts per IP per 15
-minutes) and are **refused for demo accounts** (403), so a throwaway session
-can't mint a durable login.
+The routes in this section, together with the [API keys](#personal-api-keys)
+below, share one rate limit (10 attempts per IP per 15 minutes) and are
+**refused for demo accounts** (403), so a throwaway session can't mint a durable
+credential.
### `POST /api/v1/auth/password`
@@ -260,6 +266,90 @@ Response `201`:
*Session.* Removes the caller's recovery code (no-op if none). Response:
`204 No Content`.
+## Personal API keys
+
+User-minted, **non-expiring** bearer credentials ("API keys") for headless
+integrations - dashboards, cron jobs, monitoring. A key authenticates on any
+*Session* or *Session (media)* route exactly like a session token and **acts as
+its owner**: an admin's key also passes *Admin* routes, a regular user's does
+not. A pairing token is never accepted as a bearer credential, and an API key is
+never valid for `/auth/exchange`. Revocation is the only lifecycle - a key never
+expires. All three routes share the [self-service](#self-service-account) rate
+limit (10 attempts per IP per 15 minutes) and are **refused for demo accounts**
+(403).
+
+**Containment.** A key cannot mint a *fresh durable credential*. A request that
+authenticates with an API key is refused (**403**) on the four credential-minting
+routes - `POST /auth/tokens` (another key), `POST /auth/recovery`,
+`POST /auth/pair`, and `POST /auth/password` - so revoking a leaked key cuts off
+everything it could reach: it can't spawn a key, recovery code, pairing token, or
+password that would outlive its own revocation (mirrors GitHub's "a token cannot
+create tokens"). It may still **list and revoke** keys and clear a recovery code,
+since those only reduce access.
+
+### `POST /api/v1/auth/tokens`
+
+*Session.* Mints an API key and returns the secret **exactly once**.
+
+| Field | Type | Required | Notes |
+|---|---|---|---|
+| `label` | string | yes | display name; trimmed, 1-100 characters |
+
+Response `200`:
+
+```json
+{
+ "token": "Qm9WZbT5nR8sHc2fLdA7yUqPgVi4oXk1NwsE3vJx0eK",
+ "api_key": {
+ "id": 3,
+ "label": "Home dashboard",
+ "created_at": "2026-07-08T14:02:11Z",
+ "last_seen": null
+ }
+}
+```
+
+`token` is the bearer secret - only its SHA-256 hash is stored, so this is the
+one time it appears. `api_key.last_seen` is `null` until the key first
+authenticates a request.
+
+| Status | Meaning |
+|---|---|
+| `400` | `label` missing/empty, or longer than 100 characters |
+| `403` | demo account, or the caller is itself authenticating with an API key (containment - a key can't mint another) |
+| `429` | account-mutation limit tripped |
+
+### `GET /api/v1/auth/tokens`
+
+*Session.* The caller's live (non-revoked) API keys, newest first - **metadata
+only** (never the secret or its hash):
+
+```json
+{
+ "api_keys": [
+ {
+ "id": 3,
+ "label": "Home dashboard",
+ "created_at": "2026-07-08T14:02:11Z",
+ "last_seen": "2026-07-09T06:30:00Z"
+ }
+ ]
+}
+```
+
+`403` for demo accounts; `429` on the shared limit.
+
+### `DELETE /api/v1/auth/tokens/{id}`
+
+*Session.* Revokes one of the caller's **own** API keys by id; it stops working
+immediately. Response: `204 No Content`.
+
+| Status | Meaning |
+|---|---|
+| `403` | demo account |
+| `404` | no such API key for this caller (missing, already revoked, another user's, or not an API-key token id) |
+| `429` | account-mutation limit tripped |
+
## Demo
### `POST /api/v1/demo/session`
diff --git a/docs-developers/server/auth-and-security.md b/docs-developers/server/auth-and-security.md
index af97c0d..3cf12fd 100644
--- a/docs-developers/server/auth-and-security.md
+++ b/docs-developers/server/auth-and-security.md
@@ -36,10 +36,10 @@ plaintext credential ever appears, exactly once. Handlers return freshly-minted
codes/tokens in the response body and store only the hash.
:::
-## Tokens: session vs pairing
+## Tokens: session, pairing, and API keys
-`tokens.kind` distinguishes two lifetimes (`auth.KindSession`,
-`auth.KindPairing`):
+`tokens.kind` distinguishes three kinds (`auth.KindSession`, `auth.KindPairing`,
+`auth.KindAPI`):
- **Session tokens** are the durable bearer credential (`Authorization: Bearer
…`). Issued by `POST /auth/login` and `POST /auth/exchange` with **no
@@ -55,6 +55,24 @@ codes/tokens in the response body and store only the hash.
(multi-scan within it, since recovery codes are unlimited); `/auth/pair` and
demo tokens are unlinked - single-use (revoked on exchange) with the same
10-minute TTL.
+- **API keys** (`auth.KindAPI`) are user-minted, **non-expiring** bearer
+ credentials for headless integrations (dashboards, cron), created / listed /
+ revoked via `POST` / `GET` / `DELETE /auth/tokens`
+ (`IssueAPIToken` / `ListAPITokens` / `RevokeTokenByID`). A key authenticates
+ exactly like a session token and **acts as its owner** - an admin's key also
+ passes `requireAdmin` - but it is never valid for `/auth/exchange` (pairing),
+ and its lifecycle is mint / list / revoke rather than sign-in / sign-out. Only
+ the SHA-256 hash is stored (the secret is returned once at creation) and the
+ user's label rides in `tokens.device_name`. All three routes go through
+ `gateSelfService`, so they share the `accountLimiter` and are refused for demo
+ accounts, exactly like the password/recovery routes. **Containment:** an API-key
+ caller is additionally barred (403, via `denyAPIKey` in
+ `internal/api/handlers_auth.go`) from the four credential-minting routes
+ (`POST /auth/{tokens,recovery,pair,password}`), so a leaked key can never spawn a
+ fresh durable credential - another key, recovery code, pairing token, or
+ password - that would outlive its own revocation (mirrors GitHub's "a token
+ cannot create tokens"). It can still list/revoke keys and clear a recovery code,
+ which only reduce access.
`auth.ResolveToken` validates a presented secret for a specific kind: it hashes
the secret, looks up the row, and rejects revoked tokens, expired tokens, and
@@ -64,12 +82,16 @@ deliberately no `last_login` column.
The middleware wrappers in `internal/api/middleware.go`:
-- `requireAuth` - session token from the `Authorization` header only.
+- `requireAuth` - a **session token or an API key** from the `Authorization`
+ header only (`ResolveTokenKinds(secret, KindSession, KindAPI)`); a pairing
+ token is never accepted here, so a QR/pairing secret can't be used as a durable
+ credential.
- `requireMediaAuth` - additionally accepts `?token=` as a query parameter,
used **only** for the two media GETs (`/cover`, `/stream`) because browser
``/`