refactor(tts): transition rate limiting to runtime config and admin panel

- Remove legacy TTS rate limit environment variables and migrate all related configuration to runtime settings managed via the Admin UI
- Add runtime config keys for TTS rate limiting enable/disable and per-user/IP daily quotas
- Refactor rate limiter and API routes to use runtime config for thresholds and enablement
- Update AdminFeaturesPanel to allow editing TTS rate limiting and quota values in the UI
- Add shared-provider-selection utility for consistent provider selection logic
- Update documentation to reflect new runtime/admin configuration and remove obsolete env var docs
- Add unit tests for rate limit runtime settings and provider selection

BREAKING CHANGE: TTS rate limiting is now controlled via Admin → Site features; environment variables for TTS rate limiting are no longer supported and will be ignored.
This commit is contained in:
Richard R 2026-05-30 09:55:16 -06:00
parent e262f93f90
commit a0f2d008e0
20 changed files with 744 additions and 227 deletions

View file

@ -15,7 +15,7 @@
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
# (Optional) TTS request/cache tuning (leave unset to use defaults)
# (Optional) TTS request/cache tuning (deaults shown below; leave unset to use defaults)
# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB
# TTS_CACHE_TTL_MS=1800000 # 30 minutes
# TTS_MAX_RETRIES=2
@ -24,27 +24,18 @@ API_KEY=api_key_optional
# TTS_RETRY_BACKOFF=2
# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds
# (Optional) Enable TTS character rate limiting (default is `false`)
# TTS_ENABLE_RATE_LIMIT=true
# (Optional) TTS per-user daily limits (leave unset to use defaults)
# TTS_DAILY_LIMIT_ANONYMOUS=50000
# TTS_DAILY_LIMIT_AUTHENTICATED=500000
# (Optional) TTS IP backstop daily limits (leave unset to use defaults)
# TTS_IP_DAILY_LIMIT_ANONYMOUS=100000
# TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000
# Auth configuration (recommended for contributors and public instances)
# Auth configuration (recommended; required for admin features)
# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set
BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network)
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`)
USE_ANONYMOUS_AUTH_SESSIONS=
# USE_ANONYMOUS_AUTH_SESSIONS=false
# (Optional) Sign in w/ GitHub Configuration
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing)
DISABLE_AUTH_RATE_LIMIT=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`)
# DISABLE_AUTH_RATE_LIMIT=false
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
# Admins see the "Admin" tab in Settings (TTS shared providers + site features).
@ -54,34 +45,28 @@ 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=
# POSTGRES_URL=
# Embedded SeaweedFS weed mini config
# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`)
USE_EMBEDDED_WEED_MINI=
WEED_MINI_DIR=
WEED_MINI_WAIT_SEC=
# (Optional) Enable embedded weed mini for local S3-compatible storage (defaults shown below)
# USE_EMBEDDED_WEED_MINI=true
# WEED_MINI_DIR=docstore/seaweedfs
# WEED_MINI_WAIT_SEC=20
# S3 storage config (use with embedded weed mini or external S3-compatible storage)
# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts.
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
S3_REGION=
# S3_REGION=
# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host.
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=
S3_PREFIX=
# S3_ENDPOINT=
# S3_FORCE_PATH_STYLE=
# S3_PREFIX=
# Migrations configuration
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
RUN_DRIZZLE_MIGRATIONS=
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
RUN_FS_MIGRATIONS=
# (Optional) Server library import roots (uses /docstore/library by default)
IMPORT_LIBRARY_DIR=
IMPORT_LIBRARY_DIRS=
# (Optional) Library import mode directory (uses /docstore/library by default)
# IMPORT_LIBRARY_DIR=
# IMPORT_LIBRARY_DIRS=
# Compute
# Embedded/local (default): leave COMPUTE_WORKER_URL empty.
@ -118,7 +103,7 @@ IMPORT_LIBRARY_DIRS=
# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
# (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN=
# FFMPEG_BIN=
# (Optional) Client feature flags — seeded into the admin-managed runtime
# config on first boot, then ignored. Edit values from Settings → Admin →
@ -132,3 +117,10 @@ FFMPEG_BIN=
# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai
# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true
# RUNTIME_SEED_DISABLE_TTS_LIMIT=true
# Migrations configuration
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
# RUN_DRIZZLE_MIGRATIONS=true
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
# RUN_FS_MIGRATIONS=true

View file

