Merge pull request #92 from richardr1126/refactor/pre-release-cleanup

refactor: pre-release cleanup — rate limiting, worker modularization, vitest migration
This commit is contained in:
Richard R 2026-05-30 17:32:53 -06:00 committed by GitHub
commit 3daf4b6453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
134 changed files with 8476 additions and 2389 deletions

View file

@ -15,7 +15,7 @@
API_BASE=http://localhost:8880/v1
API_KEY=api_key_optional
# (Optional) TTS request/cache tuning (leave unset to use defaults)
# (Optional) TTS request/cache tuning (defaults shown below; leave unset to use defaults)
# TTS_CACHE_MAX_SIZE_BYTES=268435456 # 256MB
# TTS_CACHE_TTL_MS=1800000 # 30 minutes
# TTS_MAX_RETRIES=2
@ -24,27 +24,24 @@ API_KEY=api_key_optional
# TTS_RETRY_BACKOFF=2
# TTS_UPSTREAM_TIMEOUT_MS=285000 # 285 seconds
# (Optional) Enable TTS character rate limiting (default is `false`)
# TTS_ENABLE_RATE_LIMIT=true
# (Optional) TTS per-user daily limits (leave unset to use defaults)
# TTS_DAILY_LIMIT_ANONYMOUS=50000
# TTS_DAILY_LIMIT_AUTHENTICATED=500000
# (Optional) TTS IP backstop daily limits (leave unset to use defaults)
# TTS_IP_DAILY_LIMIT_ANONYMOUS=100000
# TTS_IP_DAILY_LIMIT_AUTHENTICATED=1000000
# Auth configuration (recommended for contributors and public instances)
# Auth configuration (recommended; required for admin features)
# (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)
AUTH_SECRET=some_random_secret_key # Generate with `openssl rand -hex 32`
AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional trusted origins (BASE_URL is always trusted)
# (Optional) Allow anonymous auth sessions when auth is enabled (default: `false`)
USE_ANONYMOUS_AUTH_SESSIONS=
# USE_ANONYMOUS_AUTH_SESSIONS=false
# (Optional) Sign in w/ GitHub Configuration
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing)
DISABLE_AUTH_RATE_LIMIT=
# GITHUB_CLIENT_ID=
# GITHUB_CLIENT_SECRET=
# (Optional) Disable Better Auth built-in rate limiting (useful for testing; default: `false`)
# DISABLE_AUTH_RATE_LIMIT=false
# (Optional) Honor the x-openreader-test-namespace header on a production build.
# This is test/CI scaffolding and MUST stay unset on real deployments; it is set
# automatically by the Playwright web server. Non-production builds honor it
# without this flag.
# ENABLE_TEST_NAMESPACE=false
# (Optional) Comma-separated list of emails that are auto-promoted to admin.
# Admins see the "Admin" tab in Settings (TTS shared providers + site features).
@ -54,34 +51,28 @@ ADMIN_EMAILS=
# (Optional) Backend DB used for server-side metadata (documents/audiobooks) and, when auth is enabled, auth tables.
# Defaults to SQLite at docstore/sqlite3.db when not set.
POSTGRES_URL=
# POSTGRES_URL=
# Embedded SeaweedFS weed mini config
# (Optional) Enable embedded weed mini for local S3-compatible storage (default: `true`)
USE_EMBEDDED_WEED_MINI=
WEED_MINI_DIR=
WEED_MINI_WAIT_SEC=
# (Optional) Enable embedded weed mini for local S3-compatible storage (defaults shown below)
# USE_EMBEDDED_WEED_MINI=true
# WEED_MINI_DIR=docstore/seaweedfs
# WEED_MINI_WAIT_SEC=20
# S3 storage config (use with embedded weed mini or external S3-compatible storage)
# (Optional) For embedded weed mini, set explicit keys if you want stable credentials across restarts.
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_BUCKET=
S3_REGION=
# S3_REGION=
# (Optional) If empty in embedded mode, OpenReader uses BASE_URL host (when set) or detected LAN host.
S3_ENDPOINT=
S3_FORCE_PATH_STYLE=
S3_PREFIX=
# S3_ENDPOINT=
# S3_FORCE_PATH_STYLE=
# S3_PREFIX=
# Migrations configuration
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
RUN_DRIZZLE_MIGRATIONS=
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
RUN_FS_MIGRATIONS=
# (Optional) Server library import roots (uses /docstore/library by default)
IMPORT_LIBRARY_DIR=
IMPORT_LIBRARY_DIRS=
# (Optional) Library import mode directory (uses /docstore/library by default)
# IMPORT_LIBRARY_DIR=
# IMPORT_LIBRARY_DIRS=
# Compute
# Embedded/local (default): leave COMPUTE_WORKER_URL empty.
@ -118,7 +109,7 @@ IMPORT_LIBRARY_DIRS=
# PDF_LAYOUT_MODEL_BASE_URL=https://huggingface.co/Bei0001/PP-DocLayoutV3-ONNX/resolve/main
# (Optional) Override ffmpeg binary path used for audiobook processing
FFMPEG_BIN=
# FFMPEG_BIN=
# (Optional) Client feature flags — seeded into the admin-managed runtime
# config on first boot, then ignored. Edit values from Settings → Admin →
@ -132,3 +123,11 @@ FFMPEG_BIN=
# RUNTIME_SEED_DEFAULT_TTS_PROVIDER=custom-openai
# RUNTIME_SEED_CHANGELOG_FEED_URL=https://docs.openreader.richardr.dev/changelog/manifest.json
# RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT=true
# RUNTIME_SEED_DISABLE_TTS_LIMIT=true
# RUNTIME_SEED_DISABLE_COMPUTE_LIMIT=true
# Migrations configuration
# (Optional) Skip automatic startup migrations when set to `false` (default: `true`)
# RUN_DRIZZLE_MIGRATIONS=true
# (Optional) Skip automatic filesystem->S3/DB migration pass when set to `false` (default: `true`)
# RUN_FS_MIGRATIONS=true

27
.github/workflows/vitest.yml vendored Normal file
View file

@ -0,0 +1,27 @@
name: Vitest Tests
on:
push:
branches: [main, master]
pull_request:
branches: [main, master]
jobs:
vitest-testing:
timeout-minutes: 20
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
persist-credentials: false
- uses: actions/setup-node@v5
with:
node-version: lts/*
package-manager-cache: false
- uses: pnpm/action-setup@v6
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Apply drizzle migrations (SQLite)
run: pnpm migrate
- name: Run Vitest suites
run: pnpm test:unit

View file

@ -0,0 +1,95 @@
import { describe, expect, test } from 'vitest';
import type { WorkerOperationRequest } from '../../src/api-contracts';
import {
InMemoryOperationEventStream,
InMemoryOperationQueue,
InMemoryOperationStateStore,
OperationOrchestrator,
} from '../../src/control-plane';
function buildRequest(opKey: string): WorkerOperationRequest {
return {
kind: 'pdf_layout',
opKey,
payload: {
documentId: `doc-${opKey}`,
namespace: null,
documentObjectKey: `s3://bucket/${opKey}.pdf`,
},
};
}
describe('operation orchestrator', () => {
test('reuses fresh operation and replaces stale operation', async () => {
let now = 1_000;
let nextId = 1;
const queue = new InMemoryOperationQueue();
const stateStore = new InMemoryOperationStateStore();
const eventStream = new InMemoryOperationEventStream();
const orchestrator = new OperationOrchestrator({
queue,
stateStore,
eventStream,
config: { opStaleMs: 2_000, maxCasRetries: 5 },
clock: { now: () => now },
idFactory: {
opId: () => `op-${nextId}`,
jobId: () => `job-${nextId++}`,
},
});
const request = buildRequest('shared-op');
const first = await orchestrator.enqueueOrReuse(request);
expect(first.opId).toBe('op-1');
now = 2_000;
const reused = await orchestrator.enqueueOrReuse(request);
expect(reused.opId).toBe('op-1');
await orchestrator.markRunning({ opId: first.opId, updatedAt: 2_100 });
now = 6_000;
const replaced = await orchestrator.enqueueOrReuse(request);
expect(replaced.opId).toBe('op-2');
expect(await stateStore.getOpIndex('shared-op')).toEqual({ opId: 'op-2' });
expect(queue.size('pdf_layout')).toBe(2);
});
test('survives transient CAS conflict and eventually creates operation', async () => {
const queue = new InMemoryOperationQueue();
const eventStream = new InMemoryOperationEventStream();
const store = new InMemoryOperationStateStore();
let firstAttempt = true;
const conflictStore = {
getOpState: store.getOpState.bind(store),
putOpState: store.putOpState.bind(store),
getOpIndex: store.getOpIndex.bind(store),
compareAndSetOpIndex: async (input: { opKey: string; newOpId: string; expectedOpId: string | null }) => {
if (firstAttempt && input.expectedOpId === null) {
firstAttempt = false;
return false;
}
return store.compareAndSetOpIndex(input);
},
};
let id = 1;
const orchestrator = new OperationOrchestrator({
queue,
stateStore: conflictStore,
eventStream,
config: { opStaleMs: 2_000, maxCasRetries: 4 },
idFactory: {
opId: () => `op-${id}`,
jobId: () => `job-${id++}`,
},
});
const created = await orchestrator.enqueueOrReuse(buildRequest('cas-key'));
expect(created.opId).toMatch(/^op-/);
expect(await store.getOpIndex('cas-key')).toEqual({ opId: created.opId });
});
});

View file

@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { encodeSseFrame, parseSseEventId, parseSsePayload } from '../../src/control-plane/sse';
describe('sse codec', () => {
test('encodes event id and payload and decodes both reliably', () => {
const frame = encodeSseFrame({
id: 42,
event: 'snapshot',
data: { ok: true, opId: 'op-1' },
});
expect(frame).toContain('event: snapshot');
expect(parseSseEventId(frame)).toBe(42);
expect(parseSsePayload(frame)).toBe('{"ok":true,"opId":"op-1"}');
});
test('supports multiline data payload', () => {
const frame = encodeSseFrame({
id: 5,
data: 'line1\nline2',
});
expect(parseSseEventId(frame)).toBe(5);
expect(parseSsePayload(frame)).toBe('line1\nline2');
});
});

View file

@ -0,0 +1,76 @@
import { describe, expect, test } from 'vitest';
import type { WorkerOperationState } from '../../src/api-contracts';
import {
explainReplacementReason,
isInflightStatus,
isTerminalStatus,
shouldReuseExistingOperation,
} from '../../src/control-plane/state-machine';
function runningState(overrides: Partial<WorkerOperationState> = {}): WorkerOperationState {
return {
opId: 'op-1',
opKey: 'key-1',
kind: 'pdf_layout',
jobId: 'job-1',
status: 'running',
queuedAt: 1_000,
updatedAt: 2_000,
...overrides,
};
}
describe('state-machine decisions', () => {
test('identifies terminal and inflight states', () => {
expect(isTerminalStatus('succeeded')).toBe(true);
expect(isTerminalStatus('failed')).toBe(true);
expect(isTerminalStatus('queued')).toBe(false);
expect(isInflightStatus('queued')).toBe(true);
expect(isInflightStatus('running')).toBe(true);
expect(isInflightStatus('failed')).toBe(false);
});
test('reuses fresh inflight operation and rejects stale inflight operation', () => {
const current = runningState();
expect(shouldReuseExistingOperation({
current,
requestKind: 'pdf_layout',
now: 2_900,
opStaleMs: 1_000,
})).toBe(true);
expect(shouldReuseExistingOperation({
current,
requestKind: 'pdf_layout',
now: 3_100,
opStaleMs: 1_000,
})).toBe(false);
expect(explainReplacementReason({
current,
requestKind: 'pdf_layout',
now: 3_100,
opStaleMs: 1_000,
})).toBe('stale_running');
});
test('never reuses kind-mismatched operation', () => {
const current = runningState({ kind: 'whisper_align' });
expect(shouldReuseExistingOperation({
current,
requestKind: 'pdf_layout',
now: 2_100,
opStaleMs: 10_000,
})).toBe(false);
expect(explainReplacementReason({
current,
requestKind: 'pdf_layout',
now: 2_100,
opStaleMs: 10_000,
})).toBe('kind_mismatch');
});
});

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
import type { WorkerOperationKind } from '@openreader/compute-core/api-contracts';
export type RetryAction = 'nak_retry' | 'term_fail';
export function buildQueueWaitTiming(queuedAt: number, now: number): { queueWaitMs: number } | undefined {
if (!Number.isFinite(queuedAt) || !Number.isFinite(now)) return undefined;
return { queueWaitMs: Math.max(0, Math.floor(now - queuedAt)) };
}
export function decideRetryAction(input: {
kind: WorkerOperationKind;
deliveryCount: number;
pdfAttempts: number;
whisperMaxDeliver?: number;
}): RetryAction {
const whisperMaxDeliver = input.whisperMaxDeliver ?? 1;
if (input.kind === 'whisper_align') {
return input.deliveryCount < whisperMaxDeliver ? 'nak_retry' : 'term_fail';
}
return input.deliveryCount < input.pdfAttempts ? 'nak_retry' : 'term_fail';
}

View file

@ -0,0 +1,122 @@
import { afterEach, beforeEach, describe, expect, test } from 'vitest';
import { createComputeWorkerApp } from '../../src/runtime';
import { FakeControlPlane } from '../fixtures/fake-control-plane';
const AUTH = { authorization: 'Bearer test-token' };
describe('compute worker API routes', () => {
let fake: FakeControlPlane;
let runtime: Awaited<ReturnType<typeof createComputeWorkerApp>>;
beforeEach(async () => {
fake = new FakeControlPlane();
runtime = await createComputeWorkerApp({
workerToken: 'test-token',
disableWorkers: true,
routeDeps: fake.deps,
});
});
afterEach(async () => {
await runtime.close();
});
test('allows unauthenticated health checks but protects operation routes', async () => {
const live = await runtime.app.inject({ method: 'GET', url: '/health/live' });
expect(live.statusCode).toBe(200);
const protectedRoute = await runtime.app.inject({ method: 'GET', url: '/ops/op-1' });
expect(protectedRoute.statusCode).toBe(401);
});
test('validates operation creation body and returns 400 for invalid payload', async () => {
const response = await runtime.app.inject({
method: 'POST',
url: '/ops',
headers: AUTH,
payload: {
kind: 'pdf_layout',
opKey: '',
payload: {
documentId: 'd1',
namespace: null,
documentObjectKey: 's3://bucket/doc.pdf',
},
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toMatchObject({ error: 'Invalid request body' });
});
test('creates operation and fetches state by op id', async () => {
const create = await runtime.app.inject({
method: 'POST',
url: '/ops',
headers: AUTH,
payload: {
kind: 'pdf_layout',
opKey: 'doc-1:layout',
payload: {
documentId: 'doc-1',
namespace: null,
documentObjectKey: 'openreader/doc-1.pdf',
},
},
});
expect(create.statusCode).toBe(202);
const created = create.json();
expect(created).toMatchObject({ kind: 'pdf_layout', status: 'queued' });
const fetch = await runtime.app.inject({
method: 'GET',
url: `/ops/${created.opId}`,
headers: AUTH,
});
expect(fetch.statusCode).toBe(200);
expect(fetch.json()).toMatchObject({ opId: created.opId, status: 'queued' });
});
test('returns not found for unknown operation and event stream lookups', async () => {
const opResponse = await runtime.app.inject({
method: 'GET',
url: '/ops/missing',
headers: AUTH,
});
expect(opResponse.statusCode).toBe(404);
const eventsResponse = await runtime.app.inject({
method: 'GET',
url: '/ops/missing/events',
headers: AUTH,
});
expect(eventsResponse.statusCode).toBe(404);
});
test('streams initial SSE snapshot for terminal operation and honors cursor id', async () => {
fake.seedState({
opId: 'op-terminal',
opKey: 'k-terminal',
kind: 'pdf_layout',
jobId: 'job-op-terminal',
status: 'succeeded',
queuedAt: 1000,
updatedAt: 2000,
result: { parsedObjectKey: 'openreader/parsed.json' },
});
const stream = await runtime.app.inject({
method: 'GET',
url: '/ops/op-terminal/events?sinceEventId=7',
headers: AUTH,
});
expect(stream.statusCode).toBe(200);
expect(stream.headers['content-type']).toContain('text/event-stream');
expect(stream.body).toContain('event: snapshot');
expect(stream.body).toContain('id: 7');
expect(stream.body).toContain('"status":"succeeded"');
});
});

View file

@ -0,0 +1,119 @@
import type {
PdfLayoutJobResult,
WhisperAlignJobResult,
WorkerOperationEvent,
WorkerOperationRequest,
WorkerOperationState,
} from '@openreader/compute-core/api-contracts';
import type { ComputeWorkerRouteDeps } from '../../src/runtime';
type ComputeResult = WhisperAlignJobResult | PdfLayoutJobResult;
type ComputeState = WorkerOperationState<ComputeResult>;
type ComputeEvent = WorkerOperationEvent<ComputeResult>;
export class FakeControlPlane {
private readonly stateByOpId = new Map<string, ComputeState>();
private readonly opIdByOpKey = new Map<string, string>();
private readonly eventsByOpId = new Map<string, ComputeEvent[]>();
private nextOpId = 1;
readonly deps: ComputeWorkerRouteDeps = {
orchestrator: {
enqueueOrReuse: async (request) => this.enqueueOrReuse(request),
markRunning: async (input) => this.updateState(input.opId, {
status: 'running',
startedAt: input.startedAt,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markProgress: async (input) => this.updateState(input.opId, {
status: 'running',
progress: input.progress,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markSucceeded: async (input) => this.updateState(input.opId, {
status: 'succeeded',
result: input.result as ComputeResult,
updatedAt: input.updatedAt,
timing: input.timing,
}),
markFailed: async (input) => this.updateState(input.opId, {
status: 'failed',
error: typeof input.error === 'string' ? { message: input.error } : input.error,
updatedAt: input.updatedAt,
timing: input.timing,
}),
},
operationStateStore: {
getOpState: async (opId) => this.stateByOpId.get(opId) ?? null,
},
operationEventStream: {
subscribe: async ({ opId, sinceEventId, onEvent }) => {
const since = Math.max(0, Math.floor(sinceEventId ?? 0));
const replay = (this.eventsByOpId.get(opId) ?? []).filter((event) => event.eventId > since);
for (const event of replay) {
await onEvent(event);
}
return () => undefined;
},
},
};
seedState(state: ComputeState): void {
this.stateByOpId.set(state.opId, state);
this.opIdByOpKey.set(state.opKey, state.opId);
}
seedEvent(opId: string, event: ComputeEvent): void {
const list = this.eventsByOpId.get(opId) ?? [];
list.push(event);
this.eventsByOpId.set(opId, list);
}
private async enqueueOrReuse(request: WorkerOperationRequest): Promise<ComputeState> {
const existingId = this.opIdByOpKey.get(request.opKey);
if (existingId) {
const existing = this.stateByOpId.get(existingId);
if (existing) return existing;
}
const now = Date.now();
const opId = `op-${this.nextOpId++}`;
const state: ComputeState = {
opId,
opKey: request.opKey,
kind: request.kind,
jobId: `job-${opId}`,
status: 'queued',
queuedAt: now,
updatedAt: now,
};
this.stateByOpId.set(opId, state);
this.opIdByOpKey.set(request.opKey, opId);
this.seedEvent(opId, { eventId: 1, snapshot: state });
return state;
}
private async updateState(
opId: string,
patch: Partial<ComputeState>,
): Promise<ComputeState> {
const current = this.stateByOpId.get(opId);
if (!current) {
throw new Error(`Unknown opId: ${opId}`);
}
const next: ComputeState = {
...current,
...patch,
updatedAt: patch.updatedAt ?? Date.now(),
};
this.stateByOpId.set(opId, next);
const currentEvents = this.eventsByOpId.get(opId) ?? [];
const nextEventId = (currentEvents.at(-1)?.eventId ?? 0) + 1;
currentEvents.push({ eventId: nextEventId, snapshot: next });
this.eventsByOpId.set(opId, currentEvents);
return next;
}
}

View file

@ -0,0 +1,8 @@
process.env.COMPUTE_WORKER_TOKEN = process.env.COMPUTE_WORKER_TOKEN || 'test-token';
process.env.NATS_URL = process.env.NATS_URL || 'nats://127.0.0.1:4222';
process.env.COMPUTE_PREWARM_MODELS = 'false';
process.env.S3_BUCKET = process.env.S3_BUCKET || 'test-bucket';
process.env.S3_REGION = process.env.S3_REGION || 'us-east-1';
process.env.S3_ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID || 'test';
process.env.S3_SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY || 'test';
process.env.S3_PREFIX = process.env.S3_PREFIX || 'openreader';

View file

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["./**/*.ts"],
"exclude": []
}

View file

