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.
This commit is contained in:
Richard R 2026-05-31 12:09:37 -06:00
parent 4bcd90274c
commit 5f28be5d58
59 changed files with 226 additions and 476 deletions

View file

@ -11,16 +11,16 @@
# NOTE: On first boot, the server auto-seeds these values into a "default-openai" # 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-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 # 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_BASE=http://localhost:8880/v1
API_KEY=api_key_optional API_KEY=api_key_optional
# Auth configuration (recommended; required for admin features) # Auth configuration (required in v4+)
# (Optional) Auth is only enabled when AUTH_SECRET and BASE_URL are set
BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network) BASE_URL=http://localhost:3003 # Externally facing URL for this app (set to LAN IP for access from other devices on the network)
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32` AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted) AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`) # (Optional) Allow anonymous auth sessions (default: `false`)
# USE_ANONYMOUS_AUTH_SESSIONS=false # USE_ANONYMOUS_AUTH_SESSIONS=false
# (Optional) Sign in w/ GitHub Configuration # (Optional) Sign in w/ GitHub Configuration
# GITHUB_CLIENT_ID= # 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. # (Optional) Comma-separated list of emails that are auto-promoted to admin.
ADMIN_EMAILS= 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. # Defaults to SQLite at docstore/sqlite3.db when not set.
# POSTGRES_URL= # POSTGRES_URL=

View file

@ -11,7 +11,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
USE_EMBEDDED_WEED_MINI: true USE_EMBEDDED_WEED_MINI: true
ENABLE_TEST_NAMESPACE: true
BASE_URL: http://127.0.0.1:3003 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 S3_ENDPOINT: http://127.0.0.1:8333
steps: steps:
- uses: actions/checkout@v5 - uses: actions/checkout@v5

View file

@ -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). - 🎯 **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. - 🎧 **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. - 🗂️ **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 ## 🚀 Start Here

View file

@ -6,23 +6,21 @@ This page covers application-level configuration for provider access and authent
## Auth behavior ## Auth behavior
- Auth is enabled only when both `BASE_URL` and `AUTH_SECRET` are set. - `BASE_URL` and `AUTH_SECRET` are required at startup in v4+.
- Remove either value to disable auth.
- Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`. - Keep `AUTH_TRUSTED_ORIGINS` empty to trust only `BASE_URL`.
- Anonymous auth sessions are disabled by default. - Anonymous auth sessions are disabled by default.
- Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows. - Set `USE_ANONYMOUS_AUTH_SESSIONS=true` to enable anonymous session flows.
## Runtime modes ## 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, 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). - **Auth enabled, admin user**: full **Settings → Admin** access (shared providers + site features).
## Admin role ## 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 ```env
ADMIN_EMAILS=alice@example.com,bob@example.com 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. - `/` is a public landing/onboarding page and remains indexable.
- `/app` is the protected app home (document list and uploader UI). - `/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`. - Protected app routes continue to require auth; when anonymous sessions are disabled and no session exists, users are redirected to `/signin`.
## Related docs ## 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. - 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. - 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 ## Claim modal note
- You may still see old anonymous settings/progress available to claim from older deployments. - You may still see old anonymous settings/progress available to claim from older deployments.

View file

