feat(admin,config,ui,db): introduce runtime-editable site config and admin-managed TTS providers

Add database-backed runtime configuration for feature flags and TTS provider credentials, editable via a new admin UI panel. Replace static NEXT_PUBLIC_* and TTS provider env vars with admin-managed settings stored in the database and injected at SSR for client access. Implement admin-only panels for managing shared TTS provider credentials (with encrypted API keys) and live site feature flags. Add schema migrations, API routes, React contexts, and hooks for SSR-injected runtime config and live updates. Update client and server logic to resolve configuration from the database at runtime, enforcing admin restrictions and supporting migration from legacy env-based config.

BREAKING CHANGE: Feature flags and TTS provider credentials are now managed at runtime via the admin UI. Environment variables are only used for initial seeding and are ignored after first boot. Existing deployments must migrate configuration to the admin panel.
This commit is contained in:
Richard R 2026-05-12 22:39:24 -06:00
parent c648d2bf6a
commit 876ca7d774
50 changed files with 7012 additions and 212 deletions

View file

@ -1,6 +1,11 @@
# Local / OpenAI TTS API Configuration (default)
# Suggest using https://github.com/remsky/Kokoro-FastAPI
#
# NOTE: On first boot, the server auto-seeds these values into a "default-openai"
# admin-managed shared provider (DB-backed, encrypted at rest). After that, the
# admin UI is authoritative and changing these env vars has no effect. You may
# remove them once the seed has run. See Settings → Admin → Shared providers.
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
@ -35,6 +40,12 @@ GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing)
DISABLE_AUTH_RATE_LIMIT=
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
# Admins see the "Admin" tab in Settings (TTS shared providers + site features).
# Demotion is automatic: removing an email here demotes the user on next login.
# Requires auth to be enabled (AUTH_SECRET + BASE_URL).
ADMIN_EMAILS=
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables.
# Defaults to SQLite at docstore/sqlite3.db when not set.
POSTGRES_URL=
@ -72,10 +83,14 @@ WHISPER_CPP_BIN=/whisper.cpp/build/bin/whisper-cli
# (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN=
# (Optional) Client feature flags (does not work in Docker containers due to being build-time only)
# (Optional) Client feature flags — seeded into the admin-managed runtime
# config on first boot, then ignored. Edit values from Settings → Admin →
# Site features instead of redeploying. SSR-injected so they take effect
# without rebuilding (unlike the old NEXT_PUBLIC_* build-time pattern).
# NEXT_PUBLIC_ENABLE_DOCX_CONVERSION=true
# NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS=true
# NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB=true
# NEXT_PUBLIC_RESTRICT_USER_API_KEYS=true
# NEXT_PUBLIC_DEFAULT_TTS_PROVIDER=custom-openai
# NEXT_PUBLIC_DEFAULT_TTS_MODEL=kokoro
# NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS=true

View file