@ -1,16 +1,17 @@
import { expect, test } from '@playwright/test';
import { OperationOrchestrator } from '../../compute/core/src/control-plane';
import type { WorkerOperationRequest } from '../../compute/core/src/api-contracts';
import { describe, expect, test } from 'vitest';
import { OperationOrchestrator } from '@openreader/compute-core/control-plane';
import type { WorkerOperationRequest } from '@openreader/compute-core/api-contracts';
import {
JetStreamOperationEventStream,
JetStreamOperationQueue,
JetStreamOperationStateStore,
hashOpKey,
opEventsSubject,
opIndexKvKey,
opStateKvKey,
type KvEntryLike,
type KvStoreLike,
} from '../../compute/worker/src/control-plane/jetstream';
} from '../../src/control-plane/jetstream';
class FakeKvStore implements KvStoreLike {
private readonly data = new Map<string, KvEntryLike>();
@ -18,46 +19,31 @@ class FakeKvStore implements KvStoreLike {
async get(key: string): Promise<KvEntryLike | null> {
const value = this.data.get(key);
if (!value) return null;
return {
operation: value.operation,
value: value.value.slice(),
revision: value.revision,
};
return value
? {
operation: value.operation,
value: value.value.slice(),
revision: value.revision,
}
: null;
}
async put(key: string, data: Uint8Array): Promise<void> {
this.revision += 1;
this.data.set(key, {
operation: 'PUT',
value: data.slice(),
revision: this.revision,
});
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
}
async create(key: string, data: Uint8Array): Promise<void> {
if (this.data.has(key)) {
throw new Error('key exists');
}
if (this.data.has(key)) throw new Error('key exists');
this.revision += 1;
this.data.set(key, {
operation: 'PUT',
value: data.slice(),
revision: this.revision,
});
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
}
async update(key: string, data: Uint8Array, version: number): Promise<void> {
const current = this.data.get(key);
if (!current || current.revision !== version) {
throw new Error('wrong last sequence');
}
if (!current || current.revision !== version) throw new Error('wrong last sequence');
this.revision += 1;
this.data.set(key, {
operation: 'PUT',
value: data.slice(),
revision: this.revision,
});
this.data.set(key, { operation: 'PUT', value: data.slice(), revision: this.revision });
}
}
@ -72,7 +58,7 @@ class FakeJetStream {
async publish(subject: string, data: Uint8Array): Promise<{ seq: number; stream: string; duplicate: boolean }> {
this.seq += 1;
const payload = JSON.parse(new TextDecoder().decode(data)) as unknown;
const payload = JSON.parse(new TextDecoder().decode(data));
this.published.push({ subject, payload, seq: this.seq });
return { seq: this.seq, stream: 'fake', duplicate: false };
}
@ -90,97 +76,81 @@ function buildPdfRequest(opKey: string): WorkerOperationRequest {
};
}
test.describe('worker jetstream control-plane adapters', () => {
test('state store compareAndSet handles create/update semantics', async () => {
describe('jetstream adapters', () => {
test('state store compareAndSet enforces create/update semantics', async () => {
const kv = new FakeKvStore();
const store = new JetStreamOperationStateStore({ getKv: async () => kv });
const created = await store.compareAndSetOpIndex({
opKey: 'k1',
newOpId: 'op-1',
expectedOpId: null,
});
const failedCreate = await store.compareAndSetOpIndex({
opKey: 'k1',
newOpId: 'op-2',
expectedOpId: null,
});
const wrongExpected = await store.compareAndSetOpIndex({
opKey: 'k1',
newOpId: 'op-2',
expectedOpId: 'op-x',
});
const updated = await store.compareAndSetOpIndex({
opKey: 'k1',
newOpId: 'op-2',
expectedOpId: 'op-1',
});
const created = await store.compareAndSetOpIndex({ opKey: 'k1', newOpId: 'op-1', expectedOpId: null });
const duplicateCreate = await store.compareAndSetOpIndex({ opKey: 'k1', newOpId: 'op-2', expectedOpId: null });
const wrongExpected = await store.compareAndSetOpIndex({ opKey: 'k1', newOpId: 'op-2', expectedOpId: 'op-x' });
const updated = await store.compareAndSetOpIndex({ opKey: 'k1', newOpId: 'op-2', expectedOpId: 'op-1' });
expect(created).toBeTruthy();
expect(failedCreate).toBeFalsy();
expect(wrongExpected).toBeFalsy();
expect(updated).toBeTruthy();
expect(created).toBe(true);
expect(duplicateCreate).toBe(false);
expect(wrongExpected).toBe(false);
expect(updated).toBe(true);
expect(await store.getOpIndex('k1')).toEqual({ opId: 'op-2' });
});
test('queue and event adapters publish expected JetStream subjects', async () => {
test('queue routes layout jobs to the expected subject and publishes events', async () => {
const js = new FakeJetStream();
const queue = new JetStreamOperationQueue({
getJs: async () => js as any,
getJs: async () => js as never,
whisperSubject: 'jobs.whisper',
layoutSubject: 'jobs.layout',
});
const events = new JetStreamOperationEventStream({
getJs: async () => js as any,
getJs: async () => js as never,
getJsm: async () => ({
consumers: {
add: async () => ({ name: 'noop' }),
delete: async () => true,
},
}) as any,
}) as never,
eventsStreamName: 'compute_events',
});
await queue.enqueue({
jobId: 'j1',
opId: 'o1',
opKey: 'k1',
jobId: 'job-1',
opId: 'op-1',
opKey: 'k-1',
kind: 'pdf_layout',
queuedAt: 1000,
payload: { documentId: 'd1', namespace: null, documentObjectKey: 'obj' },
});
const appended = await events.append('o1', {
opId: 'o1',
opKey: 'k1',
const appended = await events.append('op-1', {
opId: 'op-1',
opKey: 'k-1',
kind: 'pdf_layout',
jobId: 'j1',
jobId: 'job-1',
status: 'queued',
queuedAt: 1000,
updatedAt: 1000,
});
expect(js.published.map((entry) => entry.subject)).toEqual(['jobs.layout', opEventsSubject('o1')]);
expect(js.published.map((entry) => entry.subject)).toEqual(['jobs.layout', opEventsSubject('op-1')]);
expect(appended.eventId).toBe(2);
});
test('orchestrator integration writes index/state and reuses active op', async () => {
test('orchestrator writes expected index/state keys', async () => {
const kv = new FakeKvStore();
const js = new FakeJetStream();
const store = new JetStreamOperationStateStore({ getKv: async () => kv });
const events = new JetStreamOperationEventStream({
getJs: async () => js as any,
const stateStore = new JetStreamOperationStateStore({ getKv: async () => kv });
const eventStream = new JetStreamOperationEventStream({
getJs: async () => js as never,
getJsm: async () => ({
consumers: {
add: async () => ({ name: 'noop' }),
delete: async () => true,
},
}) as any,
}) as never,
eventsStreamName: 'compute_events',
});
const queue = new JetStreamOperationQueue({
getJs: async () => js as any,
getJs: async () => js as never,
whisperSubject: 'jobs.whisper',
layoutSubject: 'jobs.layout',
});
@ -189,8 +159,8 @@ test.describe('worker jetstream control-plane adapters', () => {
let nextId = 1;
const orchestrator = new OperationOrchestrator({
queue,
stateStore: store,
eventStream: events,
stateStore,
eventStream,
config: { opStaleMs: 10_000, maxCasRetries: 3 },
clock: { now: () => now },
idFactory: {
@ -199,18 +169,18 @@ test.describe('worker jetstream control-plane adapters', () => {
},
});
const req = buildPdfRequest('k-integration');
const first = await orchestrator.enqueueOrReuse(req);
const first = await orchestrator.enqueueOrReuse(buildPdfRequest('k-integration'));
now = 2_000;
const reused = await orchestrator.enqueueOrReuse(req);
const reused = await orchestrator.enqueueOrReuse(buildPdfRequest('k-integration'));
expect(first.opId).toBe('op-1');
expect(reused.opId).toBe('op-1');
const indexEntry = await kv.get(opIndexKvKey('k-integration'));
const stateEntry = await kv.get(opStateKvKey('op-1'));
expect(indexEntry?.operation).toBe('PUT');
expect(stateEntry?.operation).toBe('PUT');
expect(js.published.map((entry) => entry.subject)).toEqual(['ops.events.op-1', 'jobs.layout']);
expect(hashOpKey('k-integration')).toHaveLength(64);
});
});

View file

@ -0,0 +1,37 @@
import { describe, expect, test } from 'vitest';
import { hashOpKey, opIndexKvKey, opStateKvKey } from '../../src/control-plane/jetstream';
import {
buildInferProgressForPageParsed,
buildInferProgressForPageStart,
} from '../../src/pdf-progress';
describe('compute worker helpers', () => {
test('hash and kv key helpers are stable and deterministic', () => {
const hashA = hashOpKey('doc-123|layout|v1');
const hashB = hashOpKey('doc-123|layout|v1');
const hashC = hashOpKey('doc-123|layout|v2');
expect(hashA).toBe(hashB);
expect(hashA).toHaveLength(64);
expect(hashC).not.toBe(hashA);
expect(opIndexKvKey('doc-123|layout|v1')).toBe(`op_index.${hashA}`);
expect(opStateKvKey('op-1')).toBe('op_state.op-1');
});
test('progress helpers infer page counters correctly', () => {
expect(buildInferProgressForPageStart({ pageNumber: 1, totalPages: 12 })).toEqual({
totalPages: 12,
pagesParsed: 0,
currentPage: 1,
phase: 'infer',
});
expect(buildInferProgressForPageParsed({ pageNumber: 5, totalPages: 12 })).toEqual({
totalPages: 12,
pagesParsed: 5,
currentPage: 5,
phase: 'infer',
});
});
});

View file

@ -0,0 +1,18 @@
import { describe, expect, test } from 'vitest';
import { buildQueueWaitTiming, decideRetryAction } from '../../src/worker-loop-policy';
describe('worker loop policy', () => {
test('returns queue wait timing with non-negative clamped duration', () => {
expect(buildQueueWaitTiming(1000, 1300)).toEqual({ queueWaitMs: 300 });
expect(buildQueueWaitTiming(1500, 1300)).toEqual({ queueWaitMs: 0 });
});
test('retry policy: layout jobs can retry until max attempts', () => {
expect(decideRetryAction({ kind: 'pdf_layout', deliveryCount: 1, pdfAttempts: 3 })).toBe('nak_retry');
expect(decideRetryAction({ kind: 'pdf_layout', deliveryCount: 3, pdfAttempts: 3 })).toBe('term_fail');
});
test('retry policy: whisper jobs terminate immediately by default', () => {
expect(decideRetryAction({ kind: 'whisper_align', deliveryCount: 1, pdfAttempts: 10 })).toBe('term_fail');
});
});

View file

@ -1,7 +1,7 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"noEmit": true
"noEmit": true,
},
"include": ["src/**/*.ts"],
"exclude": []

View file

@ -90,6 +90,21 @@ Each row shows a source badge:
Turning `restrictUserApiKeys` off allows user-supplied API keys to flow through this server. Use this only for trusted/self-hosted deployments where that tradeoff is acceptable.
:::
## Rate limiting
A dedicated **Rate limiting** group (within the same admin panel) collects the daily quotas, the PDF parsing throttle, and the upload size cap:
| Key | What it controls |
| --- | --- |
| `disableTtsRateLimit` | Disable the per-user/IP daily TTS character limits. When `false`, the daily-limit fields below it apply. |
| `disableComputeRateLimit` | Disable per-user PDF parsing rate limiting. When `false`, the burst/sustained limit fields below it apply. |
| `maxUploadMb` | Maximum size (MB) accepted for a single document upload. Enforced server-side and signed into the presigned S3 PUT. |
The **Disable TTS daily rate limiting** and **Disable PDF parsing rate limiting** toggles each reveal a collapsible group of numeric inputs when set to `false`:
- TTS: anonymous/authenticated per-user daily limits and anonymous/authenticated IP daily backstops.
- PDF parsing: burst limit + window (seconds) and sustained limit + window (seconds). The sustained window doubles as a concurrency cap.
## Migrating off env vars
The future-direction goal is to remove `RUNTIME_SEED_*` / `API_KEY` / `API_BASE` from your `.env` entirely. To do that safely:

View file

@ -7,7 +7,8 @@ This page explains OpenReader's TTS character rate limiting controls.
## Overview
- TTS rate limiting is disabled by default.
- To enable it, set `TTS_ENABLE_RATE_LIMIT=true`.
- Primary control is **Settings → Admin → Site features → Disable TTS daily rate limiting**.
- Optional first-boot seed: `RUNTIME_SEED_DISABLE_TTS_LIMIT=true`.
- Limits are enforced per day in UTC.
- Enforcement applies only when auth is enabled.
@ -28,24 +29,14 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta
- `DISABLE_AUTH_RATE_LIMIT` only affects Better Auth's own request throttling.
- `DISABLE_AUTH_RATE_LIMIT` does not disable TTS character limits.
## Environment variables
## Runtime config + seed var
Enable/disable:
- `TTS_ENABLE_RATE_LIMIT` (default: `false`)
Per-user daily limits:
- `TTS_DAILY_LIMIT_ANONYMOUS` (default: `50000`)
- `TTS_DAILY_LIMIT_AUTHENTICATED` (default: `500000`)
IP backstop daily limits:
- `TTS_IP_DAILY_LIMIT_ANONYMOUS` (default: `100000`)
- `TTS_IP_DAILY_LIMIT_AUTHENTICATED` (default: `1000000`)
- First-boot seed toggle: `RUNTIME_SEED_DISABLE_TTS_LIMIT` (default: `true`)
- Per-user and IP backstop limit values are configured in **Settings → Admin → Site features** and stored in DB runtime settings.
## Related docs
- TTS/rate-limit environment variables: [Environment Variables](../reference/environment-variables#tts-provider-and-request-behavior)
- PDF parsing rate limiting (separate, compute-side throttle): [Admin Panel → Site features](./admin-panel#site-features) and [Environment Variables](../reference/environment-variables#compute-pdf-parsing-rate-limiting-runtime-settings)
- Auth configuration: [Auth](./auth)
- Provider setup: [TTS Providers](./tts-providers)

View file

@ -24,11 +24,6 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `TTS_RETRY_MAX_MS` | TTS retry | `2000` | Tune max retry delay |
| `TTS_RETRY_BACKOFF` | TTS retry | `2` | Tune exponential backoff factor |
| `TTS_UPSTREAM_TIMEOUT_MS` | TTS request timeout | `285000` | Set max upstream TTS request duration before fail-fast |
| `TTS_ENABLE_RATE_LIMIT` | Rate limiting | `false` | Set `true` to enable TTS per-user/IP daily character limits |
| `TTS_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `50000` | Override anonymous per-user daily character limit |
| `TTS_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `500000` | Override authenticated per-user daily character limit |
| `TTS_IP_DAILY_LIMIT_ANONYMOUS` | Rate limiting | `100000` | Override anonymous IP backstop daily limit |
| `TTS_IP_DAILY_LIMIT_AUTHENTICATED` | Rate limiting | `1000000` | Override authenticated IP backstop daily limit |
| `BASE_URL` | Auth | unset | Required (with `AUTH_SECRET`) to enable auth |
| `AUTH_SECRET` | Auth | unset | Required (with `BASE_URL`) to enable auth |
| `AUTH_TRUSTED_ORIGINS` | Auth | empty | Add extra allowed origins |
@ -36,6 +31,7 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `GITHUB_CLIENT_ID` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_SECRET` to enable GitHub sign-in |
| `GITHUB_CLIENT_SECRET` | Auth/OAuth | unset | Set with `GITHUB_CLIENT_ID` to enable GitHub sign-in |
| `DISABLE_AUTH_RATE_LIMIT` | Rate limiting | `false` | Set `true` to disable auth-layer rate limiting |
| `ENABLE_TEST_NAMESPACE` | Testing/CI | unset | Honor the `x-openreader-test-namespace` header on a production build; leave unset on real deployments |
| `ADMIN_EMAILS` | Auth/Admin | empty | Comma-separated emails auto-promoted to admin (requires auth enabled) |
| `POSTGRES_URL` | Database | unset (SQLite mode) | Set to switch metadata/auth DB to Postgres |
| `USE_EMBEDDED_WEED_MINI` | Storage | `true` when unset | Set `false` to use external S3-compatible storage only |
@ -48,8 +44,6 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `S3_ENDPOINT` | Storage | derived in embedded mode | Set for S3-compatible providers (MinIO/SeaweedFS/R2/etc.) |
| `S3_FORCE_PATH_STYLE` | Storage | `true` in embedded mode | Set per provider requirement |
| `S3_PREFIX` | Storage | `openreader` | Customize object key prefix |
| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations |
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
| `IMPORT_LIBRARY_DIR` | Library import | `docstore/library` fallback | Set a single server library root |
| `IMPORT_LIBRARY_DIRS` | Library import | unset | Set multiple roots (comma/colon/semicolon separated) |
| `COMPUTE_WORKER_URL` | Heavy compute backend | unset | Set only for standalone external compute worker; leave unset for embedded worker startup |
@ -76,6 +70,10 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o
| `RUNTIME_SEED_RESTRICT_USER_API_KEYS` | Legacy bootstrap seed | runtime-dependent | Optional first-boot seed to restrict per-user BYOK |
| `RUNTIME_SEED_DEFAULT_TTS_PROVIDER` | Legacy bootstrap seed | `custom-openai` | Optional first-boot seed for default TTS provider slug |
| `RUNTIME_SEED_ENABLE_AUDIOBOOK_EXPORT` | Legacy bootstrap seed | `true` | Optional first-boot seed to enable audiobook export UI |
| `RUNTIME_SEED_DISABLE_TTS_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps TTS daily rate limiting disabled |
| `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT` | Legacy bootstrap seed | `true` | Optional first-boot seed that keeps PDF parsing rate limiting disabled (other compute-limit values + max upload size are admin-only) |
| `RUN_DRIZZLE_MIGRATIONS` | Database migrations | `true` | Set `false` to skip startup Drizzle schema migrations |
| `RUN_FS_MIGRATIONS` | Storage migrations | `true` | Set `false` to skip startup filesystem -> S3/DB migration pass |
@ -158,37 +156,22 @@ Maximum upstream TTS request timeout in milliseconds.
- Applies to outbound provider calls from server routes using shared TTS generation
- Increase for slower providers/models; decrease to fail fast and surface retryable errors sooner
### TTS_ENABLE_RATE_LIMIT
### TTS Daily Rate Limiting (Runtime Settings)
Controls TTS character rate limiting in the TTS API.
TTS character rate limiting is now managed from **Settings → Admin → Site features**.
- Default: `false` (TTS char limits disabled)
- Set to `true` to enforce `TTS_DAILY_LIMIT_*` and `TTS_IP_DAILY_LIMIT_*`
- For behavior details and examples, see [TTS Rate Limiting](../configure/tts-rate-limiting)
- `disableTtsRateLimit` default: `true` (rate limiting disabled)
- Daily limit defaults:
- Anonymous per-user: `50000`
- Authenticated per-user: `500000`
- Anonymous IP backstop: `100000`
- Authenticated IP backstop: `1000000`
### TTS_DAILY_LIMIT_ANONYMOUS
Optional first-boot seeds:
Anonymous per-user daily character limit.
- `RUNTIME_SEED_DISABLE_TTS_LIMIT`
- Default: `50000`
### TTS_DAILY_LIMIT_AUTHENTICATED
Authenticated per-user daily character limit.
- Default: `500000`
### TTS_IP_DAILY_LIMIT_ANONYMOUS
Anonymous IP backstop daily character limit.
- Default: `100000`
### TTS_IP_DAILY_LIMIT_AUTHENTICATED
Authenticated IP backstop daily character limit.
- Default: `1000000`
After first boot, these values are DB-backed admin runtime settings.
## Auth and Identity
@ -247,6 +230,15 @@ Controls Better Auth rate limiting.
- This does not affect TTS character rate limiting
- Related docs: [Auth](../configure/auth)
### ENABLE_TEST_NAMESPACE
Honors the `x-openreader-test-namespace` request header, which scopes documents/storage into an isolated namespace for end-to-end tests.
- Default: unset (header ignored on production builds)
- Non-production builds (`NODE_ENV !== 'production'`) honor the header without this flag.
- Production builds (`pnpm build && pnpm start`) honor it only when `ENABLE_TEST_NAMESPACE=true`. The Playwright web server sets this automatically.
- **Leave unset on real deployments.** It is test/CI scaffolding only.
### ADMIN_EMAILS
Comma-separated list of email addresses that are auto-promoted to admin.
@ -349,24 +341,13 @@ Prefix prepended to stored object keys.
- Default: `openreader`
- Related docs: [Object / Blob Storage](../configure/object-blob-storage)
## Migration Controls
### Maximum Upload Size (Runtime Settings)
### RUN_DRIZZLE_MIGRATIONS
The maximum size accepted for a single document upload is an admin runtime setting (no env var).
Controls startup migration execution in shared entrypoint.
- Default: `true`
- Set `false` to skip automatic startup Drizzle schema migrations
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database)
### RUN_FS_MIGRATIONS
Controls startup filesystem-to-object-store migration execution in shared entrypoint.
- Default: `true`
- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations
- Set `false` to skip automatic storage migration pass
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage)
- Runtime key: `maxUploadMb` (default: `200`)
- Configure in **Settings → Admin → Site features → Max upload size (MB)**.
- Enforced up front (`413`) and signed into the presigned S3 PUT so a client cannot stream more than it declared.
## Library Import
@ -511,6 +492,19 @@ Absolute path or executable name for the ffmpeg binary used by audiobook/process
- Resolution order: `FFMPEG_BIN` -> `ffmpeg-static`
- Example: `/var/task/node_modules/ffmpeg-static/ffmpeg`
### Compute (PDF Parsing) Rate Limiting (Runtime Settings)
Per-user throttling of expensive PDF layout parsing is managed from **Settings → Admin → Site features**, not env vars. Enforcement applies only when auth is enabled.
- `disableComputeRateLimit` default: `true` (rate limiting disabled, like the TTS limit; enable it in the admin panel)
- When enabled, the following admin-tunable sub-limits apply:
- `computeParseBurstMax` (default `8`) over `computeParseBurstWindowSec` (default `60`)
- `computeParseSustainedMax` (default `24`) over `computeParseSustainedWindowSec` (default `600`)
- The sustained window also acts as a concurrency cap: because the worker bounds each job's duration, the count of parses started in that window is an upper bound on those still running.
- Exceeding a limit returns `429` (`application/problem+json`) with `Retry-After`.
Optional first-boot seed: `RUNTIME_SEED_DISABLE_COMPUTE_LIMIT`. All other values are DB-backed admin runtime settings.
## Legacy First-Boot Runtime Seeds (optional)
These variables exist only as **first-boot seeds** for the admin-managed runtime config. Prefer changing site features from **Settings → Admin → Site features**. Keep these only when you need bootstrap defaults before the first admin login. See [Admin Panel](../configure/admin-panel) for migration behavior.
@ -583,3 +577,38 @@ Controls whether audiobook export UI/actions are shown in the client.
- Default: `true` (enabled)
- Affects export entry points in PDF/EPUB pages and document settings UI
- Runtime key: `enableAudiobookExport`
### RUNTIME_SEED_DISABLE_TTS_LIMIT
Seeds the TTS daily character rate-limit on/off state on first boot.
- Default: `true` (TTS rate limiting disabled)
- Runtime key: `disableTtsRateLimit`
- Per-user/IP daily limit values are admin-only runtime settings (see [TTS Daily Rate Limiting](#tts-daily-rate-limiting-runtime-settings)).
### RUNTIME_SEED_DISABLE_COMPUTE_LIMIT
Seeds the PDF parsing rate-limit on/off state on first boot.
- Default: `true` (PDF parsing rate limiting disabled, matching `RUNTIME_SEED_DISABLE_TTS_LIMIT`)
- Runtime key: `disableComputeRateLimit`
- The burst/sustained limits, their windows, and the max upload size are admin-only runtime settings (see [Compute (PDF Parsing) Rate Limiting](#compute-pdf-parsing-rate-limiting-runtime-settings)).
## Migration Controls
### RUN_DRIZZLE_MIGRATIONS
Controls startup migration execution in shared entrypoint.
- Default: `true`
- Set `false` to skip automatic startup Drizzle schema migrations
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database)
### RUN_FS_MIGRATIONS
Controls startup filesystem-to-object-store migration execution in shared entrypoint.
- Default: `true`
- Runs `scripts/migrate-fs-v2.mjs` at startup after DB migrations
- Set `false` to skip automatic storage migration pass
- Related docs: [Migrations](../configure/migrations), [Database](../configure/database), [Object / Blob Storage](../configure/object-blob-storage)

View file

@ -0,0 +1,9 @@
CREATE TABLE "user_job_events" (
"user_id" text NOT NULL,
"action" text NOT NULL,
"op_id" text NOT NULL,
"created_at" bigint DEFAULT (extract(epoch from now()) * 1000)::bigint NOT NULL,
CONSTRAINT "user_job_events_user_id_action_op_id_pk" PRIMARY KEY("user_id","action","op_id")
);
--> statement-breakpoint
CREATE INDEX "idx_user_job_events_user_action_created" ON "user_job_events" USING btree ("user_id","action","created_at");

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,13 @@
"when": 1779378127846,
"tag": "0007_pdf_parse_progress",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1780162695652,
"tag": "0008_user_job_events",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,9 @@
CREATE TABLE `user_job_events` (
`user_id` text NOT NULL,
`action` text NOT NULL,
`op_id` text NOT NULL,
`created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL,
PRIMARY KEY(`user_id`, `action`, `op_id`)
);
--> statement-breakpoint
CREATE INDEX `idx_user_job_events_user_action_created` ON `user_job_events` (`user_id`,`action`,`created_at`);

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,13 @@
"when": 1779378125905,
"tag": "0007_pdf_parse_progress",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1780162695101,
"tag": "0008_user_job_events",
"breakpoints": true
}
]
}

View file

@ -12,6 +12,9 @@
"start:raw": "next start -p 3003",
"lint": "next lint",
"test": "playwright test",
"test:e2e": "playwright test",
"test:unit": "vitest run",
"test:compute": "vitest run --project compute-core --project compute-worker",
"test:ci-env": "CI=true playwright test",
"migrate": "node drizzle/scripts/migrate.mjs",
"migrate-fs": "node scripts/migrate-fs-v2.mjs",
@ -90,6 +93,7 @@
"eslint-config-next": "^15.5.18",
"postcss": "^8.5.14",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.7"
}
}

View file

@ -16,6 +16,7 @@ if (process.env.CI === 'true') {
*/
export default defineConfig({
testDir: './tests',
testIgnore: '**/unit/**',
tsconfig: './tsconfig.json',
timeout: 30 * 1000,
outputDir: './tests/results',
@ -39,8 +40,10 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
// Disable auth rate limiting for tests to support parallel workers creating sessions
command: `pnpm build && DISABLE_AUTH_RATE_LIMIT=true pnpm start`,
// Disable auth rate limiting for tests to support parallel workers creating sessions.
// ENABLE_TEST_NAMESPACE opts the production build into honoring the
// x-openreader-test-namespace header (ignored on real prod deployments).
command: `pnpm build && DISABLE_AUTH_RATE_LIMIT=true ENABLE_TEST_NAMESPACE=true pnpm start`,
url: 'http://localhost:3003',
reuseExistingServer: !process.env.CI,
timeout: 120 * 1000,
@ -58,7 +61,6 @@ export default defineConfig({
{
name: 'firefox',
testIgnore: '**/unit/**',
use: {
...devices['Desktop Firefox'],
extraHTTPHeaders: { 'x-openreader-test-namespace': 'firefox' },
@ -67,7 +69,6 @@ export default defineConfig({
{
name: 'webkit',
testIgnore: '**/unit/**',
use: {
...devices['Desktop Safari'],
extraHTTPHeaders: { 'x-openreader-test-namespace': 'webkit' },

View file

@ -42,7 +42,7 @@ importers:
version: 7.0.1
better-auth:
specifier: ^1.6.11
version: 1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(kysely@0.28.17)(pg@8.20.0))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
version: 1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(kysely@0.28.17)(pg@8.20.0))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)))
better-sqlite3:
specifier: ^12.10.0
version: 12.10.0
@ -194,6 +194,9 @@ importers:
typescript:
specifier: ^5.9.3
version: 5.9.3
vitest:
specifier: ^4.1.7
version: 4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3))
compute/core:
dependencies:
@ -1569,6 +1572,12 @@ packages:
'@napi-rs/wasm-runtime@0.2.12':
resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==}
'@napi-rs/wasm-runtime@1.1.4':
resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==}
peerDependencies:
'@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1
'@nats-io/jetstream@3.4.0':
resolution: {integrity: sha512-GzHQodNJ942+R5LRb8PuZ5ugVWVWMRiufxUYLLVWkXKfwDXYN+Owo0d7L/b9O7BPyrbYD7jQWAC6+ZVuXa9Gyw==}
@ -1679,6 +1688,9 @@ packages:
resolution: {integrity: sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==}
engines: {node: '>=14'}
'@oxc-project/types@0.132.0':
resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==}
'@pinojs/redact@0.4.0':
resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==}
@ -1717,6 +1729,104 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
'@rolldown/binding-android-arm64@1.0.2':
resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@rolldown/binding-darwin-arm64@1.0.2':
resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@rolldown/binding-darwin-x64@1.0.2':
resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@rolldown/binding-freebsd-x64@1.0.2':
resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@rolldown/binding-linux-arm64-gnu@1.0.2':
resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.2':
resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.2':
resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.2':
resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.2':
resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.2':
resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@rolldown/binding-wasm32-wasi@1.0.2':
resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [wasm32]
'@rolldown/binding-win32-arm64-msvc@1.0.2':
resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@rolldown/binding-win32-x64-msvc@1.0.2':
resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@rolldown/pluginutils@1.0.1':
resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==}
'@rtsao/scc@1.1.0':
resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==}
@ -1960,9 +2070,15 @@ packages:
'@types/better-sqlite3@7.6.13':
resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
'@types/debug@4.1.13':
resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==}
'@types/deep-eql@4.0.2':
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
'@types/estree-jsx@1.0.5':
resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==}
@ -2207,6 +2323,35 @@ packages:
vue-router:
optional: true
'@vitest/expect@4.1.7':
resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==}
'@vitest/mocker@4.1.7':
resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==}
peerDependencies:
msw: ^2.4.9
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
msw:
optional: true
vite:
optional: true
'@vitest/pretty-format@4.1.7':
resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==}
'@vitest/runner@4.1.7':
resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==}
'@vitest/snapshot@4.1.7':
resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==}
'@vitest/spy@4.1.7':
resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==}
'@vitest/utils@4.1.7':
resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==}
'@xmldom/xmldom@0.9.10':
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
@ -2327,6 +2472,10 @@ packages:
resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==}
engines: {node: '>= 0.4'}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
ast-types-flow@0.0.8:
resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==}
@ -2569,6 +2718,10 @@ packages:
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
chai@6.2.2:
resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==}
engines: {node: '>=18'}
chalk@4.1.2:
resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==}
engines: {node: '>=10'}
@ -2635,6 +2788,9 @@ packages:
resolution: {integrity: sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==}
engines: {'0': node >= 6.0}
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
cookie@1.1.1:
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
engines: {node: '>=18'}
@ -2909,6 +3065,9 @@ packages:
resolution: {integrity: sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==}
engines: {node: '>= 0.4'}
es-module-lexer@2.1.0:
resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==}
es-object-atoms@1.1.1:
resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
engines: {node: '>= 0.4'}
@ -3087,6 +3246,9 @@ packages:
estree-util-is-identifier-name@3.0.0:
resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
engines: {node: '>=0.10.0'}
@ -3109,6 +3271,10 @@ packages:
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
engines: {node: '>=6'}
expect-type@1.3.0:
resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==}
engines: {node: '>=12.0.0'}
ext@1.7.0:
resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==}
@ -3624,6 +3790,80 @@ packages:
light-my-request@6.6.0:
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [android]
lightningcss-darwin-arm64@1.32.0:
resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [darwin]
lightningcss-darwin-x64@1.32.0:
resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [darwin]
lightningcss-freebsd-x64@1.32.0:
resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [freebsd]
lightningcss-linux-arm-gnueabihf@1.32.0:
resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==}
engines: {node: '>= 12.0.0'}
cpu: [arm]
os: [linux]
lightningcss-linux-arm64-gnu@1.32.0:
resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [win32]
lightningcss-win32-x64-msvc@1.32.0:
resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [win32]
lightningcss@1.32.0:
resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==}
engines: {node: '>= 12.0.0'}
lilconfig@3.1.3:
resolution: {integrity: sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==}
engines: {node: '>=14'}
@ -3658,6 +3898,9 @@ packages:
resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==}
engines: {node: 20 || >=22}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
make-cancellable-promise@1.3.2:
resolution: {integrity: sha512-GCXh3bq/WuMbS+Ky4JBPW1hYTOU+znU+Q5m9Pu+pI8EoUqIHk9+tviOKC6/qhHh8C4/As3tzJ69IF32kdz85ww==}
@ -3953,6 +4196,9 @@ packages:
resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==}
engines: {node: '>= 0.4'}
obug@2.1.1:
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
on-exit-leak-free@2.1.2:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
@ -4037,6 +4283,9 @@ packages:
resolution: {integrity: sha512-+vnG6S4dYcYxZd+CZxzXCNKdELYZSKfohrk98yajCo1PtRoDgCTrrwOvK1GT0UoAdVszagDVllQc0U1vaX4NUQ==}
engines: {node: '>=6'}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
pdfjs-dist@4.8.69:
resolution: {integrity: sha512-IHZsA4T7YElCKNNXtiLgqScw4zPd3pG9do8UrznC757gMd7UPeHSL2qwNNMJo4r79fl8oj1Xx+1nh2YkzdMpLQ==}
engines: {node: '>=18'}
@ -4177,6 +4426,10 @@ packages:
resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==}
engines: {node: ^10 || ^12 || >=14}
postcss@8.5.15:
resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==}
engines: {node: ^10 || ^12 || >=14}
postgres-array@2.0.0:
resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==}
engines: {node: '>=4'}
@ -4411,6 +4664,11 @@ packages:
rfdc@1.4.1:
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
rolldown@1.0.2:
resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
rou3@0.7.12:
resolution: {integrity: sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==}
@ -4511,6 +4769,9 @@ packages:
resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
engines: {node: '>= 0.4'}
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
@ -4545,6 +4806,12 @@ packages:
stable-hash@0.0.5:
resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==}
stackback@0.0.2:
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
std-env@4.1.0:
resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==}
stop-iteration-iterator@1.1.0:
resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==}
engines: {node: '>= 0.4'}
@ -4692,10 +4959,21 @@ packages:
tiny-invariant@1.3.3:
resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==}
tinybench@2.9.0:
resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==}
tinyexec@1.2.3:
resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==}
engines: {node: '>=18'}
tinyglobby@0.2.16:
resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==}
engines: {node: '>=12.0.0'}
tinyrainbow@3.1.0:
resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==}
engines: {node: '>=14.0.0'}
to-regex-range@5.0.1:
resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==}
engines: {node: '>=8.0'}
@ -4825,6 +5103,90 @@ packages:
vfile@6.0.3:
resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==}
vite@8.0.14:
resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
'@types/node': ^20.19.0 || >=22.12.0
'@vitejs/devtools': ^0.1.18
esbuild: ^0.27.0 || ^0.28.0
jiti: '>=1.21.0'
less: ^4.0.0
sass: ^1.70.0
sass-embedded: ^1.70.0
stylus: '>=0.54.8'
sugarss: ^5.0.0
terser: ^5.16.0
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
'@types/node':
optional: true
'@vitejs/devtools':
optional: true
esbuild:
optional: true
jiti:
optional: true
less:
optional: true
sass:
optional: true
sass-embedded:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
tsx:
optional: true
yaml:
optional: true
vitest@4.1.7:
resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==}
engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0}
hasBin: true
peerDependencies:
'@edge-runtime/vm': '*'
'@opentelemetry/api': ^1.9.0
'@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0
'@vitest/browser-playwright': 4.1.7
'@vitest/browser-preview': 4.1.7
'@vitest/browser-webdriverio': 4.1.7
'@vitest/coverage-istanbul': 4.1.7
'@vitest/coverage-v8': 4.1.7
'@vitest/ui': 4.1.7
happy-dom: '*'
jsdom: '*'
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
peerDependenciesMeta:
'@edge-runtime/vm':
optional: true
'@opentelemetry/api':
optional: true
'@types/node':
optional: true
'@vitest/browser-playwright':
optional: true
'@vitest/browser-preview':
optional: true
'@vitest/browser-webdriverio':
optional: true
'@vitest/coverage-istanbul':
optional: true
'@vitest/coverage-v8':
optional: true
'@vitest/ui':
optional: true
happy-dom:
optional: true
jsdom:
optional: true
warning@4.0.3:
resolution: {integrity: sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==}
@ -4849,6 +5211,11 @@ packages:
engines: {node: '>= 8'}
hasBin: true
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@ -6248,6 +6615,13 @@ snapshots:
'@tybys/wasm-util': 0.10.2
optional: true
'@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@tybys/wasm-util': 0.10.2
optional: true
'@nats-io/jetstream@3.4.0':
dependencies:
'@nats-io/nats-core': 3.4.0
@ -6326,6 +6700,8 @@ snapshots:
'@opentelemetry/semantic-conventions@1.41.1': {}
'@oxc-project/types@0.132.0': {}
'@pinojs/redact@0.4.0': {}
'@pkgjs/parseargs@0.11.0':
@ -6360,6 +6736,57 @@ snapshots:
dependencies:
react: 19.2.6
'@rolldown/binding-android-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-arm64@1.0.2':
optional: true
'@rolldown/binding-darwin-x64@1.0.2':
optional: true
'@rolldown/binding-freebsd-x64@1.0.2':
optional: true
'@rolldown/binding-linux-arm-gnueabihf@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-arm64-musl@1.0.2':
optional: true
'@rolldown/binding-linux-ppc64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-s390x-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-gnu@1.0.2':
optional: true
'@rolldown/binding-linux-x64-musl@1.0.2':
optional: true
'@rolldown/binding-openharmony-arm64@1.0.2':
optional: true
'@rolldown/binding-wasm32-wasi@1.0.2':
dependencies:
'@emnapi/core': 1.10.0
'@emnapi/runtime': 1.10.0
'@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)
optional: true
'@rolldown/binding-win32-arm64-msvc@1.0.2':
optional: true
'@rolldown/binding-win32-x64-msvc@1.0.2':
optional: true
'@rolldown/pluginutils@1.0.1': {}
'@rtsao/scc@1.1.0': {}
'@rushstack/eslint-patch@1.16.1': {}
@ -6660,10 +7087,17 @@ snapshots:
dependencies:
'@types/node': 20.19.41
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
assertion-error: 2.0.1
'@types/debug@4.1.13':
dependencies:
'@types/ms': 2.1.0
'@types/deep-eql@4.0.2': {}
'@types/estree-jsx@1.0.5':
dependencies:
'@types/estree': 1.0.9
@ -6873,6 +7307,47 @@ snapshots:
next: 15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
react: 19.2.6
'@vitest/expect@4.1.7':
dependencies:
'@standard-schema/spec': 1.1.0
'@types/chai': 5.2.3
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.7(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3))':
dependencies:
'@vitest/spy': 4.1.7
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)
'@vitest/pretty-format@4.1.7':
dependencies:
tinyrainbow: 3.1.0
'@vitest/runner@4.1.7':
dependencies:
'@vitest/utils': 4.1.7
pathe: 2.0.3
'@vitest/snapshot@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
'@vitest/utils': 4.1.7
magic-string: 0.30.21
pathe: 2.0.3
'@vitest/spy@4.1.7': {}
'@vitest/utils@4.1.7':
dependencies:
'@vitest/pretty-format': 4.1.7
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@xmldom/xmldom@0.9.10': {}
abort-controller@3.0.0:
@ -7031,6 +7506,8 @@ snapshots:
get-intrinsic: 1.3.0
is-array-buffer: 3.0.5
assertion-error@2.0.1: {}
ast-types-flow@0.0.8: {}
async-function@1.0.0: {}
@ -7096,7 +7573,7 @@ snapshots:
base64-js@1.5.1: {}
better-auth@1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(kysely@0.28.17)(pg@8.20.0))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6):
better-auth@1.6.11(better-sqlite3@12.10.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(kysely@0.28.17)(pg@8.20.0))(next@15.5.18(@playwright/test@1.60.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(pg@8.20.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3))):
dependencies:
'@better-auth/core': 1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0)
'@better-auth/drizzle-adapter': 1.6.11(@better-auth/core@1.6.11(@better-auth/utils@0.4.0)(@better-fetch/fetch@1.1.21)(better-call@1.3.5(zod@4.4.3))(jose@6.2.3)(kysely@0.28.17)(nanostores@1.3.0))(@better-auth/utils@0.4.0)(drizzle-orm@0.45.2(@types/better-sqlite3@7.6.13)(@types/pg@8.20.0)(better-sqlite3@12.10.0)(kysely@0.28.17)(pg@8.20.0))
@ -7123,6 +7600,7 @@ snapshots:
pg: 8.20.0
react: 19.2.6
react-dom: 19.2.6(react@19.2.6)
vitest: 4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3))
transitivePeerDependencies:
- '@cloudflare/workers-types'
- '@opentelemetry/api'
@ -7219,6 +7697,8 @@ snapshots:
ccount@2.0.1: {}
chai@6.2.2: {}
chalk@4.1.2:
dependencies:
ansi-styles: 4.3.0
@ -7287,6 +7767,8 @@ snapshots:
readable-stream: 3.6.2
typedarray: 0.0.6
convert-source-map@2.0.0: {}
cookie@1.1.1: {}
core-js@3.49.0: {}
@ -7531,6 +8013,8 @@ snapshots:
iterator.prototype: 1.1.5
math-intrinsics: 1.1.0
es-module-lexer@2.1.0: {}
es-object-atoms@1.1.1:
dependencies:
es-errors: 1.3.0
@ -7891,6 +8375,10 @@ snapshots:
estree-util-is-identifier-name@3.0.0: {}
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.9
esutils@2.0.3: {}
event-emitter@0.3.5:
@ -7910,6 +8398,8 @@ snapshots:
expand-template@2.0.3: {}
expect-type@1.3.0: {}
ext@1.7.0:
dependencies:
type: 2.7.3
@ -8485,6 +8975,55 @@ snapshots:
process-warning: 4.0.1
set-cookie-parser: 2.7.2
lightningcss-android-arm64@1.32.0:
optional: true
lightningcss-darwin-arm64@1.32.0:
optional: true
lightningcss-darwin-x64@1.32.0:
optional: true
lightningcss-freebsd-x64@1.32.0:
optional: true
lightningcss-linux-arm-gnueabihf@1.32.0:
optional: true
lightningcss-linux-arm64-gnu@1.32.0:
optional: true
lightningcss-linux-arm64-musl@1.32.0:
optional: true
lightningcss-linux-x64-gnu@1.32.0:
optional: true
lightningcss-linux-x64-musl@1.32.0:
optional: true
lightningcss-win32-arm64-msvc@1.32.0:
optional: true
lightningcss-win32-x64-msvc@1.32.0:
optional: true
lightningcss@1.32.0:
dependencies:
detect-libc: 2.1.2
optionalDependencies:
lightningcss-android-arm64: 1.32.0
lightningcss-darwin-arm64: 1.32.0
lightningcss-darwin-x64: 1.32.0
lightningcss-freebsd-x64: 1.32.0
lightningcss-linux-arm-gnueabihf: 1.32.0
lightningcss-linux-arm64-gnu: 1.32.0
lightningcss-linux-arm64-musl: 1.32.0
lightningcss-linux-x64-gnu: 1.32.0
lightningcss-linux-x64-musl: 1.32.0
lightningcss-win32-arm64-msvc: 1.32.0
lightningcss-win32-x64-msvc: 1.32.0
lilconfig@3.1.3: {}
lines-and-columns@1.2.4: {}
@ -8511,6 +9050,10 @@ snapshots:
lru-cache@11.3.6: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
make-cancellable-promise@1.3.2: {}
make-event-props@1.6.2: {}
@ -9008,6 +9551,8 @@ snapshots:
define-properties: 1.2.1
es-object-atoms: 1.1.1
obug@2.1.1: {}
on-exit-leak-free@2.1.2: {}
once@1.4.0:
@ -9087,6 +9632,8 @@ snapshots:
path2d@0.2.2:
optional: true
pathe@2.0.3: {}
pdfjs-dist@4.8.69:
optionalDependencies:
canvas: 3.2.3
@ -9232,6 +9779,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
postcss@8.5.15:
dependencies:
nanoid: 3.3.12
picocolors: 1.1.1
source-map-js: 1.2.1
postgres-array@2.0.0: {}
postgres-bytea@1.0.1: {}
@ -9537,6 +10090,27 @@ snapshots:
rfdc@1.4.1: {}
rolldown@1.0.2:
dependencies:
'@oxc-project/types': 0.132.0
'@rolldown/pluginutils': 1.0.1
optionalDependencies:
'@rolldown/binding-android-arm64': 1.0.2
'@rolldown/binding-darwin-arm64': 1.0.2
'@rolldown/binding-darwin-x64': 1.0.2
'@rolldown/binding-freebsd-x64': 1.0.2
'@rolldown/binding-linux-arm-gnueabihf': 1.0.2
'@rolldown/binding-linux-arm64-gnu': 1.0.2
'@rolldown/binding-linux-arm64-musl': 1.0.2
'@rolldown/binding-linux-ppc64-gnu': 1.0.2
'@rolldown/binding-linux-s390x-gnu': 1.0.2
'@rolldown/binding-linux-x64-gnu': 1.0.2
'@rolldown/binding-linux-x64-musl': 1.0.2
'@rolldown/binding-openharmony-arm64': 1.0.2
'@rolldown/binding-wasm32-wasi': 1.0.2
'@rolldown/binding-win32-arm64-msvc': 1.0.2
'@rolldown/binding-win32-x64-msvc': 1.0.2
rou3@0.7.12: {}
run-parallel@1.2.0:
@ -9678,6 +10252,8 @@ snapshots:
side-channel-map: 1.0.1
side-channel-weakmap: 1.0.2
siginfo@2.0.0: {}
signal-exit@4.1.0: {}
simple-concat@1.0.1: {}
@ -9707,6 +10283,10 @@ snapshots:
stable-hash@0.0.5: {}
stackback@0.0.2: {}
std-env@4.1.0: {}
stop-iteration-iterator@1.1.0:
dependencies:
es-errors: 1.3.0
@ -9928,11 +10508,17 @@ snapshots:
tiny-invariant@1.3.3: {}
tinybench@2.9.0: {}
tinyexec@1.2.3: {}
tinyglobby@0.2.16:
dependencies:
fdir: 6.5.0(picomatch@4.0.4)
picomatch: 4.0.4
tinyrainbow@3.1.0: {}
to-regex-range@5.0.1:
dependencies:
is-number: 7.0.0
@ -10110,6 +10696,47 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.0.2
tinyglobby: 0.2.16
optionalDependencies:
'@types/node': 20.19.41
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 1.21.7
tsx: 4.22.3
vitest@4.1.7(@types/node@20.19.41)(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)):
dependencies:
'@vitest/expect': 4.1.7
'@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3))
'@vitest/pretty-format': 4.1.7
'@vitest/runner': 4.1.7
'@vitest/snapshot': 4.1.7
'@vitest/spy': 4.1.7
'@vitest/utils': 4.1.7
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.1
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.2.3
tinyglobby: 0.2.16
tinyrainbow: 3.1.0
vite: 8.0.14(@types/node@20.19.41)(esbuild@0.28.0)(jiti@1.21.7)(tsx@4.22.3)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 20.19.41
transitivePeerDependencies:
- msw
warning@4.0.3:
dependencies:
loose-envify: 1.4.0
@ -10159,6 +10786,11 @@ snapshots:
dependencies:
isexe: 2.0.0
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:

View file

@ -243,7 +243,7 @@ export default function EPUBPage() {
)}
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>

View file

@ -234,7 +234,7 @@ export default function HTMLPage() {
)}
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>

View file

@ -441,7 +441,7 @@ export default function PDFViewerPage() {
)}
{isAtLimit ? (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
<RateLimitPauseButton />
<RateLimitBanner />
</div>

View file

@ -8,7 +8,7 @@ import { and, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
import { audiobooks, audiobookChapters } from '@/db/schema';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -532,7 +532,15 @@ export async function POST(request: NextRequest) {
sharedDefaultInstructions: credResolved.adminRecord?.defaultInstructions,
});
if (authEnabled && userId && isTtsRateLimitEnabled()) {
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
if (authEnabled && userId && ttsRateLimitEnabled) {
const isAnonymous = Boolean(user?.isAnonymous);
const charCount = data.text.length;
const ip = getClientIp(request);
@ -549,6 +557,10 @@ export async function POST(request: NextRequest) {
deviceId: device?.deviceId ?? null,
ip,
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
if (!rateLimitResult.allowed) {
@ -570,7 +582,7 @@ export async function POST(request: NextRequest) {
resetTimeMs,
userType: isAnonymous ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymous
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
? `Sign up to increase your limit from ${formatLimitForHint(limits.anonymous)} to ${formatLimitForHint(limits.authenticated)} characters per day`
: undefined,
instance: request.nextUrl.pathname,
};

View file

@ -22,6 +22,9 @@ import {
} from '@/lib/server/documents/parse-state';
import { healStaleDocumentParseState } from '@/lib/server/documents/parse-state-healing';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { checkJobRate, recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
import { buildComputeRateLimitedResponse } from '@/lib/server/rate-limit/problem-response';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { isS3Configured } from '@/lib/server/storage/s3';
import { createRequestLogger, hashForLog, type ServerLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
@ -263,11 +266,17 @@ export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string
}
if (effectiveStatus === 'failed' && retryFailed) {
const rateConfig = getPdfLayoutRateConfig(await getResolvedRuntimeConfig());
const rateDecision = await checkJobRate(authCtxOrRes.userId, 'pdf_layout', rateConfig);
if (!rateDecision.allowed) {
return buildComputeRateLimitedResponse({ decision: rateDecision, pathname: req.nextUrl.pathname });
}
const created = await createOrReusePdfWorkerOperation({
documentId: id,
namespace: testNamespace,
documentObjectKey: documentKey(id, testNamespace),
});
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created);
return NextResponse.json({
parseStatus: snapshot.parseStatus,
@ -385,12 +394,19 @@ export async function POST(req: NextRequest, ctx: { params: Promise<{ id: string
}
}
const rateConfig = getPdfLayoutRateConfig(await getResolvedRuntimeConfig());
const rateDecision = await checkJobRate(authCtxOrRes.userId, 'pdf_layout', rateConfig);
if (!rateDecision.allowed) {
return buildComputeRateLimitedResponse({ decision: rateDecision, pathname: req.nextUrl.pathname });
}
const created = await createOrReusePdfWorkerOperation({
documentId: id,
namespace: testNamespace,
documentObjectKey: documentKey(id, testNamespace),
forceToken: randomUUID(),
});
await recordJobEvent(authCtxOrRes.userId, 'pdf_layout', created.opId, rateConfig);
const snapshot = snapshotFromWorkerState(created);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, putDocumentBlob } from '@/lib/server/documents/blobstore';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { isS3Configured } from '@/lib/server/storage/s3';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -33,7 +34,55 @@ export async function PUT(req: NextRequest) {
}
const contentType = (req.headers.get('content-type') || 'application/octet-stream').trim() || 'application/octet-stream';
const body = Buffer.from(await req.arrayBuffer());
const { maxUploadMb } = await getResolvedRuntimeConfig();
const maxUploadBytes = maxUploadMb * 1024 * 1024;
// Reject before buffering when the declared length is already over the cap.
const declaredLength = Number(req.headers.get('content-length') || '');
if (Number.isFinite(declaredLength) && declaredLength > maxUploadBytes) {
return NextResponse.json(
{ error: `Upload exceeds the maximum allowed size of ${maxUploadBytes} bytes`, maxBytes: maxUploadBytes },
{ status: 413 },
);
}
// Backstop for chunked/omitted Content-Length: stream the body so we can
// bail out as soon as the running total crosses the cap instead of
// buffering the entire payload first.
const stream = req.body;
if (!stream) {
return NextResponse.json({ error: 'Missing request body' }, { status: 400 });
}
const reader = stream.getReader();
const chunks: Uint8Array[] = [];
let totalBytes = 0;
let overLimit = false;
try {
while (true) {
const { value, done } = await reader.read();
if (done) break;
if (!value) continue;
totalBytes += value.byteLength;
if (totalBytes > maxUploadBytes) {
overLimit = true;
break;
}
chunks.push(value);
}
} finally {
if (overLimit) {
await reader.cancel().catch(() => {});
}
reader.releaseLock();
}
if (overLimit) {
return NextResponse.json(
{ error: `Upload exceeds the maximum allowed size of ${maxUploadBytes} bytes`, maxBytes: maxUploadBytes },
{ status: 413 },
);
}
const body = Buffer.concat(chunks, totalBytes);
const namespace = getOpenReaderTestNamespace(req.headers);
try {

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { requireAuthContext } from '@/lib/server/auth/auth';
import { isValidDocumentId, presignPut } from '@/lib/server/documents/blobstore';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { getOpenReaderTestNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
import { errorToLog, serverLogger } from '@/lib/server/logger';
@ -53,10 +54,25 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: 'No valid uploads provided' }, { status: 400 });
}
const { maxUploadMb } = await getResolvedRuntimeConfig();
const maxUploadBytes = maxUploadMb * 1024 * 1024;
const oversized = uploads.find((upload) => upload.size > maxUploadBytes);
if (oversized) {
return NextResponse.json(
{
error: `Upload exceeds the maximum allowed size of ${maxUploadBytes} bytes`,
maxBytes: maxUploadBytes,
},
{ status: 413 },
);
}
const namespace = getOpenReaderTestNamespace(req.headers);
const signed = await Promise.all(
uploads.map(async (upload) => {
const res = await presignPut(upload.id, upload.contentType, namespace);
const res = await presignPut(upload.id, upload.contentType, namespace, {
contentLength: upload.size,
});
return {
id: upload.id,
url: res.url,

View file

@ -11,6 +11,8 @@ import { documents } from '@/db/schema';
import { safeDocumentName } from '@/lib/server/documents/utils';
import { enqueueDocumentPreview } from '@/lib/server/documents/previews';
import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job';
import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { stringifyDocumentParseState } from '@/lib/server/documents/parse-state';
import { getOpenReaderTestNamespace, getUnclaimedUserIdForNamespace } from '@/lib/server/testing/test-namespace';
import { isS3Configured } from '@/lib/server/storage/s3';
@ -173,6 +175,9 @@ export async function POST(req: NextRequest) {
}, 'Failed to enqueue preview for converted DOCX');
});
// Record upload-driven parse load (see register route for rationale).
const pdfRateConfig = getPdfLayoutRateConfig(await getResolvedRuntimeConfig());
await recordJobEvent(storageUserId, 'pdf_layout', `docx:${randomUUID()}`, pdfRateConfig);
enqueueParsePdfJob({
documentId: id,
userId: storageUserId,

View file

@ -1,3 +1,4 @@
import { randomUUID } from 'node:crypto';
import { NextRequest, NextResponse } from 'next/server';
import { and, count, eq, inArray } from 'drizzle-orm';
import { db } from '@/db';
@ -12,6 +13,8 @@ import {
enqueueDocumentPreview,
} from '@/lib/server/documents/previews';
import { enqueueParsePdfJob } from '@/lib/server/jobs/user-pdf-layout-job';
import { recordJobEvent, getPdfLayoutRateConfig } from '@/lib/server/rate-limit/job-rate-limiter';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { deleteDocumentBlob, headDocumentBlob, isMissingBlobError, isValidDocumentId } from '@/lib/server/documents/blobstore';
import {
normalizeParseStatus,
@ -90,6 +93,11 @@ export async function POST(req: NextRequest) {
const stored: BaseDocument[] = [];
// Resolve the parse rate-limit config once (only when a PDF is present).
const pdfRateConfig = documentsData.some((doc) => doc.type === 'pdf')
? getPdfLayoutRateConfig(await getResolvedRuntimeConfig())
: null;
for (const doc of documentsData) {
let headSize = doc.size;
@ -184,6 +192,11 @@ export async function POST(req: NextRequest) {
});
if (doc.type === 'pdf') {
// Account for upload-driven parse load in the same ledger the explicit
// re-parse limiter reads. We record (not reject) here so a legitimate
// bulk upload always parses; the recorded load still throttles
// subsequent loopable re-parse spam via /parsed.
await recordJobEvent(ctxOrRes.userId, 'pdf_layout', `register:${randomUUID()}`, pdfRateConfig ?? { enabled: false, windows: [] });
enqueueParsePdfJob({
documentId: doc.id,
userId: storageUserId,

View file

@ -1,11 +1,12 @@
import { NextResponse, type NextRequest } from 'next/server';
import { auth } from '@/lib/server/auth/auth';
import { rateLimiter, RATE_LIMITS, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { headers } from 'next/headers';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
import { getOrCreateDeviceId, setDeviceIdCookie } from '@/lib/server/rate-limit/device-id';
import { nextUtcMidnightTimestampMs } from '@/lib/shared/timestamps';
import { getResolvedRuntimeConfig } from '@/lib/server/runtime-config';
import { errorToLog, serverLogger } from '@/lib/server/logger';
import { errorResponse } from '@/lib/server/errors/next-response';
@ -17,7 +18,14 @@ function getUtcResetTimeMs(): number {
export async function GET(req: NextRequest) {
try {
const ttsRateLimitEnabled = isTtsRateLimitEnabled();
const runtimeConfig = await getResolvedRuntimeConfig();
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
// If auth is not enabled, return unlimited status
if (!isAuthEnabled() || !auth) {
@ -46,8 +54,8 @@ export async function GET(req: NextRequest) {
return NextResponse.json({
allowed: true,
currentCount: 0,
limit: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? RATE_LIMITS.ANONYMOUS : Number.MAX_SAFE_INTEGER,
limit: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
remainingChars: ttsRateLimitEnabled ? limits.anonymous : Number.MAX_SAFE_INTEGER,
resetTimeMs,
userType: 'unauthenticated',
authEnabled: true
@ -57,7 +65,7 @@ export async function GET(req: NextRequest) {
const isAnonymous = Boolean((session.user as { isAnonymous?: boolean }).isAnonymous);
const ip = getClientIp(req);
const device = isTtsRateLimitEnabled() ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const device = ttsRateLimitEnabled ? (isAnonymous ? getOrCreateDeviceId(req) : null) : null;
const result = await rateLimiter.getCurrentUsage(
{
@ -67,7 +75,11 @@ export async function GET(req: NextRequest) {
{
deviceId: device?.deviceId ?? null,
ip,
}
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
const response = NextResponse.json({

View file

@ -23,7 +23,7 @@ import {
} from '@/lib/server/tts/segments';
import { isBuiltInTtsProviderId, isTtsProviderType } from '@/lib/shared/tts-provider-catalog';
import { resolveSegmentDocumentScope } from '@/lib/server/tts/segments-auth';
import { rateLimiter, isTtsRateLimitEnabled } from '@/lib/server/rate-limit/rate-limiter';
import { rateLimiter, resolveRateLimitThresholds } from '@/lib/server/rate-limit/rate-limiter';
import { resolveTtsCredentials } from '@/lib/server/admin/resolve-credentials';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { getClientIp } from '@/lib/server/rate-limit/request-ip';
@ -169,6 +169,13 @@ export async function POST(request: NextRequest) {
const scope = await resolveSegmentDocumentScope(request, parsed.documentId);
if (scope instanceof Response) return scope;
const runtimeConfig = await getResolvedRuntimeConfig();
const ttsRateLimitEnabled = !runtimeConfig.disableTtsRateLimit;
const limits = resolveRateLimitThresholds({
anonymous: runtimeConfig.ttsDailyLimitAnonymous,
authenticated: runtimeConfig.ttsDailyLimitAuthenticated,
ipAnonymous: runtimeConfig.ttsIpDailyLimitAnonymous,
ipAuthenticated: runtimeConfig.ttsIpDailyLimitAuthenticated,
});
const requestCreds = await resolveTtsCredentials({
providerHeader: parsed.settings.providerRef,
apiKeyHeader: request.headers.get('x-openai-key'),
@ -461,7 +468,7 @@ export async function POST(request: NextRequest) {
segmentId: segment.segmentId,
});
if (scope.authEnabled && scope.userId && isTtsRateLimitEnabled()) {
if (scope.authEnabled && scope.userId && ttsRateLimitEnabled) {
const charCount = segment.text.length;
const ip = getClientIp(request);
const device = scope.isAnonymousUser ? getOrCreateDeviceId(request) : null;
@ -477,6 +484,10 @@ export async function POST(request: NextRequest) {
deviceId: device?.deviceId ?? null,
ip,
},
{
enabled: ttsRateLimitEnabled,
limits,
},
);
if (!rateLimitResult.allowed) {
@ -484,6 +495,8 @@ export async function POST(request: NextRequest) {
rateLimitResult,
isAnonymousUser: scope.isAnonymousUser,
pathname: request.nextUrl.pathname,
anonymousLimit: limits.anonymous,
authenticatedLimit: limits.authenticated,
});
attachDeviceIdCookie(response, deviceIdToSet, didCreateDeviceIdCookie);
return response;

View file

@ -1,6 +1,6 @@
import "./globals.css";
import { ReactNode } from "react";
import { Metadata } from "next";
import { Metadata, Viewport } from "next";
import { Figtree } from "next/font/google";
import { ConsentAwareAnalytics } from "@/components/ConsentAwareAnalytics";
import { CookieConsentBanner } from "@/components/CookieConsentBanner";
@ -30,6 +30,10 @@ const themeInitScript = `
})();
`;
export const viewport: Viewport = {
viewportFit: "cover",
};
export const metadata: Metadata = {
title: {
default: "OpenReader",

View file

@ -41,7 +41,7 @@ export function CookieConsentBanner() {
leave="transition ease-in duration-200 transform"
leaveFrom="translate-y-0 opacity-100"
leaveTo="translate-y-full opacity-0"
className="fixed bottom-0 left-0 right-0 z-[60] p-4 md:p-6"
className="fixed bottom-0 left-0 right-0 z-[60] px-4 pt-4 pb-[max(1rem,env(safe-area-inset-bottom))] md:px-6 md:pt-6 md:pb-[max(1.5rem,env(safe-area-inset-bottom))]"
>
<div className="mx-auto max-w-5xl rounded-xl border border-offbase bg-base p-5 shadow-2xl md:flex md:items-center md:justify-between md:gap-8">
<div className="mb-4 md:mb-0">

View file

@ -1,7 +1,8 @@
'use client';
import { useState } from 'react';
import { DocumentList } from '@/components/doclist/DocumentList';
import { SettingsModal } from '@/components/SettingsModal';
import { SettingsModal, SettingsTrigger } from '@/components/SettingsModal';
import { UserMenu } from '@/components/auth/UserMenu';
const Brand = () => (
@ -14,20 +15,24 @@ const Brand = () => (
</div>
);
const AppActions = () => (
<div className="flex flex-col gap-0.5 w-full">
<SettingsModal
triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
/>
<UserMenu variant="sidebar" />
</div>
);
export function HomeContent() {
const [settingsOpen, setSettingsOpen] = useState(false);
const appActions = (
<div className="flex flex-col gap-0.5 w-full">
<SettingsTrigger
triggerLabel="Settings"
className="w-full justify-start gap-2 px-2 py-1 text-[12px] border-transparent hover:border-accent"
onOpen={() => setSettingsOpen(true)}
/>
<UserMenu variant="sidebar" />
</div>
);
return (
<div className="w-full h-full">
<DocumentList brand={<Brand />} appActions={<AppActions />} />
<DocumentList brand={<Brand />} appActions={appActions} />
<SettingsModal open={settingsOpen} onOpenChange={setSettingsOpen} />
</div>
);
}

View file

@ -130,19 +130,42 @@ const SIDEBAR_SECTIONS: SidebarSection[] = [
type AdminSubTab = 'providers' | 'features';
export function SettingsModal({
export function SettingsTrigger({
className = '',
triggerLabel,
onOpen,
}: {
className?: string;
triggerLabel?: string;
onOpen: () => void;
}) {
return (
<Button
onClick={onOpen}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
);
}
export function SettingsModal({
open,
onOpenChange,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
}) {
const runtimeConfig = useRuntimeConfig();
const enableDestructiveDelete = runtimeConfig.enableDestructiveDeleteActions;
const showAllProviderModels = runtimeConfig.showAllProviderModels;
const enableTTSProvidersTab = runtimeConfig.enableTtsProvidersTab;
const restrictUserApiKeys = runtimeConfig.restrictUserApiKeys;
const [isOpen, setIsOpen] = useState(false);
const isOpen = open;
const setIsOpen = onOpenChange;
const [isChangelogOpen, setIsChangelogOpen] = useState(false);
const [activeSection, setActiveSection] = useState<SectionId>(enableTTSProvidersTab ? 'api' : 'theme');
@ -215,7 +238,7 @@ export function SettingsModal({
if (changelogOpenSignal <= 0) return;
setIsOpen(true);
setIsChangelogOpen(true);
}, [changelogOpenSignal]);
}, [changelogOpenSignal, setIsOpen]);
useEffect(() => {
setLocalApiKey(apiKey);
@ -409,7 +432,7 @@ export function SettingsModal({
} else {
setCustomModelInput('');
}
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels]);
}, [apiKey, baseUrl, providerRef, providerType, ttsModel, ttsInstructions, ttsModels, setIsOpen]);
const [systemIsDark, setSystemIsDark] = useState(
typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches
@ -489,19 +512,6 @@ export function SettingsModal({
return (
<>
<Button
onClick={() => {
setIsOpen(true);
setIsChangelogOpen(false);
}}
className={`inline-flex items-center py-1 px-2 rounded-md border border-offbase bg-base text-foreground text-xs hover:bg-offbase hover:text-accent transition-transform transition-colors duration-200 ease-out hover:scale-[1.01] ${className}`}
aria-label="Settings"
tabIndex={0}
>
<SettingsIcon className="w-4 h-4 transition-transform duration-200 ease-out hover:scale-[1.01] hover:rotate-45" />
{triggerLabel && <span className="ml-2">{triggerLabel}</span>}
</Button>
<Transition appear show={isOpen} as={Fragment}>
<Dialog
as="div"

View file

@ -104,11 +104,19 @@ export function AdminFeaturesPanel() {
setDraft((d) => ({ ...d, [key]: value }));
setDirty((s) => {
const next = new Set(s);
next.add(key);
const baselineValue = data?.values?.[key];
if (Object.is(value, baselineValue)) next.delete(key);
else next.add(key);
return next;
});
};
const updatePositiveIntDraft = (key: string, raw: string) => {
const parsed = Number(raw);
if (!Number.isFinite(parsed)) return;
updateDraft(key, Math.max(1, Math.floor(parsed)));
};
const resetField = (key: string) => {
if (saving) return;
resetMutation.mutate(key);
@ -151,6 +159,8 @@ export function AdminFeaturesPanel() {
} as ProviderOption
: fallbackShared;
const selectedProviderOption = effectiveSelectedProvider;
const shouldRenderRateLimitInputs = draft.disableTtsRateLimit === false;
const shouldRenderComputeRateLimitInputs = draft.disableComputeRateLimit === false;
const handleProviderChange = (opt: ProviderOption) => {
updateDraft('defaultTtsProvider', opt.id);
@ -272,6 +282,173 @@ export function AdminFeaturesPanel() {
/>
</Section>
<Section
title="Rate limiting"
subtitle="Daily TTS quotas, PDF parsing throttle, and upload size."
action={<Badge tone="foreground">Limits</Badge>}
>
<ToggleRow
label="Disable TTS daily rate limiting"
description="When on, per-user/IP daily character quotas are not enforced."
checked={Boolean(draft.disableTtsRateLimit)}
onChange={(checked) => updateDraft('disableTtsRateLimit', checked)}
right={renderSource('disableTtsRateLimit')}
variant="flat"
/>
{shouldRenderRateLimitInputs ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous per-user daily limit</label>
{renderSource('ttsDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated per-user daily limit</label>
{renderSource('ttsDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsDailyLimitAuthenticated', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Anonymous IP daily backstop</label>
{renderSource('ttsIpDailyLimitAnonymous')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAnonymous ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAnonymous', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Authenticated IP daily backstop</label>
{renderSource('ttsIpDailyLimitAuthenticated')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.ttsIpDailyLimitAuthenticated ?? '')}
onChange={(event) => updatePositiveIntDraft('ttsIpDailyLimitAuthenticated', event.target.value)}
/>
</div>
</div>
) : null}
<ToggleRow
label="Disable PDF parsing rate limiting"
description="When on, per-user limits on starting PDF layout parses are not enforced."
checked={Boolean(draft.disableComputeRateLimit)}
onChange={(checked) => updateDraft('disableComputeRateLimit', checked)}
right={renderSource('disableComputeRateLimit')}
variant="flat"
/>
{shouldRenderComputeRateLimitInputs ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2.5 px-0.5 py-1.5">
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Burst limit (parses)</label>
{renderSource('computeParseBurstMax')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstMax', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Burst window (seconds)</label>
{renderSource('computeParseBurstWindowSec')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseBurstWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Sustained limit (parses)</label>
{renderSource('computeParseSustainedMax')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedMax ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedMax', event.target.value)}
/>
</div>
<div className="space-y-1">
<div className="flex items-center justify-between gap-2">
<label className="text-xs font-medium text-foreground">Sustained window (seconds)</label>
{renderSource('computeParseSustainedWindowSec')}
</div>
<input
type="number"
min={1}
step={1}
className={inputClass}
value={String(draft.computeParseSustainedWindowSec ?? '')}
onChange={(event) => updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)}
/>
</div>
</div>
) : null}
<div className="px-0.5 pt-1 pb-2 border-b border-offbase last:border-b-0">
<div className="flex items-center gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<span className="block text-sm font-medium leading-5 text-foreground">Max upload size</span>
<span className="block text-xs leading-4 text-muted">Largest single document upload accepted.</span>
</div>
<div className="shrink-0 self-start pl-1.5">{renderSource('maxUploadMb')}</div>
<div className="shrink-0 flex items-center gap-1.5">
<input
type="number"
min={1}
step={1}
inputMode="numeric"
aria-label="Max upload size in megabytes"
className="w-20 rounded-md bg-background border border-offbase px-2.5 py-1.5 text-sm text-foreground text-right focus:outline-none focus:ring-2 focus:ring-accent"
value={String(draft.maxUploadMb ?? '')}
onChange={(event) => updatePositiveIntDraft('maxUploadMb', event.target.value)}
/>
<span className="text-xs text-muted">MB</span>
</div>
</div>
</div>
</Section>
<Section
title="Site features"
subtitle="Feature flags for all users."

View file

@ -1,11 +1,11 @@
'use client';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Transition } from '@headlessui/react';
import { Button, Input, Listbox, ListboxButton, ListboxOption, ListboxOptions, Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast';
import { ChevronUpDownIcon, CheckIcon, PlusIcon } from '@/components/icons/Icons';
import { ChevronUpDownIcon, CheckIcon, DotsHorizontalIcon, PlusIcon } from '@/components/icons/Icons';
import { providerSupportsCustomModel, resolveProviderModels, type TtsModelDefinition, type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { defaultBaseUrlForProviderType, defaultModelForProviderType, resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import {
@ -56,6 +56,50 @@ interface FormState {
const providerDefaultModel = defaultModelForProviderType;
const ADMIN_PROVIDERS_QUERY_KEY = ['admin-providers'] as const;
const ADMIN_SETTINGS_QUERY_KEY = ['admin-settings'] as const;
const ADMIN_DEFAULT_PROVIDER_QUERY_KEY = ['admin-settings', 'default-provider-slug'] as const;
async function fetchDefaultProviderSlug(): Promise<string> {
const res = await fetch('/api/admin/settings');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = (await res.json()) as { values?: { defaultTtsProvider?: unknown } };
return typeof data.values?.defaultTtsProvider === 'string' ? data.values.defaultTtsProvider : '';
}
async function patchDefaultProviderSlug(slug: string): Promise<void> {
const res = await fetch('/api/admin/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { defaultTtsProvider: slug } }),
});
if (res.status === 200 || res.status === 204) return;
if (res.status === 207) {
// Multi-status: the request succeeded for some keys but failed for others.
// Only treat it as success when the defaultTtsProvider field itself was
// accepted (no matching entry in the errors array).
const payload = (await res.json().catch(() => ({}))) as {
errors?: Array<{ key?: string; message?: string }>;
};
const failure = payload.errors?.find((entry) => entry?.key === 'defaultTtsProvider');
if (failure) {
throw new Error(failure.message || 'Failed to update default provider');
}
return;
}
throw new Error(`HTTP ${res.status}`);
}
async function patchProviderEnabled(input: { id: string; enabled: boolean }): Promise<void> {
const res = await fetch(`/api/admin/providers/${input.id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: input.enabled }),
});
if (!res.ok) {
const data = (await res.json().catch(() => ({}))) as { error?: string };
throw new Error(data.error || `HTTP ${res.status}`);
}
}
function createEmptyForm(): FormState {
return {
@ -70,6 +114,11 @@ function createEmptyForm(): FormState {
};
}
function truncateModelLabel(value: string, maxLength = 56): string {
if (value.length <= maxLength) return value;
return `${value.slice(0, maxLength - 3)}...`;
}
async function fetchAdminProviders(): Promise<AdminProviderMasked[]> {
const res = await fetch('/api/admin/providers');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
@ -122,19 +171,35 @@ export function AdminProvidersPanel() {
queryKey: ADMIN_PROVIDERS_QUERY_KEY,
queryFn: fetchAdminProviders,
});
const {
data: defaultProviderSlug = '',
error: defaultProviderError,
} = useQuery({
queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY,
queryFn: fetchDefaultProviderSlug,
});
useEffect(() => {
if (!error) return;
console.error('[AdminProvidersPanel] load failed:', error);
toast.error('Failed to load admin providers');
}, [error]);
useEffect(() => {
if (!defaultProviderError) return;
console.error('[AdminProvidersPanel] default provider load failed:', defaultProviderError);
toast.error('Failed to load default provider');
}, [defaultProviderError]);
const saveMutation = useMutation({
mutationFn: upsertAdminProvider,
onSuccess: async (_data, variables) => {
toast.success(variables.editingId === '__new' ? 'Provider created' : 'Provider updated');
cancelEdit();
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] save failed:', mutationError);
@ -146,13 +211,46 @@ export function AdminProvidersPanel() {
mutationFn: deleteAdminProvider,
onSuccess: async () => {
toast.success('Provider deleted');
await queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY });
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] delete failed:', mutationError);
toast.error((mutationError as Error).message || 'Delete failed');
},
});
const toggleEnabledMutation = useMutation({
mutationFn: patchProviderEnabled,
onSuccess: async (_data, vars) => {
toast.success(vars.enabled ? 'Provider enabled' : 'Provider disabled');
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_PROVIDERS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] toggle enabled failed:', mutationError);
toast.error((mutationError as Error).message || 'Update failed');
},
});
const setDefaultMutation = useMutation({
mutationFn: patchDefaultProviderSlug,
onSuccess: async () => {
toast.success('Default provider updated');
await Promise.all([
queryClient.invalidateQueries({ queryKey: ADMIN_SETTINGS_QUERY_KEY }),
queryClient.invalidateQueries({ queryKey: ADMIN_DEFAULT_PROVIDER_QUERY_KEY }),
]);
},
onError: (mutationError) => {
console.error('[AdminProvidersPanel] set default failed:', mutationError);
toast.error((mutationError as Error).message || 'Failed to set default');
},
});
const startCreate = () => {
setForm(createEmptyForm());
@ -191,6 +289,14 @@ export function AdminProvidersPanel() {
if (deleteMutation.isPending) return;
deleteMutation.mutate(id);
};
const toggleEnabled = (provider: AdminProviderMasked) => {
if (toggleEnabledMutation.isPending) return;
toggleEnabledMutation.mutate({ id: provider.id, enabled: !provider.enabled });
};
const setDefault = (slug: string) => {
if (setDefaultMutation.isPending) return;
setDefaultMutation.mutate(slug);
};
const isEditingExisting = editingId !== null && editingId !== '__new';
const editingProvider = isEditingExisting
@ -504,38 +610,101 @@ export function AdminProvidersPanel() {
<p className="text-xs text-muted py-2">No shared providers configured yet.</p>
) : (
providers.map((p) => (
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0">
<div className="flex items-start gap-3">
<div key={p.id} className="py-1.5 border-b border-offbase last:border-b-0 px-0.5 rounded-md">
<div className="flex items-start gap-2.5">
<div className="flex-1 min-w-0 space-y-0.5">
<div className="flex items-center gap-2 flex-wrap">
<div className="flex items-center gap-1.5 flex-wrap">
<span className="text-sm font-medium text-foreground truncate">{p.displayName}</span>
<Badge tone="muted">{p.slug}</Badge>
{defaultProviderSlug === p.slug && <Badge tone="foreground">Default</Badge>}
{!p.enabled && <Badge tone="muted">Disabled</Badge>}
</div>
<div className="text-xs text-muted truncate">
<div className="text-xs text-muted">
{p.providerType}
{p.defaultModel ? ` · ${p.defaultModel}` : ''}
{p.defaultModel ? (
<>
{' · '}
<span title={p.defaultModel}>{truncateModelLabel(p.defaultModel)}</span>
</>
) : (
' · no default model'
)}
{p.defaultInstructions ? ' · instructions' : ''}
{p.baseUrl ? ` · ${p.baseUrl}` : ''}
{' · '}key {p.apiKeyMask}
</div>
<div className="text-[11px] text-muted truncate">
{p.baseUrl ? p.baseUrl : 'provider base URL default'} · key {p.apiKeyMask}
</div>
</div>
<div className="shrink-0 flex gap-1.5">
<Button
onClick={() => startEdit(p)}
className={buttonClass({ variant: 'outline', size: 'xs', className: 'h-7' })}
disabled={!!editingId || deleteMutation.isPending}
<Menu as="div" className="relative shrink-0">
<MenuButton
className="inline-flex items-center justify-center h-7 w-7 rounded-md border border-offbase bg-base text-muted hover:text-accent hover:bg-offbase transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent disabled:opacity-50"
title="Provider actions"
aria-label="Provider actions"
disabled={!!editingId || deleteMutation.isPending || toggleEnabledMutation.isPending || setDefaultMutation.isPending}
>
Edit
</Button>
<Button
onClick={() => remove(p.id)}
className={buttonClass({ variant: 'danger', size: 'xs', className: 'h-7' })}
disabled={!!editingId || deleteMutation.isPending}
<DotsHorizontalIcon className="h-3 w-4" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="transform opacity-0 scale-95"
enterTo="transform opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="transform opacity-100 scale-100"
leaveTo="transform opacity-0 scale-95"
>
Delete
</Button>
</div>
<MenuItems
anchor="bottom end"
className="z-50 mt-2 min-w-[170px] rounded-md bg-base shadow-lg ring-1 ring-black/5 focus:outline-none p-1"
>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => startEdit(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Edit
</button>
)}
</MenuItem>
<MenuItem disabled={!p.enabled || defaultProviderSlug === p.slug}>
{({ active, disabled }) => (
<button
type="button"
onClick={() => setDefault(p.slug)}
disabled={disabled}
className={`${disabled ? 'text-muted/60 cursor-not-allowed' : active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Set as default
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => toggleEnabled(p)}
className={`${active ? 'bg-offbase text-accent' : 'text-foreground'} flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
{p.enabled ? 'Disable' : 'Enable'}
</button>
)}
</MenuItem>
<MenuItem>
{({ active }) => (
<button
type="button"
onClick={() => remove(p.id)}
className={`${active ? 'bg-offbase' : ''} text-red-500 flex w-full items-center gap-2 rounded-md px-2 py-2 text-xs`}
>
Delete
</button>
)}
</MenuItem>
</MenuItems>
</Transition>
</Menu>
</div>
</div>
))

View file

@ -354,7 +354,10 @@ export function FinderSidebar({
</div>
</div>
{bottomSlot && (
<div className="shrink-0 border-t border-offbase p-2" onClick={(e) => e.stopPropagation()}>
<div
className="shrink-0 border-t border-offbase px-2 pt-2 pb-[max(0.5rem,env(safe-area-inset-bottom))] md:pb-2"
onClick={(e) => e.stopPropagation()}
>
{bottomSlot}
</div>
)}

View file

@ -19,7 +19,7 @@ export function FinderStatusBar({
<div
role="status"
aria-live="polite"
className="h-6 px-3 flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none"
className="min-h-6 px-3 pb-[env(safe-area-inset-bottom)] flex items-center justify-between gap-3 text-[11px] text-muted bg-base border-t border-offbase select-none"
>
<span className="truncate">{summary}</span>
<span className="shrink-0">

View file

@ -32,7 +32,7 @@ export default function TTSPlayer({ currentPage, numPages }: {
return (
<div className="sticky bottom-0 z-30 w-full border-t border-offbase bg-base" data-app-ttsbar>
<div className="px-2 md:px-3 pt-1 pb-1.5 flex items-center justify-center gap-1 min-h-10">
<div className="px-2 md:px-3 pt-1 pb-[max(0.375rem,env(safe-area-inset-bottom))] flex items-center justify-center gap-1 min-h-10">
{/* Speed control */}
<SpeedControl
setSpeedAndRestart={setSpeedAndRestart}

View file

@ -22,6 +22,11 @@ export interface RuntimeConfig {
enableDocxConversion: boolean;
enableDestructiveDeleteActions: boolean;
showAllProviderModels: boolean;
disableTtsRateLimit: boolean;
ttsDailyLimitAnonymous: number;
ttsDailyLimitAuthenticated: number;
ttsIpDailyLimitAnonymous: number;
ttsIpDailyLimitAuthenticated: number;
computeAvailable: boolean;
}
@ -36,6 +41,11 @@ const RUNTIME_DEFAULTS: RuntimeConfig = {
enableDocxConversion: true,
enableDestructiveDeleteActions: true,
showAllProviderModels: true,
disableTtsRateLimit: true,
ttsDailyLimitAnonymous: 50_000,
ttsDailyLimitAuthenticated: 500_000,
ttsIpDailyLimitAnonymous: 100_000,
ttsIpDailyLimitAuthenticated: 1_000_000,
computeAvailable: true,
};

View file

@ -10,6 +10,7 @@ export const documents = usePostgres ? postgresSchema.documents : sqliteSchema.d
export const audiobooks = usePostgres ? postgresSchema.audiobooks : sqliteSchema.audiobooks;
export const audiobookChapters = usePostgres ? postgresSchema.audiobookChapters : sqliteSchema.audiobookChapters;
export const userTtsChars = usePostgres ? postgresSchema.userTtsChars : sqliteSchema.userTtsChars;
export const userJobEvents = usePostgres ? postgresSchema.userJobEvents : sqliteSchema.userJobEvents;
export const userPreferences = usePostgres ? postgresSchema.userPreferences : sqliteSchema.userPreferences;
export const documentSettings = usePostgres ? postgresSchema.documentSettings : sqliteSchema.documentSettings;
export const userDocumentProgress = usePostgres ? postgresSchema.userDocumentProgress : sqliteSchema.userDocumentProgress;

View file

@ -66,6 +66,22 @@ export const userTtsChars = pgTable("user_tts_chars", {
index('idx_user_tts_chars_date').on(table.date),
]);
// Generic per-user job-creation ledger for rate/concurrency limiting of
// expensive compute operations (e.g. PDF layout parsing). One row per created
// worker op. A trailing-window COUNT over (user_id, action) enforces both a
// short-window burst cap and a wider sustained/concurrency cap; because the
// worker bounds each op by a hard cap, "ops created in the last hard-cap
// window" is an upper bound on in-flight ops. Old rows are pruned opportunistically.
export const userJobEvents = pgTable('user_job_events', {
userId: text('user_id').notNull(),
action: text('action').notNull(),
opId: text('op_id').notNull(),
createdAt: bigint('created_at', { mode: 'number' }).notNull().default(PG_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.action, table.opId] }),
index('idx_user_job_events_user_action_created').on(table.userId, table.action, table.createdAt),
]);
export const userPreferences = pgTable('user_preferences', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: jsonb('data_json').notNull().default({}),

View file

@ -66,6 +66,22 @@ export const userTtsChars = sqliteTable("user_tts_chars", {
index('idx_user_tts_chars_date').on(table.date),
]);
// Generic per-user job-creation ledger for rate/concurrency limiting of
// expensive compute operations (e.g. PDF layout parsing). One row per created
// worker op. A trailing-window COUNT over (user_id, action) enforces both a
// short-window burst cap and a wider sustained/concurrency cap; because the
// worker bounds each op by a hard cap, "ops created in the last hard-cap
// window" is an upper bound on in-flight ops. Old rows are pruned opportunistically.
export const userJobEvents = sqliteTable('user_job_events', {
userId: text('user_id').notNull(),
action: text('action').notNull(),
opId: text('op_id').notNull(),
createdAt: integer('created_at').notNull().default(SQLITE_NOW_MS),
}, (table) => [
primaryKey({ columns: [table.userId, table.action, table.opId] }),
index('idx_user_job_events_user_action_created').on(table.userId, table.action, table.createdAt),
]);
export const userPreferences = sqliteTable('user_preferences', {
userId: text('user_id').primaryKey().references(() => user.id, { onDelete: 'cascade' }),
dataJson: text('data_json').notNull().default('{}'),

View file

@ -5,6 +5,8 @@ import { adminProviders } from '@/db/schema';
import { apiKeyLast4, decryptSecret, encryptSecret } from '@/lib/server/crypto/secrets';
import { type TtsProviderId } from '@/lib/shared/tts-provider-catalog';
import { resolveTtsProviderModelPolicy } from '@/lib/shared/tts-provider-policy';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
import { getRuntimeConfig, setRuntimeConfigKey } from '@/lib/server/admin/settings';
export const BUILT_IN_PROVIDER_IDS: readonly TtsProviderId[] = [
'custom-openai',
@ -195,6 +197,19 @@ export async function listAdminProviders(): Promise<AdminProviderRecord[]> {
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
}
export async function listEnabledAdminProviders(): Promise<AdminProviderRecord[]> {
const rows = await db
.select()
.from(adminProviders)
.where(eq(adminProviders.enabled, 1))
.orderBy(
desc(adminProviders.updatedAt),
desc(adminProviders.createdAt),
asc(adminProviders.slug),
);
return (rows as Array<Record<string, unknown>>).map(rowToRecord);
}
export async function getAdminProviderBySlug(slug: string): Promise<AdminProviderRecord | null> {
const rows = await db.select().from(adminProviders).where(eq(adminProviders.slug, slug)).limit(1);
const arr = rows as Array<Record<string, unknown>>;
@ -258,6 +273,8 @@ export async function updateAdminProvider(
): Promise<AdminProviderRecord> {
const current = await getAdminProviderById(id);
if (!current) throw new AdminProviderError('provider not found', 404);
const runtimeConfigBefore = await getRuntimeConfig();
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === current.slug;
const update: Record<string, unknown> = { updatedAt: Date.now() };
const nextModel =
@ -309,13 +326,27 @@ export async function updateAdminProvider(
await db.update(adminProviders).set(update).where(eq(adminProviders.id, id));
const updated = await getAdminProviderById(id);
if (!updated) throw new AdminProviderError('failed to load updated provider', 500);
if (wasDefaultProvider) {
if (updated.enabled && updated.slug !== current.slug) {
await setRuntimeConfigKey('defaultTtsProvider', updated.slug);
} else if (!updated.enabled) {
await swapDefaultSharedProvider(updated.slug);
}
}
await ensureDefaultSharedProviderValidity();
return updated;
}
export async function deleteAdminProvider(id: string): Promise<void> {
const existing = await getAdminProviderById(id);
if (!existing) throw new AdminProviderError('provider not found', 404);
const runtimeConfigBefore = await getRuntimeConfig();
const wasDefaultProvider = runtimeConfigBefore.defaultTtsProvider === existing.slug;
await db.delete(adminProviders).where(eq(adminProviders.id, id));
if (wasDefaultProvider) {
await swapDefaultSharedProvider(existing.slug);
}
await ensureDefaultSharedProviderValidity();
}
/** Lookup helper used by TTS routes: returns null if not found or disabled. */
@ -333,11 +364,49 @@ export async function getEnabledAdminProviderBySlug(
}
export async function getFirstEnabledAdminProvider(): Promise<AdminProviderRecord | null> {
const rows = await db
.select()
.from(adminProviders)
.where(eq(adminProviders.enabled, 1))
.limit(1);
const arr = rows as Array<Record<string, unknown>>;
return arr[0] ? rowToRecord(arr[0]) : null;
const rows = await listEnabledAdminProviders();
return rows[0] ?? null;
}
export async function resolvePreferredEnabledAdminProvider(input: {
requestedSlug?: string | null;
runtimeDefaultSlug?: string | null;
}): Promise<AdminProviderRecord | null> {
const providers = await listEnabledAdminProviders();
const selectedSlug = resolvePreferredSharedProviderSlug({
providers,
requestedSlug: input.requestedSlug,
runtimeDefaultSlug: input.runtimeDefaultSlug,
});
if (!selectedSlug) return null;
return providers.find((provider) => provider.slug === selectedSlug) ?? null;
}
async function ensureDefaultSharedProviderValidity(): Promise<void> {
const runtimeConfig = await getRuntimeConfig();
const currentDefaultSlug = runtimeConfig.defaultTtsProvider;
if (!currentDefaultSlug || BUILT_IN_PROVIDER_IDS.includes(currentDefaultSlug as TtsProviderId)) {
return;
}
const currentDefaultEnabled = await getEnabledAdminProviderBySlug(currentDefaultSlug);
if (currentDefaultEnabled) return;
const nextProvider = await resolvePreferredEnabledAdminProvider({
runtimeDefaultSlug: currentDefaultSlug,
});
if (!nextProvider) return;
await setRuntimeConfigKey('defaultTtsProvider', nextProvider.slug);
}
async function swapDefaultSharedProvider(excludedSlug: string): Promise<void> {
const providers = await listEnabledAdminProviders();
const filtered = providers.filter((provider) => provider.slug !== excludedSlug);
const selectedSlug = resolvePreferredSharedProviderSlug({
providers: filtered,
runtimeDefaultSlug: null,
});
if (!selectedSlug) return;
await setRuntimeConfigKey('defaultTtsProvider', selectedSlug);
}

View file

@ -1,7 +1,7 @@
import {
getEnabledAdminProviderBySlug,
getFirstEnabledAdminProvider,
decryptedKeyFor,
resolvePreferredEnabledAdminProvider,
type AdminProviderRecord,
} from '@/lib/server/admin/providers';
@ -46,37 +46,20 @@ export async function resolveTtsCredentials(opts: {
if (opts.restrictUserApiKeys) {
const requestedIsBuiltIn = isBuiltInProviderId(requestedProvider);
const fallback = opts.fallbackProvider || '';
const fallbackIsBuiltIn = isBuiltInProviderId(fallback);
const requestedSharedSlug = requestedIsBuiltIn ? '' : requestedProvider;
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallback;
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
if (preferredSharedSlug) {
const admin = await getEnabledAdminProviderBySlug(preferredSharedSlug);
if (!admin) {
return { error: 'provider_unknown', slug: preferredSharedSlug };
}
const apiKey = await decryptedKeyFor(admin);
return {
provider: admin.providerType,
apiKey,
baseUrl: admin.baseUrl || undefined,
fromAdmin: true,
adminRecord: admin,
};
}
const firstShared = await getFirstEnabledAdminProvider();
if (!firstShared) {
const selected = await resolvePreferredEnabledAdminProvider({
requestedSlug: requestedIsBuiltIn ? null : requestedProvider,
runtimeDefaultSlug: fallback,
});
if (!selected) {
return { error: 'no_shared_provider_configured', slug: requestedProvider };
}
const apiKey = await decryptedKeyFor(firstShared);
const apiKey = await decryptedKeyFor(selected);
return {
provider: firstShared.providerType,
provider: selected.providerType,
apiKey,
baseUrl: firstShared.baseUrl || undefined,
baseUrl: selected.baseUrl || undefined,
fromAdmin: true,
adminRecord: firstShared,
adminRecord: selected,
};
}

View file

@ -76,6 +76,24 @@ function stringValue(defaultValue: string, envVar: string): RuntimeConfigKeyDef<
};
}
function positiveIntValue(defaultValue: number, envVar?: string): RuntimeConfigKeyDef<number> {
return {
default: defaultValue,
envVar,
parseEnv(raw) {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const parsed = Number(trimmed);
if (!Number.isFinite(parsed) || parsed < 1) return undefined;
return Math.floor(parsed);
},
validate(value) {
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return undefined;
return Math.floor(value);
},
};
}
export const RUNTIME_CONFIG_SCHEMA = {
defaultTtsProvider: stringValue('custom-openai', 'RUNTIME_SEED_DEFAULT_TTS_PROVIDER'),
changelogFeedUrl: stringValue('https://docs.openreader.richardr.dev/changelog/manifest.json', 'RUNTIME_SEED_CHANGELOG_FEED_URL'),
@ -88,6 +106,23 @@ export const RUNTIME_CONFIG_SCHEMA = {
enableDocxConversion: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DOCX_CONVERSION'),
enableDestructiveDeleteActions: booleanFlag(true, 'RUNTIME_SEED_ENABLE_DESTRUCTIVE_DELETE_ACTIONS'),
showAllProviderModels: runtimeBoolean(true),
disableTtsRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_TTS_LIMIT'),
ttsDailyLimitAnonymous: positiveIntValue(50_000),
ttsDailyLimitAuthenticated: positiveIntValue(500_000),
ttsIpDailyLimitAnonymous: positiveIntValue(100_000),
ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000),
// Per-user throttle for expensive PDF-layout parsing. Disabled by default
// (admins enable it in Settings → Admin), mirroring disableTtsRateLimit.
// When enabled, the sub-limits below apply (admin-tunable, no env seed):
// a short "burst" window plus a wider "sustained" window that also bounds
// concurrency (the worker caps each job's duration).
disableComputeRateLimit: booleanFlag(true, 'RUNTIME_SEED_DISABLE_COMPUTE_LIMIT'),
computeParseBurstMax: positiveIntValue(8),
computeParseBurstWindowSec: positiveIntValue(60),
computeParseSustainedMax: positiveIntValue(24),
computeParseSustainedWindowSec: positiveIntValue(600),
// Maximum size (MB) accepted for a single document upload.
maxUploadMb: positiveIntValue(200),
} as const satisfies Record<string, RuntimeConfigKeyDef<unknown>>;
export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA;

View file

@ -8,6 +8,7 @@ import { isBuiltInTtsProviderId, isTtsProviderType, type TtsProviderId } from '@
import type { AudiobookGenerationSettings } from '@/types/client';
import type { TTSAudiobookFormat } from '@/types/tts';
import { resolveEffectiveTtsInstructions } from '@/lib/server/admin/tts-instructions';
import { resolvePreferredSharedProviderSlug } from '@/lib/shared/shared-provider-selection';
function isAudiobookFormat(value: unknown): value is TTSAudiobookFormat {
return value === 'mp3' || value === 'm4b';
@ -101,13 +102,12 @@ function resolveRestrictedProviderRef(
fallbackProviderRef: string,
sharedProviders: SharedProviderPolicyEntry[],
): string {
const requestedIsBuiltIn = isBuiltInTtsProviderId(providerRef);
const fallbackIsBuiltIn = isBuiltInTtsProviderId(fallbackProviderRef);
const requestedSharedSlug = requestedIsBuiltIn ? '' : providerRef;
const fallbackSharedSlug = fallbackIsBuiltIn ? '' : fallbackProviderRef;
const preferredSharedSlug = requestedSharedSlug || fallbackSharedSlug;
if (preferredSharedSlug) return preferredSharedSlug;
return sharedProviders[0]?.slug || providerRef;
const preferred = resolvePreferredSharedProviderSlug({
providers: sharedProviders,
requestedSlug: isBuiltInTtsProviderId(providerRef) ? null : providerRef,
runtimeDefaultSlug: isBuiltInTtsProviderId(fallbackProviderRef) ? null : fallbackProviderRef,
});
return preferred || providerRef;
}
export function canonicalizeAudiobookSettingsForRuntime(input: {

View file

@ -104,17 +104,28 @@ export async function presignPut(
id: string,
contentType: string,
namespace: string | null,
options?: { contentLength?: number },
): Promise<{ url: string; headers: Record<string, string> }> {
const cfg = getS3Config();
const client = getS3Client();
const key = documentKey(id, namespace);
const normalizedType = (contentType || 'application/octet-stream').trim() || 'application/octet-stream';
// When the client declares an exact size, sign Content-Length so S3 rejects a
// PUT whose body does not match (the browser always sends an accurate
// Content-Length for a known body). Skipped when size is unknown/zero so the
// upload still works against stores that enforce the signed header.
const contentLength =
typeof options?.contentLength === 'number' && Number.isFinite(options.contentLength) && options.contentLength > 0
? Math.floor(options.contentLength)
: undefined;
const command = new PutObjectCommand({
Bucket: cfg.bucket,
Key: key,
ContentType: normalizedType,
ServerSideEncryption: 'AES256',
...(contentLength !== undefined ? { ContentLength: contentLength } : {}),
});
const url = await getSignedUrl(client, command, { expiresIn: 60 * 5 });

View file

@ -0,0 +1,167 @@
import { and, eq, gte, lt, sql } from 'drizzle-orm';
import { db } from '@/db';
import { userJobEvents } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { nowTimestampMs } from '@/lib/shared/timestamps';
import type { RuntimeConfig } from '@/lib/server/admin/settings';
/**
* Per-user rate / concurrency limiting for expensive compute operations.
*
* Backed by the `user_job_events` ledger: one row is recorded per created
* worker op, and limits are enforced by COUNTing rows for a (userId, action)
* within trailing time windows. Two windows are configured per action:
* - a short "burst" window (stops tight retry/replace loops), and
* - a wider "sustained" window. Because the worker bounds each op by a hard
* cap, "ops created in the sustained window" is an upper bound on the
* number that can still be running i.e. an effective concurrency cap.
*
* Limits are configured by site admins via runtime config (not env), mirroring
* the TTS rate limiter. The design is generic: add a new `JobRateAction` plus a
* config mapping to throttle a different job type. Limits are a backstop a
* small overshoot under highly concurrent requests is acceptable (the check is
* read-then-record, not a single atomic transaction).
*/
export type JobRateAction = 'pdf_layout';
export interface JobRateWindow {
windowMs: number;
max: number;
}
export interface JobRateConfig {
enabled: boolean;
windows: JobRateWindow[];
}
export interface JobRateDecision {
allowed: boolean;
/** Milliseconds until the binding window frees a slot (0 when allowed). */
retryAfterMs: number;
counts: Array<{ windowMs: number; max: number; count: number }>;
}
/** Builds the PDF-layout parse limiter config from resolved runtime config. */
export function getPdfLayoutRateConfig(runtime: Pick<
RuntimeConfig,
| 'disableComputeRateLimit'
| 'computeParseBurstMax'
| 'computeParseBurstWindowSec'
| 'computeParseSustainedMax'
| 'computeParseSustainedWindowSec'
>): JobRateConfig {
return {
enabled: !runtime.disableComputeRateLimit,
windows: [
{ windowMs: runtime.computeParseBurstWindowSec * 1000, max: runtime.computeParseBurstMax },
{ windowMs: runtime.computeParseSustainedWindowSec * 1000, max: runtime.computeParseSustainedMax },
],
};
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const safeDb = () => db as any;
function isActive(config: Pick<JobRateConfig, 'enabled'>, userId: string | null | undefined): userId is string {
return isAuthEnabled() && config.enabled && Boolean(userId);
}
async function countSince(userId: string, action: JobRateAction, since: number): Promise<number> {
const rows = await safeDb()
.select({ c: sql<number>`count(*)` })
.from(userJobEvents)
.where(and(
eq(userJobEvents.userId, userId),
eq(userJobEvents.action, action),
gte(userJobEvents.createdAt, since),
));
return Number(rows[0]?.c ?? 0);
}
async function oldestSince(userId: string, action: JobRateAction, since: number): Promise<number | null> {
const rows = await safeDb()
.select({ oldest: sql<number>`min(${userJobEvents.createdAt})` })
.from(userJobEvents)
.where(and(
eq(userJobEvents.userId, userId),
eq(userJobEvents.action, action),
gte(userJobEvents.createdAt, since),
));
const value = rows[0]?.oldest;
return value == null ? null : Number(value);
}
/**
* Returns whether a new op may be created for this user/action. Read-only call
* `recordJobEvent` after the op is actually created.
*/
export async function checkJobRate(
userId: string | null | undefined,
action: JobRateAction,
config: JobRateConfig,
): Promise<JobRateDecision> {
if (!isActive(config, userId)) {
return { allowed: true, retryAfterMs: 0, counts: [] };
}
const now = nowTimestampMs();
const counts: JobRateDecision['counts'] = [];
let retryAfterMs = 0;
let allowed = true;
for (const window of config.windows) {
if (!Number.isFinite(window.windowMs) || window.windowMs <= 0 || window.max <= 0) continue;
const since = now - window.windowMs;
const count = await countSince(userId, action, since);
counts.push({ windowMs: window.windowMs, max: window.max, count });
if (count >= window.max) {
allowed = false;
const oldest = await oldestSince(userId, action, since);
// When the oldest in-window event ages out, a slot frees up.
const freesAt = (oldest ?? now) + window.windowMs;
retryAfterMs = Math.max(retryAfterMs, Math.max(0, freesAt - now));
}
}
return { allowed, retryAfterMs, counts };
}
/** Records a created op so it counts toward future window checks. */
export async function recordJobEvent(
userId: string | null | undefined,
action: JobRateAction,
opId: string,
config: JobRateConfig,
): Promise<void> {
if (!isActive(config, userId) || !opId) return;
const now = nowTimestampMs();
try {
await safeDb()
.insert(userJobEvents)
.values({ userId, action, opId, createdAt: now })
.onConflictDoNothing({ target: [userJobEvents.userId, userJobEvents.action, userJobEvents.opId] });
} catch {
// Recording is best-effort; never block op creation on ledger writes.
}
// Opportunistic prune of rows older than the largest configured window so
// the ledger stays small without a separate cron, but never deletes events
// that could still affect an in-window count.
if (Math.random() < 0.05) {
const largestWindowMs = config.windows.reduce(
(max, w) => (Number.isFinite(w.windowMs) && w.windowMs > max ? w.windowMs : max),
24 * 60 * 60 * 1000,
);
try {
await safeDb()
.delete(userJobEvents)
.where(and(
eq(userJobEvents.action, action),
lt(userJobEvents.createdAt, now - largestWindowMs),
));
} catch {
// ignore prune failures
}
}
}

View file

@ -1,5 +1,6 @@
import { NextResponse } from 'next/server';
import { RATE_LIMITS, type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
import { type RateLimitResult } from '@/lib/server/rate-limit/rate-limiter';
import { type JobRateDecision } from '@/lib/server/rate-limit/job-rate-limiter';
function formatLimitForHint(limit: number): string {
if (!Number.isFinite(limit) || limit <= 0) return String(limit);
@ -15,8 +16,10 @@ export function buildDailyQuotaExceededResponse(input: {
rateLimitResult: RateLimitResult;
isAnonymousUser: boolean;
pathname: string;
anonymousLimit: number;
authenticatedLimit: number;
}): NextResponse {
const { rateLimitResult, isAnonymousUser, pathname } = input;
const { rateLimitResult, isAnonymousUser, pathname, anonymousLimit, authenticatedLimit } = input;
const resetTimeMs = rateLimitResult.resetTimeMs;
const retryAfterSeconds = Math.max(0, Math.ceil((resetTimeMs - Date.now()) / 1000));
@ -32,7 +35,7 @@ export function buildDailyQuotaExceededResponse(input: {
resetTimeMs,
userType: isAnonymousUser ? 'anonymous' : 'authenticated',
upgradeHint: isAnonymousUser
? `Sign up to increase your limit from ${formatLimitForHint(RATE_LIMITS.ANONYMOUS)} to ${formatLimitForHint(RATE_LIMITS.AUTHENTICATED)} characters per day`
? `Sign up to increase your limit from ${formatLimitForHint(anonymousLimit)} to ${formatLimitForHint(authenticatedLimit)} characters per day`
: undefined,
instance: pathname,
}), {
@ -43,3 +46,28 @@ export function buildDailyQuotaExceededResponse(input: {
},
});
}
/**
* 429 response for the compute job rate / concurrency limiter (e.g. PDF parse).
*/
export function buildComputeRateLimitedResponse(input: {
decision: JobRateDecision;
pathname: string;
}): NextResponse {
const retryAfterSeconds = Math.max(1, Math.ceil(input.decision.retryAfterMs / 1000));
return new NextResponse(JSON.stringify({
type: 'https://openreader.app/problems/compute-rate-limited',
title: 'Too many compute requests',
status: 429,
detail: 'You have started too many processing operations recently. Please wait and try again.',
code: 'COMPUTE_RATE_LIMITED',
retryAfterMs: input.decision.retryAfterMs,
instance: input.pathname,
}), {
status: 429,
headers: {
'Content-Type': 'application/problem+json',
'Retry-After': String(retryAfterSeconds),
},
});
}

View file

@ -3,53 +3,40 @@ import { userTtsChars } from '@/db/schema';
import { isAuthEnabled } from '@/lib/server/auth/config';
import { eq, and, lt, sql } from 'drizzle-orm';
import { nextUtcMidnightTimestampMs, nowTimestampMs } from '@/lib/shared/timestamps';
import { serverLogger } from '@/lib/server/logger';
import { logDegraded } from '@/lib/server/errors/logging';
function readPositiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (!raw || raw.trim() === '') return fallback;
const parsed = Number(raw);
if (!Number.isFinite(parsed) || parsed <= 0) {
logDegraded(serverLogger, {
event: 'rate_limit.config.invalid_env',
msg: 'Invalid rate limiter env value; using default',
step: 'read_limit_env',
context: {
envVar: name,
envValue: raw,
fallbackValue: fallback,
},
});
return fallback;
}
return Math.floor(parsed);
export interface RateLimitThresholds {
anonymous: number;
authenticated: number;
ipAnonymous: number;
ipAuthenticated: number;
}
function readBooleanEnv(name: string, fallback: boolean): boolean {
const raw = process.env[name];
if (!raw || raw.trim() === '') return fallback;
const normalized = raw.trim().toLowerCase();
if (normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on') return true;
if (normalized === '0' || normalized === 'false' || normalized === 'no' || normalized === 'off') return false;
return fallback;
export interface RateLimitRuntimeOptions {
enabled: boolean;
limits: RateLimitThresholds;
}
export function isTtsRateLimitEnabled(): boolean {
return readBooleanEnv('TTS_ENABLE_RATE_LIMIT', false);
}
export const DEFAULT_RATE_LIMITS: RateLimitThresholds = {
anonymous: 50_000,
authenticated: 500_000,
ipAnonymous: 100_000,
ipAuthenticated: 1_000_000,
};
// Rate limits configuration - character counts per day
export const RATE_LIMITS = {
ANONYMOUS: readPositiveIntEnv('TTS_DAILY_LIMIT_ANONYMOUS', 50_000),
AUTHENTICATED: readPositiveIntEnv('TTS_DAILY_LIMIT_AUTHENTICATED', 500_000),
// IP-based backstop limits to make it harder to reset limits by creating new accounts
// or clearing storage/cookies
IP_ANONYMOUS: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_ANONYMOUS', 100_000),
IP_AUTHENTICATED: readPositiveIntEnv('TTS_IP_DAILY_LIMIT_AUTHENTICATED', 1_000_000),
} as const;
export function resolveRateLimitThresholds(input?: Partial<RateLimitThresholds>): RateLimitThresholds {
const source = input ?? {};
const normalize = (value: unknown, fallback: number): number => {
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback;
return Math.floor(value);
};
return {
anonymous: normalize(source.anonymous, DEFAULT_RATE_LIMITS.anonymous),
authenticated: normalize(source.authenticated, DEFAULT_RATE_LIMITS.authenticated),
ipAnonymous: normalize(source.ipAnonymous, DEFAULT_RATE_LIMITS.ipAnonymous),
ipAuthenticated: normalize(source.ipAuthenticated, DEFAULT_RATE_LIMITS.ipAuthenticated),
};
}
// Helper to ensure DB is strictly typed when we know it exists
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -163,8 +150,15 @@ export class RateLimiter {
/**
* Check if a user can use TTS and increment their char count if allowed
*/
async checkAndIncrementLimit(user: UserInfo, charCount: number, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
async checkAndIncrementLimit(
user: UserInfo,
charCount: number,
backstops?: RateLimitBackstops,
options?: RateLimitRuntimeOptions,
): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) {
return {
allowed: true,
currentCount: 0,
@ -176,7 +170,7 @@ export class RateLimiter {
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -184,13 +178,13 @@ export class RateLimiter {
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
});
}
@ -254,7 +248,7 @@ export class RateLimiter {
});
} catch (error) {
if (error instanceof RateLimitExceeded) {
const current = await this.getCurrentUsage(user, backstops);
const current = await this.getCurrentUsage(user, backstops, options);
return { ...current, allowed: false };
}
throw error;
@ -264,8 +258,14 @@ export class RateLimiter {
/**
* Get current usage for a user without incrementing
*/
async getCurrentUsage(user: UserInfo, backstops?: RateLimitBackstops): Promise<RateLimitResult> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) {
async getCurrentUsage(
user: UserInfo,
backstops?: RateLimitBackstops,
options?: RateLimitRuntimeOptions,
): Promise<RateLimitResult> {
const limits = resolveRateLimitThresholds(options?.limits);
const enabled = options?.enabled ?? true;
if (!isAuthEnabled() || !enabled) {
return {
allowed: true,
currentCount: 0,
@ -276,7 +276,7 @@ export class RateLimiter {
}
const today = new Date().toISOString().split('T')[0];
const userLimit = user.isAnonymous ? RATE_LIMITS.ANONYMOUS : RATE_LIMITS.AUTHENTICATED;
const userLimit = user.isAnonymous ? limits.anonymous : limits.authenticated;
const buckets: Bucket[] = [{ key: user.id, limit: userLimit }];
@ -284,13 +284,13 @@ export class RateLimiter {
const ip = backstops?.ip?.toString() || null;
if (user.isAnonymous && deviceId) {
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: RATE_LIMITS.ANONYMOUS });
buckets.push({ key: normalizeBackstopKey('device', deviceId), limit: limits.anonymous });
}
if (ip) {
buckets.push({
key: normalizeBackstopKey('ip', ip),
limit: user.isAnonymous ? RATE_LIMITS.IP_ANONYMOUS : RATE_LIMITS.IP_AUTHENTICATED,
limit: user.isAnonymous ? limits.ipAnonymous : limits.ipAuthenticated,
});
}
@ -321,7 +321,7 @@ export class RateLimiter {
* Transfer char counts when anonymous user creates an account
*/
async transferAnonymousUsage(anonymousUserId: string, authenticatedUserId: string): Promise<void> {
if (!isAuthEnabled() || !isTtsRateLimitEnabled()) return;
if (!isAuthEnabled()) return;
const today = new Date().toISOString().split('T')[0];
const dateValue = today as unknown as UserTtsCharsDateValue;

View file

@ -1,26 +1,55 @@
import type { NextRequest } from 'next/server';
/**
* Best-effort client IP extraction that works on Vercel and typical reverse proxies.
* Best-effort client IP extraction for rate-limit backstops.
*
* Note: IP-based limits are a backstop only; they are not perfectly reliable.
* Security note: `X-Forwarded-For` is a list the client can *prepend* to, so the
* left-most entry is attacker-controlled and must never be trusted as the client
* IP doing so lets an attacker land every request in a fresh bucket and defeat
* the IP backstop entirely.
*
* Precedence (most trustworthy first):
* 1. x-vercel-forwarded-for set by Vercel; inbound client copies are
* stripped, so it cannot be spoofed. Only consulted when running on Vercel.
* 2. x-real-ip / cf-connecting-ip single values set by the reverse proxy
* (Vercel / Cloudflare overwrite any client-supplied copy).
* 3. x-forwarded-for right-most hop only (the address seen by the closest
* trusted proxy), as a best-effort fallback for single-proxy self-hosts.
* 4. NextRequest.ip runtime-provided connecting address.
*
* IP-based limits remain a backstop only; the per-user bucket is the primary
* control for authenticated abuse.
*/
function firstIp(value: string | null): string | null {
if (!value) return null;
const first = value.split(',')[0]?.trim();
return first || null;
}
export function getClientIp(req: NextRequest): string | null {
// Standard proxy header. Vercel also sets this.
const forwardedFor = req.headers.get('x-forwarded-for');
if (forwardedFor) {
const first = forwardedFor.split(',')[0]?.trim();
if (first) return first;
// 1. Vercel-internal header (clients cannot set x-vercel-* — Vercel strips them).
if (process.env.VERCEL) {
const vercelIp = firstIp(req.headers.get('x-vercel-forwarded-for'));
if (vercelIp) return vercelIp;
}
const realIp = req.headers.get('x-real-ip');
if (realIp) return realIp.trim();
// 2. Single-value proxy headers set (and overwritten) by the edge.
const realIp = req.headers.get('x-real-ip')?.trim();
if (realIp) return realIp;
// Some proxies use this.
const cfConnectingIp = req.headers.get('cf-connecting-ip');
if (cfConnectingIp) return cfConnectingIp.trim();
const cfConnectingIp = req.headers.get('cf-connecting-ip')?.trim();
if (cfConnectingIp) return cfConnectingIp;
// NextRequest may expose ip depending on runtime.
// 3. Fall back to the RIGHT-most X-Forwarded-For hop (closest trusted proxy's
// view). Never the left-most, which the client controls.
const forwardedFor = req.headers.get('x-forwarded-for');
if (forwardedFor) {
const parts = forwardedFor.split(',').map((part) => part.trim()).filter(Boolean);
const rightmost = parts[parts.length - 1];
if (rightmost) return rightmost;
}
// 4. Runtime-provided connecting address, when available.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const reqAny = req as any;
const ip = typeof reqAny.ip === 'string' ? (reqAny.ip as string) : null;

View file

@ -5,7 +5,24 @@ import { ensureSystemUserExists } from '@/db';
const TEST_NAMESPACE_HEADER = 'x-openreader-test-namespace';
const SAFE_NAMESPACE_REGEX = /^[a-zA-Z0-9._-]{1,128}$/;
/**
* The test-namespace header is test/CI scaffolding and must never be honored on
* a real production deployment. It is enabled when `ENABLE_TEST_NAMESPACE=true`
* (set explicitly by the Playwright web server / CI env) or, for local dev
* convenience, whenever the build is not a production build.
*
* Note: the E2E suite runs against a production build (`pnpm build && pnpm
* start`), so a `NODE_ENV` check alone is insufficient — CI relies on the
* explicit flag.
*/
function isTestNamespaceEnabled(): boolean {
if (process.env.ENABLE_TEST_NAMESPACE?.trim().toLowerCase() === 'true') return true;
return process.env.NODE_ENV !== 'production';
}
export function getOpenReaderTestNamespace(headers: Headers): string | null {
if (!isTestNamespaceEnabled()) return null;
const raw = headers.get(TEST_NAMESPACE_HEADER)?.trim();
if (!raw) return null;

View file

@ -0,0 +1,34 @@
import { isBuiltInTtsProviderId } from '@/lib/shared/tts-provider-catalog';
export interface SharedProviderSlugEntry {
slug: string;
}
function normalizeSharedSlug(value: string | null | undefined): string {
const trimmed = typeof value === 'string' ? value.trim() : '';
if (!trimmed) return '';
return isBuiltInTtsProviderId(trimmed) ? '' : trimmed;
}
export function resolvePreferredSharedProviderSlug<T extends SharedProviderSlugEntry>(input: {
providers: readonly T[];
requestedSlug?: string | null;
runtimeDefaultSlug?: string | null;
}): string | null {
const providers = input.providers;
if (providers.length === 0) return null;
const bySlug = new Map<string, T>();
for (const provider of providers) {
bySlug.set(provider.slug, provider);
}
const requested = normalizeSharedSlug(input.requestedSlug);
if (requested && bySlug.has(requested)) return requested;
const runtimeDefault = normalizeSharedSlug(input.runtimeDefaultSlug);
if (runtimeDefault && bySlug.has(runtimeDefault)) return runtimeDefault;
if (bySlug.has('default-openai')) return 'default-openai';
return providers[0]?.slug ?? null;
}

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { inArray } from 'drizzle-orm';
import { db } from '../../src/db';
import { adminProviders } from '../../src/db/schema';
@ -12,7 +12,7 @@ import {
type AdminProviderRecord,
} from '../../src/lib/server/admin/providers';
test.describe('admin provider validation', () => {
describe('admin provider validation', () => {
test('normalizes valid slugs to lowercase', () => {
expect(validateSlug('My-Provider-1')).toBe('my-provider-1');
});

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { resolveEffectiveTtsInstructions } from '../../src/lib/server/admin/tts-instructions';
test.describe('resolveEffectiveTtsInstructions', () => {
describe('resolveEffectiveTtsInstructions', () => {
test('uses explicit request instructions when model supports them', () => {
const out = resolveEffectiveTtsInstructions({
model: 'gpt-4o-mini-tts',

View file

@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { buildAllowedAudiobookUserIds, pickAudiobookOwner } from '../../src/lib/server/audiobooks/user-scope';
test.describe('audiobook scope selection', () => {
describe('audiobook scope selection', () => {
test('uses only unclaimed scope when auth is disabled', () => {
const result = buildAllowedAudiobookUserIds(false, null, 'unclaimed::ns');
expect(result.preferredUserId).toBe('unclaimed::ns');

View file

@ -1,10 +1,10 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
canonicalizeAudiobookSettingsForRuntime,
coerceAudiobookGenerationSettings,
} from '../../src/lib/server/audiobooks/settings';
test.describe('coerceAudiobookGenerationSettings', () => {
describe('coerceAudiobookGenerationSettings', () => {
test('accepts current metadata shape without migration', () => {
const result = coerceAudiobookGenerationSettings({
providerRef: 'openai',

View file

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
escapeFFMetadata,
encodeChapterTitleTag,
@ -7,7 +7,7 @@ import {
decodeChapterFileName
} from '../../src/lib/server/audiobooks/chapters';
test.describe('escapeFFMetadata', () => {
describe('escapeFFMetadata', () => {
test('escapes special characters correctly', () => {
const input = 'Title with = ; # and backslash \\';
// Expected: Equal -> \=, Semicolon -> \;, Hash -> \#, Backslash -> \\
@ -37,7 +37,7 @@ test.describe('escapeFFMetadata', () => {
});
});
test.describe('Title Tags', () => {
describe('Title Tags', () => {
test('encodeChapterTitleTag formats correctly', () => {
expect(encodeChapterTitleTag(0, 'Intro')).toBe('0001 - Intro');
expect(encodeChapterTitleTag(9, 'Chapter Ten')).toBe('0010 - Chapter Ten');
@ -63,7 +63,7 @@ test.describe('Title Tags', () => {
});
});
test.describe('Chapter File Names', () => {
describe('Chapter File Names', () => {
test('encodeChapterFileName formats correctly', () => {
expect(encodeChapterFileName(0, 'Intro', 'mp3')).toBe('0001__Intro.mp3');
expect(encodeChapterFileName(1, 'Part 2', 'm4b')).toBe('0002__Part%202.m4b');

View file

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { beforeAll, describe, expect, test } from 'vitest';
import {
audiobookKey,
audiobookPrefix,
@ -14,8 +14,8 @@ function configureS3Env() {
process.env.S3_PREFIX = 'openreader-test';
}
test.describe('audiobooks-blobstore', () => {
test.beforeAll(() => {
describe('audiobooks-blobstore', () => {
beforeAll(() => {
configureS3Env();
});

View file

@ -1,119 +0,0 @@
import { test, expect } from '@playwright/test';
import { isAnonymousAuthSessionsEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config';
const ORIGINAL_BASE_URL = process.env.BASE_URL;
const ORIGINAL_AUTH_SECRET = process.env.AUTH_SECRET;
const ORIGINAL_USE_ANON = process.env.USE_ANONYMOUS_AUTH_SESSIONS;
const ORIGINAL_GITHUB_CLIENT_ID = process.env.GITHUB_CLIENT_ID;
const ORIGINAL_GITHUB_CLIENT_SECRET = process.env.GITHUB_CLIENT_SECRET;
function restoreEnv() {
if (ORIGINAL_BASE_URL === undefined) delete process.env.BASE_URL;
else process.env.BASE_URL = ORIGINAL_BASE_URL;
if (ORIGINAL_AUTH_SECRET === undefined) delete process.env.AUTH_SECRET;
else process.env.AUTH_SECRET = ORIGINAL_AUTH_SECRET;
if (ORIGINAL_USE_ANON === undefined) delete process.env.USE_ANONYMOUS_AUTH_SESSIONS;
else process.env.USE_ANONYMOUS_AUTH_SESSIONS = ORIGINAL_USE_ANON;
if (ORIGINAL_GITHUB_CLIENT_ID === undefined) delete process.env.GITHUB_CLIENT_ID;
else process.env.GITHUB_CLIENT_ID = ORIGINAL_GITHUB_CLIENT_ID;
if (ORIGINAL_GITHUB_CLIENT_SECRET === undefined) delete process.env.GITHUB_CLIENT_SECRET;
else process.env.GITHUB_CLIENT_SECRET = ORIGINAL_GITHUB_CLIENT_SECRET;
}
function setAuthEnabledEnv() {
process.env.BASE_URL = 'http://localhost:3003';
process.env.AUTH_SECRET = 'test-secret';
}
test.describe('auth config anonymous-session flag', () => {
test.afterEach(() => {
restoreEnv();
});
test('returns false when auth is disabled', () => {
delete process.env.BASE_URL;
delete process.env.AUTH_SECRET;
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('defaults to false when env var is unset', () => {
setAuthEnabledEnv();
delete process.env.USE_ANONYMOUS_AUTH_SESSIONS;
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('returns true only when env var is true', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'true';
expect(isAnonymousAuthSessionsEnabled()).toBe(true);
});
test('returns false when env var is false', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = 'false';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
test('falls back to false for invalid values', () => {
setAuthEnabledEnv();
process.env.USE_ANONYMOUS_AUTH_SESSIONS = '1';
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
});
});
test.describe('auth config github-auth flag', () => {
test.afterEach(() => {
restoreEnv();
});
test('returns false when auth is disabled', () => {
delete process.env.BASE_URL;
delete process.env.AUTH_SECRET;
process.env.GITHUB_CLIENT_ID = 'some-id';
process.env.GITHUB_CLIENT_SECRET = 'some-secret';
expect(isGithubAuthEnabled()).toBe(false);
});
test('returns false when GITHUB_CLIENT_ID is missing', () => {
setAuthEnabledEnv();
delete process.env.GITHUB_CLIENT_ID;
process.env.GITHUB_CLIENT_SECRET = 'some-secret';
expect(isGithubAuthEnabled()).toBe(false);
});
test('returns false when GITHUB_CLIENT_SECRET is missing', () => {
setAuthEnabledEnv();
process.env.GITHUB_CLIENT_ID = 'some-id';
delete process.env.GITHUB_CLIENT_SECRET;
expect(isGithubAuthEnabled()).toBe(false);
});
test('returns false when both GitHub env vars are missing', () => {
setAuthEnabledEnv();
delete process.env.GITHUB_CLIENT_ID;
delete process.env.GITHUB_CLIENT_SECRET;
expect(isGithubAuthEnabled()).toBe(false);
});
test('returns true when auth is enabled and both GitHub env vars are set', () => {
setAuthEnabledEnv();
process.env.GITHUB_CLIENT_ID = 'some-id';
process.env.GITHUB_CLIENT_SECRET = 'some-secret';
expect(isGithubAuthEnabled()).toBe(true);
});
});

View file

@ -0,0 +1,111 @@
import { describe, expect, test } from 'vitest';
import { getAuthBaseUrl, isAnonymousAuthSessionsEnabled, isAuthEnabled, isGithubAuthEnabled } from '../../src/lib/server/auth/config';
import { withEnv } from './support/env';
describe('auth config contract', () => {
test('auth is enabled only when AUTH_SECRET and BASE_URL are both set', async () => {
await withEnv(
{
AUTH_SECRET: undefined,
BASE_URL: undefined,
},
async () => {
expect(isAuthEnabled()).toBe(false);
expect(getAuthBaseUrl()).toBeNull();
},
);
await withEnv(
{
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003',
},
async () => {
expect(isAuthEnabled()).toBe(true);
expect(getAuthBaseUrl()).toBe('http://localhost:3003');
},
);
});
test.each([
{ envValue: undefined, expected: false },
{ envValue: 'true', expected: true },
{ envValue: 'false', expected: false },
{ envValue: '1', expected: false },
{ envValue: 'TRUE', expected: true },
])(
'anonymous sessions honor strict boolean parsing when auth is enabled (value: $envValue)',
async ({ envValue, expected }) => {
await withEnv(
{
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003',
USE_ANONYMOUS_AUTH_SESSIONS: envValue,
},
async () => {
expect(isAnonymousAuthSessionsEnabled()).toBe(expected);
},
);
},
);
test('anonymous sessions are always disabled when auth is disabled', async () => {
await withEnv(
{
AUTH_SECRET: undefined,
BASE_URL: undefined,
USE_ANONYMOUS_AUTH_SESSIONS: 'true',
},
async () => {
expect(isAnonymousAuthSessionsEnabled()).toBe(false);
},
);
});
test.each([
{
title: 'returns false when auth is disabled',
env: {
AUTH_SECRET: undefined,
BASE_URL: undefined,
GITHUB_CLIENT_ID: 'id',
GITHUB_CLIENT_SECRET: 'secret',
},
expected: false,
},
{
title: 'returns false when GitHub client id is missing',
env: {
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003',
GITHUB_CLIENT_ID: undefined,
GITHUB_CLIENT_SECRET: 'secret',
},
expected: false,
},
{
title: 'returns false when GitHub client secret is missing',
env: {
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003',
GITHUB_CLIENT_ID: 'id',
GITHUB_CLIENT_SECRET: undefined,
},
expected: false,
},
{
title: 'returns true when auth is enabled and GitHub credentials are present',
env: {
AUTH_SECRET: 'unit-secret',
BASE_URL: 'http://localhost:3003',
GITHUB_CLIENT_ID: 'id',
GITHUB_CLIENT_SECRET: 'secret',
},
expected: true,
},
])('$title', async ({ env, expected }) => {
await withEnv(env, async () => {
expect(isGithubAuthEnabled()).toBe(expected);
});
});
});

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
canonicalizeEpubSegmentAgainstSpineText,
@ -6,7 +6,7 @@ import {
} from '../../src/lib/client/epub/canonicalize-epub-segment';
import { planCanonicalTtsSegments } from '../../src/lib/shared/tts-segment-plan';
test.describe('canonicalizeEpubSegmentAgainstSpineText', () => {
describe('canonicalizeEpubSegmentAgainstSpineText', () => {
test('maps an exact sentence to the canonical segment identity', () => {
const spineText = [
'First section sentence with enough words to stand alone.',
@ -95,7 +95,7 @@ test.describe('canonicalizeEpubSegmentAgainstSpineText', () => {
});
});
test.describe('canonicalizeEpubSegmentsAgainstSpineText', () => {
describe('canonicalizeEpubSegmentsAgainstSpineText', () => {
test('maps overlap-boundary drift sentences to forward canonical segments', () => {
const sourceSentences = [
'The star was particularly bright when the station lights switched off for cycle night.',

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { scheduleChangelogCheck } from '../../src/lib/client/changelog-check';
@ -6,7 +6,7 @@ function wait(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
test.describe('changelog check scheduling', () => {
describe('changelog check scheduling', () => {
test('cancels throwaway mount and runs exactly once on remount', async () => {
const completedRef = { current: null as string | null };
const inFlightRef = { current: null as string | null };

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
findCurrentVersionIndex,
@ -10,7 +10,7 @@ import {
type ChangelogManifestEntry,
} from '../../src/lib/shared/changelog';
test.describe('changelog utilities', () => {
describe('changelog utilities', () => {
test('normalizes version strings with and without v prefix', () => {
expect(normalizeVersion('v2.2.0')).toBe('2.2.0');
expect(normalizeVersion('2.2.0')).toBe('2.2.0');

View file

@ -1,18 +0,0 @@
import { expect, test } from '@playwright/test';
import { isAbortLikeError } from '../../src/lib/server/compute/abort-like-error';
test.describe('isAbortLikeError', () => {
test('matches abort-shaped errors', () => {
expect(isAbortLikeError(new DOMException('This operation was aborted', 'AbortError'))).toBe(true);
expect(isAbortLikeError(Object.assign(new Error('random'), { name: 'AbortError' }))).toBe(true);
expect(isAbortLikeError(new Error('This operation was aborted'))).toBe(true);
expect(isAbortLikeError({ name: 'AbortError' })).toBe(true);
});
test('does not match non-abort errors', () => {
expect(isAbortLikeError(new Error('boom'))).toBe(false);
expect(isAbortLikeError({ name: 'TypeError' })).toBe(false);
expect(isAbortLikeError(null)).toBe(false);
expect(isAbortLikeError(undefined)).toBe(false);
});
});

View file

@ -1,201 +0,0 @@
import { expect, test } from '@playwright/test';
import type { WorkerOperationRequest, WorkerOperationState } from '../../compute/core/src/api-contracts';
import {
encodeSseFrame,
explainReplacementReason,
InMemoryOperationEventStream,
InMemoryOperationQueue,
InMemoryOperationStateStore,
OperationOrchestrator,
parseSseEventId,
parseSsePayload,
shouldReuseExistingOperation,
} from '../../compute/core/src/control-plane';
function buildPdfLayoutRequest(opKey: string): WorkerOperationRequest {
return {
kind: 'pdf_layout',
opKey,
payload: {
documentId: `doc-${opKey}`,
documentObjectKey: `s3://bucket/${opKey}.pdf`,
namespace: null,
},
};
}
test.describe('compute control-plane', () => {
test('in-memory queue and state store support enqueue/claim/CAS', async () => {
const queue = new InMemoryOperationQueue();
const store = new InMemoryOperationStateStore();
await queue.enqueue({
jobId: 'job-1',
opId: 'op-1',
opKey: 'k-1',
kind: 'pdf_layout',
queuedAt: 1000,
payload: { documentId: 'd1', documentObjectKey: 'obj1', namespace: null },
});
await queue.enqueue({
jobId: 'job-2',
opId: 'op-2',
opKey: 'k-2',
kind: 'whisper_align',
queuedAt: 1100,
payload: { text: 'hello', audioObjectKey: 'obj2' },
});
expect(queue.size()).toBe(2);
expect(queue.size('pdf_layout')).toBe(1);
expect(queue.size('whisper_align')).toBe(1);
const claimedLayout = await queue.claimNext('pdf_layout');
expect(claimedLayout?.opId).toBe('op-1');
expect(queue.size('pdf_layout')).toBe(0);
const firstCas = await store.compareAndSetOpIndex({
opKey: 'k-1',
newOpId: 'op-1',
expectedOpId: null,
});
const secondCas = await store.compareAndSetOpIndex({
opKey: 'k-1',
newOpId: 'op-2',
expectedOpId: null,
});
expect(firstCas).toBeTruthy();
expect(secondCas).toBeFalsy();
expect(await store.getOpIndex('k-1')).toEqual({ opId: 'op-1' });
});
test('in-memory event stream replays from sinceEventId and streams live events', async () => {
const stream = new InMemoryOperationEventStream();
const queued: WorkerOperationState = {
opId: 'op-1',
opKey: 'k-1',
kind: 'pdf_layout',
jobId: 'job-1',
status: 'queued',
queuedAt: 1000,
updatedAt: 1000,
};
const running: WorkerOperationState = {
...queued,
status: 'running',
startedAt: 1200,
updatedAt: 1200,
};
const succeeded: WorkerOperationState = {
...running,
status: 'succeeded',
updatedAt: 1400,
result: { ok: true },
};
await stream.append('op-1', queued);
await stream.append('op-1', running);
const receivedEventIds: number[] = [];
const unsubscribe = await stream.subscribe({
opId: 'op-1',
sinceEventId: 1,
onEvent: (event) => {
receivedEventIds.push(event.eventId);
},
});
await stream.append('op-1', succeeded);
unsubscribe();
expect(receivedEventIds).toEqual([2, 3]);
});
test('orchestrator reuses fresh inflight operations and replaces stale ones', async () => {
let now = 1_000;
let nextId = 1;
const queue = new InMemoryOperationQueue();
const stateStore = new InMemoryOperationStateStore();
const eventStream = new InMemoryOperationEventStream();
const orchestrator = new OperationOrchestrator({
queue,
stateStore,
eventStream,
config: { opStaleMs: 2_000, maxCasRetries: 5 },
clock: { now: () => now },
idFactory: {
opId: () => `op-${nextId}`,
jobId: () => `job-${nextId++}`,
},
});
const request = buildPdfLayoutRequest('same-op-key');
const first = await orchestrator.enqueueOrReuse(request);
expect(first.opId).toBe('op-1');
expect(queue.size('pdf_layout')).toBe(1);
now = 2_000;
const reused = await orchestrator.enqueueOrReuse(request);
expect(reused.opId).toBe('op-1');
expect(queue.size('pdf_layout')).toBe(1);
await orchestrator.markRunning({ opId: first.opId, updatedAt: 2_100 });
now = 6_000;
const replaced = await orchestrator.enqueueOrReuse(request);
expect(replaced.opId).toBe('op-2');
expect(queue.size('pdf_layout')).toBe(2);
expect(await stateStore.getOpIndex('same-op-key')).toEqual({ opId: 'op-2' });
const op1Events = await eventStream.listSince('op-1', 0);
const op2Events = await eventStream.listSince('op-2', 0);
expect(op1Events.map((event) => event.eventId)).toEqual([1, 2]);
expect(op2Events.map((event) => event.eventId)).toEqual([1]);
});
test('state-machine helpers return consistent reuse/replacement decisions', () => {
const current: WorkerOperationState = {
opId: 'op-1',
opKey: 'same-op-key',
kind: 'pdf_layout',
jobId: 'job-1',
status: 'running',
queuedAt: 1000,
updatedAt: 2000,
};
expect(shouldReuseExistingOperation({
current,
requestKind: 'pdf_layout',
now: 2500,
opStaleMs: 1_000,
})).toBeTruthy();
expect(shouldReuseExistingOperation({
current,
requestKind: 'pdf_layout',
now: 4005,
opStaleMs: 1_000,
})).toBeFalsy();
expect(explainReplacementReason({
current,
requestKind: 'pdf_layout',
now: 4005,
opStaleMs: 1_000,
})).toBe('stale_running');
});
test('sse helpers encode and decode frame payload and id', () => {
const frame = encodeSseFrame({
id: 42,
event: 'snapshot',
data: { ok: true },
});
expect(parseSseEventId(frame)).toBe(42);
expect(parseSsePayload(frame)).toBe('{"ok":true}');
});
});

View file

@ -1,29 +0,0 @@
import { expect, test } from '@playwright/test';
import { buildPdfOpKey } from '../../src/lib/server/compute/worker';
test.describe('compute worker pdf opKey', () => {
test('keeps stable key when no force token is provided', () => {
const base = {
documentId: 'doc-123',
namespace: 'ns-1',
documentObjectKey: 'docs/ns-1/doc-123',
};
expect(buildPdfOpKey(base)).toBe('pdf_layout|v1|doc-123|ns-1|docs/ns-1/doc-123|');
expect(buildPdfOpKey(base)).toBe(buildPdfOpKey(base));
});
test('cache-busts key when force token is provided', () => {
const base = {
documentId: 'doc-123',
namespace: 'ns-1',
documentObjectKey: 'docs/ns-1/doc-123',
};
const opKeyA = buildPdfOpKey({ ...base, forceToken: 'force-a' });
const opKeyB = buildPdfOpKey({ ...base, forceToken: 'force-b' });
const normal = buildPdfOpKey(base);
expect(opKeyA).not.toBe(opKeyB);
expect(opKeyA).not.toBe(normal);
expect(opKeyB).not.toBe(normal);
});
});

View file

@ -1,32 +0,0 @@
import { expect, test } from '@playwright/test';
import {
buildInferProgressForPageParsed,
buildInferProgressForPageStart,
} from '../../compute/worker/src/pdf-progress';
test.describe('compute worker pdf progress helpers', () => {
test('page-start progress keeps current page but does not count it as parsed yet', () => {
expect(buildInferProgressForPageStart({ pageNumber: 1, totalPages: 12 })).toEqual({
totalPages: 12,
pagesParsed: 0,
currentPage: 1,
phase: 'infer',
});
expect(buildInferProgressForPageStart({ pageNumber: 5, totalPages: 12 })).toEqual({
totalPages: 12,
pagesParsed: 4,
currentPage: 5,
phase: 'infer',
});
});
test('page-parsed progress counts the current page as parsed', () => {
expect(buildInferProgressForPageParsed({ pageNumber: 5, totalPages: 12 })).toEqual({
totalPages: 12,
pagesParsed: 5,
currentPage: 5,
phase: 'infer',
});
});
});

View file

@ -1,10 +1,10 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { getMigratedDocumentFileName } from '../../src/lib/server/storage/docstore-legacy';
test.describe('Docstore Filename Safety', () => {
describe('docstore filename safety', () => {
const id = 'a'.repeat(64); // Simulate sha256 hex ID
test('should generate standard filename for short names', () => {
test('generates stable file names for short inputs', () => {
const name = 'test-file.pdf';
const result = getMigratedDocumentFileName(id, name);
expect(result).toBe(`${id}__test-file.pdf`);
@ -50,4 +50,13 @@ test.describe('Docstore Filename Safety', () => {
expect(result.length).toBe(240);
expect(result).not.toContain('truncated-');
});
test('drops path traversal and null-byte fragments from migrated file names', () => {
const dirtyName = '../nested/\u0000financial-report.pdf';
const result = getMigratedDocumentFileName(id, dirtyName);
expect(result).toBe(`${id}__financial-report.pdf`);
expect(result).not.toContain('..');
expect(result).not.toContain('\u0000');
});
});

View file

@ -1,16 +1,17 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import type { BaseDocument, EPUBDocument, HTMLDocument, PDFDocument } from '../../src/types/documents';
import { ensureCachedDocumentCore } from '../../src/lib/client/cache/documents';
import { makeBaseDocument } from './support/document-fixtures';
test.describe('document-cache-core', () => {
describe('document-cache-core', () => {
test('returns cached PDF without downloading', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'pdf1',
name: 'a.pdf',
size: 10,
lastModified: Date.now(),
lastModified: 1_700_000_000_001,
type: 'pdf',
};
});
let downloads = 0;
const cached: PDFDocument = { ...meta, type: 'pdf', data: new ArrayBuffer(1) };
@ -32,13 +33,13 @@ test.describe('document-cache-core', () => {
});
test('downloads and stores on cache miss (EPUB)', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'epub1',
name: 'b.epub',
size: 10,
lastModified: Date.now(),
lastModified: 1_700_000_000_002,
type: 'epub',
};
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let downloads = 0;
@ -63,13 +64,13 @@ test.describe('document-cache-core', () => {
});
test('downloads, decodes, and stores HTML on cache miss', async () => {
const meta: BaseDocument = {
const meta: BaseDocument = makeBaseDocument({
id: 'html1',
name: 'c.txt',
size: 5,
lastModified: Date.now(),
lastModified: 1_700_000_000_003,
type: 'html',
};
});
const store = new Map<string, PDFDocument | EPUBDocument | HTMLDocument>();
let decodedCalls = 0;
@ -92,4 +93,22 @@ test.describe('document-cache-core', () => {
expect(result.type).toBe('html');
expect((result as HTMLDocument).data).toBe('hello');
});
test('throws deterministic cache error when download succeeds but backend misses persisted PDF', async () => {
const meta = makeBaseDocument({
id: 'pdf-cache-miss',
type: 'pdf',
});
await expect(
ensureCachedDocumentCore(meta, {
get: async () => null,
putPdf: async () => { /* simulate write path without persisted row */ },
putEpub: async () => { /* unused */ },
putHtml: async () => { /* unused */ },
download: async () => new Uint8Array([1, 2, 3]).buffer,
decodeText: () => '',
}),
).rejects.toThrow('Failed to cache PDF');
});
});

View file

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import path from 'path';
import { readFile } from 'fs/promises';
import {
@ -6,7 +6,7 @@ import {
renderPdfFirstPageToJpeg,
} from '../../src/lib/server/documents/previews-render';
test.describe('document-previews-render', () => {
describe('document-previews-render', () => {
test('renders first PDF page to JPEG preview', async () => {
const pdfPath = path.join(process.cwd(), 'tests/files/sample.pdf');
const bytes = await readFile(pdfPath);

View file

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
filterNonEmptySpineTextEntries,
resolveLoadedSpineSectionDocument,
} from '../../src/hooks/epub/useEPUBAudiobook';
test.describe('EPUB audiobook hook helpers', () => {
describe('EPUB audiobook hook helpers', () => {
test('resolveLoadedSpineSectionDocument prefers ownerDocument when available', () => {
const ownerDocument = { body: { textContent: 'from ownerDocument' } } as unknown as Document;
const loaded = { ownerDocument } as unknown;

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
isDirectionalEpubLocation,
@ -6,7 +6,7 @@ import {
shouldPersistEpubLocation,
} from '../../src/hooks/epub/useEPUBLocationController';
test.describe('EPUB location controller helpers', () => {
describe('EPUB location controller helpers', () => {
test('detects directional locations', () => {
expect(isDirectionalEpubLocation('next')).toBe(true);
expect(isDirectionalEpubLocation('prev')).toBe(true);

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import type { Book } from 'epubjs';
import {
buildEpubLocator,
@ -71,7 +71,7 @@ function makeFakeBook(items: Array<{ index: number; href: string; cfiBase: strin
return { book, sections };
}
test.describe('findSegmentOffset', () => {
describe('findSegmentOffset', () => {
test('finds an offset for matching text', () => {
const spineText = 'Hello world. This is a test paragraph for offsets.';
const offset = findSegmentOffset(spineText, 'this is a test');
@ -98,7 +98,7 @@ test.describe('findSegmentOffset', () => {
});
});
test.describe('resolveSpineFromCfi', () => {
describe('resolveSpineFromCfi', () => {
test('returns spine identity for a CFI inside a known spine', () => {
const { book } = makeFakeBook([
{ index: 2, href: 'OEBPS/ch02.xhtml', cfiBase: '/6/4', text: 'chapter 2 contents' },
@ -125,7 +125,7 @@ test.describe('resolveSpineFromCfi', () => {
});
});
test.describe('buildEpubLocator', () => {
describe('buildEpubLocator', () => {
test('produces a stable locator with charOffset within the spine item', async () => {
const { book } = makeFakeBook([
{
@ -175,7 +175,7 @@ test.describe('buildEpubLocator', () => {
});
});
test.describe('buildEpubLocatorFromChunk', () => {
describe('buildEpubLocatorFromChunk', () => {
test('uses the chunk anchor offset as a hint to disambiguate repeated text', () => {
const spineText = 'foo bar foo bar foo bar';
const anchorEarly = {
@ -230,7 +230,7 @@ test.describe('buildEpubLocatorFromChunk', () => {
});
});
test.describe('buildEpubLocator anchored-search regression', () => {
describe('buildEpubLocator anchored-search regression', () => {
// Pins the contract for the server-side resolver path: when called with
// a `chunkText` argument representing the current rendered page, the
// returned locator's `charOffset` is **at or after** the chunk's position
@ -261,7 +261,7 @@ test.describe('buildEpubLocator anchored-search regression', () => {
});
});
test.describe('findSegmentOffset fallback contract', () => {
describe('findSegmentOffset fallback contract', () => {
// These tests pin the documented behavior of `findSegmentOffset`. The
// from-start fallback is correct for single-shot lookups but **wrong** in
// a monotonic per-sentence walk — `resolveMonotonicSentenceOffsets` exists
@ -278,7 +278,7 @@ test.describe('findSegmentOffset fallback contract', () => {
});
});
test.describe('resolveMonotonicSentenceOffsets', () => {
describe('resolveMonotonicSentenceOffsets', () => {
test('returns monotonically non-decreasing offsets for sentences in order', () => {
const spineText = 'The cat sat on the mat. The dog barked loudly. Then it was quiet.';
const sentences = [
@ -347,7 +347,7 @@ test.describe('resolveMonotonicSentenceOffsets', () => {
});
});
test.describe('getSpineItemPlainText shape handling', () => {
describe('getSpineItemPlainText shape handling', () => {
// Regression: epubjs's `Section.load()` resolves to the spine item's
// `<html>` Element (NOT a Document). Reading `loaded.body` directly was
// returning undefined for every spine item, so spineText came back ''

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
buildMonotonicWordToTokenMap,
@ -27,7 +27,7 @@ const alignmentWords = (words: string[]): TTSSentenceAlignment['words'] =>
charEnd: word.length,
}));
test.describe('EPUB word highlight mapping', () => {
describe('EPUB word highlight mapping', () => {
test('tokenizes canonical segment words with source offsets', () => {
const tokens = tokenizeCanonicalSegment(segment('"Hello," she said.', 12));

View file

@ -1,11 +1,11 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { createHtmlAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/html';
import { parseHtmlBlocks, type HtmlBlock } from '../../src/lib/client/html/blocks';
const blocksFromMd = (src: string): HtmlBlock[] => parseHtmlBlocks(src, false);
const blocksFromTxt = (src: string): HtmlBlock[] => parseHtmlBlocks(src, true);
test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', () => {
test('starts a new chapter at each h1/h2 heading by default', async () => {
const blocks = blocksFromMd(
[
@ -95,7 +95,7 @@ test.describe('createHtmlAudiobookSourceAdapter (markdown chapter splitting)', (
});
});
test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () => {
test('chunks TXT documents into "Part N" of 50 blocks by default', async () => {
const blocks = blocksFromTxt(
Array.from({ length: 120 }, (_, i) => `Block ${i + 1}.`).join('\n\n'),
@ -130,7 +130,7 @@ test.describe('createHtmlAudiobookSourceAdapter (txt chapter splitting)', () =>
});
});
test.describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
describe('createHtmlAudiobookSourceAdapter — prepareChapter', () => {
test('returns the same chapter by index that prepareChapters lists', async () => {
const blocks = blocksFromMd(['# A', '', 'one', '', '## B', '', 'two'].join('\n'));
const adapter = createHtmlAudiobookSourceAdapter({

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
mdToPlainText,
parseHtmlBlocks,
@ -6,7 +6,7 @@ import {
splitTxtBlocks,
} from '../../src/lib/client/html/blocks';
test.describe('parseHtmlBlocks (markdown)', () => {
describe('parseHtmlBlocks (markdown)', () => {
test('splits headings, paragraphs, and lists into separate blocks', () => {
const src = [
'# Title',
@ -55,7 +55,7 @@ test.describe('parseHtmlBlocks (markdown)', () => {
});
});
test.describe('parseHtmlBlocks (txt)', () => {
describe('parseHtmlBlocks (txt)', () => {
test('splits on blank-line boundaries and preserves intra-block whitespace', () => {
const src = 'First block\nmore.\n\nSecond block.\n\n\nThird block.';
const blocks = parseHtmlBlocks(src, true);
@ -73,7 +73,7 @@ test.describe('parseHtmlBlocks (txt)', () => {
});
});
test.describe('mdToPlainText badge/image stripping', () => {
describe('mdToPlainText badge/image stripping', () => {
// The bug: badge alt text was being kept in plainText. Since the rendered
// DOM is just an <img> with no text node, the sentence-highlight pattern
// matcher couldn't find those words and the WHOLE first-segment match
@ -124,7 +124,7 @@ test.describe('mdToPlainText badge/image stripping', () => {
});
});
test.describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
describe('buildFullDocumentText-style integration (badge-only blocks)', () => {
// If a paragraph is composed only of badges, its plainText is empty after
// stripping. The reader filters empty plainText out of the TTS source
// (`useHtmlDocument#buildFullDocumentText`), so badge blocks don't generate

View file

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { iconsGridStyle, maxColumnsForIconGrid } from '../../src/components/doclist/views/iconsGrid';
test.describe('icons grid layout', () => {
describe('icons grid layout', () => {
test('calculates max columns from width and icon size', () => {
expect(maxColumnsForIconGrid('md', 136)).toBe(1);
expect(maxColumnsForIconGrid('md', 300)).toBe(2);

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { getMaxVoicesForProvider } from '../../src/lib/shared/kokoro';
test.describe('kokoro voice limits', () => {
describe('kokoro voice limits', () => {
test('keeps Replicate single-voice even for Kokoro models', () => {
expect(getMaxVoicesForProvider('replicate', 'kokoro')).toBe(1);
expect(getMaxVoicesForProvider('replicate', 'hexgrad/Kokoro-82M')).toBe(1);

View file

@ -1,4 +1,4 @@
import { test, expect } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
preprocessSentenceForAudio,
splitTextToTtsBlocks,
@ -18,7 +18,7 @@ const expectNormalizedBlocks = (blocks: string[], maxLen = Number.POSITIVE_INFIN
}
};
test.describe('preprocessSentenceForAudio', () => {
describe('preprocessSentenceForAudio', () => {
test('normalizes common extraction artifacts', () => {
const cases: Array<{ input: string; expected: string }> = [
{
@ -45,7 +45,7 @@ test.describe('preprocessSentenceForAudio', () => {
});
});
test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
describe('splitTextToTtsBlocks (PDF-oriented)', () => {
test('returns [] for empty input', () => {
expect(splitTextToTtsBlocks('')).toEqual([]);
expect(splitTextToTtsBlocks(' ')).toEqual([]);
@ -160,7 +160,7 @@ test.describe('splitTextToTtsBlocks (PDF-oriented)', () => {
});
});
test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
test('returns [] for empty input', () => {
expect(splitTextToTtsBlocksEPUB('')).toEqual([]);
expect(splitTextToTtsBlocksEPUB(' ')).toEqual([]);
@ -193,7 +193,7 @@ test.describe('splitTextToTtsBlocksEPUB (highlight-friendly)', () => {
});
});
test.describe('normalizeTextForTts', () => {
describe('normalizeTextForTts', () => {
test('returns a single normalized string without newlines', () => {
const input = 'Hello.\nWorld.\n\nNext paragraph.';
const normalized = normalizeTextForTts(input);

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { createCoalescedAsyncRunner, resolveNextOnboardingStep } from '../../src/lib/client/onboarding-flow';
test.describe('onboarding flow resolver', () => {
describe('onboarding flow resolver', () => {
test('resolves deterministic order with privacy first', () => {
const step = resolveNextOnboardingStep({
privacyRequired: true,
@ -78,7 +78,7 @@ test.describe('onboarding flow resolver', () => {
});
});
test.describe('coalesced onboarding runner', () => {
describe('coalesced onboarding runner', () => {
test('coalesces concurrent triggers into one extra rerun', async () => {
let runs = 0;
let nestedRequested = false;

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { ONBOARDING_STATE_REGISTRY } from '../../src/lib/shared/onboarding-state';
import { SYNCED_PREFERENCE_KEYS } from '../../src/types/user-state';
test.describe('onboarding state storage scopes', () => {
describe('onboarding state storage scopes', () => {
test('keeps local onboarding flags out of synced server preferences', () => {
expect(ONBOARDING_STATE_REGISTRY.privacyAccepted.scope).toBe('local-dexie');
expect(ONBOARDING_STATE_REGISTRY.firstVisitSettingsOpened.scope).toBe('local-dexie');

View file

@ -1,15 +1,15 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { createPdfAudiobookSourceAdapter } from '../../src/lib/client/audiobooks/adapters/pdf';
import type { ParsedPdfDocument } from '../../src/types/parsed-pdf';
import type { DocumentSettings } from '../../src/types/document-settings';
test.describe('pdf audiobook adapter', () => {
describe('pdf audiobook adapter', () => {
test('builds chapters from paragraph titles and filters skipped kinds', async () => {
const parsed: ParsedPdfDocument = {
schemaVersion: 1,
documentId: 'doc-1',
parserVersion: 'test',
parsedAt: Date.now(),
parsedAt: 1_700_000_000_000,
pages: [
{
pageNumber: 1,
@ -77,7 +77,7 @@ test.describe('pdf audiobook adapter', () => {
schemaVersion: 1,
documentId: 'doc-2',
parserVersion: 'test',
parsedAt: Date.now(),
parsedAt: 1_700_000_000_001,
pages: [
{
pageNumber: 1,

View file

@ -1,8 +1,8 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { buildPageTextFromBlocks } from '../../src/lib/client/pdf-block-text';
import type { ParsedPdfPage } from '../../src/types/parsed-pdf';
test.describe('buildPageTextFromBlocks', () => {
describe('buildPageTextFromBlocks', () => {
test('filters skipped kinds and preserves reading order', () => {
const page: ParsedPdfPage = {
pageNumber: 1,

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import {
FORCE_REPARSE_CONFIRM_MESSAGE,
FORCE_REPARSE_CONFIRM_TEXT,
@ -6,7 +6,7 @@ import {
isForceReparseDisabled,
} from '../../src/lib/client/pdf/force-reparse';
test.describe('pdf force reparse controls', () => {
describe('pdf force reparse controls', () => {
test('disables action while parse is pending or running', () => {
expect(isForceReparseDisabled('pending')).toBeTruthy();
expect(isForceReparseDisabled('running')).toBeTruthy();

View file

@ -1,7 +1,7 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { mergeTextWithRegions } from '@openreader/compute-core';
test.describe('mergeTextWithRegions', () => {
describe('mergeTextWithRegions', () => {
test('assigns text items to containing regions by centroid and joins in reading order', () => {
const regions = [
{ bbox: [0, 0, 100, 50] as [number, number, number, number], label: 'text' as const },
@ -20,4 +20,18 @@ test.describe('mergeTextWithRegions', () => {
expect(merged[0].text).toBe('hello world');
expect(merged[1].text).toBe('Figure 1.2');
});
test('drops text whose centroid is outside every region', () => {
const regions = [
{ bbox: [0, 0, 50, 50] as [number, number, number, number], label: 'text' as const },
];
const textItems = [
{ text: 'inside', x: 10, y: 10, width: 10, height: 8 },
{ text: 'outside', x: 80, y: 80, width: 12, height: 8 },
];
const merged = mergeTextWithRegions(regions, textItems);
expect(merged).toHaveLength(1);
expect(merged[0].text).toBe('inside');
});
});

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { normalizeTextItemsForLayout } from '@openreader/compute-core';
import type { TextItem } from 'pdfjs-dist/types/src/display/api';
@ -19,7 +19,7 @@ function makeTextItem(
} as unknown as TextItem;
}
test.describe('normalizeTextItemsForLayout', () => {
describe('normalizeTextItemsForLayout', () => {
test('keeps horizontal body text and drops rotated/skewed margin text', () => {
const horizontal = makeTextItem(
'Powered by large language models',
@ -36,4 +36,12 @@ test.describe('normalizeTextItemsForLayout', () => {
expect(normalized).toHaveLength(1);
expect(normalized[0]?.text).toBe('Powered by large language models');
});
test('drops malformed/vertical-only runs so downstream layout planning sees no body text', () => {
const vertical = makeTextItem('Side label', [0, 10, -10, 0, 30, 200]);
const skewed = makeTextItem('Watermark', [10, 5, 2, 10, 200, 500]);
const normalized = normalizeTextItemsForLayout([vertical, skewed], 800);
expect(normalized).toEqual([]);
});
});

View file

@ -1,6 +1,7 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { stitchCrossPageBlocks } from '@openreader/compute-core';
import type { ParsedPdfBlock, ParsedPdfDocument, ParsedPdfBlockKind } from '../../src/types/parsed-pdf';
import { makeParsedPdfDocument, makeParsedPdfPage } from './support/document-fixtures';
function makeBlock(
id: string,
@ -23,19 +24,13 @@ function makeBlock(
}
function makeDoc(page1Blocks: ParsedPdfBlock[], page2Blocks: ParsedPdfBlock[]): ParsedPdfDocument {
return {
schemaVersion: 1,
documentId: 'doc',
parserVersion: 'test',
parsedAt: 0,
pages: [
{ pageNumber: 1, width: 100, height: 100, blocks: page1Blocks },
{ pageNumber: 2, width: 100, height: 100, blocks: page2Blocks },
],
};
return makeParsedPdfDocument([
makeParsedPdfPage(1, page1Blocks),
makeParsedPdfPage(2, page2Blocks),
]);
}
test.describe('stitchCrossPageBlocks', () => {
describe('stitchCrossPageBlocks', () => {
test('stitches paragraph continuation across footer/header noise', () => {
const doc = makeDoc(
[

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test';
import { describe, expect, test } from 'vitest';
import { buildPdfPageSourceUnits, buildPdfPrefetchPayload } from '../../src/lib/client/pdf-tts-planning';
import type { ParsedPdfPage } from '../../src/types/parsed-pdf';
@ -39,7 +39,7 @@ function buildPage(pageNumber: number): ParsedPdfPage {
};
}
test.describe('pdf tts planning helpers', () => {
describe('pdf tts planning helpers', () => {
test('buildPdfPageSourceUnits uses parsed blocks, honors skip kinds, and pins locator page', () => {
const page = buildPage(2);
const units = buildPdfPageSourceUnits(page, 2, ['header']);

View file

@ -0,0 +1,28 @@
import { describe, expect, test } from 'vitest';
import { RUNTIME_CONFIG_SCHEMA } from '../../src/lib/server/admin/settings';
describe('TTS rate limit runtime config seeds', () => {
test('defaults disable TTS daily rate limiting', () => {
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.default).toBe(true);
});
test('parses disable seed boolean values', () => {
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('true')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('false')).toBe(false);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('1')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('0')).toBe(false);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('on')).toBe(true);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('no')).toBe(false);
expect(RUNTIME_CONFIG_SCHEMA.disableTtsRateLimit.parseEnv('invalid')).toBeUndefined();
});
test('daily limit values are runtime-only (no env seed vars)', () => {
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAnonymous.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsIpDailyLimitAuthenticated.envVar).toBeUndefined();
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAnonymous.default).toBe(50_000);
expect(RUNTIME_CONFIG_SCHEMA.ttsDailyLimitAuthenticated.default).toBe(500_000);
});
});

Some files were not shown because too many files have changed in this diff Show more