2 Commits
Author SHA1 Message Date
john-okeefe 311049379d docs(android): add QR pairing sign-in to roadmap, update auth design
Document the authentication decision reached for the Android client:
username/password login is primary (the app needs the user-JWT API
surface that device tokens cannot reach), with the app self-approving
its own device registration post-login so it still shows up on the
Devices page with sync attribution.

Add the Netflix-style QR pairing flow to the post-v1 roadmap with its
constraints: the QR grants a full login with zero typing; a typed-code
fallback covers phones with broken cameras; KOReader keeps its existing
flow (no typed codes there); and pairing must encode the configured
BASE_URL rather than a detected LAN IP so remote instances
(https://public.domain) work identically.
2026-08-28 22:45:29 -04:00
john-okeefe f65db5ab4f docs(api): align auth/devices/libraries/media-items docs with handlers
Verified against the Echo routes and handler structs, fixing drift that
would break API clients:

- login: response field is access_token, not token (AuthResponse struct)
- register status: status is only pending|approved; expiry is HTTP 410
  (not a status value), approved responses are single-use, and pending
  registrations do not survive server restarts
- visible libraries: endpoint is GET /api/libraries/visibility and
  returns a top-level array of full library rows, not a wrapped object
- media items list: response is {"data": [...]}, library_id is optional,
  limit defaults to 50 (max 1000), no total field; document the sort
  parameter, the two response shapes, and raw-vs-resolved file paths

refresh and device-registration docs verified accurate; no changes.
2026-08-28 22:15:42 -04:00
5 changed files with 153 additions and 57 deletions
+16 -3
View File
@@ -68,10 +68,11 @@ Keeping `:core:domain` free of Android dependencies preserves optionality: a fut
4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader) 4. While online, a WebSocket connection receives realtime updates pushed by other devices (web reader, KOReader)
5. Books are downloaded to app storage for fully offline reading, with storage management UI 5. Books are downloaded to app storage for fully offline reading, with storage management UI
### Device registration & auth ### Authentication & device identity
- The app registers as a Bookhoard **device** using the existing QR-approval flow (`POST /api/devices/register` + web-based approval) — no passwords stored on the device - **Primary auth: username/password login** via the existing endpoints (`POST /api/auth/login` + refresh). The app is a full user client — browse, collections, ratings, and annotation management all live behind the user JWT, which device tokens cannot reach
- JWT access/refresh handled by the existing auth endpoints - After login, the app registers itself as a **device** (`device_type: mobile`) and **self-approves** its registration using its own JWT — approval only requires a logged-in user. The phone then appears on the Devices page with sync attribution, per-device settings, and individually revocable access, with no QR ceremony
- Netflix-style QR pairing as a zero-typing sign-in option: post-v1 (see below)
--- ---
@@ -125,6 +126,18 @@ iOS is a real roadmap item but not near-term. The strategy is **not** to pre-pay
## 🔭 Post-v1 Ideas ## 🔭 Post-v1 Ideas
### QR pairing sign-in (Netflix-style)
"Add device" on the web (while logged in) displays a QR code; a fresh app install scans it and is **fully signed in** — no server URL, no password, nothing typed on the phone.
- **QR is a full login**: the claim endpoint returns JWT + refresh token (plus the device token for sync identity)
- **Typed-code fallback** (GitHub/Netflix device-flow style: app displays a short code, user enters it on the web) for phones with broken cameras or no camera
- **KOReader keeps its existing flow unchanged** — no typed-code pairing there; it is already as convenient as it can be
- **Use the configured `BASE_URL`, never a detected LAN IP** — if the server is published at `https://public.domain`, pairing must work identically from outside the LAN
- Requires small server additions: `pair`/`claim` endpoints backed by single-use pairing sessions with a short TTL (in-memory like `pendingRegistrations`)
### Other ideas
- Webtoon / continuous vertical reading mode - Webtoon / continuous vertical reading mode
- Home-screen widgets and app shortcuts ("continue reading") - Home-screen widgets and app shortcuts ("continue reading")
- Text-to-speech - Text-to-speech
+3 -1
View File
@@ -26,7 +26,7 @@ Authenticate with email and password.
```json ```json
{ {
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refresh_token": "d4f5g6h7...", "refresh_token": "d4f5g6h7...",
"token_type": "Bearer", "token_type": "Bearer",
"expires_in": 604800, "expires_in": 604800,
@@ -41,6 +41,8 @@ Authenticate with email and password.
} }
``` ```
Note: the access token field is `access_token` (not `token`). Nullable profile fields (`first_name`, `last_name`) may be empty strings.
**Set-Cookie Header**: **Set-Cookie Header**:
``` ```
+12 -18
View File
@@ -24,7 +24,9 @@ Check device registration status or get device details.
```json ```json
{ {
"status": "pending|approved|expired", "status": "pending|approved",
"message": "awaiting user approval",
"expires_in": 123,
"auth_token": "device-bearer-token...", "auth_token": "device-bearer-token...",
"device_id": "uuid", "device_id": "uuid",
"sync_endpoints": { "sync_endpoints": {
@@ -35,24 +37,16 @@ Check device registration status or get device details.
} }
``` ```
## Response (200 OK) - Device Details `status` is `pending` or `approved`. While pending, the response includes `message` and `expires_in` (seconds remaining). Once approved, the response includes `auth_token`, `device_id`, and `sync_endpoints`; `auth_token` fields are empty when pending.
```json **The approved response is single-use**: the registration is deleted from the pending map once returned, so store the `auth_token` immediately. A repeat status check for the same `registration_id` returns 404.
{
"id": "uuid",
"device_name": "My Kobo Clara",
"device_type": "kobo",
"last_sync": "2026-01-31T10:00:00Z",
"last_seen": "2026-01-31T10:05:00Z",
"sync_enabled": true,
"auto_sync": true,
"sync_frequency_minutes": 5
}
```
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | --------------------------------------------- | | ---- | -------------------------------------------------- |
| 401 | Invalid or expired token (for device details) | | 400 | Invalid or missing `registration_id` |
| 404 | Device or registration not found | | 404 | Registration not found (unknown or already issued) |
| 410 | Registration expired (`{"error": "registration expired"}`) |
Note: expiration is signaled by HTTP 410 Gone, not a `"status": "expired"` value. Pending registrations are held in server memory, so a server restart also invalidates them (subsequent checks return 404).
@@ -2,7 +2,7 @@
Retrieve all libraries visible to the current user. Retrieve all libraries visible to the current user.
**Endpoint**: `GET /api/libraries/visible` **Endpoint**: `GET /api/libraries/visibility`
**Auth**: Required **Auth**: Required
## Request Headers ## Request Headers
@@ -14,26 +14,33 @@ Retrieve all libraries visible to the current user.
### Example Request ### Example Request
```http ```http
GET /api/libraries/visible GET /api/libraries/visibility
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
``` ```
## Response (200 OK) ## Response (200 OK)
A top-level JSON **array** of library rows:
```json ```json
{ [
"libraries": [ {
{ "id": "uuid",
"id": "uuid", "name": "My Ebooks",
"name": "My Ebooks", "description": "Ebook collection",
"description": "Ebook collection", "library_type_id": "uuid",
"type_name": "ebooks", "created_by_admin_id": "uuid",
"is_visible": true "created_at": "2026-01-31T10:00:00Z",
} "updated_at": "2026-01-31T10:00:00Z",
] "type_name": "ebooks",
} "type_description": "Ebook libraries",
"is_visible": true
}
]
``` ```
Nullable columns (`description`, `type_description`) serialize as `null` when unset. Timestamps are RFC 3339.
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
@@ -1,17 +1,22 @@
# List Media Items # List Media Items
Retrieve a paginated list of media items from a library. Retrieve a paginated list of media items, scoped to a library or across all libraries.
**Endpoint**: `GET /api/media-items` **Endpoint**: `GET /api/media-items`
**Auth**: Required **Auth**: Required
## Query Parameters ## Query Parameters
| Parameter | Type | Required | Description | | Parameter | Type | Required | Description |
| ---------- | ------- | -------- | ----------------------------------------------- | | ---------- | ------ | -------- | ------------------------------------------------------ |
| library_id | string | Yes | Library UUID | | library_id | string | No | Library UUID. If omitted, items from all libraries are returned |
| limit | integer | No | Number of items to return (max 100, default 20) | | limit | int | No | Items to return (default 50, max 1000) |
| offset | integer | No | Number of items to skip | | offset | int | No | Items to skip (must be >= 0) |
| sort | string | No | Sort expression, default `created_at DESC` |
### Allowed sort expressions
`created_at`, `title`, `author`, `series`, `date_published`, `copyright_year`, `page_count`, `genre` — each with ` ASC` or ` DESC` (e.g. `title ASC`). Any other value silently falls back to `created_at DESC`.
## Request Headers ## Request Headers
@@ -22,46 +27,121 @@ Retrieve a paginated list of media items from a library.
### Example Request ### Example Request
```http ```http
GET /api/media-items?library_id=uuid&limit=20&offset=0 GET /api/media-items?library_id=uuid&limit=20&offset=0&sort=title%20ASC
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
``` ```
## Response (200 OK) ## Response (200 OK)
The response body is `{"data": [...]}` in both modes. The item shape differs by mode.
**No total is returned** — page until fewer items than `limit` come back.
### With `library_id` — full database rows
Nullable columns serialize as `null`.
```json ```json
{ {
"media_items": [ "data": [
{ {
"id": "uuid", "id": "uuid",
"library_id": "uuid", "library_id": "uuid",
"title": "Book Title", "title": "Book Title",
"author": "Author Name", "author": "Author Name",
"isbn": "978-...",
"description": "Book description", "description": "Book description",
"file_path": "/path/to/book.epub", "file_path": "relative/path/book.epub",
"file_size": 1024000, "file_size": 1024000,
"mime_type": "application/epub+zip", "mime_type": "application/epub+zip",
"cover_image_path": "/path/to/cover.jpg", "cover_image_path": "relative/path/cover.jpg",
"series": "Series Name", "series": "Series Name",
"series_number": 1, "series_number": 1,
"tags": ["sci-fi", "space opera"], "tags": ["sci-fi"],
"tags_search": ["sci fi", "space opera"], "asin": null,
"contributors": ["Author Name", "ACME CORP."], "date_published": "2023-06-01",
"contributors_search": ["author name", "acme corp"], "publisher": null,
"contributors": ["Author Name"],
"language": "en", "language": "en",
"edition": null,
"page_count": 350, "page_count": 350,
"genre": "Science Fiction", "genre": "Science Fiction",
"copyright_year": 2023, "copyright_year": 2023,
"created_at": "2026-01-31T10:00:00Z" "goodreads_id": null,
"openlibrary_id": null,
"google_books_id": null,
"added_by_admin_id": "uuid",
"created_at": "2026-01-31T10:00:00Z",
"imported_at": "2026-01-31T10:00:00Z",
"updated_at": "2026-01-31T10:00:00Z",
"format_group": "epub",
"format_mimetype": "application/epub+zip",
"is_reflowable": true,
"has_fixed_layout": false,
"total_characters": 480000,
"chapter_count": 24
} }
], ]
"total": 100 }
```
Note: in this mode `file_path` and `cover_image_path` are the raw relative storage paths, not URLs.
### Without `library_id` — curated items with resolved URLs
Across all libraries; file and cover paths are resolved to fetchable URL paths (`/uploads/...` or library-scoped paths):
```json
{
"data": [
{
"id": "uuid",
"library_id": "uuid",
"title": "Book Title",
"author": "Author Name",
"isbn": "978-...",
"description": "Book description",
"file_path": "/api/libraries/<uuid>/files/...",
"file_size": 1024000,
"mime_type": "application/epub+zip",
"cover_image_path": "/api/libraries/<uuid>/files/.../cover.jpg",
"series": "Series Name",
"series_number": 1,
"tags": ["sci-fi"],
"asin": null,
"date_published": "2023-06-01",
"publisher": null,
"contributors": ["Author Name"],
"language": "en",
"edition": null,
"page_count": 350,
"genre": "Science Fiction",
"created_at": "2026-01-31T10:00:00Z",
"updated_at": "2026-01-31T10:00:00Z",
"format_group": "epub",
"manga_type": null,
"reading_direction": null,
"series_count": null,
"volume": null,
"imprint": null,
"age_rating": null,
"web_url": null,
"metadata_notes": null,
"community_rating": null,
"story_arc": null,
"is_black_and_white": false,
"alternate_info": null,
"scan_information": null,
"summary": null
}
]
} }
``` ```
## Error Responses ## Error Responses
| Code | Description | | Code | Description |
| ---- | ----------------------------------------- | | ---- | ------------------------------------------ |
| 400 | Invalid query parameters | | 400 | Invalid `library_id`, `offset` < 0 |
| 401 | Invalid or expired token | | 401 | Invalid or expired token |
| 403 | User does not have access to this library | | 500 | Query failure (returned as `{"error": …}`) |