diff --git a/.env.example b/.env.example index bce7bd8..f9612d0 100644 --- a/.env.example +++ b/.env.example @@ -11,16 +11,16 @@ # NOTE: On first boot, the server auto-seeds these values into a "default-openai" # admin-managed shared provider (DB-backed, encrypted at rest). After that, the # admin UI is authoritative and changing these env vars has no effect. You may -# remove them once the seed has run. See Settings → Admin → Shared providers. +# remove them once the seed has run. Auth must be configured for this seed path. +# See Settings → Admin → Shared providers. API_BASE=http://localhost:8880/v1 API_KEY=api_key_optional -# Auth configuration (recommended; required for admin features) -# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set +# Auth configuration (required in v4+) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) 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`) +# (Optional) Allow anonymous auth sessions (default: `false`) # USE_ANONYMOUS_AUTH_SESSIONS=false # (Optional) Sign in w/ GitHub Configuration # GITHUB_CLIENT_ID= @@ -29,7 +29,7 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr # (Optional) Comma-separated list of emails that are auto-promoted to admin. 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 auth tables. # Defaults to SQLite at docstore/sqlite3.db when not set. # POSTGRES_URL= diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 723e0eb..7ed850a 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -11,7 +11,10 @@ jobs: runs-on: ubuntu-latest env: USE_EMBEDDED_WEED_MINI: true + ENABLE_TEST_NAMESPACE: true BASE_URL: http://127.0.0.1:3003 + AUTH_SECRET: ci-auth-secret-change-me + USE_ANONYMOUS_AUTH_SESSIONS: true S3_ENDPOINT: http://127.0.0.1:8333 steps: - uses: actions/checkout@v5 diff --git a/README.md b/README.md index 44ab3b7..6ded1a6 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ OpenReader is an open source, self-host-friendly text-to-speech document reader - 🎯 **Multi-provider TTS** — self-hosted OpenAI-compatible servers (Kokoro-FastAPI, KittenTTS-FastAPI, Orpheus-FastAPI) or cloud APIs (OpenAI, Replicate, DeepInfra). - 🎧 **Audiobook export** in `m4b`/`mp3` with resumable chapter processing. - 🗂️ **Flexible backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync. -- 🐳 **Self-host friendly** — Docker (amd64/arm64), optional auth, and automatic startup migrations. +- 🐳 **Self-host friendly** — Docker (amd64/arm64), built-in auth/session support, and automatic startup migrations. ## 🚀 Start Here diff --git a/docs-site/docs/configure/auth.md b/docs-site/docs/configure/auth.md index 34e5053..4967bd1 100644 --- a/docs-site/docs/configure/auth.md +++ b/docs-site/docs/configure/auth.md @@ -6,23 +6,21 @@ This page covers application-level configuration for provider access and authent ## Auth behavior -- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. -- Remove either value to disable auth. +- `BASE_URL` and `AUTH_SECRET` are required at startup in v4+. - Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`. - Anonymous auth sessions are disabled by default. - Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows. ## Runtime modes -OpenReader effectively has three common runtime modes: +OpenReader has two common runtime modes: -- **Auth disabled** (`BASE_URL` or `AUTH_SECRET` unset): no admin panel. Shared providers can still exist via first-boot seeding (`API_KEY`/`API_BASE`), but you cannot manage them in-app. - **Auth enabled, non-admin user**: user account/session features are available, but no admin controls. - **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features). ## Admin role -When auth is enabled, you can designate one or more users as admins via the `ADMIN_EMAILS` env var: +You can designate one or more users as admins via the `ADMIN_EMAILS` env var: ```env ADMIN_EMAILS=alice@example.com,bob@example.com @@ -39,7 +37,7 @@ Admin assignment is reconciled on every session resolution, so removing an email - `/` is a public landing/onboarding page and remains indexable. - `/app` is the protected app home (document list and uploader UI). -- If auth is enabled and a valid session exists (including anonymous), visiting `/` redirects to `/app`. +- If a valid session exists (including anonymous), visiting `/` redirects to `/app`. - Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`. ## Related docs @@ -60,11 +58,6 @@ Admin assignment is reconciled on every session resolution, so removing an email - Updates are not instant push-based sync; they use normal client polling/refresh behavior. - If two devices change the same item around the same time, the newest update wins. -### Auth disabled - -- Settings and reading progress stay local in the browser (Dexie/IndexedDB). -- This avoids no-auth cross-browser conflicts, but there is no cross-device sync. - ## Claim modal note - You may still see old anonymous settings/progress available to claim from older deployments. diff --git a/docs-site/docs/configure/database.md b/docs-site/docs/configure/database.md index d878e9e..b87829a 100644 --- a/docs-site/docs/configure/database.md +++ b/docs-site/docs/configure/database.md @@ -54,6 +54,5 @@ For database variable behavior, see [Environment Variables](../reference/environ ## State sync summary -- With auth enabled, settings and reading progress are stored in SQL and synced from the app. -- With auth disabled, settings and reading progress remain local in the browser. +- Settings and reading progress are stored in SQL and synced from the app. - Sync is currently request-based (not realtime push invalidation). diff --git a/docs-site/docs/deploy/local-development.md b/docs-site/docs/deploy/local-development.md index 3f178c8..338aeb0 100644 --- a/docs-site/docs/deploy/local-development.md +++ b/docs-site/docs/deploy/local-development.md @@ -243,18 +243,7 @@ Use the same ownership split: Use one of these `.env` mode templates: - - -```env -API_BASE=http://host.docker.internal:8880/v1 -API_KEY=none -# Leave BASE_URL and AUTH_SECRET unset to keep auth disabled. -# (Admin panel is unavailable without auth.) -# API_BASE/API_KEY seed a shared default provider if you want shared mode. -``` - - - + ```env API_BASE=http://host.docker.internal:8880/v1 @@ -286,6 +275,8 @@ ADMIN_EMAILS=you@example.com API_BASE=http://host.docker.internal:8880/v1 API_KEY=none USE_EMBEDDED_WEED_MINI=false +BASE_URL=http://localhost:3003 +AUTH_SECRET= S3_BUCKET=your-bucket S3_REGION=us-east-1 S3_ACCESS_KEY_ID=your-access-key @@ -301,6 +292,8 @@ S3_SECRET_ACCESS_KEY=your-secret-key ```env API_BASE=http://host.docker.internal:8880/v1 API_KEY=none +BASE_URL=http://localhost:3003 +AUTH_SECRET= COMPUTE_WORKER_URL=http://localhost:8081 COMPUTE_WORKER_TOKEN= USE_EMBEDDED_WEED_MINI=false @@ -321,7 +314,7 @@ On first boot, `API_KEY` / `API_BASE` can bootstrap `default-openai`, and `RUNTI ::: :::note User BYOK restriction default -If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin** when auth/admin is enabled, or by seeding `runtimeConfig.restrictUserApiKeys=false` in runtime seed JSON). +If you want each user to enter personal provider credentials, set `restrictUserApiKeys=false` (from **Settings → Admin**, or by seeding `runtimeConfig.restrictUserApiKeys=false` in runtime seed JSON). ::: :::info diff --git a/docs-site/docs/deploy/vercel-deployment.md b/docs-site/docs/deploy/vercel-deployment.md index 3459c9c..c830ac5 100644 --- a/docs-site/docs/deploy/vercel-deployment.md +++ b/docs-site/docs/deploy/vercel-deployment.md @@ -93,7 +93,7 @@ If you must pre-seed site features/providers at deploy time, use `RUNTIME_SEED_J See [Environment Variables](../reference/environment-variables#runtime-json-seed-v4) for schema and examples. :::warning Auth recommendation -For internet-exposed Vercel deployments, set both `BASE_URL` and `AUTH_SECRET` — they are also required for the admin panel and for encrypting admin-stored TTS credentials. Running without auth is possible, but not recommended for public environments. +Set both `BASE_URL` and `AUTH_SECRET` — they are required in v4+ and also required for the admin panel and for encrypting admin-stored TTS credentials. ::: :::warning Rotating AUTH_SECRET invalidates admin-stored keys diff --git a/docs-site/docs/docker-quick-start.md b/docs-site/docs/docker-quick-start.md index 8032db6..d51d351 100644 --- a/docs-site/docs/docker-quick-start.md +++ b/docs-site/docs/docker-quick-start.md @@ -33,7 +33,7 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds. -Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount: +Persistent storage, embedded SeaweedFS `weed mini`, required auth, optional library mount: ```bash docker run --name openreader \ @@ -57,7 +57,7 @@ What this command enables: - `-v openreader_docstore:/app/docstore`: persists SQLite metadata, SeaweedFS blob data, and migration/runtime state. - `-v /path/to/your/library:/app/docstore/library:ro`: mounts a read-only importable library source. - `-e API_BASE=...` / `-e API_KEY=...`: **first-boot seed only.** On the first container start, these are auto-migrated into a `default-openai` admin shared provider stored in the DB (key encrypted at rest). After that, the running app no longer reads them — manage the provider from **Settings → Admin → Shared providers**. See [Admin Panel](./configure/admin-panel). -- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: together they turn on auth/session mode for local sign-in flows. +- `-e BASE_URL=...` and `-e AUTH_SECRET=...`: required for v4+ auth/session startup. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings. @@ -95,21 +95,23 @@ What this command enables: -Auth disabled, embedded storage ephemeral, no library import: +Auth required, embedded storage ephemeral, no library import: ```bash docker run --name openreader \ --restart unless-stopped \ -p 3003:3003 \ -p 8333:8333 \ + -e BASE_URL=http://localhost:3003 \ + -e AUTH_SECRET=$(openssl rand -hex 32) \ ghcr.io/richardr1126/openreader:latest ``` What this command enables: -- Fastest startup with no extra env vars. +- Fast startup with only the required auth env vars. - No persistent volume (`/app/docstore` stays container-local), so data is ephemeral unless you add a mount. -- Auth remains disabled because `BASE_URL` and `AUTH_SECRET` are not set. The admin panel requires auth, so it's unavailable in this mode. +- The app still requires `BASE_URL` + `AUTH_SECRET` in v4+, so include them even in minimal mode. - No TTS provider preset by default. Configure `API_BASE`/`API_KEY` on first boot if you want a seeded shared provider, or run auth+admin mode and manage providers from the admin panel. @@ -117,9 +119,9 @@ What this command enables: :::tip Quick Tips - Set `API_BASE` on first boot to a TTS endpoint the container can reach (`host.docker.internal` works for host-local services). After first boot, manage providers in **Settings → Admin → Shared providers**. -- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. The admin panel requires auth. +- `BASE_URL` and `AUTH_SECRET` are required in v4+. The admin panel requires auth. - Set `ADMIN_EMAILS` to your email if you want the **Admin** tab in Settings. -- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK in auth-enabled setups, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON. +- `restrictUserApiKeys` controls shared-provider-only mode. For per-user BYOK, toggle it off in **Settings → Admin → Site features** or seed `runtimeConfig.restrictUserApiKeys=false` via runtime seed JSON. - Use a `/app/docstore` mount if you want data to survive container/image replacement. - Startup automatically runs DB/storage migrations via the shared entrypoint. ::: diff --git a/docs-site/docs/introduction.md b/docs-site/docs/introduction.md index 6d00019..6a43df7 100644 --- a/docs-site/docs/introduction.md +++ b/docs-site/docs/introduction.md @@ -23,7 +23,7 @@ It supports multiple TTS providers including OpenAI, Replicate, DeepInfra, and c - Cloud: [**OpenAI**](https://platform.openai.com/docs/pricing#transcription-and-speech) (`tts-1`, `tts-1-hd`, `gpt-4o-mini-tts`), [**Replicate**](https://replicate.com/explore) (built-in catalog + any model ID), [**DeepInfra**](https://deepinfra.com/models/text-to-speech) (Kokoro-82M and others) - 🎧 **Audiobook Export** in `m4b`/`mp3` with resumable chapter generation - 🗂️ **Flexible Backend** — embedded SeaweedFS or S3-compatible storage, SQLite or Postgres, server library import, and device sync -- 🔐 **Auth Optional** — run no-auth for local use, or enable full user isolation with a claim flow for multi-user deployments +- 🔐 **Auth and User Isolation** — auth is required in v4+, with optional anonymous auth sessions for guest flows - 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls ## 🧭 Key Docs diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 3421161..2389c18 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -6,7 +6,7 @@ toc_max_heading_level: 3 This page is the source-of-truth reference for OpenReader environment variables. :::note Recommended configuration path -For auth-enabled deployments, use **Settings → Admin** as the primary source of truth for shared providers and runtime site features. +Use **Settings → Admin** as the primary source of truth for shared providers and runtime site features. `API_BASE` / `API_KEY` are optional one-time provider bootstrap seeds. Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`. ::: @@ -19,8 +19,8 @@ Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_P | `LOG_LEVEL` | Runtime logging | `info` | Set app server log level | | `API_BASE` | TTS provider bootstrap seed | unset | Optional first-boot base URL for `default-openai` | | `API_KEY` | TTS provider bootstrap seed | unset | Optional first-boot API key for `default-openai` | -| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth | -| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | +| `BASE_URL` | Auth | unset | Required at startup | +| `AUTH_SECRET` | Auth | unset | Required at startup | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | | `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to allow anonymous auth sessions | | `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in | @@ -120,16 +120,16 @@ There are no dedicated env vars for these runtime settings. ### BASE_URL -External base URL for this OpenReader instance. +Required external base URL for this OpenReader instance. -- Required with `AUTH_SECRET` to enable auth +- Required at startup - Example: `http://localhost:3003` or `https://reader.example.com` ### AUTH_SECRET -Secret key used by auth/session handling. +Required secret key used by auth/session handling. -- Required with `BASE_URL` to enable auth +- Required at startup - Generate with `openssl rand -hex 32` ### AUTH_TRUSTED_ORIGINS diff --git a/package.json b/package.json index a7aa4d5..0faf053 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,7 @@ "test:e2e": "playwright test", "test:unit": "vitest run", "test:compute": "vitest run --project compute-core --project compute-worker", - "test:ci-env": "CI=true playwright test", + "test:ci": "CI=true playwright test", "migrate": "node drizzle/scripts/migrate.mjs", "migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", diff --git a/playwright.config.ts b/playwright.config.ts index 07b27bb..d38c61e 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,15 +1,5 @@ import { defineConfig, devices } from '@playwright/test'; import 'dotenv/config'; -import fs from 'node:fs'; -import path from 'node:path'; -import dotenv from 'dotenv'; - -if (process.env.CI === 'true') { - const envCiPath = path.join(process.cwd(), '.env.ci'); - if (fs.existsSync(envCiPath)) { - dotenv.config({ path: envCiPath, override: true }); - } -} /** * See https://playwright.dev/docs/test-configuration. diff --git a/scripts/openreader-entrypoint.mjs b/scripts/openreader-entrypoint.mjs index 06dd1d4..2fa3e86 100644 --- a/scripts/openreader-entrypoint.mjs +++ b/scripts/openreader-entrypoint.mjs @@ -11,15 +11,6 @@ import * as dotenv from 'dotenv'; function loadEnvFiles() { const cwd = process.cwd(); - const isCi = isTrue(process.env.CI, false); - if (isCi) { - const envCiPath = path.join(cwd, '.env.ci'); - if (fs.existsSync(envCiPath)) { - dotenv.config({ path: envCiPath }); - } - return; - } - const envPath = path.join(cwd, '.env'); const envLocalPath = path.join(cwd, '.env.local'); @@ -46,6 +37,18 @@ function withDefault(value, fallback) { return value && value.trim() ? value.trim() : fallback; } +function requireAuthEnv(env) { + const missing = []; + if (!env.AUTH_SECRET?.trim()) missing.push('AUTH_SECRET'); + if (!env.BASE_URL?.trim()) missing.push('BASE_URL'); + if (missing.length > 0) { + throw new Error( + `Missing required auth env vars: ${missing.join(', ')}. ` + + 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.', + ); + } +} + function isPrivateIPv4(address) { if (!address) return false; if (address.startsWith('10.')) return true; @@ -328,6 +331,7 @@ async function main() { const runtimeEnv = { ...process.env }; runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty'); + requireAuthEnv(runtimeEnv); let weedProc = null; let weedExitPromise = Promise.resolve(); let natsProc = null; diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index c554046..a9fc058 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -3,7 +3,7 @@ import type { ReactNode } from 'react'; import { Toaster } from 'react-hot-toast'; import { Providers } from '@/app/providers'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; +import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '@/lib/server/auth/config'; export const dynamic = 'force-dynamic'; @@ -22,14 +22,13 @@ export const metadata: Metadata = { }; export default function AppLayout({ children }: { children: ReactNode }) { - const authEnabled = isAuthEnabled(); const authBaseUrl = getAuthBaseUrl(); const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); const githubAuthEnabled = isGithubAuthEnabled(); return ( { - if (!authEnabled) { - router.push('/app'); - } - }, [router, authEnabled]); - const validateEmail = (email: string): boolean => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; @@ -121,10 +114,6 @@ function SignInContent() { } }; - if (!authEnabled) { - return null; - } - return (
diff --git a/src/app/(app)/signup/page.tsx b/src/app/(app)/signup/page.tsx index ba09f63..0496b08 100644 --- a/src/app/(app)/signup/page.tsx +++ b/src/app/(app)/signup/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState, useEffect } from 'react'; +import { useState } from 'react'; import { Button, Input } from '@headlessui/react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; @@ -24,13 +24,6 @@ export default function SignUpPage() { const enableUserSignups = useFeatureFlag('enableUserSignups'); const { refresh: refreshRateLimit } = useAuthRateLimit(); - // Check if auth is enabled, redirect home if not - useEffect(() => { - if (!authEnabled) { - router.push('/app'); - } - }, [router, authEnabled]); - const validateEmail = (email: string): boolean => { return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); }; @@ -109,10 +102,6 @@ export default function SignUpPage() { } }; - if (!authEnabled) { - return null; - } - if (!enableUserSignups) { return (
diff --git a/src/app/api/account/delete/route.ts b/src/app/api/account/delete/route.ts index 740aae5..26e1a0f 100644 --- a/src/app/api/account/delete/route.ts +++ b/src/app/api/account/delete/route.ts @@ -1,17 +1,12 @@ import { headers } from 'next/headers'; import { NextResponse } from 'next/server'; import { auth } from '@/lib/server/auth/auth'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; import { errorResponse } from '@/lib/server/errors/next-response'; export async function DELETE() { - if (!isAuthEnabled() || !auth) { - return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 }); - } - const reqHeaders = await headers(); const session = await auth.api.getSession({ diff --git a/src/app/api/audiobook/chapter/route.ts b/src/app/api/audiobook/chapter/route.ts index 8ec30b2..c0342a3 100644 --- a/src/app/api/audiobook/chapter/route.ts +++ b/src/app/api/audiobook/chapter/route.ts @@ -286,11 +286,11 @@ export async function POST(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled, user } = ctxOrRes; + const { userId, user } = ctxOrRes; const runtimeConfig = await getResolvedRuntimeConfig(); const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const bookId = data.bookId || randomUUID(); if (!isSafeId(bookId)) { @@ -540,7 +540,7 @@ export async function POST(request: NextRequest) { ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated, }); - if (authEnabled && userId && ttsRateLimitEnabled) { + if (userId && ttsRateLimitEnabled) { const isAnonymous = Boolean(user?.isAnonymous); const charCount = data.text.length; const ip = getClientIp(request); @@ -809,10 +809,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -906,10 +906,10 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/audiobook/route.ts b/src/app/api/audiobook/route.ts index 0b2a5da..033b010 100644 --- a/src/app/api/audiobook/route.ts +++ b/src/app/api/audiobook/route.ts @@ -169,10 +169,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) @@ -404,10 +404,10 @@ export async function DELETE(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/audiobook/status/route.ts b/src/app/api/audiobook/status/route.ts index 29bda02..9a1f561 100644 --- a/src/app/api/audiobook/status/route.ts +++ b/src/app/api/audiobook/status/route.ts @@ -74,10 +74,10 @@ export async function GET(request: NextRequest) { const ctxOrRes = await requireAuthContext(request); if (ctxOrRes instanceof Response) return ctxOrRes; - const { userId, authEnabled } = ctxOrRes; + const { userId } = ctxOrRes; const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); - const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); + const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId); const existingBookRows = await db .select({ userId: audiobooks.userId }) .from(audiobooks) diff --git a/src/app/api/auth/[...all]/route.ts b/src/app/api/auth/[...all]/route.ts index 35ec1e4..e5802a6 100644 --- a/src/app/api/auth/[...all]/route.ts +++ b/src/app/api/auth/[...all]/route.ts @@ -1,11 +1,6 @@ import { auth } from "@/lib/server/auth/auth"; // path to your auth file import { toNextJsHandler } from "better-auth/next-js"; -const handlers = auth - ? toNextJsHandler(auth) - : { - POST: async () => new Response("Auth disabled", { status: 404 }), - GET: async () => new Response("Auth disabled", { status: 404 }) - }; +const handlers = toNextJsHandler(auth); -export const { POST, GET } = handlers; \ No newline at end of file +export const { POST, GET } = handlers; diff --git a/src/app/api/documents/[id]/parsed/events/route.ts b/src/app/api/documents/[id]/parsed/events/route.ts index b9091f9..e4590ca 100644 --- a/src/app/api/documents/[id]/parsed/events/route.ts +++ b/src/app/api/documents/[id]/parsed/events/route.ts @@ -162,7 +162,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserIdHash = hashForLog(storageUserId); - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const row = await loadPreferredRow({ documentId: id, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 3ca677f..0301e88 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -208,7 +208,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); @@ -365,7 +365,7 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await loadRows({ documentId: id, allowedUserIds }); const row = pickPreferredRow(rows, storageUserId); diff --git a/src/app/api/documents/[id]/settings/route.ts b/src/app/api/documents/[id]/settings/route.ts index 5b94e23..478ddf5 100644 --- a/src/app/api/documents/[id]/settings/route.ts +++ b/src/app/api/documents/[id]/settings/route.ts @@ -44,7 +44,7 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = await db .select({ userId: documents.userId }) diff --git a/src/app/api/documents/blob/get/fallback/route.ts b/src/app/api/documents/blob/get/fallback/route.ts index 5584cfc..841e070 100644 --- a/src/app/api/documents/blob/get/fallback/route.ts +++ b/src/app/api/documents/blob/get/fallback/route.ts @@ -38,7 +38,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/get/presign/route.ts b/src/app/api/documents/blob/get/presign/route.ts index 8346c3c..cdb0265 100644 --- a/src/app/api/documents/blob/get/presign/route.ts +++ b/src/app/api/documents/blob/get/presign/route.ts @@ -28,7 +28,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/fallback/route.ts b/src/app/api/documents/blob/preview/fallback/route.ts index a83955e..949e785 100644 --- a/src/app/api/documents/blob/preview/fallback/route.ts +++ b/src/app/api/documents/blob/preview/fallback/route.ts @@ -56,7 +56,7 @@ export async function GET(req: NextRequest) { const testNamespace = getOpenReaderTestNamespace(req.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const url = new URL(req.url); const id = (url.searchParams.get('id') || '').trim().toLowerCase(); diff --git a/src/app/api/documents/blob/preview/utils.ts b/src/app/api/documents/blob/preview/utils.ts index e1784fe..ef11138 100644 --- a/src/app/api/documents/blob/preview/utils.ts +++ b/src/app/api/documents/blob/preview/utils.ts @@ -44,7 +44,7 @@ export async function validatePreviewRequest(req: NextRequest): Promise { @@ -556,14 +555,12 @@ export function SettingsModal({
- {authEnabled && ( - - )} +
@@ -1161,15 +1158,6 @@ export function SettingsModal({ > Clear cache - {enableDestructiveDelete && !authEnabled && ( - - )} @@ -1208,7 +1196,7 @@ export function SettingsModal({ )} {/* Account Section */} - {activeSection === 'account' && authEnabled && ( + {activeSection === 'account' && (
{/* Session info */}
diff --git a/src/components/auth/AuthLoader.tsx b/src/components/auth/AuthLoader.tsx index 96968a3..47f95a2 100644 --- a/src/components/auth/AuthLoader.tsx +++ b/src/components/auth/AuthLoader.tsx @@ -113,7 +113,6 @@ export function AuthLoader({ children }: { children: ReactNode }) { useEffect(() => { const checkStatus = async () => { - if (!authEnabled) return; if (isPending) return; if (session) { @@ -236,17 +235,16 @@ export function AuthLoader({ children }: { children: ReactNode }) { ]); useEffect(() => { - if (!authEnabled) return; if (sessionError) { console.warn('[AuthLoader] useSession error', sessionError); } - }, [authEnabled, sessionError]); + }, [sessionError]); const shouldBlockForProtectedNoSession = - authEnabled && !allowAnonymousAuthSessions && !isAuthPage && !session; + !allowAnonymousAuthSessions && !isAuthPage && !session; const shouldBlockForDisallowedAnonymous = - authEnabled && !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous); - const isLoading = authEnabled && ( + !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous); + const isLoading = ( (allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) || (!allowAnonymousAuthSessions && !isAuthPage && ( isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous diff --git a/src/components/auth/RateLimitBanner.tsx b/src/components/auth/RateLimitBanner.tsx index 8453ae0..aeadbfb 100644 --- a/src/components/auth/RateLimitBanner.tsx +++ b/src/components/auth/RateLimitBanner.tsx @@ -9,11 +9,10 @@ interface RateLimitBannerProps { } export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit, timeUntilReset, authEnabled } = useAuthRateLimit(); + const { status, isAtLimit, timeUntilReset } = useAuthRateLimit(); const enableUserSignups = useFeatureFlag('enableUserSignups'); - // Don't show banner if auth is not enabled or if not at limit - if (!authEnabled || !status?.authEnabled || !isAtLimit) { + if (!status?.authEnabled || !isAtLimit) { return null; } @@ -51,10 +50,9 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { * Compact version for inline display */ export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { - const { status, isAtLimit, authEnabled } = useAuthRateLimit(); + const { status, isAtLimit } = useAuthRateLimit(); - // Don't show if auth is not enabled - if (!authEnabled || !status?.authEnabled) { + if (!status?.authEnabled) { return null; } diff --git a/src/components/auth/UserMenu.tsx b/src/components/auth/UserMenu.tsx index bbb6f88..a30d419 100644 --- a/src/components/auth/UserMenu.tsx +++ b/src/components/auth/UserMenu.tsx @@ -18,12 +18,12 @@ export function UserMenu({ className?: string; variant?: UserMenuVariant; }) { - const { authEnabled, baseUrl } = useAuthConfig(); + const { baseUrl } = useAuthConfig(); const enableUserSignups = useFeatureFlag('enableUserSignups'); const { data: session, isPending } = useAuthSession(); const router = useRouter(); - if (!authEnabled || isPending) return null; + if (isPending) return null; const handleDisconnectAccount = async () => { const client = getAuthClient(baseUrl); diff --git a/src/contexts/AuthRateLimitContext.tsx b/src/contexts/AuthRateLimitContext.tsx index fb69fb2..d753257 100644 --- a/src/contexts/AuthRateLimitContext.tsx +++ b/src/contexts/AuthRateLimitContext.tsx @@ -140,26 +140,13 @@ export function AuthRateLimitProvider({ } return parseRateLimitStatus(await response.json()); }, - enabled: authEnabled, + enabled: true, retry: 0, }); - const status = authEnabled - ? (queryStatus ?? null) - : { - allowed: true, - currentCount: 0, - // Avoid Infinity to prevent JSON/serialization edge cases elsewhere. - limit: Number.MAX_SAFE_INTEGER, - remainingChars: Number.MAX_SAFE_INTEGER, - resetTimeMs: nextUtcMidnightTimestampMs(), - userType: 'unauthenticated' as const, - authEnabled: false, - }; - const loading = authEnabled ? (isPending || isFetching) : false; - const error = authEnabled - ? (queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null) - : null; + const status = queryStatus ?? null; + const loading = isPending || isFetching; + const error = queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : null; useEffect(() => { if (!queryError) return; @@ -167,15 +154,13 @@ export function AuthRateLimitProvider({ }, [queryError]); const refresh = useCallback(async () => { - if (!authEnabled) return; await refetch(); - }, [authEnabled, refetch]); + }, [refetch]); const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : ''; const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; const incrementCount = useCallback((charCount: number) => { - if (!authEnabled) return; queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prevStatus) => { if (!prevStatus) return prevStatus; @@ -189,7 +174,7 @@ export function AuthRateLimitProvider({ allowed: newRemainingChars > 0 }; }); - }, [authEnabled, queryClient]); + }, [queryClient]); const onTTSStart = useCallback(() => { pendingTTSRef.current += 1; @@ -239,7 +224,6 @@ export function AuthRateLimitProvider({ onTTSStart, onTTSComplete, triggerRateLimit: () => { - if (!authEnabled) return; queryClient.setQueryData(RATE_LIMIT_QUERY_KEY, (prev) => prev ? { ...prev, remainingChars: 0, allowed: false } : null, ); diff --git a/src/contexts/ConfigContext.tsx b/src/contexts/ConfigContext.tsx index f119ddb..b6a6c74 100644 --- a/src/contexts/ConfigContext.tsx +++ b/src/contexts/ConfigContext.tsx @@ -9,7 +9,6 @@ import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/sha import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { useAuthSession } from '@/hooks/useAuthSession'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { applyConfigUpdate } from '@/lib/client/config/updates'; @@ -70,7 +69,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) { const didRunStartupMigrations = useRef(false); const didAttemptInitialPreferenceSeedForSession = useRef(null); const syncedPreferenceKeys = useMemo(() => new Set(SYNCED_PREFERENCE_KEYS), []); - const { authEnabled } = useAuthConfig(); const { providers: sharedProviders } = useSharedProviders(); const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const sessionKey = sessionData?.user?.id ?? 'no-session'; @@ -83,7 +81,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [sharedProviders]); const queueSyncedPreferencePatch = useCallback((patch: Partial) => { - if (!authEnabled || sessionKey === 'no-session') return; + if (sessionKey === 'no-session') return; const syncedPatch: SyncedPreferencesPatch = {}; for (const key of SYNCED_PREFERENCE_KEYS) { @@ -94,7 +92,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { } if (Object.keys(syncedPatch).length === 0) return; scheduleUserPreferencesSync(syncedPatch, sessionKey); - }, [authEnabled, sessionKey]); + }, [sessionKey]); // Cancel pending/in-flight preference syncs whenever the session changes or on unmount. useEffect(() => { @@ -157,7 +155,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [isDBReady]); const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => { - if (!isDBReady || !authEnabled) return; + if (!isDBReady) return; try { const remote = await getUserPreferences({ signal }); if (!remote?.hasStoredPreferences) return; @@ -167,20 +165,20 @@ export function ConfigProvider({ children }: { children: ReactNode }) { if ((error as Error)?.name === 'AbortError') return; console.warn('Failed to load synced preferences:', error); } - }, [isDBReady, authEnabled]); + }, [isDBReady]); useEffect(() => { - if (!isDBReady || !authEnabled || isSessionPending) return; + if (!isDBReady || isSessionPending) return; const controller = new AbortController(); refreshSyncedPreferencesFromServer(controller.signal).catch((error) => { if ((error as Error)?.name === 'AbortError') return; console.warn('Synced preferences refresh failed:', error); }); return () => controller.abort(); - }, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); + }, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); useEffect(() => { - if (!isDBReady || !authEnabled) return; + if (!isDBReady) return; let activeController: AbortController | null = null; const onFocus = () => { if (activeController) activeController.abort(); @@ -195,7 +193,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { window.removeEventListener('focus', onFocus); if (activeController) activeController.abort(); }; - }, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); + }, [isDBReady, refreshSyncedPreferencesFromServer]); const appConfig = useLiveQuery( async () => { @@ -301,7 +299,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); useEffect(() => { - if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; + if (!isDBReady || !appConfig || isSessionPending) return; if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; didAttemptInitialPreferenceSeedForSession.current = sessionKey; @@ -330,7 +328,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) { }); return () => controller.abort(); - }, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]); + }, [isDBReady, appConfig, isSessionPending, sessionKey]); // Destructure for convenience and to match context shape const { diff --git a/src/contexts/OnboardingFlowContext.tsx b/src/contexts/OnboardingFlowContext.tsx index 0384a09..b45981c 100644 --- a/src/contexts/OnboardingFlowContext.tsx +++ b/src/contexts/OnboardingFlowContext.tsx @@ -4,7 +4,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal'; import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; import { PrivacyModal } from '@/components/PrivacyModal'; -import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthSession } from '@/hooks/useAuthSession'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie'; @@ -105,7 +104,6 @@ async function readLocalOnboardingSnapshot(): Promise { } export function OnboardingFlowProvider({ children }: { children: ReactNode }) { - const { authEnabled } = useAuthConfig(); const { data: session, isPending: isSessionPending } = useAuthSession(); const runtimeConfig = useRuntimeConfig(); const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; @@ -136,12 +134,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { const runOnceFlow = useCallback(async () => { const local = await readLocalOnboardingSnapshot(); - const privacyRequired = authEnabled; + const privacyRequired = true; const privacyAccepted = !privacyRequired || local.privacyAccepted; const isClaimEligible = Boolean( - authEnabled - && userId + userId && !isAnonymous && !claimDismissedUsersRef.current.has(userId), ); @@ -199,7 +196,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { pendingChangelogOpenRef.current = false; setChangelogOpenSignal((value) => value + 1); } - }, [authEnabled, isAnonymous, userId]); + }, [isAnonymous, userId]); runOnceFlowRef.current = runOnceFlow; @@ -223,12 +220,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { useEffect(() => { void runFlow(); - }, [authEnabled, isAnonymous, runFlow, userId]); + }, [isAnonymous, runFlow, userId]); useEffect(() => { - if (!authEnabled) { - return; - } const onPrivacyAccepted = () => { void runFlow(); }; @@ -236,15 +230,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return () => { window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); }; - }, [authEnabled, runFlow]); + }, [runFlow]); useEffect(() => { - if (!authEnabled) { - return () => { }; - } - return scheduleChangelogCheck({ - authEnabled, + authEnabled: true, isSessionPending, sessionUserId: userId, appVersion: runtimeConfig.appVersion, @@ -258,7 +248,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { delayMs: 120, retryDelayMs: 400, }); - }, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]); + }, [isSessionPending, runFlow, runtimeConfig.appVersion, userId]); const contextValue = useMemo(() => ({ changelogOpenSignal, @@ -267,21 +257,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) { return ( {children} - {authEnabled && ( - { }} - /> - )} - {authEnabled && ( - - )} + { }} + /> + ['useSession']>; - -/** Stable empty result returned when auth is disabled. */ -const EMPTY_SESSION: SessionHookResult = { - data: null, - isPending: false, - isRefetching: false, - // better-auth types use BetterFetchError | null - // eslint-disable-next-line @typescript-eslint/no-explicit-any - error: null as any, - refetch: async () => { }, -}; - -/** Stub client whose useSession() always returns the empty shape. */ -const STUB_CLIENT = { useSession: () => EMPTY_SESSION } as ReturnType; - /** * Hook for session that uses the correct baseUrl from context. - * A stub client is used when auth is disabled so that useSession() - * is always called unconditionally (Rules of Hooks). */ export function useAuthSession() { - const { baseUrl, authEnabled } = useAuthConfig(); + const { baseUrl } = useAuthConfig(); const client = useMemo(() => { - if (!authEnabled || !baseUrl) return STUB_CLIENT; + if (!baseUrl) { + throw new Error('Auth base URL is required'); + } return getAuthClient(baseUrl); - }, [baseUrl, authEnabled]); + }, [baseUrl]); return client.useSession(); } - diff --git a/src/lib/server/admin/seed.ts b/src/lib/server/admin/seed.ts index 7e6ecd0..c06cc48 100644 --- a/src/lib/server/admin/seed.ts +++ b/src/lib/server/admin/seed.ts @@ -301,29 +301,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise { try { enc = encryptSecret(apiKey); } catch (error) { - try { - await db - .insert(adminSettings) - .values({ - key: 'restrictUserApiKeys', - valueJson: JSON.stringify(false) as never, - source: 'env-seed', - updatedAt: now, - }) - .onConflictDoNothing({ target: adminSettings.key }); - logDegraded(serverLogger, { - event: 'admin.seed.restrict_user_api_keys.defaulted', - msg: 'API_KEY present but AUTH_SECRET missing; defaulting restrictUserApiKeys=false', - step: 'set_restrict_user_api_keys_fallback', - }); - } catch (fallbackError) { - logDegraded(serverLogger, { - event: 'admin.seed.restrict_user_api_keys.fallback_write_failed', - msg: 'Failed to write restrictUserApiKeys fallback after encryption failure', - step: 'set_restrict_user_api_keys_fallback', - error: fallbackError, - }); - } logDegraded(serverLogger, { event: 'admin.seed.provider_key_encrypt.failed', msg: 'Failed to encrypt default provider API key', diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index bf6058f..1f2048e 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -1,7 +1,6 @@ import { and, eq } from 'drizzle-orm'; import { db } from '@/db'; import { adminProviders, adminSettings } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { serverLogger } from '@/lib/server/logger'; import { logDegraded } from '@/lib/server/errors/logging'; @@ -137,11 +136,6 @@ function buildDefaults(): RuntimeConfig { for (const key of RUNTIME_KEYS) { (out as Record)[key] = RUNTIME_CONFIG_SCHEMA[key].default; } - // In no-auth mode there is no admin UI to configure shared providers. - // Keep legacy BYOK available by default unless explicitly overridden. - if (!isAuthEnabled()) { - out.restrictUserApiKeys = false; - } return out; } diff --git a/src/lib/server/audiobooks/user-scope.ts b/src/lib/server/audiobooks/user-scope.ts index ecb351d..03753d3 100644 --- a/src/lib/server/audiobooks/user-scope.ts +++ b/src/lib/server/audiobooks/user-scope.ts @@ -1,12 +1,7 @@ export function buildAllowedAudiobookUserIds( - authEnabled: boolean, userId: string | null, unclaimedUserId: string, ): { preferredUserId: string; allowedUserIds: string[] } { - if (!authEnabled) { - return { preferredUserId: unclaimedUserId, allowedUserIds: [unclaimedUserId] }; - } - const preferredUserId = userId ?? unclaimedUserId; const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId])); return { preferredUserId, allowedUserIds }; diff --git a/src/lib/server/auth/admin.ts b/src/lib/server/auth/admin.ts index 9967154..4f16e3b 100644 --- a/src/lib/server/auth/admin.ts +++ b/src/lib/server/auth/admin.ts @@ -9,16 +9,13 @@ export type AdminAuthContext = AuthContext & { /** * Returns the admin auth context, or a 401/403 Response if the requester is * not authenticated / not an admin. Mirrors the `requireAuthContext` shape. - * - * When auth is disabled, this always returns 403 — there is no notion of - * "admin" without authentication. */ export async function requireAdminContext( request: Pick, ): Promise { const ctx = await getAuthContext(request); - if (!ctx.authEnabled || !ctx.userId || !ctx.user) { + if (!ctx.userId || !ctx.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/lib/server/auth/auth.ts b/src/lib/server/auth/auth.ts index 781c89b..89afe15 100644 --- a/src/lib/server/auth/auth.ts +++ b/src/lib/server/auth/auth.ts @@ -5,7 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; import { db } from "@/db"; -import { isAuthEnabled, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config"; +import { getRequiredAuthEnv, isAnonymousAuthSessionsEnabled } from "@/lib/server/auth/config"; import { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync"; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy'; @@ -58,6 +58,7 @@ function envFlagEnabled(name: string, defaultValue: boolean): boolean { } const authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite; +const requiredAuthEnv = getRequiredAuthEnv(); const createAuth = () => betterAuth({ // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -65,8 +66,8 @@ const createAuth = () => betterAuth({ provider: process.env.POSTGRES_URL ? "pg" : "sqlite", schema: authSchema as Record, }), - secret: process.env.AUTH_SECRET!, - baseURL: process.env.BASE_URL!, + secret: requiredAuthEnv.authSecret, + baseURL: requiredAuthEnv.baseUrl, trustedOrigins: getTrustedOrigins(), emailAndPassword: { enabled: true, @@ -309,7 +310,7 @@ const createAuth = () => betterAuth({ ], }); -export const auth = isAuthEnabled() ? createAuth() : null; +export const auth = createAuth(); type AuthInstance = ReturnType; export type Session = AuthInstance["$Infer"]["Session"]; @@ -319,19 +320,13 @@ export type User = AuthSessionUser & { }; export type AuthContext = { - authEnabled: boolean; + authEnabled: true; session: Session | null; user: User | null; userId: string | null; }; export async function getAuthContext(request: Pick): Promise { - const authEnabled = isAuthEnabled(); - - if (!authEnabled || !auth) { - return { authEnabled, session: null, user: null, userId: null }; - } - const session = await auth.api.getSession({ headers: request.headers }); const user = (session?.user ?? null) as User | null; const userId = user?.id ?? null; @@ -347,7 +342,7 @@ export async function getAuthContext(request: Pick): Pro } } - return { authEnabled, session, user, userId }; + return { authEnabled: true, session, user, userId }; } export async function requireAuthContext( @@ -356,10 +351,6 @@ export async function requireAuthContext( ): Promise { const ctx = await getAuthContext(request); - if (!ctx.authEnabled) { - return ctx; - } - if (!ctx.userId) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); } diff --git a/src/lib/server/auth/config.ts b/src/lib/server/auth/config.ts index 0d70370..5691850 100644 --- a/src/lib/server/auth/config.ts +++ b/src/lib/server/auth/config.ts @@ -1,9 +1,24 @@ -/** - * Centralized auth configuration check. - * Auth is only enabled when BOTH AUTH_SECRET and BASE_URL are set. - */ -export function isAuthEnabled(): boolean { - return !!(process.env.AUTH_SECRET && process.env.BASE_URL); +export type RequiredAuthEnv = { + authSecret: string; + baseUrl: string; +}; + +function getRequiredEnvValue(name: 'AUTH_SECRET' | 'BASE_URL'): string { + const value = process.env[name]?.trim(); + if (!value) { + throw new Error( + `Missing required environment variable: ${name}. ` + + 'OpenReader v4 requires both AUTH_SECRET and BASE_URL at startup.', + ); + } + return value; +} + +export function getRequiredAuthEnv(): RequiredAuthEnv { + return { + authSecret: getRequiredEnvValue('AUTH_SECRET'), + baseUrl: getRequiredEnvValue('BASE_URL'), + }; } function parseBooleanEnv(name: string, defaultValue: boolean): boolean { @@ -21,25 +36,21 @@ function parseBooleanEnv(name: string, defaultValue: boolean): boolean { * Defaults to false when unset or invalid. */ export function isAnonymousAuthSessionsEnabled(): boolean { - if (!isAuthEnabled()) return false; + getRequiredAuthEnv(); return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false); } /** - * GitHub sign-in is available only when auth is enabled AND - * both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set. + * GitHub sign-in is available when both GITHUB_CLIENT_ID and + * GITHUB_CLIENT_SECRET are set. */ export function isGithubAuthEnabled(): boolean { - if (!isAuthEnabled()) return false; return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); } /** - * Get the auth base URL if auth is enabled, otherwise null. + * Get the required auth base URL. */ -export function getAuthBaseUrl(): string | null { - if (!isAuthEnabled()) { - return null; - } - return process.env.BASE_URL || null; +export function getAuthBaseUrl(): string { + return getRequiredAuthEnv().baseUrl; } diff --git a/src/lib/server/rate-limit/job-rate-limiter.ts b/src/lib/server/rate-limit/job-rate-limiter.ts index f5a41e5..ef6a45a 100644 --- a/src/lib/server/rate-limit/job-rate-limiter.ts +++ b/src/lib/server/rate-limit/job-rate-limiter.ts @@ -1,7 +1,6 @@ import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { db } from '@/db'; import { userJobEvents } from '@/db/schema'; -import { isAuthEnabled } from '@/lib/server/auth/config'; import { nowTimestampMs } from '@/lib/shared/timestamps'; import type { RuntimeConfig } from '@/lib/server/admin/settings'; @@ -63,7 +62,7 @@ export function getPdfLayoutRateConfig(runtime: Pick< const safeDb = () => db as any; function isActive(config: Pick, userId: string | null | undefined): userId is string { - return isAuthEnabled() && config.enabled && Boolean(userId); + return config.enabled && Boolean(userId); } async function countSince(userId: string, action: JobRateAction, since: number): Promise { diff --git a/src/lib/server/rate-limit/rate-limiter.ts b/src/lib/server/rate-limit/rate-limiter.ts index 0a9dd68..b7ce741 100644 --- a/src/lib/server/rate-limit/rate-limiter.ts +++ b/src/lib/server/rate-limit/rate-limiter.ts @@ -1,6 +1,5 @@ import { db } from '@/db'; 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'; @@ -158,7 +157,7 @@ export class RateLimiter { ): Promise { const limits = resolveRateLimitThresholds(options?.limits); const enabled = options?.enabled ?? true; - if (!isAuthEnabled() || !enabled) { + if (!enabled) { return { allowed: true, currentCount: 0, @@ -265,7 +264,7 @@ export class RateLimiter { ): Promise { const limits = resolveRateLimitThresholds(options?.limits); const enabled = options?.enabled ?? true; - if (!isAuthEnabled() || !enabled) { + if (!enabled) { return { allowed: true, currentCount: 0, @@ -321,8 +320,6 @@ export class RateLimiter { * Transfer char counts when anonymous user creates an account */ async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise { - if (!isAuthEnabled()) return; - const today = new Date().toISOString().split('T')[0]; const dateValue = today as unknown as UserTtsCharsDateValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; diff --git a/src/lib/server/tts/segments-audio.ts b/src/lib/server/tts/segments-audio.ts index 0b12372..779c79e 100644 --- a/src/lib/server/tts/segments-audio.ts +++ b/src/lib/server/tts/segments-audio.ts @@ -3,7 +3,6 @@ import type { NextRequest } from 'next/server'; import { NextResponse } from 'next/server'; import { db } from '@/db'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; -import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth'; export type ResolvedSegmentAudio = { documentId: string; @@ -54,6 +53,7 @@ export async function resolveCompletedSegmentAudio( return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); } + const { resolveSegmentDocumentScope } = await import('@/lib/server/tts/segments-auth'); const scope = await resolveSegmentDocumentScope(request, documentId); if (scope instanceof Response) return scope; diff --git a/src/lib/server/tts/segments-auth.ts b/src/lib/server/tts/segments-auth.ts index e68b68b..ab10d35 100644 --- a/src/lib/server/tts/segments-auth.ts +++ b/src/lib/server/tts/segments-auth.ts @@ -9,7 +9,7 @@ import type { ReaderType } from '@/types/user-state'; export type ResolvedSegmentDocumentScope = { testNamespace: string | null; storageUserId: string; - authEnabled: boolean; + authEnabled: true; userId: string | null; isAnonymousUser: boolean; documentVersion: number; @@ -32,7 +32,7 @@ export async function resolveSegmentDocumentScope( const testNamespace = getOpenReaderTestNamespace(request.headers); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const storageUserId = ctxOrRes.userId ?? unclaimedUserId; - const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; + const allowedUserIds = [storageUserId, unclaimedUserId]; const rows = (await db .select({ @@ -55,7 +55,7 @@ export async function resolveSegmentDocumentScope( return { testNamespace, storageUserId: doc.userId, - authEnabled: ctxOrRes.authEnabled, + authEnabled: true, userId: ctxOrRes.userId, isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous), documentVersion: Number(doc.lastModified), diff --git a/src/lib/server/user/claim-data.ts b/src/lib/server/user/claim-data.ts index 89cc319..3a9f88c 100644 --- a/src/lib/server/user/claim-data.ts +++ b/src/lib/server/user/claim-data.ts @@ -10,8 +10,6 @@ import { } from '../audiobooks/blobstore'; import { isS3Configured } from '../storage/s3'; -import { isAuthEnabled } from '@/lib/server/auth/config'; - type AudiobookRow = { id: string; userId: string; @@ -89,7 +87,7 @@ async function moveAudiobookBlobScope( } export async function claimAnonymousData(userId: string, unclaimedUserId: string = UNCLAIMED_USER_ID, namespace: string | null = null) { - if (!isAuthEnabled() || !userId) { + if (!userId) { return { documents: 0, audiobooks: 0, preferences: 0, progress: 0 }; } @@ -122,7 +120,7 @@ export async function transferUserDocuments( // eslint-disable-next-line @typescript-eslint/no-explicit-any options?: { db?: any }, ): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const database = options?.db ?? db; @@ -150,7 +148,7 @@ export async function transferUserAudiobooks( toUserId: string, namespace: string | null = null, ): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const books = (await db @@ -188,7 +186,7 @@ export async function transferUserAudiobooks( } export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const fromRows = (await db @@ -226,7 +224,7 @@ export async function transferUserPreferences(fromUserId: string, toUserId: stri } export async function transferUserProgress(fromUserId: string, toUserId: string): Promise { - if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; + if (!fromUserId || !toUserId) return 0; if (fromUserId === toUserId) return 0; const fromRows = (await db diff --git a/src/middleware.ts b/src/middleware.ts index 28dc91d..050d589 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; import type { NextRequest } from 'next/server'; +import { getRequiredAuthEnv } from '@/lib/server/auth/config'; /** * Better Auth session cookie name (default prefix + session_token). @@ -34,12 +35,8 @@ function isPublicPath(pathname: string): boolean { return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix)); } -function isAuthEnabled(): boolean { - return !!(process.env.AUTH_SECRET && process.env.BASE_URL); -} - function isAnonymousAuthEnabled(): boolean { - if (!isAuthEnabled()) return false; + getRequiredAuthEnv(); const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS; return raw?.trim().toLowerCase() === 'true'; } @@ -88,10 +85,7 @@ export function middleware(request: NextRequest) { } } - // When auth is disabled entirely, let everything through. - if (!isAuthEnabled()) { - return NextResponse.next(); - } + getRequiredAuthEnv(); const { pathname } = request.nextUrl; diff --git a/tests/delete.spec.ts b/tests/delete.spec.ts index b42600c..18ef57e 100644 --- a/tests/delete.spec.ts +++ b/tests/delete.spec.ts @@ -1,5 +1,5 @@ import { test, expect } from '@playwright/test'; -import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName, deleteAllLocalDocuments, ensureDocumentsListed } from './helpers'; +import { setupTest, uploadFile, expectDocumentListed, expectNoDocumentLink, deleteDocumentByName } from './helpers'; test.describe('Document deletion flow', () => { test.beforeEach(async ({ page }, testInfo) => { @@ -23,30 +23,4 @@ test.describe('Document deletion flow', () => { await expectDocumentListed(page, 'sample.pdf'); }); - - test('deletes all local documents from Settings modal', async ({ page }) => { - // This test only applies when auth is NOT enabled, since with auth - // the bulk-delete UI lives in the delete-account flow instead. - test.skip( - Boolean(process.env.AUTH_SECRET && process.env.BASE_URL), - 'Bulk document deletion is part of the delete-account flow when auth is enabled', - ); - - // Upload multiple docs (PDF + EPUB) - await uploadFile(page, 'sample.pdf'); - await uploadFile(page, 'sample.epub'); - - // Verify both appear - await ensureDocumentsListed(page, ['sample.pdf', 'sample.epub']); - - // Delete all local documents via Settings - await deleteAllLocalDocuments(page); - - // Assert both documents are removed - await expectNoDocumentLink(page, 'sample.pdf'); - await expectNoDocumentLink(page, 'sample.epub'); - - // Uploader should be visible when no docs remain - await expect(page.locator('input[type=file]').first()).toBeVisible({ timeout: 10000 }); - }); }); diff --git a/tests/global-teardown.ts b/tests/global-teardown.ts index 1115dc0..fa6356f 100644 --- a/tests/global-teardown.ts +++ b/tests/global-teardown.ts @@ -66,7 +66,7 @@ function parseAudiobookScopeFromKey( } export default async function globalTeardown(): Promise { - // Always clear namespaced no-auth SQL rows from prior runs. + // Always clear namespaced unclaimed SQL rows from prior runs. await db.delete(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%')); await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); diff --git a/tests/helpers.ts b/tests/helpers.ts index 79d15b4..8c19785 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -5,10 +5,6 @@ import { createHash } from 'crypto'; const DIR = './tests/files/'; -function isAuthEnabledForTests() { - return Boolean(process.env.AUTH_SECRET && process.env.BASE_URL); -} - // Small util to safely use filenames inside regex patterns function escapeRegExp(input: string) { return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -250,37 +246,6 @@ export async function setupTest(page: Page, testInfo?: TestInfo) { await page.context().setExtraHTTPHeaders({ 'x-openreader-test-namespace': namespace }); } - // In no-auth mode, all tests in a worker share the same server-side unclaimed identity. - // Clear docs at setup to avoid cross-test collisions on duplicate filenames. - if (!isAuthEnabledForTests()) { - const headers = namespace ? { 'x-openreader-test-namespace': namespace } : undefined; - let cleared = false; - let authProtected = false; - let attempts = 0; - while (!cleared && attempts < 3) { - attempts += 1; - try { - const res = await page.request.delete('/api/documents', { ...(headers ? { headers } : {}) }); - // If this endpoint requires auth, we're not in no-auth mode for this run. - // Skip cleanup rather than hard-failing setup. - if (res.status() === 401 || res.status() === 403) { - authProtected = true; - break; - } - if (res.ok()) { - cleared = true; - break; - } - } catch { - // retry - } - await page.waitForTimeout(200); - } - if (!cleared && !authProtected) { - throw new Error('Failed to clear server documents before test setup'); - } - } - // Pre-seed consent to prevent the cookie banner from blocking interactions. await page.addInitScript(() => { try { diff --git a/tests/unit/audiobook-scope.vitest.spec.ts b/tests/unit/audiobook-scope.vitest.spec.ts index ca9705b..d53c9c2 100644 --- a/tests/unit/audiobook-scope.vitest.spec.ts +++ b/tests/unit/audiobook-scope.vitest.spec.ts @@ -2,20 +2,14 @@ import { describe, expect, test } from 'vitest'; import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope'; describe('audiobook scope selection', () => { - test('uses only unclaimed scope when auth is disabled', () => { - const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns'); - expect(result.preferredUserId).toBe('unclaimed::ns'); - expect(result.allowedUserIds).toEqual(['unclaimed::ns']); - }); - - test('includes both preferred and unclaimed scopes when auth is enabled', () => { - const result = buildAllowedAudiobookUserIds(true, 'user-123', 'unclaimed::ns'); + test('includes both preferred and unclaimed scopes', () => { + const result = buildAllowedAudiobookUserIds('user-123', 'unclaimed::ns'); expect(result.preferredUserId).toBe('user-123'); expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']); }); test('deduplicates preferred/unclaimed ids when they are the same', () => { - const result = buildAllowedAudiobookUserIds(true, 'unclaimed::ns', 'unclaimed::ns'); + const result = buildAllowedAudiobookUserIds('unclaimed::ns', 'unclaimed::ns'); expect(result.allowedUserIds).toEqual(['unclaimed::ns']); }); diff --git a/tests/unit/auth-config.vitest.spec.ts b/tests/unit/auth-config.vitest.spec.ts index eac330d..2b7d999 100644 --- a/tests/unit/auth-config.vitest.spec.ts +++ b/tests/unit/auth-config.vitest.spec.ts @@ -1,27 +1,19 @@ import { describe, expect, test } from 'vitest'; -import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; +import { getAuthBaseUrl, getRequiredAuthEnv, isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config'; import { withEnv } from './support/env'; describe('auth config contract', () => { - test('auth is enabled only when AUTH_SECRET and BASE_URL are both set', async () => { + test('reads required AUTH_SECRET and BASE_URL', async () => { await withEnv( { - AUTH_SECRET: undefined, - BASE_URL: undefined, - }, - async () => { - expect(isAuthEnabled()).toBe(false); - expect(getAuthBaseUrl()).toBeNull(); - }, - ); - - await withEnv( - { - AUTH_SECRET: 'unit-secret', + AUTH_SECRET: 'unit-secret-2', BASE_URL: 'http://localhost:3003', }, async () => { - expect(isAuthEnabled()).toBe(true); + expect(getRequiredAuthEnv()).toEqual({ + authSecret: 'unit-secret-2', + baseUrl: 'http://localhost:3003', + }); expect(getAuthBaseUrl()).toBe('http://localhost:3003'); }, ); @@ -49,12 +41,12 @@ describe('auth config contract', () => { }, ); - test('anonymous sessions are always disabled when auth is disabled', async () => { + test('anonymous session config returns false for non-true values', async () => { await withEnv( { - AUTH_SECRET: undefined, - BASE_URL: undefined, - USE_ANONYMOUS_AUTH_SESSIONS: 'true', + AUTH_SECRET: 'unit-secret', + BASE_URL: 'http://localhost:3003', + USE_ANONYMOUS_AUTH_SESSIONS: '1', }, async () => { expect(isAnonymousAuthSessionsEnabled()).toBe(false); @@ -63,16 +55,6 @@ describe('auth config contract', () => { }); test.each([ - { - title: 'returns false when auth is disabled', - env: { - AUTH_SECRET: undefined, - BASE_URL: undefined, - GITHUB_CLIENT_ID: 'id', - GITHUB_CLIENT_SECRET: 'secret', - }, - expected: false, - }, { title: 'returns false when GitHub client id is missing', env: { diff --git a/tests/unit/onboarding-flow.vitest.spec.ts b/tests/unit/onboarding-flow.vitest.spec.ts index 27eaaa6..6a23fd8 100644 --- a/tests/unit/onboarding-flow.vitest.spec.ts +++ b/tests/unit/onboarding-flow.vitest.spec.ts @@ -55,7 +55,7 @@ describe('onboarding flow resolver', () => { expect(step).toBe('changelog'); }); - test('resolves done when no steps are pending (auth and no-auth parity)', () => { + test('resolves done when no steps are pending', () => { const authStep = resolveNextOnboardingStep({ privacyRequired: true, privacyAccepted: true, diff --git a/tests/unit/setup-env.ts b/tests/unit/setup-env.ts new file mode 100644 index 0000000..89c72de --- /dev/null +++ b/tests/unit/setup-env.ts @@ -0,0 +1,7 @@ +if (!process.env.AUTH_SECRET?.trim()) { + process.env.AUTH_SECRET = 'vitest-auth-secret'; +} + +if (!process.env.BASE_URL?.trim()) { + process.env.BASE_URL = 'http://localhost:3003'; +} diff --git a/vitest.config.ts b/vitest.config.ts index 2bf3656..3489ba3 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,14 @@ import { defineConfig } from 'vitest/config'; import { fileURLToPath } from 'node:url'; +if (!process.env.AUTH_SECRET?.trim()) { + process.env.AUTH_SECRET = 'vitest-auth-secret'; +} + +if (!/^https?:\/\//.test(process.env.BASE_URL ?? '')) { + process.env.BASE_URL = 'http://localhost:3003'; +} + const srcDir = fileURLToPath(new URL('./src/', import.meta.url)); const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', import.meta.url)); const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url)); @@ -33,6 +41,7 @@ export default defineConfig({ name: 'openreader', environment: 'node', include: ['tests/unit/**/*.vitest.spec.ts'], + setupFiles: ['tests/unit/setup-env.ts'], }, }, {