diff --git a/.env.example b/.env.example index 9de5c4d..d3c5c47 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/docs-site/docs/configure/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md index ed507d1..b9cd801 100644 --- a/docs-site/docs/configure/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -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 diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index b4a4078..4db5083 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -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 diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index c2a2f27..f0875de 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -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, }; diff --git a/src/app/api/rate-limit/status/route.ts b/src/app/api/rate-limit/status/route.ts index e541f9f..f21d8dc 100644 --- a/src/app/api/rate-limit/status/route.ts +++ b/src/app/api/rate-limit/status/route.ts @@ -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({ diff --git a/src/app/api/tts/segments/ensure/route.ts b/src/app/api/tts/segments/ensure/route.ts index a3dc204..80178fb 100644 --- a/src/app/api/tts/segments/ensure/route.ts +++ b/src/app/api/tts/segments/ensure/route.ts @@ -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; diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 41aca85..3c3c6da 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -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>({}); const [dirty, setDirty] = useState>(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" /> + updateDraft('disableTtsRateLimit', checked)} + right={renderSource('disableTtsRateLimit')} + variant="flat" + /> + +
+
+
+
+ + {renderSource('ttsDailyLimitAnonymous')} +
+ updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)} + /> +
+
+
+ + {renderSource('ttsDailyLimitAuthenticated')} +
+ updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)} + /> +
+
+
+ + {renderSource('ttsIpDailyLimitAnonymous')} +
+ updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)} + /> +
+
+
+ + {renderSource('ttsIpDailyLimitAuthenticated')} +
+ updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)} + /> +
+
+
+
Feature Flags} > -
-
-
-

Changelog feed URL

-

- Public URL to the changelog manifest JSON used by Settings. -

-
-
{renderSource('changelogFeedUrl')}
-
+ updateDraft('changelogFeedUrl', event.target.value)} placeholder="https://docs.openreader.richardr.dev/changelog/manifest.json" /> -
+ { + 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 { + 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 { + 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 { 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() {

No shared providers configured yet.

) : ( providers.map((p) => ( -
-
+
+
-
+
{p.displayName} {p.slug} + {defaultProviderSlug === p.slug && Default} {!p.enabled && Disabled}
-
+
{p.providerType} - {p.defaultModel ? ` · ${p.defaultModel}` : ''} + {p.defaultModel ? ( + <> + {' · '} + {truncateModelLabel(p.defaultModel)} + + ) : ( + ' · no default model' + )} {p.defaultInstructions ? ' · instructions' : ''} - {p.baseUrl ? ` · ${p.baseUrl}` : ''} - {' · '}key {p.apiKeyMask} +
+
+ {p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
-
- - -
+ + + {({ active }) => ( + + )} + + + {({ active, disabled }) => ( + + )} + + + {({ active }) => ( + + )} + + + {({ active }) => ( + + )} + + + +
)) diff --git a/src/components/formPrimitives.tsx b/src/components/formPrimitives.tsx index 6095591..d332594 100644 --- a/src/components/formPrimitives.tsx +++ b/src/components/formPrimitives.tsx @@ -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 ( +
+
+
+ {label} + {description} + {value} +
+ {right ?
{right}
: null} + +
+ +
+ {children} +
+
+
+ ); +} + export function CheckItem({ label, checked, diff --git a/src/components/icons/Icons.tsx b/src/components/icons/Icons.tsx index 74c6e8f..a780481 100644 --- a/src/components/icons/Icons.tsx +++ b/src/components/icons/Icons.tsx @@ -193,6 +193,27 @@ export function PlusIcon(props: React.SVGProps) { ); } +export function EditIcon(props: React.SVGProps) { + return ( + + + + + ); +} + export function UploadIcon(props: React.SVGProps) { return ( { return (rows as Array>).map(rowToRecord); } +export async function listEnabledAdminProviders(): Promise { + 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>).map(rowToRecord); +} + export async function getAdminProviderBySlug(slug: string): Promise { const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1); const arr = rows as Array>; @@ -258,6 +273,8 @@ export async function updateAdminProvider( ): Promise { 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 = { 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 { 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 { - const rows = await db - .select() - .from(adminProviders) - .where(eq(adminProviders.enabled, 1)) - .limit(1); - const arr = rows as Array>; - 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 { + 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 { + 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 { + 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); } diff --git a/src/lib/server/admin/resolve-credentials.ts b/src/lib/server/admin/resolve-credentials.ts index 95dd7a3..2193b39 100644 --- a/src/lib/server/admin/resolve-credentials.ts +++ b/src/lib/server/admin/resolve-credentials.ts @@ -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, }; } diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index f10dffe..c92b718 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -76,6 +76,24 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef< }; } +function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef { + 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>; export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA; diff --git a/src/lib/server/audiobooks/settings.ts b/src/lib/server/audiobooks/settings.ts index 1e61ef7..cf74673 100644 --- a/src/lib/server/audiobooks/settings.ts +++ b/src/lib/server/audiobooks/settings.ts @@ -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: { diff --git a/src/lib/server/rate-limit/problem-response.ts b/src/lib/server/rate-limit/problem-response.ts index 9f7e2ad..69b08ab 100644 --- a/src/lib/server/rate-limit/problem-response.ts +++ b/src/lib/server/rate-limit/problem-response.ts @@ -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, }), { diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index 053cb8d..0a9dd68 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -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 { + 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 { - if (!isAuthEnabled() || !isTtsRateLimitEnabled()) { + async checkAndIncrementLimit( + user: UserInfo, + charCount: number, + backstops?: RateLimitBackstops, + options?: RateLimitRuntimeOptions, + ): Promise { + 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 { - if (!isAuthEnabled() || !isTtsRateLimitEnabled()) { + async getCurrentUsage( + user: UserInfo, + backstops?: RateLimitBackstops, + options?: RateLimitRuntimeOptions, + ): Promise { + 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 { - if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return; + if (!isAuthEnabled()) return; const today = new Date().toISOString().split('T')[0]; const dateValue = today as unknown as UserTtsCharsDateValue; diff --git a/src/lib/shared/shared-provider-selection.ts b/src/lib/shared/shared-provider-selection.ts new file mode 100644 index 0000000..8dbe87f --- /dev/null +++ b/src/lib/shared/shared-provider-selection.ts @@ -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(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(); + 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; +} diff --git a/tests/unit/rate-limit-runtime-settings.spec.ts b/tests/unit/rate-limit-runtime-settings.spec.ts new file mode 100644 index 0000000..df7748d --- /dev/null +++ b/tests/unit/rate-limit-runtime-settings.spec.ts @@ -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); + }); +}); diff --git a/tests/unit/shared-provider-selection.spec.ts b/tests/unit/shared-provider-selection.spec.ts new file mode 100644 index 0000000..a371989 --- /dev/null +++ b/tests/unit/shared-provider-selection.spec.ts @@ -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'); + }); +});