@ -7,7 +7,8 @@ This page explains OpenReader's TTS character rate limiting controls.
## Overview
- TTS rate limiting is disabled by default.
- To enable it, set `TTS_ENABLE_RATE_LIMIT=true`.
- Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**.
- Optional first-boot seed: `RUNTIME_SEED_DISABLE_TTS_LIMIT=true`.
- Limits are enforced per day in UTC.
- Enforcement applies only when auth is enabled.
@ -28,21 +29,10 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta
- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
## Environment variables
## Runtime config + seed var
Enable/disable:
- `TTS_ENABLE_RATE_LIMIT` (default: `false`)
Per-user daily limits:
- `TTS_DAILY_LIMIT_ANONYMOUS` (default: `50000`)
- `TTS_DAILY_LIMIT_AUTHENTICATED` (default: `500000`)
IP backstop daily limits:
- `TTS_IP_DAILY_LIMIT_ANONYMOUS` (default: `100000`)
- `TTS_IP_DAILY_LIMIT_AUTHENTICATED` (default: `1000000`)
- First-boot seed toggle: `RUNTIME_SEED_DISABLE_TTS_LIMIT` (default: `true`)
- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
## Related docs

View file

@ -24,11 +24,6 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `TTS_RETRY_MAX_MS` | TTS retry | `2000` | Tune max retry delay |
| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor |
| `TTS_UPSTREAM_TIMEOUT_MS` | TTS request timeout | `285000` | Set max upstream TTS request duration before fail-fast |
| `TTS_ENABLE_RATE_LIMIT` | Rate limiting | `false` | Set `true` to enable TTS per-user/IP daily character limits |
| `TTS_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `50000` | Override anonymous per-user daily character limit |
| `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit |
| `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit |
| `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit |
| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth |
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth |
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
@ -76,6 +71,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK |
| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug |
| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI |
| `RUNTIME_SEED_DISABLE_TTS_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps TTS daily rate limiting disabled |
@ -158,37 +154,22 @@ Maximum upstream TTS request timeout in milliseconds.
- Applies to outbound provider calls from server routes using shared TTS generation
- Increase for slower providers/models; decrease to fail fast and surface retryable errors sooner
### TTS_ENABLE_RATE_LIMIT
### TTS Daily Rate Limiting (Runtime Settings)
Controls TTS character rate limiting in the TTS API.
TTS character rate limiting is now managed from **Settings → Admin → Site features**.
- Default: `false` (TTS char limits disabled)
- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*`
- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting)
- `disableTtsRateLimit` default: `true` (rate limiting disabled)
- Daily limit defaults:
- Anonymous per-user: `50000`
- Authenticated per-user: `500000`
- Anonymous IP backstop: `100000`
- Authenticated IP backstop: `1000000`
### TTS_DAILY_LIMIT_ANONYMOUS
Optional first-boot seeds:
Anonymous per-user daily character limit.
- `RUNTIME_SEED_DISABLE_TTS_LIMIT`
- Default: `50000`
### TTS_DAILY_LIMIT_AUTHENTICATED
Authenticated per-user daily character limit.
- Default: `500000`
### TTS_IP_DAILY_LIMIT_ANONYMOUS
Anonymous IP backstop daily character limit.
- Default: `100000`
### TTS_IP_DAILY_LIMIT_AUTHENTICATED
Authenticated IP backstop daily character limit.
- Default: `1000000`
After first boot, these values are DB-backed admin runtime settings.
## Auth and Identity

View file

