diff --git a/.env.example b/.env.example index 02c3e57..173fc65 100644 --- a/.env.example +++ b/.env.example @@ -14,7 +14,7 @@ # remove them once the seed has run. Auth must be configured for this seed path. # See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 -API_KEY=api_key_optional +API_KEY= # Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) @@ -88,4 +88,4 @@ S3_BUCKET= # (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,"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}]} +# RUNTIME_SEED_JSON={"version":1,"runtimeConfig":{"enableUserSignups":true,"restrictUserApiKeys":true,"defaultTtsProvider":"custom-openai","enableTtsProvidersTab":true,"enableAudiobookExport":true,"enableDocxConversion":true,"showAllProviderModels":true,"disableTtsRateLimit":true,"ttsDailyLimitAnonymous":50000,"ttsDailyLimitAuthenticated":500000,"ttsIpDailyLimitAnonymous":100000,"ttsIpDailyLimitAuthenticated":1000000,"ttsCacheMaxSizeBytes":268435456,"ttsCacheTtlMs":1800000,"ttsUpstreamMaxRetries":2,"ttsUpstreamTimeoutMs":285000,"disableComputeRateLimit":true,"computeParseBurstMax":8,"computeParseBurstWindowSec":60,"computeParseSustainedMax":24,"computeParseSustainedWindowSec":600,"maxUploadMb":200,"changelogFeedUrl":"https://docs.openreader.richardr.dev/changelog/manifest.json"},"providers":[{"slug":"default-openai","displayName":"Default (seeded)","providerType":"custom-openai","baseUrl":"http://localhost:8880/v1","defaultModel":"kokoro","enabled":true}]} diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 181071a..82a70c4 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -50,10 +50,10 @@ Whether users can supply their own personal built-in provider keys is controlled ### Auto-seeded "default-openai" -On first boot, if `admin_providers` is empty and the legacy `API_KEY` env var is set, OpenReader creates a single shared provider with: +On first boot, if `admin_providers` is empty and `API_BASE` or `API_KEY` is set, OpenReader creates a single shared provider with: - slug `default-openai`, displayName `Default (from env)`, providerType `custom-openai` -- baseUrl from `API_BASE`, apiKey from `API_KEY` (encrypted) +- baseUrl from `API_BASE`, apiKey from `API_KEY` when provided (blank keys are supported) - defaultModel set to `kokoro` (you can edit it in Admin → Shared providers) After this seed runs, the legacy `API_KEY` / `API_BASE` env vars are no longer read by the TTS routes — the DB row is authoritative. You can rename, edit, disable, or delete this row like any other from the admin UI, and remove the env vars from your `.env` when convenient. @@ -124,7 +124,7 @@ In v4, runtime site features are managed by admin settings and optional JSON see 1. Deploy this version with your existing env values in place. 2. Boot the app once. Open Settings → Admin and verify: - Seeded settings appear as **from seed** (if you supplied a runtime JSON seed). - - A `default-openai` row exists in **Shared providers** (if you had `API_KEY` set). + - A `default-openai` row exists in **Shared providers** (if you had `API_BASE` or `API_KEY` set). 3. Remove any bootstrap env vars you no longer need from `.env`. 4. Redeploy. Behavior is unchanged — the DB is now the source of truth. diff --git a/docs-site/docs/configure/tts-provider-guides/other.md b/docs-site/docs/configure/tts-provider-guides/other.md index bcb3a0a..4e3aafc 100644 --- a/docs-site/docs/configure/tts-provider-guides/other.md +++ b/docs-site/docs/configure/tts-provider-guides/other.md @@ -26,7 +26,7 @@ Known compatible implementations: [Kokoro-FastAPI](./kokoro-fastapi), [KittenTTS ```env API_BASE=http://your-tts-server/v1 -API_KEY=optional-key-if-required +# API_KEY=optional-key-if-required ``` **Or in-app via Settings → TTS Provider:** diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 7454b7b..3f5f0b1 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -249,7 +249,6 @@ Use one of these `.env` mode templates: ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Optional when you need multiple local origins: @@ -260,10 +259,9 @@ AUTH_SECRET= ```env -# API_BASE / API_KEY are seeded into the admin "default-openai" shared provider +# API_BASE and optional API_KEY are seeded into the admin "default-openai" shared provider # on first boot, then no longer read. Manage them in Settings → Admin afterwards. API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= # Comma-separated emails to auto-promote to admin on signin. @@ -275,7 +273,6 @@ ADMIN_EMAILS=you@example.com ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none USE_EMBEDDED_WEED_MINI=false BASE_URL=http://localhost:3003 AUTH_SECRET= @@ -293,7 +290,6 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none BASE_URL=http://localhost:3003 AUTH_SECRET= COMPUTE_WORKER_URL=http://localhost:8081 diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 06cf6fa..e9e1cc0 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -42,7 +42,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://localhost:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e ADMIN_EMAILS=you@example.com \ @@ -54,7 +53,7 @@ What this command enables: - `-p 3003:3003`: exposes the OpenReader web app/API. - `-p 8333:8333`: exposes embedded SeaweedFS S3 endpoint for direct browser presigned upload/download. - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. -- `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). +- `-e API_BASE=...` / optional `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest when provided). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). - `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. @@ -70,7 +69,6 @@ docker run --name openreader \ -p 8333:8333 \ -v openreader_docstore:/app/docstore \ -e API_BASE=http://host.docker.internal:8880/v1 \ - -e API_KEY=none \ -e BASE_URL=http://:3003 \ -e AUTH_SECRET=$(openssl rand -hex 32) \ -e AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 \ @@ -88,7 +86,7 @@ What this command enables: - `AUTH_TRUSTED_ORIGINS` allows localhost loopback origins in addition to your primary LAN origin. - `USE_ANONYMOUS_AUTH_SESSIONS=true` allows guest sessions while auth is enabled. - `API_BASE` seeds the default TTS endpoint into the admin-managed `default-openai` shared provider on first boot. Edit it from **Settings → Admin → Shared providers** after that. -- `API_KEY` seeds the default provider's key (encrypted at rest). After first boot, manage keys from the admin panel or set per-user keys if `restrictUserApiKeys=false`. Always needed for seeding the default provider. +- `API_KEY` optionally seeds the default provider's key (encrypted at rest). Omit it for an upstream that does not require authentication. - `ADMIN_EMAILS=...` (optional) auto-promotes the listed email(s) to admin so they can manage shared providers and site feature flags from the UI. - `openreader_docstore` volume keeps data persistent across restarts. @@ -112,7 +110,7 @@ What this command enables: - Fast startup with only the required auth env vars. - No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount. - The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode. -- No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. +- No TTS provider preset by default. Configure `API_BASE` and, when required, `API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index d80bd8e..71fc981 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -85,7 +85,7 @@ App server log level. Optional first-boot bootstrap base URL for the auto-created `default-openai` shared provider. - Example: `http://host.docker.internal:8880/v1` -- Read only for provider bootstrap when shared providers are empty and `API_KEY` is set. +- Read only for provider bootstrap when shared providers are empty. Setting `API_BASE` is sufficient; `API_KEY` may be blank. - After bootstrap, provider configuration is DB-backed and managed in **Settings → Admin → Shared providers**. ### API_KEY @@ -446,7 +446,6 @@ Example: "displayName": "Default (seeded)", "providerType": "custom-openai", "baseUrl": "http://localhost:8880/v1", - "apiKey": "api_key_optional", "defaultModel": "kokoro", "enabled": true } @@ -457,7 +456,7 @@ Example: 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. +- 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. `API_BASE` alone is sufficient for an upstream that does not require authentication. Precedence summary: diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index e63d045..7aa881a 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -501,7 +501,7 @@ export async function POST(request: NextRequest) { normalize: { code: 'AUDIOBOOK_CHAPTER_UNSUPPORTED_PROVIDER', errorClass: 'validation', httpStatus: 500 }, }); } - const openApiKey = credResolved.apiKey || 'none'; + const openApiKey = credResolved.apiKey; const openApiBaseUrl = credResolved.baseUrl; const effectiveProviderRef = credResolved.adminRecord?.slug || requestedProvider; const model = resolveTtsModelForProvider({ diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index f089231..4dfb6d9 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -644,7 +644,7 @@ export async function POST(request: NextRequest) { instructions: effectiveSettings.ttsInstructions, language: effectiveSettings.language, provider: requestCreds.provider, - apiKey: requestCreds.apiKey || 'none', + apiKey: requestCreds.apiKey, baseUrl: requestCreds.baseUrl, testNamespace: scope.testNamespace, }, request.signal, { diff --git a/src/app/api/tts/voices/route.ts b/src/app/api/tts/voices/route.ts index 0e1b5f5..1c31b39 100644 --- a/src/app/api/tts/voices/route.ts +++ b/src/app/api/tts/voices/route.ts @@ -62,7 +62,7 @@ export async function GET(req: NextRequest) { const voices = await resolveVoices({ provider: resolved.provider, model: requestedModel, - apiKey: resolved.apiKey || 'none', + apiKey: resolved.apiKey, baseUrl: resolved.baseUrl, }); return NextResponse.json({ voices }); diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index c06cc48..0409299 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -32,7 +32,7 @@ type SeedProviderInput = { slug: string; displayName: string; providerType: TtsProviderId; - apiKey: string; + apiKey?: string; baseUrl?: string | null; defaultModel?: string | null; defaultInstructions?: string | null; @@ -187,8 +187,8 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI 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.apiKey !== undefined && typeof rec.apiKey !== 'string') { + throw new Error(`Seed JSON providers[${index}].apiKey must be a string when provided`); } if (rec.baseUrl !== undefined && rec.baseUrl !== null && typeof rec.baseUrl !== 'string') { @@ -208,7 +208,7 @@ function validateSeedProviderEntry(value: unknown, index: number): SeedProviderI slug: validateSlug(rec.slug), displayName: rec.displayName.trim(), providerType: validateProviderType(rec.providerType), - apiKey: rec.apiKey.trim(), + ...(rec.apiKey !== undefined ? { 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) } : {}), @@ -246,7 +246,8 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi if (existing.length > 0) continue; try { - const encrypted = encryptSecret(provider.apiKey); + const apiKey = provider.apiKey ?? ''; + const encrypted = encryptSecret(apiKey); await db.insert(adminProviders).values({ id: randomUUID(), slug: provider.slug, @@ -255,7 +256,7 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi baseUrl: provider.baseUrl ?? null, apiKeyCiphertext: encrypted.ciphertext, apiKeyIv: encrypted.iv, - apiKeyLast4: apiKeyLast4(provider.apiKey), + apiKeyLast4: apiKeyLast4(apiKey), defaultModel: provider.defaultModel ?? null, defaultInstructions: provider.defaultInstructions ?? null, enabled: provider.enabled === false ? 0 : 1, @@ -278,8 +279,9 @@ async function seedAdminProvidersFromJson(providers: SeedProviderInput[]): Promi } async function seedDefaultAdminProviderFromEnvFallback(): Promise { - const apiKey = process.env.API_KEY; - if (!apiKey || !apiKey.trim()) return; + const apiKey = process.env.API_KEY?.trim() ?? ''; + const baseUrl = process.env.API_BASE?.trim() || null; + if (!apiKey && !baseUrl) return; let existing: Array; try { @@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { } if (existing.length > 0) return; - const baseUrl = process.env.API_BASE?.trim() || null; const now = Date.now(); let enc: ReturnType; try { diff --git a/src/lib/server/tts/generate.ts b/src/lib/server/tts/generate.ts index fdeb583..bbbbd48 100644 --- a/src/lib/server/tts/generate.ts +++ b/src/lib/server/tts/generate.ts @@ -641,6 +641,7 @@ async function runProviderRequest( const openai = new OpenAI({ apiKey: request.apiKey, baseURL: request.baseUrl, + defaultHeaders: request.apiKey ? undefined : { Authorization: null }, maxRetries: 0, timeout: upstreamSettings.ttsUpstreamTimeoutMs, }); diff --git a/src/lib/server/tts/voice-resolution.ts b/src/lib/server/tts/voice-resolution.ts index 1fd9420..4a517c4 100644 --- a/src/lib/server/tts/voice-resolution.ts +++ b/src/lib/server/tts/voice-resolution.ts @@ -377,7 +377,7 @@ async function fetchCustomOpenAiVoices(baseUrl: string, apiKey: string): Promise const response = await fetch(`${normalizedBaseUrl}/audio/voices`, { signal: controller.signal, headers: { - Authorization: `Bearer ${apiKey}`, + ...(apiKey ? { Authorization: `Bearer ${apiKey}` } : {}), 'Content-Type': 'application/json', }, }); diff --git a/tests/unit/admin-providers-validation.vitest.spec.ts b/tests/unit/admin-providers-validation.vitest.spec.ts index e977cc7..6511e0d 100644 --- a/tests/unit/admin-providers-validation.vitest.spec.ts +++ b/tests/unit/admin-providers-validation.vitest.spec.ts @@ -5,6 +5,8 @@ import { adminProviders } from '../../src/db/schema'; import { AdminProviderError, + createAdminProvider, + decryptedKeyFor, listAdminProviders, toMasked, validateProviderType, @@ -53,6 +55,23 @@ describe('admin provider validation', () => { expect(toMasked(record).apiKeyMask).toBe('••••abcd'); }); + test('creates providers without an API key', async () => { + const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; + const created = await createAdminProvider({ + slug: `keyless-${suffix}`, + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + + try { + expect(toMasked(created).apiKeyMask).toBe('(not set)'); + await expect(decryptedKeyFor(created)).resolves.toBe(''); + } finally { + await db.delete(adminProviders).where(inArray(adminProviders.id, [created.id])); + } + }); + test('lists providers in deterministic updated/created/slug order', async () => { const suffix = `${Date.now()}${Math.random().toString(36).slice(2, 8)}`; const inserted = [ diff --git a/tests/unit/runtime-seed-json.vitest.spec.ts b/tests/unit/runtime-seed-json.vitest.spec.ts index e62cefc..fd710bf 100644 --- a/tests/unit/runtime-seed-json.vitest.spec.ts +++ b/tests/unit/runtime-seed-json.vitest.spec.ts @@ -160,6 +160,26 @@ describe('runtime seed JSON parsing', () => { expect(__seedInternals.shouldUseEnvProviderFallback(false)).toBe(true); expect(__seedInternals.shouldUseEnvProviderFallback(true)).toBe(false); }); + + test('accepts seeded providers without an API key', () => { + const parsed = __seedInternals.parseRuntimeSeedDocument(JSON.stringify({ + version: 1, + providers: [{ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }], + })); + + expect(parsed.seed.providers?.[0]).toMatchObject({ + slug: 'keyless-provider', + displayName: 'Keyless Provider', + providerType: 'custom-openai', + baseUrl: 'http://localhost:8880/v1', + }); + expect(parsed.seed.providers?.[0]).not.toHaveProperty('apiKey'); + }); }); describe('runtime config JSON seeding', () => { @@ -380,6 +400,44 @@ describe('provider seeding and fallback precedence', () => { } }); + test('falls back to API_BASE with a blank API_KEY', 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: '', + AUTH_SECRET: 'seed-test-auth-secret-keyless', + }, async () => { + await __seedInternals.runSeed(); + }); + + const rows = await db + .select({ + slug: adminProviders.slug, + baseUrl: adminProviders.baseUrl, + apiKeyLast4: adminProviders.apiKeyLast4, + }) + .from(adminProviders); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + slug: 'default-openai', + baseUrl: 'http://localhost:8880/v1', + apiKeyLast4: '', + }); + } 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: [] });