diff --git a/.env.example b/.env.example index 69c8ed0..f9612d0 100644 --- a/.env.example +++ b/.env.example @@ -11,45 +11,25 @@ # NOTE: On first boot, the server auto-seeds these values into a "default-openai" # admin-managed shared provider (DB-backed, encrypted at rest). After that, the # admin UI is authoritative and changing these env vars has no effect. You may -# remove them once the seed has run. See Settings → Admin → Shared providers. +# remove them once the seed has run. Auth must be configured for this seed path. +# See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 API_KEY=api_key_optional -# (Optional) TTS request/cache tuning (defaults shown below; leave unset to use defaults) -# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB -# TTS_CACHE_TTL_MS=1800000 # 30 minutes -# TTS_MAX_RETRIES=2 -# TTS_RETRY_INITIAL_MS=250 -# TTS_RETRY_MAX_MS=2000 -# TTS_RETRY_BACKOFF=2 -# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds - -# Auth configuration (recommended; required for admin features) -# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set +# Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32` AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) -# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`) +# (Optional) Allow anonymous auth sessions (default: `false`) # USE_ANONYMOUS_AUTH_SESSIONS=false # (Optional) Sign in w/ GitHub Configuration # GITHUB_CLIENT_ID= # GITHUB_CLIENT_SECRET= -# (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`) -# DISABLE_AUTH_RATE_LIMIT=false - -# (Optional) Honor the x-openreader-test-namespace header on a production build. -# This is test/CI scaffolding and MUST stay unset on real deployments; it is set -# automatically by the Playwright web server. Non-production builds honor it -# without this flag. -# ENABLE_TEST_NAMESPACE=false # (Optional) Comma-separated list of emails that are auto-promoted to admin. -# Admins see the "Admin" tab in Settings (TTS shared providers + site features). -# Demotion is automatic: removing an email here demotes the user on next login. -# Requires auth to be enabled (AUTH_SECRET + BASE_URL). ADMIN_EMAILS= -# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables. +# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. # POSTGRES_URL= @@ -74,60 +54,37 @@ S3_BUCKET= # IMPORT_LIBRARY_DIR= # IMPORT_LIBRARY_DIRS= -# Compute -# Embedded/local (default): leave COMPUTE_WORKER_URL empty. -# External worker: set COMPUTE_WORKER_URL + COMPUTE_WORKER_TOKEN. -# External worker split: -# - App side (this root `.env`): set only routing/auth + shared timeout/stale overrides. -# - Worker side (`compute/worker/.env*` or worker platform env): set NATS_*, S3_*, model base URLs, and worker tuning. -# Details: docs/deploy/compute-worker -# COMPUTE_WORKER_URL=http://localhost:8081 -# COMPUTE_WORKER_TOKEN=local-compute-token -# Optional embedded startup controls: +# Compute worker configuration +# (Optional) Embedded compute worker (automatic startup) for local compute tasks (defaults shown below) # EMBEDDED_COMPUTE_WORKER_PORT=8081 # EMBEDDED_NATS_PORT=4222 # EMBEDDED_NATS_MONITOR_PORT=8222 # EMBEDDED_NATS_STORE_DIR=docstore/nats/jetstream -# `NATS_URL` here is for embedded startup only. In external worker mode, set `NATS_URL` on the worker service env. # NATS_URL=nats://127.0.0.1:4222 -# Optional worker log level: # COMPUTE_LOG_LEVEL=info -# Optional shared compute tuning: # COMPUTE_JOB_CONCURRENCY=1 # COMPUTE_WHISPER_TIMEOUT_MS=30000 # COMPUTE_PDF_TIMEOUT_MS=300000 # COMPUTE_OP_STALE_MS=1800000 - -# Optional Whisper ONNX base URL override (must contain all expected files) -# Default expected Whisper variant is q4: -# - onnx/encoder_model_q4.onnx -# - onnx/decoder_model_merged_q4.onnx -# - onnx/decoder_with_past_model_q4.onnx # WHISPER_MODEL_BASE_URL=https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main - -# Optional PDF layout ONNX base URL override (must contain all expected files) # PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main +# (Optional) External compute worker config (see docs/deploy/compute-worker) +# COMPUTE_WORKER_URL=http://localhost:8081 +# COMPUTE_WORKER_TOKEN=local-compute-token -# (Optional) Override ffmpeg binary path used for audiobook processing +# (Optional) Override ffmpeg binary path used for audio processing # FFMPEG_BIN= -# (Optional) Client feature flags — seeded into the admin-managed runtime -# config on first boot, then ignored. Edit values from Settings → Admin → -# Site features instead of redeploying. SSR-injected so they take effect -# without rebuilding (unlike the old build-time public-env pattern). -# RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true -# RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true -# RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true -# RUNTIME_SEED_ENABLE_USER_SIGNUPS=true -# RUNTIME_SEED_RESTRICT_USER_API_KEYS=true -# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai -# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json -# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true -# RUNTIME_SEED_DISABLE_TTS_LIMIT=true -# RUNTIME_SEED_DISABLE_COMPUTE_LIMIT=true +# (Optional) Test/dev overrides +# DISABLE_AUTH_RATE_LIMIT=false +# ENABLE_TEST_NAMESPACE=false -# Migrations configuration # (Optional) Skip automatic startup migrations when set to `false` (default: `true`) # RUN_DRIZZLE_MIGRATIONS=true # (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`) # RUN_FS_MIGRATIONS=true + +# (Optional) v4 JSON seed for first-boot runtime config + shared providers. +# If both are set, RUNTIME_SEED_JSON_PATH is used. +# RUNTIME_SEED_JSON_PATH=/absolute/path/to/openreader-seed.json +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"enableDestructiveDeleteActions":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}]} diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 723e0eb..7ed850a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -11,7 +11,10 @@ jobs: runs-on: ubuntu-latest env: USE_EMBEDDED_WEED_MINI: true + ENABLE_TEST_NAMESPACE: true BASE_URL: http://127.0.0.1:3003 + AUTH_SECRET: ci-auth-secret-change-me + USE_ANONYMOUS_AUTH_SESSIONS: true S3_ENDPOINT: http://127.0.0.1:8333 steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index 44ab3b7..6ded1a6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. - 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync. -- 🐳 **Self-host friendly** — Docker (amd64/arm64), optional auth, and automatic startup migrations. +- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations. ## 🚀 Start Here diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index f8d32df..79029c3 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -82,9 +82,9 @@ Word-by-word highlighting and PDF layout parsing capability are controlled by co Each row shows a source badge: -- **from env** — the value was migrated from the corresponding `RUNTIME_SEED_*` env var on first boot. Editing it in the UI flips the source to **admin**. -- **admin** — explicit admin override. Use **Reset** on the row to clear it back to the env-default state. -- **default** — neither env nor admin set; uses the built-in default. +- **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. @@ -105,18 +105,31 @@ The **Disable TTS daily rate limiting** and **Disable PDF parsing rate limiting* - 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 -The future-direction goal is to remove `RUNTIME_SEED_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely: +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: - - Each `RUNTIME_SEED_*` setting appears as **from env**. + - 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 the env vars from your `.env`. +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 the env vars indefinitely if you prefer; they're only read on the first boot when the corresponding DB row is absent, so there's no harm in leaving them around. +You can keep `API_BASE` / `API_KEY` if you intentionally want bootstrap fallback behavior on empty provider tables. ## How keys are protected @@ -133,4 +146,4 @@ Because the encryption key for `admin_providers` is derived from `AUTH_SECRET`, - [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` and the legacy flags that the admin UI replaces. +- [Environment Variables](../reference/environment-variables) — `ADMIN_EMAILS`, provider bootstrap vars, and runtime JSON seed. diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 34e5053..6332449 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -6,23 +6,21 @@ This page covers application-level configuration for provider access and authent ## Auth behavior -- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. -- Remove either value to disable auth. +- `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 effectively has three common runtime modes: +OpenReader has two common runtime modes: -- **Auth disabled** (`BASE_URL` or `AUTH_SECRET` unset): no admin panel. Shared providers can still exist via first-boot seeding (`API_KEY`/`API_BASE`), but you cannot manage them in-app. - **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 -When auth is enabled, you can designate one or more users as admins via the `ADMIN_EMAILS` env var: +You can designate one or more users as admins via the `ADMIN_EMAILS` env var: ```env ADMIN_EMAILS=alice@example.com,bob@example.com @@ -39,7 +37,7 @@ Admin assignment is reconciled on every session resolution, so removing an email - `/` is a public landing/onboarding page and remains indexable. - `/app` is the protected app home (document list and uploader UI). -- If auth is enabled and a valid session exists (including anonymous), visiting `/` redirects to `/app`. +- 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 @@ -60,11 +58,7 @@ Admin assignment is reconciled on every session resolution, so removing an email - 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. -### Auth disabled - -- Settings and reading progress stay local in the browser (Dexie/IndexedDB). -- This avoids no-auth cross-browser conflicts, but there is no cross-device sync. - ## 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/docs/configure/database.md b/docs-site/docs/configure/database.md index d878e9e..b87829a 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -54,6 +54,5 @@ For database variable behavior, see [Environment Variables](../reference/environ ## State sync summary -- With auth enabled, settings and reading progress are stored in SQL and synced from the app. -- With auth disabled, settings and reading progress remain local in the browser. +- 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/docs/configure/tts-providers.md b/docs-site/docs/configure/tts-providers.md index 1fa5961..fac1813 100644 --- a/docs-site/docs/configure/tts-providers.md +++ b/docs-site/docs/configure/tts-providers.md @@ -11,7 +11,7 @@ OpenReader routes all TTS requests through the Next.js server to an OpenAI-compa **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**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported for no-admin bootstrap flows. +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 diff --git a/docs-site/docs/configure/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md index fedf7f0..967124c 100644 --- a/docs-site/docs/configure/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -8,7 +8,6 @@ This page explains OpenReader's TTS character rate limiting controls. - TTS rate limiting is disabled by default. - Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**. -- Optional first-boot seed: `RUNTIME_SEED_DISABLE_TTS_LIMIT=true`. - Limits are enforced per day in UTC. - Enforcement applies only when auth is enabled. @@ -29,10 +28,11 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta - `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling. - `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits. -## Runtime config + seed var +## Runtime config -- First-boot seed toggle: `RUNTIME_SEED_DISABLE_TTS_LIMIT` (default: `true`) +- `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 diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 17830a9..338aeb0 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -243,18 +243,7 @@ Use the same ownership split: Use one of these `.env` mode templates: - - -```env -API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none -# Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. -# (Admin panel is unavailable without auth.) -# API_BASE/API_KEY seed a shared default provider if you want shared mode. -``` - - - + ```env API_BASE=http://host.docker.internal:8880/v1 @@ -286,6 +275,8 @@ ADMIN_EMAILS=you@example.com 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 @@ -301,6 +292,8 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```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 @@ -317,11 +310,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key :::note Env vars vs. admin panel -On first boot, `API_KEY` / `API_BASE` and any `RUNTIME_SEED_*` flags you've set get auto-seeded into the admin-managed runtime config (DB-backed, keys encrypted at rest). After that, the admin UI is authoritative and editing those env vars no longer changes app behavior. See [Admin Panel](../configure/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** when auth/admin is enabled, or via legacy first-boot seed `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` for no-admin bootstrap flows). +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 diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 86a9b3a..c830ac5 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -86,14 +86,14 @@ After the first successful deploy and admin login, open **Settings → Admin** a - `showAllProviderModels=false` if you want users locked to each provider's default model. - `enableAudiobookExport=true`. -## 3. Legacy first-boot seed (optional) +## 3. Runtime JSON seed (optional) -If you must pre-seed site features via environment variables, the legacy `RUNTIME_SEED_*` seeds are still supported on first boot only. Prefer the admin panel for ongoing management. +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#legacy-first-boot-runtime-seeds-optional) for the complete legacy seed list. +See [Environment Variables](../reference/environment-variables#runtime-json-seed-v4) for schema and examples. :::warning Auth recommendation -For internet-exposed Vercel deployments, set both `BASE_URL` and `AUTH_SECRET` — they are also required for the admin panel and for encrypting admin-stored TTS credentials. Running without auth is possible, but not recommended for public environments. +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 diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index f61ea07..d51d351 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -33,7 +33,7 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds. -Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount: +Persistent storage, embedded SeaweedFS `weed mini`, required auth, optional library mount: ```bash docker run --name openreader \ @@ -57,7 +57,7 @@ What this command enables: - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. - `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source. - `-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=...`: together they turn on auth/session mode for local sign-in flows. +- `-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. @@ -95,21 +95,23 @@ What this command enables: -Auth disabled, embedded storage ephemeral, no library import: +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: -- Fastest startup with no extra env vars. +- 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. -- Auth remains disabled because `BASE_URL` and `AUTH_SECRET` are not set. The admin panel requires auth, so it's unavailable in this mode. +- 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. @@ -117,9 +119,9 @@ What this command enables: :::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**. -- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. The admin panel requires auth. +- `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 in auth-enabled setups, toggle it off in **Settings → Admin → Site features**. Legacy first-boot seed via `RUNTIME_SEED_RESTRICT_USER_API_KEYS=false` is still supported. +- `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. ::: diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 6d00019..6a43df7 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -23,7 +23,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - 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 Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments +- 🔐 **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 diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 2fc71f9..2389c18 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -3,36 +3,29 @@ title: Environment Variables toc_max_heading_level: 3 --- -This is the single reference page for OpenReader environment variables. +This page is the source-of-truth reference for OpenReader environment variables. :::note Recommended configuration path -For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared TTS providers and site features. Legacy env vars (`API_KEY`, `API_BASE`, and `RUNTIME_SEED_*`) are optional first-boot seeds only. +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; shared by app server + compute worker | +| `LOG_FORMAT` | Runtime logging | `pretty` | Set `json` for structured logs | | `LOG_LEVEL` | Runtime logging | `info` | Set app server log level | -| `API_BASE` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | -| `API_KEY` | Legacy bootstrap seed | none | Optional first-boot seed into `default-openai`; then manage in Settings → Admin → Shared providers | -| `TTS_CACHE_MAX_SIZE_BYTES` | TTS caching | `268435456` (256 MB) | Tune in-memory TTS cache size | -| `TTS_CACHE_TTL_MS` | TTS caching | `1800000` (30 min) | Tune in-memory TTS cache TTL | -| `TTS_MAX_RETRIES` | TTS retry | `2` | Tune retry attempts for upstream 429/5xx | -| `TTS_RETRY_INITIAL_MS` | TTS retry | `250` | Tune initial retry delay | -| `TTS_RETRY_MAX_MS` | TTS retry | `2000` | Tune max retry delay | -| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor | -| `TTS_UPSTREAM_TIMEOUT_MS` | TTS request timeout | `285000` | Set max upstream TTS request duration before fail-fast | -| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | -| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | +| `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 enable anonymous auth sessions | +| `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 | -| `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting | -| `ENABLE_TEST_NAMESPACE` | Testing/CI | unset | Honor the `x-openreader-test-namespace` header on a production build; leave unset on real deployments | -| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) | +| `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 | @@ -46,36 +39,27 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `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) | -| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup | -| `COMPUTE_WORKER_TOKEN` | Heavy compute backend | unset (auto-generated in embedded startup) | Required for standalone external compute worker auth; must match worker | -| `EMBEDDED_COMPUTE_WORKER_PORT` | Heavy compute backend | `8081` | Override embedded worker bind port | -| `EMBEDDED_NATS_PORT` | Heavy compute backend | `4222` | Override embedded NATS client port | -| `EMBEDDED_NATS_MONITOR_PORT` | Heavy compute backend | `8222` | Override embedded NATS monitor port | -| `EMBEDDED_NATS_STORE_DIR` | Heavy compute backend | `docstore/nats/jetstream` | Override embedded JetStream storage directory | -| `NATS_URL` | Heavy compute backend | `nats://127.0.0.1:4222` in embedded startup | Optional override for embedded startup or required on standalone worker service | -| `COMPUTE_LOG_LEVEL` | Heavy compute backend | `info` | Compute worker log level | -| `COMPUTE_JOB_CONCURRENCY` | Heavy compute backend | `1` | Worker-side shared compute concurrency cap | -| `COMPUTE_WHISPER_TIMEOUT_MS` | Heavy compute backend | `30000` | Shared whisper alignment timeout budget (worker + worker client wait budget) | -| `COMPUTE_PDF_TIMEOUT_MS` | Heavy compute backend | `300000` | Shared PDF idle-timeout budget (worker + worker client wait budget) | -| `COMPUTE_OP_STALE_MS` | Heavy compute backend | `max(30m, 4x max compute timeout)` | Shared stale window for worker op replacement and app-side stale PDF parse-state healing | -| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | -| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped q4 downloads | +| `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 | -| `RUNTIME_SEED_*` runtime seeds | Legacy bootstrap seed | varies | Optional first-boot seeds for site features; then manage in Settings → Admin → Site features | -| `RUNTIME_SEED_ENABLE_DOCX_CONVERSION` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable/disable DOCX conversion UI | -| `RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide destructive delete actions | -| `RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB` | Legacy bootstrap seed | `true` | Optional first-boot seed to show/hide user TTS providers tab | -| `RUNTIME_SEED_CHANGELOG_FEED_URL` | Legacy bootstrap seed | `https://docs.openreader.richardr.dev/changelog/manifest.json` | Optional first-boot seed for changelog feed URL; then manage in Settings → Admin → Site features | -| `RUNTIME_SEED_ENABLE_USER_SIGNUPS` | Legacy bootstrap seed | `true` | Optional first-boot seed for whether new accounts can be created; then manage in Settings → Admin → Site features | -| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK | -| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug | -| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI | -| `RUNTIME_SEED_DISABLE_TTS_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps TTS daily rate limiting disabled | -| `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps PDF parsing rate limiting disabled (other compute-limit values + max upload size are admin-only) | -| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations | +| `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 @@ -86,7 +70,6 @@ Controls log output format for server-side Pino loggers. - Default: `pretty` - Allowed values: `pretty`, `json` - Applies to app server and compute worker -- Recommended in production (Vercel + external worker): `json` ### LOG_LEVEL @@ -98,157 +81,88 @@ App server log level. ### API_BASE -Bootstrap base URL for the legacy OpenAI-compatible TTS endpoint. +Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider. - Example: `http://host.docker.internal:8880/v1` -- **Seeded on first boot** into the auto-created `default-openai` shared provider, then no longer read by the running app. Manage in **Settings → Admin → Shared providers** afterwards. -- Related docs: [Admin Panel](../configure/admin-panel), [TTS Providers](../configure/tts-providers) +- 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 -Bootstrap API key for the legacy OpenAI-compatible TTS endpoint. +Optional first-boot bootstrap API key for the auto-created `default-openai` shared provider. -- Example: your provider token, or omit if the provider doesn't require auth -- **Seeded on first boot** into the auto-created `default-openai` shared provider (encrypted at rest), then no longer read by the running app. Manage in **Settings → Admin → Shared providers** afterwards. -- Related docs: [Admin Panel](../configure/admin-panel), [TTS Providers](../configure/tts-providers) - -### TTS_CACHE_MAX_SIZE_BYTES - -Maximum in-memory TTS audio cache size in bytes. - -- Default: `268435456` (256 MB) - -### TTS_CACHE_TTL_MS - -In-memory TTS audio cache TTL in milliseconds. - -- Default: `1800000` (30 minutes) - -### TTS_MAX_RETRIES - -Maximum retries for upstream TTS failures (429/5xx). - -- Default: `2` - -### TTS_RETRY_INITIAL_MS - -Initial retry delay in milliseconds for TTS upstream requests. - -- Default: `250` - -### TTS_RETRY_MAX_MS - -Maximum retry delay in milliseconds. - -- Default: `2000` - -### TTS_RETRY_BACKOFF - -Exponential backoff multiplier between retries. - -- Default: `2` - -### TTS_UPSTREAM_TIMEOUT_MS - -Maximum upstream TTS request timeout in milliseconds. - -- Default: `285000` (285 seconds) -- Applies to outbound provider calls from server routes using shared TTS generation -- Increase for slower providers/models; decrease to fail fast and surface retryable errors sooner +- 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) -TTS character rate limiting is now managed from **Settings → Admin → Site features**. +Managed as runtime config in **Settings → Admin → Site features**. -- `disableTtsRateLimit` default: `true` (rate limiting disabled) -- Daily limit defaults: - - Anonymous per-user: `50000` - - Authenticated per-user: `500000` - - Anonymous IP backstop: `100000` - - Authenticated IP backstop: `1000000` +- `disableTtsRateLimit` default: `true` (daily TTS limits disabled) +- `ttsDailyLimitAnonymous` default: `50000` +- `ttsDailyLimitAuthenticated` default: `500000` +- `ttsIpDailyLimitAnonymous` default: `100000` +- `ttsIpDailyLimitAuthenticated` default: `1000000` -Optional first-boot seeds: +### TTS Upstream Settings (Runtime Settings) -- `RUNTIME_SEED_DISABLE_TTS_LIMIT` +Managed as runtime config in **Settings → Admin → Site features → TTS upstream**. -After first boot, these values are DB-backed admin runtime settings. +- `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 -External base URL for this OpenReader instance. +Required external base URL for this OpenReader instance. -- Required with `AUTH_SECRET` to enable auth +- Required at startup - Example: `http://localhost:3003` or `https://reader.example.com` -- Related docs: [Auth](../configure/auth) ### AUTH_SECRET -Secret key used by auth/session handling. +Required secret key used by auth/session handling. -- Required with `BASE_URL` to enable auth +- Required at startup - Generate with `openssl rand -hex 32` -- Also used to HMAC-hash server-side TTS segment text fingerprints -- Related docs: [Auth](../configure/auth) ### AUTH_TRUSTED_ORIGINS Additional allowed origins for auth requests. - Comma-separated list -- `BASE_URL` origin is always trusted automatically -- Related docs: [Auth](../configure/auth) +- `BASE_URL` origin is trusted automatically ### USE_ANONYMOUS_AUTH_SESSIONS Controls whether auth-enabled deployments can create/use anonymous sessions. -- Default: `false` (anonymous sessions disabled) -- Set `true` to allow anonymous sessions and guest-style flows -- When `false`, users must sign in or sign up with an account -- Related docs: [Auth](../configure/auth) +- Default: `false` ### GITHUB_CLIENT_ID GitHub OAuth client ID. -- Enable only with `GITHUB_CLIENT_SECRET` +- Set with `GITHUB_CLIENT_SECRET` ### GITHUB_CLIENT_SECRET GitHub OAuth client secret. -- Enable only with `GITHUB_CLIENT_ID` - -### DISABLE_AUTH_RATE_LIMIT - -Controls Better Auth rate limiting. - -- Default behavior: auth-layer rate limiting enabled -- Set to `true` to disable auth-layer rate limiting -- This does not affect TTS character rate limiting -- Related docs: [Auth](../configure/auth) - -### ENABLE_TEST_NAMESPACE - -Honors the `x-openreader-test-namespace` request header, which scopes documents/storage into an isolated namespace for end-to-end tests. - -- Default: unset (header ignored on production builds) -- Non-production builds (`NODE_ENV !== 'production'`) honor the header without this flag. -- Production builds (`pnpm build && pnpm start`) honor it only when `ENABLE_TEST_NAMESPACE=true`. The Playwright web server sets this automatically. -- **Leave unset on real deployments.** It is test/CI scaffolding only. +- Set with `GITHUB_CLIENT_ID` ### ADMIN_EMAILS -Comma-separated list of email addresses that are auto-promoted to admin. +Comma-separated list of email addresses auto-promoted to admin. -- Default: empty (no admins) -- Requires auth to be enabled (`AUTH_SECRET` + `BASE_URL`). -- Matched emails get `user.is_admin = true` on every session resolution; removed emails are demoted on the next session resolve. -- Admins see a new **Admin** tab in Settings exposing shared TTS providers and site-wide feature toggles. Keys for shared providers are stored encrypted in the DB and never returned to the client. -- Example: `ADMIN_EMAILS=alice@example.com,bob@example.com` -- Related docs: [Admin Panel](../configure/admin-panel), [Auth](../configure/auth) +- Requires auth to be enabled +- Admins can manage shared providers and runtime site features in-app ## Database and Object Blob Storage @@ -258,9 +172,6 @@ Switches metadata/auth storage from SQLite to Postgres. - Unset: SQLite at `docstore/sqlite3.db` - Set: Postgres mode -- Related docs: [Database](../configure/database) - -### Embedded SeaweedFS weed mini config ### USE_EMBEDDED_WEED_MINI @@ -268,129 +179,83 @@ Controls embedded SeaweedFS startup. - Default behavior: treated as enabled when unset - Set `false` to rely on external S3-compatible storage -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### WEED_MINI_DIR Data directory for embedded SeaweedFS (`weed mini`). - Default: `docstore/seaweedfs` -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) ### WEED_MINI_WAIT_SEC -Maximum seconds to wait for embedded SeaweedFS startup. +Max wait time for embedded SeaweedFS startup. - Default: `20` -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) - -### S3 storage config ### S3_ACCESS_KEY_ID -Access key for S3-compatible storage. +S3 access key. -- Auto-generated in embedded mode if unset -- Set explicitly for stable credentials or external providers -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Optional in embedded mode (auto-generated when unset) +- Required for external S3 mode ### S3_SECRET_ACCESS_KEY -Secret key for S3-compatible storage. +S3 secret key. -- Auto-generated in embedded mode if unset -- Set explicitly for stable credentials or external providers -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Optional in embedded mode (auto-generated when unset) +- Required for external S3 mode ### S3_BUCKET -Bucket name used for document blobs. +S3 bucket name. -- Default in embedded mode: `openreader-documents` -- Required for external S3-compatible storage -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Embedded default: `openreader-documents` +- Required for external S3 mode ### S3_REGION -Region used by the S3 client. +S3 region. -- Default in embedded mode: `us-east-1` -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Embedded default: `us-east-1` +- Required for external S3 mode ### S3_ENDPOINT -Endpoint URL for S3-compatible storage. +Custom endpoint for S3-compatible providers. -- In embedded mode, defaults to `http://:8333` (or detected host) -- For AWS S3, usually leave unset -- For MinIO/SeaweedFS/R2/B2-style APIs, typically set explicitly -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Optional for AWS +- Typical for MinIO/SeaweedFS/R2 ### S3_FORCE_PATH_STYLE -Path-style S3 addressing toggle. +Force path-style S3 URLs. -- Default in embedded mode: `true` -- Set according to provider requirements -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) +- Embedded default: `true` ### S3_PREFIX -Prefix prepended to stored object keys. +Object key prefix. - Default: `openreader` -- Related docs: [Object / Blob Storage](../configure/object-blob-storage) - -### Maximum Upload Size (Runtime Settings) - -The maximum size accepted for a single document upload is an admin runtime setting (no env var). - -- Runtime key: `maxUploadMb` (default: `200`) -- Configure in **Settings → Admin → Site features → Max upload size (MB)**. -- Enforced up front (`413`) and signed into the presigned S3 PUT so a client cannot stream more than it declared. ## Library Import ### IMPORT_LIBRARY_DIR -Single directory root for server library import. - -- Used when `IMPORT_LIBRARY_DIRS` is unset -- Default fallback root: `docstore/library` -- Related docs: [Server Library Import](../configure/server-library-import) +Single library source directory. ### IMPORT_LIBRARY_DIRS -Multiple library roots for server library import. +Multiple library roots. -- Separator: comma, colon, or semicolon -- Takes precedence over `IMPORT_LIBRARY_DIR` -- Related docs: [Server Library Import](../configure/server-library-import) +- Supports comma, colon, or semicolon-separated values -## Audio Tooling and Alignment - -### COMPUTE_WORKER_URL - -Base URL for standalone external compute worker mode. - -- Leave unset for embedded/local startup (`pnpm dev` / `pnpm start`) so entrypoint can start embedded worker+NATS. -- Embedded startup requires `nats-server` available on host PATH. -- Required only when using a standalone external worker service. -- App-side only: set on app server/root `.env` (routing target), not worker-only env files. -- Example: `http://localhost:8081` - -### COMPUTE_WORKER_TOKEN - -Bearer token for compute-worker auth. - -- Required for standalone external worker service mode. -- Must match worker service `COMPUTE_WORKER_TOKEN`. -- In embedded startup, entrypoint auto-generates one if unset. -- In external worker mode, set this on both app server/root `.env` and worker service env (`compute/worker/.env*` or platform env). +## Compute Worker and Model Configuration ### EMBEDDED_COMPUTE_WORKER_PORT -Embedded compute-worker HTTP port. +Embedded compute worker port. - Default: `8081` @@ -402,213 +267,200 @@ Embedded NATS client port. ### EMBEDDED_NATS_MONITOR_PORT -Embedded NATS monitor (`/healthz`) port. +Embedded NATS monitor port. - Default: `8222` ### EMBEDDED_NATS_STORE_DIR -Embedded JetStream storage directory. +Embedded NATS JetStream data directory. - Default: `docstore/nats/jetstream` ### NATS_URL -NATS connection URL used by compute worker runtime. +NATS URL used by compute services. - Embedded startup default: `nats://127.0.0.1:4222` -- Standalone worker service: set in worker service env (`compute/worker/.env*` or platform env) -- For embedded startup, this is optional; startup supplies the default value. -- Worker-side only in external mode: set on worker service env, not app/root `.env`. ### COMPUTE_LOG_LEVEL Compute worker log level. - Default: `info` -- In standalone mode, set this on the worker service env. ### COMPUTE_JOB_CONCURRENCY -Worker-side shared compute concurrency cap. +Max concurrent compute jobs per worker. - Default: `1` -- Set on the compute-worker service environment ### COMPUTE_WHISPER_TIMEOUT_MS -Shared whisper alignment timeout budget in milliseconds. +Whisper alignment timeout budget. - Default: `30000` -- Used by: - - Worker compute whisper runtime - - App server worker-client wait budget (SSE wait timeout) ### COMPUTE_PDF_TIMEOUT_MS -Shared PDF idle-timeout budget in milliseconds. +PDF parse timeout budget. -- Default: `300000` (5 minutes) -- Used by: - - Worker compute PDF runtime (idle timeout) - - App server worker-client wait budget (SSE wait timeout) +- Default: `300000` ### COMPUTE_OP_STALE_MS -Shared stale window in milliseconds. +Stale operation window before worker/app cleanup logic can replace an op. -- Default: `max(30m, 4x max(COMPUTE_WHISPER_TIMEOUT_MS, COMPUTE_PDF_TIMEOUT_MS))` -- Used by: - - Worker op reuse/replacement guard (`/ops` opKey stale detection) - - App-server PDF parse-state stale healing in `/api/documents/[id]/parsed*` -- If a parse row is stuck in `pending`/`running` past this window, app routes mark it failed so retries/reparse can proceed. -- Keep this value aligned on both app-server and worker service envs. - -### PDF_LAYOUT_MODEL_BASE_URL - -Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`. - -- Default: `https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main` -- Required files at that base: - - `PP-DocLayoutV3.onnx` - - `PP-DocLayoutV3.onnx.data` - - `config.json` - - `preprocessor_config.json` -- Configure this on the worker service env (not only the app server env) +- Default: `max(30m, 4x max compute timeout)` ### WHISPER_MODEL_BASE_URL -Optional base URL override for the built-in ONNX Whisper alignment model downloader. +Base URL for Whisper ONNX model downloads. -- Default: `https://huggingface.co/onnx-community/whisper-base_timestamped/resolve/main` -- Default model variant: q4 (`encoder_model_q4.onnx`, `decoder_model_merged_q4.onnx`, `decoder_with_past_model_q4.onnx`) -- The base URL must host all expected manifest files under the same relative paths. -- Configure this on the worker service env (not only the app server env) +### 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 -Absolute path or executable name for the ffmpeg binary used by audiobook/processing routes. +Override ffmpeg binary path used for audio processing. -- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static` -- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg` +- Used by audiobook processing routes and compute worker Whisper audio decode. -### Compute (PDF Parsing) Rate Limiting (Runtime Settings) +## Testing and CI -Per-user throttling of expensive PDF layout parsing is managed from **Settings → Admin → Site features**, not env vars. Enforcement applies only when auth is enabled. +### DISABLE_AUTH_RATE_LIMIT -- `disableComputeRateLimit` default: `true` (rate limiting disabled, like the TTS limit; enable it in the admin panel) -- When enabled, the following admin-tunable sub-limits apply: - - `computeParseBurstMax` (default `8`) over `computeParseBurstWindowSec` (default `60`) - - `computeParseSustainedMax` (default `24`) over `computeParseSustainedWindowSec` (default `600`) -- The sustained window also acts as a concurrency cap: because the worker bounds each job's duration, the count of parses started in that window is an upper bound on those still running. -- Exceeding a limit returns `429` (`application/problem+json`) with `Retry-After`. +Disables Better Auth request rate limiting. -Optional first-boot seed: `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT`. All other values are DB-backed admin runtime settings. +- Default: `false` -## Legacy First-Boot Runtime Seeds (optional) +### ENABLE_TEST_NAMESPACE -These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior. - -The values are SSR-injected via `window.__RUNTIME_CONFIG__`, so admin edits take effect for all users on the next page load — no rebuild required (unlike the old build-time public env pattern). - -### RUNTIME_SEED_ENABLE_DOCX_CONVERSION - -Controls whether the experimental DOCX-to-PDF conversion and upload feature is enabled. - -- Default: `true` (enabled) -- Runtime key: `enableDocxConversion` - -### RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS - -Controls whether the "Delete all user docs" and other bulk-delete buttons are shown in Settings. - -- Default: `true` (enabled) -- Runtime key: `enableDestructiveDeleteActions` - -### RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB - -Controls whether the **TTS Provider** section appears in the user-facing Settings modal. - -- Default: `true` (enabled) -- Set `false` to hide provider/model/API controls in the per-user Settings modal (the admin panel is unaffected). -- Runtime key: `enableTtsProvidersTab` - -### RUNTIME_SEED_ENABLE_USER_SIGNUPS - -Controls whether new user accounts can be created. - -- Default: `true` (enabled) -- When `false`, new account creation is blocked for email sign-up, first-time OAuth signup, and anonymous-to-account upgrades. -- Existing users can still sign in. -- Runtime key: `enableUserSignups` - -### RUNTIME_SEED_RESTRICT_USER_API_KEYS - -Controls whether users can supply personal API keys/base URLs for built-in providers. - -- Default: runtime-dependent -- When `true`, server routes only use admin-managed shared providers. -- When `false`, users can use per-user BYOK credentials for built-in providers. -- Runtime key: `restrictUserApiKeys` - -### RUNTIME_SEED_DEFAULT_TTS_PROVIDER - -Sets the default TTS provider for new users. - -- Default: `custom-openai` -- Example values: `replicate`, `deepinfra`, `openai`, `custom-openai`, or an admin-defined shared provider slug (e.g. `kokoro-prod`) -- Runtime key: `defaultTtsProvider` - -`showAllProviderModels` is a runtime-only admin setting (no env seed). Configure it in **Settings → Admin → Site features**. - -### RUNTIME_SEED_CHANGELOG_FEED_URL - -Sets the changelog manifest URL used by the Settings modal changelog viewer. - -- Default: `https://docs.openreader.richardr.dev/changelog/manifest.json` -- Use this in self-hosted deployments when you publish changelog feeds to a custom docs domain/path. -- Runtime key: `changelogFeedUrl` - - -### RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT - -Controls whether audiobook export UI/actions are shown in the client. - -- Default: `true` (enabled) -- Affects export entry points in PDF/EPUB pages and document settings UI -- Runtime key: `enableAudiobookExport` - -### RUNTIME_SEED_DISABLE_TTS_LIMIT - -Seeds the TTS daily character rate-limit on/off state on first boot. - -- Default: `true` (TTS rate limiting disabled) -- Runtime key: `disableTtsRateLimit` -- Per-user/IP daily limit values are admin-only runtime settings (see [TTS Daily Rate Limiting](#tts-daily-rate-limiting-runtime-settings)). - -### RUNTIME_SEED_DISABLE_COMPUTE_LIMIT - -Seeds the PDF parsing rate-limit on/off state on first boot. - -- Default: `true` (PDF parsing rate limiting disabled, matching `RUNTIME_SEED_DISABLE_TTS_LIMIT`) -- Runtime key: `disableComputeRateLimit` -- The burst/sustained limits, their windows, and the max upload size are admin-only runtime settings (see [Compute (PDF Parsing) Rate Limiting](#compute-pdf-parsing-rate-limiting-runtime-settings)). +Enables the `x-openreader-test-namespace` header path in production builds. ## Migration Controls ### RUN_DRIZZLE_MIGRATIONS -Controls startup migration execution in shared entrypoint. +Controls startup Drizzle schema migrations. - Default: `true` -- Set `false` to skip automatic startup Drizzle schema migrations -- Related docs: [Migrations](../configure/migrations), [Database](../configure/database) +- Set `false` to skip startup migration run ### RUN_FS_MIGRATIONS -Controls startup filesystem-to-object-store migration execution in shared entrypoint. +Controls startup filesystem-to-S3/DB migration pass. - Default: `true` -- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations -- Set `false` to skip automatic storage migration pass -- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage) +- 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, + "enableDestructiveDeleteActions": 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/package.json b/package.json index a7aa4d5..e208eda 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,6 @@ "test:e2e": "playwright test", "test:unit": "vitest run", "test:compute": "vitest run --project compute-core --project compute-worker", - "test:ci-env": "CI=true playwright test", "migrate": "node drizzle/scripts/migrate.mjs", "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", diff --git a/playwright.config.ts b/playwright.config.ts index 07b27bb..d38c61e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,15 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; import 'dotenv/config'; -import fs from 'node:fs'; -import path from 'node:path'; -import dotenv from 'dotenv'; - -if (process.env.CI === 'true') { - const envCiPath = path.join(process.cwd(), '.env.ci'); - if (fs.existsSync(envCiPath)) { - dotenv.config({ path: envCiPath, override: true }); - } -} /** * See https://playwright.dev/docs/test-configuration. diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 06dd1d4..2fa3e86 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -11,15 +11,6 @@ import * as dotenv from 'dotenv'; function loadEnvFiles() { const cwd = process.cwd(); - const isCi = isTrue(process.env.CI, false); - if (isCi) { - const envCiPath = path.join(cwd, '.env.ci'); - if (fs.existsSync(envCiPath)) { - dotenv.config({ path: envCiPath }); - } - return; - } - const envPath = path.join(cwd, '.env'); const envLocalPath = path.join(cwd, '.env.local'); @@ -46,6 +37,18 @@ function withDefault(value, fallback) { return value && value.trim() ? value.trim() : fallback; } +function requireAuthEnv(env) { + const missing = []; + if (!env.AUTH_SECRET?.trim()) missing.push('AUTH_SECRET'); + if (!env.BASE_URL?.trim()) missing.push('BASE_URL'); + if (missing.length > 0) { + throw new Error( + `Missing required auth env vars: ${missing.join(', ')}. ` + + 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.', + ); + } +} + function isPrivateIPv4(address) { if (!address) return false; if (address.startsWith('10.')) return true; @@ -328,6 +331,7 @@ async function main() { const runtimeEnv = { ...process.env }; runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty'); + requireAuthEnv(runtimeEnv); let weedProc = null; let weedExitPromise = Promise.resolve(); let natsProc = null; diff --git a/src/app/(app)/epub/[id]/useEpubDocument.ts b/src/app/(app)/epub/[id]/useEpubDocument.ts index 8c5a78a..6dc2b65 100644 --- a/src/app/(app)/epub/[id]/useEpubDocument.ts +++ b/src/app/(app)/epub/[id]/useEpubDocument.ts @@ -18,7 +18,6 @@ import { EpubRenderedLocationCloneManager } from '@/lib/client/epub/rendered-loc import { canonicalizeEpubSegmentAgainstSpineText } from '@/lib/client/epub/canonicalize-epub-segment'; import { buildEpubLocator, getSpineItemPlainText } from '@/lib/client/epub/spine-coordinates'; import { useTTS, type EpubLocatorResolver } from '@/contexts/TTSContext'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { createRangeCfi } from '@/lib/client/epub'; import { normalizeTtsLocationKey } from '@/lib/shared/tts-locator'; import { useConfig } from '@/contexts/ConfigContext'; @@ -94,7 +93,6 @@ export interface EpubDocumentState { */ export function useEpubDocument(documentId?: string): EpubDocumentState { const { setText: setTTSText, currDocPage, currDocPages, setCurrDocPages, stop, skipToLocation, setIsEPUB } = useTTS(); - const { authEnabled } = useAuthConfig(); // Configuration context to get TTS settings const { apiKey, @@ -372,7 +370,6 @@ export function useEpubDocument(documentId?: string): EpubDocumentState { const handleLocationChanged = useEPUBLocationController({ documentId, - authEnabled, isEpubSetOnceRef: isEPUBSetOnce, shouldPauseRef, setIsEpub: setIsEPUB, diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c554046..dcf5127 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -22,14 +22,12 @@ export const metadata: Metadata = { }; export default function AppLayout({ children }: { children: ReactNode }) { - const authEnabled = isAuthEnabled(); const authBaseUrl = getAuthBaseUrl(); const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); const githubAuthEnabled = isGithubAuthEnabled(); return ( (null); - const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig(); + const { baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig(); const enableUserSignups = useFeatureFlag('enableUserSignups'); const { refresh: refreshRateLimit } = useAuthRateLimit(); const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; - // Check if auth is enabled, redirect home if not - useEffect(() => { - if (!authEnabled) { - router.push('/app'); - } - }, [router, authEnabled]); - const validateEmail = (email: string): boolean => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; @@ -121,10 +114,6 @@ function SignInContent() { } }; - if (!authEnabled) { - return null; - } - return (
@@ -253,7 +242,7 @@ function SignInContent() {

By signing in, you agree to our{' '}

- {authEnabled && ( - - )} +
@@ -1161,15 +1158,6 @@ export function SettingsModal({ > Clear cache - {enableDestructiveDelete && !authEnabled && ( - - )} @@ -1208,7 +1196,7 @@ export function SettingsModal({ )} {/* Account Section */} - {activeSection === 'account' && authEnabled && ( + {activeSection === 'account' && (
{/* Session info */}
diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 2facf72..e4381ca 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -18,7 +18,7 @@ import { import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders'; -type RuntimeConfigSource = 'env-seed' | 'admin' | 'default'; +type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin' | 'default'; interface SettingsResponse { values: Record; @@ -506,6 +506,71 @@ export function AdminFeaturesPanel() { /> +
Upstream} + > +
+
+
+ + {renderSource('ttsUpstreamMaxRetries')} +
+ updatePositiveIntDraft('ttsUpstreamMaxRetries', event.target.value)} + /> +
+
+
+ + {renderSource('ttsUpstreamTimeoutMs')} +
+ updatePositiveIntDraft('ttsUpstreamTimeoutMs', event.target.value)} + /> +
+
+
+ + {renderSource('ttsCacheMaxSizeBytes')} +
+ updatePositiveIntDraft('ttsCacheMaxSizeBytes', event.target.value)} + /> +
+
+
+ + {renderSource('ttsCacheTtlMs')} +
+ updatePositiveIntDraft('ttsCacheTtlMs', event.target.value)} + /> +
+
+
+

{dirty.size > 0 @@ -600,6 +665,8 @@ function SourceBadge({ )} {dirty ? ( Modified + ) : source === 'json-seed' ? ( + from seed ) : source === 'env-seed' ? ( from env ) : source === 'admin' ? ( diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 96968a3..e24e887 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -91,7 +91,7 @@ function isRateLimited(info: ErrorInfo | null): boolean { } export function AuthLoader({ children }: { children: ReactNode }) { - const { authEnabled, baseUrl, allowAnonymousAuthSessions } = useAuthConfig(); + const { baseUrl, allowAnonymousAuthSessions } = useAuthConfig(); const { refresh: refreshRateLimit } = useAuthRateLimit(); const { data: session, isPending, error: sessionError, refetch: refetchSession } = useAuthSession(); const router = useRouter(); @@ -109,11 +109,10 @@ export function AuthLoader({ children }: { children: ReactNode }) { attemptedForNullSessionRef.current = false; setBootstrapError(null); setIsRedirecting(false); - }, [authEnabled, baseUrl, allowAnonymousAuthSessions, pathname]); + }, [baseUrl, allowAnonymousAuthSessions, pathname]); useEffect(() => { const checkStatus = async () => { - if (!authEnabled) return; if (isPending) return; if (session) { @@ -225,7 +224,6 @@ export function AuthLoader({ children }: { children: ReactNode }) { }, [ session, isPending, - authEnabled, baseUrl, allowAnonymousAuthSessions, refreshRateLimit, @@ -236,17 +234,16 @@ export function AuthLoader({ children }: { children: ReactNode }) { ]); useEffect(() => { - if (!authEnabled) return; if (sessionError) { console.warn('[AuthLoader] useSession error', sessionError); } - }, [authEnabled, sessionError]); + }, [sessionError]); const shouldBlockForProtectedNoSession = - authEnabled && !allowAnonymousAuthSessions && !isAuthPage && !session; + !allowAnonymousAuthSessions && !isAuthPage && !session; const shouldBlockForDisallowedAnonymous = - authEnabled && !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous); - const isLoading = authEnabled && ( + !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous); + const isLoading = ( (allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) || (!allowAnonymousAuthSessions && !isAuthPage && ( isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous diff --git a/src/components/auth/RateLimitBanner.tsx b/src/components/auth/RateLimitBanner.tsx index 8453ae0..21a44f8 100644 --- a/src/components/auth/RateLimitBanner.tsx +++ b/src/components/auth/RateLimitBanner.tsx @@ -9,11 +9,10 @@ interface RateLimitBannerProps { } export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit, timeUntilReset, authEnabled } = useAuthRateLimit(); + const { status, isAtLimit, timeUntilReset } = useAuthRateLimit(); const enableUserSignups = useFeatureFlag('enableUserSignups'); - // Don't show banner if auth is not enabled or if not at limit - if (!authEnabled || !status?.authEnabled || !isAtLimit) { + if (!status || !isAtLimit) { return null; } @@ -51,10 +50,9 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { * Compact version for inline display */ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit, authEnabled } = useAuthRateLimit(); + const { status, isAtLimit } = useAuthRateLimit(); - // Don't show if auth is not enabled - if (!authEnabled || !status?.authEnabled) { + if (!status) { return null; } diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index bbb6f88..a30d419 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -18,12 +18,12 @@ export function UserMenu({ className?: string; variant?: UserMenuVariant; }) { - const { authEnabled, baseUrl } = useAuthConfig(); + const { baseUrl } = useAuthConfig(); const enableUserSignups = useFeatureFlag('enableUserSignups'); const { data: session, isPending } = useAuthSession(); const router = useRouter(); - if (!authEnabled || isPending) return null; + if (isPending) return null; const handleDisconnectAccount = async () => { const client = getAuthClient(baseUrl); diff --git a/src/components/doclist/views/DocumentTile.tsx b/src/components/doclist/views/DocumentTile.tsx index e43ddef..467847b 100644 --- a/src/components/doclist/views/DocumentTile.tsx +++ b/src/components/doclist/views/DocumentTile.tsx @@ -6,8 +6,6 @@ import { Button } from '@headlessui/react'; import { PDFIcon, EPUBIcon, FileIcon } from '@/components/icons/Icons'; import type { DocumentListDocument, IconSize } from '@/types/documents'; import { DocumentPreview } from '@/components/doclist/DocumentPreview'; -import { useAuthSession } from '@/hooks/useAuthSession'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useDocumentSelection } from '../dnd/DocumentSelectionContext'; import { DND_DOCUMENT, documentIdentityKey, type DocumentDragItem } from '../dnd/dndTypes'; @@ -69,13 +67,10 @@ export function DocumentTile({ onDelete, onMergeIntoFolder, }: DocumentTileProps) { - const { authEnabled } = useAuthConfig(); - const { data: session } = useAuthSession(); const href = `/${doc.type}/${encodeURIComponent(doc.id)}`; const selection = useDocumentSelection(); - const isAnonymousAuthed = Boolean(authEnabled && session?.user?.isAnonymous); - const showDeleteButton = !(isAnonymousAuthed && doc.scope === 'unclaimed'); + const showDeleteButton = true; const isSelected = selection.isSelected(doc); const isInFolder = Boolean(doc.folderId); diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index fb69fb2..17c16f6 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -11,12 +11,10 @@ export interface RateLimitStatus { remainingChars: number; resetTimeMs: number; userType: 'anonymous' | 'authenticated' | 'unauthenticated'; - authEnabled: boolean; } interface AuthRateLimitContextType { // Auth Config - authEnabled: boolean; authBaseUrl: string | null; allowAnonymousAuthSessions: boolean; githubAuthEnabled: boolean; @@ -45,8 +43,8 @@ export function useAuthRateLimit(): AuthRateLimitContextType { } export function useAuthConfig() { - const { authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit(); - return { authEnabled, baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }; + const { authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthRateLimit(); + return { baseUrl: authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled }; } export function useRateLimit() { @@ -87,7 +85,6 @@ function parseRateLimitStatus(raw: unknown): RateLimitStatus | null { remainingChars: Number(data.remainingChars ?? 0), resetTimeMs: coerceTimestampMs(data.resetTimeMs ?? data.resetTime, nextUtcMidnightTimestampMs()), userType, - authEnabled: Boolean(data.authEnabled), }; } @@ -105,7 +102,6 @@ export function formatCharCount(count: number): string { interface AuthRateLimitProviderProps { children: ReactNode; - authEnabled: boolean; authBaseUrl: string | null; allowAnonymousAuthSessions: boolean; githubAuthEnabled: boolean; @@ -115,7 +111,6 @@ const RATE_LIMIT_QUERY_KEY = ['rate-limit-status'] as const; export function AuthRateLimitProvider({ children, - authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled, @@ -140,26 +135,13 @@ export function AuthRateLimitProvider({ } return parseRateLimitStatus(await response.json()); }, - enabled: authEnabled, + enabled: true, retry: 0, }); - const status = authEnabled - ? (queryStatus ?? null) - : { - allowed: true, - currentCount: 0, - // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. - limit: Number.MAX_SAFE_INTEGER, - remainingChars: Number.MAX_SAFE_INTEGER, - resetTimeMs: nextUtcMidnightTimestampMs(), - userType: 'unauthenticated' as const, - authEnabled: false, - }; - const loading = authEnabled ? (isPending || isFetching) : false; - const error = authEnabled - ? (queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null) - : null; + const status = queryStatus ?? null; + const loading = isPending || isFetching; + const error = queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null; useEffect(() => { if (!queryError) return; @@ -167,15 +149,13 @@ export function AuthRateLimitProvider({ }, [queryError]); const refresh = useCallback(async () => { - if (!authEnabled) return; await refetch(); - }, [authEnabled, refetch]); + }, [refetch]); const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : ''; const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; const incrementCount = useCallback((charCount: number) => { - if (!authEnabled) return; queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prevStatus) => { if (!prevStatus) return prevStatus; @@ -189,7 +169,7 @@ export function AuthRateLimitProvider({ allowed: newRemainingChars > 0 }; }); - }, [authEnabled, queryClient]); + }, [queryClient]); const onTTSStart = useCallback(() => { pendingTTSRef.current += 1; @@ -225,7 +205,6 @@ export function AuthRateLimitProvider({ }, []); const contextValue: AuthRateLimitContextType = { - authEnabled, authBaseUrl, allowAnonymousAuthSessions, githubAuthEnabled, @@ -239,7 +218,6 @@ export function AuthRateLimitProvider({ onTTSStart, onTTSComplete, triggerRateLimit: () => { - if (!authEnabled) return; queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prev) => prev ? { ...prev, remainingChars: 0, allowed: false } : null, ); diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f119ddb..b6a6c74 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -9,7 +9,6 @@ import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/sha import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; @@ -70,7 +69,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); - const { authEnabled } = useAuthConfig(); const { providers: sharedProviders } = useSharedProviders(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; @@ -83,7 +81,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [sharedProviders]); const queueSyncedPreferencePatch = useCallback((patch: Partial) => { - if (!authEnabled || sessionKey === 'no-session') return; + if (sessionKey === 'no-session') return; const syncedPatch: SyncedPreferencesPatch = {}; for (const key of SYNCED_PREFERENCE_KEYS) { @@ -94,7 +92,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { } if (Object.keys(syncedPatch).length === 0) return; scheduleUserPreferencesSync(syncedPatch, sessionKey); - }, [authEnabled, sessionKey]); + }, [sessionKey]); // Cancel pending/in-flight preference syncs whenever the session changes or on unmount. useEffect(() => { @@ -157,7 +155,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [isDBReady]); const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => { - if (!isDBReady || !authEnabled) return; + if (!isDBReady) return; try { const remote = await getUserPreferences({ signal }); if (!remote?.hasStoredPreferences) return; @@ -167,20 +165,20 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to load synced preferences:', error); } - }, [isDBReady, authEnabled]); + }, [isDBReady]); useEffect(() => { - if (!isDBReady || !authEnabled || isSessionPending) return; + if (!isDBReady || isSessionPending) return; const controller = new AbortController(); refreshSyncedPreferencesFromServer(controller.signal).catch((error) => { if ((error as Error)?.name === 'AbortError') return; console.warn('Synced preferences refresh failed:', error); }); return () => controller.abort(); - }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); + }, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); useEffect(() => { - if (!isDBReady || !authEnabled) return; + if (!isDBReady) return; let activeController: AbortController | null = null; const onFocus = () => { if (activeController) activeController.abort(); @@ -195,7 +193,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { window.removeEventListener('focus', onFocus); if (activeController) activeController.abort(); }; - }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); + }, [isDBReady, refreshSyncedPreferencesFromServer]); const appConfig = useLiveQuery( async () => { @@ -301,7 +299,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); useEffect(() => { - if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; + if (!isDBReady || !appConfig || isSessionPending) return; if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; didAttemptInitialPreferenceSeedForSession.current = sessionKey; @@ -330,7 +328,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }); return () => controller.abort(); - }, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]); + }, [isDBReady, appConfig, isSessionPending, sessionKey]); // Destructure for convenience and to match context shape const { diff --git a/src/contexts/OnboardingFlowContext.tsx b/src/contexts/OnboardingFlowContext.tsx index 0384a09..e1cadaf 100644 --- a/src/contexts/OnboardingFlowContext.tsx +++ b/src/contexts/OnboardingFlowContext.tsx @@ -4,7 +4,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal'; import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; import { PrivacyModal } from '@/components/PrivacyModal'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie'; @@ -105,7 +104,6 @@ async function readLocalOnboardingSnapshot(): Promise { } export function OnboardingFlowProvider({ children }: { children: ReactNode }) { - const { authEnabled } = useAuthConfig(); const { data: session, isPending: isSessionPending } = useAuthSession(); const runtimeConfig = useRuntimeConfig(); const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; @@ -136,12 +134,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { const runOnceFlow = useCallback(async () => { const local = await readLocalOnboardingSnapshot(); - const privacyRequired = authEnabled; + const privacyRequired = true; const privacyAccepted = !privacyRequired || local.privacyAccepted; const isClaimEligible = Boolean( - authEnabled - && userId + userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId), ); @@ -199,7 +196,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { pendingChangelogOpenRef.current = false; setChangelogOpenSignal((value) => value + 1); } - }, [authEnabled, isAnonymous, userId]); + }, [isAnonymous, userId]); runOnceFlowRef.current = runOnceFlow; @@ -223,12 +220,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { useEffect(() => { void runFlow(); - }, [authEnabled, isAnonymous, runFlow, userId]); + }, [isAnonymous, runFlow, userId]); useEffect(() => { - if (!authEnabled) { - return; - } const onPrivacyAccepted = () => { void runFlow(); }; @@ -236,15 +230,10 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return () => { window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); }; - }, [authEnabled, runFlow]); + }, [runFlow]); useEffect(() => { - if (!authEnabled) { - return () => { }; - } - return scheduleChangelogCheck({ - authEnabled, isSessionPending, sessionUserId: userId, appVersion: runtimeConfig.appVersion, @@ -258,7 +247,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { delayMs: 120, retryDelayMs: 400, }); - }, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]); + }, [isSessionPending, runFlow, runtimeConfig.appVersion, userId]); const contextValue = useMemo(() => ({ changelogOpenSignal, @@ -267,21 +256,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return ( {children} - {authEnabled && ( - { }} - /> - )} - {authEnabled && ( - - )} + { }} + /> + { - if (authEnabled) { - try { - const remote = await getDocumentProgress(docId); - if (!cancelled && remote?.location) { - await setLastDocumentLocation(docId, remote.location).catch((error) => { - console.warn('Error caching remote location locally:', error); - }); - applyLocation(remote.location); - return; - } - } catch (error) { - console.warn('Error loading remote progress:', error); - } - } - try { const local = await getLastDocumentLocation(docId); if (!cancelled && local) { @@ -3252,6 +3236,18 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement } catch (error) { console.warn('Error loading local last location:', error); } + + try { + const remote = await getDocumentProgress(docId); + if (!cancelled && remote?.location) { + await setLastDocumentLocation(docId, remote.location).catch((error) => { + console.warn('Error caching remote location locally:', error); + }); + applyLocation(remote.location); + } + } catch (error) { + console.warn('Error loading remote progress:', error); + } }; load(); @@ -3259,7 +3255,7 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement return () => { cancelled = true; }; - }, [id, isEPUB, currentReaderType, authEnabled]); + }, [id, isEPUB, currentReaderType]); // Save current position periodically for non-EPUB readers. useEffect(() => { @@ -3271,18 +3267,16 @@ export function TTSProvider({ children }: { children: ReactNode }): ReactElement setLastDocumentLocation(id as string, location).catch(error => { console.warn('Error saving non-EPUB location:', error); }); - if (authEnabled) { - scheduleDocumentProgressSync({ - documentId: id as string, - readerType: currentReaderType, - location, - }); - } + scheduleDocumentProgressSync({ + documentId: id as string, + readerType: currentReaderType, + location, + }); }, 1000); // Debounce saves by 1 second return () => clearTimeout(timeoutId); } - }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType, authEnabled]); + }, [id, isEPUB, currDocPage, currDocPageNumber, currentIndex, sentences.length, currentReaderType]); /** * Renders the TTS context provider with its children diff --git a/src/db/index.ts b/src/db/index.ts index ae265e1..a6e43ec 100644 --- a/src/db/index.ts +++ b/src/db/index.ts @@ -23,9 +23,10 @@ const seededUserIds = new Set(); /** * Ensure a system/placeholder user row exists in the user table for `userId`. * All user-facing tables now have userId foreign keys with ON DELETE CASCADE - * referencing the user table. When auth is disabled the app stores data under - * the 'unclaimed' userId (and namespace variants like 'unclaimed::ns'), so - * those rows must exist before any data can be inserted. + * referencing the user table. The legacy first-enable-auth claim migration can + * still move rows from the historical 'unclaimed' userId (and namespaced + * variants like 'unclaimed::ns'), so those placeholder rows must exist before + * any migration transfer can run. * * This is safe to call repeatedly — it short-circuits via an in-memory Set * and uses INSERT … ON CONFLICT/OR IGNORE at the SQL level. @@ -109,4 +110,3 @@ export const db: any = new Proxy({} as any, { return typeof value === 'function' ? value.bind(instance) : value; }, }); - diff --git a/src/hooks/epub/useEPUBLocationController.ts b/src/hooks/epub/useEPUBLocationController.ts index 198b39d..726122a 100644 --- a/src/hooks/epub/useEPUBLocationController.ts +++ b/src/hooks/epub/useEPUBLocationController.ts @@ -33,7 +33,6 @@ export function shouldPersistEpubLocation( type UseEpubLocationControllerParams = { documentId?: string; - authEnabled: boolean; isEpubSetOnceRef: MutableRefObject; shouldPauseRef: MutableRefObject; setIsEpub: (isEpub: boolean) => void; @@ -46,7 +45,6 @@ type UseEpubLocationControllerParams = { export function useEPUBLocationController({ documentId, - authEnabled, isEpubSetOnceRef, shouldPauseRef, setIsEpub, @@ -138,13 +136,11 @@ export function useEPUBLocationController({ // Save the location to IndexedDB if not initial if (shouldPersistEpubLocation(documentId, locationRef.current)) { setLastDocumentLocation(documentId, location.toString()); - if (authEnabled) { - scheduleDocumentProgressSync({ - documentId, - readerType: 'epub', - location: location.toString(), - }); - } + scheduleDocumentProgressSync({ + documentId, + readerType: 'epub', + location: location.toString(), + }); } skipToLocation(location); @@ -155,7 +151,6 @@ export function useEPUBLocationController({ shouldPauseRef.current = true; } }, [ - authEnabled, bookRef, documentId, extractPageText, diff --git a/src/hooks/useAuthSession.ts b/src/hooks/useAuthSession.ts index 40bad42..3e81790 100644 --- a/src/hooks/useAuthSession.ts +++ b/src/hooks/useAuthSession.ts @@ -4,35 +4,18 @@ import { useMemo } from 'react'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { getAuthClient } from '@/lib/client/auth-client'; -type SessionHookResult = ReturnType['useSession']>; - -/** Stable empty result returned when auth is disabled. */ -const EMPTY_SESSION: SessionHookResult = { - data: null, - isPending: false, - isRefetching: false, - // better-auth types use BetterFetchError | null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: null as any, - refetch: async () => { }, -}; - -/** Stub client whose useSession() always returns the empty shape. */ -const STUB_CLIENT = { useSession: () => EMPTY_SESSION } as ReturnType; - /** * Hook for session that uses the correct baseUrl from context. - * A stub client is used when auth is disabled so that useSession() - * is always called unconditionally (Rules of Hooks). */ export function useAuthSession() { - const { baseUrl, authEnabled } = useAuthConfig(); + const { baseUrl } = useAuthConfig(); const client = useMemo(() => { - if (!authEnabled || !baseUrl) return STUB_CLIENT; + if (!baseUrl) { + throw new Error('Auth base URL is required'); + } return getAuthClient(baseUrl); - }, [baseUrl, authEnabled]); + }, [baseUrl]); return client.useSession(); } - diff --git a/src/lib/client/api/documents.ts b/src/lib/client/api/documents.ts index 7eb4570..b943354 100644 --- a/src/lib/client/api/documents.ts +++ b/src/lib/client/api/documents.ts @@ -341,14 +341,11 @@ export async function uploadDocuments(files: File[], options?: UploadOptions): P return uploadDocumentSources(sources, options); } -export async function deleteDocuments(options?: { ids?: string[]; scope?: 'user' | 'unclaimed'; signal?: AbortSignal }): Promise { +export async function deleteDocuments(options?: { ids?: string[]; signal?: AbortSignal }): Promise { const params = new URLSearchParams(); if (options?.ids?.length) { params.set('ids', options.ids.join(',')); } - if (options?.scope) { - params.set('scope', options.scope); - } const url = params.toString() ? `/api/documents?${params.toString()}` : '/api/documents'; const res = await fetch(url, { method: 'DELETE', signal: options?.signal }); diff --git a/src/lib/client/changelog-check.ts b/src/lib/client/changelog-check.ts index 70b1571..9514e94 100644 --- a/src/lib/client/changelog-check.ts +++ b/src/lib/client/changelog-check.ts @@ -9,7 +9,6 @@ export type ChangelogVersionCheckResponse = { type RefLike = { current: string | null }; export type RunChangelogCheckArgs = { - authEnabled: boolean; isSessionPending: boolean; sessionUserId: string | null | undefined; appVersion: string | null | undefined; @@ -23,13 +22,12 @@ export type RunChangelogCheckArgs = { export async function runChangelogCheck(args: RunChangelogCheckArgs): Promise { const sessionUserId = args.sessionUserId ?? null; - if (args.authEnabled && (args.isSessionPending || !sessionUserId)) return; + if (args.isSessionPending || !sessionUserId) return; const currentVersion = normalizeVersion(args.appVersion || ''); if (!currentVersion) return; - const userKey = sessionUserId ?? 'server-unclaimed'; - const checkKey = `${userKey}:${currentVersion}`; + const checkKey = `${sessionUserId}:${currentVersion}`; if (args.completedRef.current === checkKey) return; if (args.inFlightRef.current === checkKey) return; args.inFlightRef.current = checkKey; diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 281f3d7..c06cc48 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -3,30 +3,53 @@ import { adminProviders, adminSettings } from '@/db/schema'; import { encryptSecret, apiKeyLast4 } from '@/lib/server/crypto/secrets'; import { randomUUID } from 'node:crypto'; import { and, eq } from 'drizzle-orm'; +import { readFile } from 'node:fs/promises'; import { serverLogger } from '@/lib/server/logger'; import { - RUNTIME_CONFIG_SCHEMA, - seedRuntimeConfigFromEnv, + seedRuntimeConfigFromValues, } from '@/lib/server/admin/settings'; import { logDegraded } from '@/lib/server/errors/logging'; +import { validateProviderType, validateSlug } from '@/lib/server/admin/providers'; +import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog'; /** - * Idempotent boot-time seeding for the admin layer. Safe to call multiple - * times. Runs: + * Idempotent boot-time seeding for the admin layer. Safe to call multiple times. * - * 1. `seedRuntimeConfigFromEnv()` — for each `RUNTIME_SEED_*` env var that - * maps to a runtime config key, write the value as `source='env-seed'`. - * - * 2. Default admin provider seed — if `admin_providers` is empty AND - * `API_KEY` is set, create a single `default-openai` row from the - * legacy `API_KEY` / `API_BASE` env vars. After this runs, the TTS - * routes no longer fall back to those env vars. - * - * 3. Legacy row cleanup — remove historical env-seeded - * `defaultTtsProvider="default-openai"` rows so the shared default is - * treated as an implicit baseline, not an override. + * v4 behavior: + * 1) Optional JSON seed from RUNTIME_SEED_JSON_PATH or RUNTIME_SEED_JSON + * - runtimeConfig: strict validation against RUNTIME_CONFIG_SCHEMA + * - providers: optional shared providers seed list + * 2) Legacy provider fallback: if providers were not supplied in JSON and + * no provider rows exist, seed default-openai from API_KEY/API_BASE. + * 3) Legacy row cleanup for historical defaultTtsProvider/defaultTtsModel rows. */ +const RUNTIME_SEED_JSON = 'RUNTIME_SEED_JSON'; +const RUNTIME_SEED_JSON_PATH = 'RUNTIME_SEED_JSON_PATH'; +const SEED_VERSION = 1; + +type SeedProviderInput = { + slug: string; + displayName: string; + providerType: TtsProviderId; + apiKey: string; + baseUrl?: string | null; + defaultModel?: string | null; + defaultInstructions?: string | null; + enabled?: boolean; +}; + +type ServerSeedDocument = { + version: number; + runtimeConfig?: Record; + providers?: SeedProviderInput[]; +}; + +type ParsedSeedResult = { + seed: ServerSeedDocument; + hasProvidersSection: boolean; +}; + let seedPromise: Promise | null = null; export async function ensureAdminSeed(): Promise { @@ -38,7 +61,6 @@ export async function ensureAdminSeed(): Promise { step: 'run_admin_seed', error, }); - // Reset so a subsequent call can retry (e.g. once migrations run). seedPromise = null; throw error; }); @@ -51,13 +73,211 @@ export async function ensureAdminSeed(): Promise { } async function runSeed(): Promise { - await seedRuntimeConfigFromEnv(); - await seedDefaultAdminProvider(); + const parsedSeed = await loadRuntimeSeedFromEnv(); + + if (parsedSeed?.seed.runtimeConfig) { + await seedRuntimeConfigStrict(parsedSeed.seed.runtimeConfig); + } + + if (parsedSeed?.seed.providers) { + await seedAdminProvidersFromJson(parsedSeed.seed.providers); + } + + if (shouldUseEnvProviderFallback(parsedSeed?.hasProvidersSection ?? false)) { + await seedDefaultAdminProviderFromEnvFallback(); + } + await cleanupLegacyDefaultTtsProviderSeedRow(); await cleanupLegacyDefaultTtsModelRows(); } -async function seedDefaultAdminProvider(): Promise { +function shouldUseEnvProviderFallback(hasProvidersSection: boolean): boolean { + return !hasProvidersSection; +} + +async function loadRuntimeSeedFromEnv(): Promise { + const pathValue = process.env[RUNTIME_SEED_JSON_PATH]?.trim(); + const inlineValue = process.env[RUNTIME_SEED_JSON]?.trim(); + + if (!pathValue && !inlineValue) return null; + + const raw = pathValue + ? await readFile(pathValue, 'utf8') + : inlineValue as string; + + return parseRuntimeSeedDocument(raw); +} + +function parseRuntimeSeedDocument(raw: string): ParsedSeedResult { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch (error) { + throw new Error(`Invalid ${RUNTIME_SEED_JSON}/${RUNTIME_SEED_JSON_PATH} JSON: ${String(error)}`); + } + + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error('Seed JSON root must be an object'); + } + + const record = parsed as Record; + const allowedTopLevel = new Set(['version', 'runtimeConfig', 'providers']); + const unknownTopLevel = Object.keys(record).filter((key) => !allowedTopLevel.has(key)); + if (unknownTopLevel.length > 0) { + throw new Error(`Seed JSON contains unknown top-level keys: ${unknownTopLevel.join(', ')}`); + } + + if (record.version !== SEED_VERSION) { + throw new Error(`Seed JSON version must be ${SEED_VERSION}`); + } + + let runtimeConfig: Record | undefined; + if (record.runtimeConfig !== undefined) { + if (!record.runtimeConfig || typeof record.runtimeConfig !== 'object' || Array.isArray(record.runtimeConfig)) { + throw new Error('Seed JSON runtimeConfig must be an object when provided'); + } + runtimeConfig = record.runtimeConfig as Record; + } + + let providers: SeedProviderInput[] | undefined; + if (record.providers !== undefined) { + if (!Array.isArray(record.providers)) { + throw new Error('Seed JSON providers must be an array when provided'); + } + providers = record.providers.map((entry, index) => validateSeedProviderEntry(entry, index)); + } + + return { + seed: { + version: SEED_VERSION, + ...(runtimeConfig ? { runtimeConfig } : {}), + ...(providers ? { providers } : {}), + }, + hasProvidersSection: Object.prototype.hasOwnProperty.call(record, 'providers'), + }; +} + +function validateSeedProviderEntry(value: unknown, index: number): SeedProviderInput { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new Error(`Seed JSON providers[${index}] must be an object`); + } + + const rec = value as Record; + const allowed = new Set([ + 'slug', + 'displayName', + 'providerType', + 'apiKey', + 'baseUrl', + 'defaultModel', + 'defaultInstructions', + 'enabled', + ]); + const unknown = Object.keys(rec).filter((key) => !allowed.has(key)); + if (unknown.length > 0) { + throw new Error(`Seed JSON providers[${index}] contains unknown keys: ${unknown.join(', ')}`); + } + + if (typeof rec.slug !== 'string' || !rec.slug.trim()) { + throw new Error(`Seed JSON providers[${index}].slug must be a non-empty string`); + } + if (typeof rec.displayName !== 'string' || !rec.displayName.trim()) { + throw new Error(`Seed JSON providers[${index}].displayName must be a non-empty string`); + } + if (typeof rec.providerType !== 'string') { + throw new Error(`Seed JSON providers[${index}].providerType must be a string`); + } + if (typeof rec.apiKey !== 'string' || !rec.apiKey.trim()) { + throw new Error(`Seed JSON providers[${index}].apiKey must be a non-empty string`); + } + + if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') { + throw new Error(`Seed JSON providers[${index}].baseUrl must be a string or null`); + } + if (rec.defaultModel !== undefined && rec.defaultModel !== null && typeof rec.defaultModel !== 'string') { + throw new Error(`Seed JSON providers[${index}].defaultModel must be a string or null`); + } + if (rec.defaultInstructions !== undefined && rec.defaultInstructions !== null && typeof rec.defaultInstructions !== 'string') { + throw new Error(`Seed JSON providers[${index}].defaultInstructions must be a string or null`); + } + if (rec.enabled !== undefined && typeof rec.enabled !== 'boolean') { + throw new Error(`Seed JSON providers[${index}].enabled must be a boolean when provided`); + } + + return { + slug: validateSlug(rec.slug), + displayName: rec.displayName.trim(), + providerType: validateProviderType(rec.providerType), + apiKey: rec.apiKey.trim(), + ...(rec.baseUrl !== undefined ? { baseUrl: normalizeOptionalString(rec.baseUrl) } : {}), + ...(rec.defaultModel !== undefined ? { defaultModel: normalizeOptionalString(rec.defaultModel) } : {}), + ...(rec.defaultInstructions !== undefined ? { defaultInstructions: normalizeOptionalString(rec.defaultInstructions) } : {}), + ...(rec.enabled !== undefined ? { enabled: rec.enabled } : {}), + }; +} + +function normalizeOptionalString(value: unknown): string | null { + if (value === null || value === undefined) return null; + const trimmed = String(value).trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function seedRuntimeConfigStrict(input: Record): Promise { + const result = await seedRuntimeConfigFromValues(input, 'json-seed'); + if (result.unknown.length > 0 || result.invalid.length > 0) { + const issues = [ + ...(result.unknown.length > 0 ? [`unknown keys: ${result.unknown.join(', ')}`] : []), + ...(result.invalid.length > 0 ? [`invalid values for: ${result.invalid.join(', ')}`] : []), + ].join('; '); + throw new Error(`Seed JSON runtimeConfig validation failed (${issues})`); + } +} + +async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promise { + if (providers.length === 0) return; + + const now = Date.now(); + for (const provider of providers) { + const existing = await db + .select({ id: adminProviders.id }) + .from(adminProviders) + .where(eq(adminProviders.slug, provider.slug)) + .limit(1); + if (existing.length > 0) continue; + + try { + const encrypted = encryptSecret(provider.apiKey); + await db.insert(adminProviders).values({ + id: randomUUID(), + slug: provider.slug, + displayName: provider.displayName, + providerType: provider.providerType, + baseUrl: provider.baseUrl ?? null, + apiKeyCiphertext: encrypted.ciphertext, + apiKeyIv: encrypted.iv, + apiKeyLast4: apiKeyLast4(provider.apiKey), + defaultModel: provider.defaultModel ?? null, + defaultInstructions: provider.defaultInstructions ?? null, + enabled: provider.enabled === false ? 0 : 1, + createdAt: now, + updatedAt: now, + }); + } catch (error) { + logDegraded(serverLogger, { + event: 'admin.seed.provider_insert.failed', + msg: 'Failed to seed provider from JSON seed', + step: 'seed_json_provider', + context: { + providerSlug: provider.slug, + providerDisplayName: provider.displayName, + }, + error, + }); + } + } +} + +async function seedDefaultAdminProviderFromEnvFallback(): Promise { const apiKey = process.env.API_KEY; if (!apiKey || !apiKey.trim()) return; @@ -81,36 +301,6 @@ async function seedDefaultAdminProvider(): Promise { try { enc = encryptSecret(apiKey); } catch (error) { - const hasExplicitRestriction = - Boolean( - RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar - && process.env[RUNTIME_CONFIG_SCHEMA.restrictUserApiKeys.envVar]?.trim(), - ); - if (!hasExplicitRestriction) { - try { - await db - .insert(adminSettings) - .values({ - key: 'restrictUserApiKeys', - valueJson: JSON.stringify(false) as never, - source: 'env-seed', - updatedAt: now, - }) - .onConflictDoNothing({ target: adminSettings.key }); - logDegraded(serverLogger, { - event: 'admin.seed.restrict_user_api_keys.defaulted', - msg: 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false', - step: 'set_restrict_user_api_keys_fallback', - }); - } catch (fallbackError) { - logDegraded(serverLogger, { - event: 'admin.seed.restrict_user_api_keys.fallback_write_failed', - msg: 'Failed to write restrictUserApiKeys fallback after encryption failure', - step: 'set_restrict_user_api_keys_fallback', - error: fallbackError, - }); - } - } logDegraded(serverLogger, { event: 'admin.seed.provider_key_encrypt.failed', msg: 'Failed to encrypt default provider API key', @@ -138,7 +328,7 @@ async function seedDefaultAdminProvider(): Promise { serverLogger.info({ event: 'admin.seed.provider_insert.succeeded', providerSlug: 'default-openai', - }, 'Created default-openai admin provider from env'); + }, 'Created default-openai admin provider from env fallback'); } catch (error) { logDegraded(serverLogger, { event: 'admin.seed.provider_insert.failed', @@ -151,12 +341,6 @@ async function seedDefaultAdminProvider(): Promise { } async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise { - // If an explicit env default exists, keep env-seeded behavior. - const explicit = RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar - ? process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar] - : undefined; - if (explicit && explicit.trim()) return; - const key = 'defaultTtsProvider'; const seededValue = JSON.stringify('default-openai'); try { @@ -193,3 +377,10 @@ async function cleanupLegacyDefaultTtsModelRows(): Promise { }); } } + +export const __seedInternals = { + runSeed, + parseRuntimeSeedDocument, + validateSeedProviderEntry, + shouldUseEnvProviderFallback, +}; diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 1abbd34..1f2048e 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,46 +1,29 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded } from '@/lib/server/errors/logging'; /** - * Runtime config: site-wide settings that used to live in build-time env vars. - * env vars. Each key has: + * Runtime config: site-wide settings that are persisted in admin_settings. + * Each key has: * - a TypeScript value type - * - an env var name used for the first-run seed - * - a parser that turns a string env value into the typed value - * - a default applied when neither the DB nor the env has a value - * - * On first boot, `seedRuntimeConfigFromEnv()` writes a row for every key - * whose env var is set. After that, `getRuntimeConfig()` reads from DB only. + * - a default used when no DB value exists + * - a validator for admin/seed writes */ -export type RuntimeConfigSource = 'env-seed' | 'admin'; +export type RuntimeConfigSource = 'json-seed' | 'env-seed' | 'admin'; export interface RuntimeConfigKeyDef { /** TS-level default. Used when neither DB nor env have a value. */ default: T; - /** Env var name to seed from on first run. Omit for DB/admin-only keys. */ - envVar?: string; - /** Parse a string env value to T. Returns undefined to skip seeding. */ - parseEnv(raw: string): T | undefined; /** Validate an incoming admin-supplied value. */ validate(value: unknown): T | undefined; } -function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef { +function booleanFlag(defaultValue: boolean): RuntimeConfigKeyDef { return { default: defaultValue, - envVar, - parseEnv(raw) { - const lower = raw.trim().toLowerCase(); - if (lower === '' ) return undefined; - if (['1', 'true', 'yes', 'on'].includes(lower)) return true; - if (['0', 'false', 'no', 'off'].includes(lower)) return false; - return undefined; - }, validate(value) { if (typeof value === 'boolean') return value; return undefined; @@ -51,9 +34,6 @@ function booleanFlag(defaultValue: boolean, envVar: string): RuntimeConfigKeyDef function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef { return { default: defaultValue, - parseEnv() { - return undefined; - }, validate(value) { if (typeof value === 'boolean') return value; return undefined; @@ -61,14 +41,9 @@ function runtimeBoolean(defaultValue: boolean): RuntimeConfigKeyDef { }; } -function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef { +function stringValue(defaultValue: string): RuntimeConfigKeyDef { return { default: defaultValue, - envVar, - parseEnv(raw) { - const trimmed = raw.trim(); - return trimmed ? trimmed : undefined; - }, validate(value) { if (typeof value === 'string') return value; return undefined; @@ -76,17 +51,9 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef< }; } -function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef { +function positiveIntValue(defaultValue: number): RuntimeConfigKeyDef { return { default: defaultValue, - envVar, - parseEnv(raw) { - const trimmed = raw.trim(); - if (!trimmed) return undefined; - const parsed = Number(trimmed); - if (!Number.isFinite(parsed) || parsed < 1) return undefined; - return Math.floor(parsed); - }, validate(value) { if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return undefined; return Math.floor(value); @@ -95,28 +62,32 @@ function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigK } export const RUNTIME_CONFIG_SCHEMA = { - defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'), - changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'), - enableUserSignups: booleanFlag(true, 'RUNTIME_SEED_ENABLE_USER_SIGNUPS'), - restrictUserApiKeys: booleanFlag(true, 'RUNTIME_SEED_RESTRICT_USER_API_KEYS'), + defaultTtsProvider: stringValue('custom-openai'), + changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json'), + enableUserSignups: booleanFlag(true), + restrictUserApiKeys: booleanFlag(true), // Historically the env semantics were "true unless explicitly 'false'", // i.e. the feature defaults to ON. - enableTtsProvidersTab: booleanFlag(true, 'RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB'), - enableAudiobookExport: booleanFlag(true, 'RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT'), - enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'), - enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), + enableTtsProvidersTab: booleanFlag(true), + enableAudiobookExport: booleanFlag(true), + enableDocxConversion: booleanFlag(true), + enableDestructiveDeleteActions: booleanFlag(true), showAllProviderModels: runtimeBoolean(true), - disableTtsRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_TTS_LIMIT'), + disableTtsRateLimit: booleanFlag(true), ttsDailyLimitAnonymous: positiveIntValue(50_000), ttsDailyLimitAuthenticated: positiveIntValue(500_000), ttsIpDailyLimitAnonymous: positiveIntValue(100_000), ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000), + ttsCacheMaxSizeBytes: positiveIntValue(256 * 1024 * 1024), + ttsCacheTtlMs: positiveIntValue(1000 * 60 * 30), + ttsUpstreamMaxRetries: positiveIntValue(2), + ttsUpstreamTimeoutMs: positiveIntValue(285_000), // Per-user throttle for expensive PDF-layout parsing. Disabled by default // (admins enable it in Settings → Admin), mirroring disableTtsRateLimit. // When enabled, the sub-limits below apply (admin-tunable, no env seed): // a short "burst" window plus a wider "sustained" window that also bounds // concurrency (the worker caps each job's duration). - disableComputeRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_COMPUTE_LIMIT'), + disableComputeRateLimit: booleanFlag(true), computeParseBurstMax: positiveIntValue(8), computeParseBurstWindowSec: positiveIntValue(60), computeParseSustainedMax: positiveIntValue(24), @@ -165,11 +136,6 @@ function buildDefaults(): RuntimeConfig { for (const key of RUNTIME_KEYS) { (out as Record)[key] = RUNTIME_CONFIG_SCHEMA[key].default; } - // In no-auth mode there is no admin UI to configure shared providers. - // Keep legacy BYOK available by default unless explicitly overridden. - if (!isAuthEnabled()) { - out.restrictUserApiKeys = false; - } return out; } @@ -264,7 +230,12 @@ export async function getRuntimeConfigWithSources(): Promise<{ const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value); if (validated !== undefined) { (values as Record)[key] = validated; - sources[key] = (row.source === 'env-seed' ? 'env-seed' : 'admin'); + sources[key] = ( + row.source === 'env-seed' + || row.source === 'json-seed' + ? row.source + : 'admin' + ); } else { sources[key] = 'default'; } @@ -305,55 +276,70 @@ export async function setRuntimeConfigKey( } } -/** Delete a runtime config row (resets to env/default behavior). */ +/** Delete a runtime config row (resets to default/implicit behavior). */ export async function clearRuntimeConfigKey(key: RuntimeConfigKey): Promise { await db.delete(adminSettings).where(eq(adminSettings.key, key)); } /** - * First-run seed: for every key whose row is absent AND env var is set, - * write a row with `source = 'env-seed'`. Idempotent — never overwrites - * an existing row. + * First-run seed from a parsed object. Only inserts keys that are absent, + * never overwriting an existing row. */ -export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfigKey[] }> { +export async function seedRuntimeConfigFromValues( + input: Record, + source: RuntimeConfigSource = 'json-seed', +): Promise<{ seeded: RuntimeConfigKey[]; invalid: string[]; unknown: string[] }> { const seeded: RuntimeConfigKey[] = []; - let existing: Map; - try { - existing = await readAllRows(); - } catch { - return { seeded }; - } + const invalid: string[] = []; + const unknown: string[] = []; + const validEntries: Array<{ key: RuntimeConfigKey; value: RuntimeConfig[RuntimeConfigKey] }> = []; + const existing = await readAllRows(); const now = Date.now(); - for (const key of RUNTIME_KEYS) { - if (existing.has(key)) continue; - const def = RUNTIME_CONFIG_SCHEMA[key]; - if (!def.envVar) continue; - const raw = process.env[def.envVar]; - if (raw === undefined || raw === null || raw === '') continue; - const parsed = def.parseEnv(raw); - if (parsed === undefined) continue; + + for (const [rawKey, rawValue] of Object.entries(input)) { + if (!RUNTIME_KEYS.includes(rawKey as RuntimeConfigKey)) { + unknown.push(rawKey); + continue; + } + const key = rawKey as RuntimeConfigKey; + const def = RUNTIME_CONFIG_SCHEMA[key] as RuntimeConfigKeyDef; + const validated = def.validate(rawValue); + if (validated === undefined) { + invalid.push(rawKey); + continue; + } + validEntries.push({ key, value: validated }); + } + + if (unknown.length > 0 || invalid.length > 0) { + return { seeded, invalid, unknown }; + } + + for (const entry of validEntries) { + if (existing.has(entry.key)) continue; try { await db .insert(adminSettings) .values({ - key, - valueJson: serializeForStorage(parsed) as never, - source: 'env-seed', + key: entry.key, + valueJson: serializeForStorage(entry.value) as never, + source, updatedAt: now, }) .onConflictDoNothing({ target: adminSettings.key }); - seeded.push(key); + seeded.push(entry.key); } catch (error) { logDegraded(serverLogger, { event: 'admin.runtime_config.seed.failed', msg: 'Runtime config seed failed', step: 'seed_runtime_config_key', - context: { key }, + context: { key: entry.key, source }, error, }); } } - return { seeded }; + + return { seeded, invalid, unknown }; } export { RUNTIME_KEYS }; diff --git a/src/lib/server/audiobooks/user-scope.ts b/src/lib/server/audiobooks/user-scope.ts deleted file mode 100644 index ecb351d..0000000 --- a/src/lib/server/audiobooks/user-scope.ts +++ /dev/null @@ -1,25 +0,0 @@ -export function buildAllowedAudiobookUserIds( - authEnabled: boolean, - userId: string | null, - unclaimedUserId: string, -): { preferredUserId: string; allowedUserIds: string[] } { - if (!authEnabled) { - return { preferredUserId: unclaimedUserId, allowedUserIds: [unclaimedUserId] }; - } - - const preferredUserId = userId ?? unclaimedUserId; - const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId])); - return { preferredUserId, allowedUserIds }; -} - -export function pickAudiobookOwner( - existingUserIds: string[], - preferredUserId: string, - unclaimedUserId: string, -): string | null { - const existing = new Set(existingUserIds); - // Keep resumed writes on unclaimed scope when the book already exists there. - if (existing.has(unclaimedUserId)) return unclaimedUserId; - if (existing.has(preferredUserId)) return preferredUserId; - return existingUserIds[0] ?? null; -} diff --git a/src/lib/server/auth/admin.ts b/src/lib/server/auth/admin.ts index 9967154..4f16e3b 100644 --- a/src/lib/server/auth/admin.ts +++ b/src/lib/server/auth/admin.ts @@ -9,16 +9,13 @@ export type AdminAuthContext = AuthContext & { /** * Returns the admin auth context, or a 401/403 Response if the requester is * not authenticated / not an admin. Mirrors the `requireAuthContext` shape. - * - * When auth is disabled, this always returns 403 — there is no notion of - * "admin" without authentication. */ export async function requireAdminContext( request: Pick, ): Promise { const ctx = await getAuthContext(request); - if (!ctx.authEnabled || !ctx.userId || !ctx.user) { + if (!ctx.userId || !ctx.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 781c89b..285d811 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -5,7 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { db } from "@/db"; -import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config"; +import { getRequiredAuthEnv, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config"; import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync"; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy'; @@ -58,6 +58,7 @@ function envFlagEnabled(name: string, defaultValue: boolean): boolean { } const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite; +const requiredAuthEnv = getRequiredAuthEnv(); const createAuth = () => betterAuth({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -65,8 +66,8 @@ const createAuth = () => betterAuth({ provider: process.env.POSTGRES_URL ? "pg" : "sqlite", schema: authSchema as Record, }), - secret: process.env.AUTH_SECRET!, - baseURL: process.env.BASE_URL!, + secret: requiredAuthEnv.authSecret, + baseURL: requiredAuthEnv.baseUrl, trustedOrigins: getTrustedOrigins(), emailAndPassword: { enabled: true, @@ -309,7 +310,7 @@ const createAuth = () => betterAuth({ ], }); -export const auth = isAuthEnabled() ? createAuth() : null; +export const auth = createAuth(); type AuthInstance = ReturnType; export type Session = AuthInstance["$Infer"]["Session"]; @@ -319,19 +320,12 @@ export type User = AuthSessionUser & { }; export type AuthContext = { - authEnabled: boolean; session: Session | null; user: User | null; userId: string | null; }; export async function getAuthContext(request: Pick): Promise { - const authEnabled = isAuthEnabled(); - - if (!authEnabled || !auth) { - return { authEnabled, session: null, user: null, userId: null }; - } - const session = await auth.api.getSession({ headers: request.headers }); const user = (session?.user ?? null) as User | null; const userId = user?.id ?? null; @@ -347,7 +341,7 @@ export async function getAuthContext(request: Pick): Pro } } - return { authEnabled, session, user, userId }; + return { session, user, userId }; } export async function requireAuthContext( @@ -356,10 +350,6 @@ export async function requireAuthContext( ): Promise { const ctx = await getAuthContext(request); - if (!ctx.authEnabled) { - return ctx; - } - if (!ctx.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/lib/server/auth/config.ts b/src/lib/server/auth/config.ts index 0d70370..5691850 100644 --- a/src/lib/server/auth/config.ts +++ b/src/lib/server/auth/config.ts @@ -1,9 +1,24 @@ -/** - * Centralized auth configuration check. - * Auth is only enabled when BOTH AUTH_SECRET and BASE_URL are set. - */ -export function isAuthEnabled(): boolean { - return !!(process.env.AUTH_SECRET && process.env.BASE_URL); +export type RequiredAuthEnv = { + authSecret: string; + baseUrl: string; +}; + +function getRequiredEnvValue(name: 'AUTH_SECRET' | 'BASE_URL'): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error( + `Missing required environment variable: ${name}. ` + + 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.', + ); + } + return value; +} + +export function getRequiredAuthEnv(): RequiredAuthEnv { + return { + authSecret: getRequiredEnvValue('AUTH_SECRET'), + baseUrl: getRequiredEnvValue('BASE_URL'), + }; } function parseBooleanEnv(name: string, defaultValue: boolean): boolean { @@ -21,25 +36,21 @@ function parseBooleanEnv(name: string, defaultValue: boolean): boolean { * Defaults to false when unset or invalid. */ export function isAnonymousAuthSessionsEnabled(): boolean { - if (!isAuthEnabled()) return false; + getRequiredAuthEnv(); return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false); } /** - * GitHub sign-in is available only when auth is enabled AND - * both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set. + * GitHub sign-in is available when both GITHUB_CLIENT_ID and + * GITHUB_CLIENT_SECRET are set. */ export function isGithubAuthEnabled(): boolean { - if (!isAuthEnabled()) return false; return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); } /** - * Get the auth base URL if auth is enabled, otherwise null. + * Get the required auth base URL. */ -export function getAuthBaseUrl(): string | null { - if (!isAuthEnabled()) { - return null; - } - return process.env.BASE_URL || null; +export function getAuthBaseUrl(): string { + return getRequiredAuthEnv().baseUrl; } diff --git a/src/lib/server/rate-limit/job-rate-limiter.ts b/src/lib/server/rate-limit/job-rate-limiter.ts index f5a41e5..ef6a45a 100644 --- a/src/lib/server/rate-limit/job-rate-limiter.ts +++ b/src/lib/server/rate-limit/job-rate-limiter.ts @@ -1,7 +1,6 @@ import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { db } from '@/db'; import { userJobEvents } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { nowTimestampMs } from '@/lib/shared/timestamps'; import type { RuntimeConfig } from '@/lib/server/admin/settings'; @@ -63,7 +62,7 @@ export function getPdfLayoutRateConfig(runtime: Pick< const safeDb = () => db as any; function isActive(config: Pick, userId: string | null | undefined): userId is string { - return isAuthEnabled() && config.enabled && Boolean(userId); + return config.enabled && Boolean(userId); } async function countSince(userId: string, action: JobRateAction, since: number): Promise { diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index 0a9dd68..b7ce741 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -1,6 +1,5 @@ import { db } from '@/db'; import { userTtsChars } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { eq, and, lt, sql } from 'drizzle-orm'; import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; @@ -158,7 +157,7 @@ export class RateLimiter { ): Promise { const limits = resolveRateLimitThresholds(options?.limits); const enabled = options?.enabled ?? true; - if (!isAuthEnabled() || !enabled) { + if (!enabled) { return { allowed: true, currentCount: 0, @@ -265,7 +264,7 @@ export class RateLimiter { ): Promise { const limits = resolveRateLimitThresholds(options?.limits); const enabled = options?.enabled ?? true; - if (!isAuthEnabled() || !enabled) { + if (!enabled) { return { allowed: true, currentCount: 0, @@ -321,8 +320,6 @@ export class RateLimiter { * Transfer char counts when anonymous user creates an account */ async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise { - if (!isAuthEnabled()) return; - const today = new Date().toISOString().split('T')[0]; const dateValue = today as unknown as UserTtsCharsDateValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index ce82ed1..01a7978 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -56,14 +56,83 @@ const replicateBlockedUntilByScope = new LRUCache({ max: REPLICATE_COOLDOWN_SCOPE_CACHE_MAX_ENTRIES, }); -const TTS_CACHE_MAX_SIZE_BYTES = Number(process.env.TTS_CACHE_MAX_SIZE_BYTES || 256 * 1024 * 1024); // 256MB -const TTS_CACHE_TTL_MS = Number(process.env.TTS_CACHE_TTL_MS || 1000 * 60 * 30); // 30 minutes +const DEFAULT_TTS_CACHE_MAX_SIZE_BYTES = 256 * 1024 * 1024; +const DEFAULT_TTS_CACHE_TTL_MS = 1000 * 60 * 30; +const DEFAULT_TTS_UPSTREAM_MAX_RETRIES = 2; +const DEFAULT_TTS_UPSTREAM_TIMEOUT_MS = 285_000; +const OPENAI_RETRY_INITIAL_DELAY_MS = 250; +const OPENAI_RETRY_MAX_DELAY_MS = 2000; +const OPENAI_RETRY_BACKOFF = 2; -const ttsAudioCache = new LRUCache({ - maxSize: TTS_CACHE_MAX_SIZE_BYTES, - sizeCalculation: (value) => value.byteLength, - ttl: TTS_CACHE_TTL_MS, -}); +export interface TtsUpstreamRuntimeSettings { + ttsCacheMaxSizeBytes?: number; + ttsCacheTtlMs?: number; + ttsUpstreamMaxRetries?: number; + ttsUpstreamTimeoutMs?: number; +} + +interface ResolvedTtsUpstreamRuntimeSettings { + ttsCacheMaxSizeBytes: number; + ttsCacheTtlMs: number; + ttsUpstreamMaxRetries: number; + ttsUpstreamTimeoutMs: number; +} + +function clampPositiveInteger(value: unknown, fallback: number): number { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.floor(parsed); +} + +function resolveTtsUpstreamSettings( + settings?: TtsUpstreamRuntimeSettings, +): ResolvedTtsUpstreamRuntimeSettings { + return { + ttsCacheMaxSizeBytes: clampPositiveInteger( + settings?.ttsCacheMaxSizeBytes, + DEFAULT_TTS_CACHE_MAX_SIZE_BYTES, + ), + ttsCacheTtlMs: clampPositiveInteger(settings?.ttsCacheTtlMs, DEFAULT_TTS_CACHE_TTL_MS), + ttsUpstreamMaxRetries: clampPositiveInteger( + settings?.ttsUpstreamMaxRetries, + DEFAULT_TTS_UPSTREAM_MAX_RETRIES, + ), + ttsUpstreamTimeoutMs: clampPositiveInteger( + settings?.ttsUpstreamTimeoutMs, + DEFAULT_TTS_UPSTREAM_TIMEOUT_MS, + ), + }; +} + +function createTtsAudioCache(maxSize: number, ttlMs: number): LRUCache { + return new LRUCache({ + maxSize, + sizeCalculation: (value) => value.byteLength, + ttl: ttlMs, + }); +} + +let activeCacheConfig = { + maxSize: DEFAULT_TTS_CACHE_MAX_SIZE_BYTES, + ttlMs: DEFAULT_TTS_CACHE_TTL_MS, +}; + +let ttsAudioCache = createTtsAudioCache(activeCacheConfig.maxSize, activeCacheConfig.ttlMs); + +function ensureTtsAudioCache(settings: ResolvedTtsUpstreamRuntimeSettings): void { + if ( + activeCacheConfig.maxSize === settings.ttsCacheMaxSizeBytes + && activeCacheConfig.ttlMs === settings.ttsCacheTtlMs + ) { + return; + } + + activeCacheConfig = { + maxSize: settings.ttsCacheMaxSizeBytes, + ttlMs: settings.ttsCacheTtlMs, + }; + ttsAudioCache = createTtsAudioCache(activeCacheConfig.maxSize, activeCacheConfig.ttlMs); +} const inflightRequests = new Map(); @@ -324,13 +393,11 @@ async function getTestMockTtsBuffer(testNamespace?: string | null): Promise { let attempt = 0; - const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2); - let delay = Number(process.env.TTS_RETRY_INITIAL_MS ?? 250); - const maxDelay = Number(process.env.TTS_RETRY_MAX_MS ?? 2000); - const backoff = Number(process.env.TTS_RETRY_BACKOFF ?? 2); + let delay = OPENAI_RETRY_INITIAL_DELAY_MS; for (; ;) { try { @@ -348,8 +415,8 @@ async function fetchTTSBufferWithRetry( throw error; } - await sleep(Math.min(delay, maxDelay)); - delay = Math.min(maxDelay, delay * backoff); + await sleep(Math.min(delay, OPENAI_RETRY_MAX_DELAY_MS)); + delay = Math.min(OPENAI_RETRY_MAX_DELAY_MS, delay * OPENAI_RETRY_BACKOFF); attempt += 1; } } @@ -434,14 +501,17 @@ async function buildReplicateInput(request: ResolvedServerTTSRequest): Promise { +async function runReplicateRequest( + request: ResolvedServerTTSRequest, + signal: AbortSignal, + maxRetries: number, +): Promise { const replicate = new Replicate({ auth: request.apiKey }); const input = await buildReplicateInput(request); const modelId = request.model as `${string}/${string}`; const cooldownScopeKey = getReplicateCooldownScopeKey(request); return runWithReplicateGate(cooldownScopeKey, signal, async () => { - const maxRetries = Number(process.env.TTS_MAX_RETRIES ?? 2); let attempt = 0; for (; ;) { @@ -493,19 +563,23 @@ async function runReplicateRequest(request: ResolvedServerTTSRequest, signal: Ab }); } -async function runProviderRequest(request: ResolvedServerTTSRequest, signal: AbortSignal): Promise { +async function runProviderRequest( + request: ResolvedServerTTSRequest, + signal: AbortSignal, + upstreamSettings: ResolvedTtsUpstreamRuntimeSettings, +): Promise { const mockBuffer = await getTestMockTtsBuffer(request.testNamespace); if (mockBuffer) return mockBuffer; if (request.provider === 'replicate') { - return runReplicateRequest(request, signal); + return runReplicateRequest(request, signal, upstreamSettings.ttsUpstreamMaxRetries); } const openai = new OpenAI({ apiKey: request.apiKey, baseURL: request.baseUrl, maxRetries: 0, - timeout: Number(process.env.TTS_UPSTREAM_TIMEOUT_MS ?? 285_000), + timeout: upstreamSettings.ttsUpstreamTimeoutMs, }); const createParams: ExtendedSpeechParams = { @@ -520,13 +594,17 @@ async function runProviderRequest(request: ResolvedServerTTSRequest, signal: Abo createParams.instructions = request.instructions; } - return fetchTTSBufferWithRetry(openai, createParams, signal); + return fetchTTSBufferWithRetry(openai, createParams, signal, upstreamSettings.ttsUpstreamMaxRetries); } export async function generateTTSBuffer( request: ServerTTSRequest, - signal?: AbortSignal + signal?: AbortSignal, + runtimeSettings?: TtsUpstreamRuntimeSettings, ): Promise { + const upstreamSettings = resolveTtsUpstreamSettings(runtimeSettings); + ensureTtsAudioCache(upstreamSettings); + const resolved = resolveTTSRequest(request); const cacheKey = makeCacheKey({ provider: resolved.provider, @@ -569,7 +647,7 @@ export async function generateTTSBuffer( consumers: 1, promise: (async () => { try { - const buffer = await runProviderRequest(resolved, controller.signal); + const buffer = await runProviderRequest(resolved, controller.signal, upstreamSettings); ttsAudioCache.set(cacheKey, buffer); return buffer; } finally { diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts index 0b12372..779c79e 100644 --- a/src/lib/server/tts/segments-audio.ts +++ b/src/lib/server/tts/segments-audio.ts @@ -3,7 +3,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { db } from '@/db'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; export type ResolvedSegmentAudio = { documentId: string; @@ -54,6 +53,7 @@ export async function resolveCompletedSegmentAudio( return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); } + const { resolveSegmentDocumentScope } = await import('@/lib/server/tts/segments-auth'); const scope = await resolveSegmentDocumentScope(request, documentId); if (scope instanceof Response) return scope; diff --git a/src/lib/server/tts/segments-auth.ts b/src/lib/server/tts/segments-auth.ts index e68b68b..ff19de3 100644 --- a/src/lib/server/tts/segments-auth.ts +++ b/src/lib/server/tts/segments-auth.ts @@ -3,14 +3,13 @@ import type { NextRequest } from 'next/server'; import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import type { ReaderType } from '@/types/user-state'; export type ResolvedSegmentDocumentScope = { testNamespace: string | null; storageUserId: string; - authEnabled: boolean; - userId: string | null; + userId: string; isAnonymousUser: boolean; documentVersion: number; readerType: ReaderType; @@ -28,11 +27,11 @@ export async function resolveSegmentDocumentScope( ): Promise { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 }); const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const storageUserId = ctxOrRes.userId; + const allowedUserIds = [storageUserId]; const rows = (await db .select({ @@ -55,7 +54,6 @@ export async function resolveSegmentDocumentScope( return { testNamespace, storageUserId: doc.userId, - authEnabled: ctxOrRes.authEnabled, userId: ctxOrRes.userId, isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous), documentVersion: Number(doc.lastModified), diff --git a/src/lib/server/user/claim-data.ts b/src/lib/server/user/claim-data.ts index 89cc319..3a9f88c 100644 --- a/src/lib/server/user/claim-data.ts +++ b/src/lib/server/user/claim-data.ts @@ -10,8 +10,6 @@ import { } from '../audiobooks/blobstore'; import { isS3Configured } from '../storage/s3'; -import { isAuthEnabled } from '@/lib/server/auth/config'; - type AudiobookRow = { id: string; userId: string; @@ -89,7 +87,7 @@ async function moveAudiobookBlobScope( } export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { - if (!isAuthEnabled() || !userId) { + if (!userId) { return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 }; } @@ -122,7 +120,7 @@ export async function transferUserDocuments( // eslint-disable-next-line @typescript-eslint/no-explicit-any options?: { db?: any }, ): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const database = options?.db ?? db; @@ -150,7 +148,7 @@ export async function transferUserAudiobooks( toUserId: string, namespace: string | null = null, ): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const books = (await db @@ -188,7 +186,7 @@ export async function transferUserAudiobooks( } export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const fromRows = (await db @@ -226,7 +224,7 @@ export async function transferUserPreferences(fromUserId: string, toUserId: stri } export async function transferUserProgress(fromUserId: string, toUserId: string): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const fromRows = (await db diff --git a/src/lib/server/user/resolve-state-scope.ts b/src/lib/server/user/resolve-state-scope.ts index 01b6488..584b0e3 100644 --- a/src/lib/server/user/resolve-state-scope.ts +++ b/src/lib/server/user/resolve-state-scope.ts @@ -1,13 +1,12 @@ import type { NextRequest } from 'next/server'; import type { AuthContext } from '@/lib/server/auth/auth'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; export type ResolvedUserStateScope = { auth: AuthContext; namespace: string | null; ownerUserId: string; - unclaimedUserId: string; }; export async function resolveUserStateScope( @@ -15,16 +14,14 @@ export async function resolveUserStateScope( ): Promise { const auth = await requireAuthContext(req); if (auth instanceof Response) return auth; + if (!auth.userId) return Response.json({ error: 'Unauthorized' }, { status: 401 }); const namespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(namespace); - const ownerUserId = auth.userId ?? unclaimedUserId; + const ownerUserId = auth.userId; return { auth, namespace, ownerUserId, - unclaimedUserId, }; } - diff --git a/src/middleware.ts b/src/middleware.ts index 28dc91d..050d589 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { getRequiredAuthEnv } from '@/lib/server/auth/config'; /** * Better Auth session cookie name (default prefix + session_token). @@ -34,12 +35,8 @@ function isPublicPath(pathname: string): boolean { return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } -function isAuthEnabled(): boolean { - return !!(process.env.AUTH_SECRET && process.env.BASE_URL); -} - function isAnonymousAuthEnabled(): boolean { - if (!isAuthEnabled()) return false; + getRequiredAuthEnv(); const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS; return raw?.trim().toLowerCase() === 'true'; } @@ -88,10 +85,7 @@ export function middleware(request: NextRequest) { } } - // When auth is disabled entirely, let everything through. - if (!isAuthEnabled()) { - return NextResponse.next(); - } + getRequiredAuthEnv(); const { pathname } = request.nextUrl; diff --git a/src/types/documents.ts b/src/types/documents.ts index e9fcc42..b50bd40 100644 --- a/src/types/documents.ts +++ b/src/types/documents.ts @@ -9,7 +9,7 @@ export interface BaseDocument { type: DocumentType; parseStatus?: 'pending' | 'running' | 'ready' | 'failed' | null; parsedJsonKey?: string | null; - scope?: 'user' | 'unclaimed'; + scope?: 'user'; folderId?: string; isConverting?: boolean; } diff --git a/tests/delete.spec.ts b/tests/delete.spec.ts index b42600c..18ef57e 100644 --- a/tests/delete.spec.ts +++ b/tests/delete.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers'; +import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName } from './helpers'; test.describe('Document deletion flow', () => { test.beforeEach(async ({ page }, testInfo) => { @@ -23,30 +23,4 @@ test.describe('Document deletion flow', () => { await expectDocumentListed(page, 'sample.pdf'); }); - - test('deletes all local documents from Settings modal', async ({ page }) => { - // This test only applies when auth is NOT enabled, since with auth - // the bulk-delete UI lives in the delete-account flow instead. - test.skip( - Boolean(process.env.AUTH_SECRET && process.env.BASE_URL), - 'Bulk document deletion is part of the delete-account flow when auth is enabled', - ); - - // Upload multiple docs (PDF + EPUB) - await uploadFile(page, 'sample.pdf'); - await uploadFile(page, 'sample.epub'); - - // Verify both appear - await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']); - - // Delete all local documents via Settings - await deleteAllLocalDocuments(page); - - // Assert both documents are removed - await expectNoDocumentLink(page, 'sample.pdf'); - await expectNoDocumentLink(page, 'sample.epub'); - - // Uploader should be visible when no docs remain - await expect(page.locator('input[type=file]').first()).toBeVisible({ timeout: 10000 }); - }); }); diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index 1115dc0..fa6356f 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -66,7 +66,7 @@ function parseAudiobookScopeFromKey( } export default async function globalTeardown(): Promise { - // Always clear namespaced no-auth SQL rows from prior runs. + // Always clear namespaced unclaimed SQL rows from prior runs. await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%')); await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); diff --git a/tests/helpers.ts b/tests/helpers.ts index 79d15b4..8c19785 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -5,10 +5,6 @@ import { createHash } from 'crypto'; const DIR = './tests/files/'; -function isAuthEnabledForTests() { - return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL); -} - // Small util to safely use filenames inside regex patterns function escapeRegExp(input: string) { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -250,37 +246,6 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace }); } - // In no-auth mode, all tests in a worker share the same server-side unclaimed identity. - // Clear docs at setup to avoid cross-test collisions on duplicate filenames. - if (!isAuthEnabledForTests()) { - const headers = namespace ? { 'x-openreader-test-namespace': namespace } : undefined; - let cleared = false; - let authProtected = false; - let attempts = 0; - while (!cleared && attempts < 3) { - attempts += 1; - try { - const res = await page.request.delete('/api/documents', { ...(headers ? { headers } : {}) }); - // If this endpoint requires auth, we're not in no-auth mode for this run. - // Skip cleanup rather than hard-failing setup. - if (res.status() === 401 || res.status() === 403) { - authProtected = true; - break; - } - if (res.ok()) { - cleared = true; - break; - } - } catch { - // retry - } - await page.waitForTimeout(200); - } - if (!cleared && !authProtected) { - throw new Error('Failed to clear server documents before test setup'); - } - } - // Pre-seed consent to prevent the cookie banner from blocking interactions. await page.addInitScript(() => { try { diff --git a/tests/unit/audiobook-scope.vitest.spec.ts b/tests/unit/audiobook-scope.vitest.spec.ts deleted file mode 100644 index ca9705b..0000000 --- a/tests/unit/audiobook-scope.vitest.spec.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope'; - -describe('audiobook scope selection', () => { - test('uses only unclaimed scope when auth is disabled', () => { - const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns'); - expect(result.preferredUserId).toBe('unclaimed::ns'); - expect(result.allowedUserIds).toEqual(['unclaimed::ns']); - }); - - test('includes both preferred and unclaimed scopes when auth is enabled', () => { - const result = buildAllowedAudiobookUserIds(true, 'user-123', 'unclaimed::ns'); - expect(result.preferredUserId).toBe('user-123'); - expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']); - }); - - test('deduplicates preferred/unclaimed ids when they are the same', () => { - const result = buildAllowedAudiobookUserIds(true, 'unclaimed::ns', 'unclaimed::ns'); - expect(result.allowedUserIds).toEqual(['unclaimed::ns']); - }); - - test('prefers unclaimed owner when both scopes exist', () => { - const owner = pickAudiobookOwner(['user-123', 'unclaimed::ns'], 'user-123', 'unclaimed::ns'); - expect(owner).toBe('unclaimed::ns'); - }); - - test('falls back to preferred owner when unclaimed is missing', () => { - const owner = pickAudiobookOwner(['user-123'], 'user-123', 'unclaimed::ns'); - expect(owner).toBe('user-123'); - }); - - test('returns null when no matching owners exist', () => { - const owner = pickAudiobookOwner([], 'user-123', 'unclaimed::ns'); - expect(owner).toBeNull(); - }); -}); diff --git a/tests/unit/auth-config.vitest.spec.ts b/tests/unit/auth-config.vitest.spec.ts index eac330d..2b7d999 100644 --- a/tests/unit/auth-config.vitest.spec.ts +++ b/tests/unit/auth-config.vitest.spec.ts @@ -1,27 +1,19 @@ import { describe, expect, test } from 'vitest'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; +import { getAuthBaseUrl, getRequiredAuthEnv, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; import { withEnv } from './support/env'; describe('auth config contract', () => { - test('auth is enabled only when AUTH_SECRET and BASE_URL are both set', async () => { + test('reads required AUTH_SECRET and BASE_URL', async () => { await withEnv( { - AUTH_SECRET: undefined, - BASE_URL: undefined, - }, - async () => { - expect(isAuthEnabled()).toBe(false); - expect(getAuthBaseUrl()).toBeNull(); - }, - ); - - await withEnv( - { - AUTH_SECRET: 'unit-secret', + AUTH_SECRET: 'unit-secret-2', BASE_URL: 'http://localhost:3003', }, async () => { - expect(isAuthEnabled()).toBe(true); + expect(getRequiredAuthEnv()).toEqual({ + authSecret: 'unit-secret-2', + baseUrl: 'http://localhost:3003', + }); expect(getAuthBaseUrl()).toBe('http://localhost:3003'); }, ); @@ -49,12 +41,12 @@ describe('auth config contract', () => { }, ); - test('anonymous sessions are always disabled when auth is disabled', async () => { + test('anonymous session config returns false for non-true values', async () => { await withEnv( { - AUTH_SECRET: undefined, - BASE_URL: undefined, - USE_ANONYMOUS_AUTH_SESSIONS: 'true', + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + USE_ANONYMOUS_AUTH_SESSIONS: '1', }, async () => { expect(isAnonymousAuthSessionsEnabled()).toBe(false); @@ -63,16 +55,6 @@ describe('auth config contract', () => { }); test.each([ - { - title: 'returns false when auth is disabled', - env: { - AUTH_SECRET: undefined, - BASE_URL: undefined, - GITHUB_CLIENT_ID: 'id', - GITHUB_CLIENT_SECRET: 'secret', - }, - expected: false, - }, { title: 'returns false when GitHub client id is missing', env: { diff --git a/tests/unit/changelog-check.vitest.spec.ts b/tests/unit/changelog-check.vitest.spec.ts index 19d8642..383e8dd 100644 --- a/tests/unit/changelog-check.vitest.spec.ts +++ b/tests/unit/changelog-check.vitest.spec.ts @@ -14,7 +14,6 @@ describe('changelog check scheduling', () => { let openCalls = 0; const args = { - authEnabled: true, isSessionPending: false, sessionUserId: 'u1', appVersion: '3.3.0', @@ -48,13 +47,12 @@ describe('changelog check scheduling', () => { expect(inFlightRef.current).toBeNull(); }); - test('does not run when auth is enabled and session is pending', async () => { + test('does not run while session is pending', async () => { const completedRef = { current: null as string | null }; const inFlightRef = { current: null as string | null }; let apiCalls = 0; const cleanup = scheduleChangelogCheck({ - authEnabled: true, isSessionPending: true, sessionUserId: null, appVersion: '3.3.0', diff --git a/tests/unit/onboarding-flow.vitest.spec.ts b/tests/unit/onboarding-flow.vitest.spec.ts index 27eaaa6..8569171 100644 --- a/tests/unit/onboarding-flow.vitest.spec.ts +++ b/tests/unit/onboarding-flow.vitest.spec.ts @@ -55,7 +55,7 @@ describe('onboarding flow resolver', () => { expect(step).toBe('changelog'); }); - test('resolves done when no steps are pending (auth and no-auth parity)', () => { + test('resolves done when no steps are pending', () => { const authStep = resolveNextOnboardingStep({ privacyRequired: true, privacyAccepted: true, @@ -64,7 +64,7 @@ describe('onboarding flow resolver', () => { migrationRequired: false, changelogPending: false, }); - const noAuthStep = resolveNextOnboardingStep({ + const minimalStep = resolveNextOnboardingStep({ privacyRequired: false, privacyAccepted: false, claimEligible: false, @@ -74,7 +74,7 @@ describe('onboarding flow resolver', () => { }); expect(authStep).toBe('done'); - expect(noAuthStep).toBe('done'); + expect(minimalStep).toBe('done'); }); }); diff --git a/tests/unit/rate-limit-runtime-settings.vitest.spec.ts b/tests/unit/rate-limit-runtime-settings.vitest.spec.ts index b39f3e9..b06eb34 100644 --- a/tests/unit/rate-limit-runtime-settings.vitest.spec.ts +++ b/tests/unit/rate-limit-runtime-settings.vitest.spec.ts @@ -7,21 +7,7 @@ describe('TTS rate limit runtime config seeds', () => { expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true); }); - test('parses disable seed boolean values', () => { - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('true')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('false')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('1')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('0')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('on')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('no')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('invalid')).toBeUndefined(); - }); - - test('daily limit values are runtime-only (no env seed vars)', () => { - expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.envVar).toBeUndefined(); - expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.envVar).toBeUndefined(); - expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAnonymous.envVar).toBeUndefined(); - expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAuthenticated.envVar).toBeUndefined(); + test('daily limit values are runtime defaults', () => { expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.default).toBe(50_000); expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.default).toBe(500_000); }); diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts new file mode 100644 index 0000000..71b2ad7 --- /dev/null +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -0,0 +1,424 @@ +import { and, eq, inArray, not } from 'drizzle-orm'; +import { describe, expect, test } from 'vitest'; + +import { db } from '../../src/db'; +import { adminProviders, adminSettings } from '../../src/db/schema'; +import { RUNTIME_KEYS, seedRuntimeConfigFromValues } from '../../src/lib/server/admin/settings'; +import { __seedInternals } from '../../src/lib/server/admin/seed'; + +type SettingRow = { + key: string; + valueJson: unknown; + source: string; + updatedAt: number | null; +}; + +type ProviderRow = { + id: string; + slug: string; + displayName: string; + providerType: string; + baseUrl: string | null; + apiKeyCiphertext: string; + apiKeyIv: string; + apiKeyLast4: string | null; + defaultModel: string | null; + defaultInstructions: string | null; + enabled: number; + createdAt: number; + updatedAt: number; +}; + +async function withEnv( + values: Record, + run: () => Promise, +): Promise { + const original = new Map(); + for (const key of Object.keys(values)) { + original.set(key, process.env[key]); + const next = values[key]; + if (next === undefined) { + delete process.env[key]; + } else { + process.env[key] = next; + } + } + try { + await run(); + } finally { + for (const [key, previous] of original.entries()) { + if (previous === undefined) delete process.env[key]; + else process.env[key] = previous; + } + } +} + +function parseStoredValue(stored: unknown): unknown { + if (typeof stored === 'string') { + try { + return JSON.parse(stored); + } catch { + return stored; + } + } + return stored; +} + +async function snapshotSettings(keys: string[]): Promise { + const rows = await db + .select({ + key: adminSettings.key, + valueJson: adminSettings.valueJson, + source: adminSettings.source, + updatedAt: adminSettings.updatedAt, + }) + .from(adminSettings) + .where(inArray(adminSettings.key, keys)); + return rows as SettingRow[]; +} + +async function restoreSettings(keys: string[], snapshot: SettingRow[]): Promise { + await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); + if (snapshot.length === 0) return; + + const now = Date.now(); + for (const row of snapshot) { + await db + .insert(adminSettings) + .values({ + key: row.key, + valueJson: row.valueJson as never, + source: row.source, + updatedAt: row.updatedAt ?? now, + }) + .onConflictDoUpdate({ + target: adminSettings.key, + set: { + valueJson: row.valueJson as never, + source: row.source, + updatedAt: row.updatedAt ?? now, + }, + }); + } +} + +async function snapshotProvidersBySlug(slugs: string[]): Promise { + const rows = await db.select().from(adminProviders).where(inArray(adminProviders.slug, slugs)); + return rows as ProviderRow[]; +} + +async function restoreProvidersBySlug(slugs: string[], snapshot: ProviderRow[]): Promise { + await db.delete(adminProviders).where(inArray(adminProviders.slug, slugs)); + if (snapshot.length === 0) return; + for (const row of snapshot) { + await db + .insert(adminProviders) + .values(row) + .onConflictDoUpdate({ + target: adminProviders.slug, + set: { + displayName: row.displayName, + providerType: row.providerType, + baseUrl: row.baseUrl, + apiKeyCiphertext: row.apiKeyCiphertext, + apiKeyIv: row.apiKeyIv, + apiKeyLast4: row.apiKeyLast4, + defaultModel: row.defaultModel, + defaultInstructions: row.defaultInstructions, + enabled: row.enabled, + updatedAt: row.updatedAt, + }, + }); + } +} + +async function hasNonTestProviderRows(testSlugs: string[]): Promise { + const rows = await db + .select({ slug: adminProviders.slug }) + .from(adminProviders) + .where(not(inArray(adminProviders.slug, testSlugs))) + .limit(1); + return rows.length > 0; +} + +describe('runtime seed JSON parsing', () => { + test('rejects malformed JSON input', () => { + expect(() => __seedInternals.parseRuntimeSeedDocument('{not json')).toThrow(/invalid/i); + }); + + test('rejects unknown top-level seed keys', () => { + const raw = JSON.stringify({ + version: 1, + runtimeConfig: { enableUserSignups: true }, + extra: true, + }); + + expect(() => __seedInternals.parseRuntimeSeedDocument(raw)).toThrow(/unknown top-level/i); + }); + + test('providers section disables env fallback path', () => { + expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); + expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); + }); +}); + +describe('runtime config JSON seeding', () => { + test('seeds validated values with json-seed source and does not overwrite', async () => { + const keys: string[] = ['enableUserSignups', 'ttsUpstreamMaxRetries']; + const snapshot = await snapshotSettings(keys); + + try { + await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); + + const first = await seedRuntimeConfigFromValues({ + enableUserSignups: false, + ttsUpstreamMaxRetries: 9, + }, 'json-seed'); + + expect(first.unknown).toEqual([]); + expect(first.invalid).toEqual([]); + expect(first.seeded.sort()).toEqual(['enableUserSignups', 'ttsUpstreamMaxRetries']); + + const rowsAfterFirst = await snapshotSettings(keys); + const byKeyFirst = new Map(rowsAfterFirst.map((row) => [row.key, row])); + + expect(parseStoredValue(byKeyFirst.get('enableUserSignups')?.valueJson)).toBe(false); + expect(parseStoredValue(byKeyFirst.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9); + expect(byKeyFirst.get('enableUserSignups')?.source).toBe('json-seed'); + expect(byKeyFirst.get('ttsUpstreamMaxRetries')?.source).toBe('json-seed'); + + const second = await seedRuntimeConfigFromValues({ + enableUserSignups: true, + ttsUpstreamMaxRetries: 1, + }, 'json-seed'); + + expect(second.seeded).toEqual([]); + const rowsAfterSecond = await snapshotSettings(keys); + const byKeySecond = new Map(rowsAfterSecond.map((row) => [row.key, row])); + expect(parseStoredValue(byKeySecond.get('enableUserSignups')?.valueJson)).toBe(false); + expect(parseStoredValue(byKeySecond.get('ttsUpstreamMaxRetries')?.valueJson)).toBe(9); + } finally { + await restoreSettings(keys, snapshot); + } + }); + + test('strictly rejects unknown/invalid entries without writes', async () => { + const key = 'enableUserSignups'; + const snapshot = await snapshotSettings([key]); + try { + await db.delete(adminSettings).where(eq(adminSettings.key, key)); + const result = await seedRuntimeConfigFromValues({ + unknownRuntimeSetting: true, + enableUserSignups: 'false', + }, 'json-seed'); + + expect(result.unknown).toEqual(['unknownRuntimeSetting']); + expect(result.invalid).toEqual(['enableUserSignups']); + expect(result.seeded).toEqual([]); + + const rows = await snapshotSettings([key]); + expect(rows).toHaveLength(0); + } finally { + await restoreSettings([key], snapshot); + } + }); + + test('supports full runtime config JSON seeding across all keys', async () => { + const keys: string[] = [...RUNTIME_KEYS]; + const snapshot = await snapshotSettings(keys); + const fullPayload: Record = { + defaultTtsProvider: 'seed-shared-provider', + changelogFeedUrl: 'https://example.com/changelog/manifest.json', + enableUserSignups: false, + restrictUserApiKeys: false, + enableTtsProvidersTab: false, + enableAudiobookExport: false, + enableDocxConversion: false, + enableDestructiveDeleteActions: false, + showAllProviderModels: false, + disableTtsRateLimit: false, + ttsDailyLimitAnonymous: 12345, + ttsDailyLimitAuthenticated: 23456, + ttsIpDailyLimitAnonymous: 34567, + ttsIpDailyLimitAuthenticated: 45678, + ttsCacheMaxSizeBytes: 16 * 1024 * 1024, + ttsCacheTtlMs: 600_000, + ttsUpstreamMaxRetries: 3, + ttsUpstreamTimeoutMs: 120_000, + disableComputeRateLimit: false, + computeParseBurstMax: 4, + computeParseBurstWindowSec: 30, + computeParseSustainedMax: 12, + computeParseSustainedWindowSec: 300, + maxUploadMb: 150, + }; + try { + await db.delete(adminSettings).where(inArray(adminSettings.key, keys)); + const result = await seedRuntimeConfigFromValues(fullPayload, 'json-seed'); + expect(result.unknown).toEqual([]); + expect(result.invalid).toEqual([]); + expect(result.seeded).toHaveLength(RUNTIME_KEYS.length); + + const rows = await snapshotSettings(keys); + const byKey = new Map(rows.map((row) => [row.key, row])); + expect(parseStoredValue(byKey.get('defaultTtsProvider')?.valueJson)).toBe('seed-shared-provider'); + expect(parseStoredValue(byKey.get('enableUserSignups')?.valueJson)).toBe(false); + expect(parseStoredValue(byKey.get('ttsUpstreamTimeoutMs')?.valueJson)).toBe(120_000); + expect(parseStoredValue(byKey.get('maxUploadMb')?.valueJson)).toBe(150); + expect(byKey.get('defaultTtsProvider')?.source).toBe('json-seed'); + } finally { + await restoreSettings(keys, snapshot); + } + }); + + test('schema key set is complete for JSON seed support', () => { + expect(RUNTIME_KEYS.length).toBeGreaterThan(0); + expect(RUNTIME_KEYS).toContain('defaultTtsProvider'); + expect(RUNTIME_KEYS).toContain('ttsUpstreamTimeoutMs'); + }); +}); + +describe('provider seeding and fallback precedence', () => { + const testSlugs = ['json-seeded-provider', 'default-openai']; + + test('seeds providers from JSON and skips API_BASE/API_KEY fallback', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ + version: 1, + providers: [ + { + slug: 'json-seeded-provider', + displayName: 'JSON Seeded Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:5555/v1', + apiKey: 'seeded_provider_key_1234', + defaultModel: 'kokoro', + enabled: true, + }, + ], + }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:9999/v1', + API_KEY: 'fallback_should_not_be_used', + AUTH_SECRET: 'seed-test-auth-secret-123', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + displayName: adminProviders.displayName, + providerType: adminProviders.providerType, + baseUrl: adminProviders.baseUrl, + defaultModel: adminProviders.defaultModel, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders) + .where(inArray(adminProviders.slug, testSlugs)); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'json-seeded-provider', + displayName: 'JSON Seeded Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:5555/v1', + defaultModel: 'kokoro', + apiKeyLast4: '1234', + }); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + + test('falls back to API_BASE/API_KEY when JSON providers are absent', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ version: 1, runtimeConfig: { enableUserSignups: true } }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); + if (blockedByOtherRows) return; + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:8880/v1', + API_KEY: 'fallback_env_api_key_9876', + AUTH_SECRET: 'seed-test-auth-secret-456', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + displayName: adminProviders.displayName, + providerType: adminProviders.providerType, + baseUrl: adminProviders.baseUrl, + defaultModel: adminProviders.defaultModel, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'default-openai', + displayName: 'Default (from env)', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + defaultModel: 'kokoro', + apiKeyLast4: '9876', + }); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + + test('does not use API_BASE/API_KEY fallback when providers key is present but empty', async () => { + const providerSnapshot = await snapshotProvidersBySlug(testSlugs); + const runtimeSeed = JSON.stringify({ version: 1, providers: [] }); + + try { + await db.delete(adminProviders).where(inArray(adminProviders.slug, testSlugs)); + const blockedByOtherRows = await hasNonTestProviderRows(testSlugs); + if (blockedByOtherRows) return; + + await withEnv({ + RUNTIME_SEED_JSON: runtimeSeed, + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: 'http://localhost:7777/v1', + API_KEY: 'fallback_should_stay_unused_1111', + AUTH_SECRET: 'seed-test-auth-secret-789', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ slug: adminProviders.slug }) + .from(adminProviders) + .where(eq(adminProviders.slug, 'default-openai')) as Array<{ slug: string }>; + expect(rows).toHaveLength(0); + } finally { + await restoreProvidersBySlug(testSlugs, providerSnapshot); + } + }); + + test('throws on malformed runtime seed JSON during full seed execution', async () => { + await withEnv({ + RUNTIME_SEED_JSON: '{bad json', + RUNTIME_SEED_JSON_PATH: undefined, + API_BASE: undefined, + API_KEY: undefined, + AUTH_SECRET: 'seed-test-auth-secret-abc', + }, async () => { + await expect(__seedInternals.runSeed()).rejects.toThrow(/invalid/i); + }); + }); +}); diff --git a/tests/unit/setup-env.ts b/tests/unit/setup-env.ts new file mode 100644 index 0000000..89c72de --- /dev/null +++ b/tests/unit/setup-env.ts @@ -0,0 +1,7 @@ +if (!process.env.AUTH_SECRET?.trim()) { + process.env.AUTH_SECRET = 'vitest-auth-secret'; +} + +if (!process.env.BASE_URL?.trim()) { + process.env.BASE_URL = 'http://localhost:3003'; +} diff --git a/tests/unit/signup-policy.vitest.spec.ts b/tests/unit/signup-policy.vitest.spec.ts index a74ba83..069a85b 100644 --- a/tests/unit/signup-policy.vitest.spec.ts +++ b/tests/unit/signup-policy.vitest.spec.ts @@ -7,16 +7,6 @@ describe('enableUserSignups runtime config', () => { test('defaults to enabled', () => { expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.default).toBe(true); }); - - test('parses first-boot env seed values', () => { - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('true')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('false')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('1')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('0')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('yes')).toBe(true); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('off')).toBe(false); - expect(RUNTIME_CONFIG_SCHEMA.enableUserSignups.parseEnv('maybe')).toBeUndefined(); - }); }); describe('signup policy enforcement', () => { diff --git a/vitest.config.ts b/vitest.config.ts index 2bf3656..3489ba3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; +if (!process.env.AUTH_SECRET?.trim()) { + process.env.AUTH_SECRET = 'vitest-auth-secret'; +} + +if (!/^https?:\/\//.test(process.env.BASE_URL ?? '')) { + process.env.BASE_URL = 'http://localhost:3003'; +} + const srcDir = fileURLToPath(new URL('./src/', import.meta.url)); const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', import.meta.url)); const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url)); @@ -33,6 +41,7 @@ export default defineConfig({ name: 'openreader', environment: 'node', include: ['tests/unit/**/*.vitest.spec.ts'], + setupFiles: ['tests/unit/setup-env.ts'], }, }, {