@ -8,7 +8,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -532,7 +532,15 @@ export async function POST(request: NextRequest) {
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions,
});
if (authEnabled && userId && isTtsRateLimitEnabled()) {
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
if (authEnabled && userId && ttsRateLimitEnabled) {
const isAnonymous = Boolean(user?.isAnonymous);
const charCount = data.text.length;
const ip = getClientIp(request);
@ -549,6 +557,10 @@ export async function POST(request: NextRequest) {
deviceId: device?.deviceId ?? null,
ip,
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
if (!rateLimitResult.allowed) {
@ -570,7 +582,7 @@ export async function POST(request: NextRequest) {
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
? `Sign up to increase your limit from ${formatLimitForHint(limits.anonymous)} to ${formatLimitForHint(limits.authenticated)} characters per day`
: undefined,
instance: request.nextUrl.pathname,
};

View file

@ -1,11 +1,12 @@
import { NextResponse, type NextRequest } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
@ -17,7 +18,14 @@ function getUtcResetTimeMs(): number {
export async function GET(req: NextRequest) {
try {
const ttsRateLimitEnabled = isTtsRateLimitEnabled();
const runtimeConfig = await getResolvedRuntimeConfig();
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
@ -46,8 +54,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
limit: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: true
@ -57,7 +65,7 @@ export async function GET(req: NextRequest) {
const isAnonymous = Boolean((session.user as { isAnonymous?: boolean }).isAnonymous);
const ip = getClientIp(req);
const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const device = ttsRateLimitEnabled ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const result = await rateLimiter.getCurrentUsage(
{
@ -67,7 +75,11 @@ export async function GET(req: NextRequest) {
{
deviceId: device?.deviceId ?? null,
ip,
}
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
const response = NextResponse.json({

View file

@ -23,7 +23,7 @@ import {
} from '@/lib/server/tts/segments';
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } 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';
@ -169,6 +169,13 @@ export async function POST(request: NextRequest) {
const scope = await resolveSegmentDocumentScope(request, parsed.documentId);
if (scope instanceof Response) return scope;
const runtimeConfig = await getResolvedRuntimeConfig();
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.providerRef,
apiKeyHeader: request.headers.get('x-openai-key'),
@ -461,7 +468,7 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId,
});
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) {
const charCount = segment.text.length;
const ip = getClientIp(request);
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
@ -477,6 +484,10 @@ export async function POST(request: NextRequest) {
deviceId: device?.deviceId ?? null,
ip,
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
if (!rateLimitResult.allowed) {
@ -484,6 +495,8 @@ export async function POST(request: NextRequest) {
rateLimitResult,
isAnonymousUser: scope.isAnonymousUser,
pathname: request.nextUrl.pathname,
anonymousLimit: limits.anonymous,
authenticatedLimit: limits.authenticated,
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;

View file

@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import {
Badge,
EditableRow,
Section,
ToggleRow,
buttonClass,
@ -56,12 +57,14 @@ export function AdminFeaturesPanel() {
});
const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set());
const [editingChangelogUrl, setEditingChangelogUrl] = useState(false);
const { providers: sharedProviders } = useSharedProviders();
useEffect(() => {
if (!data) return;
setDraft({ ...data.values });
setDirty(new Set());
setEditingChangelogUrl(false);
}, [data]);
useEffect(() => {
@ -104,11 +107,19 @@ export function AdminFeaturesPanel() {
setDraft((d) => ({ ...d, [key]: value }));
setDirty((s) => {
const next = new Set(s);
next.add(key);
const baselineValue = data?.values?.[key];
if (Object.is(value, baselineValue)) next.delete(key);
else next.add(key);
return next;
});
};
const updatePositiveIntDraft = (key: string, raw: string) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
updateDraft(key, Math.max(1, Math.floor(parsed)));
};
const resetField = (key: string) => {
if (saving) return;
resetMutation.mutate(key);
@ -151,6 +162,7 @@ export function AdminFeaturesPanel() {
} as ProviderOption
: fallbackShared;
const selectedProviderOption = effectiveSelectedProvider;
const showTtsDailyLimitInputs = !Boolean(draft.disableTtsRateLimit);
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
@ -270,6 +282,85 @@ export function AdminFeaturesPanel() {
right={renderSource('showAllProviderModels')}
variant="flat"
/>
<ToggleRow
label="Disable TTS daily rate limiting"
description="When on, per-user/IP daily character quotas are not enforced."
checked={Boolean(draft.disableTtsRateLimit)}
onChange={(checked) => updateDraft('disableTtsRateLimit', checked)}
right={renderSource('disableTtsRateLimit')}
variant="flat"
/>
<Transition
as={Fragment}
show={showTtsDailyLimitInputs}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 -translate-y-1 max-h-0"
enterTo="opacity-100 translate-y-0 max-h-[420px]"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 translate-y-0 max-h-[420px]"
leaveTo="opacity-0 -translate-y-1 max-h-0"
>
<div className="overflow-hidden">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
</div>
</div>
</div>
</Transition>
</Section>
<Section
@ -277,16 +368,15 @@ export function AdminFeaturesPanel() {
subtitle="Feature flags for all users."
action={<Badge tone="foreground">Feature Flags</Badge>}
>
<div className="space-y-1.5 pb-2 border-b border-offbase">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-sm font-medium text-foreground">Changelog feed URL</p>
<p className="text-xs text-muted mt-0.5">
Public URL to the changelog manifest JSON used by Settings.
</p>
</div>
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div>
</div>
<EditableRow
label="Changelog feed URL"
description="Public URL to the changelog manifest JSON used by Settings."
value={String(draft.changelogFeedUrl ?? '') || '—'}
expanded={editingChangelogUrl}
onExpandedChange={setEditingChangelogUrl}
right={renderSource('changelogFeedUrl')}
editLabel={editingChangelogUrl ? 'Close editor' : 'Edit URL'}
>
<input
type="text"
className={inputClass}
@ -294,7 +384,7 @@ export function AdminFeaturesPanel() {
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/>
</div>
</EditableRow>
<ToggleRow
label="Allow new account sign-ups"
description="When off, new accounts cannot be created. Existing accounts can still sign in."

View file

@ -1,11 +1,11 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import {
@ -56,6 +56,35 @@ interface FormState {
const providerDefaultModel = defaultModelForProviderType;
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const;
async function fetchDefaultProviderSlug(): Promise<string> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { values?: { defaultTtsProvider?: unknown } };
return typeof data.values?.defaultTtsProvider === 'string' ? data.values.defaultTtsProvider : '';
}
async function patchDefaultProviderSlug(slug: string): Promise<void> {
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { defaultTtsProvider: slug } }),
});
if (!res.ok && res.status !== 207) throw new Error(`HTTP ${res.status}`);
}
async function patchProviderEnabled(input: { id: string; enabled: boolean }): Promise<void> {
const res = await fetch(`/api/admin/providers/${input.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: input.enabled }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `HTTP ${res.status}`);
}
}
function createEmptyForm(): FormState {
return {
@ -70,6 +99,11 @@ function createEmptyForm(): FormState {
};
}
function truncateModelLabel(value: string, maxLength = 56): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
async function fetchAdminProviders(): Promise<AdminProviderMasked[]> {
const res = await fetch('/api/admin/providers');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -122,19 +156,34 @@ export function AdminProvidersPanel() {
queryKey: ADMIN_PROVIDERS_QUERY_KEY,
queryFn: fetchAdminProviders,
});
const {
data: defaultProviderSlug = '',
error: defaultProviderError,
} = useQuery({
queryKey: ADMIN_SETTINGS_QUERY_KEY,
queryFn: fetchDefaultProviderSlug,
});
useEffect(() => {
if (!error) return;
console.error('[AdminProvidersPanel] load failed:', error);
toast.error('Failed to load admin providers');
}, [error]);
useEffect(() => {
if (!defaultProviderError) return;
console.error('[AdminProvidersPanel] default provider load failed:', defaultProviderError);
toast.error('Failed to load default provider');
}, [defaultProviderError]);
const saveMutation = useMutation({
mutationFn: upsertAdminProvider,
onSuccess: async (_data, variables) => {
toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated');
cancelEdit();
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] save failed:', mutationError);
@ -146,13 +195,41 @@ export function AdminProvidersPanel() {
mutationFn: deleteAdminProvider,
onSuccess: async () => {
toast.success('Provider deleted');
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] delete failed:', mutationError);
toast.error((mutationError as Error).message || 'Delete failed');
},
});
const toggleEnabledMutation = useMutation({
mutationFn: patchProviderEnabled,
onSuccess: async (_data, vars) => {
toast.success(vars.enabled ? 'Provider enabled' : 'Provider disabled');
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] toggle enabled failed:', mutationError);
toast.error((mutationError as Error).message || 'Update failed');
},
});
const setDefaultMutation = useMutation({
mutationFn: patchDefaultProviderSlug,
onSuccess: async () => {
toast.success('Default provider updated');
await queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY });
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] set default failed:', mutationError);
toast.error((mutationError as Error).message || 'Failed to set default');
},
});
const startCreate = () => {
setForm(createEmptyForm());
@ -191,6 +268,14 @@ export function AdminProvidersPanel() {
if (deleteMutation.isPending) return;
deleteMutation.mutate(id);
};
const toggleEnabled = (provider: AdminProviderMasked) => {
if (toggleEnabledMutation.isPending) return;
toggleEnabledMutation.mutate({ id: provider.id, enabled: !provider.enabled });
};
const setDefault = (slug: string) => {
if (setDefaultMutation.isPending) return;
setDefaultMutation.mutate(slug);
};
const isEditingExisting = editingId !== null && editingId !== '__new';
const editingProvider = isEditingExisting
@ -504,38 +589,101 @@ export function AdminProvidersPanel() {
<p className="text-xs text-muted py-2">No shared providers configured yet.</p>
) : (
providers.map((p) => (
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0">
<div className="flex items-start gap-3">
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0 px-0.5 rounded-md">
<div className="flex items-start gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">{p.displayName}</span>
<Badge tone="muted">{p.slug}</Badge>
{defaultProviderSlug === p.slug && <Badge tone="foreground">Default</Badge>}
{!p.enabled && <Badge tone="muted">Disabled</Badge>}
</div>
<div className="text-xs text-muted truncate">
<div className="text-xs text-muted">
{p.providerType}
{p.defaultModel ? ` · ${p.defaultModel}` : ''}
{p.defaultModel ? (
<>
{' · '}
<span title={p.defaultModel}>{truncateModelLabel(p.defaultModel)}</span>
</>
) : (
' · no default model'
)}
{p.defaultInstructions ? ' · instructions' : ''}
{p.baseUrl ? ` · ${p.baseUrl}` : ''}
{' · '}key {p.apiKeyMask}
</div>
<div className="text-[11px] text-muted truncate">
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
</div>
</div>
<div className="shrink-0 flex gap-1.5">
<Button
onClick={() => startEdit(p)}
className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })}
disabled={!!editingId || deleteMutation.isPending}
<Menu as="div" className="relative shrink-0">
<MenuButton
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
title="Provider actions"
aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
>
Edit
</Button>
<Button
onClick={() => remove(p.id)}
className={buttonClass({ variant: 'danger', size: 'xs', className: 'h-7' })}
disabled={!!editingId || deleteMutation.isPending}
<DotsHorizontalIcon className="h-3 w-4" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
Delete
</Button>
</div>
<MenuItems
anchor="bottom end"
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => startEdit(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Edit
</button>
)}
</MenuItem>
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => setDefault(p.slug)}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Set as default
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => toggleEnabled(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => remove(p.id)}
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Delete
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
</div>
))

View file

@ -1,6 +1,8 @@
'use client';
import { useId, type ReactNode } from 'react';
import { Fragment, useId, type ReactNode } from 'react';
import { Transition } from '@headlessui/react';
import { EditIcon } from '@/components/icons/Icons';
/**
* Shared compact form primitives used by settings-like surfaces across
@ -239,6 +241,65 @@ export function ToggleRow({
);
}
export function EditableRow({
label,
description,
value,
expanded,
onExpandedChange,
right,
children,
editLabel = 'Edit',
}: {
label: string;
description: string;
value: ReactNode;
expanded: boolean;
onExpandedChange: (expanded: boolean) => void;
right?: ReactNode;
children: ReactNode;
editLabel?: string;
}) {
const rowClass =
'px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0 transition-transform duration-200 ease-out hover:scale-[1.003]';
return (
<div className={rowClass}>
<div className="flex items-start gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<span className="block text-sm font-medium leading-5 text-foreground">{label}</span>
<span className="block text-xs leading-4 text-muted">{description}</span>
<span className="block text-xs leading-5 text-foreground truncate">{value}</span>
</div>
{right ? <div className="shrink-0 self-start pl-1.5">{right}</div> : null}
<button
type="button"
onClick={() => onExpandedChange(!expanded)}
className="inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-offbase bg-base text-muted transition-colors hover:bg-offbase hover:text-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
title={editLabel}
aria-label={editLabel}
aria-expanded={expanded}
>
<EditIcon className="h-3.5 w-3.5" />
</button>
</div>
<Transition
as={Fragment}
show={expanded}
enter="transition-all duration-200 ease-out"
enterFrom="opacity-0 -translate-y-1 max-h-0"
enterTo="opacity-100 translate-y-0 max-h-48"
leave="transition-all duration-150 ease-in"
leaveFrom="opacity-100 translate-y-0 max-h-48"
leaveTo="opacity-0 -translate-y-1 max-h-0"
>
<div className="overflow-hidden pt-2">
{children}
</div>
</Transition>
</div>
);
}
export function CheckItem({
label,
checked,

View file

@ -193,6 +193,27 @@ export function PlusIcon(props: React.SVGProps<SVGSVGElement>) {
);
}
export function EditIcon(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 20h9" />
<path d="M16.5 3.5a2.1 2.1 0 1 1 3 3L7 19l-4 1 1-4 12.5-12.5z" />
</svg>
);
}
export function UploadIcon(props: React.SVGProps<SVGSVGElement>) {
return (
<svg

View file

@ -22,6 +22,11 @@ export interface RuntimeConfig {
enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllProviderModels: boolean;
disableTtsRateLimit: boolean;
ttsDailyLimitAnonymous: number;
ttsDailyLimitAuthenticated: number;
ttsIpDailyLimitAnonymous: number;
ttsIpDailyLimitAuthenticated: number;
computeAvailable: boolean;
}
@ -36,6 +41,11 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllProviderModels: true,
disableTtsRateLimit: true,
ttsDailyLimitAnonymous: 50_000,
ttsDailyLimitAuthenticated: 500_000,
ttsIpDailyLimitAnonymous: 100_000,
ttsIpDailyLimitAuthenticated: 1_000_000,
computeAvailable: true,
};

View file

@ -5,6 +5,8 @@ import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
import { getRuntimeConfig, setRuntimeConfigKey } from '@/lib/server/admin/settings';
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
'custom-openai',
@ -195,6 +197,19 @@ export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
}
export async function listEnabledAdminProviders(): Promise<AdminProviderRecord[]> {
const rows = await db
.select()
.from(adminProviders)
.where(eq(adminProviders.enabled, 1))
.orderBy(
desc(adminProviders.updatedAt),
desc(adminProviders.createdAt),
asc(adminProviders.slug),
);
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>>;
@ -258,6 +273,8 @@ export async function updateAdminProvider(
): Promise<AdminProviderRecord> {
const current = await getAdminProviderById(id);
if (!current) throw new AdminProviderError('provider not found', 404);
const runtimeConfigBefore = await getRuntimeConfig();
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === current.slug;
const update: Record<string, unknown> = { updatedAt: Date.now() };
const nextModel =
@ -309,13 +326,27 @@ export async function updateAdminProvider(
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);
if (wasDefaultProvider) {
if (updated.enabled && updated.slug !== current.slug) {
await setRuntimeConfigKey('defaultTtsProvider', updated.slug);
} else if (!updated.enabled) {
await swapDefaultSharedProvider(updated.slug);
}
}
await ensureDefaultSharedProviderValidity();
return updated;
}
export async function deleteAdminProvider(id: string): Promise<void> {
const existing = await getAdminProviderById(id);
if (!existing) throw new AdminProviderError('provider not found', 404);
const runtimeConfigBefore = await getRuntimeConfig();
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === existing.slug;
await db.delete(adminProviders).where(eq(adminProviders.id, id));
if (wasDefaultProvider) {
await swapDefaultSharedProvider(existing.slug);
}
await ensureDefaultSharedProviderValidity();
}
/** Lookup helper used by TTS routes: returns null if not found or disabled. */
@ -333,11 +364,49 @@ export async function getEnabledAdminProviderBySlug(
}
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;
const rows = await listEnabledAdminProviders();
return rows[0] ?? null;
}
export async function resolvePreferredEnabledAdminProvider(input: {
requestedSlug?: string | null;
runtimeDefaultSlug?: string | null;
}): Promise<AdminProviderRecord | null> {
const providers = await listEnabledAdminProviders();
const selectedSlug = resolvePreferredSharedProviderSlug({
providers,
requestedSlug: input.requestedSlug,
runtimeDefaultSlug: input.runtimeDefaultSlug,
});
if (!selectedSlug) return null;
return providers.find((provider) => provider.slug === selectedSlug) ?? null;
}
async function ensureDefaultSharedProviderValidity(): Promise<void> {
const runtimeConfig = await getRuntimeConfig();
const currentDefaultSlug = runtimeConfig.defaultTtsProvider;
if (!currentDefaultSlug || BUILT_IN_PROVIDER_IDS.includes(currentDefaultSlug as TtsProviderId)) {
return;
}
const currentDefaultEnabled = await getEnabledAdminProviderBySlug(currentDefaultSlug);
if (currentDefaultEnabled) return;
const nextProvider = await resolvePreferredEnabledAdminProvider({
runtimeDefaultSlug: currentDefaultSlug,
});
if (!nextProvider) return;
await setRuntimeConfigKey('defaultTtsProvider', nextProvider.slug);
}
async function swapDefaultSharedProvider(excludedSlug: string): Promise<void> {
const providers = await listEnabledAdminProviders();
const filtered = providers.filter((provider) => provider.slug !== excludedSlug);
const selectedSlug = resolvePreferredSharedProviderSlug({
providers: filtered,
runtimeDefaultSlug: null,
});
if (!selectedSlug) return;
await setRuntimeConfigKey('defaultTtsProvider', selectedSlug);
}

View file

@ -1,7 +1,7 @@
import {
getEnabledAdminProviderBySlug,
getFirstEnabledAdminProvider,
decryptedKeyFor,
resolvePreferredEnabledAdminProvider,
type AdminProviderRecord,
} from '@/lib/server/admin/providers';
@ -46,37 +46,20 @@ export async function resolveTtsCredentials(opts: {
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) {
const selected = await resolvePreferredEnabledAdminProvider({
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
runtimeDefaultSlug: fallback,
});
if (!selected) {
return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(firstShared);
const apiKey = await decryptedKeyFor(selected);
return {
provider: firstShared.providerType,
provider: selected.providerType,
apiKey,
baseUrl: firstShared.baseUrl || undefined,
baseUrl: selected.baseUrl || undefined,
fromAdmin: true,
adminRecord: firstShared,
adminRecord: selected,
};
}

View file

@ -76,6 +76,24 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
};
}
function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef<number> {
return {
default: defaultValue,
envVar,
parseEnv(raw) {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
return Math.floor(parsed);
},
validate(value) {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return undefined;
return Math.floor(value);
},
};
}
export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'),
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'),
@ -88,6 +106,11 @@ export const RUNTIME_CONFIG_SCHEMA = {
enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'),
enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
showAllProviderModels: runtimeBoolean(true),
disableTtsRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_TTS_LIMIT'),
ttsDailyLimitAnonymous: positiveIntValue(50_000),
ttsDailyLimitAuthenticated: positiveIntValue(500_000),
ttsIpDailyLimitAnonymous: positiveIntValue(100_000),
ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000),
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;

View file

@ -8,6 +8,7 @@ import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
return value === 'mp3' || value === 'm4b';
@ -101,13 +102,12 @@ function resolveRestrictedProviderRef(
fallbackProviderRef: string,
sharedProviders: SharedProviderPolicyEntry[],
): string {
const requestedIsBuiltIn = isBuiltInTtsProviderId(providerRef);
const fallbackIsBuiltIn = isBuiltInTtsProviderId(fallbackProviderRef);
const requestedSharedSlug = requestedIsBuiltIn ? '' : providerRef;
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallbackProviderRef;
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
if (preferredSharedSlug) return preferredSharedSlug;
return sharedProviders[0]?.slug || providerRef;
const preferred = resolvePreferredSharedProviderSlug({
providers: sharedProviders,
requestedSlug: isBuiltInTtsProviderId(providerRef) ? null : providerRef,
runtimeDefaultSlug: isBuiltInTtsProviderId(fallbackProviderRef) ? null : fallbackProviderRef,
});
return preferred || providerRef;
}
export function canonicalizeAudiobookSettingsForRuntime(input: {

View file

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server';
import { RATE_LIMITS, type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
import { type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
@ -15,8 +15,10 @@ export function buildDailyQuotaExceededResponse(input: {
rateLimitResult: RateLimitResult;
isAnonymousUser: boolean;
pathname: string;
anonymousLimit: number;
authenticatedLimit: number;
}): NextResponse {
const { rateLimitResult, isAnonymousUser, pathname } = input;
const { rateLimitResult, isAnonymousUser, pathname, anonymousLimit, authenticatedLimit } = input;
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
@ -32,7 +34,7 @@ export function buildDailyQuotaExceededResponse(input: {
resetTimeMs,
userType: isAnonymousUser ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymousUser
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
? `Sign up to increase your limit from ${formatLimitForHint(anonymousLimit)} to ${formatLimitForHint(authenticatedLimit)} characters per day`
: undefined,
instance: pathname,
}), {

View file

@ -3,53 +3,40 @@ import { userTtsChars } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { eq, and, lt, sql } from 'drizzle-orm';
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
function readPositiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw || raw.trim() === '') return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
logDegraded(serverLogger, {
event: 'rate_limit.config.invalid_env',
msg: 'Invalid rate limiter env value; using default',
step: 'read_limit_env',
context: {
envVar: name,
envValue: raw,
fallbackValue: fallback,
},
});
return fallback;
}
return Math.floor(parsed);
export interface RateLimitThresholds {
anonymous: number;
authenticated: number;
ipAnonymous: number;
ipAuthenticated: number;
}
function readBooleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (!raw || raw.trim() === '') return fallback;
const normalized = raw.trim().toLowerCase();
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false;
return fallback;
export interface RateLimitRuntimeOptions {
enabled: boolean;
limits: RateLimitThresholds;
}
export function isTtsRateLimitEnabled(): boolean {
return readBooleanEnv('TTS_ENABLE_RATE_LIMIT', false);
}
export const DEFAULT_RATE_LIMITS: RateLimitThresholds = {
anonymous: 50_000,
authenticated: 500_000,
ipAnonymous: 100_000,
ipAuthenticated: 1_000_000,
};
// Rate limits configuration - character counts per day
export const RATE_LIMITS = {
ANONYMOUS: readPositiveIntEnv('TTS_DAILY_LIMIT_ANONYMOUS', 50_000),
AUTHENTICATED: readPositiveIntEnv('TTS_DAILY_LIMIT_AUTHENTICATED', 500_000),
// IP-based backstop limits to make it harder to reset limits by creating new accounts
// or clearing storage/cookies
IP_ANONYMOUS: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_ANONYMOUS', 100_000),
IP_AUTHENTICATED: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_AUTHENTICATED', 1_000_000),
} as const;
export function resolveRateLimitThresholds(input?: Partial<RateLimitThresholds>): RateLimitThresholds {
const source = input ?? {};
const normalize = (value: unknown, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
return Math.floor(value);
};
return {
anonymous: normalize(source.anonymous, DEFAULT_RATE_LIMITS.anonymous),
authenticated: normalize(source.authenticated, DEFAULT_RATE_LIMITS.authenticated),
ipAnonymous: normalize(source.ipAnonymous, DEFAULT_RATE_LIMITS.ipAnonymous),
ipAuthenticated: normalize(source.ipAuthenticated, DEFAULT_RATE_LIMITS.ipAuthenticated),
};
}
// Helper to ensure DB is strictly typed when we know it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -163,8 +150,15 @@ export class RateLimiter {
/**
* Check if a user can use TTS and increment their char count if allowed
*/
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
async checkAndIncrementLimit(
user: UserInfo,
charCount: number,
backstops?: RateLimitBackstops,
options?: RateLimitRuntimeOptions,
): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) {
return {
allowed: true,
currentCount: 0,
@ -176,7 +170,7 @@ export class RateLimiter {
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -184,13 +178,13 @@ export class RateLimiter {
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
});
}
@ -254,7 +248,7 @@ export class RateLimiter {
});
} catch (error) {
if (error instanceof RateLimitExceeded) {
const current = await this.getCurrentUsage(user, backstops);
const current = await this.getCurrentUsage(user, backstops, options);
return { ...current, allowed: false };
}
throw error;
@ -264,8 +258,14 @@ export class RateLimiter {
/**
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
async getCurrentUsage(
user: UserInfo,
backstops?: RateLimitBackstops,
options?: RateLimitRuntimeOptions,
): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) {
return {
allowed: true,
currentCount: 0,
@ -276,7 +276,7 @@ export class RateLimiter {
}
const today = new Date().toISOString().split('T')[0];
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -284,13 +284,13 @@ export class RateLimiter {
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
});
}
@ -321,7 +321,7 @@ export class RateLimiter {
* Transfer char counts when anonymous user creates an account
*/
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return;
if (!isAuthEnabled()) return;
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;

View file

@ -0,0 +1,34 @@
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
export interface SharedProviderSlugEntry {
slug: string;
}
function normalizeSharedSlug(value: string | null | undefined): string {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return '';
return isBuiltInTtsProviderId(trimmed) ? '' : trimmed;
}
export function resolvePreferredSharedProviderSlug<T extends SharedProviderSlugEntry>(input: {
providers: readonly T[];
requestedSlug?: string | null;
runtimeDefaultSlug?: string | null;
}): string | null {
const providers = input.providers;
if (providers.length === 0) return null;
const bySlug = new Map<string, T>();
for (const provider of providers) {
bySlug.set(provider.slug, provider);
}
const requested = normalizeSharedSlug(input.requestedSlug);
if (requested && bySlug.has(requested)) return requested;
const runtimeDefault = normalizeSharedSlug(input.runtimeDefaultSlug);
if (runtimeDefault && bySlug.has(runtimeDefault)) return runtimeDefault;
if (bySlug.has('default-openai')) return 'default-openai';
return providers[0]?.slug ?? null;
}

View file

@ -0,0 +1,25 @@
import { expect, test } from '@playwright/test';
import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings';
test.describe('TTS rate limit runtime config seeds', () => {
test('defaults disable TTS daily rate limiting', () => {
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true);
});
test('parses disable seed boolean values', () => {
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('true')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('false')).toBe(false);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('1')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('0')).toBe(false);
});
test('daily limit values are runtime-only (no env seed vars)', () => {
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAnonymous.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAuthenticated.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.default).toBe(50_000);
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.default).toBe(500_000);
});
});

View file

@ -0,0 +1,51 @@
import { expect, test } from '@playwright/test';
import { resolvePreferredSharedProviderSlug } from '../../src/lib/shared/shared-provider-selection';
const PROVIDERS = [
{ slug: 'shared-a' },
{ slug: 'default-openai' },
{ slug: 'shared-b' },
] as const;
test.describe('resolvePreferredSharedProviderSlug', () => {
test('prefers requested shared slug when present', () => {
expect(resolvePreferredSharedProviderSlug({
providers: PROVIDERS,
requestedSlug: 'shared-b',
runtimeDefaultSlug: 'shared-a',
})).toBe('shared-b');
});
test('falls back to runtime default shared slug when requested is missing', () => {
expect(resolvePreferredSharedProviderSlug({
providers: PROVIDERS,
requestedSlug: 'missing-slug',
runtimeDefaultSlug: 'shared-a',
})).toBe('shared-a');
});
test('falls back to default-openai before first provider', () => {
expect(resolvePreferredSharedProviderSlug({
providers: PROVIDERS,
requestedSlug: null,
runtimeDefaultSlug: null,
})).toBe('default-openai');
});
test('ignores built-in provider ids for requested/runtime defaults', () => {
expect(resolvePreferredSharedProviderSlug({
providers: PROVIDERS,
requestedSlug: 'openai',
runtimeDefaultSlug: 'custom-openai',
})).toBe('default-openai');
});
test('falls back to first enabled provider when default-openai is missing', () => {
expect(resolvePreferredSharedProviderSlug({
providers: [{ slug: 'shared-z' }, { slug: 'shared-y' }],
requestedSlug: null,
runtimeDefaultSlug: null,
})).toBe('shared-z');
});
});