@ -54,6 +54,5 @@ For database variable behavior, see [Environment Variables](../reference/environ
## State sync summary ## State sync summary
- With auth enabled, settings and reading progress are stored in SQL and synced from the app. - 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.
- Sync is currently request-based (not realtime push invalidation). - Sync is currently request-based (not realtime push invalidation).

View file

@ -243,18 +243,7 @@ Use the same ownership split:
Use one of these `.env` mode templates: Use one of these `.env` mode templates:
<Tabs groupId="local-env-modes"> <Tabs groupId="local-env-modes">
<TabItem value="no-auth" label="No Auth (simple)" default> <TabItem value="auth-enabled" label="Auth Enabled" default>
```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.
```
</TabItem>
<TabItem value="auth-enabled" label="Auth Enabled">
```env ```env
API_BASE=http://host.docker.internal:8880/v1 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_BASE=http://host.docker.internal:8880/v1
API_KEY=none API_KEY=none
USE_EMBEDDED_WEED_MINI=false USE_EMBEDDED_WEED_MINI=false
BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
S3_BUCKET=your-bucket S3_BUCKET=your-bucket
S3_REGION=us-east-1 S3_REGION=us-east-1
S3_ACCESS_KEY_ID=your-access-key S3_ACCESS_KEY_ID=your-access-key
@ -301,6 +292,8 @@ S3_SECRET_ACCESS_KEY=your-secret-key
```env ```env
API_BASE=http://host.docker.internal:8880/v1 API_BASE=http://host.docker.internal:8880/v1
API_KEY=none API_KEY=none
BASE_URL=http://localhost:3003
AUTH_SECRET=<generate-with-openssl-rand-hex-32>
COMPUTE_WORKER_URL=http://localhost:8081 COMPUTE_WORKER_URL=http://localhost:8081
COMPUTE_WORKER_TOKEN=<same-token-used-by-worker> COMPUTE_WORKER_TOKEN=<same-token-used-by-worker>
USE_EMBEDDED_WEED_MINI=false 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 :::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 :::info

View file

@ -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. See [Environment Variables](../reference/environment-variables#runtime-json-seed-v4) for schema and examples.
:::warning Auth recommendation :::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 :::warning Rotating AUTH_SECRET invalidates admin-stored keys

View file

@ -33,7 +33,7 @@ OpenReader currently pins embedded SeaweedFS to `4.18` in CI and Docker builds.
<Tabs groupId="docker-start-mode"> <Tabs groupId="docker-start-mode">
<TabItem value="localhost" label="Localhost" default> <TabItem value="localhost" label="Localhost" default>
Persistent storage, embedded SeaweedFS `weed mini`, optional auth, optional library mount: Persistent storage, embedded SeaweedFS `weed mini`, required auth, optional library mount:
```bash ```bash
docker run --name openreader \ 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 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. - `-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 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. - `-e ADMIN_EMAILS=...`: (optional, requires auth) comma-separated emails auto-promoted to admin. Admins see the **Admin** tab in Settings.
</TabItem> </TabItem>
@ -95,21 +95,23 @@ What this command enables:
</TabItem> </TabItem>
<TabItem value="minimal" label="Minimal"> <TabItem value="minimal" label="Minimal">
Auth disabled, embedded storage ephemeral, no library import: Auth required, embedded storage ephemeral, no library import:
```bash ```bash
docker run --name openreader \ docker run --name openreader \
--restart unless-stopped \ --restart unless-stopped \
-p 3003:3003 \ -p 3003:3003 \
-p 8333:8333 \ -p 8333:8333 \
-e BASE_URL=http://localhost:3003 \
-e AUTH_SECRET=$(openssl rand -hex 32) \
ghcr.io/richardr1126/openreader:latest ghcr.io/richardr1126/openreader:latest
``` ```
What this command enables: 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. - 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. - 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.
</TabItem> </TabItem>
@ -117,9 +119,9 @@ What this command enables:
:::tip Quick Tips :::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**. - 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. - 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. - Use a `/app/docstore` mount if you want data to survive container/image replacement.
- Startup automatically runs DB/storage migrations via the shared entrypoint. - Startup automatically runs DB/storage migrations via the shared entrypoint.
::: :::

View file

@ -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) - 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 - 🎧 **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 - 🗂️ **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 - 🎨 **Customizable** — 13 built-in themes (light and dark palettes), per-user TTS settings, and document handling controls
## 🧭 Key Docs ## 🧭 Key Docs

View file

@ -6,7 +6,7 @@ toc_max_heading_level: 3
This page is the source-of-truth reference for OpenReader environment variables. This page is the source-of-truth reference for OpenReader environment variables.
:::note Recommended configuration path :::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. `API_BASE` / `API_KEY` are optional one-time provider bootstrap seeds.
Runtime site features are seeded with `RUNTIME_SEED_JSON` / `RUNTIME_SEED_JSON_PATH`. 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 | | `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_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` | | `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 | | `BASE_URL` | Auth | unset | Required at startup |
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth | | `AUTH_SECRET` | Auth | unset | Required at startup |
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins | | `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
| `USE_ANONYMOUS_AUTH_SESSIONS` | Auth | `false` | Set `true` to allow anonymous auth sessions | | `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 | | `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 ### 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` - Example: `http://localhost:3003` or `https://reader.example.com`
### AUTH_SECRET ### 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` - Generate with `openssl rand -hex 32`
### AUTH_TRUSTED_ORIGINS ### AUTH_TRUSTED_ORIGINS

View file

@ -15,7 +15,7 @@
"test:e2e": "playwright test", "test:e2e": "playwright test",
"test:unit": "vitest run", "test:unit": "vitest run",
"test:compute": "vitest run --project compute-core --project compute-worker", "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": "node drizzle/scripts/migrate.mjs",
"migrate-fs": "node scripts/migrate-fs-v2.mjs", "migrate-fs": "node scripts/migrate-fs-v2.mjs",
"migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true", "migrate-fs:dry-run": "node scripts/migrate-fs-v2.mjs --dry-run true",

View file

@ -1,15 +1,5 @@
import { defineConfig, devices } from '@playwright/test'; import { defineConfig, devices } from '@playwright/test';
import 'dotenv/config'; 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. * See https://playwright.dev/docs/test-configuration.

View file

@ -11,15 +11,6 @@ import * as dotenv from 'dotenv';
function loadEnvFiles() { function loadEnvFiles() {
const cwd = process.cwd(); 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 envPath = path.join(cwd, '.env');
const envLocalPath = path.join(cwd, '.env.local'); const envLocalPath = path.join(cwd, '.env.local');
@ -46,6 +37,18 @@ function withDefault(value, fallback) {
return value && value.trim() ? value.trim() : 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) { function isPrivateIPv4(address) {
if (!address) return false; if (!address) return false;
if (address.startsWith('10.')) return true; if (address.startsWith('10.')) return true;
@ -328,6 +331,7 @@ async function main() {
const runtimeEnv = { ...process.env }; const runtimeEnv = { ...process.env };
runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty'); runtimeEnv.LOG_FORMAT = withDefault(runtimeEnv.LOG_FORMAT, 'pretty');
requireAuthEnv(runtimeEnv);
let weedProc = null; let weedProc = null;
let weedExitPromise = Promise.resolve(); let weedExitPromise = Promise.resolve();
let natsProc = null; let natsProc = null;

View file

@ -3,7 +3,7 @@ import type { ReactNode } from 'react';
import { Toaster } from 'react-hot-toast'; import { Toaster } from 'react-hot-toast';
import { Providers } from '@/app/providers'; 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'; export const dynamic = 'force-dynamic';
@ -22,14 +22,13 @@ export const metadata: Metadata = {
}; };
export default function AppLayout({ children }: { children: ReactNode }) { export default function AppLayout({ children }: { children: ReactNode }) {
const authEnabled = isAuthEnabled();
const authBaseUrl = getAuthBaseUrl(); const authBaseUrl = getAuthBaseUrl();
const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled(); const allowAnonymousAuthSessions = isAnonymousAuthSessionsEnabled();
const githubAuthEnabled = isGithubAuthEnabled(); const githubAuthEnabled = isGithubAuthEnabled();
return ( return (
<Providers <Providers
authEnabled={authEnabled} authEnabled
authBaseUrl={authBaseUrl} authBaseUrl={authBaseUrl}
allowAnonymousAuthSessions={allowAnonymousAuthSessions} allowAnonymousAuthSessions={allowAnonymousAuthSessions}
githubAuthEnabled={githubAuthEnabled} githubAuthEnabled={githubAuthEnabled}

View file

@ -37,13 +37,6 @@ function SignInContent() {
const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous; const isAnyLoading = loadingEmail || loadingGithub || loadingAnonymous;
// Check if auth is enabled, redirect home if not
useEffect(() => {
if (!authEnabled) {
router.push('/app');
}
}, [router, authEnabled]);
const validateEmail = (email: string): boolean => { const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}; };
@ -121,10 +114,6 @@ function SignInContent() {
} }
}; };
if (!authEnabled) {
return null;
}
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">
<Suspense fallback={null}> <Suspense fallback={null}>

View file

@ -1,6 +1,6 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useState } from 'react';
import { Button, Input } from '@headlessui/react'; import { Button, Input } from '@headlessui/react';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
@ -24,13 +24,6 @@ export default function SignUpPage() {
const enableUserSignups = useFeatureFlag('enableUserSignups'); const enableUserSignups = useFeatureFlag('enableUserSignups');
const { refresh: refreshRateLimit } = useAuthRateLimit(); 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 => { const validateEmail = (email: string): boolean => {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
}; };
@ -109,10 +102,6 @@ export default function SignUpPage() {
} }
}; };
if (!authEnabled) {
return null;
}
if (!enableUserSignups) { if (!enableUserSignups) {
return ( return (
<div className="min-h-screen flex items-center justify-center p-4 bg-background"> <div className="min-h-screen flex items-center justify-center p-4 bg-background">

View file

@ -1,17 +1,12 @@
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace'; import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { deleteUserStorageData } from '@/lib/server/user/data-cleanup'; import { deleteUserStorageData } from '@/lib/server/user/data-cleanup';
import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, hashForLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response'; import { errorResponse } from '@/lib/server/errors/next-response';
export async function DELETE() { export async function DELETE() {
if (!isAuthEnabled() || !auth) {
return NextResponse.json({ error: 'Authentication disabled' }, { status: 403 });
}
const reqHeaders = await headers(); const reqHeaders = await headers();
const session = await auth.api.getSession({ const session = await auth.api.getSession({

View file

@ -286,11 +286,11 @@ export async function POST(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled, user } = ctxOrRes; const { userId, user } = ctxOrRes;
const runtimeConfig = await getResolvedRuntimeConfig(); const runtimeConfig = await getResolvedRuntimeConfig();
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const bookId = data.bookId || randomUUID(); const bookId = data.bookId || randomUUID();
if (!isSafeId(bookId)) { if (!isSafeId(bookId)) {
@ -540,7 +540,7 @@ export async function POST(request: NextRequest) {
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated, ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
}); });
if (authEnabled && userId && ttsRateLimitEnabled) { if (userId && ttsRateLimitEnabled) {
const isAnonymous = Boolean(user?.isAnonymous); const isAnonymous = Boolean(user?.isAnonymous);
const charCount = data.text.length; const charCount = data.text.length;
const ip = getClientIp(request); const ip = getClientIp(request);
@ -809,10 +809,10 @@ export async function GET(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes; const { userId } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const existingBookRows = await db const existingBookRows = await db
.select({ userId: audiobooks.userId }) .select({ userId: audiobooks.userId })
.from(audiobooks) .from(audiobooks)
@ -906,10 +906,10 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes; const { userId } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const existingBookRows = await db const existingBookRows = await db
.select({ userId: audiobooks.userId }) .select({ userId: audiobooks.userId })
.from(audiobooks) .from(audiobooks)

View file

@ -169,10 +169,10 @@ export async function GET(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes; const { userId } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const existingBookRows = await db const existingBookRows = await db
.select({ userId: audiobooks.userId }) .select({ userId: audiobooks.userId })
.from(audiobooks) .from(audiobooks)
@ -404,10 +404,10 @@ export async function DELETE(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes; const { userId } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const existingBookRows = await db const existingBookRows = await db
.select({ userId: audiobooks.userId }) .select({ userId: audiobooks.userId })
.from(audiobooks) .from(audiobooks)

View file

@ -74,10 +74,10 @@ export async function GET(request: NextRequest) {
const ctxOrRes = await requireAuthContext(request); const ctxOrRes = await requireAuthContext(request);
if (ctxOrRes instanceof Response) return ctxOrRes; if (ctxOrRes instanceof Response) return ctxOrRes;
const { userId, authEnabled } = ctxOrRes; const { userId } = ctxOrRes;
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(authEnabled, userId, unclaimedUserId); const { preferredUserId, allowedUserIds } = buildAllowedAudiobookUserIds(userId, unclaimedUserId);
const existingBookRows = await db const existingBookRows = await db
.select({ userId: audiobooks.userId }) .select({ userId: audiobooks.userId })
.from(audiobooks) .from(audiobooks)

View file

@ -1,11 +1,6 @@
import { auth } from "@/lib/server/auth/auth"; // path to your auth file import { auth } from "@/lib/server/auth/auth"; // path to your auth file
import { toNextJsHandler } from "better-auth/next-js"; import { toNextJsHandler } from "better-auth/next-js";
const handlers = auth const handlers = toNextJsHandler(auth);
? toNextJsHandler(auth)
: {
POST: async () => new Response("Auth disabled", { status: 404 }),
GET: async () => new Response("Auth disabled", { status: 404 })
};
export const { POST, GET } = handlers; export const { POST, GET } = handlers;

View file

@ -162,7 +162,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const storageUserIdHash = hashForLog(storageUserId); const storageUserIdHash = hashForLog(storageUserId);
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const row = await loadPreferredRow({ const row = await loadPreferredRow({
documentId: id, documentId: id,

View file

@ -208,7 +208,7 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const rows = await loadRows({ documentId: id, allowedUserIds }); const rows = await loadRows({ documentId: id, allowedUserIds });
const row = pickPreferredRow(rows, storageUserId); 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 testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const rows = await loadRows({ documentId: id, allowedUserIds }); const rows = await loadRows({ documentId: id, allowedUserIds });
const row = pickPreferredRow(rows, storageUserId); const row = pickPreferredRow(rows, storageUserId);

View file

@ -44,7 +44,7 @@ async function resolveDocumentAccess(req: NextRequest, documentId: string): Prom
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = authCtxOrRes.userId ?? unclaimedUserId; const storageUserId = authCtxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = authCtxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const rows = await db const rows = await db
.select({ userId: documents.userId }) .select({ userId: documents.userId })

View file

@ -38,7 +38,7 @@ export async function GET(req: NextRequest) {
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const url = new URL(req.url); const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase(); const id = (url.searchParams.get('id') || '').trim().toLowerCase();

View file

@ -28,7 +28,7 @@ export async function GET(req: NextRequest) {
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const url = new URL(req.url); const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase(); const id = (url.searchParams.get('id') || '').trim().toLowerCase();

View file

@ -56,7 +56,7 @@ export async function GET(req: NextRequest) {
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const url = new URL(req.url); const url = new URL(req.url);
const id = (url.searchParams.get('id') || '').trim().toLowerCase(); const id = (url.searchParams.get('id') || '').trim().toLowerCase();

View file

@ -44,7 +44,7 @@ export async function validatePreviewRequest(req: NextRequest): Promise<Validate
// Deduplicate allowedUserIds // Deduplicate allowedUserIds
const allowedUserIds = Array.from(new Set( const allowedUserIds = Array.from(new Set(
ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId] [storageUserId, unclaimedUserId]
)); ));
const url = new URL(req.url); const url = new URL(req.url);

View file

@ -228,7 +228,7 @@ export async function GET(req: NextRequest) {
const testNamespace = getOpenReaderTestNamespace(req.headers); const testNamespace = getOpenReaderTestNamespace(req.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const url = new URL(req.url); const url = new URL(req.url);
const idsParam = url.searchParams.get('ids'); const idsParam = url.searchParams.get('ids');
@ -325,7 +325,7 @@ export async function DELETE(req: NextRequest) {
); );
} }
if (ctxOrRes.authEnabled && wantsUnclaimed && ctxOrRes.user?.isAnonymous) { if (wantsUnclaimed && ctxOrRes.user?.isAnonymous) {
return NextResponse.json({ error: 'Forbidden' }, { status: 403 }); return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
} }

View file

@ -2,7 +2,6 @@ import { NextResponse, type NextRequest } from 'next/server';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter'; import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { headers } from 'next/headers'; import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip'; import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id'; import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps'; import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
@ -27,22 +26,6 @@ export async function GET(req: NextRequest) {
}); });
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit; const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
const resetTimeMs = getUtcResetTimeMs();
return NextResponse.json({
allowed: true,
currentCount: 0,
// Avoid Infinity in JSON (serializes to null). This value is never shown
// because authEnabled=false, but we keep it finite to prevent surprises.
limit: Number.MAX_SAFE_INTEGER,
remainingChars: Number.MAX_SAFE_INTEGER,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: false
});
}
// Get session from auth // Get session from auth
const session = await auth.api.getSession({ const session = await auth.api.getSession({
headers: await headers() headers: await headers()

View file

@ -1,22 +1,19 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { auth } from '@/lib/server/auth/auth'; import { auth } from '@/lib/server/auth/auth';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { listAdminProviders, toPublic } from '@/lib/server/admin/providers'; import { listAdminProviders, toPublic } from '@/lib/server/admin/providers';
import { errorToLog, serverLogger } from '@/lib/server/logger'; import { errorToLog, serverLogger } from '@/lib/server/logger';
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
/** /**
* Public list of admin-configured TTS providers. Auth-gated when auth is * Public list of admin-configured TTS providers. Auth-gated and never returns
* enabled. Never returns keys, base URLs, or ciphertext only the data the * keys, base URLs, or ciphertext only the data the
* client needs to render the provider picker. * client needs to render the provider picker.
*/ */
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
if (isAuthEnabled()) { const session = await auth.api.getSession({ headers: req.headers });
const session = await auth?.api.getSession({ headers: req.headers }); if (!session?.user) {
if (!session?.user) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
} }
try { try {

View file

@ -199,7 +199,7 @@ export function SettingsModal({
const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false); const [showDeleteDocsConfirm, setShowDeleteDocsConfirm] = useState(false);
const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false); const [showDeleteAccountConfirm, setShowDeleteAccountConfirm] = useState(false);
const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation(); const { progress, setProgress, estimatedTimeRemaining } = useTimeEstimation();
const { authEnabled, baseUrl: authBaseUrl } = useAuthConfig(); const { baseUrl: authBaseUrl } = useAuthConfig();
const { data: session } = useAuthSession(); const { data: session } = useAuthSession();
const { changelogOpenSignal } = useOnboardingFlow(); const { changelogOpenSignal } = useOnboardingFlow();
const router = useRouter(); const router = useRouter();
@ -470,11 +470,10 @@ export function SettingsModal({
if (section.id === 'api' && !enableTTSProvidersTab) { if (section.id === 'api' && !enableTTSProvidersTab) {
return false; return false;
} }
if (section.authOnly && !authEnabled) return false;
if (section.adminOnly && !isAdmin) return false; if (section.adminOnly && !isAdmin) return false;
return true; return true;
}), }),
[authEnabled, isAdmin, enableTTSProvidersTab] [isAdmin, enableTTSProvidersTab]
); );
useEffect(() => { useEffect(() => {
@ -556,14 +555,12 @@ export function SettingsModal({
</Button> </Button>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
{authEnabled && ( <Button
<Button onClick={() => showPrivacyModal({ authEnabled: true })}
onClick={() => showPrivacyModal({ authEnabled })} className="text-sm font-medium text-muted hover:text-accent transition-colors"
className="text-sm font-medium text-muted hover:text-accent transition-colors" >
> Privacy
Privacy </Button>
</Button>
)}
</div> </div>
</div> </div>
@ -1161,15 +1158,6 @@ export function SettingsModal({
> >
Clear cache Clear cache
</Button> </Button>
{enableDestructiveDelete && !authEnabled && (
<Button
onClick={() => setShowDeleteDocsConfirm(true)}
disabled={isBusy}
className={buttonClass({ variant: 'danger', size: 'md' })}
>
Delete all data
</Button>
)}
</div> </div>
</div> </div>
</div> </div>
@ -1208,7 +1196,7 @@ export function SettingsModal({
)} )}
{/* Account Section */} {/* Account Section */}
{activeSection === 'account' && authEnabled && ( {activeSection === 'account' && (
<div className="space-y-2"> <div className="space-y-2">
{/* Session info */} {/* Session info */}
<div className="rounded-lg bg-background border border-offbase p-4 space-y-2"> <div className="rounded-lg bg-background border border-offbase p-4 space-y-2">

View file

@ -113,7 +113,6 @@ export function AuthLoader({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
const checkStatus = async () => { const checkStatus = async () => {
if (!authEnabled) return;
if (isPending) return; if (isPending) return;
if (session) { if (session) {
@ -236,17 +235,16 @@ export function AuthLoader({ children }: { children: ReactNode }) {
]); ]);
useEffect(() => { useEffect(() => {
if (!authEnabled) return;
if (sessionError) { if (sessionError) {
console.warn('[AuthLoader] useSession error', sessionError); console.warn('[AuthLoader] useSession error', sessionError);
} }
}, [authEnabled, sessionError]); }, [sessionError]);
const shouldBlockForProtectedNoSession = const shouldBlockForProtectedNoSession =
authEnabled && !allowAnonymousAuthSessions && !isAuthPage && !session; !allowAnonymousAuthSessions && !isAuthPage && !session;
const shouldBlockForDisallowedAnonymous = const shouldBlockForDisallowedAnonymous =
authEnabled && !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous); !allowAnonymousAuthSessions && Boolean(session?.user?.isAnonymous);
const isLoading = authEnabled && ( const isLoading = (
(allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) || (allowAnonymousAuthSessions && (isPending || isAutoLoggingIn || !session)) ||
(!allowAnonymousAuthSessions && !isAuthPage && ( (!allowAnonymousAuthSessions && !isAuthPage && (
isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous isPending || isRedirecting || shouldBlockForProtectedNoSession || shouldBlockForDisallowedAnonymous

View file

@ -9,11 +9,10 @@ interface RateLimitBannerProps {
} }
export function RateLimitBanner({ className = '' }: RateLimitBannerProps) { export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, timeUntilReset, authEnabled } = useAuthRateLimit(); const { status, isAtLimit, timeUntilReset } = useAuthRateLimit();
const enableUserSignups = useFeatureFlag('enableUserSignups'); const enableUserSignups = useFeatureFlag('enableUserSignups');
// Don't show banner if auth is not enabled or if not at limit if (!status?.authEnabled || !isAtLimit) {
if (!authEnabled || !status?.authEnabled || !isAtLimit) {
return null; return null;
} }
@ -51,10 +50,9 @@ export function RateLimitBanner({ className = '' }: RateLimitBannerProps) {
* Compact version for inline display * Compact version for inline display
*/ */
export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) { export function RateLimitIndicator({ className = '' }: RateLimitBannerProps) {
const { status, isAtLimit, authEnabled } = useAuthRateLimit(); const { status, isAtLimit } = useAuthRateLimit();
// Don't show if auth is not enabled if (!status?.authEnabled) {
if (!authEnabled || !status?.authEnabled) {
return null; return null;
} }

View file

@ -18,12 +18,12 @@ export function UserMenu({
className?: string; className?: string;
variant?: UserMenuVariant; variant?: UserMenuVariant;
}) { }) {
const { authEnabled, baseUrl } = useAuthConfig(); const { baseUrl } = useAuthConfig();
const enableUserSignups = useFeatureFlag('enableUserSignups'); const enableUserSignups = useFeatureFlag('enableUserSignups');
const { data: session, isPending } = useAuthSession(); const { data: session, isPending } = useAuthSession();
const router = useRouter(); const router = useRouter();
if (!authEnabled || isPending) return null; if (isPending) return null;
const handleDisconnectAccount = async () => { const handleDisconnectAccount = async () => {
const client = getAuthClient(baseUrl); const client = getAuthClient(baseUrl);

View file

@ -140,26 +140,13 @@ export function AuthRateLimitProvider({
} }
return parseRateLimitStatus(await response.json()); return parseRateLimitStatus(await response.json());
}, },
enabled: authEnabled, enabled: true,
retry: 0, retry: 0,
}); });
const status = authEnabled const status = queryStatus ?? null;
? (queryStatus ?? null) const loading = isPending || isFetching;
: { const error = queryError instanceof Error ? queryError.message : queryError ? 'Unknown error' : 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;
useEffect(() => { useEffect(() => {
if (!queryError) return; if (!queryError) return;
@ -167,15 +154,13 @@ export function AuthRateLimitProvider({
}, [queryError]); }, [queryError]);
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!authEnabled) return;
await refetch(); await refetch();
}, [authEnabled, refetch]); }, [refetch]);
const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : ''; const timeUntilReset = status ? calculateTimeUntilReset(status.resetTimeMs) : '';
const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false; const isAtLimit = status ? (status.remainingChars <= 0 || !status.allowed) : false;
const incrementCount = useCallback((charCount: number) => { const incrementCount = useCallback((charCount: number) => {
if (!authEnabled) return;
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prevStatus) => { queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prevStatus) => {
if (!prevStatus) return prevStatus; if (!prevStatus) return prevStatus;
@ -189,7 +174,7 @@ export function AuthRateLimitProvider({
allowed: newRemainingChars > 0 allowed: newRemainingChars > 0
}; };
}); });
}, [authEnabled, queryClient]); }, [queryClient]);
const onTTSStart = useCallback(() => { const onTTSStart = useCallback(() => {
pendingTTSRef.current += 1; pendingTTSRef.current += 1;
@ -239,7 +224,6 @@ export function AuthRateLimitProvider({
onTTSStart, onTTSStart,
onTTSComplete, onTTSComplete,
triggerRateLimit: () => { triggerRateLimit: () => {
if (!authEnabled) return;
queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prev) => queryClient.setQueryData<RateLimitStatus | null>(RATE_LIMIT_QUERY_KEY, (prev) =>
prev ? { ...prev, remainingChars: 0, allowed: false } : null, prev ? { ...prev, remainingChars: 0, allowed: false } : null,
); );

View file

@ -9,7 +9,6 @@ import { resolveEffectiveProviderType, resolveProviderDefaults } from '@/lib/sha
import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state'; import { scheduleUserPreferencesSync, cancelPendingPreferenceSync, getUserPreferences, putUserPreferences } from '@/lib/client/api/user-state';
import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state'; import { SYNCED_PREFERENCE_KEYS, type SyncedPreferenceKey, type SyncedPreferencesPatch } from '@/types/user-state';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useFeatureFlag } from '@/contexts/RuntimeConfigContext'; import { useFeatureFlag } from '@/contexts/RuntimeConfigContext';
import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences'; import { buildSyncedPreferencePatch } from '@/lib/client/config/preferences';
import { applyConfigUpdate } from '@/lib/client/config/updates'; import { applyConfigUpdate } from '@/lib/client/config/updates';
@ -70,7 +69,6 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
const didRunStartupMigrations = useRef(false); const didRunStartupMigrations = useRef(false);
const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null); const didAttemptInitialPreferenceSeedForSession = useRef<string | null>(null);
const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []); const syncedPreferenceKeys = useMemo(() => new Set<string>(SYNCED_PREFERENCE_KEYS), []);
const { authEnabled } = useAuthConfig();
const { providers: sharedProviders } = useSharedProviders(); const { providers: sharedProviders } = useSharedProviders();
const { data: sessionData, isPending: isSessionPending } = useAuthSession(); const { data: sessionData, isPending: isSessionPending } = useAuthSession();
const sessionKey = sessionData?.user?.id ?? 'no-session'; const sessionKey = sessionData?.user?.id ?? 'no-session';
@ -83,7 +81,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [sharedProviders]); }, [sharedProviders]);
const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => { const queueSyncedPreferencePatch = useCallback((patch: Partial<AppConfigValues>) => {
if (!authEnabled || sessionKey === 'no-session') return; if (sessionKey === 'no-session') return;
const syncedPatch: SyncedPreferencesPatch = {}; const syncedPatch: SyncedPreferencesPatch = {};
for (const key of SYNCED_PREFERENCE_KEYS) { for (const key of SYNCED_PREFERENCE_KEYS) {
@ -94,7 +92,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
} }
if (Object.keys(syncedPatch).length === 0) return; if (Object.keys(syncedPatch).length === 0) return;
scheduleUserPreferencesSync(syncedPatch, sessionKey); scheduleUserPreferencesSync(syncedPatch, sessionKey);
}, [authEnabled, sessionKey]); }, [sessionKey]);
// Cancel pending/in-flight preference syncs whenever the session changes or on unmount. // Cancel pending/in-flight preference syncs whenever the session changes or on unmount.
useEffect(() => { useEffect(() => {
@ -157,7 +155,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [isDBReady]); }, [isDBReady]);
const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => { const refreshSyncedPreferencesFromServer = useCallback(async (signal?: AbortSignal) => {
if (!isDBReady || !authEnabled) return; if (!isDBReady) return;
try { try {
const remote = await getUserPreferences({ signal }); const remote = await getUserPreferences({ signal });
if (!remote?.hasStoredPreferences) return; if (!remote?.hasStoredPreferences) return;
@ -167,20 +165,20 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
if ((error as Error)?.name === 'AbortError') return; if ((error as Error)?.name === 'AbortError') return;
console.warn('Failed to load synced preferences:', error); console.warn('Failed to load synced preferences:', error);
} }
}, [isDBReady, authEnabled]); }, [isDBReady]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !authEnabled || isSessionPending) return; if (!isDBReady || isSessionPending) return;
const controller = new AbortController(); const controller = new AbortController();
refreshSyncedPreferencesFromServer(controller.signal).catch((error) => { refreshSyncedPreferencesFromServer(controller.signal).catch((error) => {
if ((error as Error)?.name === 'AbortError') return; if ((error as Error)?.name === 'AbortError') return;
console.warn('Synced preferences refresh failed:', error); console.warn('Synced preferences refresh failed:', error);
}); });
return () => controller.abort(); return () => controller.abort();
}, [isDBReady, authEnabled, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]); }, [isDBReady, isSessionPending, sessionKey, refreshSyncedPreferencesFromServer]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !authEnabled) return; if (!isDBReady) return;
let activeController: AbortController | null = null; let activeController: AbortController | null = null;
const onFocus = () => { const onFocus = () => {
if (activeController) activeController.abort(); if (activeController) activeController.abort();
@ -195,7 +193,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
window.removeEventListener('focus', onFocus); window.removeEventListener('focus', onFocus);
if (activeController) activeController.abort(); if (activeController) activeController.abort();
}; };
}, [isDBReady, authEnabled, refreshSyncedPreferencesFromServer]); }, [isDBReady, refreshSyncedPreferencesFromServer]);
const appConfig = useLiveQuery( const appConfig = useLiveQuery(
async () => { async () => {
@ -301,7 +299,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]); }, [showAllProviderModels, isDBReady, appConfig, sharedProviders, providerResetDefaults.providerRef, queueSyncedPreferencePatch]);
useEffect(() => { useEffect(() => {
if (!isDBReady || !authEnabled || !appConfig || isSessionPending) return; if (!isDBReady || !appConfig || isSessionPending) return;
if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return; if (didAttemptInitialPreferenceSeedForSession.current === sessionKey) return;
didAttemptInitialPreferenceSeedForSession.current = sessionKey; didAttemptInitialPreferenceSeedForSession.current = sessionKey;
@ -330,7 +328,7 @@ export function ConfigProvider({ children }: { children: ReactNode }) {
}); });
return () => controller.abort(); return () => controller.abort();
}, [isDBReady, authEnabled, appConfig, isSessionPending, sessionKey]); }, [isDBReady, appConfig, isSessionPending, sessionKey]);
// Destructure for convenience and to match context shape // Destructure for convenience and to match context shape
const { const {

View file

@ -4,7 +4,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal'; import ClaimDataModal, { type ClaimableCounts } from '@/components/auth/ClaimDataModal';
import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal'; import { DexieMigrationModal } from '@/components/documents/DexieMigrationModal';
import { PrivacyModal } from '@/components/PrivacyModal'; import { PrivacyModal } from '@/components/PrivacyModal';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { useAuthSession } from '@/hooks/useAuthSession'; import { useAuthSession } from '@/hooks/useAuthSession';
import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext'; import { useRuntimeConfig } from '@/contexts/RuntimeConfigContext';
import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie'; import { getAllEpubDocuments, getAllHtmlDocuments, getAllPdfDocuments, getAppConfig, setFirstVisit } from '@/lib/client/dexie';
@ -105,7 +104,6 @@ async function readLocalOnboardingSnapshot(): Promise<LocalOnboardingSnapshot> {
} }
export function OnboardingFlowProvider({ children }: { children: ReactNode }) { export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
const { authEnabled } = useAuthConfig();
const { data: session, isPending: isSessionPending } = useAuthSession(); const { data: session, isPending: isSessionPending } = useAuthSession();
const runtimeConfig = useRuntimeConfig(); const runtimeConfig = useRuntimeConfig();
const user = session?.user as { id?: string; isAnonymous?: boolean } | undefined; 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 runOnceFlow = useCallback(async () => {
const local = await readLocalOnboardingSnapshot(); const local = await readLocalOnboardingSnapshot();
const privacyRequired = authEnabled; const privacyRequired = true;
const privacyAccepted = !privacyRequired || local.privacyAccepted; const privacyAccepted = !privacyRequired || local.privacyAccepted;
const isClaimEligible = Boolean( const isClaimEligible = Boolean(
authEnabled userId
&& userId
&& !isAnonymous && !isAnonymous
&& !claimDismissedUsersRef.current.has(userId), && !claimDismissedUsersRef.current.has(userId),
); );
@ -199,7 +196,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
pendingChangelogOpenRef.current = false; pendingChangelogOpenRef.current = false;
setChangelogOpenSignal((value) => value + 1); setChangelogOpenSignal((value) => value + 1);
} }
}, [authEnabled, isAnonymous, userId]); }, [isAnonymous, userId]);
runOnceFlowRef.current = runOnceFlow; runOnceFlowRef.current = runOnceFlow;
@ -223,12 +220,9 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
void runFlow(); void runFlow();
}, [authEnabled, isAnonymous, runFlow, userId]); }, [isAnonymous, runFlow, userId]);
useEffect(() => { useEffect(() => {
if (!authEnabled) {
return;
}
const onPrivacyAccepted = () => { const onPrivacyAccepted = () => {
void runFlow(); void runFlow();
}; };
@ -236,15 +230,11 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
return () => { return () => {
window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted); window.removeEventListener('openreader:privacyAccepted', onPrivacyAccepted);
}; };
}, [authEnabled, runFlow]); }, [runFlow]);
useEffect(() => { useEffect(() => {
if (!authEnabled) {
return () => { };
}
return scheduleChangelogCheck({ return scheduleChangelogCheck({
authEnabled, authEnabled: true,
isSessionPending, isSessionPending,
sessionUserId: userId, sessionUserId: userId,
appVersion: runtimeConfig.appVersion, appVersion: runtimeConfig.appVersion,
@ -258,7 +248,7 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
delayMs: 120, delayMs: 120,
retryDelayMs: 400, retryDelayMs: 400,
}); });
}, [authEnabled, isSessionPending, runFlow, runtimeConfig.appVersion, userId]); }, [isSessionPending, runFlow, runtimeConfig.appVersion, userId]);
const contextValue = useMemo<OnboardingFlowContextValue>(() => ({ const contextValue = useMemo<OnboardingFlowContextValue>(() => ({
changelogOpenSignal, changelogOpenSignal,
@ -267,21 +257,17 @@ export function OnboardingFlowProvider({ children }: { children: ReactNode }) {
return ( return (
<OnboardingFlowContext.Provider value={contextValue}> <OnboardingFlowContext.Provider value={contextValue}>
{children} {children}
{authEnabled && ( <PrivacyModal
<PrivacyModal isOpen={activeBlockingModal === 'privacy'}
isOpen={activeBlockingModal === 'privacy'} onAccept={handlePrivacyAccepted}
onAccept={handlePrivacyAccepted} onDismiss={() => { }}
onDismiss={() => { }} />
/> <ClaimDataModal
)} isOpen={activeBlockingModal === 'claim'}
{authEnabled && ( claimableCounts={claimableCounts}
<ClaimDataModal onDismiss={handleClaimComplete}
isOpen={activeBlockingModal === 'claim'} onClaimed={handleClaimComplete}
claimableCounts={claimableCounts} />
onDismiss={handleClaimComplete}
onClaimed={handleClaimComplete}
/>
)}
<DexieMigrationModal <DexieMigrationModal
isOpen={activeBlockingModal === 'migration'} isOpen={activeBlockingModal === 'migration'}
localCount={migrationCounts.localCount} localCount={migrationCounts.localCount}

View file

@ -4,35 +4,18 @@ import { useMemo } from 'react';
import { useAuthConfig } from '@/contexts/AuthRateLimitContext'; import { useAuthConfig } from '@/contexts/AuthRateLimitContext';
import { getAuthClient } from '@/lib/client/auth-client'; import { getAuthClient } from '@/lib/client/auth-client';
type SessionHookResult = ReturnType<ReturnType<typeof getAuthClient>['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<typeof getAuthClient>;
/** /**
* Hook for session that uses the correct baseUrl from context. * 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() { export function useAuthSession() {
const { baseUrl, authEnabled } = useAuthConfig(); const { baseUrl } = useAuthConfig();
const client = useMemo(() => { const client = useMemo(() => {
if (!authEnabled || !baseUrl) return STUB_CLIENT; if (!baseUrl) {
throw new Error('Auth base URL is required');
}
return getAuthClient(baseUrl); return getAuthClient(baseUrl);
}, [baseUrl, authEnabled]); }, [baseUrl]);
return client.useSession(); return client.useSession();
} }

View file

@ -301,29 +301,6 @@ async function seedDefaultAdminProviderFromEnvFallback(): Promise<void> {
try { try {
enc = encryptSecret(apiKey); enc = encryptSecret(apiKey);
} catch (error) { } 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, { logDegraded(serverLogger, {
event: 'admin.seed.provider_key_encrypt.failed', event: 'admin.seed.provider_key_encrypt.failed',
msg: 'Failed to encrypt default provider API key', msg: 'Failed to encrypt default provider API key',

View file

@ -1,7 +1,6 @@
import { and, eq } from 'drizzle-orm'; import { and, eq } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { adminProviders, adminSettings } from '@/db/schema'; import { adminProviders, adminSettings } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { serverLogger } from '@/lib/server/logger'; import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging'; import { logDegraded } from '@/lib/server/errors/logging';
@ -137,11 +136,6 @@ function buildDefaults(): RuntimeConfig {
for (const key of RUNTIME_KEYS) { for (const key of RUNTIME_KEYS) {
(out as Record<string, unknown>)[key] = RUNTIME_CONFIG_SCHEMA[key].default; (out as Record<string, unknown>)[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; return out;
} }

View file

@ -1,12 +1,7 @@
export function buildAllowedAudiobookUserIds( export function buildAllowedAudiobookUserIds(
authEnabled: boolean,
userId: string | null, userId: string | null,
unclaimedUserId: string, unclaimedUserId: string,
): { preferredUserId: string; allowedUserIds: string[] } { ): { preferredUserId: string; allowedUserIds: string[] } {
if (!authEnabled) {
return { preferredUserId: unclaimedUserId, allowedUserIds: [unclaimedUserId] };
}
const preferredUserId = userId ?? unclaimedUserId; const preferredUserId = userId ?? unclaimedUserId;
const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId])); const allowedUserIds = Array.from(new Set([preferredUserId, unclaimedUserId]));
return { preferredUserId, allowedUserIds }; return { preferredUserId, allowedUserIds };

View file

@ -9,16 +9,13 @@ export type AdminAuthContext = AuthContext & {
/** /**
* Returns the admin auth context, or a 401/403 Response if the requester is * Returns the admin auth context, or a 401/403 Response if the requester is
* not authenticated / not an admin. Mirrors the `requireAuthContext` shape. * 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( export async function requireAdminContext(
request: Pick<NextRequest, 'headers'>, request: Pick<NextRequest, 'headers'>,
): Promise<AdminAuthContext | Response> { ): Promise<AdminAuthContext | Response> {
const ctx = await getAuthContext(request); const ctx = await getAuthContext(request);
if (!ctx.authEnabled || !ctx.userId || !ctx.user) { if (!ctx.userId || !ctx.user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View file

@ -5,7 +5,7 @@ import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server'; import type { NextRequest } from 'next/server';
import { db } from "@/db"; 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 { isAdminEmail, syncAdminFlag } from "@/lib/server/admin/email-sync";
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config'; import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { assertUserSignupAllowed } from '@/lib/server/auth/signup-policy'; 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 authSchema = process.env.POSTGRES_URL ? authSchemaPostgres : authSchemaSqlite;
const requiredAuthEnv = getRequiredAuthEnv();
const createAuth = () => betterAuth({ const createAuth = () => betterAuth({
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -65,8 +66,8 @@ const createAuth = () => betterAuth({
provider: process.env.POSTGRES_URL ? "pg" : "sqlite", provider: process.env.POSTGRES_URL ? "pg" : "sqlite",
schema: authSchema as Record<string, unknown>, schema: authSchema as Record<string, unknown>,
}), }),
secret: process.env.AUTH_SECRET!, secret: requiredAuthEnv.authSecret,
baseURL: process.env.BASE_URL!, baseURL: requiredAuthEnv.baseUrl,
trustedOrigins: getTrustedOrigins(), trustedOrigins: getTrustedOrigins(),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
@ -309,7 +310,7 @@ const createAuth = () => betterAuth({
], ],
}); });
export const auth = isAuthEnabled() ? createAuth() : null; export const auth = createAuth();
type AuthInstance = ReturnType<typeof createAuth>; type AuthInstance = ReturnType<typeof createAuth>;
export type Session = AuthInstance["$Infer"]["Session"]; export type Session = AuthInstance["$Infer"]["Session"];
@ -319,19 +320,13 @@ export type User = AuthSessionUser & {
}; };
export type AuthContext = { export type AuthContext = {
authEnabled: boolean; authEnabled: true;
session: Session | null; session: Session | null;
user: User | null; user: User | null;
userId: string | null; userId: string | null;
}; };
export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> { export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Promise<AuthContext> {
const authEnabled = isAuthEnabled();
if (!authEnabled || !auth) {
return { authEnabled, session: null, user: null, userId: null };
}
const session = await auth.api.getSession({ headers: request.headers }); const session = await auth.api.getSession({ headers: request.headers });
const user = (session?.user ?? null) as User | null; const user = (session?.user ?? null) as User | null;
const userId = user?.id ?? null; const userId = user?.id ?? null;
@ -347,7 +342,7 @@ export async function getAuthContext(request: Pick<NextRequest, 'headers'>): Pro
} }
} }
return { authEnabled, session, user, userId }; return { authEnabled: true, session, user, userId };
} }
export async function requireAuthContext( export async function requireAuthContext(
@ -356,10 +351,6 @@ export async function requireAuthContext(
): Promise<AuthContext | Response> { ): Promise<AuthContext | Response> {
const ctx = await getAuthContext(request); const ctx = await getAuthContext(request);
if (!ctx.authEnabled) {
return ctx;
}
if (!ctx.userId) { if (!ctx.userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
} }

View file

@ -1,9 +1,24 @@
/** export type RequiredAuthEnv = {
* Centralized auth configuration check. authSecret: string;
* Auth is only enabled when BOTH AUTH_SECRET and BASE_URL are set. baseUrl: string;
*/ };
export function isAuthEnabled(): boolean {
return !!(process.env.AUTH_SECRET && process.env.BASE_URL); 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 { 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. * Defaults to false when unset or invalid.
*/ */
export function isAnonymousAuthSessionsEnabled(): boolean { export function isAnonymousAuthSessionsEnabled(): boolean {
if (!isAuthEnabled()) return false; getRequiredAuthEnv();
return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false); return parseBooleanEnv('USE_ANONYMOUS_AUTH_SESSIONS', false);
} }
/** /**
* GitHub sign-in is available only when auth is enabled AND * GitHub sign-in is available when both GITHUB_CLIENT_ID and
* both GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET are set. * GITHUB_CLIENT_SECRET are set.
*/ */
export function isGithubAuthEnabled(): boolean { export function isGithubAuthEnabled(): boolean {
if (!isAuthEnabled()) return false;
return !!(process.env.GITHUB_CLIENT_ID && process.env.GITHUB_CLIENT_SECRET); 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 { export function getAuthBaseUrl(): string {
if (!isAuthEnabled()) { return getRequiredAuthEnv().baseUrl;
return null;
}
return process.env.BASE_URL || null;
} }

View file

@ -1,7 +1,6 @@
import { and, eq, gte, lt, sql } from 'drizzle-orm'; import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { db } from '@/db'; import { db } from '@/db';
import { userJobEvents } from '@/db/schema'; import { userJobEvents } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { nowTimestampMs } from '@/lib/shared/timestamps'; import { nowTimestampMs } from '@/lib/shared/timestamps';
import type { RuntimeConfig } from '@/lib/server/admin/settings'; import type { RuntimeConfig } from '@/lib/server/admin/settings';
@ -63,7 +62,7 @@ export function getPdfLayoutRateConfig(runtime: Pick<
const safeDb = () => db as any; const safeDb = () => db as any;
function isActive(config: Pick<JobRateConfig, 'enabled'>, userId: string | null | undefined): userId is string { function isActive(config: Pick<JobRateConfig, 'enabled'>, 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<number> { async function countSince(userId: string, action: JobRateAction, since: number): Promise<number> {

View file

@ -1,6 +1,5 @@
import { db } from '@/db'; import { db } from '@/db';
import { userTtsChars } from '@/db/schema'; import { userTtsChars } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { eq, and, lt, sql } from 'drizzle-orm'; import { eq, and, lt, sql } from 'drizzle-orm';
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps'; import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
@ -158,7 +157,7 @@ export class RateLimiter {
): Promise<RateLimitResult> { ): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits); const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true; const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) { if (!enabled) {
return { return {
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
@ -265,7 +264,7 @@ export class RateLimiter {
): Promise<RateLimitResult> { ): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits); const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true; const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) { if (!enabled) {
return { return {
allowed: true, allowed: true,
currentCount: 0, currentCount: 0,
@ -321,8 +320,6 @@ export class RateLimiter {
* Transfer char counts when anonymous user creates an account * Transfer char counts when anonymous user creates an account
*/ */
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> { async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled()) return;
const today = new Date().toISOString().split('T')[0]; const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue; const dateValue = today as unknown as UserTtsCharsDateValue;
const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue; const updatedAt = this.getUpdatedAtValue() as unknown as UserTtsCharsUpdatedAtValue;

View file

@ -3,7 +3,6 @@ import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { db } from '@/db'; import { db } from '@/db';
import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema'; import { ttsSegmentEntries, ttsSegmentVariants } from '@/db/schema';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
export type ResolvedSegmentAudio = { export type ResolvedSegmentAudio = {
documentId: string; documentId: string;
@ -54,6 +53,7 @@ export async function resolveCompletedSegmentAudio(
return NextResponse.json({ error: 'Missing documentId or segmentId' }, { status: 400 }); 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); const scope = await resolveSegmentDocumentScope(request, documentId);
if (scope instanceof Response) return scope; if (scope instanceof Response) return scope;

View file

@ -9,7 +9,7 @@ import type { ReaderType } from '@/types/user-state';
export type ResolvedSegmentDocumentScope = { export type ResolvedSegmentDocumentScope = {
testNamespace: string | null; testNamespace: string | null;
storageUserId: string; storageUserId: string;
authEnabled: boolean; authEnabled: true;
userId: string | null; userId: string | null;
isAnonymousUser: boolean; isAnonymousUser: boolean;
documentVersion: number; documentVersion: number;
@ -32,7 +32,7 @@ export async function resolveSegmentDocumentScope(
const testNamespace = getOpenReaderTestNamespace(request.headers); const testNamespace = getOpenReaderTestNamespace(request.headers);
const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace); const unclaimedUserId = getUnclaimedUserIdForNamespace(testNamespace);
const storageUserId = ctxOrRes.userId ?? unclaimedUserId; const storageUserId = ctxOrRes.userId ?? unclaimedUserId;
const allowedUserIds = ctxOrRes.authEnabled ? [storageUserId, unclaimedUserId] : [unclaimedUserId]; const allowedUserIds = [storageUserId, unclaimedUserId];
const rows = (await db const rows = (await db
.select({ .select({
@ -55,7 +55,7 @@ export async function resolveSegmentDocumentScope(
return { return {
testNamespace, testNamespace,
storageUserId: doc.userId, storageUserId: doc.userId,
authEnabled: ctxOrRes.authEnabled, authEnabled: true,
userId: ctxOrRes.userId, userId: ctxOrRes.userId,
isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous), isAnonymousUser: Boolean(ctxOrRes.user?.isAnonymous),
documentVersion: Number(doc.lastModified), documentVersion: Number(doc.lastModified),

View file

@ -10,8 +10,6 @@ import {
} from '../audiobooks/blobstore'; } from '../audiobooks/blobstore';
import { isS3Configured } from '../storage/s3'; import { isS3Configured } from '../storage/s3';
import { isAuthEnabled } from '@/lib/server/auth/config';
type AudiobookRow = { type AudiobookRow = {
id: string; id: string;
userId: 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) { 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 }; 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 // eslint-disable-next-line @typescript-eslint/no-explicit-any
options?: { db?: any }, options?: { db?: any },
): Promise<number> { ): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0; if (fromUserId === toUserId) return 0;
const database = options?.db ?? db; const database = options?.db ?? db;
@ -150,7 +148,7 @@ export async function transferUserAudiobooks(
toUserId: string, toUserId: string,
namespace: string | null = null, namespace: string | null = null,
): Promise<number> { ): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0; if (fromUserId === toUserId) return 0;
const books = (await db const books = (await db
@ -188,7 +186,7 @@ export async function transferUserAudiobooks(
} }
export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> { export async function transferUserPreferences(fromUserId: string, toUserId: string): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0; if (fromUserId === toUserId) return 0;
const fromRows = (await db 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<number> { export async function transferUserProgress(fromUserId: string, toUserId: string): Promise<number> {
if (!isAuthEnabled() || !fromUserId || !toUserId) return 0; if (!fromUserId || !toUserId) return 0;
if (fromUserId === toUserId) return 0; if (fromUserId === toUserId) return 0;
const fromRows = (await db const fromRows = (await db

View file

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import type { NextRequest } 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). * 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)); return PUBLIC_PATH_PREFIXES.some((prefix) => pathname.startsWith(prefix));
} }
function isAuthEnabled(): boolean {
return !!(process.env.AUTH_SECRET && process.env.BASE_URL);
}
function isAnonymousAuthEnabled(): boolean { function isAnonymousAuthEnabled(): boolean {
if (!isAuthEnabled()) return false; getRequiredAuthEnv();
const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS; const raw = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
return raw?.trim().toLowerCase() === 'true'; return raw?.trim().toLowerCase() === 'true';
} }
@ -88,10 +85,7 @@ export function middleware(request: NextRequest) {
} }
} }
// When auth is disabled entirely, let everything through. getRequiredAuthEnv();
if (!isAuthEnabled()) {
return NextResponse.next();
}
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;

View file

@ -1,5 +1,5 @@
import { test, expect } from '@playwright/test'; 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.describe('Document deletion flow', () => {
test.beforeEach(async ({ page }, testInfo) => { test.beforeEach(async ({ page }, testInfo) => {
@ -23,30 +23,4 @@ test.describe('Document deletion flow', () => {
await expectDocumentListed(page, 'sample.pdf'); 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 });
});
}); });

View file

@ -66,7 +66,7 @@ function parseAudiobookScopeFromKey(
} }
export default async function globalTeardown(): Promise<void> { export default async function globalTeardown(): Promise<void> {
// 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(audiobookChapters).where(like(audiobookChapters.userId, 'unclaimed::%'));
await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%')); await db.delete(audiobooks).where(like(audiobooks.userId, 'unclaimed::%'));
await db.delete(documents).where(like(documents.userId, 'unclaimed::%')); await db.delete(documents).where(like(documents.userId, 'unclaimed::%'));

View file

@ -5,10 +5,6 @@ import { createHash } from 'crypto';
const DIR = './tests/files/'; 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 // Small util to safely use filenames inside regex patterns
function escapeRegExp(input: string) { function escapeRegExp(input: string) {
return input.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 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 }); 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. // Pre-seed consent to prevent the cookie banner from blocking interactions.
await page.addInitScript(() => { await page.addInitScript(() => {
try { try {

View file

@ -2,20 +2,14 @@ import { describe, expect, test } from 'vitest';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope'; import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope';
describe('audiobook scope selection', () => { describe('audiobook scope selection', () => {
test('uses only unclaimed scope when auth is disabled', () => { test('includes both preferred and unclaimed scopes', () => {
const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns'); const result = buildAllowedAudiobookUserIds('user-123', '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');
expect(result.preferredUserId).toBe('user-123'); expect(result.preferredUserId).toBe('user-123');
expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']); expect(result.allowedUserIds).toEqual(['user-123', 'unclaimed::ns']);
}); });
test('deduplicates preferred/unclaimed ids when they are the same', () => { 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']); expect(result.allowedUserIds).toEqual(['unclaimed::ns']);
}); });

View file

@ -1,27 +1,19 @@
import { describe, expect, test } from 'vitest'; 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'; import { withEnv } from './support/env';
describe('auth config contract', () => { 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( await withEnv(
{ {
AUTH_SECRET: undefined, AUTH_SECRET: 'unit-secret-2',
BASE_URL: undefined,
},
async () => {
expect(isAuthEnabled()).toBe(false);
expect(getAuthBaseUrl()).toBeNull();
},
);
await withEnv(
{
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003', BASE_URL: 'http://localhost:3003',
}, },
async () => { async () => {
expect(isAuthEnabled()).toBe(true); expect(getRequiredAuthEnv()).toEqual({
authSecret: 'unit-secret-2',
baseUrl: 'http://localhost:3003',
});
expect(getAuthBaseUrl()).toBe('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( await withEnv(
{ {
AUTH_SECRET: undefined, AUTH_SECRET: 'unit-secret',
BASE_URL: undefined, BASE_URL: 'http://localhost:3003',
USE_ANONYMOUS_AUTH_SESSIONS: 'true', USE_ANONYMOUS_AUTH_SESSIONS: '1',
}, },
async () => { async () => {
expect(isAnonymousAuthSessionsEnabled()).toBe(false); expect(isAnonymousAuthSessionsEnabled()).toBe(false);
@ -63,16 +55,6 @@ describe('auth config contract', () => {
}); });
test.each([ 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', title: 'returns false when GitHub client id is missing',
env: { env: {

View file

@ -55,7 +55,7 @@ describe('onboarding flow resolver', () => {
expect(step).toBe('changelog'); 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({ const authStep = resolveNextOnboardingStep({
privacyRequired: true, privacyRequired: true,
privacyAccepted: true, privacyAccepted: true,

7
tests/unit/setup-env.ts Normal file
View file

@ -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';
}

View file

@ -1,6 +1,14 @@
import { defineConfig } from 'vitest/config'; import { defineConfig } from 'vitest/config';
import { fileURLToPath } from 'node:url'; 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 srcDir = fileURLToPath(new URL('./src/', import.meta.url));
const computeCoreIndex = fileURLToPath(new URL('./compute/core/src/index.ts', 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)); const computeCoreApiContracts = fileURLToPath(new URL('./compute/core/src/api-contracts/index.ts', import.meta.url));
@ -33,6 +41,7 @@ export default defineConfig({
name: 'openreader', name: 'openreader',
environment: 'node', environment: 'node',
include: ['tests/unit/**/*.vitest.spec.ts'], include: ['tests/unit/**/*.vitest.spec.ts'],
setupFiles: ['tests/unit/setup-env.ts'],
}, },
}, },
{ {