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"
|
# 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=
|
||||||
|
|
||||||
|
|
|
||||||
3
.github/workflows/playwright.yml
vendored
3
.github/workflows/playwright.yml
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
:::
|
:::
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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({
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 };
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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> {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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::%'));
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: {
|
||||||
|
|
|
||||||
|
|
@ -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
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 { 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'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue