Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions docs-developers/architecture/cross-repo-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
4 changes: 2 additions & 2 deletions docs-developers/contributing/cross-repo-changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
2 changes: 1 addition & 1 deletion docs-developers/frontend/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
25 changes: 19 additions & 6 deletions docs-developers/server/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) |
Expand Down Expand Up @@ -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 }
Expand All @@ -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

Expand All @@ -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
Expand Down
102 changes: 96 additions & 6 deletions docs-developers/server/api/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand All @@ -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 }
Expand All @@ -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`

Expand Down Expand Up @@ -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`

Expand Down Expand Up @@ -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`
Expand Down
43 changes: 33 additions & 10 deletions docs-developers/server/auth-and-security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
`<img>`/`<audio>` elements cannot set headers. `bearerToken(r, allowQuery)`
confines the fallback deliberately: a session token in a query string can
leak into access logs and `Referer` headers, so no other route accepts it.
confines the fallback deliberately: a token in a query string can leak into
access logs and `Referer` headers, so no other route accepts it. An API key
works here too, since it authenticates wherever a session does.
- `requireAdmin` - `requireAuth` plus a role check; every `/admin/*` route uses
it. The baked-in admin HTML is unprivileged - the API enforces the role.

Expand Down Expand Up @@ -138,9 +160,10 @@ atomically replaces any existing recovery code for the user.
Public demo mode (`config.demo.*`) creates throwaway `is_demo` accounts.
Three fences keep a demo session from becoming a durable login:

- `POST /auth/password` and `POST /auth/recovery` are **refused for demo
accounts** (`User.IsDemo` checked in the handlers).
- Both endpoints, plus demo-session creation itself, are rate-limited (see
- `POST /auth/password`, `POST /auth/recovery`, and the `/auth/tokens`
(API-key) routes are **refused for demo accounts** (`User.IsDemo` checked in
the handlers / `gateSelfService`).
- These endpoints, plus demo-session creation itself, are rate-limited (see
[the limiter table](#rate-limiting)).
- A background reaper (`launcher.demoReaper`, every 15 minutes) deletes demo
accounts idle past `demo.idle_ttl` - by the `is_demo` flag, never by username
Expand Down Expand Up @@ -231,7 +254,7 @@ Two mechanisms in `internal/api/ratelimit.go`, five buckets wired in `api.New`:
| `loginLimiter` | Failure lockout per IP (`limiter`) | 10 failures / 15 min | `POST /auth/login` |
| `redeemLimiter` | Failure lockout per IP | 10 failures / 15 min | `POST /auth/redeem` |
| `demoLimiter` | **Attempt** cap per IP (`Acquire`) | 5 / 15 min | `POST /demo/session` |
| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery` |
| `accountLimiter` | **Attempt** cap per IP (`Acquire`) | 10 / 15 min | `POST /auth/password`, `POST /auth/recovery`, `/auth/tokens` (create/list/revoke) |

The failure-lockout `limiter` has two usage patterns: `Allowed`/`Fail`/`Reset`
counts only *failed* attempts (right for login/redeem, where success should not
Expand Down
3 changes: 2 additions & 1 deletion docs-developers/server/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@ last activity is derived from `MAX(tokens.last_seen)`.

**`tokens`** *(0001; `auth_code_id` added in 0014)* - opaque bearer tokens:
`user_id` (FK CASCADE), `token_hash` (UNIQUE - only the SHA-256 hash is
stored), `kind` (`'session'`/`'pairing'`), `device_name`, `created_at`,
stored), `kind` (`'session'`/`'pairing'`/`'api'`, the last being a user-minted personal
API key whose label rides in `device_name`), `device_name`, `created_at`,
`last_seen` (bumped on every authenticated request), `expires_at` (NULL = no
expiry), `revoked`, and `auth_code_id` (FK CASCADE to `auth_codes`, NULL for
sessions and unlinked pairing tokens) - a pairing token minted by redeeming a
Expand Down
2 changes: 1 addition & 1 deletion docs-developers/server/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ schema and the SQLite rationale.
### `internal/auth`

Accounts and credentials: argon2id password hashing (`hash.go`), opaque
SHA-256-hashed bearer tokens (session + pairing kinds), and redeemable auth
SHA-256-hashed bearer tokens (session, pairing, and API-key kinds), and redeemable auth
codes (invite + recovery kinds) with atomic redemption. Also owns the admin
safety guards (`ErrLastAdmin`, `ErrAdminNeedsPassword`) and the demo-account
reaper queries. See [Auth & security](auth-and-security.md).
Expand Down
4 changes: 4 additions & 0 deletions docs-users/listening/account.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,10 @@ Why you want one: if you sign out (or get a new phone) and have **no password an
Treat a recovery code like a password: anyone who has it can sign in as you. If yours ever leaks, regenerate it (or ask your admin to revoke it).
:::

### API keys

If your server supports them, an **API keys** section here lets you create keys for dashboards, scripts, and other tools that reach your server on your behalf. See [API keys for integrations](api-keys.md).

### Signing out

**Sign out** disconnects this device. If you're about to sign out with **neither a password nor a recovery code**, the app stops you with a warning - *"Without one you'll need a new invite from your admin to sign back in"* - and offers to **Set a recovery code** right there. Take the offer; it's the whole reason the button exists.
Expand Down
Loading
Loading