From f0800df74522a91bbfb6d0a02a9f50c36d21afe0 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 30 May 2026 20:47:55 -0600 Subject: [PATCH 01/10] refactor(tts): migrate TTS tuning from env vars to runtime config with admin controls Remove TTS cache and upstream tuning environment variables in favor of admin-managed runtime settings. Add new admin panel controls for TTS retry attempts, upstream timeout, audio cache size, and cache TTL. Update API routes and TTS generation logic to consume these runtime-configurable values, enabling live adjustment without redeploy. Update documentation to reflect the removal of related env vars and the new admin workflow. --- .env.example | 56 ++----- docs-site/docs/configure/admin-panel.md | 13 ++ .../docs/reference/environment-variables.md | 152 +++++++----------- src/app/api/audiobook/chapter/route.ts | 6 + src/app/api/tts/segments/ensure/route.ts | 7 +- src/components/admin/AdminFeaturesPanel.tsx | 65 ++++++++ src/contexts/RuntimeConfigContext.tsx | 8 + src/lib/server/admin/settings.ts | 4 + src/lib/server/tts/generate.ts | 122 +++++++++++--- 9 files changed, 269 insertions(+), 164 deletions(-) diff --git a/.env.example b/.env.example index 69c8ed0..e316a0f 100644 --- a/.env.example +++ b/.env.example @@ -15,15 +15,6 @@ 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 BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -34,19 +25,8 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr # (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. @@ -74,47 +54,28 @@ 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 # 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). +# (Optional) Values seeded into the admin-managed runtime config on first boot, then ignored. # RUNTIME_SEED_ENABLE_DOCX_CONVERSION=true # RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true # RUNTIME_SEED_ENABLE_TTS_PROVIDERS_TAB=true @@ -126,7 +87,10 @@ S3_BUCKET= # RUNTIME_SEED_DISABLE_TTS_LIMIT=true # RUNTIME_SEED_DISABLE_COMPUTE_LIMIT=true -# Migrations configuration +# (Optional) Test/dev overrides +# DISABLE_AUTH_RATE_LIMIT=false +# ENABLE_TEST_NAMESPACE=false + # (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`) diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index f8d32df..706115b 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -105,6 +105,19 @@ 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: diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 2fc71f9..cc1c680 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -17,21 +17,12 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `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 | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | | `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to enable 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) | | `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 | @@ -46,8 +37,6 @@ 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 | @@ -58,8 +47,10 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `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 | +| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | +| `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 | | `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 | @@ -72,6 +63,8 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `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) | +| `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 | | `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations | | `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass | @@ -112,49 +105,16 @@ Bootstrap API key for the legacy OpenAI-compatible TTS endpoint. - **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 +### TTS Upstream Settings (Runtime Settings) -Maximum in-memory TTS audio cache size in bytes. +TTS upstream request behavior and cache settings are now managed from **Settings → Admin → Site features → TTS upstream**. -- Default: `268435456` (256 MB) +- `ttsUpstreamMaxRetries` default: `2` +- `ttsUpstreamTimeoutMs` default: `285000` +- `ttsCacheMaxSizeBytes` default: `268435456` (256 MB) +- `ttsCacheTtlMs` default: `1800000` (30 minutes) -### 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 +There are no environment variables for these settings in v4. ### TTS Daily Rate Limiting (Runtime Settings) @@ -221,24 +181,6 @@ 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. - ### ADMIN_EMAILS Comma-separated list of email addresses that are auto-promoted to admin. @@ -369,25 +311,6 @@ Multiple library roots for server library import. ## 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). - ### EMBEDDED_COMPUTE_WORKER_PORT Embedded compute-worker HTTP port. @@ -464,6 +387,15 @@ Shared stale window in milliseconds. - 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. +### WHISPER_MODEL_BASE_URL + +Optional base URL override for the built-in ONNX Whisper alignment model downloader. + +- 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 Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureModel()`. @@ -476,14 +408,24 @@ Optional base URL override for PP-DocLayoutV3 artifacts downloaded by `ensureMod - `preprocessor_config.json` - Configure this on the worker service env (not only the app server env) -### WHISPER_MODEL_BASE_URL +### COMPUTE_WORKER_URL -Optional base URL override for the built-in ONNX Whisper alignment model downloader. +Base URL for standalone external compute worker mode. -- 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) +- 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 compute 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). ### FFMPEG_BIN @@ -594,6 +536,26 @@ Seeds the PDF parsing rate-limit on/off state on first boot. - 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)). +## Test and Dev Overrides + +### 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. + ## Migration Controls ### RUN_DRIZZLE_MIGRATIONS diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index f0875de..8ec30b2 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -614,6 +614,12 @@ export async function POST(request: NextRequest) { testNamespace, }, request.signal, + { + ttsCacheMaxSizeBytes: runtimeConfig.ttsCacheMaxSizeBytes, + ttsCacheTtlMs: runtimeConfig.ttsCacheTtlMs, + ttsUpstreamMaxRetries: runtimeConfig.ttsUpstreamMaxRetries, + ttsUpstreamTimeoutMs: runtimeConfig.ttsUpstreamTimeoutMs, + }, ); workDir = await mkdtemp(join(tmpdir(), 'openreader-audiobook-')); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 80178fb..191a5b0 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -641,7 +641,12 @@ export async function POST(request: NextRequest) { apiKey: requestCreds.apiKey || 'none', baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, - }, request.signal); + }, request.signal, { + ttsCacheMaxSizeBytes: runtimeConfig.ttsCacheMaxSizeBytes, + ttsCacheTtlMs: runtimeConfig.ttsCacheTtlMs, + ttsUpstreamMaxRetries: runtimeConfig.ttsUpstreamMaxRetries, + ttsUpstreamTimeoutMs: runtimeConfig.ttsUpstreamTimeoutMs, + }); stageTimings.generateTtsMs = Date.now() - ttsStartedAt; failedStage = 's3.put_audio'; diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 2facf72..73618ae 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -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 diff --git a/src/contexts/RuntimeConfigContext.tsx b/src/contexts/RuntimeConfigContext.tsx index 017bec5..a67754a 100644 --- a/src/contexts/RuntimeConfigContext.tsx +++ b/src/contexts/RuntimeConfigContext.tsx @@ -27,6 +27,10 @@ export interface RuntimeConfig { ttsDailyLimitAuthenticated: number; ttsIpDailyLimitAnonymous: number; ttsIpDailyLimitAuthenticated: number; + ttsCacheMaxSizeBytes: number; + ttsCacheTtlMs: number; + ttsUpstreamMaxRetries: number; + ttsUpstreamTimeoutMs: number; computeAvailable: boolean; } @@ -46,6 +50,10 @@ const RUNTIME_DEFAULTS: RuntimeConfig = { ttsDailyLimitAuthenticated: 500_000, ttsIpDailyLimitAnonymous: 100_000, ttsIpDailyLimitAuthenticated: 1_000_000, + ttsCacheMaxSizeBytes: 256 * 1024 * 1024, + ttsCacheTtlMs: 1000 * 60 * 30, + ttsUpstreamMaxRetries: 2, + ttsUpstreamTimeoutMs: 285_000, computeAvailable: true, }; diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index 1abbd34..24058e5 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -111,6 +111,10 @@ export const RUNTIME_CONFIG_SCHEMA = { 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): 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 { From 83aa1152cb7b1c3b341f678256a49a78f4fe9f8b Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 00:13:03 -0600 Subject: [PATCH 02/10] docs(config): update environment variable docs for runtime JSON seed and remove legacy RUNTIME_SEED_* usage Remove all references to legacy RUNTIME_SEED_* environment variables from documentation and codebase. Update docs and .env.example to document the new RUNTIME_SEED_JSON and RUNTIME_SEED_JSON_PATH variables for first-boot runtime config and provider seeding. Refactor admin seed logic and runtime config schema to eliminate env-var-based seeding in favor of JSON-based initialization. Update admin panel UI and badges to reflect new seed sources. Remove obsolete env parsing logic and tests for RUNTIME_SEED_* flags. Add new tests for JSON seed behavior. BREAKING CHANGE: RUNTIME_SEED_* environment variables are no longer supported; use RUNTIME_SEED_JSON or RUNTIME_SEED_JSON_PATH for first-boot runtime config and provider seeding. --- .env.example | 17 +- docs-site/docs/configure/admin-panel.md | 16 +- docs-site/docs/configure/tts-providers.md | 2 +- docs-site/docs/configure/tts-rate-limiting.md | 6 +- docs-site/docs/deploy/local-development.md | 4 +- docs-site/docs/deploy/vercel-deployment.md | 6 +- docs-site/docs/docker-quick-start.md | 2 +- .../docs/reference/environment-variables.md | 512 +++++++----------- src/components/admin/AdminFeaturesPanel.tsx | 4 +- src/lib/server/admin/seed.ts | 322 +++++++++-- src/lib/server/admin/settings.ts | 140 +++-- ...rate-limit-runtime-settings.vitest.spec.ts | 16 +- tests/unit/runtime-seed-json.vitest.spec.ts | 424 +++++++++++++++ tests/unit/signup-policy.vitest.spec.ts | 10 - 14 files changed, 982 insertions(+), 499 deletions(-) create mode 100644 tests/unit/runtime-seed-json.vitest.spec.ts diff --git a/.env.example b/.env.example index e316a0f..1bb2a04 100644 --- a/.env.example +++ b/.env.example @@ -75,18 +75,6 @@ S3_BUCKET= # (Optional) Override ffmpeg binary path used for audiobook processing # FFMPEG_BIN= -# (Optional) Values seeded into the admin-managed runtime config on first boot, then ignored. -# 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 @@ -95,3 +83,8 @@ S3_BUCKET= # 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/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 706115b..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. @@ -120,16 +120,16 @@ In v4 these settings are admin-only and are no longer configurable through envir ## 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 @@ -146,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/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..3f178c8 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -317,11 +317,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** when auth/admin is enabled, 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..3459c9c 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -86,11 +86,11 @@ 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. diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index f61ea07..8032db6 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -119,7 +119,7 @@ What this command enables: - 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. - 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 in auth-enabled setups, 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/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index cc1c680..3d9ebd8 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -3,27 +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. +For auth-enabled deployments, 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 | +| `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 (with `AUTH_SECRET`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `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 | -| `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 | @@ -37,38 +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) | -| `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 | -| `WHISPER_MODEL_BASE_URL` | Whisper ONNX model | onnx-community defaults | Optional base URL override for ONNX whisper-base_timestamped q4 downloads | -| `PDF_LAYOUT_MODEL_BASE_URL` | PDF layout model | PP-DocLayoutV3 ONNX base URL | Optional base URL override for `ensureModel()` | -| `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` | 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) | -| `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 | -| `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 @@ -79,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 @@ -91,47 +81,40 @@ 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) +- Read only for provider bootstrap when shared providers are empty. +- Stored encrypted at rest after bootstrap. +- After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. + +### TTS Daily Rate Limiting (Runtime Settings) + +Managed as runtime config in **Settings → Admin → Site features**. + +- `disableTtsRateLimit` default: `true` (daily TTS limits disabled) +- `ttsDailyLimitAnonymous` default: `50000` +- `ttsDailyLimitAuthenticated` default: `500000` +- `ttsIpDailyLimitAnonymous` default: `100000` +- `ttsIpDailyLimitAuthenticated` default: `1000000` ### TTS Upstream Settings (Runtime Settings) -TTS upstream request behavior and cache settings are now managed from **Settings → Admin → Site features → TTS upstream**. +Managed as runtime config in **Settings → Admin → Site features → TTS upstream**. - `ttsUpstreamMaxRetries` default: `2` - `ttsUpstreamTimeoutMs` default: `285000` - `ttsCacheMaxSizeBytes` default: `268435456` (256 MB) - `ttsCacheTtlMs` default: `1800000` (30 minutes) -There are no environment variables for these settings in v4. - -### TTS Daily Rate Limiting (Runtime Settings) - -TTS character rate limiting is now managed from **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` - -Optional first-boot seeds: - -- `RUNTIME_SEED_DISABLE_TTS_LIMIT` - -After first boot, these values are DB-backed admin runtime settings. +There are no dedicated env vars for these runtime settings. ## Auth and Identity @@ -141,7 +124,6 @@ External base URL for this OpenReader instance. - Required with `AUTH_SECRET` to enable auth - Example: `http://localhost:3003` or `https://reader.example.com` -- Related docs: [Auth](../configure/auth) ### AUTH_SECRET @@ -149,48 +131,38 @@ Secret key used by auth/session handling. - Required with `BASE_URL` to enable auth - 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` +- 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 @@ -200,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 @@ -210,110 +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 and Model Configuration ### EMBEDDED_COMPUTE_WORKER_PORT -Embedded compute-worker HTTP port. +Embedded compute worker port. - Default: `8081` @@ -325,252 +267,198 @@ 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. +- Default: `max(30m, 4x max compute timeout)` ### WHISPER_MODEL_BASE_URL -Optional base URL override for the built-in ONNX Whisper alignment model downloader. - -- 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) +Base URL for Whisper ONNX model downloads. ### 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) +Base URL for PDF layout model downloads. ### COMPUTE_WORKER_URL -Base URL for standalone external compute worker mode. +External compute worker URL. -- 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 compute worker service. -- App-side only: set on app server/root `.env` (routing target), not worker-only env files. -- Example: `http://localhost:8081` +- Leave unset for embedded worker mode ### COMPUTE_WORKER_TOKEN -Bearer token for compute-worker auth. +Shared token for app <-> external worker requests. -- 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 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 audiobook processing. -- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static` -- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg` - -### Compute (PDF Parsing) Rate Limiting (Runtime Settings) - -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. - -- `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`. - -Optional first-boot seed: `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT`. All other values are DB-backed admin runtime settings. - -## Legacy First-Boot Runtime Seeds (optional) - -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)). - -## Test and Dev Overrides +## Testing and CI ### DISABLE_AUTH_RATE_LIMIT -Controls Better Auth rate limiting. +Disables Better Auth request 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) +- Default: `false` ### 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. +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/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 73618ae..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; @@ -665,6 +665,8 @@ function SourceBadge({ )} {dirty ? ( Modified + ) : source === 'json-seed' ? ( + from seed ) : source === 'env-seed' ? ( from env ) : source === 'admin' ? ( diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 281f3d7..7831dea 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,209 @@ 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; + + const encrypted = encryptSecret(provider.apiKey); + + try { + 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 insert provider from JSON seed', + step: 'insert_seed_provider', + context: { providerSlug: provider.slug }, + error, + }); + } + } +} + +async function seedDefaultAdminProviderFromEnvFallback(): Promise { const apiKey = process.env.API_KEY; if (!apiKey || !apiKey.trim()) return; @@ -81,35 +299,28 @@ 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, - }); - } + 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', @@ -138,7 +349,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 +362,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 +398,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 24058e5..bf6058f 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -6,41 +6,25 @@ 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 +35,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 +42,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 +52,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,18 +63,18 @@ 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), @@ -120,7 +88,7 @@ export const RUNTIME_CONFIG_SCHEMA = { // 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), @@ -268,7 +236,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'; } @@ -309,55 +282,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/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/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', () => { From 1763a84ffc7d61a83ca66614441e34e74ff142fe Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 00:16:28 -0600 Subject: [PATCH 03/10] docs(env): clarify FFMPEG_BIN usage for audio processing in docs and env example Update .env.example and environment variable documentation to clarify that FFMPEG_BIN can override the ffmpeg binary path for general audio processing, not just audiobooks. Note that it is also used by compute worker Whisper audio decode in addition to audiobook routes. --- .env.example | 2 +- docs-site/docs/reference/environment-variables.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index 1bb2a04..bce7bd8 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,7 @@ S3_BUCKET= # 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) Test/dev overrides diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 3d9ebd8..4e98f8a 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -348,7 +348,9 @@ There are no dedicated env vars for these runtime settings. ### FFMPEG_BIN -Override ffmpeg binary path used for audiobook processing. +Override ffmpeg binary path used for audio processing. + +- Used by audiobook processing routes and compute worker Whisper audio decode. ## Testing and CI From bfe9567310a7d2c8b597122f35471bcbf771f0b2 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 00:21:20 -0600 Subject: [PATCH 04/10] Clarify compute worker token docs --- docs-site/docs/reference/environment-variables.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 4e98f8a..3421161 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -329,7 +329,7 @@ External compute worker URL. ### COMPUTE_WORKER_TOKEN -Shared token for app <-> external worker requests. +Shared token for app-to-external-worker requests. ## Compute PDF Parsing Rate Limiting (Runtime Settings) From 4bcd90274c666ebaf678f3908c351bf8ab58ca19 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 00:55:25 -0600 Subject: [PATCH 05/10] Handle JSON provider seed encryption failures per provider --- src/lib/server/admin/seed.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 7831dea..7e6ecd0 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -245,9 +245,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi .limit(1); if (existing.length > 0) continue; - const encrypted = encryptSecret(provider.apiKey); - try { + const encrypted = encryptSecret(provider.apiKey); await db.insert(adminProviders).values({ id: randomUUID(), slug: provider.slug, @@ -266,9 +265,12 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi } catch (error) { logDegraded(serverLogger, { event: 'admin.seed.provider_insert.failed', - msg: 'Failed to insert provider from JSON seed', - step: 'insert_seed_provider', - context: { providerSlug: provider.slug }, + msg: 'Failed to seed provider from JSON seed', + step: 'seed_json_provider', + context: { + providerSlug: provider.slug, + providerDisplayName: provider.displayName, + }, error, }); } From 5f28be5d586fe2bd969aef2aa815e08a19c3fc84 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 12:09:37 -0600 Subject: [PATCH 06/10] refactor(env): remove legacy no-auth mode and enforce required auth env vars Eliminate all code paths, configuration, and documentation related to running without authentication. Require AUTH_SECRET and BASE_URL at startup, updating middleware, server logic, and runtime checks to assume auth is always enabled. Simplify onboarding, settings, and test helpers to reflect mandatory auth. Update environment examples, Docker and deployment docs, and CI/test configs. Remove no-auth-specific UI flows, test cases, and feature toggles. --- .env.example | 10 ++-- .github/workflows/playwright.yml | 3 ++ README.md | 2 +- docs-site/docs/configure/auth.md | 15 ++---- docs-site/docs/configure/database.md | 3 +- docs-site/docs/deploy/local-development.md | 19 +++---- docs-site/docs/deploy/vercel-deployment.md | 2 +- docs-site/docs/docker-quick-start.md | 16 +++--- docs-site/docs/introduction.md | 2 +- .../docs/reference/environment-variables.md | 14 +++--- package.json | 2 +- playwright.config.ts | 10 ---- scripts/openreader-entrypoint.mjs | 22 ++++---- src/app/(app)/layout.tsx | 5 +- src/app/(app)/signin/page.tsx | 11 ---- src/app/(app)/signup/page.tsx | 13 +---- src/app/api/account/delete/route.ts | 5 -- src/app/api/audiobook/chapter/route.ts | 14 +++--- src/app/api/audiobook/route.ts | 8 +-- src/app/api/audiobook/status/route.ts | 4 +- src/app/api/auth/[...all]/route.ts | 9 +--- .../api/documents/[id]/parsed/events/route.ts | 2 +- src/app/api/documents/[id]/parsed/route.ts | 4 +- src/app/api/documents/[id]/settings/route.ts | 2 +- .../api/documents/blob/get/fallback/route.ts | 2 +- .../api/documents/blob/get/presign/route.ts | 2 +- .../documents/blob/preview/fallback/route.ts | 2 +- src/app/api/documents/blob/preview/utils.ts | 2 +- src/app/api/documents/route.ts | 4 +- src/app/api/rate-limit/status/route.ts | 17 ------- src/app/api/tts/shared-providers/route.ts | 13 ++--- src/components/SettingsModal.tsx | 30 ++++------- src/components/auth/AuthLoader.tsx | 10 ++-- src/components/auth/RateLimitBanner.tsx | 10 ++-- src/components/auth/UserMenu.tsx | 4 +- src/contexts/AuthRateLimitContext.tsx | 28 +++-------- src/contexts/ConfigContext.tsx | 22 ++++---- src/contexts/OnboardingFlowContext.tsx | 50 +++++++------------ src/hooks/useAuthSession.ts | 27 ++-------- src/lib/server/admin/seed.ts | 23 --------- src/lib/server/admin/settings.ts | 6 --- src/lib/server/audiobooks/user-scope.ts | 5 -- src/lib/server/auth/admin.ts | 5 +- src/lib/server/auth/auth.ts | 23 +++------ src/lib/server/auth/config.ts | 43 ++++++++++------ src/lib/server/rate-limit/job-rate-limiter.ts | 3 +- src/lib/server/rate-limit/rate-limiter.ts | 7 +-- src/lib/server/tts/segments-audio.ts | 2 +- src/lib/server/tts/segments-auth.ts | 6 +-- src/lib/server/user/claim-data.ts | 12 ++--- src/middleware.ts | 12 ++--- tests/delete.spec.ts | 28 +---------- tests/global-teardown.ts | 2 +- tests/helpers.ts | 35 ------------- tests/unit/audiobook-scope.vitest.spec.ts | 12 ++--- tests/unit/auth-config.vitest.spec.ts | 40 ++++----------- tests/unit/onboarding-flow.vitest.spec.ts | 2 +- tests/unit/setup-env.ts | 7 +++ vitest.config.ts | 9 ++++ 59 files changed, 226 insertions(+), 476 deletions(-) create mode 100644 tests/unit/setup-env.ts diff --git a/.env.example b/.env.example index bce7bd8..f9612d0 100644 --- a/.env.example +++ b/.env.example @@ -11,16 +11,16 @@ # 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 -# 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= @@ -29,7 +29,7 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr # (Optional) Comma-separated list of emails that are auto-promoted to admin. 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= 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/auth.md b/docs-site/docs/configure/auth.md index 34e5053..4967bd1 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,6 @@ 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. 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/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 3f178c8..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 @@ -321,7 +314,7 @@ On first boot, `API_KEY` / `API_BASE` can bootstrap `default-openai`, and `RUNTI ::: :::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 by seeding `runtimeConfig.restrictUserApiKeys=false` in runtime seed JSON). +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 3459c9c..c830ac5 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -93,7 +93,7 @@ If you must pre-seed site features/providers at deploy time, use `RUNTIME_SEED_J 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 8032db6..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** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON. +- `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 3421161..2389c18 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -6,7 +6,7 @@ toc_max_heading_level: 3 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 providers and runtime site features. +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`. ::: @@ -19,8 +19,8 @@ Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_P | `LOG_LEVEL` | Runtime logging | `info` | Set app server log level | | `API_BASE` | TTS provider bootstrap seed | unset | Optional first-boot base URL for `default-openai` | | `API_KEY` | TTS provider bootstrap seed | unset | Optional first-boot API key for `default-openai` | -| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | -| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | +| `BASE_URL` | Auth | unset | Required at startup | +| `AUTH_SECRET` | Auth | unset | Required at startup | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | | `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to allow anonymous auth sessions | | `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | @@ -120,16 +120,16 @@ There are no dedicated env vars for these runtime settings. ### 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` ### 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` ### AUTH_TRUSTED_ORIGINS diff --git a/package.json b/package.json index a7aa4d5..0faf053 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "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", + "test:ci": "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)/layout.tsx b/src/app/(app)/layout.tsx index c554046..a9fc058 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,13 @@ export const metadata: Metadata = { }; export default function AppLayout({ children }: { children: ReactNode }) { - const authEnabled = isAuthEnabled(); const authBaseUrl = getAuthBaseUrl(); const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); const githubAuthEnabled = isGithubAuthEnabled(); return ( { - 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 (

diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index ba09f63..0496b08 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; @@ -24,13 +24,6 @@ export default function SignUpPage() { const enableUserSignups = useFeatureFlag('enableUserSignups'); const { refresh: refreshRateLimit } = useAuthRateLimit(); - // 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); }; @@ -109,10 +102,6 @@ export default function SignUpPage() { } }; - if (!authEnabled) { - return null; - } - if (!enableUserSignups) { return (
diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 740aae5..26e1a0f 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -1,17 +1,12 @@ import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; export async function DELETE() { - if (!isAuthEnabled() || !auth) { - return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 }); - } - const reqHeaders = await headers(); const session = await auth.api.getSession({ diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 8ec30b2..c0342a3 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -286,11 +286,11 @@ export async function POST(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled, user } = ctxOrRes; + const { userId, user } = ctxOrRes; const runtimeConfig = await getResolvedRuntimeConfig(); const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { @@ -540,7 +540,7 @@ export async function POST(request: NextRequest) { ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated, }); - if (authEnabled && userId && ttsRateLimitEnabled) { + if (userId && ttsRateLimitEnabled) { const isAnonymous = Boolean(user?.isAnonymous); const charCount = data.text.length; const ip = getClientIp(request); @@ -809,10 +809,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -906,10 +906,10 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 0b2a5da..033b010 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -169,10 +169,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -404,10 +404,10 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 29bda02..9a1f561 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -74,10 +74,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 35ec1e4..e5802a6 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,11 +1,6 @@ import { auth } from "@/lib/server/auth/auth"; // path to your auth file import { toNextJsHandler } from "better-auth/next-js"; -const handlers = auth - ? toNextJsHandler(auth) - : { - POST: async () => new Response("Auth disabled", { status: 404 }), - GET: async () => new Response("Auth disabled", { status: 404 }) - }; +const handlers = toNextJsHandler(auth); -export const { POST, GET } = handlers; \ No newline at end of file +export const { POST, GET } = handlers; diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index b9091f9..e4590ca 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -162,7 +162,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserIdHash = hashForLog(storageUserId); - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const row = await loadPreferredRow({ documentId: id, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 3ca677f..0301e88 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -208,7 +208,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); @@ -365,7 +365,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts index 5b94e23..478ddf5 100644 --- a/src/app/api/documents/[id]/settings/route.ts +++ b/src/app/api/documents/[id]/settings/route.ts @@ -44,7 +44,7 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await db .select({ userId: documents.userId }) diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 5584cfc..841e070 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index 8346c3c..cdb0265 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index a83955e..949e785 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -56,7 +56,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts index e1784fe..ef11138 100644 --- a/src/app/api/documents/blob/preview/utils.ts +++ b/src/app/api/documents/blob/preview/utils.ts @@ -44,7 +44,7 @@ export async function validatePreviewRequest(req: NextRequest): Promise { @@ -556,14 +555,12 @@ export function SettingsModal({
- {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/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 96968a3..47f95a2 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -113,7 +113,6 @@ export function AuthLoader({ children }: { children: ReactNode }) { useEffect(() => { const checkStatus = async () => { - if (!authEnabled) return; if (isPending) return; if (session) { @@ -236,17 +235,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..aeadbfb 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?.authEnabled || !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?.authEnabled) { 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/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index fb69fb2..d753257 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -140,26 +140,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 +154,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 +174,7 @@ export function AuthRateLimitProvider({ allowed: newRemainingChars > 0 }; }); - }, [authEnabled, queryClient]); + }, [queryClient]); const onTTSStart = useCallback(() => { pendingTTSRef.current += 1; @@ -239,7 +224,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..b45981c 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,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return () => { window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); }; - }, [authEnabled, runFlow]); + }, [runFlow]); useEffect(() => { - if (!authEnabled) { - return () => { }; - } - return scheduleChangelogCheck({ - authEnabled, + authEnabled: true, isSessionPending, sessionUserId: userId, appVersion: runtimeConfig.appVersion, @@ -258,7 +248,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 +257,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return ( {children} - {authEnabled && ( - { }} - /> - )} - {authEnabled && ( - - )} + { }} + /> + ['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/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 7e6ecd0..c06cc48 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -301,29 +301,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { try { enc = encryptSecret(apiKey); } catch (error) { - 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', diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index bf6058f..1f2048e 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,7 +1,6 @@ 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'; @@ -137,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; } diff --git a/src/lib/server/audiobooks/user-scope.ts b/src/lib/server/audiobooks/user-scope.ts index ecb351d..03753d3 100644 --- a/src/lib/server/audiobooks/user-scope.ts +++ b/src/lib/server/audiobooks/user-scope.ts @@ -1,12 +1,7 @@ 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 }; 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..89afe15 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,13 @@ export type User = AuthSessionUser & { }; export type AuthContext = { - authEnabled: boolean; + authEnabled: true; 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 +342,7 @@ export async function getAuthContext(request: Pick): Pro } } - return { authEnabled, session, user, userId }; + return { authEnabled: true, session, user, userId }; } export async function requireAuthContext( @@ -356,10 +351,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/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..ab10d35 100644 --- a/src/lib/server/tts/segments-auth.ts +++ b/src/lib/server/tts/segments-auth.ts @@ -9,7 +9,7 @@ import type { ReaderType } from '@/types/user-state'; export type ResolvedSegmentDocumentScope = { testNamespace: string | null; storageUserId: string; - authEnabled: boolean; + authEnabled: true; userId: string | null; isAnonymousUser: boolean; documentVersion: number; @@ -32,7 +32,7 @@ export async function resolveSegmentDocumentScope( const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = (await db .select({ @@ -55,7 +55,7 @@ export async function resolveSegmentDocumentScope( return { testNamespace, storageUserId: doc.userId, - authEnabled: ctxOrRes.authEnabled, + authEnabled: true, 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/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/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 index ca9705b..d53c9c2 100644 --- a/tests/unit/audiobook-scope.vitest.spec.ts +++ b/tests/unit/audiobook-scope.vitest.spec.ts @@ -2,20 +2,14 @@ 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'); + test('includes both preferred and unclaimed scopes', () => { + const result = buildAllowedAudiobookUserIds('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'); + const result = buildAllowedAudiobookUserIds('unclaimed::ns', 'unclaimed::ns'); expect(result.allowedUserIds).toEqual(['unclaimed::ns']); }); 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/onboarding-flow.vitest.spec.ts b/tests/unit/onboarding-flow.vitest.spec.ts index 27eaaa6..6a23fd8 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, 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/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'], }, }, { From 20fa820952368c14abb178216ba5e904f45a8193 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 12:10:57 -0600 Subject: [PATCH 07/10] clean(tests): remove CI test script from package.json --- package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/package.json b/package.json index 0faf053..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": "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", From 936aa50f9ae6f04d485fea316dea7e1a6a4ae55e Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 13:01:55 -0600 Subject: [PATCH 08/10] refactor(api): remove all legacy unclaimed user scope logic and enforce strict userId scoping Eliminate all code, tests, and utilities related to the legacy 'unclaimed' user scope. All API endpoints, document and audiobook storage, and access logic now require a valid authenticated userId and only operate on resources owned by that user. Remove related helper functions, test cases, and conditional flows for anonymous/unclaimed data. Update types, client APIs, and UI logic to reflect that only 'user' scope is supported. This simplifies ownership checks and removes ambiguity around document and audiobook access. --- docs-site/docs/configure/auth.md | 1 + src/app/api/audiobook/chapter/route.ts | 62 ++++++------------- src/app/api/audiobook/route.ts | 51 ++++++--------- src/app/api/audiobook/status/route.ts | 29 ++++----- .../api/documents/[id]/parsed/events/route.ts | 8 +-- src/app/api/documents/[id]/parsed/route.ts | 14 ++--- src/app/api/documents/[id]/settings/route.ts | 14 ++--- .../api/documents/blob/get/fallback/route.ts | 8 +-- .../api/documents/blob/get/presign/route.ts | 8 +-- .../documents/blob/preview/fallback/route.ts | 8 +-- src/app/api/documents/blob/preview/utils.ts | 12 ++-- .../api/documents/docx-to-pdf/upload/route.ts | 6 +- src/app/api/documents/route.ts | 61 +++++------------- src/app/api/tts/segments/ensure/route.ts | 2 +- src/components/doclist/views/DocumentTile.tsx | 7 +-- src/contexts/OnboardingFlowContext.tsx | 1 - src/db/index.ts | 8 +-- src/lib/client/api/documents.ts | 5 +- src/lib/client/changelog-check.ts | 6 +- src/lib/server/audiobooks/user-scope.ts | 20 ------ src/lib/server/tts/segments-auth.ts | 12 ++-- src/lib/server/user/resolve-state-scope.ts | 9 +-- src/types/documents.ts | 2 +- tests/unit/audiobook-scope.vitest.spec.ts | 30 --------- tests/unit/changelog-check.vitest.spec.ts | 4 +- tests/unit/onboarding-flow.vitest.spec.ts | 4 +- 26 files changed, 120 insertions(+), 272 deletions(-) delete mode 100644 src/lib/server/audiobooks/user-scope.ts delete mode 100644 tests/unit/audiobook-scope.vitest.spec.ts diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 4967bd1..6332449 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -61,3 +61,4 @@ Admin assignment is reconciled on every session resolution, so removing an email ## 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/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index c0342a3..70e78f7 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; import { randomUUID } from 'crypto'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; @@ -26,8 +26,7 @@ import { ffprobeAudio, } from '@/lib/server/audiobooks/chapters'; import { isS3Configured } from '@/lib/server/storage/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; import { generateTTSBuffer } from '@/lib/server/tts/generate'; import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials'; @@ -285,29 +284,18 @@ export async function POST(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { userId, user } = ctxOrRes; + const { user } = ctxOrRes; + const storageUserId = ctxOrRes.userId; const runtimeConfig = await getResolvedRuntimeConfig(); const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { return NextResponse.json({ error: 'Invalid bookId parameter' }, { status: 400 }); } - const existingBookRows = await db - .select({ userId: audiobooks.userId }) - .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const storageUserId = - pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ) ?? preferredUserId; - await db .insert(audiobooks) .values({ @@ -540,7 +528,7 @@ export async function POST(request: NextRequest) { ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated, }); - if (userId && ttsRateLimitEnabled) { + if (ttsRateLimitEnabled) { const isAnonymous = Boolean(user?.isAnonymous); const charCount = data.text.length; const ip = getClientIp(request); @@ -551,7 +539,7 @@ export async function POST(request: NextRequest) { } const rateLimitResult = await rateLimiter.checkAndIncrementLimit( - { id: userId, isAnonymous }, + { id: storageUserId, isAnonymous }, charCount, { deviceId: device?.deviceId ?? null, @@ -808,26 +796,20 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { userId } = ctxOrRes; + const storageUserId = ctxOrRes.userId; const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const existingBookUserId = pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ); + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - if (!existingBookUserId) { + if (existingBookRows.length === 0) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace); + const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace); const chapter = findChapterFileNameByIndex( objects.map((object) => object.fileName), chapterIndex, @@ -839,7 +821,7 @@ export async function GET(request: NextRequest) { .where( and( eq(audiobookChapters.bookId, bookId), - eq(audiobookChapters.userId, existingBookUserId), + eq(audiobookChapters.userId, storageUserId), eq(audiobookChapters.chapterIndex, chapterIndex), ), ); @@ -848,7 +830,7 @@ export async function GET(request: NextRequest) { let buffer: Buffer; try { - buffer = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace); + buffer = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace); } catch (error) { if (isMissingBlobError(error)) { await db @@ -856,7 +838,7 @@ export async function GET(request: NextRequest) { .where( and( eq(audiobookChapters.bookId, bookId), - eq(audiobookChapters.userId, existingBookUserId), + eq(audiobookChapters.userId, storageUserId), eq(audiobookChapters.chapterIndex, chapterIndex), ), ); @@ -905,22 +887,16 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { userId } = ctxOrRes; + const storageUserId = ctxOrRes.userId; const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const storageUserId = pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ); + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - if (!storageUserId) { + if (existingBookRows.length === 0) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 033b010..336c772 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -3,7 +3,7 @@ import { spawn } from 'child_process'; import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import { join } from 'path'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; @@ -23,9 +23,8 @@ import { ffprobeAudio, } from '@/lib/server/audiobooks/chapters'; import { isS3Configured } from '@/lib/server/storage/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; import type { TTSAudiobookFormat } from '@/types/tts'; export const dynamic = 'force-dynamic'; @@ -168,25 +167,19 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { userId } = ctxOrRes; + const storageUserId = ctxOrRes.userId; const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const existingBookUserId = pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ); - if (!existingBookUserId) { + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); + if (existingBookRows.length === 0) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } - const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace); + const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace); const objectNames = objects.map((item) => item.fileName); const chapters = listChapterObjects(objectNames); if (chapters.length === 0) { @@ -205,9 +198,9 @@ export async function GET(request: NextRequest) { if (objectNames.includes(completeName) && objectNames.includes(manifestName)) { try { - const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, manifestName, testNamespace)).toString('utf8')); + const manifest = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, manifestName, testNamespace)).toString('utf8')); if (JSON.stringify(manifest) === JSON.stringify(signature)) { - const cached = await getAudiobookObjectBuffer(bookId, existingBookUserId, completeName, testNamespace); + const cached = await getAudiobookObjectBuffer(bookId, storageUserId, completeName, testNamespace); return new NextResponse(streamBuffer(cached), { headers: { 'Content-Type': chapterFileMimeType(format), @@ -220,14 +213,14 @@ export async function GET(request: NextRequest) { // Force regeneration below. } - await deleteAudiobookObject(bookId, existingBookUserId, completeName, testNamespace).catch(() => {}); - await deleteAudiobookObject(bookId, existingBookUserId, manifestName, testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, completeName, testNamespace).catch(() => {}); + await deleteAudiobookObject(bookId, storageUserId, manifestName, testNamespace).catch(() => {}); } const chapterRows = await db .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) .from(audiobookChapters) - .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId))); + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId))); const durationByIndex = new Map(); for (const row of chapterRows) { durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); @@ -241,7 +234,7 @@ export async function GET(request: NextRequest) { const localChapters: Array<{ index: number; title: string; localPath: string; duration: number }> = []; for (const chapter of chapters) { const localPath = join(workDir, chapter.fileName); - const bytes = await getAudiobookObjectBuffer(bookId, existingBookUserId, chapter.fileName, testNamespace); + const bytes = await getAudiobookObjectBuffer(bookId, storageUserId, chapter.fileName, testNamespace); await writeFile(localPath, bytes); let duration = 0; @@ -356,10 +349,10 @@ export async function GET(request: NextRequest) { await ensurePositiveDuration(outputPath, request.signal); const outputBytes = await readFile(outputPath); - await putAudiobookObject(bookId, existingBookUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace); + await putAudiobookObject(bookId, storageUserId, completeName, outputBytes, chapterFileMimeType(format), testNamespace); await putAudiobookObject( bookId, - existingBookUserId, + storageUserId, manifestName, Buffer.from(JSON.stringify(signature, null, 2), 'utf8'), 'application/json; charset=utf-8', @@ -404,21 +397,15 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId } = ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const storageUserId = ctxOrRes.userId; const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const storageUserId = pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ); + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - if (!storageUserId) { + if (existingBookRows.length === 0) { return NextResponse.json({ error: 'Book not found' }, { status: 404 }); } diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 9a1f561..6416dc3 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { audiobooks, audiobookChapters } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; @@ -7,8 +7,7 @@ import { getAudiobookObjectBuffer, isMissingBlobError, listAudiobookObjects } fr import { decodeChapterFileName } from '@/lib/server/audiobooks/chapters'; import { pruneAudiobookChaptersNotOnDisk } from '@/lib/server/audiobooks/prune'; import { isS3Configured } from '@/lib/server/storage/s3'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import type { AudiobookGenerationSettings } from '@/types/client'; import type { TTSAudiobookChapter, TTSAudiobookFormat } from '@/types/tts'; import { errorToLog, serverLogger } from '@/lib/server/logger'; @@ -73,22 +72,16 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const { userId } = ctxOrRes; + const storageUserId = ctxOrRes.userId; const testNamespace = getOpenReaderTestNamespace(request.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) - .where(and(eq(audiobooks.id, bookId), inArray(audiobooks.userId, allowedUserIds))); - const existingBookUserId = pickAudiobookOwner( - existingBookRows.map((book: { userId: string }) => book.userId), - preferredUserId, - unclaimedUserId, - ); + .where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); - if (!existingBookUserId) { + if (existingBookRows.length === 0) { return NextResponse.json({ chapters: [], exists: false, @@ -98,20 +91,20 @@ export async function GET(request: NextRequest) { }); } - const objects = await listAudiobookObjects(bookId, existingBookUserId, testNamespace); + const objects = await listAudiobookObjects(bookId, storageUserId, testNamespace); const objectNames = objects.map((object) => object.fileName); const chapterObjects = listChapterObjects(objectNames); await pruneAudiobookChaptersNotOnDisk( bookId, - existingBookUserId, + storageUserId, chapterObjects.map((chapter) => chapter.index), ); const chapterRows = await db .select({ chapterIndex: audiobookChapters.chapterIndex, duration: audiobookChapters.duration }) .from(audiobookChapters) - .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, existingBookUserId))); + .where(and(eq(audiobookChapters.bookId, bookId), eq(audiobookChapters.userId, storageUserId))); const durationByIndex = new Map(); for (const row of chapterRows) { durationByIndex.set(row.chapterIndex, Number(row.duration ?? 0)); @@ -128,7 +121,7 @@ export async function GET(request: NextRequest) { let settings: AudiobookGenerationSettings | null = null; try { - settings = JSON.parse((await getAudiobookObjectBuffer(bookId, existingBookUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; + settings = JSON.parse((await getAudiobookObjectBuffer(bookId, storageUserId, 'audiobook.meta.json', testNamespace)).toString('utf8')) as AudiobookGenerationSettings; } catch (error) { if (!isMissingBlobError(error)) throw error; settings = null; @@ -139,7 +132,7 @@ export async function GET(request: NextRequest) { if (!exists) { // Deleting the audiobook row cascades to audiobookChapters via bookFk - await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, existingBookUserId))); + await db.delete(audiobooks).where(and(eq(audiobooks.id, bookId), eq(audiobooks.userId, storageUserId))); return NextResponse.json({ chapters: [], exists: false, diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index e4590ca..927c6ff 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -10,7 +10,7 @@ import { fetchWorkerOperationState } from '@/lib/server/compute/worker-op-state' import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { normalizeParseStatus, parseDocumentParseState } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { createRequestLogger, hashForLog } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; @@ -147,6 +147,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const authCtxOrRes = await requireAuthContext(req); if (authCtxOrRes instanceof Response) return authCtxOrRes; + if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const params = await ctx.params; const id = (params.id || '').trim().toLowerCase(); @@ -159,10 +160,9 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string : null; const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; + const storageUserId = authCtxOrRes.userId; const storageUserIdHash = hashForLog(storageUserId); - const allowedUserIds = [storageUserId, unclaimedUserId]; + const allowedUserIds = [storageUserId]; const row = await loadPreferredRow({ documentId: id, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 0301e88..6ed1d96 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -21,7 +21,7 @@ import { stringifyDocumentParseState, } from '@/lib/server/documents/parse-state'; import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { checkJobRate, recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter'; import { buildComputeRateLimitedResponse } from '@/lib/server/rate-limit/problem-response'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; @@ -196,6 +196,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const authCtxOrRes = await requireAuthContext(req); if (authCtxOrRes instanceof Response) return authCtxOrRes; + if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const params = await ctx.params; const id = (params.id || '').trim().toLowerCase(); @@ -206,9 +207,8 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string } const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = authCtxOrRes.userId; + const allowedUserIds = [storageUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); @@ -347,6 +347,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string const authCtxOrRes = await requireAuthContext(req); if (authCtxOrRes instanceof Response) return authCtxOrRes; + if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const params = await ctx.params; const id = (params.id || '').trim().toLowerCase(); @@ -363,9 +364,8 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string } const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = authCtxOrRes.userId; + const allowedUserIds = [storageUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts index 478ddf5..0b2b282 100644 --- a/src/app/api/documents/[id]/settings/route.ts +++ b/src/app/api/documents/[id]/settings/route.ts @@ -1,9 +1,8 @@ import { NextRequest, NextResponse } from 'next/server'; -import { and, eq, inArray } from 'drizzle-orm'; +import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { documentSettings, documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; import { mergeDocumentSettings } from '@/lib/shared/document-settings'; import { DEFAULT_DOCUMENT_SETTINGS, type DocumentSettings } from '@/types/document-settings'; import { coerceTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; @@ -35,21 +34,19 @@ function parseStored(value: unknown): DocumentSettings { } async function resolveDocumentAccess(req: NextRequest, documentId: string): Promise< - | { ownerUserId: string; allowedUserIds: string[] } + | { ownerUserId: string } | Response > { const authCtxOrRes = await requireAuthContext(req); if (authCtxOrRes instanceof Response) return authCtxOrRes; + if (!authCtxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = authCtxOrRes.userId; const rows = await db .select({ userId: documents.userId }) .from(documents) - .where(and(eq(documents.id, documentId), inArray(documents.userId, allowedUserIds))) + .where(and(eq(documents.id, documentId), eq(documents.userId, storageUserId))) .limit(1); if (!rows[0]) { @@ -58,7 +55,6 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom return { ownerUserId: rows[0].userId, - allowedUserIds, }; } diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 841e070..1929aeb 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -5,7 +5,7 @@ import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { contentTypeForName } from '@/lib/server/storage/library-mount'; import { getDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; @@ -34,11 +34,11 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = ctxOrRes.userId; + const allowedUserIds = [storageUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index cdb0265..4992354 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -4,7 +4,7 @@ import { db } from '@/db'; import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { isValidDocumentId, presignGet } from '@/lib/server/documents/blobstore'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; @@ -24,11 +24,11 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = ctxOrRes.userId; + const allowedUserIds = [storageUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index 949e785..c67da8d 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -20,7 +20,7 @@ import { isPreviewableDocumentType, } from '@/lib/server/documents/previews'; import { extractRawTextSnippet } from '@/lib/server/documents/text-snippets'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; export const dynamic = 'force-dynamic'; @@ -52,11 +52,11 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = ctxOrRes.userId; + const allowedUserIds = [storageUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts index ef11138..937942f 100644 --- a/src/app/api/documents/blob/preview/utils.ts +++ b/src/app/api/documents/blob/preview/utils.ts @@ -5,7 +5,7 @@ import { documents } from '@/db/schema'; import { requireAuthContext } from '@/lib/server/auth/auth'; import { isValidDocumentId } from '@/lib/server/documents/blobstore'; import { isPreviewableDocumentType } from '@/lib/server/documents/previews'; -import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace'; +import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { isS3Configured } from '@/lib/server/storage/s3'; export function s3NotConfiguredResponse(): NextResponse { @@ -37,15 +37,11 @@ export async function validatePreviewRequest(req: NextRequest): Promise null); const documentsData = parseDocumentPayload(body); @@ -171,7 +171,7 @@ export async function POST(req: NextRequest) { type: doc.type, size: headSize, lastModified: doc.lastModified, - scope: storageUserId === unclaimedUserId ? 'unclaimed' : 'user', + scope: 'user', }); await enqueueDocumentPreview( @@ -224,11 +224,10 @@ export async function GET(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); - const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = [storageUserId, unclaimedUserId]; + const storageUserId = ctxOrRes.userId; + const allowedUserIds = [storageUserId]; const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); @@ -259,21 +258,7 @@ export async function GET(req: NextRequest) { parsedJsonKey: string | null; }>; - const preferredById = new Map(); - for (const row of rows) { - const existing = preferredById.get(row.id); - if (!existing) { - preferredById.set(row.id, row); - continue; - } - const isRowPrimary = row.userId === storageUserId; - const isExistingPrimary = existing.userId === storageUserId; - if (isRowPrimary && !isExistingPrimary) { - preferredById.set(row.id, row); - } - } - - const results: BaseDocument[] = Array.from(preferredById.values()).map((doc) => { + const results: BaseDocument[] = rows.map((doc) => { const type = normalizeDocumentType(doc.type, doc.name); return { id: doc.id, @@ -283,7 +268,7 @@ export async function GET(req: NextRequest) { type, parseStatus: type === 'pdf' ? normalizeParseStatus(parseDocumentParseState(doc.parseState).status) : null, parsedJsonKey: doc.parsedJsonKey, - scope: doc.userId === unclaimedUserId ? 'unclaimed' : 'user', + scope: 'user', }; }); @@ -306,37 +291,19 @@ export async function DELETE(req: NextRequest) { const ctxOrRes = await requireAuthContext(req); if (ctxOrRes instanceof Response) return ctxOrRes; + if (!ctxOrRes.userId) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); const testNamespace = getOpenReaderTestNamespace(req.headers); - const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const storageUserId = ctxOrRes.userId ?? unclaimedUserId; + const storageUserId = ctxOrRes.userId; const url = new URL(req.url); const idsParam = url.searchParams.get('ids'); const scopeParam = (url.searchParams.get('scope') || '').toLowerCase().trim(); - - const wantsUnclaimed = scopeParam === 'unclaimed'; - const wantsUser = scopeParam === '' || scopeParam === 'user'; - - if (!wantsUser && !wantsUnclaimed) { - return NextResponse.json( - { error: "Invalid scope. Expected 'user' (default) or 'unclaimed'." }, - { status: 400 }, - ); + if (scopeParam && scopeParam !== 'user') { + return NextResponse.json({ error: "Invalid scope. Expected 'user' (default)." }, { status: 400 }); } - if (wantsUnclaimed && ctxOrRes.user?.isAnonymous) { - return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); - } - - const targetUserIds = Array.from( - new Set( - [ - ...(wantsUser ? [storageUserId] : []), - ...(wantsUnclaimed ? [unclaimedUserId] : []), - ].filter(Boolean), - ), - ); + const targetUserIds = [storageUserId]; if (targetUserIds.length === 0) { return NextResponse.json({ success: true, deleted: 0 }); diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index 191a5b0..fdd6b2d 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -468,7 +468,7 @@ export async function POST(request: NextRequest) { segmentId: segment.segmentId, }); - if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) { + if (ttsRateLimitEnabled) { const charCount = segment.text.length; const ip = getClientIp(request); const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null; 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/OnboardingFlowContext.tsx b/src/contexts/OnboardingFlowContext.tsx index b45981c..e1cadaf 100644 --- a/src/contexts/OnboardingFlowContext.tsx +++ b/src/contexts/OnboardingFlowContext.tsx @@ -234,7 +234,6 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { useEffect(() => { return scheduleChangelogCheck({ - authEnabled: true, isSessionPending, sessionUserId: userId, appVersion: runtimeConfig.appVersion, 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/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/audiobooks/user-scope.ts b/src/lib/server/audiobooks/user-scope.ts deleted file mode 100644 index 03753d3..0000000 --- a/src/lib/server/audiobooks/user-scope.ts +++ /dev/null @@ -1,20 +0,0 @@ -export function buildAllowedAudiobookUserIds( - userId: string | null, - unclaimedUserId: string, -): { preferredUserId: string; allowedUserIds: string[] } { - 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/tts/segments-auth.ts b/src/lib/server/tts/segments-auth.ts index ab10d35..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: true; - 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 = [storageUserId, 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: true, userId: ctxOrRes.userId, isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous), documentVersion: Number(doc.lastModified), 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/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/unit/audiobook-scope.vitest.spec.ts b/tests/unit/audiobook-scope.vitest.spec.ts deleted file mode 100644 index d53c9c2..0000000 --- a/tests/unit/audiobook-scope.vitest.spec.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { describe, expect, test } from 'vitest'; -import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope'; - -describe('audiobook scope selection', () => { - test('includes both preferred and unclaimed scopes', () => { - const result = buildAllowedAudiobookUserIds('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('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/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 6a23fd8..8569171 100644 --- a/tests/unit/onboarding-flow.vitest.spec.ts +++ b/tests/unit/onboarding-flow.vitest.spec.ts @@ -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'); }); }); From eebb54b0185bc3a5a71ef629f7fcf9fb72b7b19a Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 13:10:01 -0600 Subject: [PATCH 09/10] refactor(auth): remove redundant authEnabled flag from contexts and components Eliminate the unused authEnabled property from context providers, hooks, components, and API responses. All logic and UI now assume authentication is required, simplifying prop signatures and reducing branching. Update related types, context values, and function calls to reflect this change. This streamlines the authentication flow and removes unnecessary configuration. --- src/app/(app)/epub/[id]/useEpubDocument.ts | 3 -- src/app/(app)/layout.tsx | 1 - src/app/(app)/signin/page.tsx | 4 +-- src/app/(app)/signup/page.tsx | 4 +-- src/app/api/rate-limit/status/route.ts | 2 -- src/app/providers.tsx | 4 +-- src/components/PrivacyModal.tsx | 4 +-- src/components/SettingsModal.tsx | 2 +- src/components/auth/AuthLoader.tsx | 5 ++- src/components/auth/RateLimitBanner.tsx | 4 +-- src/contexts/AuthRateLimitContext.tsx | 10 ++---- src/contexts/TTSContext.tsx | 39 +++++++++------------ src/hooks/epub/useEPUBLocationController.ts | 15 +++----- src/lib/server/auth/auth.ts | 3 +- 14 files changed, 36 insertions(+), 64 deletions(-) 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 a9fc058..dcf5127 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -28,7 +28,6 @@ export default function AppLayout({ children }: { children: ReactNode }) { return ( (null); - const { authEnabled, baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig(); + const { baseUrl, allowAnonymousAuthSessions, githubAuthEnabled } = useAuthConfig(); const enableUserSignups = useFeatureFlag('enableUserSignups'); const { refresh: refreshRateLimit } = useAuthRateLimit(); @@ -242,7 +242,7 @@ function SignInContent() {

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