refactor(admin): support keyless provider seeding and clarify API_KEY usage

Update provider seeding logic and documentation to allow creation of admin/shared providers without requiring an API key. Adjust environment variable handling so that a blank or missing API_KEY is valid when API_BASE is set, enabling support for upstream TTS providers that do not require authentication. Remove defaulting to 'none' for API keys in API routes and ensure headers are omitted when no key is present. Add and update tests to verify correct handling of keyless providers.

BREAKING CHANGE: Providers can now be seeded without an API key; API_KEY is no longer required if API_BASE is set. Existing deployments relying on a non-empty API_KEY for seeding should review their environment configuration.
This commit is contained in:
Richard R 2026-06-06 16:02:36 -06:00
parent 23b9417567
commit a224efd9e3
14 changed files with 104 additions and 32 deletions

View file

@ -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}]}

View file

@ -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.

View file

@ -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:**

View file

@ -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=<generate-with-openssl-rand-hex-32>
# Optional when you need multiple local origins:
@ -260,10 +259,9 @@ AUTH_SECRET=<generate-with-openssl-rand-hex-32>
<TabItem value="auth-with-admin" label="Auth + Admin Panel">
```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=<generate-with-openssl-rand-hex-32>
# 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=<generate-with-openssl-rand-hex-32>
@ -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=<generate-with-openssl-rand-hex-32>
COMPUTE_WORKER_URL=http://localhost:8081

View file

@ -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://<YOUR_LAN_IP>: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.
</TabItem>
</Tabs>

View file

@ -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:

View file

@ -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({

View file

@ -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, {

View file

@ -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 });

View file

@ -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<void> {
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<unknown>;
try {
@ -295,7 +297,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
}
if (existing.length > 0) return;
const baseUrl = process.env.API_BASE?.trim() || null;
const now = Date.now();
let enc: ReturnType<typeof encryptSecret>;
try {

View file

@ -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,
});

View file

@ -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',
},
});

View file

@ -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 = [

View file

@ -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: [] });