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