From 5f28be5d586fe2bd969aef2aa815e08a19c3fc84 Mon Sep 17 00:00:00 2001 From: Richard R Date: Sun, 31 May 2026 12:09:37 -0600 Subject: [PATCH] refactor(env): remove legacy no-auth mode and enforce required auth env vars Eliminate all code paths, configuration, and documentation related to running without authentication. Require AUTH_SECRET and BASE_URL at startup, updating middleware, server logic, and runtime checks to assume auth is always enabled. Simplify onboarding, settings, and test helpers to reflect mandatory auth. Update environment examples, Docker and deployment docs, and CI/test configs. Remove no-auth-specific UI flows, test cases, and feature toggles. --- .env.example | 10 ++-- .github/workflows/playwright.yml | 3 ++ README.md | 2 +- docs-site/docs/configure/auth.md | 15 ++---- docs-site/docs/configure/database.md | 3 +- docs-site/docs/deploy/local-development.md | 19 +++---- docs-site/docs/deploy/vercel-deployment.md | 2 +- docs-site/docs/docker-quick-start.md | 16 +++--- docs-site/docs/introduction.md | 2 +- .../docs/reference/environment-variables.md | 14 +++--- package.json | 2 +- playwright.config.ts | 10 ---- scripts/openreader-entrypoint.mjs | 22 ++++---- src/app/(app)/layout.tsx | 5 +- src/app/(app)/signin/page.tsx | 11 ---- src/app/(app)/signup/page.tsx | 13 +---- src/app/api/account/delete/route.ts | 5 -- src/app/api/audiobook/chapter/route.ts | 14 +++--- src/app/api/audiobook/route.ts | 8 +-- src/app/api/audiobook/status/route.ts | 4 +- src/app/api/auth/[...all]/route.ts | 9 +--- .../api/documents/[id]/parsed/events/route.ts | 2 +- src/app/api/documents/[id]/parsed/route.ts | 4 +- src/app/api/documents/[id]/settings/route.ts | 2 +- .../api/documents/blob/get/fallback/route.ts | 2 +- .../api/documents/blob/get/presign/route.ts | 2 +- .../documents/blob/preview/fallback/route.ts | 2 +- src/app/api/documents/blob/preview/utils.ts | 2 +- src/app/api/documents/route.ts | 4 +- src/app/api/rate-limit/status/route.ts | 17 ------- src/app/api/tts/shared-providers/route.ts | 13 ++--- src/components/SettingsModal.tsx | 30 ++++------- src/components/auth/AuthLoader.tsx | 10 ++-- src/components/auth/RateLimitBanner.tsx | 10 ++-- src/components/auth/UserMenu.tsx | 4 +- src/contexts/AuthRateLimitContext.tsx | 28 +++-------- src/contexts/ConfigContext.tsx | 22 ++++---- src/contexts/OnboardingFlowContext.tsx | 50 +++++++------------ src/hooks/useAuthSession.ts | 27 ++-------- src/lib/server/admin/seed.ts | 23 --------- src/lib/server/admin/settings.ts | 6 --- src/lib/server/audiobooks/user-scope.ts | 5 -- src/lib/server/auth/admin.ts | 5 +- src/lib/server/auth/auth.ts | 23 +++------ src/lib/server/auth/config.ts | 43 ++++++++++------ src/lib/server/rate-limit/job-rate-limiter.ts | 3 +- src/lib/server/rate-limit/rate-limiter.ts | 7 +-- src/lib/server/tts/segments-audio.ts | 2 +- src/lib/server/tts/segments-auth.ts | 6 +-- src/lib/server/user/claim-data.ts | 12 ++--- src/middleware.ts | 12 ++--- tests/delete.spec.ts | 28 +---------- tests/global-teardown.ts | 2 +- tests/helpers.ts | 35 ------------- tests/unit/audiobook-scope.vitest.spec.ts | 12 ++--- tests/unit/auth-config.vitest.spec.ts | 40 ++++----------- tests/unit/onboarding-flow.vitest.spec.ts | 2 +- tests/unit/setup-env.ts | 7 +++ vitest.config.ts | 9 ++++ 59 files changed, 226 insertions(+), 476 deletions(-) create mode 100644 tests/unit/setup-env.ts 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'], }, }, {