From fc3d05d65b2fed65b30691b9ff49b9192402d16b Mon Sep 17 00:00:00 2001 From: Richard R Date: Sat, 30 May 2026 14:17:49 -0600 Subject: [PATCH] feat(rate-limit): add per-user PDF parsing rate limiting and job event ledger implement a generic user_job_events table for tracking compute job creation enforce configurable burst and sustained limits for PDF layout parsing add admin panel controls for compute rate limiting and max upload size update API routes to apply and record rate checks for PDF parse jobs document new environment variables and admin settings for compute limits improve IP extraction logic for rate limiting accuracy add tests for request IP extraction and test namespace gating This change introduces a robust mechanism to throttle expensive compute operations, such as PDF parsing, on a per-user basis. It provides both burst and sustained rate controls, with admin-tunable parameters and clear user feedback on throttling. The job event ledger enables accurate concurrency and rate enforcement, while new documentation and tests ensure maintainability and clarity. --- .env.example | 10 + docs-site/docs/configure/admin-panel.md | 8 + docs-site/docs/configure/tts-rate-limiting.md | 1 + .../docs/reference/environment-variables.md | 84 +- drizzle/postgres/0008_user_job_events.sql | 9 + drizzle/postgres/meta/0008_snapshot.json | 1906 +++++++++++++++++ drizzle/postgres/meta/_journal.json | 7 + drizzle/sqlite/0008_user_job_events.sql | 9 + drizzle/sqlite/meta/0008_snapshot.json | 1751 +++++++++++++++ drizzle/sqlite/meta/_journal.json | 7 + playwright.config.ts | 6 +- src/app/api/documents/[id]/parsed/route.ts | 16 + .../documents/blob/upload/fallback/route.ts | 21 + .../documents/blob/upload/presign/route.ts | 18 +- .../api/documents/docx-to-pdf/upload/route.ts | 5 + src/app/api/documents/route.ts | 13 + src/components/admin/AdminFeaturesPanel.tsx | 87 + src/db/schema.ts | 1 + src/db/schema_postgres.ts | 16 + src/db/schema_sqlite.ts | 16 + src/lib/server/admin/settings.ts | 11 + src/lib/server/documents/blobstore.ts | 11 + src/lib/server/rate-limit/job-rate-limiter.ts | 162 ++ src/lib/server/rate-limit/problem-response.ts | 26 + src/lib/server/rate-limit/request-ip.ts | 55 +- src/lib/server/testing/test-namespace.ts | 17 + tests/unit/request-ip.vitest.spec.ts | 53 + tests/unit/test-namespace-gate.vitest.spec.ts | 27 + 28 files changed, 4319 insertions(+), 34 deletions(-) create mode 100644 drizzle/postgres/0008_user_job_events.sql create mode 100644 drizzle/postgres/meta/0008_snapshot.json create mode 100644 drizzle/sqlite/0008_user_job_events.sql create mode 100644 drizzle/sqlite/meta/0008_snapshot.json create mode 100644 src/lib/server/rate-limit/job-rate-limiter.ts create mode 100644 tests/unit/request-ip.vitest.spec.ts create mode 100644 tests/unit/test-namespace-gate.vitest.spec.ts diff --git a/.env.example b/.env.example index d3c5c47..0aebf94 100644 --- a/.env.example +++ b/.env.example @@ -37,6 +37,12 @@ AUTH_TRUSTED_ORIGINS=http://localhost:3003,http://127.0.0.1:3003 # Additional tr # (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). # Demotion is automatic: removing an email here demotes the user on next login. @@ -118,6 +124,10 @@ S3_BUCKET= # 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 +# Seeds the PDF parsing rate limit on first boot (default: enabled). All other +# compute-limit values (burst/sustained limits, windows) and the max upload size +# are configured in Settings → Admin, not via env. +# RUNTIME_SEED_DISABLE_COMPUTE_LIMIT=false # Migrations configuration # (Optional) Skip automatic startup migrations when set to `false` (default: `true`) diff --git a/docs-site/docs/configure/admin-panel.md b/docs-site/docs/configure/admin-panel.md index 3d36571..bdb64ce 100644 --- a/docs-site/docs/configure/admin-panel.md +++ b/docs-site/docs/configure/admin-panel.md @@ -77,6 +77,14 @@ Runtime-editable settings, one row per key: | `enableAudiobookExport` | Show the audiobook export entry points on PDF/EPUB pages. | | `enableDocxConversion` | Accept .docx uploads (converted to PDF server-side). | | `enableDestructiveDeleteActions` | Show "Delete all data" buttons in the Documents tab (auth-disabled mode). | +| `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. Word-by-word highlighting and PDF layout parsing capability are controlled by compute-worker server env configuration, not an admin runtime flag. diff --git a/docs-site/docs/configure/tts-rate-limiting.md b/docs-site/docs/configure/tts-rate-limiting.md index b9cd801..fedf7f0 100644 --- a/docs-site/docs/configure/tts-rate-limiting.md +++ b/docs-site/docs/configure/tts-rate-limiting.md @@ -37,5 +37,6 @@ If a request exceeds the active limit, the TTS API returns `429` with reset meta ## 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) diff --git a/docs-site/docs/reference/environment-variables.md b/docs-site/docs/reference/environment-variables.md index 4db5083..cb9fa0d 100644 --- a/docs-site/docs/reference/environment-variables.md +++ b/docs-site/docs/reference/environment-variables.md @@ -31,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 | @@ -43,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 | @@ -72,6 +71,9 @@ For auth-enabled deployments, use **Settings → Admin** as the primary source o | `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 | `false` | Optional first-boot seed that disables PDF parsing rate limiting (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 | @@ -228,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. @@ -330,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 @@ -492,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: `false` (rate limiting enabled) +- 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. @@ -564,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: `false` (PDF parsing rate limiting enabled) +- 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) diff --git a/drizzle/postgres/0008_user_job_events.sql b/drizzle/postgres/0008_user_job_events.sql new file mode 100644 index 0000000..be33cc7 --- /dev/null +++ b/drizzle/postgres/0008_user_job_events.sql @@ -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"); \ No newline at end of file diff --git a/drizzle/postgres/meta/0008_snapshot.json b/drizzle/postgres/meta/0008_snapshot.json new file mode 100644 index 0000000..03a4af9 --- /dev/null +++ b/drizzle/postgres/meta/0008_snapshot.json @@ -0,0 +1,1906 @@ +{ + "id": "0381962f-77d4-451e-b4ec-08b9a0dde70d", + "prevId": "68baf959-cd1d-418b-a04e-11397aa05c39", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_providers": { + "name": "admin_providers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.admin_settings": { + "name": "admin_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value_json": { + "name": "value_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobook_chapters": { + "name": "audiobook_chapters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "name": "audiobook_chapters_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audiobooks": { + "name": "audiobooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "name": "audiobooks_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_previews": { + "name": "document_previews", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "lease_until_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "name": "document_previews_document_id_namespace_variant_pk", + "columns": [ + "document_id", + "namespace", + "variant" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.document_settings": { + "name": "document_settings", + "schema": "", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "name": "document_settings_document_id_user_id_pk", + "columns": [ + "document_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.documents": { + "name": "documents", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "last_modified": { + "name": "last_modified", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_modified", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "name": "documents_id_user_id_pk", + "columns": [ + "id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_entries": { + "name": "tts_segment_entries", + "schema": "", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_version": { + "name": "document_version", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_reader_rank", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_char_offset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_spine_href", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_page", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_location", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_index", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "locator_identity_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "document_version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "name": "tts_segment_entries_segment_entry_id_user_id_pk", + "columns": [ + "segment_entry_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tts_segment_variants": { + "name": "tts_segment_variants", + "schema": "", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "segment_entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "settings_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "name": "tts_segment_variants_segment_id_user_id_pk", + "columns": [ + "segment_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_document_progress": { + "name": "user_document_progress", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "name": "user_document_progress_user_id_document_id_pk", + "columns": [ + "user_id", + "document_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_job_events": { + "name": "user_job_events", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "op_id": { + "name": "op_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_job_events_user_action_created": { + "name": "idx_user_job_events_user_action_created", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_job_events_user_id_action_op_id_pk": { + "name": "user_job_events_user_id_action_op_id_pk", + "columns": [ + "user_id", + "action", + "op_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_preferences": { + "name": "user_preferences", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "data_json": { + "name": "data_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_tts_chars": { + "name": "user_tts_chars", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "char_count": { + "name": "char_count", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + }, + "updated_at": { + "name": "updated_at", + "type": "bigint", + "primaryKey": false, + "notNull": false, + "default": "(extract(epoch from now()) * 1000)::bigint" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + { + "expression": "date", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "name": "user_tts_chars_user_id_date_pk", + "columns": [ + "user_id", + "date" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/postgres/meta/_journal.json b/drizzle/postgres/meta/_journal.json index eccd0d6..6760c50 100644 --- a/drizzle/postgres/meta/_journal.json +++ b/drizzle/postgres/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/drizzle/sqlite/0008_user_job_events.sql b/drizzle/sqlite/0008_user_job_events.sql new file mode 100644 index 0000000..f3d8b8b --- /dev/null +++ b/drizzle/sqlite/0008_user_job_events.sql @@ -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`); \ No newline at end of file diff --git a/drizzle/sqlite/meta/0008_snapshot.json b/drizzle/sqlite/meta/0008_snapshot.json new file mode 100644 index 0000000..b920031 --- /dev/null +++ b/drizzle/sqlite/meta/0008_snapshot.json @@ -0,0 +1,1751 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "5e21c9cb-0d9b-48fe-92ec-9e98ac43c198", + "prevId": "031a62ae-47a7-4476-a577-7b14ff787eba", + "tables": { + "admin_providers": { + "name": "admin_providers", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_type": { + "name": "provider_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "api_key_ciphertext": { + "name": "api_key_ciphertext", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_iv": { + "name": "api_key_iv", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "api_key_last4": { + "name": "api_key_last4", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_model": { + "name": "default_model", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "default_instructions": { + "name": "default_instructions", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "admin_providers_slug_unique": { + "name": "admin_providers_slug_unique", + "columns": [ + "slug" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "admin_settings": { + "name": "admin_settings", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "value_json": { + "name": "value_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'admin'" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobook_chapters": { + "name": "audiobook_chapters", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "book_id": { + "name": "book_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "chapter_index": { + "name": "chapter_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "format": { + "name": "format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "audiobook_chapters_user_id_user_id_fk": { + "name": "audiobook_chapters_user_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk": { + "name": "audiobook_chapters_book_id_user_id_audiobooks_id_user_id_fk", + "tableFrom": "audiobook_chapters", + "tableTo": "audiobooks", + "columnsFrom": [ + "book_id", + "user_id" + ], + "columnsTo": [ + "id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobook_chapters_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobook_chapters_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "audiobooks": { + "name": "audiobooks", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cover_path": { + "name": "cover_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "duration": { + "name": "duration", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "audiobooks_user_id_user_id_fk": { + "name": "audiobooks_user_id_user_id_fk", + "tableFrom": "audiobooks", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "audiobooks_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "audiobooks_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_previews": { + "name": "document_previews", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "''" + }, + "variant": { + "name": "variant", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'queued'" + }, + "source_last_modified_ms": { + "name": "source_last_modified_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'image/jpeg'" + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "byte_size": { + "name": "byte_size", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_owner": { + "name": "lease_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until_ms": { + "name": "lease_until_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "updated_at_ms": { + "name": "updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + } + }, + "indexes": { + "idx_document_previews_status_lease": { + "name": "idx_document_previews_status_lease", + "columns": [ + "status", + "lease_until_ms" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "document_previews_document_id_namespace_variant_pk": { + "columns": [ + "document_id", + "namespace", + "variant" + ], + "name": "document_previews_document_id_namespace_variant_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "document_settings": { + "name": "document_settings", + "columns": { + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_document_settings_user_id": { + "name": "idx_document_settings_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "document_settings_user_id_user_id_fk": { + "name": "document_settings_user_id_user_id_fk", + "tableFrom": "document_settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "document_settings_document_id_user_id_pk": { + "columns": [ + "document_id", + "user_id" + ], + "name": "document_settings_document_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "documents": { + "name": "documents", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_modified": { + "name": "last_modified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "file_path": { + "name": "file_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "parse_state": { + "name": "parse_state", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "parsed_json_key": { + "name": "parsed_json_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_documents_user_id": { + "name": "idx_documents_user_id", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "idx_documents_user_id_last_modified": { + "name": "idx_documents_user_id_last_modified", + "columns": [ + "user_id", + "last_modified" + ], + "isUnique": false + } + }, + "foreignKeys": { + "documents_user_id_user_id_fk": { + "name": "documents_user_id_user_id_fk", + "tableFrom": "documents", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "documents_id_user_id_pk": { + "columns": [ + "id", + "user_id" + ], + "name": "documents_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_entries": { + "name": "tts_segment_entries", + "columns": { + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_version": { + "name": "document_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_index": { + "name": "segment_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_key": { + "name": "segment_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "locator_reader_rank": { + "name": "locator_reader_rank", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_reader_type": { + "name": "locator_reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_page": { + "name": "locator_page", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_index": { + "name": "locator_spine_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_spine_href": { + "name": "locator_spine_href", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_char_offset": { + "name": "locator_char_offset", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_location": { + "name": "locator_location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "locator_identity_key": { + "name": "locator_identity_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_hash": { + "name": "text_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "text_length": { + "name": "text_length", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_entries_manifest_sort": { + "name": "idx_tts_segment_entries_manifest_sort", + "columns": [ + "user_id", + "document_id", + "document_version", + "locator_reader_rank", + "locator_spine_index", + "locator_char_offset", + "locator_spine_href", + "locator_page", + "locator_location", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_manifest_group": { + "name": "idx_tts_segment_entries_manifest_group", + "columns": [ + "user_id", + "document_id", + "document_version", + "segment_index", + "locator_identity_key" + ], + "isUnique": false + }, + "idx_tts_segment_entries_scope": { + "name": "idx_tts_segment_entries_scope", + "columns": [ + "user_id", + "document_id", + "document_version" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_entries_user_id_user_id_fk": { + "name": "tts_segment_entries_user_id_user_id_fk", + "tableFrom": "tts_segment_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_entries_segment_entry_id_user_id_pk": { + "columns": [ + "segment_entry_id", + "user_id" + ], + "name": "tts_segment_entries_segment_entry_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "tts_segment_variants": { + "name": "tts_segment_variants", + "columns": { + "segment_id": { + "name": "segment_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "segment_entry_id": { + "name": "segment_entry_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_hash": { + "name": "settings_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "settings_json": { + "name": "settings_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "audio_key": { + "name": "audio_key", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "audio_format": { + "name": "audio_format", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'mp3'" + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "alignment_json": { + "name": "alignment_json", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'pending'" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_tts_segment_variants_entry": { + "name": "idx_tts_segment_variants_entry", + "columns": [ + "user_id", + "segment_entry_id", + "updated_at" + ], + "isUnique": false + }, + "idx_tts_segment_variants_status": { + "name": "idx_tts_segment_variants_status", + "columns": [ + "user_id", + "status" + ], + "isUnique": false + }, + "idx_tts_segment_variants_unique_settings": { + "name": "idx_tts_segment_variants_unique_settings", + "columns": [ + "user_id", + "segment_entry_id", + "settings_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "tts_segment_variants_user_id_user_id_fk": { + "name": "tts_segment_variants_user_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk": { + "name": "tts_segment_variants_segment_entry_id_user_id_tts_segment_entries_segment_entry_id_user_id_fk", + "tableFrom": "tts_segment_variants", + "tableTo": "tts_segment_entries", + "columnsFrom": [ + "segment_entry_id", + "user_id" + ], + "columnsTo": [ + "segment_entry_id", + "user_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "tts_segment_variants_segment_id_user_id_pk": { + "columns": [ + "segment_id", + "user_id" + ], + "name": "tts_segment_variants_segment_id_user_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_document_progress": { + "name": "user_document_progress", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "reader_type": { + "name": "reader_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "location": { + "name": "location", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "progress": { + "name": "progress", + "type": "real", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_document_progress_user_id_updated_at": { + "name": "idx_user_document_progress_user_id_updated_at", + "columns": [ + "user_id", + "updated_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "user_document_progress_user_id_user_id_fk": { + "name": "user_document_progress_user_id_user_id_fk", + "tableFrom": "user_document_progress", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_document_progress_user_id_document_id_pk": { + "columns": [ + "user_id", + "document_id" + ], + "name": "user_document_progress_user_id_document_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_job_events": { + "name": "user_job_events", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "op_id": { + "name": "op_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_job_events_user_action_created": { + "name": "idx_user_job_events_user_action_created", + "columns": [ + "user_id", + "action", + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_job_events_user_id_action_op_id_pk": { + "columns": [ + "user_id", + "action", + "op_id" + ], + "name": "user_job_events_user_id_action_op_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_preferences": { + "name": "user_preferences", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "data_json": { + "name": "data_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'{}'" + }, + "client_updated_at_ms": { + "name": "client_updated_at_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "user_preferences_user_id_user_id_fk": { + "name": "user_preferences_user_id_user_id_fk", + "tableFrom": "user_preferences", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user_tts_chars": { + "name": "user_tts_chars", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "date": { + "name": "date", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "char_count": { + "name": "char_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "idx_user_tts_chars_date": { + "name": "idx_user_tts_chars_date", + "columns": [ + "date" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "user_tts_chars_user_id_date_pk": { + "columns": [ + "user_id", + "date" + ], + "name": "user_tts_chars_user_id_date_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "is_anonymous": { + "name": "is_anonymous", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": false + }, + "is_admin": { + "name": "is_admin", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/drizzle/sqlite/meta/_journal.json b/drizzle/sqlite/meta/_journal.json index 8db8d88..5db4a53 100644 --- a/drizzle/sqlite/meta/_journal.json +++ b/drizzle/sqlite/meta/_journal.json @@ -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 } ] } \ No newline at end of file diff --git a/playwright.config.ts b/playwright.config.ts index a1f9f25..6cd95e5 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -39,8 +39,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, diff --git a/src/app/api/documents/[id]/parsed/route.ts b/src/app/api/documents/[id]/parsed/route.ts index 51ef630..3ca677f 100644 --- a/src/app/api/documents/[id]/parsed/route.ts +++ b/src/app/api/documents/[id]/parsed/route.ts @@ -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); diff --git a/src/app/api/documents/blob/upload/fallback/route.ts b/src/app/api/documents/blob/upload/fallback/route.ts index bc188df..74f67df 100644 --- a/src/app/api/documents/blob/upload/fallback/route.ts +++ b/src/app/api/documents/blob/upload/fallback/route.ts @@ -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,27 @@ export async function PUT(req: NextRequest) { } const contentType = (req.headers.get('content-type') || 'application/octet-stream').trim() || 'application/octet-stream'; + + 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 }, + ); + } + const body = Buffer.from(await req.arrayBuffer()); + // Backstop for chunked/omitted Content-Length: enforce on the actual bytes. + if (body.byteLength > maxUploadBytes) { + return NextResponse.json( + { error: `Upload exceeds the maximum allowed size of ${maxUploadBytes} bytes`, maxBytes: maxUploadBytes }, + { status: 413 }, + ); + } + const namespace = getOpenReaderTestNamespace(req.headers); try { diff --git a/src/app/api/documents/blob/upload/presign/route.ts b/src/app/api/documents/blob/upload/presign/route.ts index 07756aa..6d91ee4 100644 --- a/src/app/api/documents/blob/upload/presign/route.ts +++ b/src/app/api/documents/blob/upload/presign/route.ts @@ -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, diff --git a/src/app/api/documents/docx-to-pdf/upload/route.ts b/src/app/api/documents/docx-to-pdf/upload/route.ts index 91866af..4d2b992 100644 --- a/src/app/api/documents/docx-to-pdf/upload/route.ts +++ b/src/app/api/documents/docx-to-pdf/upload/route.ts @@ -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(ctxOrRes.userId, 'pdf_layout', `docx:${randomUUID()}`, pdfRateConfig); enqueueParsePdfJob({ documentId: id, userId: storageUserId, diff --git a/src/app/api/documents/route.ts b/src/app/api/documents/route.ts index c56891c..af9c38a 100644 --- a/src/app/api/documents/route.ts +++ b/src/app/api/documents/route.ts @@ -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 }); enqueueParsePdfJob({ documentId: doc.id, userId: storageUserId, diff --git a/src/components/admin/AdminFeaturesPanel.tsx b/src/components/admin/AdminFeaturesPanel.tsx index 575c875..a450520 100644 --- a/src/components/admin/AdminFeaturesPanel.tsx +++ b/src/components/admin/AdminFeaturesPanel.tsx @@ -160,6 +160,7 @@ export function AdminFeaturesPanel() { : fallbackShared; const selectedProviderOption = effectiveSelectedProvider; const shouldRenderRateLimitInputs = draft.disableTtsRateLimit === false; + const shouldRenderComputeRateLimitInputs = draft.disableComputeRateLimit === false; const handleProviderChange = (opt: ProviderOption) => { updateDraft('defaultTtsProvider', opt.id); @@ -347,6 +348,92 @@ export function AdminFeaturesPanel() { ) : null} + + updateDraft('disableComputeRateLimit', checked)} + right={renderSource('disableComputeRateLimit')} + variant="flat" + /> + {shouldRenderComputeRateLimitInputs ? ( +
+
+
+ + {renderSource('computeParseBurstMax')} +
+ updatePositiveIntDraft('computeParseBurstMax', event.target.value)} + /> +
+
+
+ + {renderSource('computeParseBurstWindowSec')} +
+ updatePositiveIntDraft('computeParseBurstWindowSec', event.target.value)} + /> +
+
+
+ + {renderSource('computeParseSustainedMax')} +
+ updatePositiveIntDraft('computeParseSustainedMax', event.target.value)} + /> +
+
+
+ + {renderSource('computeParseSustainedWindowSec')} +
+ updatePositiveIntDraft('computeParseSustainedWindowSec', event.target.value)} + /> +
+
+ ) : null} + +
+
+
+ + {renderSource('maxUploadMb')} +
+ updatePositiveIntDraft('maxUploadMb', event.target.value)} + /> +
+
[ + 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({}), diff --git a/src/db/schema_sqlite.ts b/src/db/schema_sqlite.ts index 2781e7d..83d3420 100644 --- a/src/db/schema_sqlite.ts +++ b/src/db/schema_sqlite.ts @@ -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('{}'), diff --git a/src/lib/server/admin/settings.ts b/src/lib/server/admin/settings.ts index c92b718..aacb273 100644 --- a/src/lib/server/admin/settings.ts +++ b/src/lib/server/admin/settings.ts @@ -111,6 +111,17 @@ export const RUNTIME_CONFIG_SCHEMA = { ttsDailyLimitAuthenticated: positiveIntValue(500_000), ttsIpDailyLimitAnonymous: positiveIntValue(100_000), ttsIpDailyLimitAuthenticated: positiveIntValue(1_000_000), + // Per-user throttle for expensive PDF-layout parsing. Enabled by default. + // 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(false, '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>; export type RuntimeConfigKey = keyof typeof RUNTIME_CONFIG_SCHEMA; diff --git a/src/lib/server/documents/blobstore.ts b/src/lib/server/documents/blobstore.ts index 5ecae17..fb84f00 100644 --- a/src/lib/server/documents/blobstore.ts +++ b/src/lib/server/documents/blobstore.ts @@ -104,17 +104,28 @@ export async function presignPut( id: string, contentType: string, namespace: string | null, + options?: { contentLength?: number }, ): Promise<{ url: string; headers: Record }> { 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 }); diff --git a/src/lib/server/rate-limit/job-rate-limiter.ts b/src/lib/server/rate-limit/job-rate-limiter.ts new file mode 100644 index 0000000..d65a183 --- /dev/null +++ b/src/lib/server/rate-limit/job-rate-limiter.ts @@ -0,0 +1,162 @@ +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, userId: string | null | undefined): userId is string { + return isAuthEnabled() && config.enabled && Boolean(userId); +} + +async function countSince(userId: string, action: JobRateAction, since: number): Promise { + const rows = await safeDb() + .select({ c: sql`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 { + const rows = await safeDb() + .select({ oldest: sql`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 { + 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: Pick, +): Promise { + 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 a generous retention window so the + // ledger stays small without a separate cron. + if (Math.random() < 0.05) { + try { + await safeDb() + .delete(userJobEvents) + .where(and( + eq(userJobEvents.action, action), + lt(userJobEvents.createdAt, now - 24 * 60 * 60 * 1000), + )); + } catch { + // ignore prune failures + } + } +} diff --git a/src/lib/server/rate-limit/problem-response.ts b/src/lib/server/rate-limit/problem-response.ts index 69b08ab..5fa9d3a 100644 --- a/src/lib/server/rate-limit/problem-response.ts +++ b/src/lib/server/rate-limit/problem-response.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server'; 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); @@ -45,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), + }, + }); +} diff --git a/src/lib/server/rate-limit/request-ip.ts b/src/lib/server/rate-limit/request-ip.ts index 16d852e..2037790 100644 --- a/src/lib/server/rate-limit/request-ip.ts +++ b/src/lib/server/rate-limit/request-ip.ts @@ -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; diff --git a/src/lib/server/testing/test-namespace.ts b/src/lib/server/testing/test-namespace.ts index eb9278a..044e46f 100644 --- a/src/lib/server/testing/test-namespace.ts +++ b/src/lib/server/testing/test-namespace.ts @@ -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; diff --git a/tests/unit/request-ip.vitest.spec.ts b/tests/unit/request-ip.vitest.spec.ts new file mode 100644 index 0000000..ee51281 --- /dev/null +++ b/tests/unit/request-ip.vitest.spec.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from 'vitest'; +import type { NextRequest } from 'next/server'; +import { getClientIp } from '../../src/lib/server/rate-limit/request-ip'; +import { withEnv } from './support/env'; + +function makeReq(headers: Record, ip?: string): NextRequest { + const req = { headers: new Headers(headers) } as Partial & { ip?: string }; + if (ip) req.ip = ip; + return req as NextRequest; +} + +describe('getClientIp', () => { + test('never trusts the left-most (client-prependable) X-Forwarded-For entry', async () => { + await withEnv({ VERCEL: undefined }, () => { + // Attacker prepends a spoofed value; the real connecting IP is the right-most hop. + const req = makeReq({ 'x-forwarded-for': '1.2.3.4, 9.9.9.9' }); + expect(getClientIp(req)).toBe('9.9.9.9'); + }); + }); + + test('prefers x-real-ip over X-Forwarded-For', async () => { + await withEnv({ VERCEL: undefined }, () => { + const req = makeReq({ 'x-real-ip': '5.5.5.5', 'x-forwarded-for': '1.2.3.4, 9.9.9.9' }); + expect(getClientIp(req)).toBe('5.5.5.5'); + }); + }); + + test('prefers x-vercel-forwarded-for when running on Vercel', async () => { + await withEnv({ VERCEL: '1' }, () => { + const req = makeReq({ + 'x-vercel-forwarded-for': '8.8.8.8', + 'x-real-ip': '5.5.5.5', + 'x-forwarded-for': '1.2.3.4', + }); + expect(getClientIp(req)).toBe('8.8.8.8'); + }); + }); + + test('ignores x-vercel-forwarded-for when not on Vercel', async () => { + await withEnv({ VERCEL: undefined }, () => { + // A self-hosted deployment must not trust a client-supplied x-vercel-* header. + const req = makeReq({ 'x-vercel-forwarded-for': '8.8.8.8', 'x-real-ip': '5.5.5.5' }); + expect(getClientIp(req)).toBe('5.5.5.5'); + }); + }); + + test('falls back to the connecting address when no proxy headers are present', async () => { + await withEnv({ VERCEL: undefined }, () => { + const req = makeReq({}, '10.0.0.1'); + expect(getClientIp(req)).toBe('10.0.0.1'); + }); + }); +}); diff --git a/tests/unit/test-namespace-gate.vitest.spec.ts b/tests/unit/test-namespace-gate.vitest.spec.ts new file mode 100644 index 0000000..b2e4eb4 --- /dev/null +++ b/tests/unit/test-namespace-gate.vitest.spec.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from 'vitest'; +import { getOpenReaderTestNamespace } from '../../src/lib/server/testing/test-namespace'; +import { withEnv } from './support/env'; + +function headers(value: string): Headers { + return new Headers({ 'x-openreader-test-namespace': value }); +} + +describe('getOpenReaderTestNamespace gate', () => { + test('honored in non-production builds', async () => { + await withEnv({ NODE_ENV: 'development', ENABLE_TEST_NAMESPACE: undefined }, () => { + expect(getOpenReaderTestNamespace(headers('chromium'))).toBe('chromium'); + }); + }); + + test('ignored on production builds without the explicit flag', async () => { + await withEnv({ NODE_ENV: 'production', ENABLE_TEST_NAMESPACE: undefined }, () => { + expect(getOpenReaderTestNamespace(headers('attacker'))).toBeNull(); + }); + }); + + test('honored on production builds when ENABLE_TEST_NAMESPACE=true (CI parity)', async () => { + await withEnv({ NODE_ENV: 'production', ENABLE_TEST_NAMESPACE: 'true' }, () => { + expect(getOpenReaderTestNamespace(headers('webkit'))).toBe('webkit'); + }); + }); +});