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:
commit
3daf4b6453
134 changed files with 8476 additions and 2389 deletions
69
.env.example
69
.env.example
|
|
@ -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
27
.github/workflows/vitest.yml
vendored
Normal 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
|
||||
95
compute/core/tests/control-plane/orchestrator.test.ts
Normal file
95
compute/core/tests/control-plane/orchestrator.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
26
compute/core/tests/control-plane/sse.test.ts
Normal file
26
compute/core/tests/control-plane/sse.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
76
compute/core/tests/control-plane/state-machine.test.ts
Normal file
76
compute/core/tests/control-plane/state-machine.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1428
compute/worker/src/runtime.ts
Normal file
1428
compute/worker/src/runtime.ts
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
22
compute/worker/src/worker-loop-policy.ts
Normal file
22
compute/worker/src/worker-loop-policy.ts
Normal 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';
|
||||
}
|
||||
122
compute/worker/tests/api/routes.test.ts
Normal file
122
compute/worker/tests/api/routes.test.ts
Normal 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"');
|
||||
});
|
||||
});
|
||||
119
compute/worker/tests/fixtures/fake-control-plane.ts
vendored
Normal file
119
compute/worker/tests/fixtures/fake-control-plane.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
8
compute/worker/tests/setup-env.ts
Normal file
8
compute/worker/tests/setup-env.ts
Normal 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';
|
||||
9
compute/worker/tests/tsconfig.json
Normal file
9
compute/worker/tests/tsconfig.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"types": ["node", "vitest/globals"]
|
||||
},
|
||||
"include": ["./**/*.ts"],
|
||||
"exclude": []
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
37
compute/worker/tests/unit/key-and-progress.test.ts
Normal file
37
compute/worker/tests/unit/key-and-progress.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
18
compute/worker/tests/unit/worker-loop-policy.test.ts
Normal file
18
compute/worker/tests/unit/worker-loop-policy.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
"noEmit": true,
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": []
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
9
drizzle/postgres/0008_user_job_events.sql
Normal file
9
drizzle/postgres/0008_user_job_events.sql
Normal 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");
|
||||
1906
drizzle/postgres/meta/0008_snapshot.json
Normal file
1906
drizzle/postgres/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
9
drizzle/sqlite/0008_user_job_events.sql
Normal file
9
drizzle/sqlite/0008_user_job_events.sql
Normal 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`);
|
||||
1751
drizzle/sqlite/meta/0008_snapshot.json
Normal file
1751
drizzle/sqlite/meta/0008_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
636
pnpm-lock.yaml
636
pnpm-lock.yaml
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
))
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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({}),
|
||||
|
|
|
|||
|
|
@ -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('{}'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
167
src/lib/server/rate-limit/job-rate-limiter.ts
Normal file
167
src/lib/server/rate-limit/job-rate-limiter.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
34
src/lib/shared/shared-provider-selection.ts
Normal file
34
src/lib/shared/shared-provider-selection.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
@ -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');
|
||||
|
|
@ -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',
|
||||
|
|
@ -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');
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
111
tests/unit/auth-config.vitest.spec.ts
Normal file
111
tests/unit/auth-config.vitest.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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.',
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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');
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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}');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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 ''
|
||||
|
|
@ -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));
|
||||
|
||||
|
|
@ -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({
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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');
|
||||
|
|
@ -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,
|
||||
|
|
@ -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,
|
||||
|
|
@ -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();
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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(
|
||||
[
|
||||
|
|
@ -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']);
|
||||
28
tests/unit/rate-limit-runtime-settings.vitest.spec.ts
Normal file
28
tests/unit/rate-limit-runtime-settings.vitest.spec.ts
Normal 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
Loading…
Reference in a new issue