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.
This commit is contained in:
parent
f0800df745
commit
83aa1152cb
14 changed files with 982 additions and 499 deletions
17
.env.example
17
.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}]}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -317,11 +317,11 @@ S3_SECRET_ACCESS_KEY=your-secret-key
|
|||
</Tabs>
|
||||
|
||||
:::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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
:::
|
||||
|
|
|
|||
|
|
@ -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://<BASE_URL host>: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)
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
|
|
@ -665,6 +665,8 @@ function SourceBadge({
|
|||
)}
|
||||
{dirty ? (
|
||||
<Badge tone="accent">Modified</Badge>
|
||||
) : source === 'json-seed' ? (
|
||||
<Badge tone="muted">from seed</Badge>
|
||||
) : source === 'env-seed' ? (
|
||||
<Badge tone="muted">from env</Badge>
|
||||
) : source === 'admin' ? (
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
providers?: SeedProviderInput[];
|
||||
};
|
||||
|
||||
type ParsedSeedResult = {
|
||||
seed: ServerSeedDocument;
|
||||
hasProvidersSection: boolean;
|
||||
};
|
||||
|
||||
let seedPromise: Promise<void> | null = null;
|
||||
|
||||
export async function ensureAdminSeed(): Promise<void> {
|
||||
|
|
@ -38,7 +61,6 @@ export async function ensureAdminSeed(): Promise<void> {
|
|||
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<void> {
|
|||
}
|
||||
|
||||
async function runSeed(): Promise<void> {
|
||||
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<void> {
|
||||
function shouldUseEnvProviderFallback(hasProvidersSection: boolean): boolean {
|
||||
return !hasProvidersSection;
|
||||
}
|
||||
|
||||
async function loadRuntimeSeedFromEnv(): Promise<ParsedSeedResult | null> {
|
||||
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<string, unknown>;
|
||||
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<string, unknown> | 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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>;
|
||||
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<string, unknown>): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
const apiKey = process.env.API_KEY;
|
||||
if (!apiKey || !apiKey.trim()) return;
|
||||
|
||||
|
|
@ -81,35 +299,28 @@ async function seedDefaultAdminProvider(): Promise<void> {
|
|||
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<void> {
|
|||
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<void> {
|
|||
}
|
||||
|
||||
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
|
||||
// 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<void> {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const __seedInternals = {
|
||||
runSeed,
|
||||
parseRuntimeSeedDocument,
|
||||
validateSeedProviderEntry,
|
||||
shouldUseEnvProviderFallback,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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<T> {
|
||||
/** 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<boolean> {
|
||||
function booleanFlag(defaultValue: boolean): RuntimeConfigKeyDef<boolean> {
|
||||
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<boolean> {
|
||||
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<boolean> {
|
|||
};
|
||||
}
|
||||
|
||||
function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<string> {
|
||||
function stringValue(defaultValue: string): RuntimeConfigKeyDef<string> {
|
||||
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<number> {
|
||||
function positiveIntValue(defaultValue: number): RuntimeConfigKeyDef<number> {
|
||||
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<string, unknown>)[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<K extends RuntimeConfigKey>(
|
|||
}
|
||||
}
|
||||
|
||||
/** 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<void> {
|
||||
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<string, unknown>,
|
||||
source: RuntimeConfigSource = 'json-seed',
|
||||
): Promise<{ seeded: RuntimeConfigKey[]; invalid: string[]; unknown: string[] }> {
|
||||
const seeded: RuntimeConfigKey[] = [];
|
||||
let existing: Map<string, { value: unknown; source: string }>;
|
||||
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<RuntimeConfig[RuntimeConfigKey]>;
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
|
|||
424
tests/unit/runtime-seed-json.vitest.spec.ts
Normal file
424
tests/unit/runtime-seed-json.vitest.spec.ts
Normal file
|
|
@ -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<string, string | undefined>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const original = new Map<string, string | undefined>();
|
||||
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<SettingRow[]> {
|
||||
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<void> {
|
||||
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<ProviderRow[]> {
|
||||
const rows = await db.select().from(adminProviders).where(inArray(adminProviders.slug, slugs));
|
||||
return rows as ProviderRow[];
|
||||
}
|
||||
|
||||
async function restoreProvidersBySlug(slugs: string[], snapshot: ProviderRow[]): Promise<void> {
|
||||
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<boolean> {
|
||||
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<string, unknown> = {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue