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_BASE=http://localhost:8880/v1
API_KEY=api_key_optional 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_MAX_SIZE_BYTES=268435456 # 256MB
# TTS_CACHE_TTL_MS=1800000 # 30 minutes # TTS_CACHE_TTL_MS=1800000 # 30 minutes
# TTS_MAX_RETRIES=2 # TTS_MAX_RETRIES=2
@ -24,27 +24,18 @@ API_KEY=api_key_optional
# TTS_RETRY_BACKOFF=2 # TTS_RETRY_BACKOFF=2
# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds # TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds
# (Optional) Enable TTS character rate limiting (default is `false`) # Auth configuration (recommended; required for admin features)
# 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)
# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set # (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) 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_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) 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`) # (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 # (Optional) Sign in w/ GitHub Configuration
GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET= # GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing) # (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`)
DISABLE_AUTH_RATE_LIMIT= # DISABLE_AUTH_RATE_LIMIT=false
# (Optional) Comma-separated list of emails that are auto-promoted to admin. # (Optional) Comma-separated list of emails that are auto-promoted to admin.
# Admins see the "Admin" tab in Settings (TTS shared providers + site features). # 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. # (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. # Defaults to SQLite at docstore/sqlite3.db when not set.
POSTGRES_URL= # POSTGRES_URL=
# Embedded SeaweedFS weed mini config # Embedded SeaweedFS weed mini config
# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`) # (Optional) Enable embedded weed mini for local S3-compatible storage (defaults shown below)
USE_EMBEDDED_WEED_MINI= # USE_EMBEDDED_WEED_MINI=true
WEED_MINI_DIR= # WEED_MINI_DIR=docstore/seaweedfs
WEED_MINI_WAIT_SEC= # WEED_MINI_WAIT_SEC=20
# S3 storage config (use with embedded weed mini or external S3-compatible storage) # 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. # (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts.
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
S3_BUCKET= S3_BUCKET=
S3_REGION= # S3_REGION=
# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host. # (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host.
S3_ENDPOINT= # S3_ENDPOINT=
S3_FORCE_PATH_STYLE= # S3_FORCE_PATH_STYLE=
S3_PREFIX= # S3_PREFIX=
# Migrations configuration # (Optional) Library import mode directory (uses /docstore/library by default)
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`) # IMPORT_LIBRARY_DIR=
RUN_DRIZZLE_MIGRATIONS= # IMPORT_LIBRARY_DIRS=
# (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=
# Compute # Compute
# Embedded/local (default): leave COMPUTE_WORKER_URL empty. # 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 # PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
# (Optional) Override ffmpeg binary path used for audiobook processing # (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN= # FFMPEG_BIN=
# (Optional) Client feature flags — seeded into the admin-managed runtime # (Optional) Client feature flags — seeded into the admin-managed runtime
# config on first boot, then ignored. Edit values from Settings → Admin → # 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_DEFAULT_TTS_PROVIDER=custom-openai
# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json # RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true # 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 ## Overview
- TTS rate limiting is disabled by default. - 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. - Limits are enforced per day in UTC.
- Enforcement applies only when auth is enabled. - 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` only affects Better Auth's own request throttling.
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits. - `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
## Environment variables ## Runtime config + seed var
Enable/disable: - 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.
- `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`)
## Related docs ## 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_MAX_MS` | TTS retry | `2000` | Tune max retry delay |
| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor | | `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_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 | | `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth |
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth |
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | | `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_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_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_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 - 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 - 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) - `disableTtsRateLimit` default: `true` (rate limiting disabled)
- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*` - Daily limit defaults:
- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting) - 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` After first boot, these values are DB-backed admin runtime settings.
### 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`
## Auth and Identity ## Auth and Identity

View file

@ -8,7 +8,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema'; import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth'; 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 { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -532,7 +532,15 @@ export async function POST(request: NextRequest) {
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions, 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 isAnonymous = Boolean(user?.isAnonymous);
const charCount = data.text.length; const charCount = data.text.length;
const ip = getClientIp(request); const ip = getClientIp(request);
@ -549,6 +557,10 @@ export async function POST(request: NextRequest) {
deviceId: device?.deviceId ?? null, deviceId: device?.deviceId ?? null,
ip, ip,
}, },
{
enabled: ttsRateLimitEnabled,
limits,
},
); );
if (!rateLimitResult.allowed) { if (!rateLimitResult.allowed) {
@ -570,7 +582,7 @@ export async function POST(request: NextRequest) {
resetTimeMs, resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated', userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous 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, : undefined,
instance: request.nextUrl.pathname, instance: request.nextUrl.pathname,
}; };

View file

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

View file

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

View file

@ -7,6 +7,7 @@ import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons'; import { ChevronUpDownIcon, CheckIcon } from '@/components/icons/Icons';
import { import {
Badge, Badge,
EditableRow,
Section, Section,
ToggleRow, ToggleRow,
buttonClass, buttonClass,
@ -56,12 +57,14 @@ export function AdminFeaturesPanel() {
}); });
const [draft, setDraft] = useState<Record<string, unknown>>({}); const [draft, setDraft] = useState<Record<string, unknown>>({});
const [dirty, setDirty] = useState<Set<string>>(new Set()); const [dirty, setDirty] = useState<Set<string>>(new Set());
const [editingChangelogUrl, setEditingChangelogUrl] = useState(false);
const { providers: sharedProviders } = useSharedProviders(); const { providers: sharedProviders } = useSharedProviders();
useEffect(() => { useEffect(() => {
if (!data) return; if (!data) return;
setDraft({ ...data.values }); setDraft({ ...data.values });
setDirty(new Set()); setDirty(new Set());
setEditingChangelogUrl(false);
}, [data]); }, [data]);
useEffect(() => { useEffect(() => {
@ -104,11 +107,19 @@ export function AdminFeaturesPanel() {
setDraft((d) => ({ ...d, [key]: value })); setDraft((d) => ({ ...d, [key]: value }));
setDirty((s) => { setDirty((s) => {
const next = new Set(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; 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) => { const resetField = (key: string) => {
if (saving) return; if (saving) return;
resetMutation.mutate(key); resetMutation.mutate(key);
@ -151,6 +162,7 @@ export function AdminFeaturesPanel() {
} as ProviderOption } as ProviderOption
: fallbackShared; : fallbackShared;
const selectedProviderOption = effectiveSelectedProvider; const selectedProviderOption = effectiveSelectedProvider;
const showTtsDailyLimitInputs = !Boolean(draft.disableTtsRateLimit);
const handleProviderChange = (opt: ProviderOption) => { const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id); updateDraft('defaultTtsProvider', opt.id);
@ -270,6 +282,85 @@ export function AdminFeaturesPanel() {
right={renderSource('showAllProviderModels')} right={renderSource('showAllProviderModels')}
variant="flat" 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>
<Section <Section
@ -277,16 +368,15 @@ export function AdminFeaturesPanel() {
subtitle="Feature flags for all users." subtitle="Feature flags for all users."
action={<Badge tone="foreground">Feature Flags</Badge>} action={<Badge tone="foreground">Feature Flags</Badge>}
> >
<div className="space-y-1.5 pb-2 border-b border-offbase"> <EditableRow
<div className="flex items-start justify-between gap-3"> label="Changelog feed URL"
<div className="min-w-0"> description="Public URL to the changelog manifest JSON used by Settings."
<p className="text-sm font-medium text-foreground">Changelog feed URL</p> value={String(draft.changelogFeedUrl ?? '') || '—'}
<p className="text-xs text-muted mt-0.5"> expanded={editingChangelogUrl}
Public URL to the changelog manifest JSON used by Settings. onExpandedChange={setEditingChangelogUrl}
</p> right={renderSource('changelogFeedUrl')}
</div> editLabel={editingChangelogUrl ? 'Close editor' : 'Edit URL'}
<div className="shrink-0">{renderSource('changelogFeedUrl')}</div> >
</div>
<input <input
type="text" type="text"
className={inputClass} className={inputClass}
@ -294,7 +384,7 @@ export function AdminFeaturesPanel() {
onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)} onChange={(event) => updateDraft('changelogFeedUrl', event.target.value)}
placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json" placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json"
/> />
</div> </EditableRow>
<ToggleRow <ToggleRow
label="Allow new account sign-ups" label="Allow new account sign-ups"
description="When off, new accounts cannot be created. Existing accounts can still sign in." description="When off, new accounts cannot be created. Existing accounts can still sign in."

View file

@ -1,11 +1,11 @@
'use client'; 'use client';
import { useCallback, useEffect, useMemo, useState } from 'react'; 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 { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; 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 { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { import {
@ -56,6 +56,35 @@ interface FormState {
const providerDefaultModel = defaultModelForProviderType; const providerDefaultModel = defaultModelForProviderType;
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const; 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 { function createEmptyForm(): FormState {
return { 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[]> { async function fetchAdminProviders(): Promise<AdminProviderMasked[]> {
const res = await fetch('/api/admin/providers'); const res = await fetch('/api/admin/providers');
if (!res.ok) throw new Error(`HTTP ${res.status}`); if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -122,19 +156,34 @@ export function AdminProvidersPanel() {
queryKey: ADMIN_PROVIDERS_QUERY_KEY, queryKey: ADMIN_PROVIDERS_QUERY_KEY,
queryFn: fetchAdminProviders, queryFn: fetchAdminProviders,
}); });
const {
data: defaultProviderSlug = '',
error: defaultProviderError,
} = useQuery({
queryKey: ADMIN_SETTINGS_QUERY_KEY,
queryFn: fetchDefaultProviderSlug,
});
useEffect(() => { useEffect(() => {
if (!error) return; if (!error) return;
console.error('[AdminProvidersPanel] load failed:', error); console.error('[AdminProvidersPanel] load failed:', error);
toast.error('Failed to load admin providers'); toast.error('Failed to load admin providers');
}, [error]); }, [error]);
useEffect(() => {
if (!defaultProviderError) return;
console.error('[AdminProvidersPanel] default provider load failed:', defaultProviderError);
toast.error('Failed to load default provider');
}, [defaultProviderError]);
const saveMutation = useMutation({ const saveMutation = useMutation({
mutationFn: upsertAdminProvider, mutationFn: upsertAdminProvider,
onSuccess: async (_data, variables) => { onSuccess: async (_data, variables) => {
toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated'); toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated');
cancelEdit(); 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) => { onError: (mutationError) => {
console.error('[AdminProvidersPanel] save failed:', mutationError); console.error('[AdminProvidersPanel] save failed:', mutationError);
@ -146,13 +195,41 @@ export function AdminProvidersPanel() {
mutationFn: deleteAdminProvider, mutationFn: deleteAdminProvider,
onSuccess: async () => { onSuccess: async () => {
toast.success('Provider deleted'); 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) => { onError: (mutationError) => {
console.error('[AdminProvidersPanel] delete failed:', mutationError); console.error('[AdminProvidersPanel] delete failed:', mutationError);
toast.error((mutationError as Error).message || 'Delete failed'); 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 = () => { const startCreate = () => {
setForm(createEmptyForm()); setForm(createEmptyForm());
@ -191,6 +268,14 @@ export function AdminProvidersPanel() {
if (deleteMutation.isPending) return; if (deleteMutation.isPending) return;
deleteMutation.mutate(id); 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 isEditingExisting = editingId !== null && editingId !== '__new';
const editingProvider = isEditingExisting const editingProvider = isEditingExisting
@ -504,38 +589,101 @@ export function AdminProvidersPanel() {
<p className="text-xs text-muted py-2">No shared providers configured yet.</p> <p className="text-xs text-muted py-2">No shared providers configured yet.</p>
) : ( ) : (
providers.map((p) => ( providers.map((p) => (
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0"> <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-3"> <div className="flex items-start gap-2.5">
<div className="flex-1 min-w-0 space-y-0.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> <span className="text-sm font-medium text-foreground truncate">{p.displayName}</span>
<Badge tone="muted">{p.slug}</Badge> <Badge tone="muted">{p.slug}</Badge>
{defaultProviderSlug === p.slug && <Badge tone="foreground">Default</Badge>}
{!p.enabled && <Badge tone="muted">Disabled</Badge>} {!p.enabled && <Badge tone="muted">Disabled</Badge>}
</div> </div>
<div className="text-xs text-muted truncate"> <div className="text-xs text-muted">
{p.providerType} {p.providerType}
{p.defaultModel ? ` · ${p.defaultModel}` : ''} {p.defaultModel ? (
<>
{' · '}
<span title={p.defaultModel}>{truncateModelLabel(p.defaultModel)}</span>
</>
) : (
' · no default model'
)}
{p.defaultInstructions ? ' · instructions' : ''} {p.defaultInstructions ? ' · instructions' : ''}
{p.baseUrl ? ` · ${p.baseUrl}` : ''} </div>
{' · '}key {p.apiKeyMask} <div className="text-[11px] text-muted truncate">
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
</div> </div>
</div> </div>
<div className="shrink-0 flex gap-1.5"> <Menu as="div" className="relative shrink-0">
<Button <MenuButton
onClick={() => startEdit(p)} 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"
className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })} title="Provider actions"
disabled={!!editingId || deleteMutation.isPending} aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
> >
Edit <DotsHorizontalIcon className="h-3 w-4" />
</Button> </MenuButton>
<Button <Transition
onClick={() => remove(p.id)} as={Fragment}
className={buttonClass({ variant: 'danger', size: 'xs', className: 'h-7' })} enter="transition ease-out duration-100"
disabled={!!editingId || deleteMutation.isPending} 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 <MenuItems
</Button> anchor="bottom end"
</div> 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>
</div> </div>
)) ))

View file

@ -1,6 +1,8 @@
'use client'; '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 * 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({ export function CheckItem({
label, label,
checked, 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>) { export function UploadIcon(props: React.SVGProps<SVGSVGElement>) {
return ( return (
<svg <svg

View file

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

View file

@ -5,6 +5,8 @@ import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets'; import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog'; import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy'; 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[] = [ export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
'custom-openai', 'custom-openai',
@ -195,6 +197,19 @@ export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
return (rows as Array<Record<string, unknown>>).map(rowToRecord); 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> { export async function getAdminProviderBySlug(slug: string): Promise<AdminProviderRecord | null> {
const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1); const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1);
const arr = rows as Array<Record<string, unknown>>; const arr = rows as Array<Record<string, unknown>>;
@ -258,6 +273,8 @@ export async function updateAdminProvider(
): Promise<AdminProviderRecord> { ): Promise<AdminProviderRecord> {
const current = await getAdminProviderById(id); const current = await getAdminProviderById(id);
if (!current) throw new AdminProviderError('provider not found', 404); 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 update: Record<string, unknown> = { updatedAt: Date.now() };
const nextModel = const nextModel =
@ -309,13 +326,27 @@ export async function updateAdminProvider(
await db.update(adminProviders).set(update).where(eq(adminProviders.id, id)); await db.update(adminProviders).set(update).where(eq(adminProviders.id, id));
const updated = await getAdminProviderById(id); const updated = await getAdminProviderById(id);
if (!updated) throw new AdminProviderError('failed to load updated provider', 500); 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; return updated;
} }
export async function deleteAdminProvider(id: string): Promise<void> { export async function deleteAdminProvider(id: string): Promise<void> {
const existing = await getAdminProviderById(id); const existing = await getAdminProviderById(id);
if (!existing) throw new AdminProviderError('provider not found', 404); 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)); 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. */ /** 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> { export async function getFirstEnabledAdminProvider(): Promise<AdminProviderRecord | null> {
const rows = await db const rows = await listEnabledAdminProviders();
.select() return rows[0] ?? null;
.from(adminProviders) }
.where(eq(adminProviders.enabled, 1))
.limit(1); export async function resolvePreferredEnabledAdminProvider(input: {
const arr = rows as Array<Record<string, unknown>>; requestedSlug?: string | null;
return arr[0] ? rowToRecord(arr[0]) : 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 { import {
getEnabledAdminProviderBySlug, getEnabledAdminProviderBySlug,
getFirstEnabledAdminProvider,
decryptedKeyFor, decryptedKeyFor,
resolvePreferredEnabledAdminProvider,
type AdminProviderRecord, type AdminProviderRecord,
} from '@/lib/server/admin/providers'; } from '@/lib/server/admin/providers';
@ -46,37 +46,20 @@ export async function resolveTtsCredentials(opts: {
if (opts.restrictUserApiKeys) { if (opts.restrictUserApiKeys) {
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider); const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
const fallback = opts.fallbackProvider || ''; const fallback = opts.fallbackProvider || '';
const fallbackIsBuiltIn = isBuiltInProviderId(fallback); const selected = await resolvePreferredEnabledAdminProvider({
const requestedSharedSlug = requestedIsBuiltIn ? '' : requestedProvider; requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallback; runtimeDefaultSlug: fallback,
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug; });
if (!selected) {
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 }; return { error: 'no_shared_provider_configured', slug: requestedProvider };
} }
const apiKey = await decryptedKeyFor(firstShared); const apiKey = await decryptedKeyFor(selected);
return { return {
provider: firstShared.providerType, provider: selected.providerType,
apiKey, apiKey,
baseUrl: firstShared.baseUrl || undefined, baseUrl: selected.baseUrl || undefined,
fromAdmin: true, 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 = { export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'), defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'),
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'), 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'), enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'),
enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'), enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
showAllProviderModels: runtimeBoolean(true), 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>>; } as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA; 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 { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts'; import type { TTSAudiobookFormat } from '@/types/tts';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions'; import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat { function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
return value === 'mp3' || value === 'm4b'; return value === 'mp3' || value === 'm4b';
@ -101,13 +102,12 @@ function resolveRestrictedProviderRef(
fallbackProviderRef: string, fallbackProviderRef: string,
sharedProviders: SharedProviderPolicyEntry[], sharedProviders: SharedProviderPolicyEntry[],
): string { ): string {
const requestedIsBuiltIn = isBuiltInTtsProviderId(providerRef); const preferred = resolvePreferredSharedProviderSlug({
const fallbackIsBuiltIn = isBuiltInTtsProviderId(fallbackProviderRef); providers: sharedProviders,
const requestedSharedSlug = requestedIsBuiltIn ? '' : providerRef; requestedSlug: isBuiltInTtsProviderId(providerRef) ? null : providerRef,
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallbackProviderRef; runtimeDefaultSlug: isBuiltInTtsProviderId(fallbackProviderRef) ? null : fallbackProviderRef,
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug; });
if (preferredSharedSlug) return preferredSharedSlug; return preferred || providerRef;
return sharedProviders[0]?.slug || providerRef;
} }
export function canonicalizeAudiobookSettingsForRuntime(input: { export function canonicalizeAudiobookSettingsForRuntime(input: {

View file

@ -1,5 +1,5 @@
import { NextResponse } from 'next/server'; 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 { function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit); if (!Number.isFinite(limit) || limit <= 0) return String(limit);
@ -15,8 +15,10 @@ export function buildDailyQuotaExceededResponse(input: {
rateLimitResult: RateLimitResult; rateLimitResult: RateLimitResult;
isAnonymousUser: boolean; isAnonymousUser: boolean;
pathname: string; pathname: string;
anonymousLimit: number;
authenticatedLimit: number;
}): NextResponse { }): NextResponse {
const { rateLimitResult, isAnonymousUser, pathname } = input; const { rateLimitResult, isAnonymousUser, pathname, anonymousLimit, authenticatedLimit } = input;
const resetTimeMs = rateLimitResult.resetTimeMs; const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000)); const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
@ -32,7 +34,7 @@ export function buildDailyQuotaExceededResponse(input: {
resetTimeMs, resetTimeMs,
userType: isAnonymousUser ? 'anonymous' : 'authenticated', userType: isAnonymousUser ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymousUser 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, : undefined,
instance: pathname, instance: pathname,
}), { }), {

View file

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