From 470898627d7b7dd4445fab12efc78db9ca102ef3 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 2 Jun 2026 02:43:24 +0000
Subject: [PATCH] docs: snapshot v4.0.2
---
.../version-v4.0.2/about/acknowledgements.md | 16 +
.../version-v4.0.2/about/license.md | 7 +
.../about/support-and-contributing.md | 19 +
.../version-v4.0.2/configure/admin-panel.md | 148 ++++++
.../version-v4.0.2/configure/auth.md | 64 +++
.../version-v4.0.2/configure/database.md | 58 +++
.../version-v4.0.2/configure/migrations.md | 150 ++++++
.../configure/object-blob-storage.md | 167 +++++++
.../configure/server-library-import.md | 68 +++
.../tts-provider-guides/deepinfra.md | 42 ++
.../tts-provider-guides/kitten-tts-fastapi.md | 47 ++
.../tts-provider-guides/kokoro-fastapi.md | 75 +++
.../configure/tts-provider-guides/openai.md | 41 ++
.../tts-provider-guides/orpheus-fastapi.md | 41 ++
.../configure/tts-provider-guides/other.md | 52 ++
.../tts-provider-guides/replicate.md | 48 ++
.../version-v4.0.2/configure/tts-providers.md | 57 +++
.../configure/tts-rate-limiting.md | 42 ++
.../version-v4.0.2/deploy/compute-worker.md | 132 +++++
.../deploy/local-development.md | 371 ++++++++++++++
.../deploy/vercel-deployment.md | 153 ++++++
.../version-v4.0.2/docker-quick-start.md | 164 ++++++
.../version-v4.0.2/introduction.md | 44 ++
.../reference/environment-variables.md | 465 ++++++++++++++++++
.../version-v4.0.2/reference/stack.md | 70 +++
.../version-v4.0.2-sidebars.json | 78 +++
docs-site/versions.json | 1 +
27 files changed, 2620 insertions(+)
create mode 100644 docs-site/versioned_docs/version-v4.0.2/about/acknowledgements.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/about/license.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/about/support-and-contributing.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/admin-panel.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/auth.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/database.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/migrations.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/object-blob-storage.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/server-library-import.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/deepinfra.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kitten-tts-fastapi.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kokoro-fastapi.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/openai.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/orpheus-fastapi.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/other.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/replicate.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-providers.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/configure/tts-rate-limiting.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/deploy/compute-worker.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/deploy/local-development.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/deploy/vercel-deployment.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/docker-quick-start.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/introduction.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/reference/environment-variables.md
create mode 100644 docs-site/versioned_docs/version-v4.0.2/reference/stack.md
create mode 100644 docs-site/versioned_sidebars/version-v4.0.2-sidebars.json
diff --git a/docs-site/versioned_docs/version-v4.0.2/about/acknowledgements.md b/docs-site/versioned_docs/version-v4.0.2/about/acknowledgements.md
new file mode 100644
index 0000000..b47cccb
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/about/acknowledgements.md
@@ -0,0 +1,16 @@
+---
+title: Acknowledgements
+---
+
+This project is built with support from the following open-source projects and tools:
+
+- [Kokoro-82M](https://huggingface.co/hexgrad/Kokoro-82M)
+- [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
+- [Better Auth](https://www.better-auth.com/)
+- [SQLite](https://www.sqlite.org/)
+- [PostgreSQL](https://www.postgresql.org/)
+- [SeaweedFS](https://github.com/seaweedfs/seaweedfs)
+- [OpenAI Whisper](https://github.com/openai/whisper)
+- [ffmpeg](https://ffmpeg.org)
+- [react-pdf](https://github.com/wojtekmaj/react-pdf)
+- [react-reader](https://github.com/happyr/react-reader)
diff --git a/docs-site/versioned_docs/version-v4.0.2/about/license.md b/docs-site/versioned_docs/version-v4.0.2/about/license.md
new file mode 100644
index 0000000..24af383
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/about/license.md
@@ -0,0 +1,7 @@
+---
+title: License
+---
+
+OpenReader is licensed under the MIT License.
+
+- Repository license file: [LICENSE](https://github.com/richardr1126/openreader/blob/main/LICENSE)
diff --git a/docs-site/versioned_docs/version-v4.0.2/about/support-and-contributing.md b/docs-site/versioned_docs/version-v4.0.2/about/support-and-contributing.md
new file mode 100644
index 0000000..cef6224
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/about/support-and-contributing.md
@@ -0,0 +1,19 @@
+---
+title: Support and Contributing
+---
+
+## Feature requests
+
+Use [GitHub Discussions](https://github.com/richardr1126/openreader/discussions) for feature requests and ideas.
+
+## Issues and support
+
+If you encounter a bug, open an issue in [GitHub Issues](https://github.com/richardr1126/openreader/issues).
+
+## Contributing
+
+Contributions are welcome.
+
+- Fork the repository
+- Create your branch
+- Open a pull request with your changes
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/admin-panel.md b/docs-site/versioned_docs/version-v4.0.2/configure/admin-panel.md
new file mode 100644
index 0000000..181071a
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/admin-panel.md
@@ -0,0 +1,148 @@
+---
+title: Admin Panel
+---
+
+The admin panel lets a designated set of users manage **shared TTS providers** and **site-wide feature flags** directly from the Settings modal — without touching env vars or redeploying.
+
+It is gated behind authentication, so you must have auth enabled to use it ([Auth](./auth)).
+
+## Designating admins
+
+Set `ADMIN_EMAILS` to a comma-separated list of emails:
+
+```env
+AUTH_SECRET=... # required for auth
+BASE_URL=... # required for auth
+ADMIN_EMAILS=alice@example.com,bob@example.com
+```
+
+On every session resolution the server compares the user's email against this list and writes `user.is_admin = true` (or `false` for emails removed from the list). No restart is required to demote — the next page load picks it up.
+
+When the logged-in user is an admin, an **Admin** tab appears in **Settings → sidebar** with two sub-tabs:
+
+- **Shared providers** — server-side TTS provider instances visible to all users.
+- **Site features** — runtime-editable replacements for what were previously build-time public env flags.
+
+## Shared TTS providers
+
+Each shared provider is one named instance bound to one of the four built-in provider types (`custom-openai`, `openai`, `replicate`, `deepinfra`). The admin form has:
+
+| Field | Notes |
+| --- | --- |
+| **Slug** | URL-safe identifier exposed to users (e.g. `kokoro-prod`). Must not collide with a built-in id. Lowercase alphanumeric + hyphens. |
+| **Display name** | Shown in the user's provider dropdown, suffixed with "(shared)". |
+| **Provider type** | One of the four built-ins. Determines voice/model resolution. |
+| **Base URL** | Optional. Falls through to the provider type's default when blank. |
+| **API key** | Encrypted at rest with AES-256-GCM (key derived from `AUTH_SECRET` via scrypt). On edit, leave blank to keep the existing key. |
+| **Default model** | Optional. Used as the initial model when a user selects this provider. |
+| **Enabled** | Toggle to hide the provider from non-admin users without deleting it. |
+
+When a non-admin user picks a shared provider in **Settings → TTS Provider**:
+
+- The API key / base URL fields are hidden — those credentials never leave the server.
+- The TTS request still goes through the user's browser, but the server replaces the slug with the matching admin row's decrypted key and base URL before calling the upstream provider.
+- The user's per-request `x-openai-key` / `x-openai-base-url` headers are ignored for shared slugs.
+
+Whether users can supply their own personal built-in provider keys is controlled by the site feature `restrictUserApiKeys`:
+
+- `true`: users are restricted to shared providers only.
+- `false`: users may also use per-user BYOK credentials for built-in providers.
+
+### Auto-seeded "default-openai"
+
+On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with:
+
+- slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai`
+- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted)
+- defaultModel set to `kokoro` (you can edit it in Admin → Shared providers)
+
+After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient.
+
+:::warning Upgrading from v2.2.0
+In v2.2.0 and earlier, `API_KEY` / `API_BASE` were read live by the TTS routes on every request. As of v3.0.0 they are **one-shot seeds** consumed only on the first boot where `admin_providers` is empty. After upgrading, boot the app once and confirm a `default-openai` row exists in **Settings → Admin → Shared providers** with the correct base URL. If it is missing or wrong (e.g. the env vars were not set on first boot, or the table was already non-empty from a pre-release), create or edit the shared provider manually — TTS will not fall back to the env vars.
+:::
+
+## Site features
+
+Runtime-editable settings, one row per key:
+
+| Key | What it controls |
+| --- | --- |
+| `defaultTtsProvider` | Default provider id new users start with (built-in id or shared slug). |
+| `changelogFeedUrl` | Public changelog manifest URL used by the Settings modal changelog panel. |
+| `enableUserSignups` | Controls whether new accounts can be created. Existing accounts can still sign in when this is `false`. |
+| `restrictUserApiKeys` | Restrict user-supplied API keys/base URLs; when `true`, only admin shared providers are allowed. |
+| `enableTtsProvidersTab` | Whether the user-facing TTS Provider tab in Settings is shown. |
+| `showAllProviderModels` | When `false`, users are restricted to each provider's default model (shared provider `defaultModel` or built-in provider default). |
+| `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. |
+| `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). |
+
+Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag.
+
+Each row shows a source badge:
+
+- **from seed** — the value was seeded on first boot (from `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`).
+- **admin** — explicit admin override. Use **Reset** on the row to clear it back to built-in default behavior.
+- **default** — no seed/admin row exists; built-in default is active.
+
+:::warning Security note for `restrictUserApiKeys`
+Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through this server. Use this only for trusted/self-hosted deployments where that tradeoff is acceptable.
+:::
+
+## Rate limiting
+
+A dedicated **Rate limiting** group (within the same admin panel) collects the daily quotas, the PDF parsing throttle, and the upload size cap:
+
+| Key | What it controls |
+| --- | --- |
+| `disableTtsRateLimit` | Disable the per-user/IP daily TTS character limits. When `false`, the daily-limit fields below it apply. |
+| `disableComputeRateLimit` | Disable per-user PDF parsing rate limiting. When `false`, the burst/sustained limit fields below it apply. |
+| `maxUploadMb` | Maximum size (MB) accepted for a single document upload. Enforced server-side and signed into the presigned S3 PUT. |
+
+The **Disable TTS daily rate limiting** and **Disable PDF parsing rate limiting** toggles each reveal a collapsible group of numeric inputs when set to `false`:
+
+- TTS: anonymous/authenticated per-user daily limits and anonymous/authenticated IP daily backstops.
+- PDF parsing: burst limit + window (seconds) and sustained limit + window (seconds). The sustained window doubles as a concurrency cap.
+
+## TTS upstream
+
+At the end of the **Site features** tab, a dedicated **TTS upstream** group controls server-side request and cache tuning (DB-backed runtime settings, not env vars):
+
+| Key | What it controls |
+| --- | --- |
+| `ttsUpstreamMaxRetries` | Maximum retry attempts for upstream TTS 429/5xx responses. |
+| `ttsUpstreamTimeoutMs` | Upstream request timeout for OpenAI-compatible TTS calls. |
+| `ttsCacheMaxSizeBytes` | Maximum size of the in-memory TTS audio cache. |
+| `ttsCacheTtlMs` | Time-to-live for cached TTS audio buffers. |
+
+In v4 these settings are admin-only and are no longer configurable through environment variables.
+
+## Migrating off env vars
+
+In v4, runtime site features are managed by admin settings and optional JSON seed. To minimize env surface area:
+
+1. Deploy this version with your existing env values in place.
+2. Boot the app once. Open Settings → Admin and verify:
+ - Seeded settings appear as **from seed** (if you supplied a runtime JSON seed).
+ - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set).
+3. Remove any bootstrap env vars you no longer need from `.env`.
+4. Redeploy. Behavior is unchanged — the DB is now the source of truth.
+
+You can keep `API_BASE` / `API_KEY` if you intentionally want bootstrap fallback behavior on empty provider tables.
+
+## How keys are protected
+
+- API keys are encrypted in the `admin_providers` table with AES-256-GCM. The encryption key is derived from `AUTH_SECRET` via `scrypt`.
+- The masked-list view (`GET /api/admin/providers`, used by the admin UI itself) returns `••••` + last-4 only — never plaintext or ciphertext.
+- The public list endpoint (`GET /api/tts/shared-providers`, called by every user's browser) returns only `{ slug, displayName, providerType, defaultModel }`. Keys and base URLs are never exposed to the client.
+- Non-admin users cannot enumerate admin providers' credentials or base URLs through any API.
+
+:::danger Rotating `AUTH_SECRET` invalidates all stored admin provider keys
+Because the encryption key for `admin_providers` is derived from `AUTH_SECRET`, changing `AUTH_SECRET` makes every stored API key undecryptable. After rotating it, shared providers will fail to authenticate upstream until you re-enter each provider's API key in **Settings → Admin → Shared providers** (edit the row and paste the key again). There is no automated re-encryption path. If you must rotate `AUTH_SECRET`, plan to re-enter admin provider keys immediately afterward.
+:::
+
+## Related
+
+- [Auth](./auth) — required to use the admin panel.
+- [TTS Providers](./tts-providers) — built-in provider catalog and per-user behavior.
+- [Environment Variables](../reference/environment-variables) — `ADMIN_EMAILS`, provider bootstrap vars, and runtime JSON seed.
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/auth.md b/docs-site/versioned_docs/version-v4.0.2/configure/auth.md
new file mode 100644
index 0000000..6332449
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/auth.md
@@ -0,0 +1,64 @@
+---
+title: Auth
+---
+
+This page covers application-level configuration for provider access and authentication.
+
+## Auth behavior
+
+- `BASE_URL` and `AUTH_SECRET` are required at startup in v4+.
+- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
+- Anonymous auth sessions are disabled by default.
+- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
+
+## Runtime modes
+
+OpenReader has two common runtime modes:
+
+- **Auth enabled, non-admin user**: user account/session features are available, but no admin controls.
+- **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features).
+
+## Admin role
+
+You can designate one or more users as admins via the `ADMIN_EMAILS` env var:
+
+```env
+ADMIN_EMAILS=alice@example.com,bob@example.com
+```
+
+Admins see a new **Admin** tab in **Settings** with two sub-tabs:
+
+- **Shared TTS providers** — server-managed TTS provider instances with encrypted keys, visible to all users.
+- **Site features** — runtime overrides for what were previously build-time public env flags (including account signup availability, default TTS provider, audiobook export, etc.).
+
+Admin assignment is reconciled on every session resolution, so removing an email from `ADMIN_EMAILS` demotes the user on next login without a restart. See [Admin Panel](./admin-panel) for the full reference.
+
+## Route behavior
+
+- `/` is a public landing/onboarding page and remains indexable.
+- `/app` is the protected app home (document list and uploader UI).
+- If a valid session exists (including anonymous), visiting `/` redirects to `/app`.
+- Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`.
+
+## Related docs
+
+- For auth environment variables: [Environment Variables](../reference/environment-variables#auth-and-identity)
+- For admin role and shared TTS provider config: [Admin Panel](./admin-panel)
+- For TTS character limits and quota behavior: [TTS Rate Limiting](./tts-rate-limiting)
+- For provider-specific guidance: [TTS Providers](./tts-providers)
+- For storage/S3/SeaweedFS behavior: [Object / Blob Storage](./object-blob-storage)
+- For database mode: [Database](./database)
+- For migration behavior and commands: [Migrations](./migrations)
+
+## Sync notes
+
+### Auth enabled
+
+- Settings and reading progress are saved to the server.
+- Updates are not instant push-based sync; they use normal client polling/refresh behavior.
+- If two devices change the same item around the same time, the newest update wins.
+
+## Claim modal note
+
+- You may still see old anonymous settings/progress available to claim from older deployments.
+- Legacy `unclaimed` data is only surfaced through the claim flow; normal authenticated routes are scoped to your current user id.
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/database.md b/docs-site/versioned_docs/version-v4.0.2/configure/database.md
new file mode 100644
index 0000000..b87829a
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/database.md
@@ -0,0 +1,58 @@
+---
+title: Database
+---
+
+This page covers database mode selection for OpenReader.
+
+## Scope of this page
+
+- Focus: SQL metadata, state, and relational tables.
+- Not covered here: object key layout and blob transport details (see [Object / Blob Storage](./object-blob-storage)).
+
+## Database mode
+
+- SQLite (default): embedded DB at `docstore/sqlite3.db`; good for local/self-host single-instance setups.
+- Postgres: enabled when `POSTGRES_URL` is set; recommended for production/distributed deployments.
+
+## What the database stores
+
+- Document and audiobook metadata/state used by server routes.
+- Auth/session tables (`user`, `session`, `account`, `verification`) when auth is enabled — schema is auto-generated by Better Auth.
+- TTS character usage counters (`user_tts_chars`) for daily rate limiting (when enabled).
+- User settings preferences (`user_preferences`) when auth is enabled.
+- User reading progress (`user_document_progress`) when auth is enabled.
+- Document preview job/asset metadata (`document_previews`) for server-side PDF/EPUB thumbnails.
+- TTS segment metadata (`tts_segments`) for server-side playback caching:
+ - Segment identity + settings hash
+ - Audio object key and duration
+ - Optional alignment payload for word highlighting
+ - Status/error state
+ - Text fingerprint/hash (plaintext segment text is not stored)
+
+App-specific tables are manually maintained in Drizzle schema files, while auth tables are generated by the Better Auth CLI. Both are migrated together via Drizzle. See [Migrations](./migrations) for details.
+
+## What the database does not store
+
+- Raw document file bytes
+- Audiobook audio bytes
+- TTS segment audio bytes
+- Generated preview image bytes
+
+Those payloads live in object storage. SQL stores the metadata, references, and status.
+
+## Related variables
+
+- `POSTGRES_URL`
+
+For database variable behavior, see [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
+
+## Related docs
+
+- [Migrations](./migrations)
+- [Object / Blob Storage](./object-blob-storage)
+- [Auth](./auth)
+
+## State sync summary
+
+- Settings and reading progress are stored in SQL and synced from the app.
+- Sync is currently request-based (not realtime push invalidation).
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/migrations.md b/docs-site/versioned_docs/version-v4.0.2/configure/migrations.md
new file mode 100644
index 0000000..43df512
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/migrations.md
@@ -0,0 +1,150 @@
+---
+title: Migrations
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+This page covers migration behavior for both database schema and storage data in OpenReader.
+
+## Startup migration behavior
+
+By default, the shared entrypoint runs migrations automatically before app startup in:
+
+- Docker container startup
+- `pnpm dev`
+- `pnpm start`
+
+Startup migration phases:
+
+- DB schema migrations (`pnpm migrate`)
+- Storage/data migration (`pnpm migrate-fs`) for legacy filesystem content into S3 + DB rows
+
+:::info
+In most setups, you do not need to run migration commands manually because startup handles this automatically.
+:::
+
+### Schema history
+
+Migrations are applied in order. All of the following ship in v3.0.0; an instance upgrading from v2.2.0 applies `0001`–`0004` in a single startup pass.
+
+| Migration | Dialects | What it does |
+| --- | --- | --- |
+| `0001_tts_segments` | SQLite + Postgres | Creates the original single-table `tts_segments` used by server-side TTS segment caching. |
+| `0002_add_segment_key_to_tts_segments` | SQLite + Postgres | Adds the `segment_key` column to `tts_segments` for stable locator-independent segment identity. |
+| `0003_tts_segments_v2_split` | SQLite + Postgres | Replaces `tts_segments` with a normalized two-table model: `tts_segment_entries` (one row per document segment + locator identity) and `tts_segment_variants` (one row per settings combination, holding the cached audio key, status, and alignment). Drops the original `tts_segments` table — no released build (v2.2.0 or earlier) ever populated it, so there is no production data to migrate. |
+| `0004_admin_panel` | SQLite + Postgres | Creates `admin_providers` (encrypted shared TTS provider rows) and `admin_settings` (runtime site-feature config), and adds the `is_admin` column to the `user` table. Backs the [Admin Panel](./admin-panel). |
+
+To skip automatic startup migrations:
+
+- Set `RUN_DRIZZLE_MIGRATIONS=false`
+- Set `RUN_FS_MIGRATIONS=false`
+
+:::warning
+If you disable startup migrations, ensure your deployment process runs migrations before serving traffic.
+:::
+
+## Apply migrations
+
+In most cases, you do not need manual migration commands because startup runs migrations automatically.
+
+`pnpm migrate` applies migrations for one database target:
+
+- Postgres when `POSTGRES_URL` is set
+- SQLite when `POSTGRES_URL` is unset
+
+You can always override the target explicitly with `--config`.
+
+
+
+
+```bash
+# Run pending migrations for one target:
+# - Postgres if POSTGRES_URL is set
+# - SQLite if POSTGRES_URL is unset
+pnpm migrate
+
+# Run storage migration (filesystem -> S3 + DB)
+pnpm migrate-fs
+
+# Dry-run storage migration without uploading/deleting
+pnpm migrate-fs:dry-run
+```
+
+
+
+
+```bash
+# Migrate SQLite
+pnpm exec drizzle-kit migrate --config drizzle.config.sqlite.ts
+
+# Migrate Postgres
+pnpm exec drizzle-kit migrate --config drizzle.config.pg.ts
+```
+
+
+
+
+## Generate migrations
+
+`pnpm generate` is a two-phase script for contributors and schema changes:
+
+1. **Better Auth schema generation** — runs the Better Auth CLI twice (once for SQLite, once for Postgres) to produce auto-generated Drizzle schema files for auth tables (`user`, `session`, `account`, `verification`).
+2. **Drizzle migration generation** — runs `drizzle-kit generate` for both `drizzle.config.sqlite.ts` and `drizzle.config.pg.ts`, producing SQL migration files from all schema files (app + auth).
+
+:::note
+Most users do not need to run `pnpm generate`. Use it when contributing or when you have changed Drizzle schema files and need new migration files.
+:::
+
+### Schema ownership
+
+Auth tables are owned by Better Auth. Their Drizzle schema definitions are auto-generated and should **not** be hand-edited:
+
+- `src/db/schema_auth_sqlite.ts`
+- `src/db/schema_auth_postgres.ts`
+
+App-specific tables are manually maintained in the standard Drizzle schema files:
+
+- `src/db/schema_sqlite.ts`
+- `src/db/schema_postgres.ts`
+
+Both sets of schema files are included in the Drizzle configs, so `drizzle-kit generate` and `drizzle-kit migrate` handle all tables together.
+
+When app schema changes (for example `tts_segments`), keep these in sync:
+
+- `src/db/schema_sqlite.ts`
+- `src/db/schema_postgres.ts`
+- `drizzle/sqlite/*.sql` + `drizzle/sqlite/meta/_journal.json`
+- `drizzle/postgres/*.sql` + `drizzle/postgres/meta/_journal.json`
+
+
+
+
+```bash
+# Full pipeline: Better Auth CLI + Drizzle generate (both dialects)
+pnpm generate
+```
+
+
+
+
+```bash
+# Generate SQLite migrations only (skips Better Auth CLI)
+pnpm exec drizzle-kit generate --config drizzle.config.sqlite.ts
+
+# Generate Postgres migrations only (skips Better Auth CLI)
+pnpm exec drizzle-kit generate --config drizzle.config.pg.ts
+```
+
+:::warning
+Running `drizzle-kit generate` directly skips the Better Auth CLI step. If auth schema has changed upstream (e.g. after a Better Auth version bump), run `pnpm generate` instead to regenerate the auth schema files first.
+:::
+
+
+
+
+## Related docs
+
+- [Database](./database)
+- [Object / Blob Storage](./object-blob-storage)
+- [Migration Environment Variables](../reference/environment-variables#migration-controls)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/object-blob-storage.md b/docs-site/versioned_docs/version-v4.0.2/configure/object-blob-storage.md
new file mode 100644
index 0000000..802a96e
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/object-blob-storage.md
@@ -0,0 +1,167 @@
+---
+title: Object / Blob Storage
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+This page documents storage backends, blob upload routing, and core Docker mount behavior.
+
+## Scope of this page
+
+- Focus: object/blob backends, keyspaces, upload/read paths, and storage debugging.
+- Not covered here: relational metadata tables and SQL state modeling (see [Database](./database)).
+
+## Storage backends
+
+- Embedded (default): embedded SeaweedFS (`weed mini`) blob storage.
+- External: external S3-compatible object storage.
+
+Metadata database mode (SQLite vs Postgres) is configured separately in [Database](./database).
+
+:::warning SeaweedFS Compatibility Note (April 16, 2026)
+OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds.
+`4.19` introduced intermittent `InternalError` responses on S3 `PutObject` in our upload flow.
+:::
+
+Storage variables are documented in [Environment Variables](../reference/environment-variables#database-and-object-blob-storage).
+
+## Ports
+
+- `3003`: OpenReader app and API routes
+- `8333`: Embedded SeaweedFS S3 endpoint for direct browser blob access
+
+:::info
+`8333` is only needed for direct browser presigned access to embedded SeaweedFS.
+:::
+
+## Upload behavior
+
+- Primary path: browser uploads to presigned URL from `/api/documents/blob/upload/presign`.
+- Fallback path: `/api/documents/blob/upload/fallback` when direct upload fails/unreachable.
+- Read/download path: blob/content serving route `/api/documents/blob` (not the upload fallback route).
+- Preview path: `/api/documents/blob/preview` (returns `202` while a preview is generating; serves/redirects when ready).
+
+## Document previews
+
+- PDF/EPUB previews are generated server-side and stored in object storage under `document_previews_v1`.
+- Preview generation is triggered on upload registration and also backfills on first preview request for older docs.
+- Preview artifacts are temporary-cache friendly and can be regenerated from the source document blob.
+
+## FS / Volume Mounts
+
+### App data mount
+
+- Target: `/app/docstore`
+- Recommended: yes, for persistence
+- Purpose: persists SeaweedFS blob data, SQLite metadata DB, migrations, and local runtime temp state
+- Mount string: `-v openreader_docstore:/app/docstore`
+
+### Library source mount (optional)
+
+- Target: `/app/docstore/library`
+- Recommended: optional, use read-only (`:ro`)
+- Purpose: exposes host files as a source for server library import
+- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
+- Details: [Server Library Import](./server-library-import)
+
+## Private blob endpoint mode
+
+If `8333` is not published externally:
+
+- Document uploads still work through upload fallback proxy
+- Reads/snippets continue through app API routes
+- Direct presigned browser upload/download to embedded endpoint is unavailable
+
+:::warning
+Without `8333`, expect higher app-server traffic because uploads/downloads go through API routes instead of direct object endpoint access.
+:::
+
+## Audiobook Storage Debug Commands
+
+Audiobook assets are stored in object storage under the `audiobooks_v1` keyspace. Use these commands to inspect and download objects for debugging.
+
+
+
+
+```bash
+# List all audiobook objects
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive
+
+# Filter to one book id (replace )
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive | grep "-audiobook/"
+
+# Download one object by full key
+aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1//.m4b" "./audiobook.m4b"
+```
+
+
+
+
+```bash
+# List all audiobook objects
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT"
+
+# Filter to one book id (replace )
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "-audiobook/"
+
+# Download one object by full key
+aws s3 cp "s3://$S3_BUCKET/$S3_PREFIX/audiobooks_v1//.m4b" "./audiobook.m4b" --endpoint-url "$S3_ENDPOINT"
+```
+
+Embedded default example: `S3_ENDPOINT=http://127.0.0.1:8333` (or your mapped host/port).
+
+
+
+
+## TTS Segment Storage
+
+Server-side TTS segment audio is stored in object storage under the `tts_segments_v1` keyspace.
+
+Typical key layout:
+
+- `${S3_PREFIX}/tts_segments_v1/users//docs////.mp3`
+- `${S3_PREFIX}/tts_segments_v1/ns//users//docs/...` (test namespace mode)
+
+Notes:
+
+- For the corresponding SQL metadata model (`tts_segments`), see [Database](./database).
+
+## Account Deletion Cleanup
+
+Account deletion performs best-effort object cleanup:
+
+- Document blobs + preview artifacts
+- Audiobook blobs
+- TTS segment blobs under `tts_segments_v1`
+
+If object deletion fails, account deletion still proceeds and orphaned objects may require manual cleanup.
+
+## TTS Segment Storage Debug Commands
+
+Use these commands to inspect segment objects.
+
+
+
+
+```bash
+# List all TTS segment objects
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive
+
+# Filter to one document id (replace )
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive | grep "/docs//"
+```
+
+
+
+
+```bash
+# List all TTS segment objects
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive --endpoint-url "$S3_ENDPOINT"
+
+# Filter to one document id (replace )
+aws s3 ls "s3://$S3_BUCKET/$S3_PREFIX/tts_segments_v1/" --recursive --endpoint-url "$S3_ENDPOINT" | grep "/docs//"
+```
+
+
+
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/server-library-import.md b/docs-site/versioned_docs/version-v4.0.2/configure/server-library-import.md
new file mode 100644
index 0000000..acdecd2
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/server-library-import.md
@@ -0,0 +1,68 @@
+---
+title: Server Library Import
+---
+
+This page documents how server library import works and how to configure it.
+
+## What it does
+
+Server library import lets you browse files from one or more server directories and import selected files into OpenReader.
+
+- Import is user-driven via a selection modal
+- Only selected files are imported
+- Imported files become normal OpenReader documents
+
+## FS / Volume Mounts
+
+### App data mount
+
+- Target: `/app/docstore`
+- Recommended: yes, for persistence
+- Purpose: stores app runtime data, metadata DB, and embedded storage state
+- Mount string: `-v openreader_docstore:/app/docstore`
+
+### Library source mount
+
+- Target: `/app/docstore/library`
+- Recommended: yes for this feature, use read-only (`:ro`)
+- Purpose: exposes host files as import candidates in Server Library Import
+- Mount string: `-v /path/to/your/library:/app/docstore/library:ro`
+
+## Import flow
+
+1. Open **Settings -> Documents -> Server Library Import**.
+2. Select files in the modal.
+3. Click **Import**.
+
+Selected files are fetched from the server library endpoint and imported into OpenReader storage.
+
+:::warning Shared Library Roots
+Library roots are configured at the server level (not per-user). Any user who can access Server Library Import can browse/import from the same configured roots.
+
+Imported documents are still saved to the importing user's document scope.
+:::
+
+## Supported file types
+
+- `.pdf`
+- `.epub`
+- `.html`, `.htm`
+- `.txt`
+- `.md`, `.mdown`, `.markdown`
+
+## Optional: Configure Library Roots
+
+You only need this when the default mounted path is not what you want.
+
+By default, OpenReader uses `docstore/library` as the import root. You can override that with environment variables:
+
+- `IMPORT_LIBRARY_DIRS` (takes precedence): multiple roots separated by comma, colon, or semicolon
+- `IMPORT_LIBRARY_DIR`: single root
+
+See [Environment Variables](../reference/environment-variables#library-import) for variable details.
+
+## Notes
+
+- Library listing is capped per request (up to 10,000 files).
+- When auth is enabled, library import endpoints require a valid session.
+- The mounted library is a source; removing it does not delete already imported documents.
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/deepinfra.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/deepinfra.md
new file mode 100644
index 0000000..f52868f
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/deepinfra.md
@@ -0,0 +1,42 @@
+---
+title: DeepInfra
+---
+
+Use DeepInfra's hosted TTS models as your provider.
+
+## Setup
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `deepinfra`.
+2. Keep base URL as `https://api.deepinfra.com/v1/openai`.
+3. Enter your API key.
+4. Set your preferred default model/voice.
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=https://api.deepinfra.com/v1/openai
+API_KEY=your-deepinfra-key
+```
+
+**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
+
+1. Set provider to `Deepinfra`.
+2. The base URL is pre-filled, no changes needed.
+3. Enter your `API_KEY`.
+4. Choose a model and voice.
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## Notes
+
+- Available models include `hexgrad/Kokoro-82M` and `canopylabs/orpheus-3b-0.1-ft`.
+- Without an API key, only the free-tier model (`hexgrad/Kokoro-82M`) is shown in the dropdown.
+- TTS requests are sent from the server, not the browser. The API key is never exposed to clients.
+
+## References
+
+- [DeepInfra TTS models](https://deepinfra.com/models/text-to-speech)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kitten-tts-fastapi.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kitten-tts-fastapi.md
new file mode 100644
index 0000000..02b6226
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kitten-tts-fastapi.md
@@ -0,0 +1,47 @@
+---
+title: KittenTTS-FastAPI
+---
+
+Run [KittenTTS-FastAPI](https://github.com/richardr1126/KittenTTS-FastAPI) locally and connect it to OpenReader using the `Custom OpenAI-Like` provider. Lightweight and CPU-friendly.
+
+## Run KittenTTS
+
+```bash
+docker run -it --rm \
+ --name kittentts-fastapi \
+ -e KITTEN_MODEL_REPO_ID="KittenML/kitten-tts-nano-0.8-fp32" \
+ -p 8005:8005 \
+ ghcr.io/richardr1126/kittentts-fastapi-cpu
+```
+
+## Connect to OpenReader
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `custom-openai`.
+2. Set base URL to your KittenTTS endpoint (e.g. `http://kittentts-fastapi:8005/v1`).
+3. Leave API key blank unless required by your deployment.
+4. Set default model to `kitten-tts` (or your backend model id).
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=http://kittentts-fastapi:8005/v1
+```
+
+> Use `kittentts-fastapi` if that's the container name, or `host.docker.internal` if not.
+
+**Or in-app via Settings → TTS Provider:**
+
+1. Set provider to `Custom OpenAI-Like`.
+2. Set `API_BASE` to your KittenTTS endpoint (e.g. `http://kittentts-fastapi:8005/v1`).
+3. Leave `API_KEY` blank unless your deployment requires one.
+4. Choose model `kitten-tts` (or the model your deployment exposes).
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## References
+
+- [richardr1126/KittenTTS-FastAPI](https://github.com/richardr1126/KittenTTS-FastAPI)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kokoro-fastapi.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kokoro-fastapi.md
new file mode 100644
index 0000000..7a3fc2c
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/kokoro-fastapi.md
@@ -0,0 +1,75 @@
+---
+title: Kokoro-FastAPI
+---
+
+Run [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI) locally and connect it to OpenReader using the `Custom OpenAI-Like` provider.
+
+:::warning
+For Kokoro issues and support, use the upstream repository: [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI).
+:::
+
+## Run Kokoro
+
+**CPU:**
+
+```bash
+docker run --name kokoro-tts \
+ --restart unless-stopped \
+ -d \
+ -p 8880:8880 \
+ -e ONNX_NUM_THREADS=8 \
+ -e ONNX_INTER_OP_THREADS=4 \
+ -e ONNX_EXECUTION_MODE=parallel \
+ -e ONNX_OPTIMIZATION_LEVEL=all \
+ -e ONNX_MEMORY_PATTERN=true \
+ -e ONNX_ARENA_EXTEND_STRATEGY=kNextPowerOfTwo \
+ -e API_LOG_LEVEL=DEBUG \
+ ghcr.io/remsky/kokoro-fastapi-cpu:v0.2.4
+```
+
+**GPU (NVIDIA):**
+
+```bash
+docker run --name kokoro-tts \
+ --restart unless-stopped \
+ -d \
+ --gpus all \
+ --user 1001:1001 \
+ -p 8880:8880 \
+ -e USE_GPU=true \
+ -e PYTHONUNBUFFERED=1 \
+ -e API_LOG_LEVEL=DEBUG \
+ ghcr.io/remsky/kokoro-fastapi-gpu:v0.2.4
+```
+
+## Connect to OpenReader
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `custom-openai`.
+2. Set base URL to your Kokoro endpoint (e.g. `http://kokoro-tts:8880/v1`).
+3. Leave API key blank unless required by your deployment.
+4. Set default model to `Kokoro`.
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=http://kokoro-tts:8880/v1
+```
+
+> Use `kokoro-tts` if that's the container name, or `host.docker.internal` if not.
+
+**Or in-app via Settings → TTS Provider:**
+
+1. Set provider to `Custom OpenAI-Like`.
+2. Set `API_BASE` to your Kokoro endpoint (e.g. `http://kokoro-tts:8880/v1`).
+3. Leave `API_KEY` blank unless your deployment requires one.
+4. Choose model `Kokoro`.
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## References
+
+- [remsky/Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/openai.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/openai.md
new file mode 100644
index 0000000..e99e9ba
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/openai.md
@@ -0,0 +1,41 @@
+---
+title: OpenAI
+---
+
+Use the OpenAI TTS API as your provider.
+
+## Setup
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `openai`.
+2. Keep base URL as `https://api.openai.com/v1`.
+3. Enter your API key.
+4. Set your preferred default model/voice.
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=https://api.openai.com/v1
+API_KEY=sk-...
+```
+
+**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
+
+1. Set provider to `OpenAI`.
+2. The base URL is pre-filled, no changes needed.
+3. Enter your `API_KEY`.
+4. Choose a model and voice.
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## Notes
+
+- Models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`
+- TTS requests are sent from the server, not the browser. The API key is never exposed to clients.
+
+## References
+
+- [OpenAI TTS pricing](https://platform.openai.com/docs/pricing#transcription-and-speech)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/orpheus-fastapi.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/orpheus-fastapi.md
new file mode 100644
index 0000000..13e39ab
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/orpheus-fastapi.md
@@ -0,0 +1,41 @@
+---
+title: Orpheus-FastAPI
+---
+
+Run [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI) locally and connect it to OpenReader using the `Custom OpenAI-Like` provider.
+
+## Run Orpheus
+
+Refer to the upstream repository for Docker instructions: [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI).
+
+## Connect to OpenReader
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `custom-openai`.
+2. Set base URL to your Orpheus endpoint (e.g. `http://orpheus:8000/v1`).
+3. Leave API key blank unless required by your deployment.
+4. Set default model to `Orpheus` (or your backend model id).
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=http://orpheus:8000/v1
+```
+
+> Use the container name if that's how it's named, or `host.docker.internal` if not.
+
+**Or in-app via Settings → TTS Provider:**
+
+1. Set provider to `Custom OpenAI-Like`.
+2. Set `API_BASE` to your Orpheus endpoint (e.g. `http://orpheus:8000/v1`).
+3. Leave `API_KEY` blank unless your deployment requires one.
+4. Choose model `Orpheus` (or the model your deployment exposes).
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## References
+
+- [Lex-au/Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/other.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/other.md
new file mode 100644
index 0000000..bcb3a0a
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/other.md
@@ -0,0 +1,52 @@
+---
+title: Other
+---
+
+Use any OpenAI-compatible TTS service with OpenReader, including self-hosted servers not covered by a dedicated guide.
+
+## Requirements
+
+Your service must expose these endpoints:
+
+- `GET /v1/audio/voices`
+- `POST /v1/audio/speech`
+
+Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS-FastAPI](./kitten-tts-fastapi), [Orpheus-FastAPI](./orpheus-fastapi).
+
+## Setup
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `custom-openai`.
+2. Set `API_BASE` to your service base URL (typically ending in `/v1`).
+3. Set API key if your service requires authentication.
+4. Set a default model/voice supported by your backend.
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_BASE=http://your-tts-server/v1
+API_KEY=optional-key-if-required
+```
+
+**Or in-app via Settings → TTS Provider:**
+
+1. Set provider to `Custom OpenAI-Like`.
+2. Set `API_BASE` to your service's base URL (typically ending in `/v1`).
+3. Set `API_KEY` if your service requires authentication.
+4. Choose a model and voice supported by your backend.
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+:::warning TTS requests are server-side
+`API_BASE` must be reachable from the **Next.js server**, not just the browser. In Docker, use container names or `host.docker.internal`.
+:::
+
+## Troubleshooting
+
+If voices don't load, check that `/v1/audio/voices` is reachable from the server and returns a valid response shape.
+
+## References
+
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/replicate.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/replicate.md
new file mode 100644
index 0000000..ff485cd
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-provider-guides/replicate.md
@@ -0,0 +1,48 @@
+---
+title: Replicate
+---
+
+Use Replicate's hosted TTS models as your provider.
+
+## Setup
+
+**Recommended (auth + admin): Settings → Admin → Shared providers**
+
+1. Add a shared provider with type `replicate`.
+2. Enter your API key.
+3. Set default model to:
+ `alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5` (or your preferred model).
+
+**Legacy bootstrap seed (optional, first boot only):**
+
+```env
+API_KEY=r8_...
+```
+
+Then update the shared provider's **Default model** in **Settings → Admin → Shared providers**.
+
+**Per-user Settings → TTS Provider (only when `restrictUserApiKeys=false`):**
+
+1. Set provider to `Replicate`.
+2. Enter your `API_KEY`.
+3. Choose a model and voice.
+
+See [TTS Providers](../tts-providers) for admin-shared vs per-user behavior.
+
+## Notes
+
+- Built-in Replicate models:
+ - `alphanumericuser/kokoro-82m:89b6fa84e4fa2dd6bd3a96be3e1f12827a3516c9fda8fddbac7a0be131c9a6f5`
+ - `google/gemini-3.1-flash-tts`
+ - `minimax/speech-2.8-turbo`
+ - `qwen/qwen3-tts`
+ - `inworld/tts-1.5-mini`
+- You can also choose `Other` and enter any Replicate model ID (for example `owner/model-name` or `owner/model-name:version`).
+- Native model speed is not available on all Replicate models; OpenReader hides/disables native speed controls where unsupported.
+- TTS requests are sent from the server, not the browser. The API key is never exposed to clients.
+
+## References
+
+- [Replicate](https://replicate.com/explore)
+- [TTS Providers](../tts-providers)
+- [TTS Environment Variables](../../reference/environment-variables#tts-provider-and-request-behavior)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-providers.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-providers.md
new file mode 100644
index 0000000..fac1813
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-providers.md
@@ -0,0 +1,57 @@
+---
+title: TTS Providers
+---
+
+OpenReader routes all TTS requests through the Next.js server to an OpenAI-compatible API. There are three places provider configuration can live:
+
+**Admin-managed shared providers** (Settings > Admin > Shared providers): DB-backed instances configured by an admin and visible to all users. Keys are encrypted at rest and never exposed to the client. Available only when [auth is enabled](./auth) and your account is in `ADMIN_EMAILS`. See [Admin Panel](./admin-panel).
+
+**Per-user Settings modal** (Settings > TTS Provider): provider + API key stored in the user's browser and sent with every TTS request. This path is available only when the admin/runtime setting `restrictUserApiKeys=false`.
+
+**Environment variables**: `API_KEY` and `API_BASE` exist as a one-shot first-boot seed that auto-creates a `default-openai` admin shared provider. After the first boot they are no longer read by the running app.
+
+:::tip
+If you're running a private/self-hosted instance and want per-user BYOK behavior, turn off **Settings → Admin → Site features → Restrict user API keys**. For first-boot automation, set `runtimeConfig.restrictUserApiKeys=false` in `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`.
+:::
+
+## Providers
+
+- **OpenAI**: Cloud. Base URL pre-filled (`https://api.openai.com/v1`). API key required.
+- **Replicate**: Cloud. Base URL managed internally by OpenReader. API key required.
+- **DeepInfra**: Cloud. Base URL pre-filled (`https://api.deepinfra.com/v1/openai`). API key required.
+- **Custom OpenAI-Like**: Self-hosted or any custom endpoint. `API_BASE` must be set manually (typically ending in `/v1`). API key optional.
+
+For `OpenAI`, `DeepInfra`, and `Replicate` you only need to supply an API key. For `Custom OpenAI-Like` you must also set `API_BASE`.
+
+## Built-in model catalogs
+
+- **Replicate** models: `alphanumericuser/kokoro-82m`, `google/gemini-3.1-flash-tts`, `minimax/speech-2.8-turbo`, `qwen/qwen3-tts`, `inworld/tts-1.5-mini` (or choose `Other` and enter any Replicate model ID, such as `owner/model` or `owner/model:version`)
+- **OpenAI** models: `tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`
+- **DeepInfra** models: includes `hexgrad/Kokoro-82M` and additional hosted models (depending on API key / feature flags)
+
+## Custom provider requirements
+
+Self-hosted or custom providers must expose OpenAI-compatible audio endpoints:
+
+- `GET /v1/audio/voices`
+- `POST /v1/audio/speech`
+
+:::warning TTS requests are server-side
+TTS requests originate from the **Next.js server**, not the browser. `API_BASE` must be reachable from the server runtime. In Docker, use container names or `host.docker.internal` rather than `localhost`.
+:::
+
+## Provider guides
+
+- [Kokoro-FastAPI](./tts-provider-guides/kokoro-fastapi)
+- [KittenTTS-FastAPI](./tts-provider-guides/kitten-tts-fastapi)
+- [Orpheus-FastAPI](./tts-provider-guides/orpheus-fastapi)
+- [Replicate](./tts-provider-guides/replicate)
+- [DeepInfra](./tts-provider-guides/deepinfra)
+- [OpenAI](./tts-provider-guides/openai)
+- [Other](./tts-provider-guides/other)
+
+## Related
+
+- [Admin Panel](./admin-panel) — DB-backed shared providers with encrypted keys
+- [TTS Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
+- [TTS Rate Limiting](./tts-rate-limiting)
diff --git a/docs-site/versioned_docs/version-v4.0.2/configure/tts-rate-limiting.md b/docs-site/versioned_docs/version-v4.0.2/configure/tts-rate-limiting.md
new file mode 100644
index 0000000..967124c
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/configure/tts-rate-limiting.md
@@ -0,0 +1,42 @@
+---
+title: TTS Rate Limiting
+---
+
+This page explains OpenReader's TTS character rate limiting controls.
+
+## Overview
+
+- TTS rate limiting is disabled by default.
+- Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**.
+- Limits are enforced per day in UTC.
+- Enforcement applies only when auth is enabled.
+
+## How enforcement works
+
+When enabled, OpenReader enforces:
+
+- Per-user daily character limits.
+- IP backstop daily character limits.
+- Anonymous device backstop tracking (cookie-based) to reduce limit resets.
+
+If a request exceeds the active limit, the TTS API returns `429` with reset metadata for the next UTC day.
+
+## Required auth behavior
+
+- Auth must be enabled (`BASE_URL` + `AUTH_SECRET`) for TTS char limits to apply.
+- If auth is disabled, TTS character limits are effectively unlimited.
+- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
+- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
+
+## Runtime config
+
+- `disableTtsRateLimit` default: `true`
+- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
+- Optional first-boot seeding can be done via `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH` (`runtimeConfig.disableTtsRateLimit`).
+
+## Related docs
+
+- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
+- PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Site features](./admin-panel#site-features) and [Environment Variables](../reference/environment-variables#compute-pdf-parsing-rate-limiting-runtime-settings)
+- Auth configuration: [Auth](./auth)
+- Provider setup: [TTS Providers](./tts-providers)
diff --git a/docs-site/versioned_docs/version-v4.0.2/deploy/compute-worker.md b/docs-site/versioned_docs/version-v4.0.2/deploy/compute-worker.md
new file mode 100644
index 0000000..260158b
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/deploy/compute-worker.md
@@ -0,0 +1,132 @@
+---
+title: Compute Worker
+description: Deploy the standalone worker used for Whisper alignment and PDF layout parsing.
+---
+
+Use this guide when OpenReader runs compute as a separate service. For the default embedded/local flow (`pnpm dev` or `pnpm start` without `COMPUTE_WORKER_URL`), configure the root `.env` instead and see [Local Development](./local-development).
+
+## What the worker does
+
+- Runs Whisper word alignment jobs
+- Runs PDF layout parsing jobs
+- Stores durable job state in NATS JetStream and NATS KV
+
+The app server submits work to `POST /ops` and listens for updates on `GET /ops/:opId/events`.
+
+## When to use it
+
+- Required for Vercel-style deployments where heavy compute must run outside the app server
+- Useful when you want a dedicated compute host
+- Not needed for the default embedded local flow
+
+## Container image
+
+- `ghcr.io/richardr1126/openreader-compute-worker:latest`
+
+## Worker environment
+
+Required worker variables:
+
+```env
+COMPUTE_WORKER_TOKEN=...
+NATS_URL=nats://...
+S3_BUCKET=...
+S3_REGION=...
+S3_ACCESS_KEY_ID=...
+S3_SECRET_ACCESS_KEY=...
+```
+
+:::important
+`compute/worker/.env*` is only for standalone worker deployments.
+
+- Embedded/local mode: configure the root `.env` only.
+- External worker mode: set `COMPUTE_WORKER_URL` and `COMPUTE_WORKER_TOKEN` on the app, and worker runtime values on the worker service.
+- Keep shared values aligned across app and worker: `COMPUTE_WORKER_TOKEN`, `S3_*`, `COMPUTE_WHISPER_TIMEOUT_MS`, `COMPUTE_PDF_TIMEOUT_MS`, and `COMPUTE_OP_STALE_MS`.
+:::
+
+Common optional variables:
+
+- `NATS_CREDS` or `NATS_CREDS_FILE`
+- `S3_ENDPOINT`, `S3_FORCE_PATH_STYLE=true`, `S3_PREFIX=openreader`
+- `COMPUTE_WORKER_HOST=0.0.0.0`
+- `PORT=8081` for local/manual runs. Platforms like Railway usually inject `PORT`.
+- `LOG_FORMAT=json` and `COMPUTE_LOG_LEVEL=info`
+- `COMPUTE_PREWARM_MODELS=true`
+- `COMPUTE_JOB_CONCURRENCY=1`
+- `COMPUTE_WHISPER_TIMEOUT_MS=30000`
+- `COMPUTE_PDF_TIMEOUT_MS=300000`
+- `COMPUTE_PDF_JOB_ATTEMPTS=1`
+- `COMPUTE_JOBS_STREAM_MAX_BYTES=268435456`
+- `COMPUTE_EVENTS_STREAM_MAX_BYTES=134217728`
+- `COMPUTE_JOB_STATES_MAX_BYTES=67108864`
+- `COMPUTE_NATS_REPLICAS=1`
+- `COMPUTE_OP_STALE_MS=1800000`
+- `WHISPER_MODEL_BASE_URL`
+- `PDF_LAYOUT_MODEL_BASE_URL`
+
+If you need the broader app config reference, see [Environment Variables](../reference/environment-variables).
+
+## App server environment
+
+Set these on the Next.js app server:
+
+```env
+COMPUTE_WORKER_URL=https://worker.example.com
+COMPUTE_WORKER_TOKEN=
+# Optional shared overrides:
+# COMPUTE_WHISPER_TIMEOUT_MS=30000
+# COMPUTE_PDF_TIMEOUT_MS=300000
+# COMPUTE_OP_STALE_MS=1800000
+```
+
+Notes:
+
+- Model artifact overrides (`WHISPER_MODEL_BASE_URL`, `PDF_LAYOUT_MODEL_BASE_URL`) belong on the worker service, not the app server.
+- There is no app-local compute fallback once `COMPUTE_WORKER_URL` is set. If the worker is unavailable, worker-backed requests fail.
+
+## Deployment notes
+
+- App and worker must share the same object storage.
+- Embedded `weed mini` is not supported for external worker mode.
+- Protect `COMPUTE_WORKER_TOKEN` and do not expose worker routes without auth.
+- The worker connects to NATS lazily and disconnects after 120 seconds of full idle time. That allows platforms like Railway to sleep the service, but the first request after a cold start will be slower.
+
+## Health endpoints
+
+- `GET /health/live` returns `{ ok: true }`.
+- `GET /health/ready` returns `{ ok: true, natsConnected }` and reflects the current NATS session without forcing a reconnect.
+
+## Railway + Synadia example
+
+Deploy the worker image to Railway and set worker env vars similar to:
+
+```env
+COMPUTE_WORKER_HOST=0.0.0.0
+COMPUTE_WORKER_TOKEN=
+NATS_URL=tls://connect.ngs.global:4222
+NATS_CREDS="-----BEGIN NATS USER JWT-----
+...
+------END USER NKEY SEED------"
+S3_BUCKET=
+S3_REGION=
+S3_ACCESS_KEY_ID=
+S3_SECRET_ACCESS_KEY=
+# Optional:
+# S3_ENDPOINT=https://...
+# S3_FORCE_PATH_STYLE=true
+# S3_PREFIX=openreader
+```
+
+If your platform supports mounted files, you can use `NATS_CREDS_FILE` instead of `NATS_CREDS`.
+
+Set these on the OpenReader app server:
+
+```env
+COMPUTE_WORKER_URL=https://
+COMPUTE_WORKER_TOKEN=
+```
+
+Verify the worker after deploy:
+
+- `GET https:///health/live`
+- `GET https:///health/ready`
diff --git a/docs-site/versioned_docs/version-v4.0.2/deploy/local-development.md b/docs-site/versioned_docs/version-v4.0.2/deploy/local-development.md
new file mode 100644
index 0000000..338aeb0
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/deploy/local-development.md
@@ -0,0 +1,371 @@
+---
+title: Local Development
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Prerequisites
+
+
+Node.js + pnpm (required)
+
+
+
+
+```bash
+brew install nvm pnpm
+mkdir -p ~/.nvm
+echo 'export NVM_DIR="$HOME/.nvm"' >> ~/.zshrc
+echo '[ -s "$(brew --prefix nvm)/nvm.sh" ] && . "$(brew --prefix nvm)/nvm.sh"' >> ~/.zshrc
+source ~/.zshrc
+nvm install --lts
+nvm use --lts
+node -v
+pnpm -v
+```
+
+
+
+
+```bash
+# Debian/Ubuntu example
+sudo apt update
+sudo apt install -y curl
+curl -fsSL https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
+export NVM_DIR="$HOME/.nvm"
+[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"
+nvm install --lts
+nvm use --lts
+corepack enable
+corepack prepare pnpm@latest --activate
+node -v
+pnpm -v
+```
+
+
+
+
+
+
+
+SeaweedFS weed binary (required unless using external S3)
+
+
+
+
+```bash
+brew install seaweedfs
+weed version
+```
+
+:::warning SeaweedFS Compatibility Note (April 16, 2026)
+If you see intermittent S3 `InternalError` upload failures with embedded storage, use SeaweedFS `4.18`.
+OpenReader currently pins `4.18` in CI and Docker builds while `4.19` compatibility is investigated.
+:::
+
+
+
+
+```bash
+# Linux amd64 example (pin 4.18)
+mkdir -p "$HOME/.local/bin"
+curl -fsSL -o /tmp/seaweedfs.tar.gz \
+ https://github.com/seaweedfs/seaweedfs/releases/download/4.18/linux_amd64.tar.gz
+tar -xzf /tmp/seaweedfs.tar.gz -C /tmp weed
+install -m 0755 /tmp/weed "$HOME/.local/bin/weed"
+echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
+export PATH="$HOME/.local/bin:$PATH"
+weed version
+```
+
+:::warning SeaweedFS Compatibility Note (April 16, 2026)
+If you see intermittent S3 `InternalError` upload failures with embedded storage, use SeaweedFS `4.18`.
+OpenReader currently pins `4.18` in CI and Docker builds while `4.19` compatibility is investigated.
+:::
+
+
+
+
+
+
+
+NATS Server nats-server (required for embedded compute mode)
+
+If `COMPUTE_WORKER_URL` is unset, startup launches embedded compute worker + NATS, so `nats-server` must be available on host PATH.
+
+If you always use an external worker (`COMPUTE_WORKER_URL` set), this is not required.
+
+
+
+
+```bash
+brew install nats-server
+nats-server -v
+```
+
+
+
+
+```bash
+# Linux amd64 example
+mkdir -p "$HOME/.local/bin"
+curl -fsSL -o /tmp/nats-server.zip \
+ https://github.com/nats-io/nats-server/releases/latest/download/nats-server-v2.12.1-linux-amd64.zip
+unzip -j /tmp/nats-server.zip '*/nats-server' -d /tmp
+install -m 0755 /tmp/nats-server "$HOME/.local/bin/nats-server"
+echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc
+export PATH="$HOME/.local/bin:$PATH"
+nats-server -v
+```
+
+
+
+
+
+
+
+LibreOffice (optional, for DOCX conversion)
+
+
+
+
+```bash
+brew install libreoffice
+```
+
+
+
+
+```bash
+# Debian/Ubuntu example
+sudo apt update
+sudo apt install -y libreoffice
+```
+
+
+
+
+
+
+
+Word-by-word highlighting (optional)
+
+No extra native Whisper CLI build step is required.
+
+Word-by-word highlighting and PDF layout parsing are worker-backed in current releases.
+
+If you need mirrors or pinned artifact locations, set `WHISPER_MODEL_BASE_URL` in `.env` (current defaults expect q4 Whisper files at that base URL).
+
+
+
+
+External compute worker dev stack (optional)
+
+Use this only when you intentionally run compute-worker as a separate service.
+Default local flow does not need `compute/worker/.env`; embedded worker startup reads root `.env`.
+Full worker deployment details are in [Compute Worker (NATS JetStream)](./compute-worker).
+
+Start only NATS + compute-worker via compose watch:
+
+```bash
+docker compose --env-file compute/worker/.env -f compute/worker/docker-compose.yml up --watch
+# or: pnpm compute:dev:watch
+```
+
+`compute/worker/.env.example` contains a starter config for standalone worker service deployments.
+
+Run the main app separately on the host:
+
+```bash
+pnpm dev
+```
+
+For app -> external worker routing, set in root `.env`:
+
+```env
+COMPUTE_WORKER_URL=http://localhost:8081
+COMPUTE_WORKER_TOKEN=
+```
+
+Ownership in external worker mode:
+- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
+- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
+
+Worker mode requires worker-reachable shared object storage (S3-compatible endpoint).
+For external worker mode, object storage must be shared/reachable by both app and worker services.
+
+
+
+## Steps
+
+### Required flow
+
+1. Clone the repository.
+
+```bash
+git clone https://github.com/richardr1126/openreader.git
+cd openreader
+```
+
+2. Install dependencies.
+
+```bash
+pnpm i
+```
+
+3. Configure the environment.
+
+```bash
+cp .env.example .env
+```
+
+Then edit `.env`.
+
+Default embedded worker flow (no external worker URL):
+
+```env
+# Leave COMPUTE_WORKER_URL unset.
+# Entry point auto-starts embedded worker+NATS when available.
+```
+
+External worker flow:
+
+```env
+COMPUTE_WORKER_URL=http://localhost:8081
+COMPUTE_WORKER_TOKEN=
+```
+
+Use the same ownership split:
+- root `.env`: app routing/auth (`COMPUTE_WORKER_URL`, `COMPUTE_WORKER_TOKEN`) plus optional shared timeout/stale overrides
+- `compute/worker/.env*` (or worker platform env): worker runtime variables (`NATS_*`, `S3_*`, model base URLs, worker tuning)
+
+Use one of these `.env` mode templates:
+
+
+
+
+```env
+API_BASE=http://host.docker.internal:8880/v1
+API_KEY=none
+BASE_URL=http://localhost:3003
+AUTH_SECRET=
+# Optional when you need multiple local origins:
+# AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003
+```
+
+
+
+
+```env
+# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider
+# on first boot, then no longer read. Manage them in Settings → Admin afterwards.
+API_BASE=http://host.docker.internal:8880/v1
+API_KEY=none
+BASE_URL=http://localhost:3003
+AUTH_SECRET=
+# Comma-separated emails to auto-promote to admin on signin.
+ADMIN_EMAILS=you@example.com
+```
+
+
+
+
+```env
+API_BASE=http://host.docker.internal:8880/v1
+API_KEY=none
+USE_EMBEDDED_WEED_MINI=false
+BASE_URL=http://localhost:3003
+AUTH_SECRET=
+S3_BUCKET=your-bucket
+S3_REGION=us-east-1
+S3_ACCESS_KEY_ID=your-access-key
+S3_SECRET_ACCESS_KEY=your-secret-key
+# Optional for non-AWS providers:
+# S3_ENDPOINT=https://your-s3-compatible-endpoint
+# S3_FORCE_PATH_STYLE=true
+```
+
+
+
+
+```env
+API_BASE=http://host.docker.internal:8880/v1
+API_KEY=none
+BASE_URL=http://localhost:3003
+AUTH_SECRET=
+COMPUTE_WORKER_URL=http://localhost:8081
+COMPUTE_WORKER_TOKEN=
+USE_EMBEDDED_WEED_MINI=false
+S3_BUCKET=your-bucket
+S3_REGION=us-east-1
+S3_ACCESS_KEY_ID=your-access-key
+S3_SECRET_ACCESS_KEY=your-secret-key
+# Optional for non-AWS providers:
+# S3_ENDPOINT=https://your-s3-compatible-endpoint
+# S3_FORCE_PATH_STYLE=true
+```
+
+
+
+
+:::note Env vars vs. admin panel
+On first boot, `API_KEY` / `API_BASE` can bootstrap `default-openai`, and `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH` can seed runtime config + providers. After that, the admin UI is authoritative and editing bootstrap env vars no longer changes app behavior. See [Admin Panel](../configure/admin-panel).
+:::
+
+:::note User BYOK restriction default
+If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin**, or by seeding `runtimeConfig.restrictUserApiKeys=false` in runtime seed JSON).
+:::
+
+:::info
+For all environment variables, see [Environment Variables](../reference/environment-variables).
+:::
+
+See [Auth](../configure/auth) for app/auth behavior.
+See [Admin Panel](../configure/admin-panel) for the shared-provider and feature-flag management UI.
+Storage configuration details are in [Object / Blob Storage](../configure/object-blob-storage).
+Refer to [Database](../configure/database) for database modes.
+Learn about migration behavior and commands in [Migrations](../configure/migrations).
+
+4. Start the app.
+
+
+
+
+```bash
+pnpm dev
+```
+
+If you use embedded worker startup (no `COMPUTE_WORKER_URL`) and the host is missing `nats-server`,
+install `nats-server` locally or switch to external worker mode.
+
+
+
+
+```bash
+pnpm build
+pnpm start
+```
+
+
+
+
+:::warning API Base Reachability
+`API_BASE` must be reachable from the Next.js server process, not just your browser.
+:::
+
+Visit [http://localhost:3003](http://localhost:3003).
+
+### Optional workflows
+
+Run manual DB migrations only for troubleshooting or explicit migration workflows:
+
+- Migrations run automatically on startup through the shared entrypoint for both `pnpm dev` and `pnpm start`.
+
+```bash
+pnpm migrate
+```
+
+:::info
+If `POSTGRES_URL` is set, migrations target Postgres; otherwise local SQLite is used. To disable automatic startup migrations, set `RUN_DRIZZLE_MIGRATIONS=false` and/or `RUN_FS_MIGRATIONS=false`. You can run storage migration manually with `pnpm migrate-fs`.
+:::
diff --git a/docs-site/versioned_docs/version-v4.0.2/deploy/vercel-deployment.md b/docs-site/versioned_docs/version-v4.0.2/deploy/vercel-deployment.md
new file mode 100644
index 0000000..eeedbcd
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/deploy/vercel-deployment.md
@@ -0,0 +1,153 @@
+---
+title: Vercel Deployment
+---
+
+This guide covers deploying OpenReader to Vercel with external Postgres and S3-compatible object storage.
+
+## What works on Vercel
+
+- Documents (PDF/EPUB/TXT/MD) work with `POSTGRES_URL` + external S3 storage.
+- Audiobook routes work on Node.js serverless functions using `ffmpeg-static`.
+- Heavy compute features (Whisper alignment + PDF layout parsing) run through an external compute worker service.
+- For worker setup details and worker-specific env vars, see [Compute Worker (NATS JetStream)](./compute-worker).
+
+:::warning DOCX Conversion Limitation
+`docx` conversion requires `soffice` (LibreOffice), which is not available in a standard Vercel runtime.
+:::
+
+## 1. Environment Variables
+
+Recommended production setup (auth enabled, admin panel enabled):
+
+```bash
+# Infrastructure
+POSTGRES_URL=postgres://...
+USE_EMBEDDED_WEED_MINI=false
+S3_ACCESS_KEY_ID=...
+S3_SECRET_ACCESS_KEY=...
+S3_BUCKET=...
+S3_REGION=us-east-1
+S3_PREFIX=openreader
+# Optional (non-AWS S3-compatible providers):
+# S3_ENDPOINT=https://...
+# S3_FORCE_PATH_STYLE=true
+
+# Auth (required for the admin panel)
+BASE_URL=https://your-app.vercel.app
+AUTH_SECRET=...
+ADMIN_EMAILS=you@example.com # comma-separated; admins manage TTS + features in-app
+
+# Heavy compute (required on Vercel in current releases)
+COMPUTE_WORKER_URL=https://
+COMPUTE_WORKER_TOKEN=...
+
+# Logging (recommended for Vercel log ingestion)
+LOG_FORMAT=json
+LOG_LEVEL=info
+
+# First-boot seed for the TTS shared provider (optional; manage in-app afterwards)
+# API_KEY=your_replicate_key
+# API_BASE only needed for OpenAI-compatible self-hosted providers
+```
+
+If you also run an external worker service (for example Railway), set these there too:
+
+- `LOG_FORMAT=json`
+- `COMPUTE_LOG_LEVEL=info`
+
+:::note Env vars vs. admin panel (important for Vercel)
+`API_KEY` / `API_BASE` are one-shot bootstrap seeds on first deploy. After boot, manage providers and site features in **Settings → Admin**. Changes there apply on refresh without a redeploy. See [Admin Panel](../configure/admin-panel).
+:::
+
+## 1a. Railway + Synadia quick start (worker mode)
+
+If your Vercel app uses an external compute worker on Railway with Synadia Cloud (NGS):
+
+1. Deploy a Railway service from:
+ - `ghcr.io/richardr1126/openreader-compute-worker:refactor-ppdoclayoutv3-onnx-layout-parsing`
+2. Enable public networking on that Railway service and set:
+ - `COMPUTE_WORKER_URL=https://` (in Vercel)
+3. Use the same `COMPUTE_WORKER_TOKEN` value in both Vercel and Railway worker env vars.
+
+For complete Railway worker env vars (`NATS_*`, `S3_*`, health checks, and Synadia `.creds` guidance), see [Compute Worker (NATS JetStream)](./compute-worker).
+
+## 2. First-run admin configuration (recommended)
+
+After the first successful deploy and admin login, open **Settings → Admin** and configure:
+
+- **Shared providers**: create/edit your provider key(s) here (encrypted at rest).
+- **Site features**:
+ - `enableDocxConversion=false` on Vercel (`soffice` unavailable).
+ - `enableTtsProvidersTab=false` if you want shared-provider-only UX.
+ - `enableUserSignups=true` unless you explicitly want an invite-only deployment.
+ - `restrictUserApiKeys=true` to block user BYOK through the hosted server.
+ - `defaultTtsProvider=replicate` (or your preferred shared slug).
+ - `showAllProviderModels=false` if you want users locked to each provider's default model.
+ - `enableAudiobookExport=true`.
+
+## 3. Runtime JSON seed (optional)
+
+If you must pre-seed site features/providers at deploy time, use `RUNTIME_SEED_JSON` or `RUNTIME_SEED_JSON_PATH` (versioned JSON seed document). Prefer the admin panel for ongoing management.
+
+See [Environment Variables](../reference/environment-variables#runtime-json-seed-v4) for schema and examples.
+
+:::warning Auth recommendation
+Set both `BASE_URL` and `AUTH_SECRET` — they are required in v4+ and also required for the admin panel and for encrypting admin-stored TTS credentials.
+:::
+
+:::warning Rotating AUTH_SECRET invalidates admin-stored keys
+Admin-managed TTS provider keys are encrypted with a key derived from `AUTH_SECRET`. If you rotate `AUTH_SECRET` after the first deploy, you must re-enter each admin shared provider's API key from the UI.
+:::
+
+:::tip
+For all variables and defaults, see [Environment Variables](../reference/environment-variables).
+:::
+
+## 4. Database and data migrations
+
+Vercel deployments do not run `scripts/openreader-entrypoint.mjs`, so automatic startup migrations do not run there.
+
+- Run `pnpm migrate` in a controlled environment to apply Drizzle schema migrations to your Postgres DB.
+- Run `pnpm migrate-fs` only when migrating legacy local filesystem data (`docstore/documents_v1`, `docstore/audiobooks_v1`) into object storage + DB rows. Fresh Vercel deployments usually do not need this.
+
+## 5. FFmpeg packaging in Vercel functions
+
+`ffmpeg-static` binaries must be included in function traces. This repo already does that in `next.config.ts` via `outputFileTracingIncludes` for:
+
+- `/api/audiobook`
+- `/api/audiobook/chapter`
+- `/api/tts/segments/ensure`
+
+:::info
+`serverExternalPackages` should include `ffmpeg-static` so package paths resolve at runtime instead of being bundled into route output.
+:::
+
+If you change route paths or split handlers, update `outputFileTracingIncludes` accordingly.
+
+## 6. Function memory sizing
+
+FFmpeg workloads benefit from more memory/CPU. This repo includes:
+
+```json
+{
+ "$schema": "https://openapi.vercel.sh/vercel.json",
+ "functions": {
+ "app/api/audiobook/route.ts": { "memory": 3009 },
+ "app/api/tts/segments/ensure/route.ts": { "memory": 3009 }
+ }
+}
+```
+
+Adjust memory per route if your files are larger or your plan differs.
+
+## 7. Runtime expectations and caveats
+
+- Audiobook APIs require S3 configuration; otherwise they return `503`.
+- For production Vercel deploys, use `POSTGRES_URL` instead of SQLite.
+
+## 8. Smoke test after deploy
+
+1. Upload and read a PDF/EPUB document.
+2. Confirm sync/blob fetch works across refreshes/devices.
+3. Generate at least one audiobook chapter and play/download it.
+4. Verify worker-backed word highlighting and PDF parsing.
diff --git a/docs-site/versioned_docs/version-v4.0.2/docker-quick-start.md b/docs-site/versioned_docs/version-v4.0.2/docker-quick-start.md
new file mode 100644
index 0000000..8a725f8
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/docker-quick-start.md
@@ -0,0 +1,164 @@
+---
+title: Docker Quick Start
+---
+
+import Tabs from '@theme/Tabs';
+import TabItem from '@theme/TabItem';
+
+## Prerequisites
+
+- A recent Docker version installed
+- A TTS API server that OpenReader can reach:
+ - [Kokoro-FastAPI](./configure/tts-provider-guides/kokoro-fastapi)
+ - [KittenTTS-FastAPI](./configure/tts-provider-guides/kitten-tts-fastapi)
+ - [Orpheus-FastAPI](./configure/tts-provider-guides/orpheus-fastapi)
+ - [Replicate](./configure/tts-provider-guides/replicate)
+ - [DeepInfra](./configure/tts-provider-guides/deepinfra)
+ - [OpenAI](./configure/tts-provider-guides/openai)
+ - [Other OpenAI-compatible providers](./configure/tts-provider-guides/other)
+
+:::warning SeaweedFS Compatibility Note (April 16, 2026)
+OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds.
+`4.19` introduced intermittent `InternalError` responses on S3 `PutObject` in our upload flow.
+:::
+
+## Published images
+
+- App server: `ghcr.io/richardr1126/openreader:latest`
+- Compute worker (Optional): `ghcr.io/richardr1126/openreader-compute-worker:latest`
+- Legacy app alias: `ghcr.io/richardr1126/openreader-webui:latest`
+
+## 1. Start the Docker container
+
+
+
+
+Persistent storage, embedded SeaweedFS `weed mini`, required auth, optional library mount:
+
+```bash
+docker run --name openreader \
+ --restart unless-stopped \
+ -p 3003:3003 \
+ -p 8333:8333 \
+ -v openreader_docstore:/app/docstore \
+ -e API_BASE=http://host.docker.internal:8880/v1 \
+ -e API_KEY=none \
+ -e BASE_URL=http://localhost:3003 \
+ -e AUTH_SECRET=$(openssl rand -hex 32) \
+ -e ADMIN_EMAILS=you@example.com \
+ ghcr.io/richardr1126/openreader:latest
+```
+
+What this command enables:
+
+- `-p 3003:3003`: exposes the OpenReader web app/API.
+- `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download.
+- `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state.
+- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel).
+- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup.
+- `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
+
+
+
+
+Use this when the app should be reachable from other devices on your LAN:
+
+```bash
+docker run --name openreader \
+ --restart unless-stopped \
+ -p 3003:3003 \
+ -p 8333:8333 \
+ -v openreader_docstore:/app/docstore \
+ -e API_BASE=http://host.docker.internal:8880/v1 \
+ -e BASE_URL=http://:3003 \
+ -e AUTH_SECRET=$(openssl rand -hex 32) \
+ -e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \
+ -e USE_ANONYMOUS_AUTH_SESSIONS=true \
+ -e ADMIN_EMAILS=you@example.com \
+ ghcr.io/richardr1126/openreader:latest
+```
+
+Replace `YOUR_LAN_IP` with the Docker host IP address on your local network to allow access from other devices.
+
+What this command enables:
+
+- LAN access from phones/tablets/other computers via `http://:3003`.
+- `BASE_URL` points auth/session cookies and callbacks at your LAN URL.
+- `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin.
+- `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled.
+- `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that.
+- `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI.
+- `openreader_docstore` volume keeps data persistent across restarts.
+
+
+
+
+Auth required, embedded storage ephemeral, no library import:
+
+```bash
+docker run --name openreader \
+ --restart unless-stopped \
+ -p 3003:3003 \
+ -p 8333:8333 \
+ -e BASE_URL=http://localhost:3003 \
+ -e AUTH_SECRET=$(openssl rand -hex 32) \
+ ghcr.io/richardr1126/openreader:latest
+```
+
+What this command enables:
+
+- Fast startup with only the required auth env vars.
+- No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount.
+- The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode.
+- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel.
+
+
+
+
+:::tip Quick Tips
+- Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**.
+- `BASE_URL` and `AUTH_SECRET` are required in v4+. The admin panel requires auth.
+- Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings.
+- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON.
+- Use a `/app/docstore` mount if you want data to survive container/image replacement.
+- Startup automatically runs DB/storage migrations via the shared entrypoint.
+:::
+
+:::warning Port `8333` Exposure
+Expose `8333` for direct browser presigned upload/download with embedded SeaweedFS.
+
+If `8333` is not reachable from the browser, direct presigned access is unavailable. Uploads can still fall back to `/api/documents/blob/upload/fallback`, and document reads/downloads continue through `/api/documents/blob`.
+:::
+
+## 2. Configure settings in the app UI
+
+Visit [http://localhost:3003](http://localhost:3003) after startup.
+
+- If you set `ADMIN_EMAILS`, sign in with that email and open **Settings → Admin** to manage shared TTS providers and site feature flags for all users.
+- Per-user: set TTS provider/model in **Settings → TTS Provider**. API key/base URL inputs are shown only when `restrictUserApiKeys=false`.
+- Select the model voice from the voice dropdown.
+
+## 3. Update Docker image
+
+Legacy image compatibility: `ghcr.io/richardr1126/openreader-webui:latest` remains available as an alias.
+For external compute mode image details, see [Compute Worker (NATS JetStream)](./deploy/compute-worker).
+
+```bash
+docker stop openreader || true && \
+docker rm openreader || true && \
+docker image rm ghcr.io/richardr1126/openreader:latest || true && \
+docker pull ghcr.io/richardr1126/openreader:latest
+```
+
+:::tip
+If you use a mounted volume for `/app/docstore`, your persisted data remains after image updates.
+:::
+
+:::info Related Docs
+- [Environment Variables](./reference/environment-variables)
+- [Auth](./configure/auth)
+- [Admin Panel](./configure/admin-panel)
+- [Database](./configure/database)
+- [Object / Blob Storage](./configure/object-blob-storage)
+- [Migrations](./configure/migrations)
+:::
diff --git a/docs-site/versioned_docs/version-v4.0.2/introduction.md b/docs-site/versioned_docs/version-v4.0.2/introduction.md
new file mode 100644
index 0000000..6a43df7
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/introduction.md
@@ -0,0 +1,44 @@
+---
+id: intro
+title: Introduction
+slug: /
+---
+
+OpenReader is an open source text-to-speech document reader built with Next.js. It provides a read-along experience with narration for **EPUB, PDF, TXT, MD, and DOCX documents**.
+
+> Previously named **OpenReader-WebUI**.
+
+It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and custom OpenAI-compatible endpoints such as [Kokoro-FastAPI](https://github.com/remsky/Kokoro-FastAPI), [KittenTTS-FastAPI](https://github.com/richardr1126/KittenTTS-FastAPI), and [Orpheus-FastAPI](https://github.com/Lex-au/Orpheus-FastAPI).
+
+## ✨ Highlights
+
+- 🧱 **Layout-aware PDF Parsing**
+ - PP-DocLayoutV3 (ONNX) detects structured blocks with cross-page stitching and geometry-based highlighting for precise read-along sync and clean TTS segmentation
+- ⏱️ **Word-by-word Highlighting** via ONNX Whisper alignment
+ - Powered by the external compute worker control plane (NATS JetStream-backed)
+- ⚡ **Segment-based TTS Playback**
+ - Sentence-aware generation with cached audio segments, background preloading, and resumable playback across EPUB, PDF, TXT, MD, and DOCX
+- 🎯 **Multi-Provider TTS Support**
+ - Self-hosted: [**Kokoro-FastAPI**](https://github.com/remsky/Kokoro-FastAPI) (multi-voice combinations), [**KittenTTS-FastAPI**](https://github.com/richardr1126/KittenTTS-FastAPI), [**Orpheus-FastAPI**](https://github.com/Lex-au/Orpheus-FastAPI), or any custom OpenAI-compatible endpoint
+ - Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others)
+- 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation
+- 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync
+- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows
+- 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls
+
+## 🧭 Key Docs
+
+- [Docker Quick Start](./docker-quick-start)
+- [Local Development](./deploy/local-development)
+- [Vercel Deployment](./deploy/vercel-deployment)
+- [Environment Variables](./reference/environment-variables)
+- [Auth](./configure/auth)
+- [Database](./configure/database)
+- [Object / Blob Storage](./configure/object-blob-storage)
+- [Migrations](./configure/migrations)
+- [Server Library Import](./configure/server-library-import)
+- [TTS Providers](./configure/tts-providers)
+
+## Source Repository
+
+- GitHub: [richardr1126/openreader](https://github.com/richardr1126/openreader)
diff --git a/docs-site/versioned_docs/version-v4.0.2/reference/environment-variables.md b/docs-site/versioned_docs/version-v4.0.2/reference/environment-variables.md
new file mode 100644
index 0000000..45786fc
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/reference/environment-variables.md
@@ -0,0 +1,465 @@
+---
+title: Environment Variables
+toc_max_heading_level: 3
+---
+
+This page is the source-of-truth reference for OpenReader environment variables.
+
+:::note Recommended configuration path
+Use **Settings → Admin** as the primary source of truth for shared providers and runtime site features.
+`API_BASE` / `API_KEY` are optional one-time provider bootstrap seeds.
+Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`.
+:::
+
+## Quick Reference Table
+
+| Variable | Area | Default | When to set |
+| --- | --- | --- | --- |
+| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs |
+| `LOG_LEVEL` | Runtime logging | `info` | Set app server log level |
+| `API_BASE` | TTS provider bootstrap seed | unset | Optional first-boot base URL for `default-openai` |
+| `API_KEY` | TTS provider bootstrap seed | unset | Optional first-boot API key for `default-openai` |
+| `BASE_URL` | Auth | unset | Required at startup |
+| `AUTH_SECRET` | Auth | unset | Required at startup |
+| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
+| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to allow anonymous auth sessions |
+| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
+| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
+| `ADMIN_EMAILS` | Admin | empty | Comma-separated emails auto-promoted to admin |
+| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
+| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only |
+| `WEED_MINI_DIR` | Storage | `docstore/seaweedfs` | Override embedded SeaweedFS data directory |
+| `WEED_MINI_WAIT_SEC` | Storage | `20` | Tune SeaweedFS startup wait timeout |
+| `S3_ACCESS_KEY_ID` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials |
+| `S3_SECRET_ACCESS_KEY` | Storage | auto-generated in embedded mode | Set explicitly for stable/external credentials |
+| `S3_BUCKET` | Storage | `openreader-documents` in embedded mode | Required for external S3-compatible storage |
+| `S3_REGION` | Storage | `us-east-1` in embedded mode | Required for external S3-compatible storage |
+| `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) |
+| `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement |
+| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix |
+| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
+| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
+| `EMBEDDED_COMPUTE_WORKER_PORT` | Compute | `8081` | Override embedded worker bind port |
+| `EMBEDDED_NATS_PORT` | Compute | `4222` | Override embedded NATS client port |
+| `EMBEDDED_NATS_MONITOR_PORT` | Compute | `8222` | Override embedded NATS monitor port |
+| `EMBEDDED_NATS_STORE_DIR` | Compute | `docstore/nats/jetstream` | Override embedded JetStream storage directory |
+| `NATS_URL` | Compute | `nats://127.0.0.1:4222` in embedded startup | Override embedded startup or set standalone worker URL |
+| `COMPUTE_LOG_LEVEL` | Compute | `info` | Compute worker log level |
+| `COMPUTE_JOB_CONCURRENCY` | Compute | `1` | Shared compute concurrency cap |
+| `COMPUTE_WHISPER_TIMEOUT_MS` | Compute | `30000` | Whisper alignment timeout budget |
+| `COMPUTE_PDF_TIMEOUT_MS` | Compute | `300000` | PDF parse timeout budget |
+| `COMPUTE_OP_STALE_MS` | Compute | `max(30m, 4x max compute timeout)` | Shared stale window for compute op replacement |
+| `WHISPER_MODEL_BASE_URL` | Compute model source | onnx-community default | Override Whisper ONNX model base URL |
+| `PDF_LAYOUT_MODEL_BASE_URL` | Compute model source | PP-DocLayoutV3 default | Override PDF layout ONNX model base URL |
+| `COMPUTE_WORKER_URL` | External compute mode | unset | Set only for standalone external worker mode |
+| `COMPUTE_WORKER_TOKEN` | External compute mode | unset | Required for standalone external worker auth |
+| `FFMPEG_BIN` | Audio runtime | auto-detected (`ffmpeg-static`) | Override ffmpeg binary path |
+| `DISABLE_AUTH_RATE_LIMIT` | Auth request throttling | `false` | Set `true` to disable Better Auth request rate limiting |
+| `ENABLE_TEST_NAMESPACE` | Testing/CI | unset | Honor `x-openreader-test-namespace` header in production builds |
+| `RUN_DRIZZLE_MIGRATIONS` | DB migrations | `true` | Set `false` to skip startup Drizzle migrations |
+| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
+| `RUNTIME_SEED_JSON_PATH` | Runtime JSON seed | unset | Absolute path to first-boot JSON seed document |
+| `RUNTIME_SEED_JSON` | Runtime JSON seed | unset | Inline first-boot JSON seed document |
+
+## Runtime Logging
+
+### LOG_FORMAT
+
+Controls log output format for server-side Pino loggers.
+
+- Default: `pretty`
+- Allowed values: `pretty`, `json`
+- Applies to app server and compute worker
+
+### LOG_LEVEL
+
+App server log level.
+
+- Default: `info`
+
+## TTS Provider and Request Behavior
+
+### API_BASE
+
+Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider.
+
+- Example: `http://host.docker.internal:8880/v1`
+- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set.
+- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
+
+### API_KEY
+
+Optional first-boot bootstrap API key for the auto-created `default-openai` shared provider.
+
+- Read only for provider bootstrap when shared providers are empty.
+- Stored encrypted at rest after bootstrap.
+- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**.
+
+### TTS Daily Rate Limiting (Runtime Settings)
+
+Managed as runtime config in **Settings → Admin → Site features**.
+
+- `disableTtsRateLimit` default: `true` (daily TTS limits disabled)
+- `ttsDailyLimitAnonymous` default: `50000`
+- `ttsDailyLimitAuthenticated` default: `500000`
+- `ttsIpDailyLimitAnonymous` default: `100000`
+- `ttsIpDailyLimitAuthenticated` default: `1000000`
+
+### TTS Upstream Settings (Runtime Settings)
+
+Managed as runtime config in **Settings → Admin → Site features → TTS upstream**.
+
+- `ttsUpstreamMaxRetries` default: `2`
+- `ttsUpstreamTimeoutMs` default: `285000`
+- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB)
+- `ttsCacheTtlMs` default: `1800000` (30 minutes)
+
+There are no dedicated env vars for these runtime settings.
+
+## Auth and Identity
+
+### BASE_URL
+
+Required external base URL for this OpenReader instance.
+
+- Required at startup
+- Example: `http://localhost:3003` or `https://reader.example.com`
+
+### AUTH_SECRET
+
+Required secret key used by auth/session handling.
+
+- Required at startup
+- Generate with `openssl rand -hex 32`
+
+### AUTH_TRUSTED_ORIGINS
+
+Additional allowed origins for auth requests.
+
+- Comma-separated list
+- `BASE_URL` origin is trusted automatically
+
+### USE_ANONYMOUS_AUTH_SESSIONS
+
+Controls whether auth-enabled deployments can create/use anonymous sessions.
+
+- Default: `false`
+
+### GITHUB_CLIENT_ID
+
+GitHub OAuth client ID.
+
+- Set with `GITHUB_CLIENT_SECRET`
+
+### GITHUB_CLIENT_SECRET
+
+GitHub OAuth client secret.
+
+- Set with `GITHUB_CLIENT_ID`
+
+### ADMIN_EMAILS
+
+Comma-separated list of email addresses auto-promoted to admin.
+
+- Requires auth to be enabled
+- Admins can manage shared providers and runtime site features in-app
+
+## Database and Object Blob Storage
+
+### POSTGRES_URL
+
+Switches metadata/auth storage from SQLite to Postgres.
+
+- Unset: SQLite at `docstore/sqlite3.db`
+- Set: Postgres mode
+
+### USE_EMBEDDED_WEED_MINI
+
+Controls embedded SeaweedFS startup.
+
+- Default behavior: treated as enabled when unset
+- Set `false` to rely on external S3-compatible storage
+
+### WEED_MINI_DIR
+
+Data directory for embedded SeaweedFS (`weed mini`).
+
+- Default: `docstore/seaweedfs`
+
+### WEED_MINI_WAIT_SEC
+
+Max wait time for embedded SeaweedFS startup.
+
+- Default: `20`
+
+### S3_ACCESS_KEY_ID
+
+S3 access key.
+
+- Optional in embedded mode (auto-generated when unset)
+- Required for external S3 mode
+
+### S3_SECRET_ACCESS_KEY
+
+S3 secret key.
+
+- Optional in embedded mode (auto-generated when unset)
+- Required for external S3 mode
+
+### S3_BUCKET
+
+S3 bucket name.
+
+- Embedded default: `openreader-documents`
+- Required for external S3 mode
+
+### S3_REGION
+
+S3 region.
+
+- Embedded default: `us-east-1`
+- Required for external S3 mode
+
+### S3_ENDPOINT
+
+Custom endpoint for S3-compatible providers.
+
+- Optional for AWS
+- Typical for MinIO/SeaweedFS/R2
+
+### S3_FORCE_PATH_STYLE
+
+Force path-style S3 URLs.
+
+- Embedded default: `true`
+
+### S3_PREFIX
+
+Object key prefix.
+
+- Default: `openreader`
+
+## Library Import
+
+### IMPORT_LIBRARY_DIR
+
+Single library source directory.
+
+### IMPORT_LIBRARY_DIRS
+
+Multiple library roots.
+
+- Supports comma, colon, or semicolon-separated values
+
+## Compute Worker and Model Configuration
+
+### EMBEDDED_COMPUTE_WORKER_PORT
+
+Embedded compute worker port.
+
+- Default: `8081`
+
+### EMBEDDED_NATS_PORT
+
+Embedded NATS client port.
+
+- Default: `4222`
+
+### EMBEDDED_NATS_MONITOR_PORT
+
+Embedded NATS monitor port.
+
+- Default: `8222`
+
+### EMBEDDED_NATS_STORE_DIR
+
+Embedded NATS JetStream data directory.
+
+- Default: `docstore/nats/jetstream`
+
+### NATS_URL
+
+NATS URL used by compute services.
+
+- Embedded startup default: `nats://127.0.0.1:4222`
+
+### COMPUTE_LOG_LEVEL
+
+Compute worker log level.
+
+- Default: `info`
+
+### COMPUTE_JOB_CONCURRENCY
+
+Max concurrent compute jobs per worker.
+
+- Default: `1`
+
+### COMPUTE_WHISPER_TIMEOUT_MS
+
+Whisper alignment timeout budget.
+
+- Default: `30000`
+
+### COMPUTE_PDF_TIMEOUT_MS
+
+PDF parse timeout budget.
+
+- Default: `300000`
+
+### COMPUTE_OP_STALE_MS
+
+Stale operation window before worker/app cleanup logic can replace an op.
+
+- Default: `max(30m, 4x max compute timeout)`
+
+### WHISPER_MODEL_BASE_URL
+
+Base URL for Whisper ONNX model downloads.
+
+### PDF_LAYOUT_MODEL_BASE_URL
+
+Base URL for PDF layout model downloads.
+
+### COMPUTE_WORKER_URL
+
+External compute worker URL.
+
+- Leave unset for embedded worker mode
+
+### COMPUTE_WORKER_TOKEN
+
+Shared token for app-to-external-worker requests.
+
+## Compute PDF Parsing Rate Limiting (Runtime Settings)
+
+Managed as runtime config in **Settings → Admin → Site features**.
+
+- `disableComputeRateLimit` default: `true`
+- `computeParseBurstMax` default: `8`
+- `computeParseBurstWindowSec` default: `60`
+- `computeParseSustainedMax` default: `24`
+- `computeParseSustainedWindowSec` default: `600`
+- `maxUploadMb` default: `200`
+
+There are no dedicated env vars for these runtime settings.
+
+## Audio Runtime
+
+### FFMPEG_BIN
+
+Override ffmpeg binary path used for audio processing.
+
+- Used by audiobook processing routes and compute worker Whisper audio decode.
+
+## Testing and CI
+
+### DISABLE_AUTH_RATE_LIMIT
+
+Disables Better Auth request rate limiting.
+
+- Default: `false`
+
+### ENABLE_TEST_NAMESPACE
+
+Enables the `x-openreader-test-namespace` header path in production builds.
+
+## Migration Controls
+
+### RUN_DRIZZLE_MIGRATIONS
+
+Controls startup Drizzle schema migrations.
+
+- Default: `true`
+- Set `false` to skip startup migration run
+
+### RUN_FS_MIGRATIONS
+
+Controls startup filesystem-to-S3/DB migration pass.
+
+- Default: `true`
+- Set `false` to skip startup storage migration run
+
+## Runtime JSON Seed (v4)
+
+### RUNTIME_SEED_JSON_PATH
+
+Path-based first-boot seed document.
+
+- If both `RUNTIME_SEED_JSON_PATH` and `RUNTIME_SEED_JSON` are set, path wins.
+- Value must point to a JSON file readable by the app process.
+
+### RUNTIME_SEED_JSON
+
+Inline first-boot seed document.
+
+- Used only when `RUNTIME_SEED_JSON_PATH` is unset.
+- Must be a JSON object with `version: 1`.
+
+Supported top-level keys:
+
+- `version` (required, must be `1`)
+- `runtimeConfig` (optional object, strict-validated against runtime schema)
+- `providers` (optional array of shared provider seed entries)
+
+Example:
+
+```json
+{
+ "version": 1,
+ "runtimeConfig": {
+ "enableUserSignups": true,
+ "restrictUserApiKeys": true,
+ "defaultTtsProvider": "custom-openai",
+ "enableTtsProvidersTab": true,
+ "enableAudiobookExport": true,
+ "enableDocxConversion": true,
+ "showAllProviderModels": true,
+ "disableTtsRateLimit": true,
+ "ttsDailyLimitAnonymous": 50000,
+ "ttsDailyLimitAuthenticated": 500000,
+ "ttsIpDailyLimitAnonymous": 100000,
+ "ttsIpDailyLimitAuthenticated": 1000000,
+ "ttsCacheMaxSizeBytes": 268435456,
+ "ttsCacheTtlMs": 1800000,
+ "ttsUpstreamMaxRetries": 2,
+ "ttsUpstreamTimeoutMs": 285000,
+ "disableComputeRateLimit": true,
+ "computeParseBurstMax": 8,
+ "computeParseBurstWindowSec": 60,
+ "computeParseSustainedMax": 24,
+ "computeParseSustainedWindowSec": 600,
+ "maxUploadMb": 200,
+ "changelogFeedUrl": "https://docs.openreader.richardr.dev/changelog/manifest.json"
+ },
+ "providers": [
+ {
+ "slug": "default-openai",
+ "displayName": "Default (seeded)",
+ "providerType": "custom-openai",
+ "baseUrl": "http://localhost:8880/v1",
+ "apiKey": "api_key_optional",
+ "defaultModel": "kokoro",
+ "enabled": true
+ }
+ ]
+}
+```
+
+Provider fallback behavior:
+
+- If the JSON seed includes `providers` (including an empty array), `API_BASE` / `API_KEY` fallback is skipped.
+- If the JSON seed does not include a `providers` key, the legacy `API_BASE` / `API_KEY` bootstrap fallback can still create `default-openai` when provider rows are empty.
+
+Precedence summary:
+
+- Runtime reads: admin DB runtime rows override built-in defaults.
+- Seed input (`RUNTIME_SEED_JSON*`) only populates missing runtime rows on first boot; it does not overwrite existing/admin-edited rows.
+- Provider bootstrap order: JSON `providers` section > `API_BASE`/`API_KEY` fallback > no provider bootstrap.
+
+## Related
+
+- [Admin Panel](../configure/admin-panel)
+- [TTS Providers](../configure/tts-providers)
+- [Local Development](../deploy/local-development)
+- [Vercel Deployment](../deploy/vercel-deployment)
diff --git a/docs-site/versioned_docs/version-v4.0.2/reference/stack.md b/docs-site/versioned_docs/version-v4.0.2/reference/stack.md
new file mode 100644
index 0000000..ea847e6
--- /dev/null
+++ b/docs-site/versioned_docs/version-v4.0.2/reference/stack.md
@@ -0,0 +1,70 @@
+---
+title: Stack
+---
+
+## Framework
+
+- [Next.js](https://nextjs.org/) 15 (App Router, Turbopack in dev)
+- [React](https://react.dev/) 19
+- [TypeScript](https://www.typescriptlang.org/)
+- [pnpm](https://pnpm.io/) workspaces monorepo
+
+## Containerization and runtime
+
+- [Docker](https://www.docker.com/) (linux/amd64 and linux/arm64)
+- Shared entrypoint that runs DB migrations by default and can bootstrap embedded SeaweedFS before app startup
+
+## Next.js client
+
+- UI: [Tailwind CSS](https://tailwindcss.com), [Headless UI](https://headlessui.com), [@tailwindcss/typography](https://tailwindcss.com/docs/typography-plugin)
+- Interactions: `react-dnd`, `react-dropzone`
+- Server state: [TanStack Query](https://tanstack.com/query) (React Query v5)
+- Authentication: [Better Auth](https://www.better-auth.com/) client SDK
+- Local storage/cache: [Dexie.js](https://dexie.org/) (IndexedDB)
+- Audio playback: [Howler.js](https://howlerjs.com/)
+- Notifications: `react-hot-toast`
+- Document rendering:
+ - PDF: [react-pdf](https://github.com/wojtekmaj/react-pdf), [pdf.js](https://mozilla.github.io/pdf.js/)
+ - EPUB: [react-reader](https://github.com/gerhardsletten/react-reader), [epubjs](https://github.com/futurepress/epub.js/)
+ - Markdown/Text: [react-markdown](https://github.com/remarkjs/react-markdown), [remark-gfm](https://github.com/remarkjs/remark-gfm)
+- Text preprocessing/matching: [compromise](https://github.com/spencermountain/compromise), [cmpstr](https://github.com/remsky/cmpstr)
+- Analytics: [Vercel Analytics](https://vercel.com/analytics)
+
+## Next.js server
+
+- APIs: Route Handlers for sync, blob/content access, migrations, audiobook export, TTS/Whisper proxying
+- State sync: request-based today (not realtime push updates)
+- Authentication: [Better Auth](https://www.better-auth.com/) server handlers/adapters with anonymous session support
+- Metadata DB: [Drizzle ORM](https://orm.drizzle.team/) with SQLite (`better-sqlite3`) by default and optional Postgres (`pg`)
+ - App tables are manually maintained in Drizzle schema files
+ - Auth tables are auto-generated by the [Better Auth](https://www.better-auth.com/) CLI and migrated alongside app tables via Drizzle
+- Blob storage: embedded [SeaweedFS](https://github.com/seaweedfs/seaweedfs) (`weed mini`) by default, or external S3-compatible storage via AWS SDK v3
+- TTS providers: OpenAI-compatible API (`openai` SDK), [Replicate](https://replicate.com/) (`replicate` client), DeepInfra, and custom OpenAI-compatible endpoints — credentials are encrypted at rest
+- Audio pipeline: [ffmpeg](https://ffmpeg.org/) (`ffmpeg-static`) for audiobook assembly, `archiver` for export packaging
+- Utilities: `lru-cache` for in-process caching, `fast-xml-parser` for EPUB/XML parsing, `uuid` for identifier generation, `zod` for schema validation
+
+## External compute worker (optional)
+
+Monorepo packages under `compute/`:
+
+- **`@openreader/compute-core`** — ONNX runtime lifecycle, model management, and inference logic shared by compute worker runtime + app/worker contracts
+ - ONNX runtime: `onnxruntime-node` with `@huggingface/tokenizers`
+ - Whisper alignment: `onnx-community/whisper-base_timestamped` (q4) for word-level timestamps
+ - PDF layout: `Bei0001/PP-DocLayoutV3-ONNX` for document block detection and layout parsing
+ - PDF rendering: `pdfjs-dist`, `@napi-rs/canvas` for server-side page rasterization
+ - Utilities: `jszip`, `ffmpeg-static`
+- **`@openreader/compute-worker`** — standalone Node.js worker service
+ - HTTP server: [Fastify](https://fastify.dev/) v5
+ - Job queue + state: [NATS](https://nats.io/) JetStream WorkQueue pull consumers + NATS KV (`jobs.whisper`, `jobs.layout`)
+ - Storage: AWS SDK v3 S3 client for reading/writing blobs
+ - Logging: [Pino](https://getpino.io/)
+ - Validation: [Zod](https://zod.dev/)
+- Heavy compute is worker-backed via `COMPUTE_WORKER_URL` + `COMPUTE_WORKER_TOKEN` (remote queue via HTTP + NATS)
+
+## Tooling and testing
+
+- ESLint
+- TypeScript
+- [Playwright](https://playwright.dev/) end-to-end tests
+- Drizzle migration/generation scripts
+- [Docusaurus](https://docusaurus.io/) documentation site (`docs-site/`)
diff --git a/docs-site/versioned_sidebars/version-v4.0.2-sidebars.json b/docs-site/versioned_sidebars/version-v4.0.2-sidebars.json
new file mode 100644
index 0000000..06e9160
--- /dev/null
+++ b/docs-site/versioned_sidebars/version-v4.0.2-sidebars.json
@@ -0,0 +1,78 @@
+{
+ "tutorialSidebar": [
+ "intro",
+ {
+ "type": "doc",
+ "id": "docker-quick-start",
+ "label": "🐳 Docker Quick Start"
+ },
+ {
+ "type": "category",
+ "label": "⚙️ Configure",
+ "items": [
+ {
+ "type": "category",
+ "label": "🔊 TTS Providers",
+ "link": {
+ "type": "doc",
+ "id": "configure/tts-providers"
+ },
+ "items": [
+ "configure/tts-provider-guides/kokoro-fastapi",
+ "configure/tts-provider-guides/kitten-tts-fastapi",
+ "configure/tts-provider-guides/orpheus-fastapi",
+ "configure/tts-provider-guides/replicate",
+ "configure/tts-provider-guides/deepinfra",
+ "configure/tts-provider-guides/openai",
+ "configure/tts-provider-guides/other"
+ ]
+ },
+ {
+ "type": "doc",
+ "id": "configure/auth",
+ "label": "🔐 Auth"
+ },
+ {
+ "type": "doc",
+ "id": "configure/admin-panel",
+ "label": "🛡️ Admin Panel"
+ },
+ {
+ "type": "doc",
+ "id": "configure/server-library-import",
+ "label": "📥 Server Library Import"
+ },
+ "configure/tts-rate-limiting",
+ "configure/database",
+ "configure/object-blob-storage",
+ "configure/migrations"
+ ]
+ },
+ {
+ "type": "category",
+ "label": "🚀 Deploy",
+ "items": [
+ "deploy/local-development",
+ "deploy/compute-worker",
+ "deploy/vercel-deployment"
+ ]
+ },
+ {
+ "type": "category",
+ "label": "Reference",
+ "items": [
+ "reference/environment-variables",
+ "reference/stack"
+ ]
+ },
+ {
+ "type": "category",
+ "label": "About",
+ "items": [
+ "about/support-and-contributing",
+ "about/acknowledgements",
+ "about/license"
+ ]
+ }
+ ]
+}
diff --git a/docs-site/versions.json b/docs-site/versions.json
index dcf73c6..eb0a3c7 100644
--- a/docs-site/versions.json
+++ b/docs-site/versions.json
@@ -1,4 +1,5 @@
[
+ "v4.0.2",
"v4.0.1",
"v4.0.0",
"v3.0.0",