@ -0,0 +1,25 @@
CREATE TABLE "admin_providers" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"display_name" text NOT NULL,
"provider_type" text NOT NULL,
"base_url" text,
"api_key_ciphertext" text NOT NULL,
"api_key_iv" text NOT NULL,
"api_key_last4" text,
"default_model" text,
"default_instructions" text,
"enabled" integer DEFAULT 1 NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL,
CONSTRAINT "admin_providers_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "admin_settings" (
"key" text PRIMARY KEY NOT NULL,
"value_json" jsonb NOT NULL,
"source" text DEFAULT 'admin' NOT NULL,
"updated_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user" ADD COLUMN "is_admin" boolean DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,13 @@
"when": 1778627816569,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1778644075378,
"tag": "0004_admin_panel",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,25 @@
CREATE TABLE `admin_providers` (
`id` text PRIMARY KEY NOT NULL,
`slug` text NOT NULL,
`display_name` text NOT NULL,
`provider_type` text NOT NULL,
`base_url` text,
`api_key_ciphertext` text NOT NULL,
`api_key_iv` text NOT NULL,
`api_key_last4` text,
`default_model` text,
`default_instructions` text,
`enabled` integer DEFAULT 1 NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `admin_providers_slug_unique` ON `admin_providers` (`slug`);--> statement-breakpoint
CREATE TABLE `admin_settings` (
`key` text PRIMARY KEY NOT NULL,
`value_json` text NOT NULL,
`source` text DEFAULT 'admin' NOT NULL,
`updated_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL
);
--> statement-breakpoint
ALTER TABLE `user` ADD `is_admin` integer DEFAULT false NOT NULL;

File diff suppressed because it is too large Load diff

View file

@ -29,6 +29,13 @@
"when": 1778627810476,
"tag": "0003_tts_segments_v2_split",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1778644075081,
"tag": "0004_admin_panel",
"breakpoints": true
}
]
}

View file

@ -19,10 +19,10 @@ import type { AudiobookGenerationSettings } from '@/types/client';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
export default function EPUBPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, createFullAudioBook: createEPUBAudioBook, regenerateChapter: regenerateEPUBChapter } = useEPUB();

View file

@ -19,8 +19,7 @@ import { RateLimitPauseButton } from '@/components/player/RateLimitPauseButton';
import { resolveDocumentId } from '@/lib/client/dexie';
import { RateLimitBanner } from '@/components/auth/RateLimitBanner';
import { useAuthRateLimit } from '@/contexts/AuthRateLimitContext';
const canExportAudiobook = process.env.NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT !== 'false';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
// Dynamic import for client-side rendering only
const PDFViewer = dynamic(
@ -32,6 +31,7 @@ const PDFViewer = dynamic(
);
export default function PDFViewerPage() {
const canExportAudiobook = useFeatureFlag('enableAudiobookExport');
const { id } = useParams();
const router = useRouter();
const { setCurrentDocument, currDocName, clearCurrDoc, currDocPage, currDocPages, createFullAudioBook: createPDFAudioBook, regenerateChapter: regeneratePDFChapter } = usePDF();

View file

@ -0,0 +1,67 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import {
AdminProviderError,
deleteAdminProvider,
toMasked,
updateAdminProvider,
} from '@/lib/server/admin/providers';
export const dynamic = 'force-dynamic';
export async function PUT(
req: NextRequest,
context: { params: Promise<{ id: string }> },
) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const { id } = await context.params;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
try {
const rec = body as Record<string, unknown>;
const updated = await updateAdminProvider(id, {
slug: rec.slug as string | undefined,
displayName: rec.displayName as string | undefined,
providerType: rec.providerType as never,
baseUrl: rec.baseUrl as string | null | undefined,
apiKey: rec.apiKey as string | undefined,
defaultModel: rec.defaultModel as string | null | undefined,
defaultInstructions: rec.defaultInstructions as string | null | undefined,
enabled: rec.enabled as boolean | undefined,
});
return NextResponse.json({ provider: toMasked(updated) });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[admin/providers/:id] update failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}
export async function DELETE(
req: NextRequest,
context: { params: Promise<{ id: string }> },
) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const { id } = await context.params;
try {
await deleteAdminProvider(id);
return NextResponse.json({ ok: true });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[admin/providers/:id] delete failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View file

@ -0,0 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import {
AdminProviderError,
createAdminProvider,
listAdminProviders,
toMasked,
} from '@/lib/server/admin/providers';
export const dynamic = 'force-dynamic';
export async function GET(req: NextRequest) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const all = await listAdminProviders();
return NextResponse.json({ providers: all.map(toMasked) });
}
export async function POST(req: NextRequest) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
try {
const record = await createAdminProvider({
slug: String((body as Record<string, unknown>).slug ?? ''),
displayName: String((body as Record<string, unknown>).displayName ?? ''),
providerType: (body as Record<string, unknown>).providerType as never,
baseUrl: ((body as Record<string, unknown>).baseUrl as string | null | undefined) ?? null,
apiKey: String((body as Record<string, unknown>).apiKey ?? ''),
defaultModel:
((body as Record<string, unknown>).defaultModel as string | null | undefined) ?? null,
defaultInstructions:
((body as Record<string, unknown>).defaultInstructions as string | null | undefined) ?? null,
enabled: ((body as Record<string, unknown>).enabled as boolean | undefined) ?? true,
});
return NextResponse.json({ provider: toMasked(record) }, { status: 201 });
} catch (error) {
if (error instanceof AdminProviderError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error('[admin/providers] create failed:', error);
return NextResponse.json({ error: 'Internal error' }, { status: 500 });
}
}

View file

@ -0,0 +1,77 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAdminContext } from '@/lib/server/auth/admin';
import {
clearRuntimeConfigKey,
RUNTIME_CONFIG_SCHEMA,
setRuntimeConfigKey,
type RuntimeConfigKey,
} from '@/lib/server/admin/settings';
import { getResolvedRuntimeConfigWithSources } from '@/lib/server/runtime-config';
export const dynamic = 'force-dynamic';
const VALID_KEYS = new Set<RuntimeConfigKey>(
Object.keys(RUNTIME_CONFIG_SCHEMA) as RuntimeConfigKey[],
);
function isRuntimeKey(value: unknown): value is RuntimeConfigKey {
return typeof value === 'string' && VALID_KEYS.has(value as RuntimeConfigKey);
}
export async function GET(req: NextRequest) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
const { values, sources } = await getResolvedRuntimeConfigWithSources();
return NextResponse.json({ values, sources });
}
export async function PATCH(req: NextRequest) {
const ctx = await requireAdminContext(req);
if (ctx instanceof Response) return ctx;
let body: unknown;
try {
body = await req.json();
} catch {
return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 });
}
if (!body || typeof body !== 'object') {
return NextResponse.json({ error: 'Expected JSON object' }, { status: 400 });
}
const rec = body as Record<string, unknown>;
const updates = (rec.updates && typeof rec.updates === 'object'
? (rec.updates as Record<string, unknown>)
: rec) as Record<string, unknown>;
const resets = Array.isArray(rec.reset) ? (rec.reset as unknown[]) : [];
const errors: Array<{ key: string; message: string }> = [];
for (const [key, value] of Object.entries(updates)) {
if (!isRuntimeKey(key)) {
errors.push({ key, message: 'unknown key' });
continue;
}
try {
await setRuntimeConfigKey(key, value as never);
} catch (error) {
errors.push({ key, message: error instanceof Error ? error.message : String(error) });
}
}
for (const key of resets) {
if (!isRuntimeKey(key)) {
errors.push({ key: String(key), message: 'unknown key' });
continue;
}
try {
await clearRuntimeConfigKey(key);
} catch (error) {
errors.push({ key, message: error instanceof Error ? error.message : String(error) });
}
}
const { values, sources } = await getResolvedRuntimeConfigWithSources();
return NextResponse.json({ values, sources, errors }, { status: errors.length ? 207 : 200 });
}

View file

@ -29,8 +29,11 @@ import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/li
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '@/lib/server/audiobooks/user-scope';
import { getFFmpegPath } from '@/lib/server/audiobooks/ffmpeg-bin';
import { generateTTSBuffer } from '@/lib/server/tts/generate';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { supportsNativeModelSpeed } from '@/lib/shared/tts-provider-catalog';
import { supportsNativeModelSpeed, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
@ -405,12 +408,33 @@ export async function POST(request: NextRequest) {
chapterIndex = next;
}
const provider = request.headers.get('x-tts-provider')
const requestedProvider = request.headers.get('x-tts-provider')
|| mergedSettings?.ttsProvider
|| 'openai';
providerForError = provider;
const openApiKey = request.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = request.headers.get('x-openai-base-url') || process.env.API_BASE;
providerForError = requestedProvider;
const runtimeConfig = await getResolvedRuntimeConfig();
const credResolved = await resolveTtsCredentials({
providerHeader: requestedProvider,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in credResolved) {
if (credResolved.error === 'no_shared_provider_configured') {
return NextResponse.json(
{ error: 'User API keys are restricted and no shared provider is configured.' },
{ status: 503 },
);
}
return NextResponse.json(
{ error: `Unknown or disabled TTS provider: ${credResolved.slug}` },
{ status: 404 },
);
}
const provider = credResolved.provider;
const openApiKey = credResolved.apiKey || 'none';
const openApiBaseUrl = credResolved.baseUrl;
const model = mergedSettings?.ttsModel;
const voice = mergedSettings?.voice
|| (provider === 'openai'
@ -420,7 +444,11 @@ export async function POST(request: NextRequest) {
: 'af_sarah');
const rawNativeSpeed = mergedSettings?.nativeSpeed ?? 1;
const nativeSpeed = Number.isFinite(Number(rawNativeSpeed)) ? Number(rawNativeSpeed) : 1;
const instructions = mergedSettings?.ttsInstructions;
const instructions = resolveEffectiveTtsInstructions({
model,
requestInstructions: mergedSettings?.ttsInstructions,
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions,
});
if (authEnabled && userId && isTtsRateLimitEnabled()) {
const isAnonymous = Boolean(user?.isAnonymous);
@ -556,11 +584,17 @@ export async function POST(request: NextRequest) {
await deleteAudiobookObject(bookId, storageUserId, 'complete.m4b.manifest.json', testNamespace).catch(() => {});
if (!normalizedExistingSettings && incomingSettings) {
const settingsToPersist: AudiobookGenerationSettings = {
...incomingSettings,
...(supportsTtsInstructions(incomingSettings.ttsModel)
? { ttsInstructions: instructions ?? '' }
: { ttsInstructions: '' }),
};
await putAudiobookObject(
bookId,
storageUserId,
'audiobook.meta.json',
Buffer.from(JSON.stringify(incomingSettings, null, 2), 'utf8'),
Buffer.from(JSON.stringify(settingsToPersist, null, 2), 'utf8'),
'application/json; charset=utf-8',
testNamespace,
);

View file

@ -23,11 +23,14 @@ import {
} from '@/lib/server/tts/segments';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { buildDailyQuotaExceededResponse } from '@/lib/server/rate-limit/problem-response';
import { getUpstreamRetryAfterSeconds, getUpstreamStatus } from '@/lib/server/tts/upstream-response';
import { alignAudioWithText } from '@/lib/server/whisper/alignment';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import type {
TTSSegmentInput,
TTSSegmentManifestItem,
@ -164,9 +167,40 @@ export async function POST(request: NextRequest) {
const scope = await resolveSegmentDocumentScope(request, parsed.documentId);
if (scope instanceof Response) return scope;
const runtimeConfig = await getResolvedRuntimeConfig();
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.ttsProvider,
apiKeyHeader: request.headers.get('x-openai-key'),
baseUrlHeader: request.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in requestCreds) {
const status = requestCreds.error === 'no_shared_provider_configured' ? 503 : 404;
return NextResponse.json(
{
error: requestCreds.error === 'no_shared_provider_configured'
? 'User API keys are restricted and no shared provider is configured.'
: `Unknown or disabled TTS provider: ${requestCreds.slug}`,
},
{ status },
);
}
const settingsHash = buildTtsSegmentSettingsHash(parsed.settings);
const settingsJson = buildTtsSegmentSettingsJson(parsed.settings);
// Normalize request settings to the effective generation settings so cache
// keys and persisted metadata match what we actually synthesize.
const effectiveInstructions = resolveEffectiveTtsInstructions({
model: parsed.settings.ttsModel,
requestInstructions: parsed.settings.ttsInstructions,
sharedDefaultInstructions: requestCreds.adminRecord?.defaultInstructions,
}) ?? '';
const effectiveSettings: TTSSegmentSettings = {
...parsed.settings,
ttsInstructions: effectiveInstructions,
};
const settingsHash = buildTtsSegmentSettingsHash(effectiveSettings);
const settingsJson = buildTtsSegmentSettingsJson(effectiveSettings);
const nowMs = Date.now();
const storagePrefix = getS3Config().prefix;
const secret = textHmacSecret();
@ -457,14 +491,14 @@ export async function POST(request: NextRequest) {
try {
const ttsBuffer = await generateTTSBuffer({
text: segment.text,
voice: parsed.settings.voice,
speed: parsed.settings.nativeSpeed,
voice: effectiveSettings.voice,
speed: effectiveSettings.nativeSpeed,
format: 'mp3',
model: parsed.settings.ttsModel,
instructions: parsed.settings.ttsInstructions,
provider: parsed.settings.ttsProvider,
apiKey: request.headers.get('x-openai-key') || process.env.API_KEY || 'none',
baseUrl: request.headers.get('x-openai-base-url') || process.env.API_BASE,
model: effectiveSettings.ttsModel,
instructions: effectiveSettings.ttsInstructions,
provider: requestCreds.provider,
apiKey: requestCreds.apiKey || 'none',
baseUrl: requestCreds.baseUrl,
testNamespace: scope.testNamespace,
}, request.signal);

View file

@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers';
export const dynamic = 'force-dynamic';
/**
* Public list of admin-configured TTS providers. Auth-gated when auth is
* enabled. Never returns keys, base URLs, or ciphertext only the data the
* client needs to render the provider picker.
*/
export async function GET(req: NextRequest) {
if (isAuthEnabled()) {
const session = await auth?.api.getSession({ headers: req.headers });
if (!session?.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
try {
const all = await listAdminProviders();
const visible = all.filter((p) => p.enabled).map(toPublic);
return NextResponse.json({ providers: visible });
} catch (error) {
console.warn('[tts/shared-providers] list failed:', error);
return NextResponse.json({ providers: [] });
}
}

View file

@ -2,6 +2,8 @@ import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { getDefaultVoices } from '@/lib/shared/tts-provider-catalog';
import { resolveVoices } from '@/lib/server/tts/voice-resolution';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
export async function GET(req: NextRequest) {
try {
@ -11,22 +13,48 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const openApiKey = req.headers.get('x-openai-key') || process.env.API_KEY || 'none';
const openApiBaseUrl = req.headers.get('x-openai-base-url') || process.env.API_BASE;
const provider = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1';
const runtimeConfig = await getResolvedRuntimeConfig();
const resolved = await resolveTtsCredentials({
providerHeader: req.headers.get('x-tts-provider'),
apiKeyHeader: req.headers.get('x-openai-key'),
baseUrlHeader: req.headers.get('x-openai-base-url'),
fallbackProvider: runtimeConfig.defaultTtsProvider,
restrictUserApiKeys: runtimeConfig.restrictUserApiKeys,
});
if ('error' in resolved) {
const status = resolved.error === 'no_shared_provider_configured'
? 503
: resolved.error === 'provider_disabled'
? 503
: 404;
return NextResponse.json(
{
error: resolved.error === 'no_shared_provider_configured'
? 'User API keys are restricted and no shared provider is configured.'
: `Unknown or disabled TTS provider: ${resolved.slug}`,
},
{ status },
);
}
const requestedModel = req.headers.get('x-tts-model')
|| resolved.adminRecord?.defaultModel
|| 'tts-1';
const voices = await resolveVoices({
provider,
model,
apiKey: openApiKey,
baseUrl: openApiBaseUrl,
provider: resolved.provider,
model: requestedModel,
apiKey: resolved.apiKey || 'none',
baseUrl: resolved.baseUrl,
});
return NextResponse.json({ voices });
} catch (error) {
// This catch mainly guards auth/session access failures; voice resolution itself falls back internally.
console.error('Error in voices endpoint:', error);
const provider = req.headers.get('x-tts-provider') || 'openai';
const model = req.headers.get('x-tts-model') || 'tts-1';
return NextResponse.json({ voices: getDefaultVoices(provider, model) });
return NextResponse.json(
{ error: 'Failed to resolve voices', fallbackVoices: getDefaultVoices(provider, model) },
{ status: 500 },
);
}
}

View file

@ -4,6 +4,7 @@ import { Metadata } from "next";
import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
import { getResolvedRuntimeConfig } from "@/lib/server/runtime-config";
const figtree = Figtree({
subsets: ["latin"],
@ -40,11 +41,23 @@ export const metadata: Metadata = {
},
};
export default function RootLayout({ children }: { children: ReactNode }) {
function jsonEmbedSafe(value: unknown): string {
// Prevent `</script>` and U+2028/U+2029 from breaking the inline script.
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
}
export default async function RootLayout({ children }: { children: ReactNode }) {
const runtimeConfig = await getResolvedRuntimeConfig();
const runtimeConfigInit = `window.__OPENREADER_RUNTIME_CONFIG__=${jsonEmbedSafe(runtimeConfig)};`;
return (
<html lang="en" className={figtree.variable} suppressHydrationWarning>
<head>
<meta name="color-scheme" content="light dark" />
<script dangerouslySetInnerHTML={{ __html: runtimeConfigInit }} />
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body className="antialiased">

View file

@ -11,6 +11,7 @@ import { ThemeProvider } from '@/contexts/ThemeContext';
import { ConfigProvider } from '@/contexts/ConfigContext';
import { HTMLProvider } from '@/contexts/HTMLContext';
import { AuthRateLimitProvider } from '@/contexts/AuthRateLimitContext';
import { RuntimeConfigProvider } from '@/contexts/RuntimeConfigContext';
import { PrivacyModal } from '@/components/PrivacyModal';
import { AuthLoader } from '@/components/auth/AuthLoader';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
@ -29,6 +30,28 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
if (isAuthPage) {
return (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
);
}
return (
<RuntimeConfigProvider>
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
@ -37,44 +60,26 @@ export function Providers({ children, authEnabled, authBaseUrl, allowAnonymousAu
>
<ThemeProvider>
<AuthLoader>
<>
{children}
{authEnabled && <PrivacyModal />}
</>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
<>
{children}
{authEnabled && <PrivacyModal />}
<DexieMigrationModal />
</>
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
);
}
return (
<AuthRateLimitProvider
authEnabled={authEnabled}
authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled}
>
<ThemeProvider>
<AuthLoader>
<ConfigProvider>
<DocumentProvider>
<TTSProvider>
<PDFProvider>
<EPUBProvider>
<HTMLProvider>
<>
{children}
{authEnabled && <PrivacyModal />}
<DexieMigrationModal />
</>
</HTMLProvider>
</EPUBProvider>
</PDFProvider>
</TTSProvider>
</DocumentProvider>
</ConfigProvider>
</AuthLoader>
</ThemeProvider>
</AuthRateLimitProvider>
</RuntimeConfigProvider>
);
}

View file

@ -37,10 +37,10 @@ import { cacheStoredDocumentFromBytes, clearDocumentCache } from '@/lib/client/c
import { clearAllDocumentPreviewCaches, clearInMemoryDocumentPreviewCache } from '@/lib/client/cache/previews';
import { resolveTtsSettingsViewModel } from '@/lib/client/settings/tts-settings';
import { REPLICATE_KOKORO_82M_VERSIONED_MODEL, supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
const enableDestructiveDelete = process.env.NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS !== 'false';
const showAllDeepInfra = process.env.NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS !== 'false';
const enableTTSProvidersTab = process.env.NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB !== 'false';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { AdminProvidersPanel } from '@/components/admin/AdminProvidersPanel';
import { AdminFeaturesPanel } from '@/components/admin/AdminFeaturesPanel';
import { useSharedProviders } from '@/hooks/useSharedProviders';
// Hard-coded theme color palettes for the visual theme selector
type ThemeColorSet = { background: string; base: string; offbase: string; accent: string; secondaryAccent: string; foreground: string; muted: string };
@ -81,16 +81,32 @@ const CUSTOM_COLOR_FIELDS: { key: keyof CustomThemeColors; label: string }[] = [
{ key: 'muted', label: 'Muted' },
];
type SectionId = 'api' | 'theme' | 'docs' | 'account';
type SectionId = 'api' | 'theme' | 'docs' | 'account' | 'admin';
const SIDEBAR_SECTIONS: { id: SectionId; label: string; icon: React.ComponentType<React.SVGProps<SVGSVGElement>>; authOnly?: boolean }[] = [
type SidebarSection = {
id: SectionId;
label: string;
icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
authOnly?: boolean;
adminOnly?: boolean;
};
const SIDEBAR_SECTIONS: SidebarSection[] = [
{ id: 'api', label: 'TTS Provider', icon: KeyIcon },
{ id: 'theme', label: 'Appearance', icon: PaletteIcon },
{ id: 'docs', label: 'Documents', icon: DocumentIcon },
{ id: 'account', label: 'Account', icon: UserIcon, authOnly: true },
{ id: 'admin', label: 'Admin', icon: SettingsIcon, authOnly: true, adminOnly: true },
];
type AdminSubTab = 'providers' | 'features';
export function SettingsModal({ className = '' }: { className?: string }) {
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllDeepInfra = runtimeConfig.showAllDeepInfraModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const [isOpen, setIsOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SectionId>(enableTTSProvidersTab ? 'api' : 'theme');
@ -130,19 +146,25 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const router = useRouter();
const isBusy = isImportingLibrary;
const { providers: sharedProviders } = useSharedProviders();
const {
providers: ttsProviders,
models: ttsModels,
supportsCustomModel: supportsCustom,
selectedModelId,
canSubmit,
selectedSharedProvider,
} = useMemo(() => resolveTtsSettingsViewModel({
provider: localTTSProvider,
apiKey: localApiKey,
modelValue,
customModelInput,
showAllDeepInfra,
}), [localTTSProvider, localApiKey, modelValue, customModelInput]);
sharedProviders,
allowBuiltInProviders: !restrictUserApiKeys,
}), [localTTSProvider, localApiKey, modelValue, customModelInput, showAllDeepInfra, sharedProviders, restrictUserApiKeys]);
const isSharedSelected = Boolean(selectedSharedProvider);
const selectedProviderOption = ttsProviders.find((p) => p.id === localTTSProvider) ?? ttsProviders[0];
const checkFirstVist = useCallback(async () => {
const appConfig = await getAppConfig();
@ -193,6 +215,14 @@ export function SettingsModal({ className = '' }: { className?: string }) {
}
}, [modelValue, ttsModels]);
useEffect(() => {
if (!restrictUserApiKeys) return;
if (selectedProviderOption) return;
if (ttsProviders.length > 0) {
setLocalTTSProvider(ttsProviders[0].id);
}
}, [restrictUserApiKeys, selectedProviderOption, ttsProviders]);
const handleRefresh = async () => {
try {
clearInMemoryDocumentPreviewCache();
@ -371,14 +401,20 @@ export function SettingsModal({ className = '' }: { className?: string }) {
return THEME_COLORS[id] || THEME_COLORS.light;
}, [systemIsDark, customColors]);
const isAdmin = Boolean(
(session?.user as unknown as { isAdmin?: boolean } | undefined)?.isAdmin,
);
const [adminSubTab, setAdminSubTab] = useState<AdminSubTab>('providers');
const visibleSections = useMemo(
() => SIDEBAR_SECTIONS.filter((section) => {
if (section.id === 'api' && !enableTTSProvidersTab) {
return false;
}
return !section.authOnly || authEnabled;
if (section.authOnly && !authEnabled) return false;
if (section.adminOnly && !isAdmin) return false;
return true;
}),
[authEnabled]
[authEnabled, isAdmin, enableTTSProvidersTab]
);
useEffect(() => {
@ -392,8 +428,12 @@ export function SettingsModal({ className = '' }: { className?: string }) {
const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`;
const shouldShowBaseUrl = localTTSProvider !== 'replicate'
&& (localTTSProvider === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
const effectiveProviderType = selectedSharedProvider?.providerType ?? localTTSProvider;
const shouldShowBaseUrl = !restrictUserApiKeys
&& !isSharedSelected
&& effectiveProviderType !== 'replicate'
&& (effectiveProviderType === 'custom-openai' || !localBaseUrl || localBaseUrl === '');
const shouldShowApiKey = !restrictUserApiKeys && !isSharedSelected;
const selectedModel = ttsModels.find(m => m.id === selectedModelId) || ttsModels[0];
const selectedModelVersion = selectedModel?.id?.includes(':')
? selectedModel.id.slice(selectedModel.id.indexOf(':'))
@ -503,70 +543,88 @@ export function SettingsModal({ className = '' }: { className?: string }) {
<div className="space-y-4">
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Provider</label>
<Listbox
value={ttsProviders.find(p => p.id === localTTSProvider) || ttsProviders[0]}
onChange={(provider) => {
setLocalTTSProvider(provider.id);
if (provider.id === 'openai') {
setModelValue('tts-1');
setLocalBaseUrl('https://api.openai.com/v1');
} else if (provider.id === 'custom-openai') {
setModelValue('kokoro');
setLocalBaseUrl('');
} else if (provider.id === 'replicate') {
setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
setLocalBaseUrl('');
} else if (provider.id === 'deepinfra') {
setModelValue('hexgrad/Kokoro-82M');
setLocalBaseUrl('https://api.deepinfra.com/v1/openai');
}
setCustomModelInput('');
}}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.005] hover:text-accent hover:bg-offbase">
<span className="block truncate">
{ttsProviders.find(p => p.id === localTTSProvider)?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
{ttsProviders.length === 0 ? (
<p className="text-xs text-amber-500">
User API keys are restricted and no shared provider is configured. Ask an admin to add one.
</p>
) : (
<Listbox
value={selectedProviderOption!}
onChange={(provider) => {
setLocalTTSProvider(provider.id);
if (provider.shared) {
// Shared admin provider — credentials live on the server.
setLocalApiKey('');
setLocalBaseUrl('');
const shared = sharedProviders.find((p) => p.slug === provider.id);
if (shared?.defaultModel) setModelValue(shared.defaultModel);
setLocalTTSInstructions(shared?.defaultInstructions ?? '');
} else if (provider.id === 'openai') {
setModelValue('tts-1');
setLocalBaseUrl('https://api.openai.com/v1');
} else if (provider.id === 'custom-openai') {
setModelValue('kokoro');
setLocalBaseUrl('');
} else if (provider.id === 'replicate') {
setModelValue(REPLICATE_KOKORO_82M_VERSIONED_MODEL);
setLocalBaseUrl('');
} else if (provider.id === 'deepinfra') {
setModelValue('hexgrad/Kokoro-82M');
setLocalBaseUrl('https://api.deepinfra.com/v1/openai');
}
setCustomModelInput('');
}}
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-background py-2 pl-3 pr-10 text-left text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent transform transition-transform duration-200 ease-in-out hover:scale-[1.005] hover:text-accent hover:bg-offbase">
<span className="block truncate">
{selectedProviderOption?.name || 'Select Provider'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-5 w-5 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
{ttsProviders.map((provider) => (
<ListboxOption
key={provider.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
value={provider}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{ttsProviders.map((provider) => (
<ListboxOption
key={provider.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
value={provider}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{provider.name}
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
)}
</div>
{restrictUserApiKeys && (
<p className="text-xs text-muted">
This instance restricts user API keys. TTS runs through admin-configured shared providers only.
</p>
)}
{shouldShowBaseUrl && (
<div className="space-y-1.5">
@ -584,19 +642,26 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div>
)}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
{shouldShowApiKey && (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">
API Key
{localApiKey && <span className="ml-2 text-xs text-accent">(Overriding env)</span>}
</label>
<Input
type="password"
value={localApiKey}
onChange={(e) => handleInputChange('apiKey', e.target.value)}
placeholder={!showAllDeepInfra && localTTSProvider === 'deepinfra' ? "Deepinfra free or use your API key" : "Using environment variable"}
className="w-full rounded-lg bg-background py-2 px-3 text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
)}
{isSharedSelected && (
<p className="text-xs text-muted">
This is a shared provider configured by an admin. API key and base URL are managed server-side.
</p>
)}
<div className="space-y-1.5">
<label className="block text-sm font-medium text-foreground">TTS Model</label>
@ -704,12 +769,15 @@ export function SettingsModal({ className = '' }: { className?: string }) {
type="button"
className={`${btnSecondary} px-4 py-2`}
onClick={async () => {
const sharedDefault = sharedProviders.find(
(p) => p.slug === runtimeConfig.defaultTtsProvider,
);
setLocalApiKey('');
setLocalBaseUrl('');
setLocalTTSProvider('custom-openai');
setModelValue('kokoro');
setLocalTTSProvider(runtimeConfig.defaultTtsProvider);
setModelValue(runtimeConfig.defaultTtsModel);
setCustomModelInput('');
setLocalTTSInstructions('');
setLocalTTSInstructions(sharedDefault?.defaultInstructions ?? '');
}}
>
Reset
@ -721,8 +789,8 @@ export function SettingsModal({ className = '' }: { className?: string }) {
disabled={!canSubmit}
onClick={async () => {
await updateConfig({
apiKey: localApiKey || '',
baseUrl: localBaseUrl || '',
apiKey: restrictUserApiKeys ? '' : (localApiKey || ''),
baseUrl: restrictUserApiKeys ? '' : (localBaseUrl || ''),
});
await updateConfigKey('ttsProvider', localTTSProvider);
const finalModel = selectedModelId === 'custom' ? customModelInput.trim() : modelValue;
@ -1012,6 +1080,42 @@ export function SettingsModal({ className = '' }: { className?: string }) {
</div>
)}
{/* Admin Section */}
{activeSection === 'admin' && isAdmin && (
<div className="space-y-4">
<div
role="radiogroup"
aria-label="Admin tab"
className="grid grid-cols-2 gap-1 rounded-full border border-offbase bg-background p-1"
>
{([
{ id: 'providers', label: 'Shared providers' },
{ id: 'features', label: 'Site features' },
] as { id: AdminSubTab; label: string }[]).map((tab) => {
const active = adminSubTab === tab.id;
return (
<button
key={tab.id}
type="button"
role="radio"
aria-checked={active}
onClick={() => setAdminSubTab(tab.id)}
className={`rounded-full px-3 py-1.5 text-xs font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
active
? 'bg-accent text-background shadow-sm'
: 'text-muted hover:bg-base hover:text-foreground'
}`}
>
{tab.label}
</button>
);
})}
</div>
{adminSubTab === 'providers' && <AdminProvidersPanel />}
{adminSubTab === 'features' && <AdminFeaturesPanel />}
</div>
)}
{/* Account Section */}
{activeSection === 'account' && authEnabled && (
<div className="space-y-2">

View file

@ -0,0 +1,517 @@
'use client';
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react';
import {
Button,
Input,
Listbox,
ListboxButton,
ListboxOption,
ListboxOptions,
Transition,
} from '@headlessui/react';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
Card,
Section,
ToggleRow,
btnPrimary,
btnSecondary,
inputClass,
} from '@/components/admin/ui';
import {
providerSupportsCustomModel,
resolveProviderModels,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import { useSharedProviders, type SharedProviderEntry } from '@/hooks/useSharedProviders';
type RuntimeConfigSource = 'env-seed' | 'admin' | 'default';
interface SettingsResponse {
values: Record<string, unknown>;
sources: Record<string, RuntimeConfigSource>;
}
interface ProviderOption {
id: string;
name: string;
providerType: TtsProviderId;
shared: boolean;
}
export function AdminFeaturesPanel() {
const [data, setData] = useState<SettingsResponse | null>(null);
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [saving, setSaving] = useState(false);
const [customModelInput, setCustomModelInput] = useState('');
const { providers: sharedProviders } = useSharedProviders();
const refresh = useCallback(async () => {
try {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const next = (await res.json()) as SettingsResponse;
setData(next);
setDraft({ ...next.values });
setDirty(new Set());
} catch (error) {
console.error('[AdminFeaturesPanel] load failed:', error);
toast.error('Failed to load site settings');
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const updateDraft = (key: string, value: unknown) => {
setDraft((d) => ({ ...d, [key]: value }));
setDirty((s) => {
const next = new Set(s);
next.add(key);
return next;
});
};
const resetField = async (key: string) => {
setSaving(true);
try {
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: [key] }),
});
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
toast.success('Reset to env default');
setCustomModelInput('');
await refresh();
} catch (error) {
console.error(error);
toast.error('Reset failed');
} finally {
setSaving(false);
}
};
const saveAll = async () => {
if (saving || dirty.size === 0) return;
setSaving(true);
try {
const updates: Record<string, unknown> = {};
for (const key of dirty) updates[key] = draft[key];
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates }),
});
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
toast.success('Settings saved');
await refresh();
} catch (error) {
console.error(error);
toast.error('Save failed');
} finally {
setSaving(false);
}
};
const discardAll = () => {
if (!data) return;
setDraft({ ...data.values });
setDirty(new Set());
setCustomModelInput('');
};
// --- Provider + model option resolution (mirrors the user-facing TTS tab) ---
const providerOptions = useMemo<ProviderOption[]>(() => {
return sharedProviders.map((entry) => ({
id: entry.slug,
name: `${entry.displayName} (shared)`,
providerType: entry.providerType,
shared: true,
}));
}, [sharedProviders]);
const currentProviderId =
typeof draft.defaultTtsProvider === 'string'
? draft.defaultTtsProvider
: '';
const currentSharedEntry: SharedProviderEntry | undefined = sharedProviders.find(
(p) => p.slug === currentProviderId,
);
const fallbackShared = providerOptions[0];
const effectiveSelectedProvider = currentSharedEntry
? {
id: currentSharedEntry.slug,
name: `${currentSharedEntry.displayName} (shared)`,
providerType: currentSharedEntry.providerType,
shared: true,
} as ProviderOption
: fallbackShared;
const effectiveProviderType: TtsProviderId =
effectiveSelectedProvider?.providerType ?? 'custom-openai';
const showAllDeepInfra = Boolean(draft.showAllDeepInfraModels);
const modelDefinitions = useMemo(
() => resolveProviderModels(effectiveProviderType, { showAllDeepInfra }),
[effectiveProviderType, showAllDeepInfra],
);
const supportsCustomModel = providerSupportsCustomModel(effectiveProviderType);
const currentModelId =
typeof draft.defaultTtsModel === 'string' ? draft.defaultTtsModel : '';
const modelIsPreset = modelDefinitions.some((m) => m.id === currentModelId);
const selectedModelDropdownId = modelIsPreset
? currentModelId
: supportsCustomModel && currentModelId
? 'custom'
: modelDefinitions[0]?.id ?? '';
// Seed the custom input from the stored value the first time we land on a
// provider whose stored model isn't in the preset list.
useEffect(() => {
if (!modelIsPreset && supportsCustomModel && currentModelId && !customModelInput) {
setCustomModelInput(currentModelId);
}
}, [modelIsPreset, supportsCustomModel, currentModelId, customModelInput]);
const selectedProviderOption = effectiveSelectedProvider;
const selectedModelDefinition = modelDefinitions.find(
(m) => m.id === selectedModelDropdownId,
);
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
// Reset the model selection when the provider changes — try to keep the
// current id if it happens to exist in the new provider's catalog,
// otherwise fall back to the new provider's first preset.
const nextModels = resolveProviderModels(opt.providerType, { showAllDeepInfra });
const keepCurrent = nextModels.some((m) => m.id === currentModelId);
if (!keepCurrent) {
const sharedDefault = opt.shared ? sharedProviders.find((p) => p.slug === opt.id)?.defaultModel : null;
const fallback =
(sharedDefault && nextModels.some((m) => m.id === sharedDefault)
? sharedDefault
: nextModels[0]?.id) ?? '';
updateDraft('defaultTtsModel', fallback);
setCustomModelInput('');
}
};
const handleModelChange = (modelId: string) => {
if (modelId === 'custom') {
// Switching to custom keeps whatever's already in the input
updateDraft('defaultTtsModel', customModelInput.trim());
return;
}
updateDraft('defaultTtsModel', modelId);
setCustomModelInput('');
};
const handleCustomModelInput = (value: string) => {
setCustomModelInput(value);
updateDraft('defaultTtsModel', value);
};
// --- Renderers ---
const renderSource = (key: string) => {
const source = data?.sources?.[key] ?? 'default';
const isDirty = dirty.has(key);
return (
<SourceBadge
source={source}
dirty={isDirty}
canReset={source !== 'default'}
onReset={() => resetField(key)}
saving={saving}
/>
);
};
if (!data) {
return (
<Section title="TTS defaults" subtitle="Loading…">
<p className="text-xs text-muted">Fetching current values</p>
</Section>
);
}
return (
<div className="space-y-4">
<Section
title="TTS defaults"
subtitle="What new users start with, and how they can override it."
>
{/* Provider picker */}
<Card className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS provider</p>
<p className="text-xs text-muted mt-0.5">
Initial selection for new users. Only admin shared providers are selectable.
</p>
</div>
<div className="shrink-0">{renderSource('defaultTtsProvider')}</div>
</div>
{providerOptions.length > 0 ? (
<Listbox value={selectedProviderOption} onChange={handleProviderChange}>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
<span className="block truncate">{selectedProviderOption?.name ?? 'Select provider'}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{providerOptions.map((opt) => (
<ListboxOption
key={opt.id}
value={opt}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{opt.name}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
) : (
<div className="rounded-lg border border-offbase bg-base px-3 py-2 text-sm text-muted">
No shared providers configured. Add one in the Shared providers tab first.
</div>
)}
</Card>
{/* Model picker */}
<Card className="space-y-1.5">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Default TTS model</p>
<p className="text-xs text-muted mt-0.5">
Model selected when a new user lands on the chosen provider.
</p>
</div>
<div className="shrink-0">{renderSource('defaultTtsModel')}</div>
</div>
<Listbox
value={selectedModelDropdownId}
onChange={handleModelChange}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{modelDefinitions.map((model) => (
<ListboxOption
key={model.id}
value={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
>
{({ selected }) => (
<>
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') && (
<span className="block truncate text-xs text-muted">
{model.id.slice(model.id.indexOf(':'))}
</span>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
{supportsCustomModel && selectedModelDropdownId === 'custom' && (
<Input
type="text"
value={customModelInput}
onChange={(e) => handleCustomModelInput(e.target.value)}
placeholder="Enter custom model id"
className={inputClass}
/>
)}
</Card>
{/* Boolean TTS toggles */}
<ToggleRow
label="Restrict user API keys (recommended)"
description="When on, users cannot supply personal API keys/base URLs; TTS requests must use admin-configured shared providers."
checked={Boolean(draft.restrictUserApiKeys)}
onChange={(checked) => {
if (!checked) {
const ok = confirm(
'Turning this off allows user-supplied API keys to flow through this server. Continue?',
);
if (!ok) return;
}
updateDraft('restrictUserApiKeys', checked);
}}
right={renderSource('restrictUserApiKeys')}
/>
<ToggleRow
label="Show TTS provider settings tab"
description="Lets users override the provider / API key per-user. Turn off to lock everyone to admin-configured shared default provider and model."
checked={Boolean(draft.enableTtsProvidersTab)}
onChange={(checked) => updateDraft('enableTtsProvidersTab', checked)}
right={renderSource('enableTtsProvidersTab')}
/>
<ToggleRow
label="Show all Deepinfra models"
description="When off, restricts the Deepinfra picker to the free Kokoro-only subset for users without API keys."
checked={Boolean(draft.showAllDeepInfraModels)}
onChange={(checked) => updateDraft('showAllDeepInfraModels', checked)}
right={renderSource('showAllDeepInfraModels')}
/>
</Section>
<Section
title="Site features"
subtitle="Toggle features site-wide. Changes take effect for all users on the next page load."
>
<ToggleRow
label="Word-level highlighting"
description="Use whisper.cpp alignment for word-by-word highlighting during TTS playback."
checked={Boolean(draft.enableWordHighlight)}
onChange={(checked) => updateDraft('enableWordHighlight', checked)}
right={renderSource('enableWordHighlight')}
/>
<ToggleRow
label="Audiobook export"
description='Show the "Export audiobook" feature on PDF / EPUB pages.'
checked={Boolean(draft.enableAudiobookExport)}
onChange={(checked) => updateDraft('enableAudiobookExport', checked)}
right={renderSource('enableAudiobookExport')}
/>
<ToggleRow
label="DOCX upload conversion"
description="Accept .docx files in the document uploader (converted to PDF server-side)."
checked={Boolean(draft.enableDocxConversion)}
onChange={(checked) => updateDraft('enableDocxConversion', checked)}
right={renderSource('enableDocxConversion')}
/>
<ToggleRow
label="Destructive delete buttons"
description='Show "Delete all data" actions in the Documents tab (auth-disabled mode only).'
checked={Boolean(draft.enableDestructiveDeleteActions)}
onChange={(checked) => updateDraft('enableDestructiveDeleteActions', checked)}
right={renderSource('enableDestructiveDeleteActions')}
/>
</Section>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted">
{dirty.size > 0
? `${dirty.size} unsaved change${dirty.size === 1 ? '' : 's'}`
: 'No unsaved changes'}
</p>
<div className="flex gap-2">
<Button
onClick={discardAll}
disabled={dirty.size === 0 || saving}
className={`${btnSecondary} px-4 py-1.5`}
>
Discard
</Button>
<Button
onClick={saveAll}
disabled={dirty.size === 0 || saving}
className={`${btnPrimary} px-4 py-1.5`}
>
{saving ? 'Saving…' : dirty.size > 0 ? `Save (${dirty.size})` : 'Save'}
</Button>
</div>
</div>
</div>
);
}
function SourceBadge({
source,
dirty,
canReset,
onReset,
saving,
}: {
source: RuntimeConfigSource;
dirty: boolean;
canReset: boolean;
onReset: () => void;
saving: boolean;
}) {
return (
<div className="flex items-center gap-1.5">
{canReset && !dirty && (
<button
type="button"
onClick={onReset}
disabled={saving}
className="text-[11px] font-medium text-muted hover:text-accent transition-colors disabled:opacity-50"
>
Reset
</button>
)}
{dirty ? (
<Badge tone="accent">Modified</Badge>
) : source === 'env-seed' ? (
<Badge tone="muted">from env</Badge>
) : source === 'admin' ? (
<Badge tone="foreground">admin</Badge>
) : (
<Badge tone="muted">default</Badge>
)}
</div>
);
}

View file

@ -0,0 +1,548 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
import {
providerSupportsCustomModel,
resolveProviderModels,
REPLICATE_KOKORO_82M_VERSIONED_MODEL,
supportsTtsInstructions,
type TtsModelDefinition,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import {
Badge,
Card,
Field,
Section,
ToggleRow,
btnDanger,
btnOutline,
btnPrimary,
btnSecondary,
inputClass,
} from '@/components/admin/ui';
type ProviderType = TtsProviderId;
interface AdminProviderMasked {
id: string;
slug: string;
displayName: string;
providerType: ProviderType;
baseUrl: string | null;
apiKeyMask: string;
defaultModel: string | null;
defaultInstructions: string | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
const PROVIDER_TYPE_OPTIONS: { value: ProviderType; label: string }[] = [
{ value: 'custom-openai', label: 'Custom OpenAI-like' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'deepinfra', label: 'Deepinfra' },
{ value: 'replicate', label: 'Replicate' },
];
interface FormState {
slug: string;
displayName: string;
providerType: ProviderType;
baseUrl: string;
apiKey: string;
defaultModel: string;
defaultInstructions: string;
enabled: boolean;
}
function providerDefaultModel(providerType: ProviderType): string {
if (providerType === 'openai') return 'tts-1';
if (providerType === 'deepinfra') return 'hexgrad/Kokoro-82M';
if (providerType === 'replicate') return REPLICATE_KOKORO_82M_VERSIONED_MODEL;
return 'kokoro';
}
function createEmptyForm(): FormState {
return {
slug: '',
displayName: '',
providerType: 'custom-openai',
baseUrl: '',
apiKey: '',
defaultModel: providerDefaultModel('custom-openai'),
defaultInstructions: '',
enabled: true,
};
}
export function AdminProvidersPanel() {
const runtimeConfig = useRuntimeConfig();
const [providers, setProviders] = useState<AdminProviderMasked[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [editingId, setEditingId] = useState<string | null>(null); // null = none, '__new' = create
const [form, setForm] = useState<FormState>(() => createEmptyForm());
const [customModelInput, setCustomModelInput] = useState('');
const [saving, setSaving] = useState(false);
const refresh = useCallback(async () => {
setIsLoading(true);
try {
const res = await fetch('/api/admin/providers');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { providers: AdminProviderMasked[] };
setProviders(data.providers);
} catch (error) {
console.error('[AdminProvidersPanel] load failed:', error);
toast.error('Failed to load admin providers');
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const startCreate = () => {
setForm(createEmptyForm());
setCustomModelInput('');
setEditingId('__new');
};
const startEdit = (provider: AdminProviderMasked) => {
setForm({
slug: provider.slug,
displayName: provider.displayName,
providerType: provider.providerType,
baseUrl: provider.baseUrl ?? '',
apiKey: '',
defaultModel: provider.defaultModel ?? providerDefaultModel(provider.providerType),
defaultInstructions: provider.defaultInstructions ?? '',
enabled: provider.enabled,
});
setCustomModelInput('');
setEditingId(provider.id);
};
const cancelEdit = () => {
setEditingId(null);
setForm(createEmptyForm());
setCustomModelInput('');
};
const submit = async () => {
if (saving) return;
setSaving(true);
const isNew = editingId === '__new';
try {
const body = {
slug: form.slug.trim(),
displayName: form.displayName.trim(),
providerType: form.providerType,
baseUrl: form.baseUrl.trim() || null,
...(form.apiKey.length > 0 ? { apiKey: form.apiKey } : {}),
defaultModel: form.defaultModel.trim() || null,
defaultInstructions: form.defaultInstructions.trim() || null,
enabled: form.enabled,
};
const url = isNew ? '/api/admin/providers' : `/api/admin/providers/${editingId}`;
const res = await fetch(url, {
method: isNew ? 'POST' : 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
toast.success(isNew ? 'Provider created' : 'Provider updated');
cancelEdit();
await refresh();
} catch (error) {
console.error('[AdminProvidersPanel] save failed:', error);
toast.error((error as Error).message || 'Save failed');
} finally {
setSaving(false);
}
};
const remove = async (id: string) => {
if (!confirm('Delete this shared provider? Users selecting it will lose access until they switch.')) return;
try {
const res = await fetch(`/api/admin/providers/${id}`, { method: 'DELETE' });
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
toast.success('Provider deleted');
await refresh();
} catch (error) {
console.error('[AdminProvidersPanel] delete failed:', error);
toast.error((error as Error).message || 'Delete failed');
}
};
const isEditingExisting = editingId !== null && editingId !== '__new';
const editingProvider = isEditingExisting
? providers.find((p) => p.id === editingId)
: undefined;
const submitDisabled =
saving ||
!form.slug.trim() ||
!form.displayName.trim();
const selectedProviderType =
PROVIDER_TYPE_OPTIONS.find((opt) => opt.value === form.providerType)
?? PROVIDER_TYPE_OPTIONS[0];
const modelDefinitions: TtsModelDefinition[] = useMemo(
() => resolveProviderModels(form.providerType, {
apiKey: form.apiKey,
showAllDeepInfra: runtimeConfig.showAllDeepInfraModels,
}),
[form.providerType, form.apiKey, runtimeConfig.showAllDeepInfraModels],
);
const supportsCustomModel = providerSupportsCustomModel(form.providerType);
const modelIsPreset = modelDefinitions.some((model) => model.id === form.defaultModel);
const selectedModelId = modelIsPreset
? form.defaultModel
: supportsCustomModel
? 'custom'
: modelDefinitions[0]?.id ?? '';
const selectedModelDefinition = modelDefinitions.find((model) => model.id === selectedModelId);
const baseUrlPlaceholder = form.providerType === 'openai'
? 'https://api.openai.com/v1'
: form.providerType === 'deepinfra'
? 'https://api.deepinfra.com/v1/openai'
: 'https://your-tts-host/v1';
const shouldShowBaseUrl = form.providerType === 'custom-openai';
const shouldShowInstructions = supportsTtsInstructions(form.defaultModel);
useEffect(() => {
if (!supportsCustomModel) {
if (customModelInput) setCustomModelInput('');
return;
}
if (!modelIsPreset && form.defaultModel && customModelInput !== form.defaultModel) {
setCustomModelInput(form.defaultModel);
}
if (modelIsPreset && customModelInput) {
setCustomModelInput('');
}
}, [supportsCustomModel, modelIsPreset, form.defaultModel, customModelInput]);
useEffect(() => {
if (supportsTtsInstructions(form.defaultModel)) return;
if (!form.defaultInstructions) return;
setForm((prev) => ({ ...prev, defaultInstructions: '' }));
}, [form.defaultModel, form.defaultInstructions]);
return (
<Section
title="Shared TTS providers"
subtitle="Server-side providers visible to all users. API keys are encrypted at rest and never sent to the client."
action={
!editingId ? (
<Button
onClick={startCreate}
className={`${btnPrimary} h-7 w-7 p-0 inline-flex items-center justify-center`}
aria-label="Add provider"
title="Add provider"
>
<PlusIcon className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
) : null
}
>
{editingId && (
<Card className="space-y-2.5">
<div className="flex items-baseline justify-between gap-3">
<h4 className="text-sm font-semibold text-foreground">
{isEditingExisting ? `Edit "${editingProvider?.slug}"` : 'New provider'}
</h4>
{isEditingExisting && (
<span className="text-xs text-muted">slug cannot be changed after create</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5">
<Field label="Slug">
<Input
type="text"
value={form.slug}
onChange={(e) => setForm({ ...form, slug: e.target.value })}
placeholder="kokoro-prod"
className={inputClass}
disabled={isEditingExisting}
/>
</Field>
<Field label="Display name">
<Input
type="text"
value={form.displayName}
onChange={(e) => setForm({ ...form, displayName: e.target.value })}
placeholder="Kokoro (production)"
className={inputClass}
/>
</Field>
<Field label="Provider type">
<Listbox
value={selectedProviderType}
onChange={(opt) => {
const nextModel = providerDefaultModel(opt.value);
setForm({
...form,
providerType: opt.value,
baseUrl: opt.value === 'custom-openai' ? form.baseUrl : '',
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
<span className="block truncate">{selectedProviderType.label}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{PROVIDER_TYPE_OPTIONS.map((opt) => (
<ListboxOption
key={opt.value}
value={opt}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{opt.label}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
</Field>
<Field label="Default model" hint="Pre-selected for users picking this provider.">
<div className="space-y-2">
<Listbox
value={selectedModelId}
onChange={(modelId: string) => {
if (modelId === 'custom') {
const nextModel = customModelInput.trim();
setForm({
...form,
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
});
return;
}
setForm({
...form,
defaultModel: modelId,
defaultInstructions: supportsTtsInstructions(modelId) ? form.defaultInstructions : '',
});
setCustomModelInput('');
}}
>
<ListboxButton className="relative w-full cursor-pointer rounded-lg bg-base border border-offbase py-1.5 pl-3 pr-10 text-left text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent hover:bg-offbase hover:text-accent transition-colors">
<span className="block truncate">
{selectedModelDefinition?.name ?? 'Select model'}
</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronUpDownIcon className="h-4 w-4 text-muted" />
</span>
</ListboxButton>
<Transition
as={Fragment}
leave="transition ease-in duration-100"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<ListboxOptions
anchor="bottom start"
className="z-50 w-[var(--button-width)] max-h-60 overflow-y-auto overscroll-contain rounded-md bg-background py-1 shadow-lg ring-1 ring-offbase focus:outline-none [--anchor-gap:0.25rem]"
>
{modelDefinitions.map((model) => (
<ListboxOption
key={model.id}
value={model.id}
className={({ active }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 ${active ? 'bg-offbase text-accent' : 'text-foreground'}`
}
>
{({ selected }) => (
<>
<span className={`block ${selected ? 'font-medium' : 'font-normal'}`}>
<span className="block truncate">{model.name}</span>
{model.id.includes(':') && (
<span className="block truncate text-xs text-muted">
{model.id.slice(model.id.indexOf(':'))}
</span>
)}
</span>
{selected && (
<span className="absolute inset-y-0 left-0 flex items-center pl-3 text-accent">
<CheckIcon className="h-5 w-5" />
</span>
)}
</>
)}
</ListboxOption>
))}
</ListboxOptions>
</Transition>
</Listbox>
{supportsCustomModel && selectedModelId === 'custom' && (
<Input
type="text"
value={customModelInput}
onChange={(e) => {
const nextModel = e.target.value;
setCustomModelInput(nextModel);
setForm({
...form,
defaultModel: nextModel,
defaultInstructions: supportsTtsInstructions(nextModel) ? form.defaultInstructions : '',
});
}}
placeholder="Enter custom model id"
className={inputClass}
/>
)}
</div>
</Field>
{shouldShowInstructions && (
<Field
label="TTS instructions"
className="sm:col-span-2"
hint="Optional. Applied by default when this shared provider is selected."
>
<textarea
value={form.defaultInstructions}
onChange={(e) => setForm({ ...form, defaultInstructions: e.target.value })}
placeholder="Enter instructions for this model"
className={`${inputClass} min-h-24 resize-y`}
/>
</Field>
)}
{shouldShowBaseUrl && (
<Field label="Base URL" className="sm:col-span-2" hint="Optional. Falls back to the provider type's default when blank.">
<Input
type="text"
value={form.baseUrl}
onChange={(e) => setForm({ ...form, baseUrl: e.target.value })}
placeholder={baseUrlPlaceholder}
className={inputClass}
/>
</Field>
)}
<Field
label={isEditingExisting ? 'API key (leave blank to keep existing)' : 'API key (optional)'}
className="sm:col-span-2"
hint="Stored encrypted with AES-256-GCM. Never returned to clients."
>
<Input
type="password"
value={form.apiKey}
onChange={(e) => setForm({ ...form, apiKey: e.target.value })}
placeholder={isEditingExisting ? `keep existing (${editingProvider?.apiKeyMask ?? ''})` : 'Optional'}
className={inputClass}
/>
</Field>
</div>
<ToggleRow
label="Enabled"
description="When off, this provider is hidden from users without being deleted."
checked={form.enabled}
onChange={(checked) => setForm({ ...form, enabled: checked })}
/>
<div className="pt-1 flex justify-end gap-2">
<Button onClick={cancelEdit} className={`${btnSecondary} px-4 py-1.5`}>
Cancel
</Button>
<Button
onClick={submit}
disabled={submitDisabled}
className={`${btnPrimary} px-4 py-1.5`}
>
{saving ? 'Saving…' : isEditingExisting ? 'Save changes' : 'Create'}
</Button>
</div>
</Card>
)}
<div className="space-y-2">
{isLoading ? (
<p className="text-xs text-muted">Loading</p>
) : providers.length === 0 ? (
<Card>
<p className="text-xs text-muted">No shared providers configured yet.</p>
</Card>
) : (
providers.map((p) => (
<Card key={p.id}>
<div className="flex items-start gap-3">
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">{p.displayName}</span>
<Badge tone="muted">{p.slug}</Badge>
{!p.enabled && <Badge tone="muted">Disabled</Badge>}
</div>
<div className="text-xs text-muted truncate">
{p.providerType}
{p.defaultModel ? ` · ${p.defaultModel}` : ''}
{p.defaultInstructions ? ' · instructions' : ''}
{p.baseUrl ? ` · ${p.baseUrl}` : ''}
{' · '}key {p.apiKeyMask}
</div>
</div>
<div className="shrink-0 flex gap-1.5">
<Button
onClick={() => startEdit(p)}
className={`${btnOutline} px-2.5 py-1 text-xs`}
disabled={!!editingId}
>
Edit
</Button>
<Button
onClick={() => remove(p.id)}
className={`${btnDanger} px-2.5 py-1 text-xs`}
disabled={!!editingId}
>
Delete
</Button>
</div>
</div>
</Card>
))
)}
</div>
</Section>
);
}

141
src/components/admin/ui.tsx Normal file
View file

@ -0,0 +1,141 @@
'use client';
import type { ReactNode } from 'react';
/**
* Shared admin panel UI primitives that mirror the DocumentSettings /
* SettingsModal design language (section cards, toggle rows, standard
* button shapes). Keep this small and inline nothing here is meant
* to be reused outside the admin panel.
*/
export const btnBase =
'inline-flex items-center justify-center rounded-lg text-sm font-medium focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 transform transition-transform duration-200 ease-in-out disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:scale-100';
export const btnPrimary = `${btnBase} bg-accent text-background hover:bg-secondary-accent hover:scale-[1.04]`;
export const btnSecondary = `${btnBase} bg-background text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.04]`;
export const btnOutline = `${btnBase} bg-background border border-offbase text-foreground hover:bg-offbase hover:text-accent hover:scale-[1.02]`;
export const btnDanger = `${btnBase} bg-red-600 text-white border border-red-700 hover:bg-red-700 hover:scale-[1.02]`;
// Inputs use `bg-base` so they remain visible regardless of whether the
// surrounding container is `bg-background` (Card) or `bg-base` (Section).
// Using the same `bg-background` as the Card would make the input blend in.
// (Note: never use Tailwind alpha modifiers on these theme variables — they
// resolve to CSS custom properties that don't accept opacity suffixes.)
export const inputClass =
'w-full rounded-lg bg-base border border-offbase py-1.5 px-3 text-sm text-foreground shadow-sm focus:outline-none focus:ring-2 focus:ring-accent';
export function Section({
title,
subtitle,
children,
action,
}: {
title: string;
subtitle?: string;
children: ReactNode;
action?: ReactNode;
}) {
return (
<section className="rounded-2xl border border-offbase bg-base px-3 py-2.5 space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{subtitle ? <p className="text-xs text-muted mt-0.5">{subtitle}</p> : null}
</div>
{action ? <div className="shrink-0">{action}</div> : null}
</div>
{children}
</section>
);
}
export function Card({
children,
className = '',
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={`rounded-xl border border-offbase bg-background px-3 py-2 shadow-sm ${className}`}>
{children}
</div>
);
}
export function ToggleRow({
label,
description,
checked,
onChange,
disabled = false,
right,
}: {
label: string;
description: string;
checked: boolean;
onChange: (checked: boolean) => void;
disabled?: boolean;
right?: ReactNode;
}) {
return (
<Card>
<div className="flex items-start gap-2">
<label className="flex items-start gap-2 flex-1 min-w-0">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
className="mt-0.5 form-checkbox h-4 w-4 text-accent rounded border-muted disabled:opacity-50 disabled:cursor-not-allowed"
/>
<span className="space-y-0.5 min-w-0">
<span className="block text-sm font-medium text-foreground">{label}</span>
<span className="block text-xs text-muted">{description}</span>
</span>
</label>
{right ? <div className="shrink-0 self-start">{right}</div> : null}
</div>
</Card>
);
}
export function Field({
label,
hint,
className = '',
children,
}: {
label?: string;
hint?: string;
className?: string;
children: ReactNode;
}) {
return (
<div className={`space-y-1 ${className}`}>
{label ? <label className="block text-xs font-medium text-muted">{label}</label> : null}
{children}
{hint ? <p className="text-[11px] text-muted">{hint}</p> : null}
</div>
);
}
export function Badge({
tone = 'muted',
children,
}: {
tone?: 'muted' | 'accent' | 'foreground';
children: ReactNode;
}) {
const toneClass =
tone === 'accent'
? 'text-accent'
: tone === 'foreground'
? 'text-foreground bg-offbase'
: 'text-muted bg-offbase';
return (
<span className={`inline-flex items-center text-[10px] uppercase tracking-wide font-semibold rounded px-1.5 py-0.5 ${toneClass}`}>
{children}
</span>
);
}

View file

@ -15,8 +15,7 @@ import {
clampSegmentPreloadSentenceLookahead,
clampTtsSegmentMaxBlockLength,
} from '@/types/config';
const canWordHighlight = process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
const viewTypeTextMapping = [
{ id: 'single', name: 'Single Page' },
@ -105,6 +104,7 @@ export function DocumentSettings({ isOpen, setIsOpen, epub, html }: {
epub?: boolean,
html?: boolean
}) {
const canWordHighlight = useFeatureFlag('enableWordHighlight');
const {
viewType,
skipBlank,

View file

@ -5,8 +5,7 @@ import { useDropzone } from 'react-dropzone';
import { UploadIcon } from '@/components/icons/Icons';
import { useDocuments } from '@/contexts/DocumentContext';
import { uploadDocxAsPdf } from '@/lib/client/api/documents';
const enableDocx = process.env.NEXT_PUBLIC_ENABLE_DOCX_CONVERSION !== 'false';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
interface DocumentUploaderProps {
@ -15,6 +14,7 @@ interface DocumentUploaderProps {
}
export function DocumentUploader({ className = '', variant = 'default' }: DocumentUploaderProps) {
const enableDocx = useFeatureFlag('enableDocxConversion');
const {
addPDFDocument: addPDF,
addEPUBDocument: addEPUB,
@ -57,7 +57,7 @@ export function DocumentUploader({ className = '', variant = 'default' }: Docume
setIsUploading(false);
setIsConverting(false);
}
}, [addHTML, addPDF, addEPUB, refreshDocuments]);
}, [addHTML, addPDF, addEPUB, refreshDocuments, enableDocx]);
const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,

View file

@ -173,6 +173,26 @@ export function CheckIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
className={props.className}
width={props.width || '1.5em'}
height={props.height || '1.5em'}
{...props}
>
<path d="M12 5v14M5 12h14" />
</svg>
);
}
export function UploadIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View file

@ -8,6 +8,7 @@ import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPrefer
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates';
import toast from 'react-hot-toast';
@ -58,7 +59,8 @@ const ConfigContext = createContext<ConfigContextType | undefined>(undefined);
export function ConfigProvider({ children }: { children: ReactNode }) {
const [isLoading, setIsLoading] = useState(true);
const [isDBReady, setIsDBReady] = useState(false);
const ttsProvidersTabDisabled = process.env.NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB === 'false';
const ttsProvidersTabDisabled = !useFeatureFlag('enableTtsProvidersTab');
const restrictUserApiKeys = useFeatureFlag('restrictUserApiKeys');
const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
@ -230,6 +232,38 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
queueSyncedPreferencePatch(resetPatch);
}, [ttsProvidersTabDisabled, isDBReady, appConfig, queueSyncedPreferencePatch]);
useEffect(() => {
if (!restrictUserApiKeys || !isDBReady || !appConfig) return;
const resetPatch: Partial<AppConfigRow> = {};
const builtInProviderIds = new Set(['custom-openai', 'replicate', 'deepinfra', 'openai']);
if (appConfig.apiKey !== APP_CONFIG_DEFAULTS.apiKey) {
resetPatch.apiKey = APP_CONFIG_DEFAULTS.apiKey;
}
if (appConfig.baseUrl !== APP_CONFIG_DEFAULTS.baseUrl) {
resetPatch.baseUrl = APP_CONFIG_DEFAULTS.baseUrl;
}
if (builtInProviderIds.has(appConfig.ttsProvider)) {
if (appConfig.ttsProvider !== APP_CONFIG_DEFAULTS.ttsProvider) {
resetPatch.ttsProvider = APP_CONFIG_DEFAULTS.ttsProvider;
}
if (appConfig.ttsModel !== APP_CONFIG_DEFAULTS.ttsModel) {
resetPatch.ttsModel = APP_CONFIG_DEFAULTS.ttsModel;
}
if (appConfig.ttsInstructions !== APP_CONFIG_DEFAULTS.ttsInstructions) {
resetPatch.ttsInstructions = APP_CONFIG_DEFAULTS.ttsInstructions;
}
}
if (Object.keys(resetPatch).length === 0) return;
updateAppConfig(resetPatch).catch((error) => {
console.warn('Failed to enforce restricted user API key mode:', error);
});
queueSyncedPreferencePatch(resetPatch);
}, [restrictUserApiKeys, isDBReady, appConfig, queueSyncedPreferencePatch]);
useEffect(() => {
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return;
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;

View file

@ -0,0 +1,82 @@
'use client';
import { createContext, useContext, useMemo, type ReactNode } from 'react';
/**
* Site-wide runtime config resolved at SSR time and injected via
* `window.__OPENREADER_RUNTIME_CONFIG__`. Replaces module-scope reads of
* `process.env.NEXT_PUBLIC_*` so admin edits take effect on the next page
* load without a redeploy. Read-only from the client; admin writes go
* through `/api/admin/settings` and trigger a reload.
*
* Keep this in sync with `RUNTIME_CONFIG_SCHEMA` on the server.
*/
export interface RuntimeConfig {
defaultTtsProvider: string;
defaultTtsModel: string;
restrictUserApiKeys: boolean;
enableTtsProvidersTab: boolean;
enableWordHighlight: boolean;
enableAudiobookExport: boolean;
enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllDeepInfraModels: boolean;
}
const RUNTIME_DEFAULTS: RuntimeConfig = {
defaultTtsProvider: 'custom-openai',
defaultTtsModel: 'kokoro',
restrictUserApiKeys: true,
enableTtsProvidersTab: true,
enableWordHighlight: true,
enableAudiobookExport: true,
enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllDeepInfraModels: true,
};
declare global {
// Injected via SSR in `src/app/layout.tsx`. Always defined in the browser.
interface Window {
__OPENREADER_RUNTIME_CONFIG__?: Partial<RuntimeConfig>;
}
}
function readInjectedConfig(): RuntimeConfig {
if (typeof window === 'undefined') return { ...RUNTIME_DEFAULTS };
const injected = window.__OPENREADER_RUNTIME_CONFIG__;
if (!injected || typeof injected !== 'object') return { ...RUNTIME_DEFAULTS };
return { ...RUNTIME_DEFAULTS, ...injected };
}
const RuntimeConfigContext = createContext<RuntimeConfig>({ ...RUNTIME_DEFAULTS });
export function RuntimeConfigProvider({
children,
value,
}: {
children: ReactNode;
/** Optional override (for tests). When omitted, reads from window. */
value?: RuntimeConfig;
}) {
const resolved = useMemo<RuntimeConfig>(() => value ?? readInjectedConfig(), [value]);
return <RuntimeConfigContext.Provider value={resolved}>{children}</RuntimeConfigContext.Provider>;
}
export function useRuntimeConfig(): RuntimeConfig {
return useContext(RuntimeConfigContext);
}
export function useFeatureFlag<K extends keyof RuntimeConfig>(key: K): RuntimeConfig[K] {
const cfg = useRuntimeConfig();
return cfg[key];
}
/**
* Synchronous accessor for modules that are loaded before the React tree
* mounts (e.g. Dexie initialization, config defaults). Falls back to the
* built-in defaults during SSR.
*/
export function readRuntimeConfigSync(): RuntimeConfig {
return readInjectedConfig();
}

View file

@ -199,8 +199,19 @@ type JumpResolution =
const LOOP_GUARD_MIN_INDEX = 2;
const LOOP_GUARD_MIN_PROGRESS = 0.6;
const AUDIO_CACHE_MAX_ITEMS = 25;
const wordHighlightFeatureEnabled =
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
// Read once per module load from SSR-injected runtime config. This sits at
// module scope because the highlight pipeline is constructed lazily and the
// flag rarely changes within a session — admin toggling it picks up on
// reload, matching the SSR-injection model.
const wordHighlightFeatureEnabled = (() => {
if (typeof window === 'undefined') return true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__;
if (!injected || typeof injected !== 'object') return true;
return typeof injected.enableWordHighlight === 'boolean'
? injected.enableWordHighlight
: true;
})();
// Tiny silent WAV used to unlock HTML5 audio on iOS/Safari.
const SILENT_WAV_DATA_URI =

View file

@ -15,3 +15,5 @@ export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentPro
export const documentPreviews = usePostgres ? postgresSchema.documentPreviews : sqliteSchema.documentPreviews;
export const ttsSegmentEntries = usePostgres ? postgresSchema.ttsSegmentEntries : sqliteSchema.ttsSegmentEntries;
export const ttsSegmentVariants = usePostgres ? postgresSchema.ttsSegmentVariants : sqliteSchema.ttsSegmentVariants;
export const adminProviders = usePostgres ? postgresSchema.adminProviders : sqliteSchema.adminProviders;
export const adminSettings = usePostgres ? postgresSchema.adminSettings : sqliteSchema.adminSettings;

View file

@ -13,6 +13,7 @@ export const user = pgTable("user", {
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
isAnonymous: boolean("is_anonymous").default(false),
isAdmin: boolean("is_admin").default(false).notNull(),
});
export const session = pgTable(

View file

@ -17,6 +17,7 @@ export const user = sqliteTable("user", {
.$onUpdate(() => /* @__PURE__ */ new Date())
.notNull(),
isAnonymous: integer("is_anonymous", { mode: "boolean" }).default(false),
isAdmin: integer("is_admin", { mode: "boolean" }).default(false).notNull(),
});
export const session = sqliteTable(

View file

@ -158,6 +158,29 @@ export const ttsSegmentEntries = pgTable('tts_segment_entries', {
),
]);
export const adminProviders = pgTable('admin_providers', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
displayName: text('display_name').notNull(),
providerType: text('provider_type').notNull(),
baseUrl: text('base_url'),
apiKeyCiphertext: text('api_key_ciphertext').notNull(),
apiKeyIv: text('api_key_iv').notNull(),
apiKeyLast4: text('api_key_last4'),
defaultModel: text('default_model'),
defaultInstructions: text('default_instructions'),
enabled: integer('enabled').notNull().default(1),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
});
export const adminSettings = pgTable('admin_settings', {
key: text('key').primaryKey(),
valueJson: jsonb('value_json').notNull(),
source: text('source').notNull().default('admin'),
updatedAt: bigint('updated_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
});
export const ttsSegmentVariants = pgTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -158,6 +158,29 @@ export const ttsSegmentEntries = sqliteTable('tts_segment_entries', {
),
]);
export const adminProviders = sqliteTable('admin_providers', {
id: text('id').primaryKey(),
slug: text('slug').notNull().unique(),
displayName: text('display_name').notNull(),
providerType: text('provider_type').notNull(),
baseUrl: text('base_url'),
apiKeyCiphertext: text('api_key_ciphertext').notNull(),
apiKeyIv: text('api_key_iv').notNull(),
apiKeyLast4: text('api_key_last4'),
defaultModel: text('default_model'),
defaultInstructions: text('default_instructions'),
enabled: integer('enabled').notNull().default(1),
createdAt: integer('created_at').notNull().default(SQLITE_NOW_MS),
updatedAt: integer('updated_at').notNull().default(SQLITE_NOW_MS),
});
export const adminSettings = sqliteTable('admin_settings', {
key: text('key').primaryKey(),
valueJson: text('value_json').notNull(),
source: text('source').notNull().default('admin'),
updatedAt: integer('updated_at').notNull().default(SQLITE_NOW_MS),
});
export const ttsSegmentVariants = sqliteTable('tts_segment_variants', {
segmentId: text('segment_id').notNull(),
userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

View file

@ -0,0 +1,82 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
import type { TtsProviderId } from '@/lib/shared/tts-provider-catalog';
export interface SharedProviderEntry {
slug: string;
displayName: string;
providerType: TtsProviderId;
defaultModel: string | null;
defaultInstructions: string | null;
}
interface State {
data: SharedProviderEntry[];
loaded: boolean;
}
const EMPTY: State = { data: [], loaded: false };
/**
* Fetches the list of admin-configured shared TTS providers visible to the
* current user. Cached in module-scope state to avoid refetching on every
* mount. The list rarely changes during a session; admin edits land on the
* next page load via `__OPENREADER_RUNTIME_CONFIG__`-style behavior.
*/
let cached: State = EMPTY;
let inflight: Promise<SharedProviderEntry[]> | null = null;
const listeners = new Set<(s: State) => void>();
function setCached(next: State) {
cached = next;
for (const fn of listeners) fn(next);
}
async function fetchOnce(): Promise<SharedProviderEntry[]> {
if (inflight) return inflight;
inflight = (async () => {
try {
const res = await fetch('/api/tts/shared-providers', { credentials: 'same-origin' });
if (!res.ok) return [];
const data = (await res.json()) as { providers?: SharedProviderEntry[] };
return data.providers ?? [];
} catch {
return [];
} finally {
inflight = null;
}
})();
const result = await inflight;
setCached({ data: result, loaded: true });
return result;
}
export function useSharedProviders(): {
providers: SharedProviderEntry[];
isLoading: boolean;
refresh: () => Promise<void>;
} {
const [state, setState] = useState<State>(cached);
useEffect(() => {
listeners.add(setState);
if (!cached.loaded) {
void fetchOnce();
}
return () => {
listeners.delete(setState);
};
}, []);
const refresh = useCallback(async () => {
setCached({ ...cached, loaded: false });
await fetchOnce();
}, []);
return { providers: state.data, isLoading: !state.loaded, refresh };
}
export function getCachedSharedProviders(): SharedProviderEntry[] {
return cached.data;
}

View file

@ -1,5 +1,5 @@
import Dexie, { type EntityTable } from 'dexie';
import { APP_CONFIG_DEFAULTS, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
import { APP_CONFIG_DEFAULTS, getAppConfigDefaults, type ViewType, type SavedVoices, type AppConfigRow } from '@/types/config';
import {
PDFDocument,
EPUBDocument,
@ -114,7 +114,7 @@ function inferProviderAndBaseUrl(raw: RawConfigMap): { provider: string; baseUrl
let inferredProvider = raw.ttsProvider || '';
if (!raw.ttsProvider) {
inferredProvider = process.env.NEXT_PUBLIC_DEFAULT_TTS_PROVIDER || 'custom-openai';
inferredProvider = getAppConfigDefaults().ttsProvider;
} else if (!inferredProvider) {
if (cachedBaseUrl) {
const baseUrlLower = cachedBaseUrl.toLowerCase();

View file

@ -4,7 +4,9 @@ import {
resolveProviderModels,
type TtsModelDefinition,
type TtsProviderDefinition,
type TtsProviderId,
} from '@/lib/shared/tts-provider-catalog';
import type { SharedProviderEntry } from '@/hooks/useSharedProviders';
export interface ResolveTtsSettingsViewModelOptions {
provider: string;
@ -12,37 +14,89 @@ export interface ResolveTtsSettingsViewModelOptions {
modelValue: string;
customModelInput: string;
showAllDeepInfra: boolean;
sharedProviders?: SharedProviderEntry[];
allowBuiltInProviders?: boolean;
}
export interface ProviderPickerOption {
id: string;
name: string;
/** Underlying built-in provider type. Same as `id` for built-ins; mapped from `providerType` for admin shared instances. */
providerType: TtsProviderId;
/** True when this picker entry represents an admin-configured shared provider. */
shared: boolean;
}
export interface TtsSettingsViewModel {
providers: TtsProviderDefinition[];
providers: ProviderPickerOption[];
models: TtsModelDefinition[];
supportsCustomModel: boolean;
selectedModelId: string;
canSubmit: boolean;
/** The matched shared provider entry, if the current selection is a shared slug. */
selectedSharedProvider: SharedProviderEntry | null;
}
const BUILT_IN_DEFINITION_BY_ID: Map<string, TtsProviderDefinition> = new Map(
TTS_PROVIDER_DEFINITIONS.map((def) => [def.id, def]),
);
export function resolveTtsSettingsViewModel({
provider,
apiKey,
modelValue,
customModelInput,
showAllDeepInfra,
sharedProviders = [],
allowBuiltInProviders = true,
}: ResolveTtsSettingsViewModelOptions): TtsSettingsViewModel {
const models = resolveProviderModels(provider, {
const builtInOptions: ProviderPickerOption[] = allowBuiltInProviders
? TTS_PROVIDER_DEFINITIONS.map((def) => ({
id: def.id,
name: def.name,
providerType: def.id,
shared: false,
}))
: [];
const sharedOptions: ProviderPickerOption[] = sharedProviders.map((entry) => ({
id: entry.slug,
name: `${entry.displayName} (shared)`,
providerType: entry.providerType,
shared: true,
}));
const providers = [...sharedOptions, ...builtInOptions];
const selectedProviderId = providers.some((opt) => opt.id === provider)
? provider
: providers[0]?.id ?? '';
// Determine the *effective* built-in provider type used for model resolution.
const selectedShared = sharedProviders.find((p) => p.slug === selectedProviderId) ?? null;
const effectiveProvider = selectedShared ? selectedShared.providerType : selectedProviderId;
const models = resolveProviderModels(effectiveProvider, {
apiKey,
showAllDeepInfra,
});
const supportsCustomModel = providerSupportsCustomModel(provider);
const supportsCustomModel =
BUILT_IN_DEFINITION_BY_ID.has(effectiveProvider) &&
providerSupportsCustomModel(effectiveProvider);
const isPreset = models.some((model) => model.id === modelValue);
const selectedModelId = isPreset ? modelValue : (supportsCustomModel ? 'custom' : (models[0]?.id ?? ''));
const canSubmit = selectedModelId !== 'custom' || (supportsCustomModel && customModelInput.trim().length > 0);
const selectedModelId = isPreset
? modelValue
: supportsCustomModel
? 'custom'
: models[0]?.id ?? '';
const canSubmit = providers.length > 0 && (
selectedModelId !== 'custom' ||
(supportsCustomModel && customModelInput.trim().length > 0)
);
return {
providers: TTS_PROVIDER_DEFINITIONS,
providers,
models,
supportsCustomModel,
selectedModelId,
canSubmit,
selectedSharedProvider: selectedShared,
};
}

View file

@ -0,0 +1,72 @@
import { eq } from 'drizzle-orm';
import { db } from '@/db';
// We only need the `user` table here. Better Auth manages its own schema files;
// we import them lazily to avoid coupling this module to a single dialect.
function getUserTable() {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const sqlite = require('@/db/schema_auth_sqlite');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const pg = require('@/db/schema_auth_postgres');
return process.env.POSTGRES_URL ? pg.user : sqlite.user;
}
let cachedAdminEmails: Set<string> | null = null;
let cachedAdminEmailsRaw: string | null = null;
/** Parse ADMIN_EMAILS env. Memoized but invalidated when the env value changes. */
export function getAdminEmailSet(): Set<string> {
const raw = process.env.ADMIN_EMAILS ?? '';
if (cachedAdminEmails && cachedAdminEmailsRaw === raw) {
return cachedAdminEmails;
}
const next = new Set<string>();
for (const part of raw.split(',')) {
const trimmed = part.trim().toLowerCase();
if (trimmed) next.add(trimmed);
}
cachedAdminEmails = next;
cachedAdminEmailsRaw = raw;
return next;
}
export function isAdminEmail(email: string | null | undefined): boolean {
if (!email) return false;
return getAdminEmailSet().has(email.trim().toLowerCase());
}
/**
* Idempotently keep `user.is_admin` in sync with the ADMIN_EMAILS env.
*
* Called from:
* - Better Auth's `databaseHooks.user.create.after` (so newly-signed-up
* admins are promoted on first login).
* - `getAuthContext()` (so removing an email from the env demotes the user
* on the next session resolution).
*
* Returns the resolved isAdmin value. Swallows DB errors (e.g. before
* migrations have run) and returns the user's current flag value.
*/
export async function syncAdminFlag(
userId: string,
email: string | null | undefined,
currentIsAdmin: boolean,
): Promise<boolean> {
const shouldBeAdmin = isAdminEmail(email);
if (shouldBeAdmin === currentIsAdmin) return currentIsAdmin;
try {
const user = getUserTable();
await db.update(user).set({ isAdmin: shouldBeAdmin }).where(eq(user.id, userId));
return shouldBeAdmin;
} catch (error) {
console.warn('[admin] Failed to sync isAdmin flag for user', userId, error);
return currentIsAdmin;
}
}
/** Test-only: clear the memoized env value so a fresh read picks up changes. */
export function _resetAdminEmailCacheForTests() {
cachedAdminEmails = null;
cachedAdminEmailsRaw = null;
}

View file

@ -0,0 +1,331 @@
import { randomUUID } from 'node:crypto';
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
import { supportsTtsInstructions, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
'custom-openai',
'replicate',
'deepinfra',
'openai',
];
const SLUG_PATTERN = /^[a-z0-9][a-z0-9-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
export interface AdminProviderRecord {
id: string;
slug: string;
displayName: string;
providerType: TtsProviderId;
baseUrl: string | null;
apiKeyCiphertext: string;
apiKeyIv: string;
apiKeyLast4: string | null;
defaultModel: string | null;
defaultInstructions: string | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
export interface AdminProviderMasked {
id: string;
slug: string;
displayName: string;
providerType: TtsProviderId;
baseUrl: string | null;
apiKeyMask: string;
defaultModel: string | null;
defaultInstructions: string | null;
enabled: boolean;
createdAt: number;
updatedAt: number;
}
export interface AdminProviderPublic {
slug: string;
displayName: string;
providerType: TtsProviderId;
defaultModel: string | null;
defaultInstructions: string | null;
}
export interface CreateAdminProviderInput {
slug: string;
displayName: string;
providerType: TtsProviderId;
baseUrl?: string | null;
apiKey?: string;
defaultModel?: string | null;
defaultInstructions?: string | null;
enabled?: boolean;
}
export interface UpdateAdminProviderPatch {
slug?: string;
displayName?: string;
providerType?: TtsProviderId;
baseUrl?: string | null;
/** Optional — only re-encrypts when supplied. */
apiKey?: string;
defaultModel?: string | null;
defaultInstructions?: string | null;
enabled?: boolean;
}
export class AdminProviderError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
export function validateProviderType(value: unknown): TtsProviderId {
if (typeof value !== 'string' || !BUILT_IN_PROVIDER_IDS.includes(value as TtsProviderId)) {
throw new AdminProviderError(
`providerType must be one of: ${BUILT_IN_PROVIDER_IDS.join(', ')}`,
400,
);
}
return value as TtsProviderId;
}
export function validateSlug(slug: unknown): string {
if (typeof slug !== 'string') {
throw new AdminProviderError('slug is required', 400);
}
const normalized = slug.trim().toLowerCase();
if (!SLUG_PATTERN.test(normalized)) {
throw new AdminProviderError(
'slug must be lowercase alphanumeric or hyphens (164 chars, no leading/trailing hyphen)',
400,
);
}
if ((BUILT_IN_PROVIDER_IDS as readonly string[]).includes(normalized)) {
throw new AdminProviderError(
`slug "${normalized}" is reserved (collides with a built-in provider id)`,
400,
);
}
return normalized;
}
function rowToRecord(row: Record<string, unknown>): AdminProviderRecord {
// The `enabled` column is integer (0/1) in SQLite and integer in Postgres
// (we modeled it as integer there too). Either way, treat any truthy value
// as enabled. Booleans go straight through.
const enabled = row.enabled === true || row.enabled === 1 || row.enabled === '1';
return {
id: String(row.id),
slug: String(row.slug),
displayName: String(row.displayName ?? row.display_name),
providerType: String(row.providerType ?? row.provider_type) as TtsProviderId,
baseUrl: (row.baseUrl ?? row.base_url ?? null) as string | null,
apiKeyCiphertext: String(row.apiKeyCiphertext ?? row.api_key_ciphertext),
apiKeyIv: String(row.apiKeyIv ?? row.api_key_iv),
apiKeyLast4: (row.apiKeyLast4 ?? row.api_key_last4 ?? null) as string | null,
defaultModel: (row.defaultModel ?? row.default_model ?? null) as string | null,
defaultInstructions: (row.defaultInstructions ?? row.default_instructions ?? null) as string | null,
enabled,
createdAt: Number(row.createdAt ?? row.created_at ?? 0),
updatedAt: Number(row.updatedAt ?? row.updated_at ?? 0),
};
}
export function toMasked(record: AdminProviderRecord): AdminProviderMasked {
const last4 = (record.apiKeyLast4 ?? '').trim();
return {
id: record.id,
slug: record.slug,
displayName: record.displayName,
providerType: record.providerType,
baseUrl: record.baseUrl,
apiKeyMask: last4 ? `••••${last4}` : '(not set)',
defaultModel: record.defaultModel,
defaultInstructions: record.defaultInstructions,
enabled: record.enabled,
createdAt: record.createdAt,
updatedAt: record.updatedAt,
};
}
export function toPublic(record: AdminProviderRecord): AdminProviderPublic {
return {
slug: record.slug,
displayName: record.displayName,
providerType: record.providerType,
defaultModel: record.defaultModel,
defaultInstructions: record.defaultInstructions,
};
}
function normalizeOptionalText(value: string | null | undefined): string | null {
if (value === null || value === undefined) return null;
const trimmed = String(value).trim();
return trimmed ? trimmed : null;
}
function assertInstructionsCompatibility(model: string | null, instructions: string | null): void {
if (!instructions) return;
if (!supportsTtsInstructions(model)) {
throw new AdminProviderError(
'defaultInstructions is only supported for models that support TTS instructions.',
400,
);
}
}
export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
const rows = await db.select().from(adminProviders);
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
}
export async function getAdminProviderBySlug(slug: string): Promise<AdminProviderRecord | null> {
const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1);
const arr = rows as Array<Record<string, unknown>>;
return arr[0] ? rowToRecord(arr[0]) : null;
}
export async function getAdminProviderById(id: string): Promise<AdminProviderRecord | null> {
const rows = await db.select().from(adminProviders).where(eq(adminProviders.id, id)).limit(1);
const arr = rows as Array<Record<string, unknown>>;
return arr[0] ? rowToRecord(arr[0]) : null;
}
export async function decryptedKeyFor(record: AdminProviderRecord): Promise<string> {
return decryptSecret(record.apiKeyCiphertext, record.apiKeyIv);
}
export async function createAdminProvider(
input: CreateAdminProviderInput,
): Promise<AdminProviderRecord> {
const slug = validateSlug(input.slug);
const providerType = validateProviderType(input.providerType);
if (!input.displayName || !input.displayName.trim()) {
throw new AdminProviderError('displayName is required', 400);
}
const apiKey = String(input.apiKey ?? '');
const defaultModel = normalizeOptionalText(input.defaultModel);
const defaultInstructions = normalizeOptionalText(input.defaultInstructions);
assertInstructionsCompatibility(defaultModel, defaultInstructions);
const existing = await getAdminProviderBySlug(slug);
if (existing) {
throw new AdminProviderError(`slug "${slug}" already exists`, 409);
}
const enc = encryptSecret(apiKey);
const now = Date.now();
const id = randomUUID();
await db.insert(adminProviders).values({
id,
slug,
displayName: input.displayName.trim(),
providerType,
baseUrl: input.baseUrl ?? null,
apiKeyCiphertext: enc.ciphertext,
apiKeyIv: enc.iv,
apiKeyLast4: apiKeyLast4(apiKey),
defaultModel,
defaultInstructions,
enabled: (input.enabled ?? true) ? 1 : 0,
createdAt: now,
updatedAt: now,
});
const created = await getAdminProviderById(id);
if (!created) throw new AdminProviderError('failed to create provider', 500);
return created;
}
export async function updateAdminProvider(
id: string,
patch: UpdateAdminProviderPatch,
): Promise<AdminProviderRecord> {
const current = await getAdminProviderById(id);
if (!current) throw new AdminProviderError('provider not found', 404);
const update: Record<string, unknown> = { updatedAt: Date.now() };
const nextModel =
patch.defaultModel !== undefined
? normalizeOptionalText(patch.defaultModel)
: current.defaultModel;
const nextInstructions =
patch.defaultInstructions !== undefined
? normalizeOptionalText(patch.defaultInstructions)
: current.defaultInstructions;
assertInstructionsCompatibility(nextModel, nextInstructions);
if (patch.slug !== undefined) {
const slug = validateSlug(patch.slug);
if (slug !== current.slug) {
const dup = await getAdminProviderBySlug(slug);
if (dup) throw new AdminProviderError(`slug "${slug}" already exists`, 409);
update.slug = slug;
}
}
if (patch.displayName !== undefined) {
if (!patch.displayName.trim()) {
throw new AdminProviderError('displayName cannot be empty', 400);
}
update.displayName = patch.displayName.trim();
}
if (patch.providerType !== undefined) {
update.providerType = validateProviderType(patch.providerType);
}
if (patch.baseUrl !== undefined) {
update.baseUrl = patch.baseUrl ?? null;
}
if (patch.defaultModel !== undefined) {
update.defaultModel = normalizeOptionalText(patch.defaultModel);
}
if (patch.defaultInstructions !== undefined) {
update.defaultInstructions = normalizeOptionalText(patch.defaultInstructions);
}
if (patch.enabled !== undefined) {
update.enabled = patch.enabled ? 1 : 0;
}
if (patch.apiKey !== undefined && patch.apiKey !== '') {
const enc = encryptSecret(patch.apiKey);
update.apiKeyCiphertext = enc.ciphertext;
update.apiKeyIv = enc.iv;
update.apiKeyLast4 = apiKeyLast4(patch.apiKey);
}
await db.update(adminProviders).set(update).where(eq(adminProviders.id, id));
const updated = await getAdminProviderById(id);
if (!updated) throw new AdminProviderError('failed to load updated provider', 500);
return updated;
}
export async function deleteAdminProvider(id: string): Promise<void> {
const existing = await getAdminProviderById(id);
if (!existing) throw new AdminProviderError('provider not found', 404);
await db.delete(adminProviders).where(eq(adminProviders.id, id));
}
/** Lookup helper used by TTS routes: returns null if not found or disabled. */
export async function getEnabledAdminProviderBySlug(
slug: string,
): Promise<AdminProviderRecord | null> {
if (!slug) return null;
const rows = await db
.select()
.from(adminProviders)
.where(and(eq(adminProviders.slug, slug), eq(adminProviders.enabled, 1)))
.limit(1);
const arr = rows as Array<Record<string, unknown>>;
return arr[0] ? rowToRecord(arr[0]) : null;
}
export async function getFirstEnabledAdminProvider(): Promise<AdminProviderRecord | null> {
const rows = await db
.select()
.from(adminProviders)
.where(eq(adminProviders.enabled, 1))
.limit(1);
const arr = rows as Array<Record<string, unknown>>;
return arr[0] ? rowToRecord(arr[0]) : null;
}

View file

@ -0,0 +1,113 @@
import {
getEnabledAdminProviderBySlug,
getFirstEnabledAdminProvider,
decryptedKeyFor,
type AdminProviderRecord,
} from '@/lib/server/admin/providers';
export interface ResolvedTtsCredentials {
/** Provider id passed downstream to TTS generation (one of the 4 built-in IDs). */
provider: string;
/** API key for the request. Empty string when neither admin nor user supplied one. */
apiKey: string;
/** Base URL, or undefined to fall through to provider defaults. */
baseUrl: string | undefined;
/** True iff the request was resolved against an admin shared provider. */
fromAdmin: boolean;
/** The matched admin provider record, when applicable. */
adminRecord?: AdminProviderRecord;
}
/**
* Resolve TTS credentials for an incoming request.
*
* 1. If `restrictUserApiKeys` is enabled, only admin shared providers are
* used. Built-in provider ids and user-supplied key/base headers are
* ignored.
* 2. If `providerHeader` matches an enabled admin provider slug, use its
* server-stored credentials. Any `x-openai-key` / `x-openai-base-url`
* headers from the client are ignored admin keys must never round-trip
* through the client.
* 3. Otherwise, treat `providerHeader` as a built-in provider id and use the
* per-user `x-openai-key` / `x-openai-base-url` headers as today.
*
* Returns `null` when the request references a slug that exists but is
* disabled callers should reject with a 4xx.
*/
export async function resolveTtsCredentials(opts: {
providerHeader: string | null;
apiKeyHeader: string | null;
baseUrlHeader: string | null;
fallbackProvider?: string;
restrictUserApiKeys?: boolean;
}): Promise<ResolvedTtsCredentials | { error: 'provider_disabled' | 'provider_unknown' | 'no_shared_provider_configured'; slug: string }> {
const requestedProvider = opts.providerHeader || opts.fallbackProvider || 'openai';
if (opts.restrictUserApiKeys) {
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
const fallback = opts.fallbackProvider || '';
const fallbackIsBuiltIn = isBuiltInProviderId(fallback);
const requestedSharedSlug = requestedIsBuiltIn ? '' : requestedProvider;
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallback;
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
if (preferredSharedSlug) {
const admin = await getEnabledAdminProviderBySlug(preferredSharedSlug);
if (!admin) {
return { error: 'provider_unknown', slug: preferredSharedSlug };
}
const apiKey = await decryptedKeyFor(admin);
return {
provider: admin.providerType,
apiKey,
baseUrl: admin.baseUrl || undefined,
fromAdmin: true,
adminRecord: admin,
};
}
const firstShared = await getFirstEnabledAdminProvider();
if (!firstShared) {
return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(firstShared);
return {
provider: firstShared.providerType,
apiKey,
baseUrl: firstShared.baseUrl || undefined,
fromAdmin: true,
adminRecord: firstShared,
};
}
// Built-in provider ids are not admin slugs — short-circuit.
if (isBuiltInProviderId(requestedProvider)) {
return {
provider: requestedProvider,
apiKey: opts.apiKeyHeader || '',
baseUrl: opts.baseUrlHeader || undefined,
fromAdmin: false,
};
}
// Not a built-in id → try to look it up as an admin slug.
const admin = await getEnabledAdminProviderBySlug(requestedProvider);
if (!admin) {
return { error: 'provider_unknown', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(admin);
return {
provider: admin.providerType,
apiKey,
baseUrl: admin.baseUrl || undefined,
fromAdmin: true,
adminRecord: admin,
};
}
const BUILT_IN_IDS = new Set(['custom-openai', 'replicate', 'deepinfra', 'openai']);
function isBuiltInProviderId(value: string): boolean {
return BUILT_IN_IDS.has(value);
}

View file

@ -0,0 +1,144 @@
import { db } from '@/db';
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 {
RUNTIME_CONFIG_SCHEMA,
seedRuntimeConfigFromEnv,
} from '@/lib/server/admin/settings';
/**
* Idempotent boot-time seeding for the admin layer. Safe to call multiple
* times. Runs:
*
* 1. `seedRuntimeConfigFromEnv()` for each `NEXT_PUBLIC_*` 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.
*/
let seedPromise: Promise<void> | null = null;
export async function ensureAdminSeed(): Promise<void> {
if (!seedPromise) {
seedPromise = runSeed().catch((error) => {
console.warn('[admin-seed] failed:', error);
// Reset so a subsequent call can retry (e.g. once migrations run).
seedPromise = null;
throw error;
});
}
try {
await seedPromise;
} catch {
// Already logged. Don't surface to callers — admin layer is best-effort.
}
}
async function runSeed(): Promise<void> {
await seedRuntimeConfigFromEnv();
await seedDefaultAdminProvider();
await cleanupLegacyDefaultTtsProviderSeedRow();
}
async function seedDefaultAdminProvider(): Promise<void> {
const apiKey = process.env.API_KEY;
if (!apiKey || !apiKey.trim()) return;
let existing: Array<unknown>;
try {
existing = await db.select({ id: adminProviders.id }).from(adminProviders).limit(1);
} catch (error) {
console.warn('[admin-seed] could not check admin_providers (table missing?)', error);
return;
}
if (existing.length > 0) return;
const baseUrl = process.env.API_BASE?.trim() || null;
const now = Date.now();
const defaultModel = (() => {
const raw = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsModel.envVar];
return raw && raw.trim() ? raw.trim() : null;
})();
let enc: ReturnType<typeof encryptSecret>;
try {
enc = encryptSecret(apiKey);
} catch (error) {
const hasExplicitRestriction =
Boolean(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 });
console.warn(
'[admin-seed] API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false so BYOK remains available',
);
} catch (fallbackError) {
console.warn(
'[admin-seed] failed to write restrictUserApiKeys fallback after encryption failure',
fallbackError,
);
}
}
console.warn('[admin-seed] failed to encrypt default provider API key', error);
return;
}
try {
await db.insert(adminProviders).values({
id: randomUUID(),
slug: 'default-openai',
displayName: 'Default (from env)',
providerType: 'custom-openai',
baseUrl,
apiKeyCiphertext: enc.ciphertext,
apiKeyIv: enc.iv,
apiKeyLast4: apiKeyLast4(apiKey),
defaultModel,
enabled: 1,
createdAt: now,
updatedAt: now,
});
console.log('[admin-seed] created default-openai admin provider from env');
} catch (error) {
console.warn('[admin-seed] failed to insert default-openai provider', error);
}
}
async function cleanupLegacyDefaultTtsProviderSeedRow(): Promise<void> {
// If an explicit env default exists, keep env-seeded behavior.
const explicit = process.env[RUNTIME_CONFIG_SCHEMA.defaultTtsProvider.envVar];
if (explicit && explicit.trim()) return;
const key = 'defaultTtsProvider';
const seededValue = JSON.stringify('default-openai');
try {
await db
.delete(adminSettings)
.where(
and(
eq(adminSettings.key, key),
eq(adminSettings.source, 'env-seed'),
eq(adminSettings.valueJson, seededValue as never),
),
);
} catch (error) {
console.warn('[admin-seed] failed to cleanup legacy defaultTtsProvider seed row', error);
}
}

View file

@ -0,0 +1,292 @@
import { and, eq } from 'drizzle-orm';
import { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
/**
* Runtime config: site-wide settings that used to live in `NEXT_PUBLIC_*`
* env vars. 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.
*/
export type RuntimeConfigSource = '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. */
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> {
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;
},
};
}
function stringValue(defaultValue: string, envVar: 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;
},
};
}
export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'NEXT_PUBLIC_DEFAULT_TTS_PROVIDER'),
defaultTtsModel: stringValue('kokoro', 'NEXT_PUBLIC_DEFAULT_TTS_MODEL'),
restrictUserApiKeys: booleanFlag(true, 'NEXT_PUBLIC_RESTRICT_USER_API_KEYS'),
// Historically the env semantics were "true unless explicitly 'false'",
// i.e. the feature defaults to ON.
enableTtsProvidersTab: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_TTS_PROVIDERS_TAB'),
enableWordHighlight: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT'),
enableAudiobookExport: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_AUDIOBOOK_EXPORT'),
enableDocxConversion: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DOCX_CONVERSION'),
enableDestructiveDeleteActions: booleanFlag(true, 'NEXT_PUBLIC_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
showAllDeepInfraModels: booleanFlag(true, 'NEXT_PUBLIC_SHOW_ALL_DEEPINFRA_MODELS'),
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;
export type RuntimeConfig = {
[K in RuntimeConfigKey]: typeof RUNTIME_CONFIG_SCHEMA[K] extends RuntimeConfigKeyDef<infer T>
? T
: never;
};
export type RuntimeConfigEntry = {
key: RuntimeConfigKey;
value: RuntimeConfig[RuntimeConfigKey];
source: RuntimeConfigSource | 'default';
};
const RUNTIME_KEYS = Object.keys(RUNTIME_CONFIG_SCHEMA) as RuntimeConfigKey[];
async function resolveImplicitDefaultTtsProvider(): Promise<string | undefined> {
try {
const rows = await db
.select({ slug: adminProviders.slug })
.from(adminProviders)
.where(and(eq(adminProviders.slug, 'default-openai'), eq(adminProviders.enabled, 1)))
.limit(1);
return rows[0]?.slug;
} catch (error) {
console.warn('[runtime-config] implicit defaultTtsProvider lookup failed:', error);
return undefined;
}
}
function buildDefaults(): RuntimeConfig {
const out = {} as RuntimeConfig;
for (const key of RUNTIME_KEYS) {
(out as Record<string, unknown>)[key] = RUNTIME_CONFIG_SCHEMA[key].default;
}
// In no-auth mode there is no admin UI to configure shared providers.
// Keep legacy BYOK available by default unless explicitly overridden.
if (!isAuthEnabled()) {
out.restrictUserApiKeys = false;
}
return out;
}
async function readAllRows(): Promise<Map<string, { value: unknown; source: string }>> {
try {
const rows = await db.select().from(adminSettings);
const out = new Map<string, { value: unknown; source: string }>();
for (const row of rows as Array<{ key: string; valueJson: unknown; source: string }>) {
const parsed = parseStoredValue(row.valueJson);
out.set(row.key, { value: parsed, source: row.source });
}
return out;
} catch (error) {
console.warn('[runtime-config] read failed (table may not exist yet):', error);
return new Map();
}
}
function parseStoredValue(stored: unknown): unknown {
// Postgres jsonb returns the parsed value; SQLite text stores JSON strings.
if (typeof stored === 'string') {
try {
return JSON.parse(stored);
} catch {
return stored;
}
}
return stored;
}
function serializeForStorage(value: unknown): unknown {
// Use JSON.stringify for SQLite-friendly text storage; Postgres jsonb
// accepts a JS object, but a JSON string is equally valid (Postgres parses
// it). Storing as string makes the two dialects behave identically.
return JSON.stringify(value);
}
/** Resolve the full runtime config, DB overlaid on defaults. */
export async function getRuntimeConfig(): Promise<RuntimeConfig> {
const out = buildDefaults();
const rows = await readAllRows();
let implicitDefaultTtsProvider: string | null | undefined;
for (const key of RUNTIME_KEYS) {
const row = rows.get(key);
if (!row) {
if (key === 'defaultTtsProvider') {
if (implicitDefaultTtsProvider === undefined) {
implicitDefaultTtsProvider = (await resolveImplicitDefaultTtsProvider()) ?? null;
}
if (implicitDefaultTtsProvider) {
(out as Record<string, unknown>)[key] = implicitDefaultTtsProvider;
}
}
continue;
}
const validated = RUNTIME_CONFIG_SCHEMA[key].validate(row.value);
if (validated !== undefined) {
(out as Record<string, unknown>)[key] = validated;
}
}
return out;
}
/** Like getRuntimeConfig() but also reports the source of each value. */
export async function getRuntimeConfigWithSources(): Promise<{
values: RuntimeConfig;
sources: Record<RuntimeConfigKey, RuntimeConfigSource | 'default'>;
}> {
const values = buildDefaults();
const sources = {} as Record<RuntimeConfigKey, RuntimeConfigSource | 'default'>;
const rows = await readAllRows();
let implicitDefaultTtsProvider: string | null | undefined;
for (const key of RUNTIME_KEYS) {
const row = rows.get(key);
if (!row) {
if (key === 'defaultTtsProvider') {
if (implicitDefaultTtsProvider === undefined) {
implicitDefaultTtsProvider = (await resolveImplicitDefaultTtsProvider()) ?? null;
}
if (implicitDefaultTtsProvider) {
(values as Record<string, unknown>)[key] = implicitDefaultTtsProvider;
}
}
sources[key] = 'default';
continue;
}
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');
} else {
sources[key] = 'default';
}
}
return { values, sources };
}
/** Set a single runtime config key (admin write). Flips source to 'admin'. */
export async function setRuntimeConfigKey<K extends RuntimeConfigKey>(
key: K,
value: RuntimeConfig[K],
): Promise<void> {
const def = RUNTIME_CONFIG_SCHEMA[key] as RuntimeConfigKeyDef<RuntimeConfig[K]>;
const validated = def.validate(value);
if (validated === undefined) {
throw new Error(`Invalid value for runtime config key "${key}"`);
}
const serialized = serializeForStorage(validated);
const now = Date.now();
// Upsert with onConflict.
const isPg = !!process.env.POSTGRES_URL;
if (isPg) {
await db
.insert(adminSettings)
.values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now })
.onConflictDoUpdate({
target: adminSettings.key,
set: { valueJson: serialized as never, source: 'admin', updatedAt: now },
});
} else {
await db
.insert(adminSettings)
.values({ key, valueJson: serialized as never, source: 'admin', updatedAt: now })
.onConflictDoUpdate({
target: adminSettings.key,
set: { valueJson: serialized as never, source: 'admin', updatedAt: now },
});
}
}
/** Delete a runtime config row (resets to env/default 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.
*/
export async function seedRuntimeConfigFromEnv(): Promise<{ seeded: RuntimeConfigKey[] }> {
const seeded: RuntimeConfigKey[] = [];
let existing: Map<string, { value: unknown; source: string }>;
try {
existing = await readAllRows();
} catch {
return { seeded };
}
const now = Date.now();
for (const key of RUNTIME_KEYS) {
if (existing.has(key)) continue;
const def = RUNTIME_CONFIG_SCHEMA[key];
const raw = process.env[def.envVar];
if (raw === undefined || raw === null || raw === '') continue;
const parsed = def.parseEnv(raw);
if (parsed === undefined) continue;
try {
await db
.insert(adminSettings)
.values({
key,
valueJson: serializeForStorage(parsed) as never,
source: 'env-seed',
updatedAt: now,
})
.onConflictDoNothing({ target: adminSettings.key });
seeded.push(key);
} catch (error) {
console.warn('[runtime-config] seed failed for', key, error);
}
}
return { seeded };
}
export { RUNTIME_KEYS };

View file

@ -0,0 +1,31 @@
import { supportsTtsInstructions } from '@/lib/shared/tts-provider-catalog';
function normalizeInstructionCandidate(value: string | null | undefined): string | undefined {
if (value === null || value === undefined) return undefined;
const trimmed = String(value).trim();
return trimmed.length > 0 ? trimmed : undefined;
}
/**
* Resolve effective TTS instructions for a request.
*
* Priority:
* 1) explicit per-request instructions
* 2) shared-provider default instructions
*
* Returns `undefined` when the model does not support instructions or when
* neither source provides a non-empty value.
*/
export function resolveEffectiveTtsInstructions(opts: {
model: string | null | undefined;
requestInstructions?: string | null;
sharedDefaultInstructions?: string | null;
}): string | undefined {
if (!supportsTtsInstructions(opts.model)) {
return undefined;
}
return normalizeInstructionCandidate(opts.requestInstructions)
?? normalizeInstructionCandidate(opts.sharedDefaultInstructions);
}

View file

@ -0,0 +1,33 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { getAuthContext, type AuthContext } from '@/lib/server/auth/auth';
export type AdminAuthContext = AuthContext & {
user: NonNullable<AuthContext['user']> & { isAdmin: true };
};
/**
* Returns the admin auth context, or a 401/403 Response if the requester is
* not authenticated / not an admin. Mirrors the `requireAuthContext` shape.
*
* When auth is disabled, this always returns 403 there is no notion of
* "admin" without authentication.
*/
export async function requireAdminContext(
request: Pick<NextRequest, 'headers'>,
): Promise<AdminAuthContext | Response> {
const ctx = await getAuthContext(request);
if (!ctx.authEnabled || !ctx.userId || !ctx.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
// The `isAdmin` field is added via Better Auth `additionalFields`. Older
// sessions may not carry it on the type but the DB-resolved session will.
const userRecord = ctx.user as unknown as { isAdmin?: boolean | null };
if (!userRecord.isAdmin) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
}
return ctx as AdminAuthContext;
}

View file

@ -6,6 +6,7 @@ import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { db } from "@/db";
import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config";
import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
import * as authSchemaSqlite from "@/db/schema_auth_sqlite";
import * as authSchemaPostgres from "@/db/schema_auth_postgres";
@ -72,6 +73,14 @@ const createAuth = () => betterAuth({
},
},
user: {
additionalFields: {
isAdmin: {
type: 'boolean',
required: false,
defaultValue: false,
input: false, // never settable from the client; controlled by ADMIN_EMAILS
},
},
deleteUser: {
enabled: true,
beforeDelete: async (user) => {
@ -86,6 +95,22 @@ const createAuth = () => betterAuth({
},
},
},
databaseHooks: {
user: {
create: {
before: async (user) => {
// Stamp newly-created users with the correct isAdmin value if their
// email matches ADMIN_EMAILS. This avoids a follow-up UPDATE on
// first signup. The `input: false` above prevents clients from
// forcing isAdmin=true through signup payloads.
if (isAdminEmail(user.email)) {
return { data: { ...user, isAdmin: true } };
}
return { data: user };
},
},
},
},
rateLimit: {
// Better Auth built-in rate limiting is enabled by default.
// Set DISABLE_AUTH_RATE_LIMIT=true to disable it.
@ -214,6 +239,17 @@ export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Pro
const user = session?.user ?? null;
const userId = user?.id ?? null;
// Keep user.isAdmin in sync with ADMIN_EMAILS on every session resolution.
// Cheap when nothing changes (no-ops at the DB layer); promotes/demotes
// when the env list is edited. Skips anonymous users (no real email).
if (user && userId && user.email && !user.isAnonymous) {
const current = (user as unknown as { isAdmin?: boolean }).isAdmin ?? false;
const resolved = await syncAdminFlag(userId, user.email, current);
if (resolved !== current) {
(user as unknown as { isAdmin: boolean }).isAdmin = resolved;
}
}
return { authEnabled, session, user, userId };
}

View file

@ -0,0 +1,82 @@
import { createCipheriv, createDecipheriv, randomBytes, scryptSync } from 'node:crypto';
/**
* Symmetric encryption for admin-managed secrets (API keys, etc).
*
* AES-256-GCM with a 256-bit key derived once via scrypt from AUTH_SECRET.
* The IV is 12 bytes (GCM standard) and the auth tag is appended to the
* ciphertext so we only need to persist (ciphertext, iv) in the DB.
*
* The persisted ciphertext is base64; ciphertext bytes = encrypted || tag(16).
*/
const KEY_SALT = 'openreader:admin-secrets:v1';
const KEY_LENGTH = 32; // 256 bits
const IV_LENGTH = 12; // 96 bits for GCM
const TAG_LENGTH = 16; // 128 bits
let cachedKey: Buffer | null = null;
function deriveKey(): Buffer {
if (cachedKey) return cachedKey;
const secret = process.env.AUTH_SECRET;
if (!secret) {
throw new Error(
'AUTH_SECRET is required to encrypt/decrypt admin secrets. ' +
'Set AUTH_SECRET in your environment.',
);
}
cachedKey = scryptSync(secret, KEY_SALT, KEY_LENGTH);
return cachedKey;
}
export interface EncryptedSecret {
ciphertext: string; // base64(encrypted || authTag)
iv: string; // base64(iv)
}
export function encryptSecret(plaintext: string): EncryptedSecret {
if (typeof plaintext !== 'string') {
throw new TypeError('encryptSecret expects a string');
}
const key = deriveKey();
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const tag = cipher.getAuthTag();
return {
ciphertext: Buffer.concat([encrypted, tag]).toString('base64'),
iv: iv.toString('base64'),
};
}
export function decryptSecret(ciphertext: string, iv: string): string {
if (typeof ciphertext !== 'string' || typeof iv !== 'string') {
throw new TypeError('decryptSecret expects (string, string)');
}
const key = deriveKey();
const ivBuffer = Buffer.from(iv, 'base64');
const combined = Buffer.from(ciphertext, 'base64');
if (combined.length < TAG_LENGTH) {
throw new Error('Ciphertext too short to contain auth tag');
}
const encrypted = combined.subarray(0, combined.length - TAG_LENGTH);
const tag = combined.subarray(combined.length - TAG_LENGTH);
const decipher = createDecipheriv('aes-256-gcm', key, ivBuffer);
decipher.setAuthTag(tag);
const plaintext = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return plaintext.toString('utf8');
}
/** Mask helper for API keys — returns "••••" + last 4 chars (or fewer). */
export function maskApiKey(key: string): string {
if (!key) return '';
const trimmed = key.trim();
if (trimmed.length <= 4) return '•'.repeat(trimmed.length);
return '••••' + trimmed.slice(-4);
}
export function apiKeyLast4(key: string): string {
const trimmed = (key || '').trim();
return trimmed.length <= 4 ? trimmed : trimmed.slice(-4);
}

View file

@ -0,0 +1,31 @@
import 'server-only';
import { ensureAdminSeed } from '@/lib/server/admin/seed';
import {
getRuntimeConfig,
getRuntimeConfigWithSources,
type RuntimeConfig,
type RuntimeConfigKey,
type RuntimeConfigSource,
} from '@/lib/server/admin/settings';
export type ResolvedRuntimeConfig = RuntimeConfig;
/**
* Returns the resolved site-wide runtime config in the shape consumed by
* the client via SSR injection. Triggers the boot-time seed on first call
* so env values land in the DB before the first read.
*/
export async function getResolvedRuntimeConfig(): Promise<ResolvedRuntimeConfig> {
await ensureAdminSeed();
return getRuntimeConfig();
}
export async function getResolvedRuntimeConfigWithSources(): Promise<{
values: RuntimeConfig;
sources: Record<RuntimeConfigKey, RuntimeConfigSource | 'default'>;
}> {
await ensureAdminSeed();
return getRuntimeConfigWithSources();
}
export type { RuntimeConfig, RuntimeConfigKey };

View file

@ -1,8 +1,28 @@
import type { DocumentListState } from '@/types/documents';
// Runtime config (admin-controlled) is layered on top of the static defaults
// below. We resolve it lazily so this module stays importable from non-React
// contexts (Dexie, server routes). The actual values come from
// `window.__OPENREADER_RUNTIME_CONFIG__` (SSR-injected) on the client, and
// from the built-in defaults during SSR.
const wordHighlightEnabledByDefault =
process.env.NEXT_PUBLIC_ENABLE_WORD_HIGHLIGHT?.toLowerCase() !== 'false';
function readRuntimeFlag(key: string, defaultValue: boolean): boolean {
if (typeof window === 'undefined') return defaultValue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__;
if (!injected || typeof injected !== 'object') return defaultValue;
const value = injected[key];
return typeof value === 'boolean' ? value : defaultValue;
}
function readRuntimeString(key: string, defaultValue: string): string {
if (typeof window === 'undefined') return defaultValue;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const injected = (window as any).__OPENREADER_RUNTIME_CONFIG__;
if (!injected || typeof injected !== 'object') return defaultValue;
const value = injected[key];
return typeof value === 'string' && value ? value : defaultValue;
}
export type ViewType = 'single' | 'dual' | 'scroll';
@ -62,43 +82,94 @@ export interface AppConfigValues {
documentsMigrationPrompted: boolean;
}
export const APP_CONFIG_DEFAULTS: AppConfigValues = {
apiKey: '',
baseUrl: '',
viewType: 'single',
voiceSpeed: 1,
audioPlayerSpeed: 1,
voice: '',
skipBlank: true,
epubTheme: false,
headerMargin: 0,
footerMargin: 0,
leftMargin: 0,
rightMargin: 0,
ttsProvider: process.env.NEXT_PUBLIC_DEFAULT_TTS_PROVIDER || 'custom-openai',
ttsModel: process.env.NEXT_PUBLIC_DEFAULT_TTS_MODEL || 'kokoro',
ttsInstructions: '',
savedVoices: {},
smartSentenceSplitting: true,
segmentPreloadDepthPages: 1,
segmentPreloadSentenceLookahead: 3,
ttsSegmentMaxBlockLength: 450,
pdfHighlightEnabled: true,
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
epubHighlightEnabled: true,
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
firstVisit: false,
documentListState: {
sortBy: 'name',
sortDirection: 'asc',
folders: [],
collapsedFolders: [],
showHint: true,
viewMode: 'grid',
},
privacyAccepted: false,
documentsMigrationPrompted: false,
};
/**
* Build defaults lazily so we can read SSR-injected admin overrides
* (`window.__OPENREADER_RUNTIME_CONFIG__`). Modules that need the defaults
* statically should call `getAppConfigDefaults()` at use time. The exported
* `APP_CONFIG_DEFAULTS` is a Proxy that re-resolves on each access so
* mutations to the runtime config (admin edits) are picked up by anything
* that reads through it.
*/
export function getAppConfigDefaults(): AppConfigValues {
const wordHighlightEnabledByDefault = readRuntimeFlag('enableWordHighlight', true);
return {
apiKey: '',
baseUrl: '',
viewType: 'single',
voiceSpeed: 1,
audioPlayerSpeed: 1,
voice: '',
skipBlank: true,
epubTheme: false,
headerMargin: 0,
footerMargin: 0,
leftMargin: 0,
rightMargin: 0,
ttsProvider: readRuntimeString('defaultTtsProvider', 'custom-openai'),
ttsModel: readRuntimeString('defaultTtsModel', 'kokoro'),
ttsInstructions: '',
savedVoices: {},
smartSentenceSplitting: true,
segmentPreloadDepthPages: 1,
segmentPreloadSentenceLookahead: 3,
ttsSegmentMaxBlockLength: 450,
pdfHighlightEnabled: true,
pdfWordHighlightEnabled: wordHighlightEnabledByDefault,
epubHighlightEnabled: true,
epubWordHighlightEnabled: wordHighlightEnabledByDefault,
firstVisit: false,
documentListState: {
sortBy: 'name',
sortDirection: 'asc',
folders: [],
collapsedFolders: [],
showHint: true,
viewMode: 'grid',
},
privacyAccepted: false,
documentsMigrationPrompted: false,
};
}
/**
* Static defaults snapshot resolved at first access. For callers that need
* fresh values after admin edits, prefer `getAppConfigDefaults()` directly.
* Most consumers just need a stable defaults object for spreads, so this is
* resolved once per process admin overrides take effect on next page load
* (which is the SSR-injected behavior we want anyway).
*/
let cachedDefaults: AppConfigValues | null = null;
export const APP_CONFIG_DEFAULTS: AppConfigValues = (() => {
// Return a getter-backed object that resolves on first access. On the
// server, this resolves to built-in defaults; on the client, to the
// SSR-injected admin values.
const handler: ProxyHandler<AppConfigValues> = {
get(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
return (cachedDefaults as unknown as Record<string | symbol, unknown>)[prop as string];
},
has(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
return prop in (cachedDefaults as object);
},
ownKeys() {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
return Reflect.ownKeys(cachedDefaults as object);
},
getOwnPropertyDescriptor(_target, prop) {
if (!cachedDefaults) cachedDefaults = getAppConfigDefaults();
const value = (cachedDefaults as unknown as Record<string | symbol, unknown>)[prop as string];
if (value === undefined && !(prop in (cachedDefaults as object))) return undefined;
return {
value,
writable: true,
enumerable: true,
configurable: true,
};
},
};
return new Proxy({} as AppConfigValues, handler);
})();
export interface AppConfigRow extends AppConfigValues {
id: string;

View file

@ -0,0 +1,52 @@
import { expect, test } from '@playwright/test';
import {
AdminProviderError,
toMasked,
validateProviderType,
validateSlug,
type AdminProviderRecord,
} from '../../src/lib/server/admin/providers';
test.describe('admin provider validation', () => {
test('normalizes valid slugs to lowercase', () => {
expect(validateSlug('My-Provider-1')).toBe('my-provider-1');
});
test('rejects built-in provider ids as slugs', () => {
expect(() => validateSlug('openai')).toThrow(AdminProviderError);
expect(() => validateSlug('custom-openai')).toThrow('reserved');
});
test('rejects malformed slug values', () => {
expect(() => validateSlug('-bad-')).toThrow(AdminProviderError);
expect(() => validateSlug('bad_slug')).toThrow(AdminProviderError);
expect(() => validateSlug('')).toThrow(AdminProviderError);
});
test('accepts only known provider types', () => {
expect(validateProviderType('openai')).toBe('openai');
expect(() => validateProviderType('unknown')).toThrow(AdminProviderError);
});
test('masks api key from last4 in list responses', () => {
const record: AdminProviderRecord = {
id: 'id-1',
slug: 'shared-one',
displayName: 'Shared One',
providerType: 'openai',
baseUrl: null,
apiKeyCiphertext: 'ciphertext',
apiKeyIv: 'iv',
apiKeyLast4: 'abcd',
defaultModel: 'gpt-4o-mini-tts',
defaultInstructions: 'warm tone',
enabled: true,
createdAt: 1,
updatedAt: 1,
};
expect(toMasked(record).apiKeyMask).toBe('••••abcd');
});
});

View file

@ -0,0 +1,46 @@
import { expect, test } from '@playwright/test';
import { resolveEffectiveTtsInstructions } from '../../src/lib/server/admin/tts-instructions';
test.describe('resolveEffectiveTtsInstructions', () => {
test('uses explicit request instructions when model supports them', () => {
const out = resolveEffectiveTtsInstructions({
model: 'gpt-4o-mini-tts',
requestInstructions: 'Speak quickly.',
sharedDefaultInstructions: 'Default style',
});
expect(out).toBe('Speak quickly.');
});
test('falls back to shared default instructions when request value is missing', () => {
const out = resolveEffectiveTtsInstructions({
model: 'gpt-4o-mini-tts',
requestInstructions: '',
sharedDefaultInstructions: 'Warm, conversational tone',
});
expect(out).toBe('Warm, conversational tone');
});
test('trims whitespace-only values and returns undefined when both are empty', () => {
const out = resolveEffectiveTtsInstructions({
model: 'gpt-4o-mini-tts',
requestInstructions: ' ',
sharedDefaultInstructions: '\n\t',
});
expect(out).toBeUndefined();
});
test('returns undefined for models that do not support instructions', () => {
const out = resolveEffectiveTtsInstructions({
model: 'kokoro',
requestInstructions: 'Use emphasis',
sharedDefaultInstructions: 'Default',
});
expect(out).toBeUndefined();
});
});

View file

@ -0,0 +1,80 @@
import { expect, test } from '@playwright/test';
import { resolveTtsSettingsViewModel } from '../../src/lib/client/settings/tts-settings';
import type { SharedProviderEntry } from '../../src/hooks/useSharedProviders';
const SHARED: SharedProviderEntry[] = [
{
slug: 'shared-openai',
displayName: 'OpenAI Shared',
providerType: 'openai',
defaultModel: 'gpt-4o-mini-tts',
defaultInstructions: 'Default shared instructions',
},
{
slug: 'shared-replicate',
displayName: 'Replicate Shared',
providerType: 'replicate',
defaultModel: 'owner/model:ver',
defaultInstructions: null,
},
];
test.describe('resolveTtsSettingsViewModel (admin/shared modes)', () => {
test('restrict mode exposes only shared providers', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-openai',
modelValue: 'gpt-4o-mini-tts',
customModelInput: '',
showAllDeepInfra: false,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.providers.map((p) => p.id)).toEqual(['shared-openai', 'shared-replicate']);
expect(vm.providers.every((p) => p.shared)).toBe(true);
});
test('invalid provider falls back to first available option', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'missing-provider',
modelValue: 'gpt-4o-mini-tts',
customModelInput: '',
showAllDeepInfra: false,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.selectedSharedProvider?.slug).toBe('shared-openai');
});
test('custom-model capable providers use custom mode for unknown model ids', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-replicate',
modelValue: 'my-custom-model',
customModelInput: '',
showAllDeepInfra: false,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.supportsCustomModel).toBe(true);
expect(vm.selectedModelId).toBe('custom');
expect(vm.canSubmit).toBe(false);
});
test('non-custom providers fall back to preset model selection', () => {
const vm = resolveTtsSettingsViewModel({
provider: 'shared-openai',
modelValue: 'not-in-presets',
customModelInput: '',
showAllDeepInfra: false,
sharedProviders: SHARED,
allowBuiltInProviders: false,
});
expect(vm.supportsCustomModel).toBe(false);
expect(vm.selectedModelId).not.toBe('custom');
expect(vm.models.length).toBeGreaterThan(0);
});
});