diff --git a/AGENTS.md b/AGENTS.md index 888256d0..5d745404 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,7 @@ - Human-facing product and service display name: **Velopulent CMS**. - Canonical documentation: . -- Keep the executable, package name, native service identifier, environment prefix, and internal identifiers as `vcms` / `VCMS_*` for backward compatibility. Do not rename persisted paths or service keys when updating branding. +- Keep the executable, package name, native service identifier, environment prefix, and internal identifiers as `vcms` / `VCMS_*`. Do not rename persisted paths or service keys when updating branding. ## Architecture @@ -12,25 +12,24 @@ - **Frontend**: React in `apps/dashboard/` with Tanstack Router, Tanstack Query, shadcn/ui - **gRPC**: Separate server on port 50051 (compiled from `libs/proto/*.proto` via `tonic-build`) - **Build**: Nx orchestrates `dashboard:build` → `backend:build`. `build.rs` compiles proto files only. -- **Runtime**: Single binary serves REST API (`/api/*`), gRPC, GraphQL (`/api/graphql`), MCP (Streamable HTTP at `/mcp`), health probes (`/health/live`, `/health/ready`), and static SPA fallback +- **Runtime**: Single binary serves the public REST API (`/api/v1/*`), dashboard API (`/api/dashboard/*`), gRPC, GraphQL (`/api/graphql`), MCP (Streamable HTTP at `/mcp`), health probes (`/health/live`, `/health/ready`), and the static SPA fallback ## Key Directories -- `apps/backend/src/` - Rust backend source - `apps/backend/src/handlers/` - HTTP handlers (auth, content, schema, site, file, UI) - `apps/backend/src/graphql/` - GraphQL schema and resolvers - `apps/backend/src/grpc/` - gRPC service implementations (generated from `libs/proto/*.proto`) -- `apps/backend/src/mcp/` - MCP server (tools/`*.rs`, `schema.rs`, `server.rs`, `auth.rs`, `transports/`) -- `apps/backend/src/repository/` - Data access layer +- `apps/backend/src/mcp/` - MCP server (`tools/*.rs`, `resources/`, `schema.rs`, `server.rs`, `auth.rs`, `transports/`) +- `apps/backend/src/repository/` - Data access layer (`sqlite/`, `postgres/`, `traits.rs`) +- `apps/backend/src/services/` - Domain services shared by every adapter - `apps/backend/src/services/backup/` - Backup & restore engine (dump/restore, scheduler, metadata) +- `apps/backend/src/services/search/` - Full-text search engine (Tantivy index over entries) - `apps/backend/src/router/` - Route composition -- `apps/backend/tests/common/` - Shared integration test infrastructure -- `apps/backend/tests/rest/` - REST API integration tests -- `apps/backend/tests/graphql/` - GraphQL API integration tests -- `apps/backend/tests/grpc/` - gRPC API integration tests -- `libs/proto/` - Protocol Buffer definitions (`cms.proto`) +- `apps/backend/migrations/{sqlite,postgres}/` - One baseline schema per backend +- `apps/backend/tests/` - Integration tests (`common/`, `rest/`, `graphql/`, `grpc/`, `mcp/`) +- `libs/proto/` - Protocol Buffer definitions (`cms.proto` plus the vendored well-known types it imports) - `apps/dashboard/` - React frontend app -- `apps/web/` - Landing Page and Documentation (NextJS + Fumadocs) +- `apps/web/` - Landing page and documentation (Next.js + Fumadocs) - `packaging/` - Native Linux, macOS, Windows, Debian, RPM, and Arch definitions and lifecycle scripts - `xtask/` - Typed release/package orchestration; platform builders live in separate modules @@ -42,39 +41,36 @@ All commands can be run via `bun` from the repository root: bun run dev # Start backend, dashboard, and web in parallel bun run dev:backend # Backend only (no dashboard embed) bun run dev:dashboard # Dashboard Vite dev server only -bun run dev:web # Web app (NextJS) dev server only +bun run dev:web # Web app (Next.js) dev server only bun run run # Full backend with embedded dashboard bun run build # Build all projects bun run build:backend # Release build of backend bun run build:dashboard # Production build of dashboard bun run build:web # Production build of web app -bun run test # Run all Rust tests +bun run test # Run all Rust tests (SQLite) +bun run test:pg # Run all Rust tests against Postgres (see docker-compose.test.yml) bun run test:dashboard # TypeScript type check bun run lint # Lint all projects bun run format # Format all projects ``` +CI also runs `cargo fmt --check`, `cargo clippy --no-default-features --all-targets -- -D warnings`, and `bunx biome ci` inside `apps/dashboard` and `apps/web`. + ### Testing -- Rust unit tests: `bun run test` (runs `#[cfg(test)]` modules in `src/`) -- Integration tests: `cargo test --test rest` from `apps/backend/` (runs HTTP-level API tests) -- Unit tests live inline in source files (19 modules) and in `apps/backend/tests/mock_user_repository.rs`, `apps/backend/tests/file_service_tests.rs` -- Integration tests in `apps/backend/tests/` are black-box tests against a real server (no internal imports) - - `tests/common/` — shared infrastructure: `TestServer` (random port, SQLite in-memory, temp storage, seeded admin), auth helpers, `TestClient` wrapper, fixture builders - - `tests/rest/` — REST API tests: `auth`, `sites`, `collections`, `entries`, `singletons`, `files`, `webhooks`, `access_tokens`, `roles`, `backups` - - `tests/graphql/` — GraphQL API tests: `auth`, `sites`, `collections`, `entries`, `files`, `webhooks` - - `tests/grpc/` — gRPC API tests: `collections`, `entries`, `singletons`, `files`, `sites`, `webhooks` (uses tonic clients with real auth interceptor) - - Each test module gets its own server instance (isolated DB + storage) - - Tests communicate only via HTTP using `reqwest` (REST/GraphQL) or tonic clients (gRPC) - - Run REST: `cargo test --test rest -- --test-threads=1` - - Run GraphQL: `cargo test --test graphql -- --test-threads=1` - - Run gRPC: `cargo test --test grpc -- --test-threads=1` + +- Unit tests live inline in `#[cfg(test)]` modules under `src/`. +- Integration tests in `apps/backend/tests/` are black-box tests against a real server (no internal imports): + - `tests/common/` — shared infrastructure: `TestServer` (random port, isolated DB, temp storage, seeded admin), gRPC context, auth helpers, fixture builders + - `tests/rest/`, `tests/graphql/`, `tests/grpc/`, `tests/mcp/` — one binary per protocol; `*contract_tests.rs` cover cross-protocol authorization and versioning rules + - Each test gets its own server instance (isolated DB + storage) + - Run one protocol: `cargo test --test rest` (likewise `graphql`, `grpc`, `mcp`) +- Postgres: `docker compose -f docker-compose.test.yml up -d && bun run test:pg`, then `down -v`. ## CLI The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. ```bash -vcms # print help vcms serve # run portable mode from ./vcms_data vcms config show # print mode, root, bootstrap, and redacted secrets vcms secrets reset --yes # replace trust root and invalidate credentials @@ -91,13 +87,13 @@ vcms doctor # validate config, storage, database, bin the disaster-recovery path when the instance won't boot. `restore` is destructive and requires `--yes`. -`mcp stdio` opens no database, secrets, or search index of its own: it forwards -JSON-RPC between stdin/stdout and the server's `/mcp` Streamable-HTTP endpoint, -reading only `VCMS_MCP_TOKEN` (bearer) and `VCMS_MCP_URL` (default -`http://127.0.0.1:3000`). So it works even when the data is owned by the OS-service -account. The installed service always uses its fixed system root. +`mcp stdio` opens no database, secrets, or search index: it forwards JSON-RPC +between stdin/stdout and the server's `/mcp` endpoint, passing the client's MCP +protocol version through. It reads only `VCMS_MCP_TOKEN` (bearer) and +`VCMS_MCP_URL` (default `http://127.0.0.1:3000`), so it works even when the data +is owned by the OS-service account. -The server auto-migrates the database on every startup; there is no separate migrate command. +The server applies migrations on every startup; there is no separate migrate command. ## Packaging and Releases @@ -106,6 +102,7 @@ The server auto-migrates the database on every startup; there is no separate mig - Keep the release workflow thin: build the dashboard, run native build/package jobs, assemble artifacts, attest, and publish. - Ordinary CI validates packaging templates, `xtask` tests, and deterministic dry-runs. It must not install or mutate host services. - Release artifacts include portable archives, Debian, RPM, MSI, and macOS PKG packages. Arch is maintained as a package recipe consuming published Linux archives; render it with `xtask arch-render`, not as a fake release archive. +- The package version comes from the release tag with its leading `v` removed. Package formats differ in what they accept, so check prerelease versions against each format before tagging one. - The stable native service identifier is `vcms`; its human-facing display name is **Velopulent CMS**. Fresh Linux and macOS package installs register/enable the service but do not auto-start it; the Windows MSI installs the service for automatic startup and starts it immediately. ## Configuration @@ -113,25 +110,27 @@ The server auto-migrates the database on every startup; there is no separate mig Bootstrap addresses and logging live in strict `config.toml`. The master key, backup key, and optional database URL live in strict `secrets.toml`. There are no server env overrides, CLI config overrides, search paths, or `.env` loading. +Owner-managed settings (registration, upload limit, MCP, origins, trusted proxy +headers, storage, backups) and encrypted provider credentials live in the database +and are edited through the dashboard. ## Data directory -Mode is selected only by native service registration. Installed mode uses -`/var/lib/vcms`, `/Library/Application Support/vcms`, or `C:\ProgramData\vcms`. -Without a registered service, portable mode uses `/vcms_data`. Detection -errors fail closed; directory existence and legacy paths never select a mode. - -Both modes share one root layout: +Resolution lives in `apps/backend/src/paths.rs`. Mode is selected only by native +service registration. Installed mode uses `/var/lib/vcms`, +`/Library/Application Support/vcms`, or `C:\ProgramData\vcms`. Without a registered +service, portable mode uses `/vcms_data`. Detection errors fail closed. ```text / - config.toml secrets.toml vcms.db + config.toml secrets.toml vcms.db (+ -wal / -shm) storage/ backups/ logs/ search/ ``` Fresh roots and strict files are auto-created. Existing malformed files are never overwritten or silently repaired. A missing `secrets.toml` beside an existing -database is fatal. +database is fatal. `master_key` derives domain-separated keys for sessions, token +indexing, signed uploads, webhook encryption, and instance-setting encryption. Sample `config.toml`: @@ -145,19 +144,27 @@ level = "cms=info,vcms=info" output = "file" ``` -`secrets.toml` contains `master_key`, `backup_encryption_key`, and optional -`database_url`. Owner settings and encrypted integration credentials live in the -database. Fixed filesystem paths and internal tuning constants are not configurable. - ## Environment Variables -The server ignores environment configuration. Only the diskless MCP stdio client -reads environment variables: +The server ignores environment configuration. Only the MCP stdio proxy reads +environment variables: | Variable | Default | Description | |----------|---------|-------------| -| `VCMS_MCP_TOKEN` | required | Site access token forwarded as bearer auth | -| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server base URL | +| `VCMS_MCP_TOKEN` | required | `vcms_site_*` or `vcms_pat_*` token with `mcp.use`, forwarded as bearer auth | +| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server base URL; the proxy posts to `{url}/mcp` | + +## Database schema + +Each backend has a single baseline migration +(`migrations/{sqlite,postgres}/20260804000000_initial_schema.sql`). Keep both +backends equivalent. Cross-site ownership is enforced with composite foreign keys +(`collections`, `entries`, `files` expose `UNIQUE (id, site_id)`). `entries.version` +is bumped by a trigger on every update and backs optimistic concurrency. + +SQLite write transactions must begin with `BEGIN IMMEDIATE` +(`pool.begin_with("BEGIN IMMEDIATE")`): a deferred transaction that reads before it +writes fails its lock upgrade with `SQLITE_BUSY` immediately instead of waiting. ## Proto Compilation @@ -180,7 +187,8 @@ is automatically granted the `instance_owner` role). **Login is by email**; the The access model has two independent tiers. The policy lives in `models/authorization.rs` (`Authorizer`, `Action`, `InstanceRole`, `SiteRole`) and is enforced by `middleware/authz.rs`; `require_*_action` helpers live in -`middleware/auth.rs`. +`middleware/auth.rs`, and `services/authorization.rs` applies the same policy for +GraphQL, gRPC, and MCP. **Instance roles** (operators, span the whole installation; stored on `users.instance_role`): @@ -195,26 +203,38 @@ Both operators have **implicit full authority over every site** without being a site member (the `allows_site_as_instance` override). **Site roles** (collaborators, per-site; stored in `site_members.role`): -- `editor` — read everything on the site plus write content and files. -- `viewer` — read-only. +- `editor` — read everything on the site including drafts, write and publish content, and write files. +- `viewer` — read-only. Dashboard sessions see drafts; viewer tokens cannot preview drafts. - Everything else on a site (schema, webhooks, API keys, member management, site delete) is operator-only. - Instance operators are never added as site members — they already have full access, so inviting one as a member is rejected (`SiteError::CannotInviteOperator`). -**API keys** (site-scoped tokens): reads are always allowed; writes (content, -schema, files, webhooks, site manage) are gated by the key's `can_write` flag. Keys -never receive member or API-key management authority. +**Access tokens** (`vcms_site_*` site keys, `vcms_pat_*` personal tokens) carry +scopes (`models/access_token.rs::TokenScope`): `site.read`, `schema.read`, +`content.read`, `content.preview.read`, `content.write`, `content.publish`, +`files.read`, `files.write`, `mcp.use`. Each action maps to exactly one scope +(`scope_for_action`). Personal tokens additionally need the user's live role on +the site. Tokens never reach dashboard-only actions (`Authorizer::token_hard_denied`). Member management routes are nested under `/api/dashboard/sites/{site_id}/members`; the update (PUT) and remove (DELETE) endpoints take the path param `{member_user_id}` (must match the `MemberPath` extractor field name). -The `roles_v2` migration (`migrations/*/20260616000000_roles_v2.sql`) widened -`users.instance_role` to allow `instance_admin` and restricted `site_members.role` -to `editor`/`viewer` (legacy `owner`/`admin` site members collapse to `editor`, -since those operators now act through their instance role). +## Public API contract + +Every protocol names the site explicitly: REST paths `/api/v1/sites/{site_id}/…`, +GraphQL `site(id:)` (a namespace whose fields each authorize themselves) and +mutation `siteId`, gRPC `site_id` on every site-scoped request, MCP `site_id` on +every site tool. Reads return published content unless `include_drafts` is set +(`content.preview.read`). Entry and singleton updates accept an expected version +(REST `If-Match` / `expected_version`). Schema, webhook, deployment, member, key, +and backup management are dashboard-only. + +Content APIs share an `ApiRateLimiter` (1200 requests/min per client) separate +from the login `RateLimiter`. Never record request headers in tracing spans: skip +`HeaderMap` arguments in `#[instrument]`. ## Backups & Restore @@ -226,35 +246,63 @@ A logical backup/restore subsystem lives in `apps/backend/src/services/backup/`: - `mod.rs` — `BackupService`: a snapshot read (REPEATABLE READ / WAL) dumps tables → tar → zstd → optional AES-256-GCM, written to a destination `StorageProvider`. Restore is full-replace within the chosen scope in one transaction (site filter, - user-ref reconciliation, optional id remap for "import as new site"). + user-ref reconciliation, optional id remap for "import as new site"). Restored + entries get fresh random versions so pre-restore ETags never match. - `meta.rs` — CRUD for the `backups`, `backup_schedules`, `restore_jobs` tables - (migration `migrations/*/20260617000000_backups.sql`; these tables are **not** - part of a backup payload). + (these tables are **not** part of a backup payload, nor are access tokens or + `signed_upload_uses`). - `scheduler.rs` — background poller spawned from `serve` that runs due cron schedules and prunes per-schedule retention; `schedule.rs` wraps cron (`croner`). Scope is `instance` (every site + users/roles; excludes sessions and `secrets.toml`) -or `site` (one self-contained site). REST handlers are in -`handlers/backup_handler.rs`, routes in `router/backup.rs`: instance routes are -**owner-only** under `/api/dashboard/instance/{backups,restore,backup-schedules}`; -site routes are **operator-only** under -`/api/dashboard/sites/{site_id}/{backups,restore,backup-schedules}`. Restore -endpoints require a typed `confirm: "RESTORE"`. The dashboard exposes a **Backups** -tab in both site and instance settings (`components/backups/backups-section.tsx`). - -Encryption is optional AES-256-GCM using a key from `BACKUP_ENCRYPTION_KEY` (else a -random key auto-persisted to `secrets.toml`), kept separate from the backup -destination. The manifest stamps the format + DB migration version; restore refuses -a backup taken on a **newer** schema and relies on additive migrations otherwise. +or `site` (one self-contained site). Instance routes are **owner-only** under +`/api/dashboard/instance/{backups,restore,backup-schedules}`; site routes are +**operator-only** under `/api/dashboard/sites/{site_id}/{backups,restore,backup-schedules}`. +Restore endpoints require a typed `confirm: "RESTORE"`. + +Encryption uses `backup_encryption_key` from `secrets.toml`, kept separate from the +backup destination. The manifest stamps the format + DB migration version; restore +refuses a backup taken on a **newer** schema. + +## Full-text search + +Entry search uses an embedded [Tantivy](https://github.com/quickwit-oss/tantivy) +index in `apps/backend/src/services/search/`, giving the same ranked, stemmed search +on SQLite and Postgres. The DB stays the source of truth; the index is derived and +fully rebuildable. + +- `schema.rs` — fixed schema over `entries.data`: `id`, `site_id`, `collection_id`, + `status` (exact-match filters), `slug`, and `body` (flattened scalar text, + English-stemmed, BM25-ranked). +- `mod.rs` — `SearchService`, opened read-write by the server, which owns Tantivy's + single writer. `rebuild_all`/`rebuild_site` reindex from the DB. +- `queue.rs` — `SearchQueue` over the `search_index_queue` table. Content writes + enqueue here for durability across crashes. +- `indexer.rs` — the single consumer spawned by the server. Drains the queue in + batches (present in DB ⇒ upsert, absent ⇒ delete), commits per batch, and sleeps + until an enqueue rings its in-process `Notify`. + +`EntryService` and `SingletonService` enqueue on write; `EntryService::list_entries` +queries the index, so every protocol gets ranked search through its `search` +parameter. When search is disabled or the index cannot open, listing falls back to +SQL `LIKE`. The index builds on startup when empty, rebuilds after a restore, and has +reindex routes: `POST /api/dashboard/instance/search/reindex` and +`POST /api/dashboard/sites/{site_id}/search/reindex`. + +Search deliberately has no typo tolerance: Tantivy fuzzy queries score by a constant +and would flatten BM25 ranking (`ranks_frequent_term_higher` guards this). To add it +without losing ranking, change only `search_entries`: either retry with a fuzzy +query when the normal query returns nothing, or always search +`(exact)^high OR (fuzzy)^low`. ## Code Conventions - **Rust**: Idiomatic `Result`/error handling, `axum` extractors, custom `AppError` enum for HTTP errors - **React**: Functional components, hooks, Tanstack Query for server state -- **MCP tool schemas**: Generated by `rmcp` + `schemars` (Draft 2020-12). Post-processed via `clean_input_schema()` in `mcp/schema.rs` to strip `$schema`/`title`, simplify nullable types, and ensure MCP Inspector/Postman compatibility. +- **MCP**: The server supports protocol revisions `2025-06-18`, `2025-11-25`, and `2026-07-28` (`supported_protocol_versions` in `mcp/server.rs`). Tool results are JSON objects. +- **MCP tool schemas**: Generated by `rmcp` + `schemars` (Draft 2020-12). `list_tools` post-processes them with `clean_input_schema()` in `mcp/schema.rs` to strip `$schema`/`title`, simplify nullable types, and keep MCP Inspector/Postman compatible. - Unit struct param types must use manual `JsonSchema` impl (not derive) returning `{"type":"object","properties":{}}` - `serde_json::Value` fields must use `#[schemars(with = "ArbitraryJson")]` to avoid boolean `true` schemas - - Protocol version echoing is handled in the `initialize()` override in `mcp/server.rs` - **Styling**: Tailwind CSS v4 with `tw-animate-css`; shadcn/ui components - **Formatting**: Biome (not ESLint/Prettier) for frontend @@ -262,11 +310,12 @@ a backup taken on a **newer** schema and relies on additive migrations otherwise - `rustfmt.toml`: 120 max width, 4 space indent - `clippy.toml`: cognitive-complexity threshold 30 -- `biome.json`: 2-space indent, double quotes, organized imports +- `apps/dashboard/biome.json`, `apps/web/biome.json`: 2-space indent, double quotes, organized imports ## Agent Workflow 1. Discover: Check this file, `README.md`, `Cargo.toml`, `nx.json`, and code patterns -2. For new features: Identify handler and model boundaries; keep API stable +2. For new features: Identify handler and model boundaries; change every protocol adapter together 3. For bugfixes: Reproduce with `bun run dev` or `bun run test` 4. If a handler changes: Update frontend data fetching and UI integration +5. If the public contract changes: Update `apps/web/content/docs/api/` diff --git a/CLAUDE.md b/CLAUDE.md index d5af7094..43c994c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,322 +1 @@ -# AI Agent Instructions for CMS (Rust + React) - -## Architecture - -- **Backend**: Rust + Axum HTTP server, `SQLx` + (SQLite | PostgreSQL), `rust-embed` (static assets) -- **Frontend**: React in `apps/dashboard/` with Tanstack Router, Tanstack Query, shadcn/ui -- **gRPC**: Separate server on port 50051 (compiled from `libs/proto/*.proto` via `tonic-build`) -- **Build**: Nx orchestrates `dashboard:build` → `backend:build`. `build.rs` compiles proto files only. -- **Runtime**: Single binary serves REST API (`/api/*`), gRPC, GraphQL (`/api/graphql`), MCP (Streamable HTTP at `/mcp`), and static SPA fallback - -## Key Directories - -- `apps/backend/src/` - Rust backend source -- `apps/backend/src/handlers/` - HTTP handlers (auth, content, schema, site, file, UI) -- `apps/backend/src/graphql/` - GraphQL schema and resolvers -- `apps/backend/src/grpc/` - gRPC service implementations (generated from `libs/proto/*.proto`) -- `apps/backend/src/mcp/` - MCP server (tools/`*.rs`, `schema.rs`, `server.rs`, `auth.rs`, `transports/`) -- `apps/backend/src/repository/` - Data access layer -- `apps/backend/src/services/backup/` - Backup & restore engine (dump/restore, scheduler, metadata) -- `apps/backend/src/services/search/` - Full-text search engine (Tantivy index over entries) -- `apps/backend/src/router/` - Route composition -- `apps/backend/tests/common/` - Shared integration test infrastructure -- `apps/backend/tests/rest/` - REST API integration tests -- `apps/backend/tests/graphql/` - GraphQL API integration tests -- `apps/backend/tests/grpc/` - gRPC API integration tests -- `libs/proto/` - Protocol Buffer definitions (`cms.proto`) -- `apps/dashboard/` - React frontend app -- `apps/web/` - Landing Page and Documentation (NextJS + Fumadocs) - -## Developer Commands - -All commands can be run via `bun` from the repository root: - -```bash -bun run dev # Start backend, dashboard, and web in parallel -bun run dev:backend # Backend only (no dashboard embed) -bun run dev:dashboard # Dashboard Vite dev server only -bun run dev:web # Web app (NextJS) dev server only -bun run run # Full backend with embedded dashboard -bun run build # Build all projects -bun run build:backend # Release build of backend -bun run build:dashboard # Production build of dashboard -bun run build:web # Production build of web app -bun run test # Run all Rust tests -bun run test:dashboard # TypeScript type check -bun run lint # Lint all projects -bun run format # Format all projects -``` - -### Testing -- Rust unit tests: `bun run test` (runs `#[cfg(test)]` modules in `src/`) -- Integration tests: `cargo test --test rest` from `apps/backend/` (runs HTTP-level API tests) -- Unit tests live inline in source files (19 modules) and in `apps/backend/tests/mock_user_repository.rs`, `apps/backend/tests/file_service_tests.rs` -- Integration tests in `apps/backend/tests/` are black-box tests against a real server (no internal imports) - - `tests/common/` — shared infrastructure: `TestServer` (random port, SQLite in-memory, temp storage, seeded admin), auth helpers, `TestClient` wrapper, fixture builders - - `tests/rest/` — REST API tests: `auth`, `sites`, `collections`, `entries`, `singletons`, `files`, `webhooks`, `access_tokens`, `roles`, `backups` - - `tests/graphql/` — GraphQL API tests: `auth`, `sites`, `collections`, `entries`, `files`, `webhooks` - - `tests/grpc/` — gRPC API tests: `collections`, `entries`, `singletons`, `files`, `sites`, `webhooks` (uses tonic clients with real auth interceptor) - - Each test module gets its own server instance (isolated DB + storage) - - Tests communicate only via HTTP using `reqwest` (REST/GraphQL) or tonic clients (gRPC) - - Run REST: `cargo test --test rest -- --test-threads=1` - - Run GraphQL: `cargo test --test graphql -- --test-threads=1` - - Run gRPC: `cargo test --test grpc -- --test-threads=1` - -## CLI - -The backend binary (`vcms`) is a clap CLI. With no subcommand it prints help. - -```bash -vcms serve # run portable mode; refuse when native service is installed -vcms config show # print mode, root, bootstrap, secret presence, DB settings -vcms secrets reset # destructive trust-root reset and recovery report -vcms admin reset-password --email U --password P -vcms backup create [--scope instance|site] [--site ID] [--out FILE] [--no-files] [--encrypt] -vcms backup list # list recorded backups -vcms restore --file PATH [--scope instance|site] [--site ID] [--import-as-new] --yes -vcms mcp stdio # diskless proxy to a running server -vcms service status -vcms doctor -``` - -`backup`/`restore` run offline (no HTTP server) against the configured database — -the disaster-recovery path when the instance won't boot. `restore` is destructive -and requires `--yes`. - -The server auto-migrates the database on every startup; there is no separate migrate command. - -## Configuration - -Bootstrap settings live only in strict `/config.toml`; trust-root secrets and -the optional database URL live only in strict `/secrets.toml`. There is no -server environment or CLI override layer. - -## Data directory - -Resolution lives in `apps/backend/src/paths.rs`. Native service registration selects -installed mode and its fixed OS root. Without a registered service, portable mode -uses `/vcms_data`. Detection failures stop startup. - -```text -/ - config.toml secrets.toml - vcms.db (+ -wal / -shm) logs/ storage/ backups/ search/ -``` - -Installed roots are `/var/lib/vcms`, `/Library/Application Support/vcms`, and -`C:\ProgramData\vcms`. `vcms mcp stdio` creates nothing. - -Fresh roots atomically create a master key and backup encryption key in -`secrets.toml`. An existing database without its secrets file is a hard error. -Domain-separated keys are derived for token indexing, webhook encryption, and -instance-setting encryption. Owner-managed settings and provider credentials live -in the database. - -Bootstrap `config.toml`: - -```toml -[server] -http_address = "127.0.0.1:3000" -grpc_address = "127.0.0.1:50051" - -[log] -level = "cms=info,vcms=info" -output = "file" -``` - -## Environment Variables - -| Variable | Default | Description | -|----------|---------|-------------| -| `VCMS_MCP_TOKEN` | - | `vcms_site_*` access token forwarded by `vcms mcp stdio` as the bearer credential (required for stdio) | -| `VCMS_MCP_URL` | `http://127.0.0.1:3000` | Running server's base URL that `vcms mcp stdio` proxies to (`{url}/mcp`) | - -No server-side environment variables are supported. - -## Proto Compilation - -- Proto files in `libs/proto/` are compiled by `apps/backend/build.rs` into `apps/backend/src/grpc/cms/` using `tonic-build` -- Generated code is **not** committed; `build.rs` runs on every `cargo build` -- `cargo:rerun-if-changed=../../libs/proto/` triggers rebuilds - -## First-Run Behavior - -On initial startup, the server seeds a default admin user (the first user created -is automatically granted the `instance_owner` role). **Login is by email**; the -`name` field is a display name (non-unique, e.g. "John Doe"): -- Email: `admin@cms.local` -- Password: `admin` -- Display name: `admin` -**Change this password immediately in production.** - -## Authorization (RBAC) - -The access model has two independent tiers. The policy lives in -`models/authorization.rs` (`Authorizer`, `Action`, `InstanceRole`, `SiteRole`) and -is enforced by `middleware/authz.rs`; `require_*_action` helpers live in -`middleware/auth.rs`. - -**Instance roles** (operators, span the whole installation; stored on -`users.instance_role`): -- `instance_owner` — strict superset of admin. Owner-only powers: granting/revoking - instance roles (`InstanceRolesGrant`); instance-wide backup/restore - (`InstanceBackup`/`InstanceRestore`). -- `instance_admin` — manage the instance and its users, create and delete sites, - and back up / restore individual sites (`SiteBackup`/`SiteRestore`). -- A user with no instance role has no instance-level powers. - -Both operators have **implicit full authority over every site** without being a -site member (the `allows_site_as_instance` override). - -**Site roles** (collaborators, per-site; stored in `site_members.role`): -- `editor` — read everything on the site plus write content and files. -- `viewer` — read-only. -- Everything else on a site (schema, webhooks, API keys, member management, site - delete) is operator-only. -- Instance operators are never added as site members — they already have full - access, so inviting one as a member is rejected (`SiteError::CannotInviteOperator`). - -**API keys** (site-scoped tokens): reads are always allowed; writes (content, -schema, files, webhooks, site manage) are gated by the key's `can_write` flag. Keys -never receive member or API-key management authority. - -Member management routes are nested under -`/api/dashboard/sites/{site_id}/members`; the update (PUT) and remove (DELETE) -endpoints take the path param `{member_user_id}` (must match the `MemberPath` -extractor field name). - -The `roles_v2` migration (`migrations/*/20260616000000_roles_v2.sql`) widened -`users.instance_role` to allow `instance_admin` and restricted `site_members.role` -to `editor`/`viewer` (legacy `owner`/`admin` site members collapse to `editor`, -since those operators now act through their instance role). - -## Backups & Restore - -A logical backup/restore subsystem lives in `apps/backend/src/services/backup/`: -- `schema.rs` — table registry + cross-backend dump/restore. Every value is - normalized to **text** (Postgres casts `::text`/`::int`; restore casts back with - `::jsonb`/`::timestamptz`/`::bigint`/`::int::boolean`), so a backup is a portable, - DB-agnostic set of NDJSON rows that restores into any of the three backends. -- `mod.rs` — `BackupService`: a snapshot read (REPEATABLE READ / WAL) dumps tables → - tar → zstd → optional AES-256-GCM, written to a destination `StorageProvider`. - Restore is full-replace within the chosen scope in one transaction (site filter, - user-ref reconciliation, optional id remap for "import as new site"). -- `meta.rs` — CRUD for the `backups`, `backup_schedules`, `restore_jobs` tables - (migration `migrations/*/20260617000000_backups.sql`; these tables are **not** - part of a backup payload). -- `scheduler.rs` — background poller spawned from `serve` that runs due cron - schedules and prunes per-schedule retention; `schedule.rs` wraps cron (`croner`). - -Scope is `instance` (every site + users/roles; excludes sessions and `secrets.toml`) -or `site` (one self-contained site). REST handlers are in -`handlers/backup_handler.rs`, routes in `router/backup.rs`: instance routes are -**owner-only** under `/api/dashboard/instance/{backups,restore,backup-schedules}`; -site routes are **operator-only** under -`/api/dashboard/sites/{site_id}/{backups,restore,backup-schedules}`. Restore -endpoints require a typed `confirm: "RESTORE"`. The dashboard exposes a **Backups** -tab in both site and instance settings (`components/backups/backups-section.tsx`). - -Encryption is optional AES-256-GCM using a key from `BACKUP_ENCRYPTION_KEY` (else a -random key auto-persisted to `secrets.toml`), kept separate from the backup -destination. The manifest stamps the format + DB migration version; restore refuses -a backup taken on a **newer** schema and relies on additive migrations otherwise. - -## Full-text search - -Entry search is backed by an embedded [Tantivy](https://github.com/quickwit-oss/tantivy) -index in `apps/backend/src/services/search/` — one engine that gives ranked, -tokenized, stemmed search identically across SQLite/Postgres (native per-DB -FTS would mean two implementations). The DB stays the source of truth; the index -is **derived** and fully rebuildable. - -**Reads vs writes are split** so the single-writer limit never blocks *searching*: - -- `schema.rs` — fixed schema over the schema-less `entries.data`: `id`, `site_id`, - `collection_id`, `status` (exact-match filters), `slug`, and `body` (the flattened - scalar text of `data`, English-stemmed, BM25-ranked). `fields_from` re-resolves the - schema when opening an existing index read-only. -- `mod.rs` — `SearchService`. **Reading** needs no lock: `open_read_only` (reader - only) lets *any* auxiliary process search the index alongside the server (this is a - general capability; `vcms mcp stdio` itself no longer opens the index — it proxies - over HTTP). **Writing** requires the directory lock: `open` - (reader + writer) is held only by the running server. `index_doc`/`delete_doc` - stage uncommitted ops; `commit` flushes; `rebuild_all`/`rebuild_site` reindex from - the DB by reusing `EntryRepository::list` (covers singletons too). Write/commit on - a read-only instance returns `SearchError::ReadOnly`. -- `queue.rs` — `SearchQueue` over the `search_index_queue` table (migration - `20260618000000`). Content writes enqueue here - (`enqueue`/`dequeue_batch`/`delete_ids`); UUIDv7 ids order the queue chronologically. - The table exists purely for **durability** (crash recovery), not cross-process - signaling — all producers run in the server process (`vcms mcp stdio` is an HTTP proxy). -- `indexer.rs` — the **single consumer**, spawned once by the server (it owns the - writer). Drains the queue in batches (present in DB ⇒ upsert doc, absent ⇒ delete - doc — `op` is advisory), commits per batch, deletes processed rows. **Purely - event-driven**: one startup drain (rows left by a crash), then sleeps until an - enqueue rings the in-process `Notify` — no polling. - -Wiring: `Services` holds `search: Option>` (reads) and -`search_queue: Option>` (writes). `Services::new` opens the index -read-write (server); `Services::new_read_only` opens it read-only for an auxiliary -process that searches without taking the writer lock. -`EntryService`/`SingletonService` **enqueue** on write; `EntryService::list_entries` -queries the index — so REST, GraphQL, gRPC, and MCP all get ranked search via the -existing `search` param with **no handler changes**. Indexing is asynchronous -(enqueue → indexer), typically sub-second. When search is disabled -(`SEARCH_ENABLED=false`) or the index can't open, it falls back to SQL `LIKE`. The -index builds on startup when empty, rebuilds after a restore, and exposes -owner/operator reindex routes: `POST /api/dashboard/instance/search/reindex` and -`POST /api/dashboard/sites/{site_id}/search/reindex`. - -Writes land in the durable queue and the running server indexes them; rows enqueued -before a crash drain on the next start. The remaining hard limit is *concurrent -writers* — only one process indexes at a time. - -### No typo tolerance (deliberate) — and how to add it - -Search has stemming + BM25 ranking + phrase/multi-field, but **no fuzzy / typo -tolerance**. These are different things: **stemming** maps word *forms* to a root -("running"→"run") so different grammatical forms match — it does **not** fix -misspellings ("runnnig" still matches nothing). **Fuzzy** matches within an edit -distance and is what handles typos. - -Fuzzy is omitted on purpose. The obvious switch — `QueryParser::set_field_fuzzy` — -turns every term into a Tantivy `FuzzyTermQuery`, which scores by a **constant** -instead of BM25. That flattens relevance ranking: a doc mentioning the term three -times no longer outranks one mentioning it once (the `ranks_frequent_term_higher` -unit test in `services/search/mod.rs` catches exactly this). Since ranking is the -whole reason we moved off SQL `LIKE`, we kept ranking over fuzzy. - -It can be added later **without losing ranking**, contained to `search_entries` in -`services/search/mod.rs` (no schema/migration/index changes — the index already holds -the stemmed terms): -- **Zero-hit fallback** (simplest): run the normal BM25 query; only retry with a fuzzy - query when it returns nothing. Correct spellings keep perfect ranking; typos still - return results. Won't rescue a typo buried in an otherwise-matching multi-word query. -- **Boosted exact-OR-fuzzy**: always search `(exact/stemmed)^high OR (fuzzy)^low` so - exact matches keep their BM25 order and sort above fuzzy near-matches, while - misspellings still surface via the low-boost arm. Always-on, ranking intact; slightly - more query cost. - -## Code Conventions - -- **Rust**: Idiomatic `Result`/error handling, `axum` extractors, custom `AppError` enum for HTTP errors -- **React**: Functional components, hooks, Tanstack Query for server state -- **MCP tool schemas**: Generated by `rmcp` + `schemars` (Draft 2020-12). Post-processed via `clean_input_schema()` in `mcp/schema.rs` to strip `$schema`/`title`, simplify nullable types, and ensure MCP Inspector/Postman compatibility. - - Unit struct param types must use manual `JsonSchema` impl (not derive) returning `{"type":"object","properties":{}}` - - `serde_json::Value` fields must use `#[schemars(with = "ArbitraryJson")]` to avoid boolean `true` schemas - - Protocol version echoing is handled in the `initialize()` override in `mcp/server.rs` -- **Styling**: Tailwind CSS v4 with `tw-animate-css`; shadcn/ui components -- **Formatting**: Biome (not ESLint/Prettier) for frontend - -## Tooling Config - -- `rustfmt.toml`: 120 max width, 4 space indent -- `clippy.toml`: cognitive-complexity threshold 30 -- `biome.json`: 2-space indent, double quotes, organized imports - -## Agent Workflow - -1. Discover: Check this file, `README.md`, `Cargo.toml`, `nx.json`, and code patterns -2. For new features: Identify handler and model boundaries; keep API stable -3. For bugfixes: Reproduce with `bun run dev` or `bun run test` -4. If a handler changes: Update frontend data fetching and UI integration +@AGENTS.md diff --git a/Cargo.lock b/Cargo.lock index 2195f592..8b10417e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -26,34 +26,34 @@ dependencies = [ [[package]] name = "aes" -version = "0.9.1" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ "cipher", "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "aes-gcm" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdf011db2e21ce0d575593d749db5554b47fed37aff429e4dc50bc91ac93a028" +checksum = "7f2b8006a0c83f52b62ba44a97b58bf76fe2f70a329e588f67f89691d93d498f" dependencies = [ "aead", "aes", "cipher", "ctr", + "ctutils", "ghash", - "subtle", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -84,9 +84,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -143,15 +143,15 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.103" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arbitrary" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +checksum = "3bc62ac97cc33321f50863d514c3bc38a453947a8f9e781137e47c7401020aed" [[package]] name = "arc-swap" @@ -170,14 +170,14 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "arrayvec" -version = "0.7.7" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02882884d3e1bc524fb12c79f107f6ad0e1cfd498c536ffb494301740995dfe" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-slice" @@ -216,7 +216,7 @@ dependencies = [ "async-io", "async-trait", "asynk-strim", - "base64", + "base64 0.22.1", "bytes", "chrono", "fast_chemail", @@ -271,7 +271,7 @@ dependencies = [ "proc-macro2", "quote", "strum", - "syn", + "syn 2.0.119", "thiserror", ] @@ -319,13 +319,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -404,9 +404,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -414,9 +414,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -432,7 +432,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" dependencies = [ "axum-core", - "base64", + "base64 0.22.1", "bytes", "form_urlencoded", "futures-util", @@ -451,7 +451,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sha1 0.10.6", + "sha1 0.10.7", "sync_wrapper", "tokio", "tokio-tungstenite", @@ -510,13 +510,19 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "bcrypt" -version = "0.19.2" +version = "0.19.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f3c067aa24dd4ed5c79cf222a38f260c8f23d3b82a062fba3f28c6fe563b753" +checksum = "a0cd0bd35a28836d528d2b58ad499bc3c5641d59379421b1be9eeb0c2f2b912a" dependencies = [ - "base64", + "base64 0.23.1", "blowfish", "getrandom 0.4.3", "subtle", @@ -525,9 +531,9 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -580,27 +586,25 @@ dependencies = [ [[package]] name = "bon" -version = "3.9.3" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a602c73c7b0148ec6d12af6fd5cc7a46e2eacc8878271a999abac56eed12f561" +checksum = "214f5df094ce551a10a30ffb9c70243d61f121a3d985a6495933e181dee6a7d2" dependencies = [ "bon-macros", - "rustversion", ] [[package]] name = "bon-macros" -version = "3.9.3" +version = "3.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6dee98b0db6a962de883bf5d20362dee4d7ca0d12fe39a7c6c73c844e1cd7c1f" +checksum = "2706da6c749998cc555d04184909608956a69ad3f8ab1a076fbc867b4a3c3ce5" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "ident_case", - "prettyplease", + "prettyplease 0.3.0", "proc-macro2", "quote", - "rustversion", - "syn", + "syn 3.0.6", ] [[package]] @@ -617,9 +621,9 @@ checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -635,18 +639,18 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.12.0" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" dependencies = [ "serde", ] [[package]] name = "cc" -version = "1.2.65" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e228eec9be7c17ccb640b59b36a5cd805ea2a564a4c5e162c2f659fea30d3b96" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "jobserver", @@ -662,35 +666,35 @@ checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -721,9 +725,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -731,9 +735,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -743,21 +747,21 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" [[package]] name = "cmake" @@ -784,7 +788,7 @@ dependencies = [ "async-trait", "axum", "axum-extra", - "base64", + "base64 0.23.1", "bcrypt", "bytes", "chrono", @@ -804,7 +808,8 @@ dependencies = [ "mime_guess", "object_store", "prost", - "rand 0.10.1", + "prost-types", + "rand 0.10.3", "regex", "reqwest", "rmcp", @@ -831,7 +836,7 @@ dependencies = [ "tonic-prost-build", "tonic-reflection", "tower", - "tower-http 0.7.0", + "tower-http 0.7.1", "tracing", "tracing-appender", "tracing-subscriber", @@ -842,7 +847,7 @@ dependencies = [ "windows-service", "windows-sys 0.61.2", "wiremock", - "zstd", + "zstd 0.14.0", ] [[package]] @@ -859,9 +864,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "combine" -version = "4.6.7" +version = "4.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" dependencies = [ "bytes", "memchr", @@ -884,9 +889,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "cookie" -version = "0.18.1" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" dependencies = [ "percent-encoding", "time", @@ -937,6 +942,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -954,9 +965,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -983,23 +994,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ "digest 0.10.7", - "spin 0.10.0", + "spin 0.10.1", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "croner" -version = "3.0.1" +version = "4.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4aa42bcd3d846ebf66e15bd528d1087f75d1c6c1c66ebff626178a106353c576" +checksum = "4b5b023fc2978536bbb2a3b71237964fc4630bec1eb31077b4977127f4cdb9c0" dependencies = [ "chrono", "derive_builder", @@ -1008,18 +1019,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "98b0cc327b5bc766e7fda9c9260cc0fa81b43a8e240440422dff70788e3f9ef1" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1027,27 +1038,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.20" +version = "0.9.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crunchy" @@ -1087,9 +1098,9 @@ dependencies = [ [[package]] name = "ctutils" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ "cmov", ] @@ -1114,6 +1125,16 @@ dependencies = [ "darling_macro 0.23.0", ] +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", +] + [[package]] name = "darling_core" version = "0.20.11" @@ -1125,7 +1146,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", ] [[package]] @@ -1138,7 +1159,20 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.6", ] [[package]] @@ -1149,7 +1183,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1160,7 +1194,18 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.6", ] [[package]] @@ -1179,9 +1224,9 @@ dependencies = [ [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "datasketches" @@ -1234,7 +1279,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1244,7 +1289,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn", + "syn 2.0.119", ] [[package]] @@ -1271,13 +1316,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -1315,9 +1360,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.16.0" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" dependencies = [ "serde", ] @@ -1333,11 +1378,16 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1357,7 +1407,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -1399,11 +1449,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1425,9 +1474,9 @@ checksum = "9afc2bd4d5a73106dd53d10d73d3401c2f32730ba2c0b93ddb888a8983680471" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1450,9 +1499,9 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "fixedbitset" @@ -1462,12 +1511,13 @@ checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", + "zlib-rs", ] [[package]] @@ -1478,7 +1528,7 @@ checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" dependencies = [ "futures-core", "futures-sink", - "spin 0.9.8", + "spin 0.9.9", ] [[package]] @@ -1526,9 +1576,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1541,9 +1591,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1551,15 +1601,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1579,9 +1629,9 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -1595,32 +1645,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1663,11 +1713,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1677,9 +1725,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", + "wasm-bindgen", ] [[package]] @@ -1703,9 +1753,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.15" +version = "0.4.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +checksum = "7d29020232d6aa3fb1daca64c1127cf662cf97f254ae16c18c05b8ab635fc118" dependencies = [ "atomic-waker", "bytes", @@ -1722,9 +1772,9 @@ dependencies = [ [[package]] name = "handlebars" -version = "6.4.2" +version = "6.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f26569a2763497b7bd3fbd19374b774ea6038c5293678771259cd534d49740ff" +checksum = "75c54236f9045c8004a77942bebc52145b4844639db934a5c70fe08617fbe61a" dependencies = [ "derive_builder", "log", @@ -1779,9 +1829,9 @@ dependencies = [ [[package]] name = "hdrhistogram" -version = "7.5.4" +version = "7.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765c9198f173dd59ce26ff9f95ef0aafd0a0fe01fb9d72841bc5066a4c06511d" +checksum = "f49d1053f4708f0af3cf9fc5bffc7e68a914a3c45becb231c80068c9c3f78bea" dependencies = [ "byteorder", "num-traits", @@ -1795,9 +1845,9 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hermit-abi" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" [[package]] name = "hex" @@ -1831,9 +1881,9 @@ checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1841,9 +1891,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1851,9 +1901,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1876,24 +1926,24 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", @@ -1913,9 +1963,9 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", @@ -1941,16 +1991,17 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", @@ -1990,9 +2041,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2004,9 +2055,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -2017,9 +2068,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -2031,16 +2082,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -2051,15 +2103,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -2129,15 +2181,15 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" +checksum = "6e44b0a4eaa4c82f441d50a963f2d5f05a787240aeee097597033e72accfd22f" [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2147,9 +2199,9 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] @@ -2171,23 +2223,23 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "inventory" -version = "0.3.24" +version = "0.3.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c" dependencies = [ "rustversion", ] [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" [[package]] name = "is_terminal_polyfill" @@ -2246,7 +2298,7 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", ] [[package]] @@ -2265,24 +2317,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", @@ -2313,9 +2365,9 @@ dependencies = [ [[package]] name = "lazy_static" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" [[package]] name = "levenshtein_automata" @@ -2325,9 +2377,9 @@ checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libfuzzer-sys" @@ -2358,9 +2410,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -2379,9 +2431,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.33" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "loop9" @@ -2403,9 +2455,9 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "lz4_flex" @@ -2458,9 +2510,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.2" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" @@ -2503,11 +2555,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2537,7 +2599,7 @@ dependencies = [ "httparse", "memchr", "mime", - "spin 0.9.8", + "spin 0.9.9", "version_check", ] @@ -2547,6 +2609,12 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "murmurhash32" version = "0.3.1" @@ -2616,9 +2684,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2638,23 +2706,23 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-modular" -version = "0.6.4" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc41a1374056e9672221567958a66c16be12d0e2c1b408761e14d901c237d5e0" +checksum = "26ac76200f74e658124f95fa63e1a82b2fd2181c5b2fdde80b3d89d2d3f905e7" [[package]] name = "num-order" @@ -2697,13 +2765,13 @@ dependencies = [ [[package]] name = "object_store" -version = "0.14.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "765784b4390c6bcf80316e5a22f4e3661b639c9d8c83246856643c27d8ce9dbe" +checksum = "f1796bc93603f78c5760a69f2d58badc9618d22adade0a95385bb2adbae4eb94" dependencies = [ "async-trait", "aws-lc-rs", - "base64", + "base64 0.23.1", "bytes", "chrono", "crc-fast", @@ -2721,7 +2789,7 @@ dependencies = [ "parking_lot", "percent-encoding", "quick-xml", - "rand 0.10.1", + "rand 0.10.3", "reqwest", "rustls-pki-types", "serde", @@ -2763,9 +2831,9 @@ checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] name = "ordered-float" -version = "5.3.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ "num-traits", ] @@ -2834,9 +2902,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +checksum = "45d3aca230fad2e6f6317ca0a72724338c4960cb97168a85cdee66df4a9a21a8" dependencies = [ "memchr", "ucd-trie", @@ -2844,9 +2912,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +checksum = "284b60557f2c4a2e72ad3f2d34d42685a2fa4a6a61d0d2a10c0ae2a5e916c2cf" dependencies = [ "pest", "pest_generator", @@ -2854,25 +2922,24 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +checksum = "1d9d1f08a115309ee99268cf85e5228e0e56aa9caf8841ec12866b6be07c3109" dependencies = [ "pest", "pest_meta", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "pest_meta" -version = "2.8.6" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +checksum = "ed93ba1a9ffcca32130a5188701c81c0c49cf00d4b7c5007d5148951d743adcb" dependencies = [ "pest", - "sha2 0.10.9", ] [[package]] @@ -2903,7 +2970,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2914,9 +2981,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -2928,7 +2995,7 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", ] [[package]] @@ -2947,29 +3014,29 @@ dependencies = [ [[package]] name = "polyval" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dfc63250416fea14f5749b90725916a6c903f599d51cb635aa7a52bfd03eede" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" dependencies = [ "cpubits", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "universal-hash", ] [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -2987,7 +3054,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "prettyplease" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bfe0f4c752e450fc2faf62654f1c134747922825d5b04ca717b8874f41a40c0" +dependencies = [ + "proc-macro2", + "syn 3.0.6", ] [[package]] @@ -3001,9 +3078,9 @@ dependencies = [ [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -3024,7 +3101,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3048,13 +3125,13 @@ dependencies = [ "log", "multimap", "petgraph", - "prettyplease", + "prettyplease 0.2.37", "prost", "prost-types", "pulldown-cmark", "pulldown-cmark-to-cmark", "regex", - "syn", + "syn 2.0.119", "tempfile", ] @@ -3068,7 +3145,7 @@ dependencies = [ "itertools 0.14.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3109,18 +3186,18 @@ dependencies = [ [[package]] name = "pulldown-cmark-to-cmark" -version = "22.0.0" +version = "22.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +checksum = "84bbb29c624230c4bd1047bbdb2aa47e41c860e9665ce62ba9504eebe91bf867" dependencies = [ "pulldown-cmark", ] [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "quick-error" @@ -3130,9 +3207,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.40.1" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", @@ -3140,9 +3217,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -3160,15 +3237,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.15" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fcb935c5bec503c2f0e306bdd3e58bb9029dcb14fa8d9ac76e3a5256ac0763e" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.3", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -3182,23 +3260,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.46" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -3217,9 +3295,9 @@ checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -3227,9 +3305,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3261,6 +3339,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -3288,7 +3375,7 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha", "simd_helpers", "thiserror", @@ -3341,29 +3428,29 @@ dependencies = [ [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.4" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3373,9 +3460,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3390,11 +3477,11 @@ checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ - "base64", + "base64 0.23.1", "bytes", "cookie", "cookie_store", @@ -3456,21 +3543,22 @@ dependencies = [ [[package]] name = "rmcp" -version = "2.0.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d52d21e5b342699bc4de690e6104fc4e43255e4e8420ff0f2cbb963aac09da6f" +checksum = "ab5023804cbe0304cbe06197ac16e645a0a0d0ffa2953bf735c15c2fa0213350" dependencies = [ "async-trait", - "base64", + "base64 0.23.1", "bytes", "chrono", "futures", "http", "http-body", "http-body-util", + "indexmap", "pastey 0.2.3", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.3", "rmcp-macros", "schemars", "serde", @@ -3487,22 +3575,22 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "2.0.0" +version = "3.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c68cec74c5b3ac73ff46375ae49e161637bda80bba70f0d5db641583bb308ee" +checksum = "a1da498700030856ba63114bceefa30ba21f5e278cb975e5b9de3529fe842b40" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn", + "syn 3.0.6", ] [[package]] name = "rust-embed" -version = "8.11.0" +version = "8.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +checksum = "19afa5b4b6a611de00bd1bdae6ae6f39084c9399f0679c3f52d8469cf335cc23" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -3511,24 +3599,25 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.11.0" +version = "8.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +checksum = "e0d8afda6374eac59e066abee06d265247ebbaf3006cf878e2879e8356e34053" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", - "syn", + "syn 2.0.119", "walkdir", ] [[package]] name = "rust-embed-utils" -version = "8.11.0" +version = "8.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" dependencies = [ - "sha2 0.10.9", + "sha2 0.11.0", "walkdir", ] @@ -3544,9 +3633,9 @@ dependencies = [ [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3559,9 +3648,9 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ "bitflags", "errno", @@ -3572,9 +3661,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -3598,9 +3687,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.15.0" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "web-time", "zeroize", @@ -3608,9 +3697,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", @@ -3629,15 +3718,15 @@ dependencies = [ [[package]] name = "rustls-platform-verifier-android" -version = "0.1.1" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3647,9 +3736,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3677,9 +3766,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "chrono", "dyn-clone", @@ -3691,14 +3780,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn", + "syn 3.0.6", ] [[package]] @@ -3738,9 +3827,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3748,42 +3837,41 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ - "indexmap", "itoa", "memchr", "serde", @@ -3804,9 +3892,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3825,9 +3913,9 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3841,7 +3929,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3869,7 +3957,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "digest 0.11.3", ] @@ -3900,15 +3988,15 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -3931,9 +4019,9 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "sketches-ddsketch" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05e40b6cf54d988dc1a2223531b969c9a9e30906ad90ef64890c27b4bfbb46ea" +checksum = "513c3f5f732bfd6fbb187619c2dfe9d2f25f1a2976f01d575f0fd329d565df56" dependencies = [ "serde", ] @@ -3946,18 +4034,18 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.2" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" dependencies = [ "serde", ] [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3965,18 +4053,18 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" dependencies = [ "lock_api", ] [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "sqlx" @@ -3997,7 +4085,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "chrono", @@ -4037,7 +4125,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.119", ] [[package]] @@ -4060,7 +4148,7 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", + "syn 2.0.119", "thiserror", "tokio", "url", @@ -4101,7 +4189,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "chrono", @@ -4118,7 +4206,7 @@ dependencies = [ "log", "md-5", "memchr", - "rand 0.10.1", + "rand 0.10.3", "serde", "serde_json", "sha2 0.11.0", @@ -4159,9 +4247,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c25ac7aff0abd1dbc474536e40416e1102c7dd9bfba0b9861c6d357f835dcfb4" dependencies = [ "bytes", "futures-util", @@ -4217,7 +4305,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4234,9 +4322,20 @@ checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" [[package]] name = "syn" -version = "2.0.118" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -4254,13 +4353,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -4286,13 +4385,13 @@ dependencies = [ [[package]] name = "tantivy" -version = "0.26.1" +version = "0.26.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edde6a10743fff00a4e1a8c9ef020bf5f3cbad301b7d2d39f2b07f123c4eac07" +checksum = "861facfabd71044968f364837f9a083b56464ba5a59079f88706ee5c451ca069" dependencies = [ "aho-corasick", "arc-swap", - "base64", + "base64 0.22.1", "bitpacking", "bon", "byteorder", @@ -4410,7 +4509,7 @@ dependencies = [ "tantivy-bitpacker", "tantivy-common", "tantivy-fst", - "zstd", + "zstd 0.13.3", ] [[package]] @@ -4458,38 +4557,38 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -4507,9 +4606,9 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.31" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -4517,9 +4616,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -4527,24 +4626,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -4559,20 +4649,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] name = "tokio-rustls" -version = "0.26.4" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4580,9 +4670,9 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", @@ -4592,9 +4682,9 @@ dependencies = [ [[package]] name = "tokio-test" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f6d24790a10a7af737693a3e8f1d03faef7e6ca0cc99aae5066f533766de545" +checksum = "12bc5db7778a8e08ee255090861e0e7aac55e784300077e7ed293c7be36168b2" dependencies = [ "futures-core", "tokio", @@ -4615,24 +4705,25 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-io", "futures-sink", "futures-util", + "libc", "pin-project-lite", "tokio", ] [[package]] name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.7+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "7874cd34dd99040fc24e30923aa64a60a81ff3ddfe2b8129ccc84ba24949deed" dependencies = [ "indexmap", "serde_core", @@ -4645,18 +4736,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.16+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" dependencies = [ "indexmap", "toml_datetime", @@ -4666,18 +4757,18 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" dependencies = [ "winnow", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tonic" @@ -4687,7 +4778,7 @@ checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ "async-trait", "axum", - "base64", + "base64 0.22.1", "bytes", "h2", "http", @@ -4714,10 +4805,10 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" dependencies = [ - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4750,12 +4841,12 @@ version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" dependencies = [ - "prettyplease", + "prettyplease 0.2.37", "proc-macro2", "prost-build", "prost-types", "quote", - "syn", + "syn 2.0.119", "tempfile", "tonic-build", ] @@ -4814,9 +4905,9 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b11f75e912b0c2be01b63d8cf8057b8c3f97cf34abb3d431a3a4c8675498e233" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ "bitflags", "bytes", @@ -4875,7 +4966,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4947,8 +5038,8 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.4", - "sha1 0.10.6", + "rand 0.9.5", + "sha1 0.10.7", "thiserror", ] @@ -4966,9 +5057,9 @@ checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typetag" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5a897b12c6c1151ad0b138b8db50252dc301f93bc3b027db05eec82aeed298c" +checksum = "c90e86058a30d42a1a928dfb4b49bb33c98c3a2b4909492e6b0881cd94798ec2" dependencies = [ "erased-serde", "inventory", @@ -4979,13 +5070,13 @@ dependencies = [ [[package]] name = "typetag-impl" -version = "0.2.22" +version = "0.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf808357c6ed7e13ba0f3277ec8d8f21b2d501274895104263985330c726c1c5" +checksum = "f153acc4e99a5f2a5aefa09fb078be54e26271b2813f6041200b224c098d8328" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -4996,9 +5087,9 @@ checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" [[package]] name = "unicase" -version = "2.9.0" +version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" [[package]] name = "unicode-bidi" @@ -5008,9 +5099,9 @@ checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-normalization" @@ -5075,9 +5166,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.5.0" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" +checksum = "8765fe27aeff71012a3f90fa474cf1df863a7d0dd81ed25de4e63fff2544994f" dependencies = [ "indexmap", "serde", @@ -5087,21 +5178,21 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.5.0" +version = "6.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" +checksum = "d935f1c83fdf8b88f09bbe050d0cea78ea4afee6d7b0317403228c1200b2c8ab" dependencies = [ "proc-macro2", "quote", "regex", - "syn", + "syn 3.0.6", ] [[package]] name = "utoipa-scalar" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59559e1509172f6b26c1cdbc7247c4ddd1ac6560fe94b584f81ee489b141f719" +checksum = "8eb5bc76936ceb2bb790a62bec7fb99e22b8ecffca739e3d5a5afec0081ea7f3" dependencies = [ "axum", "serde", @@ -5111,9 +5202,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.4" +version = "1.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5163,9 +5254,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -5187,9 +5278,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5200,19 +5291,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5220,22 +5312,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5255,9 +5347,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5275,9 +5367,9 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.8" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" dependencies = [ "rustls-pki-types", ] @@ -5290,9 +5382,9 @@ checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" [[package]] name = "whoami" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" [[package]] name = "widestring" @@ -5352,7 +5444,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5363,7 +5455,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5418,7 +5510,7 @@ version = "0.52.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" dependencies = [ - "windows-targets 0.52.6", + "windows-targets", ] [[package]] @@ -5427,16 +5519,7 @@ version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" dependencies = [ - "windows-targets 0.52.6", -] - -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", + "windows-targets", ] [[package]] @@ -5454,31 +5537,14 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" dependencies = [ - "windows_aarch64_gnullvm 0.52.6", - "windows_aarch64_msvc 0.52.6", - "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", - "windows_i686_msvc 0.52.6", - "windows_x86_64_gnu 0.52.6", - "windows_x86_64_gnullvm 0.52.6", - "windows_x86_64_msvc 0.52.6", -] - -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", ] [[package]] @@ -5487,101 +5553,53 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - [[package]] name = "windows_i686_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - [[package]] name = "windows_i686_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] @@ -5593,7 +5611,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" dependencies = [ "assert-json-diff", - "base64", + "base64 0.22.1", "deadpool", "futures", "http", @@ -5617,9 +5635,9 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "xattr" @@ -5658,34 +5676,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.52" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.52" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5699,27 +5717,27 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5728,9 +5746,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "yoke", "zerofrom", @@ -5739,20 +5757,26 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zstd" @@ -5760,23 +5784,41 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" dependencies = [ - "zstd-safe", + "zstd-safe 7.3.0", +] + +[[package]] +name = "zstd" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf06bd8162af0734b344780deb55b42a2429ae430870d13fcc12f238e880fe6e" +dependencies = [ + "zstd-safe 8.0.0", +] + +[[package]] +name = "zstd-safe" +version = "7.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" +dependencies = [ + "zstd-sys", ] [[package]] name = "zstd-safe" -version = "7.2.4" +version = "8.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +checksum = "ae42c0555055784c70058d19ba8e275528e8a99a706684868ace5da4e716a4ab" dependencies = [ "zstd-sys", ] [[package]] name = "zstd-sys" -version = "2.0.16+zstd.1.5.7" +version = "2.1.0+zstd.1.5.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" dependencies = [ "cc", "pkg-config", @@ -5784,9 +5826,9 @@ dependencies = [ [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-jpeg" diff --git a/apps/backend/Cargo.toml b/apps/backend/Cargo.toml index d5e9467e..7e4476b8 100644 --- a/apps/backend/Cargo.toml +++ b/apps/backend/Cargo.toml @@ -34,13 +34,13 @@ async-trait = "0.1" uuid = { version = "1", features = ["v4", "v7", "serde"] } chrono = { version = "0.4", features = ["serde"] } bcrypt = "0.19" -utoipa = { version = "5", features = ["axum_extras"] } -utoipa-scalar = { version = "0.3", features = ["axum"] } +utoipa = { version = "6", features = ["axum_extras"] } +utoipa-scalar = { version = "0.4", features = ["axum"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif", "avif"] } object_store = { version = "0.14", features = ["aws"] } bytes = "1" futures-util = "0.3" -infer = "0.19" +infer = "0.22" http = "1" regex = "1" json-patch = "4" @@ -53,7 +53,7 @@ sha2 = "0.11" hmac = "0.13" hex = "0.4" cookie = "0.18" -base64 = "0.22" +base64 = "0.23" aes-gcm = "0.11" rand = "0.10" dashmap = "6" @@ -61,20 +61,21 @@ url = "2" email_address = "0.2" reqwest = { version = "0.13", features = ["json", "cookies", "multipart"] } prost = "0.14" +prost-types = "0.14" tonic = "0.14" -tonic-prost = "0.14.5" +tonic-prost = "0.14.6" tonic-reflection = "0.14" tonic-health = "0.14" -http-body-util = "0.1.3" -rmcp = { version = "2.0", features = ["server", "transport-io", "transport-streamable-http-server", "macros", "schemars"] } +http-body-util = "0.1.4" +rmcp = { version = "3.5.1", features = ["server", "transport-io", "transport-streamable-http-server", "macros", "schemars"] } tokio-util = { version = "0.7", features = ["rt"] } tokio-stream = { version = "0.1", features = ["net"] } schemars = "1" -clap = { version = "4.6.1", features = ["derive"] } -toml = "1.1.2" -zstd = "0.13" +clap = { version = "4.6.6", features = ["derive"] } +toml = "1.1.4" +zstd = "0.14" tar = "0.4" -croner = "3" +croner = "4" tantivy = "0.26" # Windows: native Service Control Manager host for `vcms service run`. diff --git a/apps/backend/migrations/postgres/20260804000000_initial_schema.sql b/apps/backend/migrations/postgres/20260804000000_initial_schema.sql index 3134ed5e..b2e61cb5 100644 --- a/apps/backend/migrations/postgres/20260804000000_initial_schema.sql +++ b/apps/backend/migrations/postgres/20260804000000_initial_schema.sql @@ -58,13 +58,14 @@ CREATE TABLE IF NOT EXISTS collections ( definition JSONB NOT NULL, is_singleton BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), - updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (id, site_id) ); CREATE TABLE IF NOT EXISTS entries ( id TEXT PRIMARY KEY NOT NULL, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + collection_id TEXT NOT NULL, data JSONB NOT NULL, slug TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'published')), @@ -72,6 +73,10 @@ CREATE TABLE IF NOT EXISTS entries ( created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), published_at TIMESTAMP WITH TIME ZONE, + -- Opaque optimistic-concurrency version; bumped by entries_version on every update. + version BIGINT NOT NULL DEFAULT 1, + UNIQUE (id, site_id), + FOREIGN KEY (collection_id, site_id) REFERENCES collections(id, site_id) ON DELETE CASCADE, CONSTRAINT entries_singleton_consistency CHECK (singleton_collection_id IS NULL OR singleton_collection_id = collection_id) ); @@ -118,17 +123,21 @@ CREATE TABLE IF NOT EXISTS files ( height INTEGER, deleted_at TIMESTAMP WITH TIME ZONE, created_by TEXT REFERENCES users(id), - created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + UNIQUE (id, site_id) ); CREATE INDEX IF NOT EXISTS idx_files_site ON files(site_id); CREATE INDEX IF NOT EXISTS idx_files_created_by ON files(created_by); CREATE TABLE IF NOT EXISTS entry_file_references ( - entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, + entry_id TEXT NOT NULL, + file_id TEXT NOT NULL, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - PRIMARY KEY (entry_id, file_id) + field_name TEXT NOT NULL DEFAULT '', + PRIMARY KEY (entry_id, file_id, field_name), + FOREIGN KEY (entry_id, site_id) REFERENCES entries(id, site_id) ON DELETE CASCADE, + FOREIGN KEY (file_id, site_id) REFERENCES files(id, site_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_efr_file ON entry_file_references(file_id); CREATE INDEX IF NOT EXISTS idx_efr_entry ON entry_file_references(entry_id); @@ -359,3 +368,35 @@ CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running'); + +CREATE OR REPLACE FUNCTION vcms_entry_version() RETURNS trigger AS $$ +BEGIN + NEW.version := OLD.version + 1; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER entries_version BEFORE UPDATE ON entries +FOR EACH ROW EXECUTE FUNCTION vcms_entry_version(); + +CREATE OR REPLACE FUNCTION vcms_entry_singleton() RETURNS trigger AS $$ +BEGIN + IF NEW.singleton_collection_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM collections WHERE id = NEW.singleton_collection_id AND is_singleton + ) THEN + RAISE EXCEPTION 'invalid singleton collection'; + END IF; + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER entries_singleton BEFORE INSERT ON entries +FOR EACH ROW EXECUTE FUNCTION vcms_entry_singleton(); + +-- Single-use record for signed upload URLs. Security state, not content: +-- deliberately excluded from logical backups. +CREATE TABLE IF NOT EXISTS signed_upload_uses ( + file_id TEXT PRIMARY KEY, + expires_at BIGINT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_signed_upload_uses_expiry ON signed_upload_uses(expires_at); diff --git a/apps/backend/migrations/sqlite/20260804000000_initial_schema.sql b/apps/backend/migrations/sqlite/20260804000000_initial_schema.sql index f046910f..f7af3074 100644 --- a/apps/backend/migrations/sqlite/20260804000000_initial_schema.sql +++ b/apps/backend/migrations/sqlite/20260804000000_initial_schema.sql @@ -57,13 +57,14 @@ CREATE TABLE IF NOT EXISTS collections ( definition JSON NOT NULL, is_singleton INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')), - updated_at TEXT NOT NULL DEFAULT (datetime('now')) + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (id, site_id) ); CREATE TABLE IF NOT EXISTS entries ( id TEXT PRIMARY KEY NOT NULL, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + collection_id TEXT NOT NULL, data JSON NOT NULL, slug TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'published')), @@ -71,9 +72,27 @@ CREATE TABLE IF NOT EXISTS entries ( created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now')), published_at TEXT, + -- Opaque optimistic-concurrency version; bumped by entries_version on every update. + version INTEGER NOT NULL DEFAULT 1, + UNIQUE (id, site_id), + FOREIGN KEY (collection_id, site_id) REFERENCES collections(id, site_id) ON DELETE CASCADE, CHECK (singleton_collection_id IS NULL OR singleton_collection_id = collection_id) ); +CREATE TRIGGER IF NOT EXISTS entries_version AFTER UPDATE ON entries +WHEN NEW.version = OLD.version +BEGIN + UPDATE entries SET version = OLD.version + 1 WHERE id = NEW.id; +END; + +CREATE TRIGGER IF NOT EXISTS entries_singleton_insert BEFORE INSERT ON entries +WHEN NEW.singleton_collection_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM collections WHERE id = NEW.singleton_collection_id AND is_singleton = 1 +) +BEGIN + SELECT RAISE(ABORT, 'invalid singleton collection'); +END; + CREATE INDEX IF NOT EXISTS idx_site_members_user ON site_members(user_id); CREATE INDEX IF NOT EXISTS idx_site_members_site ON site_members(site_id); CREATE INDEX IF NOT EXISTS idx_collections_site ON collections(site_id); @@ -117,17 +136,21 @@ CREATE TABLE IF NOT EXISTS files ( height INTEGER, deleted_at TEXT, created_by TEXT REFERENCES users(id), - created_at TEXT NOT NULL DEFAULT (datetime('now')) + created_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (id, site_id) ); CREATE INDEX IF NOT EXISTS idx_files_site ON files(site_id); CREATE INDEX IF NOT EXISTS idx_files_created_by ON files(created_by); CREATE TABLE IF NOT EXISTS entry_file_references ( - entry_id TEXT NOT NULL REFERENCES entries(id) ON DELETE CASCADE, - file_id TEXT NOT NULL REFERENCES files(id) ON DELETE CASCADE, + entry_id TEXT NOT NULL, + file_id TEXT NOT NULL, site_id TEXT NOT NULL REFERENCES sites(id) ON DELETE CASCADE, - PRIMARY KEY (entry_id, file_id) + field_name TEXT NOT NULL DEFAULT '', + PRIMARY KEY (entry_id, file_id, field_name), + FOREIGN KEY (entry_id, site_id) REFERENCES entries(id, site_id) ON DELETE CASCADE, + FOREIGN KEY (file_id, site_id) REFERENCES files(id, site_id) ON DELETE CASCADE ); CREATE INDEX IF NOT EXISTS idx_efr_file ON entry_file_references(file_id); CREATE INDEX IF NOT EXISTS idx_efr_entry ON entry_file_references(entry_id); @@ -366,3 +389,11 @@ CREATE INDEX IF NOT EXISTS idx_deployment_jobs_trigger CREATE UNIQUE INDEX IF NOT EXISTS idx_deployment_jobs_active_trigger ON deployment_jobs(trigger_id) WHERE status IN ('queued', 'running'); + +-- Single-use record for signed upload URLs. Security state, not content: +-- deliberately excluded from logical backups. +CREATE TABLE IF NOT EXISTS signed_upload_uses ( + file_id TEXT PRIMARY KEY, + expires_at INTEGER NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_signed_upload_uses_expiry ON signed_upload_uses(expires_at); diff --git a/apps/backend/src/config.rs b/apps/backend/src/config.rs index 3789e57d..79c995ad 100644 --- a/apps/backend/src/config.rs +++ b/apps/backend/src/config.rs @@ -98,8 +98,11 @@ pub struct Config { pub db_min_connections: u32, pub db_acquire_timeout_secs: u64, pub db_idle_timeout_secs: u64, + /// Login and registration attempts per client per window. pub rate_limit_max_requests: u32, pub rate_limit_window_secs: u64, + /// Content API requests (REST, GraphQL, gRPC, MCP) per client per minute. + pub api_rate_limit_max_requests: u32, pub bcrypt_cost: u32, pub trust_proxy_headers: bool, pub webhook_allow_private_targets: bool, @@ -162,6 +165,7 @@ impl Config { db_idle_timeout_secs: 600, rate_limit_max_requests: 100, rate_limit_window_secs: 60, + api_rate_limit_max_requests: 1200, bcrypt_cost: bcrypt::DEFAULT_COST, trust_proxy_headers: false, webhook_allow_private_targets: false, @@ -258,6 +262,7 @@ impl Default for Config { db_idle_timeout_secs: 600, rate_limit_max_requests: 100, rate_limit_window_secs: 60, + api_rate_limit_max_requests: 1200, bcrypt_cost: bcrypt::DEFAULT_COST, trust_proxy_headers: false, webhook_allow_private_targets: false, diff --git a/apps/backend/src/error.rs b/apps/backend/src/error.rs index 82a10f7c..7411c359 100644 --- a/apps/backend/src/error.rs +++ b/apps/backend/src/error.rs @@ -14,32 +14,57 @@ pub enum AppError { Forbidden(String), NotFound(String), Conflict(String), + PreconditionFailed(String), TooManyRequests(String), BadGateway(String), Internal(String), } +/// RFC 9457-compatible public problem envelope. The legacy `error` member is +/// retained as human-readable text for existing dashboard clients. The `code` +/// member provides a stable machine-readable value. +pub fn problem_response(status: StatusCode, code: &str, detail: impl Into) -> Response { + let detail = detail.into(); + ( + status, + [(axum::http::header::CONTENT_TYPE, "application/problem+json")], + Json(json!({ + "type": "about:blank", + "title": status.canonical_reason().unwrap_or("Request error"), + "status": status.as_u16(), + "code": code, + "error": detail, + "detail": detail, + })), + ) + .into_response() +} + impl IntoResponse for AppError { fn into_response(self) -> Response { - let (status, message) = match self { - AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), - AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), - AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg), - AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), - AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), - AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), - AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, msg), - AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg), + let (status, code, message) = match self { + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, "bad_request", msg), + AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, "unauthorized", msg), + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, "forbidden", msg), + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, "not_found", msg), + AppError::PreconditionFailed(msg) => (StatusCode::PRECONDITION_FAILED, "precondition_failed", msg), + AppError::Conflict(msg) => (StatusCode::CONFLICT, "conflict", msg), + AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, "rate_limited", msg), + AppError::BadGateway(msg) => (StatusCode::BAD_GATEWAY, "bad_gateway", msg), + AppError::Internal(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Internal server error".to_string(), + ), }; - - let body = Json(json!({"error": message})); - (status, body).into_response() + problem_response(status, code, message) } } impl From for AppError { fn from(err: RepositoryError) -> Self { match err { + RepositoryError::PreconditionFailed => AppError::PreconditionFailed("Version precondition failed".into()), RepositoryError::NotFound => AppError::NotFound("Resource not found".into()), RepositoryError::UniqueViolation(msg) => AppError::Conflict(msg), RepositoryError::Database(msg) => AppError::Internal(msg), diff --git a/apps/backend/src/graphql/context.rs b/apps/backend/src/graphql/context.rs index 9d60a36a..8a90598d 100644 --- a/apps/backend/src/graphql/context.rs +++ b/apps/backend/src/graphql/context.rs @@ -1,3 +1,4 @@ +use crate::config::Config; use crate::middleware::auth::{Actor, verify_access_token}; use crate::models::authorization::Action; use crate::repository::Repository; @@ -8,7 +9,7 @@ pub struct GqlContext { pub repository: Repository, pub services: Services, pub actor: Option, - pub site_id: Option, + pub config: Config, } impl GqlContext { @@ -16,75 +17,53 @@ impl GqlContext { repository: Repository, services: Services, auth_header: Option<&str>, - requested_site: Option<&str>, hmac_secret: &str, + config: Config, ) -> Self { let mut actor = None; - let mut site_id = None; - if let Some(header) = auth_header - && let Some(token) = header.strip_prefix("Bearer ") + && let Some(token) = crate::middleware::auth::parse_bearer_header(header) && (token.starts_with("vcms_site_") || token.starts_with("vcms_pat_")) && let Ok(auth_actor) = verify_access_token(token, &repository, hmac_secret).await { - if let Actor::ApiKey(k) = &auth_actor { - site_id = Some(k.site_id.clone()); - } else if let Actor::PersonalToken(_) = &auth_actor - && let Some(requested_site) = requested_site - && AuthorizationService::new(repository.user.clone()) - .require_site_action(&auth_actor, requested_site, Action::SiteRead) - .await - .is_ok() - { - site_id = Some(requested_site.to_owned()); - } actor = Some(auth_actor); } - Self { repository, services, actor, - site_id, + config, } } - pub fn require_site(&self) -> async_graphql::Result<&str> { - self.site_id - .as_deref() - .ok_or_else(|| async_graphql::Error::new("Site token authentication required")) - } - - async fn require_action(&self, action: Action, message: &'static str) -> async_graphql::Result<()> { - let actor = self - .actor + pub fn require_actor(&self) -> async_graphql::Result<&Actor> { + self.actor .as_ref() - .ok_or_else(|| async_graphql::Error::new("Site token authentication required"))?; - let site_id = self.require_site()?; - AuthorizationService::new(self.repository.user.clone()) - .require_site_action(actor, site_id, action) - .await - .map_err(|_| async_graphql::Error::new(message)) + .ok_or_else(|| async_graphql::Error::new("Authentication required")) } - pub async fn require_read(&self, action: Action) -> async_graphql::Result<()> { - self.require_action(action, "Access token does not have required permission") - .await - } - - pub async fn require_write(&self, action: Action) -> async_graphql::Result<()> { - self.require_action(action, "Access token does not have write permission") + /// Authorize `action` on `site_id` with token scope and the live site role. + pub async fn require_site_action_for(&self, site_id: &str, action: Action) -> async_graphql::Result<()> { + AuthorizationService::new(self.repository.user.clone()) + .require_site_action(self.require_actor()?, site_id, action) .await + .map_err(|error| crate::graphql::service_error("authorization", error)) } - pub fn require_site_match(&self, site_id: &str) -> async_graphql::Result<()> { - let key_site = self.require_site()?; - if key_site == site_id { + /// Scope gate for operations that span sites, such as `sites`. + pub fn require_token_scope(&self, action: Action) -> async_graphql::Result<()> { + let scopes = match self.require_actor()? { + Actor::ApiKey(key) => &key.scopes, + Actor::PersonalToken(token) => &token.scopes, + Actor::User(_) => return Ok(()), + }; + if crate::middleware::auth::scopes_allow_action(scopes, action) { Ok(()) } else { - Err(async_graphql::Error::new( - "Site token does not have access to this site", - )) + let scope = crate::middleware::auth::scope_for_action(action).map_or("unavailable", |scope| scope.as_str()); + Err(async_graphql::Error::new(format!( + "Token requires the '{scope}' scope." + ))) } } diff --git a/apps/backend/src/graphql/loaders.rs b/apps/backend/src/graphql/loaders.rs index a6f4df43..e0b6f8ac 100644 --- a/apps/backend/src/graphql/loaders.rs +++ b/apps/backend/src/graphql/loaders.rs @@ -12,6 +12,7 @@ use crate::repository::Repository; /// the whole GraphQL request, eliminating the N+1. #[derive(Clone, PartialEq, Eq, Hash)] pub struct EntriesByCollection { + pub site_id: String, pub collection_id: String, pub status: Option, pub published_only: bool, @@ -31,17 +32,17 @@ impl Loader for EntryLoader { ) -> Result, Self::Error> { // Group collection ids by their (status, published_only) filter so each // group maps to exactly one batched query. - let mut groups: HashMap<(Option, bool), Vec> = HashMap::new(); + let mut groups: HashMap<(String, Option, bool), Vec> = HashMap::new(); for k in keys { groups - .entry((k.status.clone(), k.published_only)) + .entry((k.site_id.clone(), k.status.clone(), k.published_only)) .or_default() .push(k.collection_id.clone()); } let mut out: HashMap> = HashMap::new(); - for ((status, published_only), ids) in groups { + for ((site_id, status, published_only), ids) in groups { let entries = self .repository .entry @@ -56,11 +57,18 @@ impl Loader for EntryLoader { for cid in ids { let key = EntriesByCollection { + site_id: site_id.clone(), collection_id: cid.clone(), status: status.clone(), published_only, }; - out.insert(key, by_cid.remove(&cid).unwrap_or_default()); + let items = by_cid + .remove(&cid) + .unwrap_or_default() + .into_iter() + .filter(|entry| entry.site_id == site_id) + .collect(); + out.insert(key, items); } } diff --git a/apps/backend/src/graphql/mod.rs b/apps/backend/src/graphql/mod.rs index 1b959eb2..727fb3ea 100644 --- a/apps/backend/src/graphql/mod.rs +++ b/apps/backend/src/graphql/mod.rs @@ -13,3 +13,28 @@ pub fn internal_error(context: &str, e: impl std::fmt::Display) -> async_graphql tracing::error!("graphql {context} error: {e}"); async_graphql::Error::new("Internal server error") } + +/// Translate domain failures while retaining safe, actionable public messages. +pub fn service_error(context: &str, error: impl Into) -> async_graphql::Error { + use async_graphql::ErrorExtensions; + let error = error.into(); + let status = error.status_code(); + if status.is_server_error() { + tracing::error!(context, error = ?error, "GraphQL operation failed"); + } + async_graphql::Error::new(error.error_message()).extend_with(|_, extensions| { + extensions.set( + "code", + match status.as_u16() { + 400 | 422 => "INVALID_INPUT", + 401 => "UNAUTHENTICATED", + 403 => "FORBIDDEN", + 404 => "NOT_FOUND", + 409 => "CONFLICT", + 412 => "PRECONDITION_FAILED", + 413 => "PAYLOAD_TOO_LARGE", + _ => "INTERNAL_ERROR", + }, + ); + }) +} diff --git a/apps/backend/src/graphql/mutation/collection.rs b/apps/backend/src/graphql/mutation/collection.rs deleted file mode 100644 index 6763626a..00000000 --- a/apps/backend/src/graphql/mutation/collection.rs +++ /dev/null @@ -1,76 +0,0 @@ -use async_graphql::{Context, Object, Result}; - -use crate::graphql::context::GqlContext; -use crate::graphql::types::collection::*; - -pub struct CollectionMutation; - -#[Object] -impl CollectionMutation { - pub async fn create_collection(&self, ctx: &Context<'_>, input: CreateCollectionInput) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - gql_ctx - .require_write(crate::models::authorization::Action::SchemaWrite) - .await?; - - let definition_str = input.definition.to_string(); - let is_singleton = input.is_singleton.unwrap_or(false); - - let collection = gql_ctx - .services - .collection - .create_collection(site_id, &input.name, &input.slug, &definition_str, is_singleton) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(db_collection_to_gql(collection)) - } - - pub async fn update_collection( - &self, - ctx: &Context<'_>, - slug: String, - input: UpdateCollectionInput, - ) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - gql_ctx - .require_write(crate::models::authorization::Action::SchemaWrite) - .await?; - - let definition_str = input.definition.as_ref().map(|s| s.to_string()); - - let collection = gql_ctx - .services - .collection - .update_collection( - site_id, - &slug, - input.name.as_deref(), - input.slug.as_deref(), - definition_str.as_deref(), - ) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(db_collection_to_gql(collection)) - } - - pub async fn delete_collection(&self, ctx: &Context<'_>, slug: String) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - gql_ctx - .require_write(crate::models::authorization::Action::SchemaWrite) - .await?; - - gql_ctx - .services - .collection - .delete_collection(site_id, &slug) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(true) - } -} diff --git a/apps/backend/src/graphql/mutation/entry.rs b/apps/backend/src/graphql/mutation/entry.rs index 595924c5..cc5085d2 100644 --- a/apps/backend/src/graphql/mutation/entry.rs +++ b/apps/backend/src/graphql/mutation/entry.rs @@ -7,115 +7,117 @@ pub struct EntryMutation; #[Object] impl EntryMutation { - pub async fn create_entry(&self, ctx: &Context<'_>, input: CreateEntryInput) -> Result { + pub async fn create_entry(&self, ctx: &Context<'_>, site_id: String, input: CreateEntryInput) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentWrite) .await?; let created_by = gql_ctx.user_id(); let entry = gql_ctx .services .entry - .create_entry(site_id, &input.collection_id, &input.data.0, &input.slug, created_by) + .create_entry(&site_id, &input.collection_id, &input.data.0, &input.slug, created_by) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.create_entry", e))?; Ok(db_entry_to_gql(entry)) } - pub async fn update_entry(&self, ctx: &Context<'_>, id: String, input: UpdateEntryInput) -> Result { + pub async fn update_entry( + &self, + ctx: &Context<'_>, + site_id: String, + id: String, + input: UpdateEntryInput, + ) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentWrite) .await?; + if input.status.is_some() { + gql_ctx + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentPublish) + .await?; + } let created_by = gql_ctx.user_id(); let entry = gql_ctx .services .entry .update_entry(crate::services::entry::UpdateEntryInput { id: &id, - site_id, + site_id: &site_id, data: input.data.as_ref().map(|d| &d.0), slug: input.slug.as_deref(), status: input.status.as_deref(), created_by, change_summary: input.change_summary.as_deref(), + expected_version: input.expected_version.as_deref(), }) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.update_entry", e))?; Ok(db_entry_to_gql(entry)) } - pub async fn delete_entry(&self, ctx: &Context<'_>, id: String) -> Result { + pub async fn delete_entry(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentWrite) .await?; gql_ctx .services .entry - .delete_entry(&id, site_id) + .delete_entry(&id, &site_id) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.delete_entry", e))?; Ok(true) } - pub async fn publish_entry(&self, ctx: &Context<'_>, id: String) -> Result { + pub async fn set_publication( + &self, + ctx: &Context<'_>, + site_id: String, + id: String, + published: bool, + ) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentPublish) .await?; - let entry = gql_ctx - .services - .entry - .publish_entry(&id, site_id) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(db_entry_to_gql(entry)) - } - - pub async fn unpublish_entry(&self, ctx: &Context<'_>, id: String) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) - .await?; - - let entry = gql_ctx - .services - .entry - .unpublish_entry(&id, site_id) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + let entry = (if published { + gql_ctx.services.entry.publish_entry(&id, &site_id).await + } else { + gql_ctx.services.entry.unpublish_entry(&id, &site_id).await + }) + .map_err(|e| crate::graphql::service_error("mutation.publish_entry", e))?; Ok(db_entry_to_gql(entry)) } - pub async fn restore_revision(&self, ctx: &Context<'_>, entry_id: String, revision_number: i64) -> Result { + pub async fn restore_revision( + &self, + ctx: &Context<'_>, + site_id: String, + entry_id: String, + revision_number: i64, + ) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::ContentWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentWrite) .await?; let created_by = gql_ctx.user_id(); let entry = gql_ctx .services .entry - .restore_revision(&entry_id, site_id, revision_number, created_by) + .restore_revision(&entry_id, &site_id, revision_number, created_by) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.restore_revision", e))?; Ok(db_entry_to_gql(entry)) } diff --git a/apps/backend/src/graphql/mutation/file.rs b/apps/backend/src/graphql/mutation/file.rs index 024229c3..fe4052bc 100644 --- a/apps/backend/src/graphql/mutation/file.rs +++ b/apps/backend/src/graphql/mutation/file.rs @@ -1,76 +1,90 @@ use async_graphql::{Context, Object, Result}; use crate::graphql::context::GqlContext; +use crate::graphql::types::file::FileUploadUrl; pub struct FileMutation; #[Object] impl FileMutation { - pub async fn delete_file(&self, ctx: &Context<'_>, id: String) -> Result { + pub async fn create_file_upload( + &self, + ctx: &Context<'_>, + site_id: String, + filename: String, + content_type: String, + ) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::FilesWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::FilesWrite) .await?; - - gql_ctx + crate::services::file::FileService::validate_filename(&filename) + .map_err(|error| crate::graphql::service_error("mutation.create_file_upload", error))?; + if !crate::utils::content_types::is_allowed(&content_type) { + return Err(async_graphql::Error::new("Content type is not allowed")); + } + let site = gql_ctx .services - .file - .soft_delete(&id, site_id) + .site + .get_site(&site_id) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(true) + .map_err(|error| crate::graphql::service_error("mutation.create_file_upload", error))? + .ok_or_else(|| async_graphql::Error::new("Site not found"))?; + let profile = site + .storage_profile_id + .ok_or_else(|| async_graphql::Error::new("Storage profile not configured"))?; + let (token, upload_path) = crate::signed_upload::SignedUploadToken::generate_with_storage_profile( + &site_id, + &filename, + &content_type, + &profile, + &gql_ctx.config.signed_upload_key, + gql_ctx.config.upload_token_expiry_secs, + ); + let expires_at = token.expires_at(); + let base = gql_ctx + .config + .public_url + .clone() + .unwrap_or_else(|| format!("http://{}", gql_ctx.config.bind_address)); + Ok(FileUploadUrl { + upload_url: format!("{}/api/v1/files/upload/{}", base.trim_end_matches('/'), upload_path), + file_id: token.file_id, + expires_at, + method: "PUT".into(), + content_type, + }) } - pub async fn restore_file(&self, ctx: &Context<'_>, id: String) -> Result { + pub async fn delete_file(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::FilesWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::FilesWrite) .await?; gql_ctx .services .file - .restore(&id, site_id) + .soft_delete(&id, &site_id) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.delete_file", e))?; Ok(true) } - pub async fn batch_delete_files(&self, ctx: &Context<'_>, ids: Vec) -> Result { + pub async fn restore_file(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::FilesWrite) + .require_site_action_for(&site_id, crate::models::authorization::Action::FilesWrite) .await?; - let count = gql_ctx - .services - .file - .batch_soft_delete(site_id, &ids) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(count as i64) - } - - pub async fn batch_restore_files(&self, ctx: &Context<'_>, ids: Vec) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; gql_ctx - .require_write(crate::models::authorization::Action::FilesWrite) - .await?; - - let count = gql_ctx .services .file - .batch_restore(site_id, &ids) + .restore(&id, &site_id) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + .map_err(|e| crate::graphql::service_error("mutation.restore_file", e))?; - Ok(count as i64) + Ok(true) } } diff --git a/apps/backend/src/graphql/mutation/mod.rs b/apps/backend/src/graphql/mutation/mod.rs index 7cd027a0..f17c087e 100644 --- a/apps/backend/src/graphql/mutation/mod.rs +++ b/apps/backend/src/graphql/mutation/mod.rs @@ -1,180 +1,86 @@ -pub mod collection; pub mod entry; pub mod file; -pub mod webhook; +pub mod singleton; use async_graphql::{Context, Object, Result}; -use std::collections::HashMap; -use crate::graphql::context::GqlContext; -use crate::graphql::types::collection::*; use crate::graphql::types::entry::{CreateEntryInput, Entry, UpdateEntryInput}; -use crate::graphql::types::webhook::{db_delivery_to_gql, db_webhook_to_gql}; pub struct MutationRoot; #[Object] impl MutationRoot { - async fn create_collection(&self, ctx: &Context<'_>, input: CreateCollectionInput) -> Result { - collection::CollectionMutation.create_collection(ctx, input).await + async fn create_entry(&self, ctx: &Context<'_>, site_id: String, input: CreateEntryInput) -> Result { + entry::EntryMutation.create_entry(ctx, site_id, input).await } - async fn update_collection( + async fn update_entry( &self, ctx: &Context<'_>, - slug: String, - input: UpdateCollectionInput, - ) -> Result { - collection::CollectionMutation.update_collection(ctx, slug, input).await - } - - async fn delete_collection(&self, ctx: &Context<'_>, slug: String) -> Result { - collection::CollectionMutation.delete_collection(ctx, slug).await - } - - async fn create_entry(&self, ctx: &Context<'_>, input: CreateEntryInput) -> Result { - entry::EntryMutation.create_entry(ctx, input).await - } - - async fn update_entry(&self, ctx: &Context<'_>, id: String, input: UpdateEntryInput) -> Result { - entry::EntryMutation.update_entry(ctx, id, input).await - } - - async fn delete_entry(&self, ctx: &Context<'_>, id: String) -> Result { - entry::EntryMutation.delete_entry(ctx, id).await - } - - async fn publish_entry(&self, ctx: &Context<'_>, id: String) -> Result { - entry::EntryMutation.publish_entry(ctx, id).await - } - - async fn unpublish_entry(&self, ctx: &Context<'_>, id: String) -> Result { - entry::EntryMutation.unpublish_entry(ctx, id).await - } - - async fn restore_revision(&self, ctx: &Context<'_>, entry_id: String, revision_number: i64) -> Result { - entry::EntryMutation - .restore_revision(ctx, entry_id, revision_number) - .await - } - - async fn delete_file(&self, ctx: &Context<'_>, id: String) -> Result { - file::FileMutation.delete_file(ctx, id).await - } - - async fn restore_file(&self, ctx: &Context<'_>, id: String) -> Result { - file::FileMutation.restore_file(ctx, id).await + site_id: String, + id: String, + input: UpdateEntryInput, + ) -> Result { + entry::EntryMutation.update_entry(ctx, site_id, id, input).await } - async fn batch_delete_files(&self, ctx: &Context<'_>, ids: Vec) -> Result { - file::FileMutation.batch_delete_files(ctx, ids).await + async fn delete_entry(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { + entry::EntryMutation.delete_entry(ctx, site_id, id).await } - async fn batch_restore_files(&self, ctx: &Context<'_>, ids: Vec) -> Result { - file::FileMutation.batch_restore_files(ctx, ids).await + async fn set_entry_publication( + &self, + ctx: &Context<'_>, + site_id: String, + id: String, + published: bool, + ) -> Result { + entry::EntryMutation.set_publication(ctx, site_id, id, published).await } - async fn create_webhook( + async fn restore_revision( &self, ctx: &Context<'_>, site_id: String, - label: String, - url: String, - headers: Option, - ) -> Result { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_write(crate::models::authorization::Action::WebhooksWrite) - .await?; - - let parsed_headers: HashMap = match headers { - Some(ref h) if !h.is_empty() => serde_json::from_str(h).unwrap_or_default(), - _ => HashMap::new(), - }; - - let webhook = gql_ctx - .services - .webhook - .create_webhook(&site_id, &label, &url, &parsed_headers, None) + entry_id: String, + revision_number: i64, + ) -> Result { + entry::EntryMutation + .restore_revision(ctx, site_id, entry_id, revision_number) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - let decrypted = gql_ctx.services.webhook.decrypt_webhook_headers(&webhook); - Ok(db_webhook_to_gql(webhook, decrypted)) } - async fn update_webhook( + async fn update_singleton( &self, ctx: &Context<'_>, site_id: String, - webhook_id: String, - label: Option, - url: Option, - headers: Option, - ) -> Result { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_write(crate::models::authorization::Action::WebhooksWrite) - .await?; - - let parsed_headers: Option> = - headers.map(|h| serde_json::from_str(&h).unwrap_or_default()); - - let webhook = gql_ctx - .services - .webhook - .update_webhook( - &webhook_id, - &site_id, - label.as_deref(), - url.as_deref(), - parsed_headers.as_ref(), - ) + slug: String, + data: crate::graphql::types::json::Json, + change_summary: Option, + expected_version: Option, + ) -> Result { + singleton::SingletonMutation + .update_singleton(ctx, site_id, slug, data, change_summary, expected_version) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - let decrypted = gql_ctx.services.webhook.decrypt_webhook_headers(&webhook); - Ok(db_webhook_to_gql(webhook, decrypted)) } - async fn delete_webhook(&self, ctx: &Context<'_>, site_id: String, webhook_id: String) -> Result { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_write(crate::models::authorization::Action::WebhooksWrite) - .await?; - - let deleted = gql_ctx - .services - .webhook - .delete_webhook(&webhook_id, &site_id) - .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; - - Ok(deleted > 0) + async fn delete_file(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { + file::FileMutation.delete_file(ctx, site_id, id).await } - async fn trigger_webhook( + async fn create_file_upload( &self, ctx: &Context<'_>, site_id: String, - webhook_id: String, - ) -> Result { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_write(crate::models::authorization::Action::WebhooksTrigger) - .await?; - - let delivery = gql_ctx - .services - .webhook - .trigger_webhook(&webhook_id, &site_id, None) + filename: String, + content_type: String, + ) -> Result { + file::FileMutation + .create_file_upload(ctx, site_id, filename, content_type) .await - .map_err(|e| async_graphql::Error::new(format!("Error: {}", e)))?; + } - Ok(db_delivery_to_gql(delivery)) + async fn restore_file(&self, ctx: &Context<'_>, site_id: String, id: String) -> Result { + file::FileMutation.restore_file(ctx, site_id, id).await } } diff --git a/apps/backend/src/graphql/mutation/singleton.rs b/apps/backend/src/graphql/mutation/singleton.rs new file mode 100644 index 00000000..333f8b92 --- /dev/null +++ b/apps/backend/src/graphql/mutation/singleton.rs @@ -0,0 +1,39 @@ +use async_graphql::{Context, Object, Result}; + +use crate::graphql::context::GqlContext; +use crate::graphql::types::collection::{SingletonGraphql, singleton_to_gql}; +use crate::graphql::types::json::Json; + +pub struct SingletonMutation; + +#[Object] +impl SingletonMutation { + pub async fn update_singleton( + &self, + ctx: &Context<'_>, + site_id: String, + slug: String, + data: Json, + change_summary: Option, + expected_version: Option, + ) -> Result { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&site_id, crate::models::authorization::Action::ContentWrite) + .await?; + let value = gql_ctx + .services + .singleton + .update_singleton( + &site_id, + &slug, + &data.0, + gql_ctx.user_id(), + change_summary.as_deref(), + expected_version.as_deref(), + ) + .await + .map_err(|error| crate::graphql::service_error("mutation.update_singleton", error))?; + Ok(singleton_to_gql(value)) + } +} diff --git a/apps/backend/src/graphql/mutation/webhook.rs b/apps/backend/src/graphql/mutation/webhook.rs deleted file mode 100644 index 2dde2ee0..00000000 --- a/apps/backend/src/graphql/mutation/webhook.rs +++ /dev/null @@ -1 +0,0 @@ -pub fn noop() {} diff --git a/apps/backend/src/graphql/query/mod.rs b/apps/backend/src/graphql/query/mod.rs index d609eb66..98e7470b 100644 --- a/apps/backend/src/graphql/query/mod.rs +++ b/apps/backend/src/graphql/query/mod.rs @@ -1,365 +1,44 @@ use async_graphql::{Context, Object, Result}; use super::context::GqlContext; -use super::types::collection::Collection; -use super::types::entry::Entry; -use super::types::file::File; -use super::types::site::Site; -use super::types::webhook::{WebhookDelivery, db_delivery_to_gql, db_webhook_to_gql}; - -use crate::repository::traits::{ListEntriesParams, ListFilesParams}; +use super::types::site::{Site, SiteDetails}; +use crate::models::authorization::Action; pub struct QueryRoot; +/// Every content read lives under `site(id:)`, so a request always names the +/// site it targets and resolvers authorize against that site explicitly. #[Object] impl QueryRoot { - async fn current_site(&self, ctx: &Context<'_>) -> Result { + /// Sites the credential can read. + async fn sites(&self, ctx: &Context<'_>) -> Result> { let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let site = gql_ctx + gql_ctx.require_token_scope(Action::SiteRead)?; + let sites = gql_ctx .services .site - .get_site(site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Site not found"))?; - - Ok(Site { - id: site.id, - name: site.name, - storage_provider: site.storage_provider, - created_by: site.created_by, - created_at: site.created_at, - updated_at: site.updated_at, - }) - } - - async fn collections(&self, ctx: &Context<'_>) -> Result> { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let db_collections = gql_ctx - .services - .collection - .list_collections(site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(db_collections - .into_iter() - .map(super::types::collection::db_collection_to_gql) - .collect()) - } - - async fn collection(&self, ctx: &Context<'_>, slug: String) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let db_collection = gql_ctx - .services - .collection - .get_collection(site_id, &slug) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Collection not found"))?; - - Ok(super::types::collection::db_collection_to_gql(db_collection)) - } - - // Each parameter is a GraphQL field argument exposed in the public schema and - // consumed positionally by the dashboard's queries; collapsing them into an - // input object would be a breaking schema change, so the arg count stands. - #[allow(clippy::too_many_arguments)] - async fn entries( - &self, - ctx: &Context<'_>, - collection_id: Option, - status: Option, - r#type: Option, - search: Option, - page: Option, - per_page: Option, - ) -> Result> { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let page_val = page.unwrap_or(1).max(1); - let per_page_val = per_page.unwrap_or(50).clamp(1, 200); - - let params = ListEntriesParams { - site_id, - collection_slug: r#type.as_deref(), - collection_id: collection_id.as_deref(), - status: status.as_deref(), - search: search.as_deref(), - published_only: status.is_none(), - page: page_val, - per_page: per_page_val, - }; - - let result = gql_ctx - .services - .entry - .list_entries(params) + .list_sites_for_actor(gql_ctx.require_actor()?) .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(result - .items + .map_err(|e| crate::graphql::service_error("query.sites", e))?; + Ok(sites .into_iter() - .map(super::types::entry::db_entry_to_gql) - .collect()) - } - - async fn entry(&self, ctx: &Context<'_>, id: String) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let entry = gql_ctx - .services - .entry - .get_entry(&id, site_id, false) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Entry not found"))?; - - Ok(super::types::entry::db_entry_to_gql(entry)) - } - - async fn files( - &self, - ctx: &Context<'_>, - page: Option, - search: Option, - file_type: Option, - ) -> Result> { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let page_val = page.unwrap_or(1).max(1); - let per_page: i64 = 30; - - let params = ListFilesParams { - site_id, - trashed: false, - search: search.as_deref(), - file_type: file_type.as_deref(), - page: page_val, - per_page, - }; - - let result = gql_ctx - .services - .file - .list_files(params) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(result - .items - .into_iter() - .map(|f| super::types::file::db_file_to_gql(f, gql_ctx)) - .collect()) - } - - async fn file(&self, ctx: &Context<'_>, id: String) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let db_file = gql_ctx - .services - .file - .get_file(&id, site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("File not found"))?; - - Ok(super::types::file::db_file_to_gql(db_file, gql_ctx)) - } - - async fn file_references( - &self, - ctx: &Context<'_>, - file_id: String, - ) -> Result> { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - let refs = gql_ctx - .services - .file - .get_file_references(&file_id, site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(refs - .into_iter() - .map(|r| super::types::file::FileReference { - entry_id: r.entry_id, - collection_name: r.collection_name, - field_name: r.field_name, + .filter_map(|site| { + let text = |key: &str| site.get(key).and_then(|value| value.as_str()).map(str::to_owned); + Some(Site { + id: text("id")?, + details: Some(SiteDetails { + name: text("name")?, + created_at: text("created_at")?, + updated_at: text("updated_at")?, + }), + }) }) .collect()) } - async fn entry_revisions( - &self, - ctx: &Context<'_>, - entry_id: String, - page: Option, - per_page: Option, - ) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - // Verify entry exists and belongs to site - gql_ctx - .services - .entry - .get_entry(&entry_id, site_id, false) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Entry not found"))?; - - let page_val = page.unwrap_or(1).max(1); - let per_page_val = per_page.unwrap_or(50).clamp(1, 200); - - let result = gql_ctx - .services - .entry - .list_revisions(&entry_id, site_id, page_val, per_page_val) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(super::types::entry::RevisionsListResult { - items: result - .items - .into_iter() - .map(|r| super::types::entry::db_revision_to_gql(r, None)) - .collect(), - total: result.total, - page: result.page, - per_page: result.per_page, - }) - } - - async fn entry_revision( - &self, - ctx: &Context<'_>, - entry_id: String, - revision_number: i64, - diff: Option, - ) -> Result { - let gql_ctx = ctx.data::()?; - let site_id = gql_ctx.require_site()?; - - // Verify entry exists and belongs to site - gql_ctx - .services - .entry - .get_entry(&entry_id, site_id, false) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Entry not found"))?; - - let revision = gql_ctx - .services - .entry - .get_revision(&entry_id, site_id, revision_number) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Revision not found"))?; - - let diff_value = if diff.unwrap_or(false) && revision_number > 1 { - if let Ok(Some(prev)) = gql_ctx - .services - .entry - .get_revision(&entry_id, site_id, revision_number - 1) - .await - { - crate::utils::diff::compute_diff_for_revision(&revision, Some(&prev)) - } else { - None - } - } else { - None - }; - - Ok(super::types::entry::db_revision_to_gql(revision, diff_value)) - } - - async fn webhooks(&self, ctx: &Context<'_>, site_id: String) -> Result> { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_read(crate::models::authorization::Action::WebhooksRead) - .await?; - - let webhooks = gql_ctx - .services - .webhook - .list_webhooks(&site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(webhooks - .into_iter() - .map(|w| { - let headers = gql_ctx.services.webhook.decrypt_webhook_headers(&w); - db_webhook_to_gql(w, headers) - }) - .collect()) - } - - async fn webhook( - &self, - ctx: &Context<'_>, - site_id: String, - webhook_id: String, - ) -> Result { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_read(crate::models::authorization::Action::WebhooksRead) - .await?; - - let webhook = gql_ctx - .services - .webhook - .get_webhook(&webhook_id, &site_id) - .await - .map_err(|e| crate::graphql::internal_error("query", e))? - .ok_or_else(|| async_graphql::Error::new("Webhook not found"))?; - - let headers = gql_ctx.services.webhook.decrypt_webhook_headers(&webhook); - Ok(db_webhook_to_gql(webhook, headers)) - } - - async fn webhook_deliveries( - &self, - ctx: &Context<'_>, - site_id: String, - webhook_id: String, - page: Option, - per_page: Option, - ) -> Result> { - let gql_ctx = ctx.data::()?; - gql_ctx.require_site_match(&site_id)?; - gql_ctx - .require_read(crate::models::authorization::Action::WebhooksRead) - .await?; - - let page_val = page.unwrap_or(1).max(1); - let per_page_val = per_page.unwrap_or(20).clamp(1, 100); - - let (deliveries, _total) = gql_ctx - .services - .webhook - .list_deliveries(&webhook_id, &site_id, page_val, per_page_val) - .await - .map_err(|e| crate::graphql::internal_error("query", e))?; - - Ok(deliveries.into_iter().map(db_delivery_to_gql).collect()) + /// Site namespace. Selecting it performs no lookup; each nested field checks + /// the credential for its own action on this site. + async fn site(&self, id: String) -> Site { + Site { id, details: None } } } diff --git a/apps/backend/src/graphql/schema.rs b/apps/backend/src/graphql/schema.rs index 845341fa..12739cb4 100644 --- a/apps/backend/src/graphql/schema.rs +++ b/apps/backend/src/graphql/schema.rs @@ -5,15 +5,9 @@ use super::query::QueryRoot; pub type CmsSchema = Schema; -pub fn build_schema(production: bool) -> CmsSchema { - let builder = Schema::build(QueryRoot, MutationRoot, EmptySubscription) +pub fn build_schema(_production: bool) -> CmsSchema { + Schema::build(QueryRoot, MutationRoot, EmptySubscription) .limit_depth(15) - .limit_complexity(2000); - // Hide the schema from unauthenticated clients in production. - let builder = if production { - builder.disable_introspection() - } else { - builder - }; - builder.finish() + .limit_complexity(2000) + .finish() } diff --git a/apps/backend/src/graphql/types/collection.rs b/apps/backend/src/graphql/types/collection.rs index 35be6b79..acba944f 100644 --- a/apps/backend/src/graphql/types/collection.rs +++ b/apps/backend/src/graphql/types/collection.rs @@ -1,4 +1,4 @@ -use async_graphql::{ComplexObject, InputObject, SimpleObject}; +use async_graphql::{ComplexObject, SimpleObject}; use super::entry::Entry; use super::json::Json; @@ -18,7 +18,9 @@ pub struct Collection { #[ComplexObject] impl Collection { - async fn entry( + /// Up to 200 most recent entries; page through `site.entries` for more. + #[graphql(complexity = "200 * child_complexity")] + async fn entries( &self, ctx: &async_graphql::Context<'_>, status: Option, @@ -27,12 +29,23 @@ impl Collection { use async_graphql::dataloader::DataLoader; let gql_ctx = ctx.data::()?; - let published_only = gql_ctx.site_id.is_some(); + gql_ctx + .require_site_action_for( + &self.site_id, + if status.as_deref() == Some("draft") { + crate::models::authorization::Action::ContentPreviewRead + } else { + crate::models::authorization::Action::ContentRead + }, + ) + .await?; + let published_only = status.as_deref() != Some("draft"); // Batched via DataLoader to avoid an N+1 across multiple collections. let loader = ctx.data::>()?; let items = loader .load_one(EntriesByCollection { + site_id: self.site_id.clone(), collection_id: self.id.clone(), status: status.clone(), published_only, @@ -45,19 +58,34 @@ impl Collection { } } -#[derive(InputObject)] -pub struct CreateCollectionInput { +#[derive(SimpleObject)] +pub struct SingletonGraphql { + pub id: String, + pub site_id: String, pub name: String, pub slug: String, pub definition: Json, - pub is_singleton: Option, + pub data: Option, + pub entry_id: Option, + pub created_at: String, + pub updated_at: String, + /// Opaque version of the singleton's content; pass it as `expectedVersion`. + pub version: Option, } -#[derive(InputObject)] -pub struct UpdateCollectionInput { - pub name: Option, - pub slug: Option, - pub definition: Option, +pub fn singleton_to_gql(value: crate::models::collection::SingletonResponse) -> SingletonGraphql { + SingletonGraphql { + id: value.id, + site_id: value.site_id, + name: value.name, + slug: value.slug, + definition: Json(value.definition), + data: value.data.map(Json), + entry_id: value.entry_id, + created_at: value.created_at, + updated_at: value.updated_at, + version: value.version, + } } pub fn db_collection_to_gql(c: crate::models::collection::Collection) -> Collection { diff --git a/apps/backend/src/graphql/types/entry.rs b/apps/backend/src/graphql/types/entry.rs index 51defff4..5e882a43 100644 --- a/apps/backend/src/graphql/types/entry.rs +++ b/apps/backend/src/graphql/types/entry.rs @@ -13,6 +13,7 @@ pub struct Entry { pub singleton_collection_id: Option, pub created_at: String, pub updated_at: String, + pub version: String, pub published_at: Option, } @@ -29,6 +30,7 @@ pub struct UpdateEntryInput { pub slug: Option, pub status: Option, pub change_summary: Option, + pub expected_version: Option, } #[derive(SimpleObject)] @@ -51,6 +53,19 @@ pub struct RevisionsListResult { pub per_page: i64, } +#[derive(SimpleObject)] +pub struct PageInfo { + pub has_next_page: bool, + pub has_previous_page: bool, +} + +#[derive(SimpleObject)] +pub struct EntryConnection { + pub nodes: Vec, + pub page_info: PageInfo, + pub total_count: i64, +} + pub fn db_entry_to_gql(e: crate::models::entry::Entry) -> Entry { let data = serde_json::from_str(&e.data).unwrap_or(serde_json::Value::Null); Entry { @@ -63,6 +78,7 @@ pub fn db_entry_to_gql(e: crate::models::entry::Entry) -> Entry { singleton_collection_id: e.singleton_collection_id, created_at: e.created_at, updated_at: e.updated_at, + version: e.version, published_at: e.published_at, } } diff --git a/apps/backend/src/graphql/types/file.rs b/apps/backend/src/graphql/types/file.rs index c9641e88..365b720e 100644 --- a/apps/backend/src/graphql/types/file.rs +++ b/apps/backend/src/graphql/types/file.rs @@ -25,6 +25,15 @@ pub struct FileReference { pub field_name: String, } +#[derive(SimpleObject)] +pub struct FileUploadUrl { + pub upload_url: String, + pub file_id: String, + pub expires_at: String, + pub method: String, + pub content_type: String, +} + pub fn db_file_to_gql(f: crate::models::file::File, _gql_ctx: &GqlContext) -> File { let url = format!("/api/files/{}", f.id); let thumbnail_url = f diff --git a/apps/backend/src/graphql/types/json.rs b/apps/backend/src/graphql/types/json.rs index 97d29ce8..803e3e0e 100644 --- a/apps/backend/src/graphql/types/json.rs +++ b/apps/backend/src/graphql/types/json.rs @@ -12,33 +12,10 @@ pub struct Json(pub serde_json::Value); #[Scalar(name = "JSON")] impl ScalarType for Json { fn parse(value: Value) -> InputValueResult { - match value { - Value::String(s) => { - let v: serde_json::Value = - serde_json::from_str(&s).map_err(|e| InputValueError::custom(format!("Invalid JSON: {}", e)))?; - Ok(Json(v)) - } - Value::Null => Ok(Json(serde_json::Value::Null)), - Value::Number(n) => { - if let Some(i) = n.as_i64() { - Ok(Json(serde_json::Value::Number(i.into()))) - } else if let Some(f) = n.as_f64() { - Ok(Json( - serde_json::Number::from_f64(f) - .map(serde_json::Value::Number) - .unwrap_or(serde_json::Value::Null), - )) - } else { - Ok(Json(serde_json::Value::Null)) - } - } - Value::Boolean(b) => Ok(Json(serde_json::Value::Bool(b))), - Value::Enum(s) => Ok(Json(serde_json::Value::String(s.to_string()))), - _ => { - let json_val = serde_json::to_value(&value).map_err(|e| InputValueError::custom(e.to_string()))?; - Ok(Json(json_val)) - } - } + value + .into_json() + .map(Json) + .map_err(|error| InputValueError::custom(error.to_string())) } fn to_value(&self) -> Value { @@ -63,3 +40,21 @@ impl From for serde_json::Value { j.0 } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn json_scalar_preserves_values_without_double_decoding_or_precision_loss() { + for original in [ + serde_json::json!("{\"x\":1}"), + serde_json::json!(u64::MAX), + serde_json::json!({"nested":[null, true, 1.5, "hello"]}), + ] { + let parsed = Json::parse(Value::from_json(original.clone()).unwrap()).unwrap(); + assert_eq!(parsed.0, original); + assert_eq!(parsed.to_value().into_json().unwrap(), original); + } + } +} diff --git a/apps/backend/src/graphql/types/mod.rs b/apps/backend/src/graphql/types/mod.rs index f4ab9059..15bbb1ea 100644 --- a/apps/backend/src/graphql/types/mod.rs +++ b/apps/backend/src/graphql/types/mod.rs @@ -3,4 +3,3 @@ pub mod entry; pub mod file; pub mod json; pub mod site; -pub mod webhook; diff --git a/apps/backend/src/graphql/types/site.rs b/apps/backend/src/graphql/types/site.rs index bdd20e68..6fff2496 100644 --- a/apps/backend/src/graphql/types/site.rs +++ b/apps/backend/src/graphql/types/site.rs @@ -1,11 +1,343 @@ -use async_graphql::SimpleObject; +use async_graphql::{ComplexObject, SimpleObject}; +/// Namespace for one site's content. Every field authorizes its own action, so a +/// content-only credential can read entries without `site.read`. #[derive(SimpleObject)] +#[graphql(complex)] pub struct Site { pub id: String, + /// Preloaded by `sites`, which has already authorized each site. + #[graphql(skip)] + pub details: Option, +} + +#[derive(Clone)] +pub struct SiteDetails { pub name: String, - pub storage_provider: String, - pub created_by: String, pub created_at: String, pub updated_at: String, } + +#[ComplexObject] +impl Site { + async fn name(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result { + Ok(self.load_details(ctx).await?.name) + } + + async fn created_at(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result { + Ok(self.load_details(ctx).await?.created_at) + } + + async fn updated_at(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result { + Ok(self.load_details(ctx).await?.updated_at) + } + + async fn collections( + &self, + ctx: &async_graphql::Context<'_>, + ) -> async_graphql::Result> { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::SchemaRead) + .await?; + let collections = gql_ctx + .services + .collection + .list_collections(&self.id) + .await + .map_err(|e| crate::graphql::service_error("site.collections", e))?; + Ok(collections + .into_iter() + .map(super::collection::db_collection_to_gql) + .collect()) + } + + async fn collection( + &self, + ctx: &async_graphql::Context<'_>, + slug: String, + ) -> async_graphql::Result { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::SchemaRead) + .await?; + let collection = gql_ctx + .services + .collection + .get_collection(&self.id, &slug) + .await + .map_err(|e| crate::graphql::service_error("site.collection", e))? + .ok_or_else(|| async_graphql::Error::new("Collection not found"))?; + Ok(super::collection::db_collection_to_gql(collection)) + } + + #[allow(clippy::too_many_arguments)] + async fn entries( + &self, + ctx: &async_graphql::Context<'_>, + collection_slug: Option, + status: Option, + search: Option, + include_drafts: Option, + page: Option, + per_page: Option, + ) -> async_graphql::Result { + let gql_ctx = ctx.data::()?; + let include_drafts = include_drafts.unwrap_or(false); + gql_ctx + .require_site_action_for(&self.id, content_read_action(include_drafts)) + .await?; + let result = gql_ctx + .services + .entry + .list_entries(crate::repository::traits::ListEntriesParams { + site_id: &self.id, + collection_slug: collection_slug.as_deref(), + collection_id: None, + status: status.as_deref(), + search: search.as_deref(), + published_only: !include_drafts, + page: page.unwrap_or(1).max(1), + per_page: per_page.unwrap_or(50).clamp(1, 200), + }) + .await + .map_err(|e| crate::graphql::service_error("site.entries", e))?; + Ok(super::entry::EntryConnection { + page_info: super::entry::PageInfo { + has_next_page: result.page.saturating_mul(result.per_page) < result.total, + has_previous_page: result.page > 1, + }, + total_count: result.total, + nodes: result.items.into_iter().map(super::entry::db_entry_to_gql).collect(), + }) + } + + async fn entry( + &self, + ctx: &async_graphql::Context<'_>, + id: String, + include_drafts: Option, + ) -> async_graphql::Result { + let gql_ctx = ctx.data::()?; + let include_drafts = include_drafts.unwrap_or(false); + gql_ctx + .require_site_action_for(&self.id, content_read_action(include_drafts)) + .await?; + let entry = gql_ctx + .services + .entry + .get_entry(&id, &self.id, !include_drafts) + .await + .map_err(|e| crate::graphql::service_error("site.entry", e))? + .ok_or_else(|| async_graphql::Error::new("Entry not found"))?; + Ok(super::entry::db_entry_to_gql(entry)) + } + + /// Revision history; revisions can hold unpublished content, so reading them + /// requires content.preview.read. + async fn entry_revisions( + &self, + ctx: &async_graphql::Context<'_>, + entry_id: String, + page: Option, + per_page: Option, + ) -> async_graphql::Result { + let gql_ctx = self.require_revision_access(ctx, &entry_id).await?; + let result = gql_ctx + .services + .entry + .list_revisions( + &entry_id, + &self.id, + page.unwrap_or(1).max(1), + per_page.unwrap_or(50).clamp(1, 200), + ) + .await + .map_err(|e| crate::graphql::service_error("site.entry_revisions", e))?; + Ok(super::entry::RevisionsListResult { + items: result + .items + .into_iter() + .map(|revision| super::entry::db_revision_to_gql(revision, None)) + .collect(), + total: result.total, + page: result.page, + per_page: result.per_page, + }) + } + + async fn entry_revision( + &self, + ctx: &async_graphql::Context<'_>, + entry_id: String, + revision_number: i64, + diff: Option, + ) -> async_graphql::Result { + let gql_ctx = self.require_revision_access(ctx, &entry_id).await?; + let entries = &gql_ctx.services.entry; + let revision = entries + .get_revision(&entry_id, &self.id, revision_number) + .await + .map_err(|e| crate::graphql::service_error("site.entry_revision", e))? + .ok_or_else(|| async_graphql::Error::new("Revision not found"))?; + let diff = if diff.unwrap_or(false) && revision_number > 1 { + match entries.get_revision(&entry_id, &self.id, revision_number - 1).await { + Ok(Some(previous)) => crate::utils::diff::compute_diff_for_revision(&revision, Some(&previous)), + _ => None, + } + } else { + None + }; + Ok(super::entry::db_revision_to_gql(revision, diff)) + } + + async fn singletons( + &self, + ctx: &async_graphql::Context<'_>, + include_drafts: Option, + ) -> async_graphql::Result> { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for( + &self.id, + if include_drafts.unwrap_or(false) { + crate::models::authorization::Action::ContentPreviewRead + } else { + crate::models::authorization::Action::ContentRead + }, + ) + .await?; + let items = gql_ctx + .services + .singleton + .list_singletons_visible(&self.id, !include_drafts.unwrap_or(false)) + .await + .map_err(|e| crate::graphql::service_error("site.singletons", e))?; + Ok(items.into_iter().map(super::collection::singleton_to_gql).collect()) + } + + async fn files( + &self, + ctx: &async_graphql::Context<'_>, + page: Option, + search: Option, + file_type: Option, + ) -> async_graphql::Result> { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::FilesRead) + .await?; + let result = gql_ctx + .services + .file + .list_files(crate::repository::traits::ListFilesParams { + site_id: &self.id, + trashed: false, + search: search.as_deref(), + file_type: file_type.as_deref(), + page: page.unwrap_or(1).max(1), + per_page: 50, + }) + .await + .map_err(|e| crate::graphql::service_error("site.files", e))?; + Ok(result + .items + .into_iter() + .map(|file| super::file::db_file_to_gql(file, gql_ctx)) + .collect()) + } + + async fn file(&self, ctx: &async_graphql::Context<'_>, id: String) -> async_graphql::Result { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::FilesRead) + .await?; + let file = gql_ctx + .services + .file + .get_file(&id, &self.id) + .await + .map_err(|e| crate::graphql::service_error("site.file", e))? + .ok_or_else(|| async_graphql::Error::new("File not found"))?; + Ok(super::file::db_file_to_gql(file, gql_ctx)) + } + + async fn file_references( + &self, + ctx: &async_graphql::Context<'_>, + file_id: String, + ) -> async_graphql::Result> { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::FilesRead) + .await?; + let files = &gql_ctx.services.file; + files + .get_file(&file_id, &self.id) + .await + .map_err(|e| crate::graphql::service_error("site.file_references", e))? + .ok_or_else(|| async_graphql::Error::new("File not found"))?; + Ok(files + .get_file_references(&file_id, &self.id) + .await + .map_err(|e| crate::graphql::service_error("site.file_references", e))? + .into_iter() + .map(|reference| super::file::FileReference { + entry_id: reference.entry_id, + collection_name: reference.collection_name, + field_name: reference.field_name, + }) + .collect()) + } +} + +impl Site { + async fn load_details(&self, ctx: &async_graphql::Context<'_>) -> async_graphql::Result { + if let Some(details) = &self.details { + return Ok(details.clone()); + } + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::SiteRead) + .await?; + let site = gql_ctx + .services + .site + .get_site(&self.id) + .await + .map_err(|e| crate::graphql::service_error("site", e))? + .ok_or_else(|| async_graphql::Error::new("Site not found"))?; + Ok(SiteDetails { + name: site.name, + created_at: site.created_at, + updated_at: site.updated_at, + }) + } + + async fn require_revision_access<'a>( + &self, + ctx: &async_graphql::Context<'a>, + entry_id: &str, + ) -> async_graphql::Result<&'a crate::graphql::context::GqlContext> { + let gql_ctx = ctx.data::()?; + gql_ctx + .require_site_action_for(&self.id, crate::models::authorization::Action::ContentPreviewRead) + .await?; + gql_ctx + .services + .entry + .get_entry(entry_id, &self.id, false) + .await + .map_err(|e| crate::graphql::service_error("site.entry", e))? + .ok_or_else(|| async_graphql::Error::new("Entry not found"))?; + Ok(gql_ctx) + } +} + +fn content_read_action(include_drafts: bool) -> crate::models::authorization::Action { + if include_drafts { + crate::models::authorization::Action::ContentPreviewRead + } else { + crate::models::authorization::Action::ContentRead + } +} diff --git a/apps/backend/src/graphql/types/webhook.rs b/apps/backend/src/graphql/types/webhook.rs deleted file mode 100644 index 9c9863b4..00000000 --- a/apps/backend/src/graphql/types/webhook.rs +++ /dev/null @@ -1,55 +0,0 @@ -use async_graphql::SimpleObject; -use std::collections::HashMap; - -#[derive(SimpleObject)] -pub struct SiteWebhook { - pub id: String, - pub site_id: String, - pub label: String, - pub url: String, - pub headers: Option, - pub created_by: Option, - pub created_at: String, - pub updated_at: String, -} - -#[derive(SimpleObject)] -pub struct WebhookDelivery { - pub id: String, - pub webhook_id: String, - pub status: String, - pub status_code: Option, - pub response_body: Option, - pub duration_ms: Option, - pub triggered_by: Option, - pub triggered_at: String, -} - -pub fn db_webhook_to_gql( - webhook: crate::models::webhook::SiteWebhook, - headers: HashMap, -) -> SiteWebhook { - SiteWebhook { - id: webhook.id, - site_id: webhook.site_id, - label: webhook.label, - url: webhook.url, - headers: Some(serde_json::to_string(&headers).unwrap_or_default()), - created_by: webhook.created_by, - created_at: webhook.created_at, - updated_at: webhook.updated_at, - } -} - -pub fn db_delivery_to_gql(delivery: crate::models::webhook::WebhookDelivery) -> WebhookDelivery { - WebhookDelivery { - id: delivery.id, - webhook_id: delivery.webhook_id, - status: delivery.status, - status_code: delivery.status_code, - response_body: delivery.response_body, - duration_ms: delivery.duration_ms, - triggered_by: delivery.triggered_by, - triggered_at: delivery.triggered_at, - } -} diff --git a/apps/backend/src/grpc/auth.rs b/apps/backend/src/grpc/auth.rs deleted file mode 100644 index 35b09ea2..00000000 --- a/apps/backend/src/grpc/auth.rs +++ /dev/null @@ -1,63 +0,0 @@ -use crate::config::Config; -use crate::grpc::interceptor::compute_key_hmac; - -/// Lightweight auth context extracted from the Bearer token. -/// This is inserted into request extensions by the interceptor. -#[derive(Clone, Debug)] -pub struct AuthContext { - pub hmac: String, -} - -/// Returned when a raw Bearer token fails format validation. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct InvalidToken; - -/// Parse and validate the raw Bearer token format. -/// -/// Returns an `AuthContext` on success, or `InvalidToken` on any validation failure. -pub fn parse_token(token: &str, config: &Config) -> Result { - if !token.starts_with("vcms_site_") { - return Err(InvalidToken); - } - - if token.len() <= "vcms_site_".len() { - return Err(InvalidToken); - } - let hmac = compute_key_hmac(token, &config.token_index_key); - - Ok(AuthContext { hmac }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_parse_token_valid() { - let config = Config { - token_index_key: "secret".to_string(), - ..Default::default() - }; - let token = "vcms_site_abc1234567890123456"; - let ctx = parse_token(token, &config).unwrap(); - assert_eq!(ctx.hmac.len(), 64); - } - - #[test] - fn test_parse_token_invalid_prefix() { - let config = Config { - token_index_key: "secret".to_string(), - ..Default::default() - }; - assert!(parse_token("not_cms_", &config).is_err()); - } - - #[test] - fn test_parse_token_too_short() { - let config = Config { - token_index_key: "secret".to_string(), - ..Default::default() - }; - assert!(parse_token("cms_", &config).is_err()); - } -} diff --git a/apps/backend/src/grpc/interceptor.rs b/apps/backend/src/grpc/interceptor.rs index d9de3f1d..a88d6671 100644 --- a/apps/backend/src/grpc/interceptor.rs +++ b/apps/backend/src/grpc/interceptor.rs @@ -1,111 +1,99 @@ use std::sync::Arc; -use hmac::digest::KeyInit; -use hmac::{Hmac, Mac}; -use sha2::Sha256; -use tracing::{Span, error}; - use crate::config::Config; -use crate::grpc::auth::{AuthContext, parse_token}; -use crate::models::access_token::{TokenScope, TokenScopes, decode_scopes, scopes_can_write}; +use crate::middleware::auth::Actor; +use crate::middleware::rate_limit::{ApiRateLimiter, RateLimiter}; +use crate::models::authorization::Action; use crate::repository::Repository; +use crate::services::authorization::AuthorizationService; +use crate::services::error::ServiceError; -pub type HmacSha256 = Hmac; - -pub fn compute_key_hmac(key: &str, hmac_secret: &str) -> String { - let mut mac = HmacSha256::new_from_slice(hmac_secret.as_bytes()).expect("HMAC can take key of any size"); - mac.update(key.as_bytes()); - hex::encode(mac.finalize().into_bytes()) +/// Raw bearer credential captured by [`AuthInterceptor`]. Verification needs the +/// database, so it happens lazily in [`get_auth_context`]. +#[derive(Clone)] +struct BearerToken { + token: String, + config: Arc, } +/// Verified caller of one RPC. #[derive(Clone, Debug)] pub struct GrpcAuthContext { - pub token_id: String, - pub site_id: String, - pub scopes: TokenScopes, + pub actor: Actor, } impl GrpcAuthContext { - pub fn can_write(&self) -> bool { - scopes_can_write(&self.scopes) - } - - pub fn require_scope(&self, scope: TokenScope) -> Result<(), tonic::Status> { - if self.scopes.contains(&scope) - || (scope == TokenScope::SiteRead && self.scopes.contains(&TokenScope::SiteSettingsRead)) - { - Ok(()) - } else { - Err(tonic::Status::permission_denied( - "Token scope does not permit this operation", - )) + /// Every site-scoped request names its site; a site key only reaches its own. + pub fn resolve_site_id(&self, requested: &str) -> Result { + if requested.is_empty() { + return Err(tonic::Status::invalid_argument("site_id is required")); + } + if self.actor.bound_site_id().is_some_and(|site_id| site_id != requested) { + return Err(tonic::Status::permission_denied( + "Token is not authorized for this site", + )); } + Ok(requested.to_string()) } - pub fn require_site_id(&self) -> Result<&str, tonic::Status> { - Ok(&self.site_id) + /// Authorize the site with both token scope and the user's live site/instance + /// role: the same policy REST, GraphQL and MCP apply. + pub async fn require_action( + &self, + repository: &Repository, + site_id: &str, + action: Action, + ) -> Result<(), tonic::Status> { + AuthorizationService::new(repository.user.clone()) + .require_site_action(&self.actor, site_id, action) + .await + .map_err(|error| match error { + ServiceError::NotFound(_) => tonic::Status::not_found("Site not found"), + ServiceError::InsufficientPermission(_) + | ServiceError::Forbidden(_) + | ServiceError::SiteTokenDenied => tonic::Status::permission_denied(error.error_message()), + _ => tonic::Status::internal("Authorization service unavailable"), + }) } } +/// Rate-limits each call by client address, then captures its bearer token. #[derive(Clone)] pub struct AuthInterceptor { config: Arc, + limiter: RateLimiter, } impl AuthInterceptor { pub fn new(config: Arc) -> Self { - Self { config } + let ApiRateLimiter(limiter) = ApiRateLimiter::from_config(&config); + Self { config, limiter } } } impl tonic::service::Interceptor for AuthInterceptor { fn call(&mut self, mut request: tonic::Request<()>) -> Result, tonic::Status> { + if !self.limiter.check(&self.limiter.grpc_client_key(&request)) { + return Err(tonic::Status::resource_exhausted( + "Too many requests. Please try again later.", + )); + } let token = request .metadata() .get("authorization") .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) - .ok_or_else(|| tonic::Status::unauthenticated("Missing access token"))?; - - let ctx = - parse_token(token, &self.config).map_err(|_| tonic::Status::unauthenticated("Invalid access token"))?; - - request.extensions_mut().insert(ctx); + .and_then(crate::middleware::auth::parse_bearer_header) + .filter(|token| token.starts_with("vcms_site_") || token.starts_with("vcms_pat_")) + .ok_or_else(|| tonic::Status::unauthenticated("Missing or malformed access token"))? + .to_owned(); + request.extensions_mut().insert(BearerToken { + token, + config: self.config.clone(), + }); Ok(request) } } -async fn validate_auth(ctx: &AuthContext, repository: &Repository) -> Result { - let key = repository.access_token.find_by_hmac(&ctx.hmac).await.map_err(|e| { - error!(error = ?e, "Database error during access token lookup"); - tonic::Status::internal("Authentication service unavailable") - })?; - let Some((key_id, site_id, _stored_hmac, expires_at, revoked_at, scopes_json, last_used_at)) = key else { - return Err(tonic::Status::unauthenticated("Invalid access token")); - }; - if revoked_at.is_some() { - return Err(tonic::Status::unauthenticated("Invalid access token")); - } - if expires_at - .as_deref() - .is_some_and(|value| !crate::middleware::auth::is_token_not_expired(Some(value))) - { - return Err(tonic::Status::unauthenticated("Access token has expired")); - } - if crate::middleware::auth::needs_touch(last_used_at.as_deref()) - && let Err(e) = repository.access_token.update_last_used(&key_id).await - { - tracing::warn!(error = ?e, key_id = %key_id, "Failed to update last_used"); - } - Span::current().record("site_id", tracing::field::display(&site_id)); - let scopes = decode_scopes(&scopes_json).map_err(|_| tonic::Status::unauthenticated("Invalid access token"))?; - Ok(GrpcAuthContext { - token_id: key_id, - site_id, - scopes, - }) -} - pub async fn get_auth_context( request: &mut tonic::Request, repository: &Repository, @@ -113,75 +101,21 @@ pub async fn get_auth_context( if let Some(ctx) = request.extensions().get::() { return Ok(ctx.clone()); } - - let auth_ctx = request + let bearer = request .extensions() - .get::() + .get::() + .cloned() .ok_or_else(|| tonic::Status::internal("Missing auth context"))?; - - let validated = validate_auth(auth_ctx, repository).await?; - request.extensions_mut().insert(validated.clone()); - Ok(validated) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_compute_key_hmac_consistency() { - let secret = "my_secret_key"; - let token = "cms_test_token"; - - let hmac1 = compute_key_hmac(token, secret); - let hmac2 = compute_key_hmac(token, secret); - - assert_eq!(hmac1, hmac2); - assert_eq!(hmac1.len(), 64); - } - - #[test] - fn test_compute_key_hmac_different_inputs() { - let secret = "my_secret_key"; - - let hmac1 = compute_key_hmac("token1", secret); - let hmac2 = compute_key_hmac("token2", secret); - - assert_ne!(hmac1, hmac2); - } - - #[test] - fn test_grpc_auth_context_permissions() { - let ctx = GrpcAuthContext { - token_id: "token123".to_string(), - site_id: "site123".to_string(), - scopes: [TokenScope::ContentWrite].into_iter().collect(), - }; - - assert!(ctx.can_write()); - assert!(ctx.require_scope(TokenScope::ContentWrite).is_ok()); - assert!(ctx.require_scope(TokenScope::ContentRead).is_err()); - } - - #[test] - fn test_grpc_auth_context_read_only() { - let ctx = GrpcAuthContext { - token_id: "token456".to_string(), - site_id: "site456".to_string(), - scopes: [TokenScope::ContentRead].into_iter().collect(), - }; - - assert!(!ctx.can_write()); - } - - #[test] - fn site_settings_read_satisfies_site_read() { - let ctx = GrpcAuthContext { - token_id: "token789".to_string(), - site_id: "site789".to_string(), - scopes: [TokenScope::SiteSettingsRead].into_iter().collect(), - }; - - assert!(ctx.require_scope(TokenScope::SiteRead).is_ok()); - } + let actor = crate::middleware::auth::verify_access_token(&bearer.token, repository, &bearer.config.token_index_key) + .await + .map_err(|(status, error)| { + if status.is_server_error() { + tonic::Status::internal("Authentication service unavailable") + } else { + tonic::Status::unauthenticated(error.0.message.clone()) + } + })?; + let ctx = GrpcAuthContext { actor }; + request.extensions_mut().insert(ctx.clone()); + Ok(ctx) } diff --git a/apps/backend/src/grpc/mod.rs b/apps/backend/src/grpc/mod.rs index b99ad9f3..6018ce47 100644 --- a/apps/backend/src/grpc/mod.rs +++ b/apps/backend/src/grpc/mod.rs @@ -1,13 +1,155 @@ -pub mod auth; pub mod cms; pub mod interceptor; pub mod server; -pub mod services { - pub mod admin_site; - pub mod admin_webhook; - pub mod collection; - pub mod entry; - pub mod file; - pub mod singleton; +/// Convert dynamic CMS JSON objects into protobuf's typed Struct representation. +pub fn json_to_struct(value: &serde_json::Value) -> Option { + let serde_json::Value::Object(object) = value else { + return None; + }; + Some(prost_types::Struct { + fields: object + .iter() + .map(|(key, value)| (key.clone(), json_to_value(value))) + .collect(), + }) +} + +pub fn struct_to_json(value: &prost_types::Struct) -> Result { + Ok(serde_json::Value::Object( + value + .fields + .iter() + .map(|(key, value)| Ok((key.clone(), value_to_json(value)?))) + .collect::>()?, + )) +} + +/// Parse stored JSON text (entry data, collection definitions) into a Struct. +pub fn json_text_to_struct(text: &str) -> Option { + serde_json::from_str::(text) + .ok() + .and_then(|value| json_to_struct(&value)) +} + +pub const DEFAULT_PAGE_SIZE: i64 = 50; +const MAX_PAGE_SIZE: i64 = 200; + +pub fn page_size(requested: i32) -> i64 { + if requested <= 0 { + DEFAULT_PAGE_SIZE + } else { + i64::from(requested).min(MAX_PAGE_SIZE) + } +} + +/// First page without a token, otherwise the page a token issued for this exact +/// query (same `fingerprint`) points at. +pub fn resolve_page(page_token: &str, fingerprint: &str, secret: &str) -> Result { + if page_token.is_empty() { + return Ok(1); + } + crate::utils::cursor::resolve_page(Some(page_token), fingerprint, secret).map_err(tonic::Status::invalid_argument) +} + +pub fn next_page_token(page: i64, per_page: i64, total: i64, fingerprint: String, secret: &str) -> String { + if page.saturating_mul(per_page) >= total { + return String::new(); + } + crate::utils::cursor::encode( + &crate::utils::cursor::PageCursor { + version: 1, + page: page + 1, + fingerprint, + }, + secret, + ) +} + +pub fn timestamp_from_text(value: &str) -> Option { + let datetime = crate::utils::timestamp::parse_db_timestamp(value)?; + Some(prost_types::Timestamp { + seconds: datetime.timestamp(), + nanos: datetime.timestamp_subsec_nanos() as i32, + }) +} + +fn value_to_json(value: &prost_types::Value) -> Result { + use prost_types::value::Kind; + Ok(match value.kind.as_ref() { + Some(Kind::NullValue(0)) => serde_json::Value::Null, + Some(Kind::NullValue(_)) | None => return Err(tonic::Status::invalid_argument("Invalid protobuf Value kind")), + Some(Kind::NumberValue(value)) => serde_json::Number::from_f64(*value) + .map(serde_json::Value::Number) + .ok_or_else(|| tonic::Status::invalid_argument("Content numbers must be finite"))?, + Some(Kind::StringValue(value)) => serde_json::Value::String(value.clone()), + Some(Kind::BoolValue(value)) => serde_json::Value::Bool(*value), + Some(Kind::StructValue(value)) => struct_to_json(value)?, + Some(Kind::ListValue(value)) => { + serde_json::Value::Array(value.values.iter().map(value_to_json).collect::>()?) + } + }) +} + +/// Preserve actionable domain failures without leaking infrastructure details. +pub fn service_error(error: impl Into) -> tonic::Status { + let error = error.into(); + let status = error.status_code(); + let code = match status { + http::StatusCode::UNAUTHORIZED => tonic::Code::Unauthenticated, + http::StatusCode::FORBIDDEN => tonic::Code::PermissionDenied, + http::StatusCode::NOT_FOUND => tonic::Code::NotFound, + http::StatusCode::CONFLICT => tonic::Code::AlreadyExists, + http::StatusCode::PRECONDITION_FAILED => tonic::Code::FailedPrecondition, + http::StatusCode::PAYLOAD_TOO_LARGE | http::StatusCode::TOO_MANY_REQUESTS => tonic::Code::ResourceExhausted, + http::StatusCode::BAD_REQUEST | http::StatusCode::UNPROCESSABLE_ENTITY => tonic::Code::InvalidArgument, + _ => tonic::Code::Internal, + }; + let message = if status.is_server_error() { + tracing::error!(error = ?error, "gRPC operation failed"); + "Internal server error".to_owned() + } else { + error.error_message() + }; + tonic::Status::new(code, message) +} + +fn json_to_value(value: &serde_json::Value) -> prost_types::Value { + use prost_types::value::Kind; + let kind = match value { + serde_json::Value::Null => Kind::NullValue(0), + serde_json::Value::Bool(value) => Kind::BoolValue(*value), + serde_json::Value::Number(value) => Kind::NumberValue(value.as_f64().unwrap_or_default()), + serde_json::Value::String(value) => Kind::StringValue(value.clone()), + serde_json::Value::Array(values) => Kind::ListValue(prost_types::ListValue { + values: values.iter().map(json_to_value).collect(), + }), + serde_json::Value::Object(values) => Kind::StructValue(prost_types::Struct { + fields: values + .iter() + .map(|(key, value)| (key.clone(), json_to_value(value))) + .collect(), + }), + }; + prost_types::Value { kind: Some(kind) } +} + +pub mod services; + +#[cfg(test)] +mod timestamp_tests { + use super::*; + #[test] + fn typed_timestamps_preserve_sqlite_and_postgres_instants() { + let canonical = timestamp_from_text("2026-09-16T08:00:00.123456Z").unwrap(); + for value in [ + "2026-09-16 08:00:00.123456", + "2026-09-16 08:00:00.123456+00", + "2026-09-16 13:30:00.123456+05:30", + ] { + assert_eq!(timestamp_from_text(value).unwrap(), canonical, "{value}"); + } + assert_eq!(canonical.nanos, 123456000); + assert!(timestamp_from_text("invalid").is_none()); + } } diff --git a/apps/backend/src/grpc/server.rs b/apps/backend/src/grpc/server.rs index ec45d671..3eea3cce 100644 --- a/apps/backend/src/grpc/server.rs +++ b/apps/backend/src/grpc/server.rs @@ -8,13 +8,13 @@ use tonic::transport::Server; use crate::config::Config; use crate::grpc::interceptor::AuthInterceptor; use crate::grpc::services::admin_site::SiteServiceImpl; -use crate::grpc::services::admin_webhook::WebhookServiceImpl; use crate::grpc::services::collection::CollectionServiceImpl; use crate::grpc::services::entry::EntryServiceImpl; use crate::grpc::services::file::FileServiceImpl; use crate::grpc::services::singleton::SingletonServiceImpl; use crate::repository::Repository; use crate::services::Services; +use crate::services::settings::SettingsService; use crate::storage::StorageRegistry; /// Boxed, pinned future returned by [`spawn_grpc_server`] (the gRPC server task). @@ -25,15 +25,22 @@ pub async fn start_grpc_server( repository: Arc, config: Arc, storage_registry: Arc, + settings: SettingsService, listener: tokio::net::TcpListener, shutdown: impl Future + Send + 'static, ) -> Result<(), Box> { let collection_svc = CollectionServiceImpl::new(services.collection.clone(), repository.clone()); - let entry_svc = EntryServiceImpl::new(services.entry.clone(), repository.clone()); - let singleton_svc = SingletonServiceImpl::new(services.singleton.clone(), storage_registry, repository.clone()); - let file_svc = FileServiceImpl::new(services.file.clone(), repository.clone()); + let entry_svc = EntryServiceImpl::new(services.entry.clone(), repository.clone(), config.clone()); + let singleton_svc = + SingletonServiceImpl::new(services.singleton.clone(), storage_registry.clone(), repository.clone()); + let file_svc = FileServiceImpl::new( + services.file.clone(), + repository.clone(), + storage_registry, + config.clone(), + settings, + ); let site_svc = SiteServiceImpl::new(services.site.clone(), repository.clone()); - let webhook_svc = WebhookServiceImpl::new(services.webhook.clone(), repository.clone()); let interceptor = AuthInterceptor::new(config.clone()); @@ -53,8 +60,6 @@ pub async fn start_grpc_server( crate::grpc::cms::v1::file_service_server::FileServiceServer::with_interceptor(file_svc, interceptor.clone()); let site_svc = crate::grpc::cms::v1::site_service_server::SiteServiceServer::with_interceptor(site_svc, interceptor.clone()); - let webhook_svc = - crate::grpc::cms::v1::webhook_service_server::WebhookServiceServer::with_interceptor(webhook_svc, interceptor); let reflection_service = tonic_reflection::server::Builder::configure() .register_encoded_file_descriptor_set(tonic::include_file_descriptor_set!("cms_descriptor")) @@ -78,9 +83,6 @@ pub async fn start_grpc_server( health_reporter .set_serving::>() .await; - health_reporter - .set_serving::>() - .await; Server::builder() .add_service(reflection_service) @@ -90,7 +92,6 @@ pub async fn start_grpc_server( .add_service(singleton_svc) .add_service(file_svc) .add_service(site_svc) - .add_service(webhook_svc) .serve_with_incoming_shutdown(TcpListenerStream::new(listener), shutdown) .await?; @@ -102,6 +103,7 @@ pub fn spawn_grpc_server( repository: Arc, config: Arc, storage_registry: Arc, + settings: SettingsService, listener: tokio::net::TcpListener, shutdown: Pin + Send>>, ) -> GrpcServerFuture { @@ -110,6 +112,7 @@ pub fn spawn_grpc_server( repository, config, storage_registry, + settings, listener, shutdown, )) diff --git a/apps/backend/src/grpc/services/admin_site.rs b/apps/backend/src/grpc/services/admin_site.rs index a07bd03b..be3c08ac 100644 --- a/apps/backend/src/grpc/services/admin_site.rs +++ b/apps/backend/src/grpc/services/admin_site.rs @@ -3,9 +3,9 @@ use std::sync::Arc; use tonic::{Request, Response, Status}; use crate::grpc::cms::v1::site_service_server::SiteService; -use crate::grpc::cms::v1::{GetSiteRequest, Site as ProtoSite, UpdateSiteRequest}; -use crate::grpc::interceptor::{GrpcAuthContext, get_auth_context}; -use crate::models::access_token::TokenScope; +use crate::grpc::cms::v1::{GetSiteRequest, ListSitesRequest, ListSitesResponse, Site as ProtoSite}; +use crate::grpc::interceptor::get_auth_context; +use crate::models::authorization::Action; use crate::models::site::Site; use crate::repository::Repository; use crate::services::site::SiteService as AppSiteService; @@ -25,45 +25,52 @@ impl SiteServiceImpl { } } -fn ensure_same_site(auth: &GrpcAuthContext, site_id: &str) -> Result<(), Status> { - if auth.require_site_id()? == site_id { - Ok(()) - } else { - Err(Status::permission_denied( - "Site token does not have access to this site", - )) - } -} - #[tonic::async_trait] impl SiteService for SiteServiceImpl { - async fn get_site(&self, mut request: Request) -> Result, Status> { + async fn list_sites(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SiteRead)?; - let site_id = request.into_inner().site_id; - ensure_same_site(&auth, &site_id)?; - - let site = self + let scopes = match &auth.actor { + crate::middleware::auth::Actor::ApiKey(key) => &key.scopes, + crate::middleware::auth::Actor::PersonalToken(token) => &token.scopes, + crate::middleware::auth::Actor::User(_) => { + return Err(Status::unauthenticated("An access token is required")); + } + }; + if !crate::middleware::auth::scopes_allow_action(scopes, Action::SiteRead) { + return Err(Status::permission_denied("Token requires the 'site.read' scope.")); + } + let sites = self .app_site_service - .get_site(&site_id) + .list_sites_for_actor(&auth.actor) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? - .ok_or_else(|| Status::not_found("Site not found"))?; - - Ok(Response::new(ProtoSite::from(site))) + .map_err(crate::grpc::service_error)? + .into_iter() + .filter_map(|site| { + let text = |key: &str| site.get(key).and_then(|value| value.as_str()); + Some(ProtoSite { + id: text("id")?.to_owned(), + name: text("name")?.to_owned(), + created_at: text("created_at").and_then(crate::grpc::timestamp_from_text), + updated_at: text("updated_at").and_then(crate::grpc::timestamp_from_text), + }) + }) + .collect(); + Ok(Response::new(ListSitesResponse { sites })) } - async fn update_site(&self, mut request: Request) -> Result, Status> { + async fn get_site(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SiteSettingsWrite)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; + let site_id = request.into_inner().site_id; + let site_id = auth.resolve_site_id(&site_id)?; + auth.require_action(&self.repository, &site_id, Action::SiteRead) + .await?; let site = self .app_site_service - .update_site(&req.site_id, req.name.as_deref()) + .get_site(&site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)? + .ok_or_else(|| Status::not_found("Site not found"))?; Ok(Response::new(ProtoSite::from(site))) } @@ -74,10 +81,8 @@ impl From for ProtoSite { Self { id: site.id, name: site.name, - storage_provider: site.storage_provider, - created_by: site.created_by, - created_at: site.created_at, - updated_at: site.updated_at, + created_at: crate::grpc::timestamp_from_text(&site.created_at), + updated_at: crate::grpc::timestamp_from_text(&site.updated_at), } } } diff --git a/apps/backend/src/grpc/services/admin_webhook.rs b/apps/backend/src/grpc/services/admin_webhook.rs deleted file mode 100644 index 9790830d..00000000 --- a/apps/backend/src/grpc/services/admin_webhook.rs +++ /dev/null @@ -1,249 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use tonic::{Request, Response, Status}; - -use crate::grpc::cms::v1::webhook_service_server::WebhookService; -use crate::grpc::cms::v1::{ - CreateWebhookRequest, DeleteWebhookRequest, GetWebhookRequest, ListWebhookDeliveriesRequest, - ListWebhookDeliveriesResponse, ListWebhooksRequest, ListWebhooksResponse, SiteWebhook as ProtoSiteWebhook, - TriggerWebhookRequest, UpdateWebhookRequest, WebhookDelivery as ProtoWebhookDelivery, -}; -use crate::grpc::interceptor::{GrpcAuthContext, get_auth_context}; -use crate::models::access_token::TokenScope; -use crate::models::webhook::WebhookDelivery; -use crate::repository::Repository; -use crate::services::webhook::WebhookService as AppWebhookService; - -#[derive(Clone)] -pub struct WebhookServiceImpl { - app_webhook_service: Arc, - repository: Arc, -} - -impl WebhookServiceImpl { - pub fn new(webhook_service: Arc, repository: Arc) -> Self { - Self { - app_webhook_service: webhook_service, - repository, - } - } -} - -fn ensure_same_site(auth: &GrpcAuthContext, site_id: &str) -> Result<(), Status> { - if auth.require_site_id()? == site_id { - Ok(()) - } else { - Err(Status::permission_denied( - "Site token does not have access to this site", - )) - } -} - -#[tonic::async_trait] -impl WebhookService for WebhookServiceImpl { - async fn list_webhooks( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksRead)?; - let site_id = request.into_inner().site_id; - ensure_same_site(&auth, &site_id)?; - - let webhooks = self - .app_webhook_service - .list_webhooks(&site_id) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(ListWebhooksResponse { - webhooks: webhooks.into_iter().map(ProtoSiteWebhook::from).collect(), - })) - } - - async fn get_webhook(&self, mut request: Request) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksRead)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let webhook = self - .app_webhook_service - .get_webhook(&req.webhook_id, &req.site_id) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? - .ok_or_else(|| Status::not_found("Webhook not found"))?; - - let headers = self.app_webhook_service.decrypt_webhook_headers(&webhook); - Ok(Response::new(ProtoSiteWebhook::from_model(webhook, headers))) - } - - async fn create_webhook( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksWrite)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let created_by = None; - let headers: HashMap = req.headers.into_iter().collect(); - - let webhook = self - .app_webhook_service - .create_webhook(&req.site_id, &req.label, &req.url, &headers, created_by) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(ProtoSiteWebhook::from_model(webhook, headers))) - } - - async fn update_webhook( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksWrite)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let headers: Option> = if req.headers.is_empty() { - None - } else { - Some(req.headers.into_iter().collect()) - }; - - let webhook = self - .app_webhook_service - .update_webhook( - &req.webhook_id, - &req.site_id, - req.label.as_deref(), - req.url.as_deref(), - headers.as_ref(), - ) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - let decrypted_headers = self.app_webhook_service.decrypt_webhook_headers(&webhook); - Ok(Response::new(ProtoSiteWebhook::from_model(webhook, decrypted_headers))) - } - - async fn delete_webhook( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksWrite)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let deleted = self - .app_webhook_service - .delete_webhook(&req.webhook_id, &req.site_id) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(crate::grpc::cms::v1::DeleteResponse { - success: deleted > 0, - message: if deleted > 0 { - "Webhook deleted".to_string() - } else { - "Webhook not found".to_string() - }, - })) - } - - async fn trigger_webhook( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksTrigger)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let triggered_by = None; - - let delivery = self - .app_webhook_service - .trigger_webhook(&req.webhook_id, &req.site_id, triggered_by) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(ProtoWebhookDelivery::from(delivery))) - } - - async fn list_webhook_deliveries( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::WebhooksRead)?; - let req = request.into_inner(); - ensure_same_site(&auth, &req.site_id)?; - - let page = req.page.max(1); - let per_page = req.per_page.clamp(1, 100); - - let (deliveries, total) = self - .app_webhook_service - .list_deliveries(&req.webhook_id, &req.site_id, page, per_page) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(ListWebhookDeliveriesResponse { - items: deliveries.into_iter().map(ProtoWebhookDelivery::from).collect(), - total, - page, - per_page, - })) - } -} - -impl ProtoSiteWebhook { - fn from_model(model: crate::models::webhook::SiteWebhook, headers: HashMap) -> Self { - Self { - id: model.id, - site_id: model.site_id, - label: model.label, - url: model.url, - headers, - created_by: model.created_by, - created_at: model.created_at, - updated_at: model.updated_at, - } - } -} - -impl From for ProtoSiteWebhook { - fn from(model: crate::models::webhook::SiteWebhook) -> Self { - Self { - id: model.id, - site_id: model.site_id, - label: model.label, - url: model.url, - headers: HashMap::new(), - created_by: model.created_by, - created_at: model.created_at, - updated_at: model.updated_at, - } - } -} - -impl From for ProtoWebhookDelivery { - fn from(delivery: WebhookDelivery) -> Self { - Self { - id: delivery.id, - webhook_id: delivery.webhook_id, - status: delivery.status, - status_code: delivery.status_code, - response_body: delivery.response_body, - duration_ms: delivery.duration_ms, - triggered_by: delivery.triggered_by, - triggered_at: delivery.triggered_at, - } - } -} diff --git a/apps/backend/src/grpc/services/collection.rs b/apps/backend/src/grpc/services/collection.rs index e1198ef9..8e7b2cc5 100644 --- a/apps/backend/src/grpc/services/collection.rs +++ b/apps/backend/src/grpc/services/collection.rs @@ -4,11 +4,10 @@ use tonic::{Request, Response, Status}; use crate::grpc::cms::v1::collection_service_server::CollectionService; use crate::grpc::cms::v1::{ - Collection as ProtoCollection, CreateCollectionRequest, DeleteCollectionRequest, DeleteResponse, - GetCollectionRequest, ListCollectionsRequest, ListCollectionsResponse, UpdateCollectionRequest, + Collection as ProtoCollection, GetCollectionRequest, ListCollectionsRequest, ListCollectionsResponse, }; use crate::grpc::interceptor::get_auth_context; -use crate::models::access_token::TokenScope; +use crate::models::authorization::Action; use crate::models::collection::Collection; use crate::repository::Repository; use crate::services::collection::CollectionService as AppCollectionService; @@ -35,14 +34,16 @@ impl CollectionService for CollectionServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SchemaRead)?; - let site_id = auth.require_site_id()?.to_string(); + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::SchemaRead) + .await?; let collections = self .app_collection_service .list_collections(&site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; let response = ListCollectionsResponse { collections: collections @@ -59,114 +60,34 @@ impl CollectionService for CollectionServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SchemaRead)?; - let site_id = auth.require_site_id()?.to_string(); - let slug = &request.into_inner().slug; - - let collection = self - .app_collection_service - .get_collection(&site_id, slug) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? - .ok_or_else(|| Status::not_found("Collection not found"))?; - - Ok(Response::new(CollectionServiceImpl::collection_to_proto(collection))) - } - - async fn create_collection( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SchemaWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); - - let definition = if req.definition.is_empty() { - "{}".to_string() - } else { - req.definition - }; + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::SchemaRead) + .await?; + let slug = &req.slug; let collection = self .app_collection_service - .create_collection(&site_id, &req.name, &req.slug, &definition, req.is_singleton) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(CollectionServiceImpl::collection_to_proto(collection))) - } - - async fn update_collection( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SchemaWrite)?; - let site_id = auth.require_site_id()?.to_string(); - - let req = request.into_inner(); - - let existing = self - .app_collection_service - .get_by_id(&req.id) + .get_collection(&site_id, slug) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Collection not found"))?; - let collection = self - .app_collection_service - .update_collection( - &site_id, - &existing.slug, - req.name.as_deref(), - req.slug.as_deref(), - req.definition.as_deref(), - ) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - Ok(Response::new(CollectionServiceImpl::collection_to_proto(collection))) } - - async fn delete_collection( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::SchemaWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); - - let deleted = self - .app_collection_service - .delete_collection(&site_id, &req.slug) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(DeleteResponse { - success: deleted > 0, - message: if deleted > 0 { - "Collection deleted".to_string() - } else { - "Collection not found".to_string() - }, - })) - } } impl CollectionServiceImpl { fn collection_to_proto(c: Collection) -> ProtoCollection { ProtoCollection { + definition: crate::grpc::json_text_to_struct(&c.definition), + created_at: crate::grpc::timestamp_from_text(&c.created_at), + updated_at: crate::grpc::timestamp_from_text(&c.updated_at), id: c.id, site_id: c.site_id, name: c.name, slug: c.slug, - definition: c.definition, is_singleton: c.is_singleton, - created_at: c.created_at, - updated_at: c.updated_at, } } } diff --git a/apps/backend/src/grpc/services/entry.rs b/apps/backend/src/grpc/services/entry.rs index 6b26ab40..6e11dc0f 100644 --- a/apps/backend/src/grpc/services/entry.rs +++ b/apps/backend/src/grpc/services/entry.rs @@ -1,5 +1,6 @@ use std::sync::Arc; +use crate::config::Config; use tonic::{Request, Response, Status}; use crate::grpc::cms::v1::entry_service_server::EntryService; @@ -10,7 +11,7 @@ use crate::grpc::cms::v1::{ UpdateEntryRequest, }; use crate::grpc::interceptor::get_auth_context; -use crate::models::access_token::TokenScope; +use crate::models::authorization::Action; use crate::models::entry::{Entry, EntryRevision}; use crate::repository::Repository; use crate::repository::traits::ListEntriesParams; @@ -21,13 +22,15 @@ use crate::services::entry::UpdateEntryInput; pub struct EntryServiceImpl { app_entry_service: Arc, repository: Arc, + config: Arc, } impl EntryServiceImpl { - pub fn new(entry_service: Arc, repository: Arc) -> Self { + pub fn new(entry_service: Arc, repository: Arc, config: Arc) -> Self { Self { app_entry_service: entry_service, repository, + config, } } } @@ -39,33 +42,56 @@ impl EntryService for EntryServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentRead)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); - + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action( + &self.repository, + &site_id, + if req.include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await?; + + let per_page = crate::grpc::page_size(req.page_size); + let fingerprint = crate::utils::cursor::fingerprint(&( + &site_id, + &req.collection_id, + &req.status, + &req.search, + req.include_drafts, + per_page, + )); + let page = crate::grpc::resolve_page(&req.page_token, &fingerprint, &self.config.token_index_key)?; let params = ListEntriesParams { site_id: &site_id, collection_slug: None, collection_id: req.collection_id.as_deref(), status: req.status.as_deref(), search: req.search.as_deref(), - published_only: false, - page: req.page, - per_page: req.per_page, + published_only: !req.include_drafts, + page, + per_page, }; let result = self .app_entry_service .list_entries(params) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; let response = ListEntriesResponse { + next_page_token: crate::grpc::next_page_token( + result.page, + result.per_page, + result.total, + fingerprint, + &self.config.token_index_key, + ), + total_size: result.total, items: result.items.into_iter().map(ProtoEntry::from).collect(), - total: result.total, - page: result.page, - per_page: result.per_page, }; Ok(Response::new(response)) @@ -73,15 +99,25 @@ impl EntryService for EntryServiceImpl { async fn get_entry(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentRead)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action( + &self.repository, + &site_id, + if req.include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await?; + let id = req.id; let entry = self .app_entry_service - .get_entry(&id, &site_id, false) + .get_entry(&id, &site_id, !req.include_drafts) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Entry not found"))?; Ok(Response::new(ProtoEntry::from(entry))) @@ -89,28 +125,36 @@ impl EntryService for EntryServiceImpl { async fn create_entry(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); - let data: serde_json::Value = serde_json::from_str(&req.data).unwrap_or_default(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentWrite) + .await?; + let data = crate::grpc::struct_to_json( + req.data + .as_ref() + .ok_or_else(|| Status::invalid_argument("data is required"))?, + )?; let entry = self .app_entry_service - .create_entry(&site_id, &req.collection_id, &data, &req.slug, None) + .create_entry(&site_id, &req.collection_id, &data, &req.slug, auth.actor.user_id()) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoEntry::from(entry))) } async fn update_entry(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); - let data: Option = req.data.as_ref().map(|d| serde_json::from_str(d).unwrap_or_default()); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentWrite) + .await?; + if req.status.is_some() { + auth.require_action(&self.repository, &site_id, Action::ContentPublish) + .await?; + } + let data = req.data.as_ref().map(crate::grpc::struct_to_json).transpose()?; let entry = self .app_entry_service @@ -120,48 +164,53 @@ impl EntryService for EntryServiceImpl { data: data.as_ref(), slug: req.slug.as_deref(), status: req.status.as_deref(), - created_by: None, + created_by: auth.actor.user_id(), change_summary: req.change_summary.as_deref(), + expected_version: if req.expected_version.is_empty() { + None + } else { + Some(req.expected_version.as_str()) + }, }) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoEntry::from(entry))) } async fn delete_entry(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentWrite) + .await?; + let id = req.id; let deleted = self .app_entry_service .delete_entry(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; - Ok(Response::new(DeleteResponse { - success: deleted > 0, - message: if deleted > 0 { - "Entry deleted".to_string() - } else { - "Entry not found".to_string() - }, - })) + if deleted == 0 { + return Err(Status::not_found("Entry not found")); + } + Ok(Response::new(DeleteResponse { deleted: true })) } async fn publish_entry(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentPublish) + .await?; + let id = req.id; let entry = self .app_entry_service .publish_entry(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoEntry::from(entry))) } @@ -171,15 +220,17 @@ impl EntryService for EntryServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentPublish) + .await?; + let id = req.id; let entry = self .app_entry_service .unpublish_entry(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoEntry::from(entry))) } @@ -189,36 +240,37 @@ impl EntryService for EntryServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentRead)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentPreviewRead) + .await?; // Verify entry exists and belongs to site self.app_entry_service .get_entry(&req.entry_id, &site_id, false) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Entry not found"))?; - let page_val = req.page.max(1); - let per_page_val = if req.per_page <= 0 { - 50 - } else { - req.per_page.clamp(1, 200) - }; - + let per_page = crate::grpc::page_size(req.page_size); + let fingerprint = crate::utils::cursor::fingerprint(&(&site_id, &req.entry_id, per_page)); + let page = crate::grpc::resolve_page(&req.page_token, &fingerprint, &self.config.token_index_key)?; let result = self .app_entry_service - .list_revisions(&req.entry_id, &site_id, page_val, per_page_val) + .list_revisions(&req.entry_id, &site_id, page, per_page) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; let response = ListEntryRevisionsResponse { + next_page_token: crate::grpc::next_page_token( + result.page, + result.per_page, + result.total, + fingerprint, + &self.config.token_index_key, + ), + total_size: result.total, items: result.items.into_iter().map(ProtoEntryRevision::from).collect(), - total: result.total, - page: result.page, - per_page: result.per_page, }; Ok(Response::new(response)) @@ -229,23 +281,23 @@ impl EntryService for EntryServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentRead)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentPreviewRead) + .await?; // Verify entry exists and belongs to site self.app_entry_service .get_entry(&req.entry_id, &site_id, false) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Entry not found"))?; let revision = self .app_entry_service .get_revision(&req.entry_id, &site_id, req.revision_number) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Revision not found"))?; Ok(Response::new(ProtoEntryRevision::from(revision))) @@ -256,22 +308,22 @@ impl EntryService for EntryServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentWrite) + .await?; self.app_entry_service .get_entry(&req.entry_id, &site_id, false) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("Entry not found"))?; let entry = self .app_entry_service - .restore_revision(&req.entry_id, &site_id, req.revision_number, None) + .restore_revision(&req.entry_id, &site_id, req.revision_number, auth.actor.user_id()) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoEntry::from(entry))) } @@ -280,16 +332,17 @@ impl EntryService for EntryServiceImpl { impl From for ProtoEntry { fn from(e: Entry) -> Self { ProtoEntry { + data: crate::grpc::json_text_to_struct(&e.data), + created_at: crate::grpc::timestamp_from_text(&e.created_at), + updated_at: crate::grpc::timestamp_from_text(&e.updated_at), + published_at: e.published_at.as_deref().and_then(crate::grpc::timestamp_from_text), id: e.id, site_id: e.site_id, collection_id: e.collection_id, - data: e.data, slug: e.slug, status: e.status, singleton_collection_id: e.singleton_collection_id, - created_at: e.created_at, - updated_at: e.updated_at, - published_at: e.published_at, + version: e.version, } } } @@ -297,12 +350,12 @@ impl From for ProtoEntry { impl From for ProtoEntryRevision { fn from(r: EntryRevision) -> Self { ProtoEntryRevision { + data: crate::grpc::json_to_struct(&r.data.0), + created_at: crate::grpc::timestamp_from_text(&r.created_at), id: r.id, entry_id: r.entry_id, revision_number: r.revision_number, - data: serde_json::to_string(&r.data.0).unwrap_or_default(), created_by: r.created_by, - created_at: r.created_at, change_summary: r.change_summary, } } diff --git a/apps/backend/src/grpc/services/file.rs b/apps/backend/src/grpc/services/file.rs index ae4f461c..ccad7d1d 100644 --- a/apps/backend/src/grpc/services/file.rs +++ b/apps/backend/src/grpc/services/file.rs @@ -1,47 +1,81 @@ use std::sync::Arc; +use futures_util::StreamExt; use tonic::{Request, Response, Status}; +use crate::config::Config; use crate::grpc::cms::v1::File as ProtoFile; use crate::grpc::cms::v1::file_service_server::FileService; use crate::grpc::cms::v1::{ - BatchDeleteFilesRequest, BatchOperationResponse, BatchRestoreFilesRequest, DeleteFileRequest, DeleteResponse, - GetFileRequest, ListFilesRequest, ListFilesResponse, RestoreFileRequest, + DeleteFileRequest, DeleteResponse, FileReference as ProtoFileReference, GetFileRequest, ListFileReferencesRequest, + ListFileReferencesResponse, ListFilesRequest, ListFilesResponse, RestoreFileRequest, }; use crate::grpc::interceptor::get_auth_context; -use crate::models::access_token::TokenScope; -use crate::models::file::File; +use crate::models::authorization::Action; +use crate::models::file::{File, FileWithUrl}; use crate::repository::Repository; use crate::repository::traits::ListFilesParams; use crate::services::file::FileService as AppFileService; +use crate::services::file::StreamingUploadRequest; +use crate::services::settings::SettingsService; +use crate::storage::{StorageProvider, StorageRegistry}; #[derive(Clone)] pub struct FileServiceImpl { app_file_service: Arc, repository: Arc, + storage_registry: Arc, + config: Arc, + settings: SettingsService, } impl FileServiceImpl { - pub fn new(file_service: Arc, repository: Arc) -> Self { + pub fn new( + file_service: Arc, + repository: Arc, + storage_registry: Arc, + config: Arc, + settings: SettingsService, + ) -> Self { Self { app_file_service: file_service, repository, + storage_registry, + config, + settings, } } + + async fn storage(&self, site_id: &str) -> Result, Status> { + let profile = self + .repository + .file + .get_storage_provider(site_id) + .await + .map_err(|_| Status::internal("Storage configuration unavailable"))?; + self.storage_registry + .get(&profile) + .map(|storage| storage as Arc) + .ok_or_else(|| Status::internal("Storage provider not configured")) + } + + fn to_proto(&self, file: &File, storage: &dyn StorageProvider) -> ProtoFile { + ProtoFile::from(self.app_file_service.file_to_with_url(file, storage)) + } } #[tonic::async_trait] impl FileService for FileServiceImpl { async fn list_files(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesRead)?; - let site_id = auth.require_site_id()?.to_string(); - let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesRead) + .await?; - let page_val = if req.page <= 0 { 1 } else { req.page }; - let per_page_val = if req.per_page <= 0 { 30 } else { req.per_page }; - + let per_page_val = crate::grpc::page_size(req.page_size); + let fingerprint = crate::utils::cursor::fingerprint(&(&site_id, &req.search, &req.file_type, per_page_val)); + let page_val = crate::grpc::resolve_page(&req.page_token, &fingerprint, &self.config.token_index_key)?; let params = ListFilesParams { site_id: &site_id, trashed: false, @@ -55,13 +89,19 @@ impl FileService for FileServiceImpl { .app_file_service .list_files(params) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; + let storage = self.storage(&site_id).await?; let response = ListFilesResponse { - files: result.items.into_iter().map(ProtoFile::from).collect(), - total: result.total, - page: result.page, - per_page: result.per_page, + next_page_token: crate::grpc::next_page_token( + result.page, + result.per_page, + result.total, + fingerprint, + &self.config.token_index_key, + ), + total_size: result.total, + files: result.items.iter().map(|file| self.to_proto(file, &*storage)).collect(), }; Ok(Response::new(response)) @@ -69,53 +109,163 @@ impl FileService for FileServiceImpl { async fn get_file(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesRead)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesRead) + .await?; + let id = req.id; let file = self .app_file_service .get_file(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::not_found("File not found"))?; + let storage = self.storage(&site_id).await?; + Ok(Response::new(self.to_proto(&file, &*storage))) + } + + async fn list_file_references( + &self, + mut request: Request, + ) -> Result, Status> { + let auth = get_auth_context(&mut request, &self.repository).await?; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesRead) + .await?; + self.app_file_service + .get_file(&req.id, &site_id) + .await + .map_err(crate::grpc::service_error)? + .ok_or_else(|| Status::not_found("File not found"))?; + let references = self + .app_file_service + .get_file_references(&req.id, &site_id) + .await + .map_err(crate::grpc::service_error)? + .into_iter() + .map(|reference| ProtoFileReference { + entry_id: reference.entry_id, + collection_name: reference.collection_name, + field_name: reference.field_name, + }) + .collect(); + Ok(Response::new(ListFileReferencesResponse { references })) + } + + async fn upload_file( + &self, + mut request: Request>, + ) -> Result, Status> { + let auth = get_auth_context(&mut request, &self.repository).await?; + let mut stream = request.into_inner(); + let first = tokio::time::timeout(std::time::Duration::from_secs(30), stream.message()) + .await + .map_err(|_| Status::deadline_exceeded("Upload stream idle timeout"))? + .map_err(|_| Status::invalid_argument("Unable to read upload stream"))? + .ok_or_else(|| Status::invalid_argument("Upload stream is empty"))?; + let site_id = auth.resolve_site_id(&first.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesWrite) + .await?; + if first.filename.trim().is_empty() || first.content_type.trim().is_empty() { + return Err(Status::invalid_argument( + "filename and content_type are required in the first upload message", + )); + } + if !crate::utils::content_types::is_allowed(&first.content_type) { + return Err(Status::invalid_argument("content_type is not allowed")); + } + let storage = self.storage(&site_id).await?; + + let storage_kind = self + .repository + .site + .get_by_id(&site_id) + .await + .map_err(|_| Status::internal("Storage configuration unavailable"))? + .ok_or_else(|| Status::not_found("Site not found"))? + .storage_provider; + let filename = first.filename.clone(); + let content_type = first.content_type.clone(); + let first_chunk = bytes::Bytes::from(first.chunk); + let expected_site = site_id.clone(); + let expected_filename = filename.clone(); + let expected_type = content_type.clone(); + let remaining = + tokio_stream::StreamExt::timeout(stream, std::time::Duration::from_secs(30)).map(move |message| { + let message = message + .map_err(|_| Status::deadline_exceeded("Upload stream idle timeout")) + .and_then(|message| message) + .and_then(|request| { + if (!request.site_id.is_empty() && request.site_id != expected_site) + || (!request.filename.is_empty() && request.filename != expected_filename) + || (!request.content_type.is_empty() && request.content_type != expected_type) + { + Err(Status::invalid_argument("Upload metadata changed between chunks")) + } else { + Ok(bytes::Bytes::from(request.chunk)) + } + }); + message.map_err(|error| Box::new(error) as Box) + }); + let chunks = + futures_util::stream::once(async move { Ok::<_, Box>(first_chunk) }) + .chain(remaining); + let file = self + .app_file_service + .upload_file_streaming( + StreamingUploadRequest { + site_id: &site_id, + file_id: None, + filename: &filename, + content_type: &content_type, + created_by: auth.actor.user_id(), + storage, + storage_provider: &storage_kind, + max_bytes: self.settings.current().general.upload_limit_mb * 1024 * 1024, + }, + chunks, + ) + .await + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoFile::from(file))) } async fn delete_file(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesWrite) + .await?; + let id = req.id; let deleted = self .app_file_service .soft_delete(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(DeleteResponse { - success: deleted > 0, - message: if deleted > 0 { - "File deleted".to_string() - } else { - "File not found".to_string() - }, - })) + .map_err(crate::grpc::service_error)?; + + if deleted == 0 { + return Err(Status::not_found("File not found")); + } + Ok(Response::new(DeleteResponse { deleted: true })) } async fn restore_file(&self, mut request: Request) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let id = request.into_inner().id; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::FilesWrite) + .await?; + let id = req.id; let restored = self .app_file_service .restore(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; if restored == 0 { return Err(Status::not_found("File not found or not deleted")); @@ -125,70 +275,30 @@ impl FileService for FileServiceImpl { .app_file_service .get_file(&id, &site_id) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))? + .map_err(crate::grpc::service_error)? .ok_or_else(|| Status::internal("File not found after restore"))?; - Ok(Response::new(ProtoFile::from(file))) - } - - async fn batch_delete_files( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let ids = request.into_inner().ids; - - let affected = self - .app_file_service - .batch_soft_delete(&site_id, &ids) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(BatchOperationResponse { - affected: affected as i64, - })) - } - - async fn batch_restore_files( - &self, - mut request: Request, - ) -> Result, Status> { - let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::FilesWrite)?; - let site_id = auth.require_site_id()?.to_string(); - let ids = request.into_inner().ids; - - let affected = self - .app_file_service - .batch_restore(&site_id, &ids) - .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; - - Ok(Response::new(BatchOperationResponse { - affected: affected as i64, - })) + let storage = self.storage(&site_id).await?; + Ok(Response::new(self.to_proto(&file, &*storage))) } } -impl From for ProtoFile { - fn from(f: File) -> Self { +impl From for ProtoFile { + fn from(f: FileWithUrl) -> Self { ProtoFile { + deleted_at: f.deleted_at.as_deref().and_then(crate::grpc::timestamp_from_text), + created_at: crate::grpc::timestamp_from_text(&f.created_at), id: f.id, site_id: f.site_id, filename: f.filename, original_name: f.original_name, mime_type: f.mime_type, size: f.size, - storage_provider: f.storage_provider, - storage_key: f.storage_key, - thumbnail_key: f.thumbnail_key, + url: f.url, + thumbnail_url: f.thumbnail_url, width: f.width, height: f.height, - deleted_at: f.deleted_at, created_by: f.created_by, - created_at: f.created_at, } } } diff --git a/apps/backend/src/grpc/services/mod.rs b/apps/backend/src/grpc/services/mod.rs index 481b997a..1a40e869 100644 --- a/apps/backend/src/grpc/services/mod.rs +++ b/apps/backend/src/grpc/services/mod.rs @@ -1,6 +1,4 @@ -pub mod admin_membership; pub mod admin_site; -pub mod admin_token; pub mod collection; pub mod entry; pub mod file; diff --git a/apps/backend/src/grpc/services/singleton.rs b/apps/backend/src/grpc/services/singleton.rs index 67a4db76..4a7cd1d5 100644 --- a/apps/backend/src/grpc/services/singleton.rs +++ b/apps/backend/src/grpc/services/singleton.rs @@ -3,12 +3,15 @@ use std::sync::Arc; use tonic::{Request, Response, Status}; use crate::grpc::cms::v1::singleton_service_server::SingletonService; -use crate::grpc::cms::v1::{GetSingletonRequest, Singleton as ProtoSingleton, UpdateSingletonRequest}; +use crate::grpc::cms::v1::{ + GetSingletonRequest, ListSingletonsRequest, ListSingletonsResponse, Singleton as ProtoSingleton, + UpdateSingletonRequest, +}; use crate::grpc::interceptor::get_auth_context; -use crate::models::access_token::TokenScope; +use crate::models::authorization::Action; use crate::repository::Repository; use crate::services::singleton::SingletonService as AppSingletonService; -use crate::storage::{STORAGE_KIND_FILESYSTEM, StorageProvider, StorageRegistry}; +use crate::storage::{StorageProvider, StorageRegistry}; #[derive(Clone)] pub struct SingletonServiceImpl { @@ -33,26 +36,68 @@ impl SingletonServiceImpl { #[tonic::async_trait] impl SingletonService for SingletonServiceImpl { + async fn list_singletons( + &self, + mut request: Request, + ) -> Result, Status> { + let auth = get_auth_context(&mut request, &self.repository).await?; + let site_id = auth.resolve_site_id(&request.get_ref().site_id)?; + auth.require_action( + &self.repository, + &site_id, + if request.get_ref().include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await?; + let values = self + .app_singleton_service + .list_singletons_visible(&site_id, !request.get_ref().include_drafts) + .await + .map_err(crate::grpc::service_error)?; + Ok(Response::new(ListSingletonsResponse { + singletons: values.into_iter().map(ProtoSingleton::from).collect(), + })) + } + async fn get_singleton( &self, mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentRead)?; - let site_id = auth.require_site_id()?.to_string(); - let slug = request.into_inner().slug; + let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action( + &self.repository, + &site_id, + if req.include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await?; + let slug = req.slug; + let storage_profile = self + .repository + .file + .get_storage_provider(&site_id) + .await + .map_err(|_| Status::internal("Storage configuration unavailable"))?; let storage = self .storage_registry - .get(STORAGE_KIND_FILESYSTEM) + .get(&storage_profile) .map(|s| s as Arc) - .ok_or_else(|| Status::internal("Filesystem storage not configured"))?; + .ok_or_else(|| Status::internal("Storage provider not configured"))?; let singleton = self .app_singleton_service - .get_singleton(&site_id, &slug, storage) + .get_singleton_visible(&site_id, &slug, storage, !req.include_drafts) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoSingleton::from(singleton))) } @@ -62,17 +107,29 @@ impl SingletonService for SingletonServiceImpl { mut request: Request, ) -> Result, Status> { let auth = get_auth_context(&mut request, &self.repository).await?; - auth.require_scope(TokenScope::ContentWrite)?; - let site_id = auth.require_site_id()?.to_string(); let req = request.into_inner(); + let site_id = auth.resolve_site_id(&req.site_id)?; + auth.require_action(&self.repository, &site_id, Action::ContentWrite) + .await?; - let data: serde_json::Value = serde_json::from_str(&req.data).unwrap_or_default(); + let data = crate::grpc::struct_to_json( + req.data + .as_ref() + .ok_or_else(|| Status::invalid_argument("data is required"))?, + )?; let singleton = self .app_singleton_service - .update_singleton(&site_id, &req.slug, &data, None, req.change_summary.as_deref()) + .update_singleton( + &site_id, + &req.slug, + &data, + auth.actor.user_id(), + req.change_summary.as_deref(), + (!req.expected_version.is_empty()).then_some(req.expected_version.as_str()), + ) .await - .map_err(|e| Status::internal(format!("Error: {}", e)))?; + .map_err(crate::grpc::service_error)?; Ok(Response::new(ProtoSingleton::from(singleton))) } @@ -81,15 +138,16 @@ impl SingletonService for SingletonServiceImpl { impl From for ProtoSingleton { fn from(c: crate::models::collection::SingletonResponse) -> Self { ProtoSingleton { + definition: crate::grpc::json_to_struct(&c.definition), + data: c.data.as_ref().and_then(crate::grpc::json_to_struct), + created_at: crate::grpc::timestamp_from_text(&c.created_at), + updated_at: crate::grpc::timestamp_from_text(&c.updated_at), id: c.id, site_id: c.site_id, name: c.name, slug: c.slug, - definition: c.definition.to_string(), - data: c.data.map(|d| d.to_string()), entry_id: c.entry_id, - created_at: c.created_at, - updated_at: c.updated_at, + version: c.version, } } } diff --git a/apps/backend/src/handlers/collection_handler.rs b/apps/backend/src/handlers/collection_handler.rs index dd377b9e..62f5029f 100644 --- a/apps/backend/src/handlers/collection_handler.rs +++ b/apps/backend/src/handlers/collection_handler.rs @@ -8,27 +8,17 @@ use serde::Deserialize; use serde_json::json; use tracing::instrument; -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::IntoParams)] pub struct CollectionSlug { collection_slug: String, } use crate::middleware::auth::{RequestContext, require_site_action}; use crate::models::authorization::Action; -use crate::models::collection::{Collection, CreateCollection, UpdateCollection}; +use crate::models::collection::{CreateCollection, PublicCollection, UpdateCollection}; use crate::repository::Repository; use crate::services::Services; -#[utoipa::path( - get, - path = "/api/v1/collections", - responses( - (status = 200, description = "List of collections", body = Vec), - (status = 401, description = "Unauthorized"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "collections" -)] #[instrument(skip(repository, services, ctx))] pub async fn list_collections( ctx: RequestContext, @@ -47,16 +37,36 @@ pub async fn list_collections( #[utoipa::path( get, - path = "/api/v1/collections/{collection_slug}", - params(("collection_slug" = String, Path, description = "Collection slug")), - responses( - (status = 200, description = "Collection details", body = Collection), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Collection not found"), - ), - security(("bearer" = []), ("access_token" = [])), + path = "/api/v1/sites/{site_id}/collections", + responses((status = 200, description = "List of collection schemas", body = Vec)), + security(("access_token" = [])), tag = "collections" )] +#[instrument(skip(repository, services, ctx))] +pub async fn list_public_collections( + ctx: RequestContext, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::SchemaRead).await { + return (status, err).into_response(); + } + match services.collection.list_collections(&ctx.site_id).await { + Ok(collections) => { + let result: Result, _> = collections.into_iter().map(PublicCollection::try_from).collect(); + match result { + Ok(collections) => (StatusCode::OK, Json(collections)).into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ) + .into_response(), + } + } + Err(error) => error.into_response(), + } +} + #[instrument(skip(repository, services, ctx))] pub async fn get_collection( ctx: RequestContext, @@ -76,18 +86,37 @@ pub async fn get_collection( } #[utoipa::path( - post, - path = "/api/v1/collections", - request_body = CreateCollection, - responses( - (status = 201, description = "Collection created", body = Collection), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 409, description = "Collection name or slug already exists"), - ), - security(("bearer" = []), ("access_token" = [])), + get, + path = "/api/v1/sites/{site_id}/collections/{collection_slug}", + params(CollectionSlug), + responses((status = 200, description = "Collection schema", body = PublicCollection)), + security(("access_token" = [])), tag = "collections" )] +#[instrument(skip(repository, services, ctx))] +pub async fn get_public_collection( + ctx: RequestContext, + Path(CollectionSlug { collection_slug }): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::SchemaRead).await { + return (status, err).into_response(); + } + match services.collection.get_collection(&ctx.site_id, &collection_slug).await { + Ok(Some(collection)) => match PublicCollection::try_from(collection) { + Ok(collection) => (StatusCode::OK, Json(collection)).into_response(), + Err(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ) + .into_response(), + }, + Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Collection not found"}))).into_response(), + Err(error) => error.into_response(), + } +} + #[instrument(skip(repository, services, ctx, payload))] pub async fn create_collection( ctx: RequestContext, @@ -118,19 +147,6 @@ pub async fn create_collection( } } -#[utoipa::path( - put, - path = "/api/v1/collections/{collection_slug}", - params(("collection_slug" = String, Path, description = "Collection slug")), - request_body = UpdateCollection, - responses( - (status = 200, description = "Collection updated", body = Collection), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "collections" -)] #[instrument(skip(repository, services, ctx, payload))] pub async fn update_collection( ctx: RequestContext, @@ -161,18 +177,6 @@ pub async fn update_collection( } } -#[utoipa::path( - delete, - path = "/api/v1/collections/{collection_slug}", - params(("collection_slug" = String, Path, description = "Collection slug")), - responses( - (status = 204, description = "Collection deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "collections" -)] #[instrument(skip(repository, services, ctx))] pub async fn delete_collection( ctx: RequestContext, diff --git a/apps/backend/src/handlers/entry_handler.rs b/apps/backend/src/handlers/entry_handler.rs index dc1f0025..f52fe8bf 100644 --- a/apps/backend/src/handlers/entry_handler.rs +++ b/apps/backend/src/handlers/entry_handler.rs @@ -1,7 +1,7 @@ use axum::{ Json, extract::{Extension, Path, Query}, - http::StatusCode, + http::{HeaderMap, StatusCode, header}, response::{IntoResponse, Response}, }; use serde::Deserialize; @@ -9,21 +9,22 @@ use serde_json::json; use std::sync::Arc; use tracing::instrument; -#[derive(Deserialize)] +use crate::config::Config; +#[derive(Deserialize, utoipa::IntoParams)] pub struct EntryId { id: String, } -#[derive(Deserialize)] +#[derive(Deserialize, utoipa::IntoParams)] pub struct RevisionId { id: String, number: i64, } use crate::error::AppError; -use crate::middleware::auth::{Actor, RequestContext, require_site_action}; +use crate::middleware::auth::{RequestContext, require_site_action}; use crate::models::authorization::Action; -use crate::models::entry::{CreateEntry, Entry, EntryRevisionResponse, RevisionsListResponse, UpdateEntry}; +use crate::models::entry::{CreateEntry, EntryRevisionResponse, PublicEntry, RevisionsListResponse, UpdateEntry}; use crate::repository::Repository; use crate::repository::traits::ListEntriesParams; use crate::services::Services; @@ -40,6 +41,28 @@ pub struct ListParams { pub per_page: Option, } +/// Public collection entry query. Continuation is cursor-only. +#[derive(Deserialize, utoipa::IntoParams)] +pub struct CollectionEntriesQuery { + pub status: Option, + pub search: Option, + pub per_page: Option, + pub include_drafts: Option, + pub cursor: Option, + pub include_total: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateCollectionEntry { + pub data: serde_json::Value, + pub slug: String, +} + +#[derive(Deserialize, utoipa::IntoParams)] +pub struct CollectionEntryPath { + pub collection_slug: String, +} + #[derive(Deserialize, utoipa::IntoParams, Debug)] pub struct RevisionListParams { pub page: Option, @@ -51,6 +74,37 @@ pub struct DiffQuery { pub diff: Option, } +#[derive(Debug, Deserialize, Default)] +pub struct PreviewQuery { + pub include_drafts: Option, +} + +pub(crate) fn with_etag(mut response: Response, version: &str) -> Response { + if let Ok(value) = format!("\"{version}\"").parse() { + response.headers_mut().insert(header::ETAG, value); + } + response +} + +pub(crate) fn if_match_version(headers: &HeaderMap) -> Result, AppError> { + let Some(header) = headers.get(header::IF_MATCH) else { + return Ok(None); + }; + let value = header + .to_str() + .map_err(|_| AppError::BadRequest("Invalid If-Match header".into()))? + .trim(); + if value == "*" { + return Ok(None); + } + let version = value + .strip_prefix('"') + .and_then(|value| value.strip_suffix('"')) + .filter(|value| !value.is_empty() && value.bytes().all(|byte| byte == 0x21 || (0x23..=0x7e).contains(&byte))) + .ok_or_else(|| AppError::BadRequest("If-Match requires one strong quoted ETag or *".into()))?; + Ok(Some(version)) +} + fn get_storage_for_site( site_storage_provider: &str, registry: &StorageRegistry, @@ -60,17 +114,6 @@ fn get_storage_for_site( .ok_or(AppError::Internal("Storage provider not found".into())) } -#[utoipa::path( - get, - path = "/api/v1/entries", - params(ListParams), - responses( - (status = 200, description = "List of entries"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "entries" -)] #[instrument(skip(repository, services, ctx, params))] pub async fn list_entries( ctx: RequestContext, @@ -78,11 +121,11 @@ pub async fn list_entries( Extension(repository): Extension, Extension(services): Extension, ) -> Response { + // Dashboard route: only interactive sessions reach it, and they see drafts. if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { return (status, err).into_response(); } - - let published_only = matches!(ctx.auth.actor, Actor::ApiKey(_)); + let published_only = false; let page = params.page.unwrap_or(1).max(1); let per_page = params.per_page.unwrap_or(50).clamp(1, 200); @@ -90,11 +133,7 @@ pub async fn list_entries( site_id: &ctx.site_id, collection_slug: params.r#type.as_deref(), collection_id: None, - status: if matches!(ctx.auth.actor, Actor::User(_)) { - params.status.as_deref() - } else { - None - }, + status: params.status.as_deref(), search: params.search.as_deref(), published_only, page, @@ -119,18 +158,115 @@ pub async fn list_entries( } } +/// Public resource-oriented variant. Collection is carried in the path so the +/// external API never needs an implementation-specific collection id. +#[instrument(skip(repository, services, ctx, params))] #[utoipa::path( get, - path = "/api/v1/entries/{id}", - params(("id" = String, Path, description = "Entry ID")), - responses( - (status = 200, description = "Entry", body = Entry), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Entry not found"), - ), - security(("bearer" = []), ("access_token" = [])), + path = "/api/v1/sites/{site_id}/collections/{collection_slug}/entries", + params(CollectionEntryPath, CollectionEntriesQuery), + responses((status = 200, description = "Entries in a collection")), + security(("access_token" = [])), tag = "entries" )] +pub async fn list_collection_entries( + ctx: RequestContext, + Path(CollectionEntryPath { collection_slug }): Path, + Query(params): Query, + Extension(repository): Extension, + Extension(services): Extension, + Extension(config): Extension, +) -> Response { + if let Err((status, err)) = require_site_action( + &ctx, + &repository, + if params.include_drafts.unwrap_or(false) || params.status.as_deref() == Some("draft") { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await + { + return (status, err).into_response(); + } + + match services.collection.get_collection(&ctx.site_id, &collection_slug).await { + Ok(Some(collection)) if !collection.is_singleton => {} + Ok(_) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Collection not found"}))).into_response(), + Err(error) => return error.into_response(), + } + + let include_drafts = params.include_drafts.unwrap_or(false) || params.status.as_deref() == Some("draft"); + let fingerprint = crate::utils::cursor::fingerprint(&( + &ctx.site_id, + &collection_slug, + ¶ms.status, + ¶ms.search, + include_drafts, + params.per_page.unwrap_or(50).clamp(1, 200), + )); + let page = match crate::utils::cursor::resolve_page(params.cursor.as_deref(), &fingerprint, &config.token_index_key) + { + Ok(page) => page, + Err(message) => return crate::utils::cursor::invalid_cursor_response(message), + }; + let per_page = params.per_page.unwrap_or(50).clamp(1, 200); + let result = services + .entry + .list_entries(ListEntriesParams { + site_id: &ctx.site_id, + collection_slug: Some(&collection_slug), + collection_id: None, + status: params.status.as_deref(), + search: params.search.as_deref(), + published_only: !include_drafts, + page, + per_page, + }) + .await; + + match result { + Ok(result) => { + let items: Result, _> = result.items.into_iter().map(PublicEntry::try_from).collect(); + match items { + Ok(items) => { + let has_next_page = result.page.saturating_mul(result.per_page) < result.total; + let next_cursor = has_next_page.then(|| { + crate::utils::cursor::encode( + &crate::utils::cursor::PageCursor { + version: 1, + page: result.page + 1, + fingerprint, + }, + &config.token_index_key, + ) + }); + ( + StatusCode::OK, + Json(json!({ + "items": items, + "page_info": { + "has_next_page": has_next_page, + "next_cursor": next_cursor, + }, + "total": params.include_total.unwrap_or(false).then_some(result.total), + "per_page": result.per_page, + })), + ) + .into_response() + } + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "invalid_stored_entry", "message": error})), + ) + .into_response(), + } + } + Err(error) => error.into_response(), + } +} + #[instrument(skip(repository, services, ctx, storage_registry))] pub async fn get_entry( ctx: RequestContext, @@ -139,11 +275,11 @@ pub async fn get_entry( Extension(services): Extension, Extension(storage_registry): Extension>, ) -> Response { + // Dashboard route: only interactive sessions reach it, and they see drafts. if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { return (status, err).into_response(); } - - let published_only = matches!(ctx.auth.actor, Actor::ApiKey(_)); + let published_only = false; match services.entry.get_entry(&id, &ctx.site_id, published_only).await { Ok(Some(item)) => { @@ -167,19 +303,6 @@ pub async fn get_entry( } } -#[utoipa::path( - post, - path = "/api/v1/entries", - request_body = CreateEntry, - responses( - (status = 201, description = "Entry created", body = Entry), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 409, description = "Slug already exists"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "entries" -)] #[instrument(skip(repository, services, ctx, payload))] pub async fn create_entry( ctx: RequestContext, @@ -208,19 +331,217 @@ pub async fn create_entry( } } +#[instrument(skip(repository, services, ctx, payload))] #[utoipa::path( - put, - path = "/api/v1/entries/{id}", - params(("id" = String, Path, description = "Entry ID")), + post, + path = "/api/v1/sites/{site_id}/collections/{collection_slug}/entries", + params(CollectionEntryPath), + request_body = CreateCollectionEntry, + responses((status = 201, description = "Entry created", body = PublicEntry)), + security(("access_token" = [])), + tag = "entries" +)] +pub async fn create_collection_entry( + ctx: RequestContext, + Path(CollectionEntryPath { collection_slug }): Path, + Extension(repository): Extension, + Extension(services): Extension, + axum::Json(payload): axum::Json, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentWrite).await { + return (status, err).into_response(); + } + + let collection = match services.collection.get_collection(&ctx.site_id, &collection_slug).await { + Ok(Some(collection)) => collection, + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Collection not found"}))).into_response(), + Err(error) => return error.into_response(), + }; + if collection.is_singleton { + return ( + StatusCode::UNPROCESSABLE_ENTITY, + Json(json!({"error": "Use the singleton resource for singleton collections"})), + ) + .into_response(); + } + + match services + .entry + .create_entry( + &ctx.site_id, + &collection.id, + &payload.data, + &payload.slug, + ctx.auth.actor.user_id(), + ) + .await + { + Ok(item) => match PublicEntry::try_from(item) { + Ok(item) => (StatusCode::CREATED, Json(item)).into_response(), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "invalid_stored_entry", "message": error})), + ) + .into_response(), + }, + Err(error) => error.into_response(), + } +} + +#[instrument(skip(repository, services, ctx))] +#[utoipa::path( + get, + path = "/api/v1/sites/{site_id}/entries/{id}", + params(EntryId), + responses((status = 200, description = "Entry", body = PublicEntry)), + security(("access_token" = [])), + tag = "entries" +)] +pub async fn get_public_entry( + ctx: RequestContext, + Query(params): Query, + Path(EntryId { id }): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + let include_drafts = params.include_drafts.unwrap_or(false); + if let Err((status, err)) = require_site_action( + &ctx, + &repository, + if include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await + { + return (status, err).into_response(); + } + match services.entry.get_entry(&id, &ctx.site_id, !include_drafts).await { + Ok(Some(entry)) => match PublicEntry::try_from(entry) { + Ok(entry) => with_etag((StatusCode::OK, Json(entry.clone())).into_response(), &entry.version), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "invalid_stored_entry", "message": error})), + ) + .into_response(), + }, + Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Entry not found"}))).into_response(), + Err(error) => error.into_response(), + } +} + +#[instrument(skip(repository, services, ctx, headers, payload))] +#[utoipa::path( + patch, + path = "/api/v1/sites/{site_id}/entries/{id}", + params(EntryId), request_body = UpdateEntry, - responses( - (status = 200, description = "Entry updated", body = Entry), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), + responses((status = 200, description = "Entry updated", body = PublicEntry)), + security(("access_token" = [])), tag = "entries" )] +pub async fn update_public_entry( + ctx: RequestContext, + Path(EntryId { id }): Path, + headers: HeaderMap, + Extension(repository): Extension, + Extension(services): Extension, + Json(payload): Json, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentWrite).await { + return (status, err).into_response(); + } + if payload.status.is_some() + && let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentPublish).await + { + return (status, err).into_response(); + } + let expected_version = match if_match_version(&headers) { + Ok(version) => version.or(payload.expected_version.as_deref()), + Err(error) => return error.into_response(), + }; + match services + .entry + .update_entry(UpdateEntryInput { + id: &id, + site_id: &ctx.site_id, + data: payload.data.as_ref(), + slug: payload.slug.as_deref(), + status: payload.status.as_deref(), + created_by: ctx.auth.actor.user_id(), + change_summary: payload.change_summary.as_deref(), + expected_version, + }) + .await + { + Ok(entry) => match PublicEntry::try_from(entry) { + Ok(entry) => with_etag((StatusCode::OK, Json(entry.clone())).into_response(), &entry.version), + Err(error) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "invalid_stored_entry", "message": error})), + ) + .into_response(), + }, + Err(error) => error.into_response(), + } +} + +#[instrument(skip(repository, services, ctx))] +#[utoipa::path( + post, + path = "/api/v1/sites/{site_id}/entries/{id}/publish", + params(EntryId), + responses((status = 200, description = "Entry published", body = PublicEntry)), + security(("access_token" = [])), + tag = "entries" +)] +pub async fn publish_public_entry( + ctx: RequestContext, + Path(EntryId { id }): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentPublish).await { + return (status, err).into_response(); + } + match services.entry.publish_entry(&id, &ctx.site_id).await { + Ok(entry) => match PublicEntry::try_from(entry) { + Ok(entry) => with_etag((StatusCode::OK, Json(entry.clone())).into_response(), &entry.version), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": error}))).into_response(), + }, + Err(error) => error.into_response(), + } +} + +#[instrument(skip(repository, services, ctx))] +#[utoipa::path( + post, + path = "/api/v1/sites/{site_id}/entries/{id}/unpublish", + params(EntryId), + responses((status = 200, description = "Entry unpublished", body = PublicEntry)), + security(("access_token" = [])), + tag = "entries" +)] +pub async fn unpublish_public_entry( + ctx: RequestContext, + Path(EntryId { id }): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentPublish).await { + return (status, err).into_response(); + } + match services.entry.unpublish_entry(&id, &ctx.site_id).await { + Ok(entry) => match PublicEntry::try_from(entry) { + Ok(entry) => with_etag((StatusCode::OK, Json(entry.clone())).into_response(), &entry.version), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": error}))).into_response(), + }, + Err(error) => error.into_response(), + } +} + #[instrument(skip(repository, services, ctx, payload))] pub async fn update_entry( ctx: RequestContext, @@ -244,6 +565,7 @@ pub async fn update_entry( status: payload.status.as_deref(), created_by, change_summary: payload.change_summary.as_deref(), + expected_version: None, }) .await { @@ -254,7 +576,7 @@ pub async fn update_entry( #[utoipa::path( delete, - path = "/api/v1/entries/{id}", + path = "/api/v1/sites/{site_id}/entries/{id}", params(("id" = String, Path, description = "Entry ID")), responses( (status = 204, description = "Entry deleted"), @@ -281,19 +603,6 @@ pub async fn delete_entry( } } -#[utoipa::path( - post, - path = "/api/v1/entries/{id}/publish", - params(("id" = String, Path, description = "Entry ID")), - responses( - (status = 200, description = "Entry published", body = Entry), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 404, description = "Entry not found"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "entries" -)] #[instrument(skip(repository, services, ctx))] pub async fn publish_entry( ctx: RequestContext, @@ -311,19 +620,6 @@ pub async fn publish_entry( } } -#[utoipa::path( - post, - path = "/api/v1/entries/{id}/unpublish", - params(("id" = String, Path, description = "Entry ID")), - responses( - (status = 200, description = "Entry unpublished", body = Entry), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 404, description = "Entry not found"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "entries" -)] #[instrument(skip(repository, services, ctx))] pub async fn unpublish_entry( ctx: RequestContext, @@ -343,7 +639,7 @@ pub async fn unpublish_entry( #[utoipa::path( get, - path = "/api/v1/entries/{id}/revisions", + path = "/api/v1/sites/{site_id}/entries/{id}/revisions", params(("id" = String, Path, description = "Entry ID"), RevisionListParams), responses( (status = 200, description = "List of revisions", body = RevisionsListResponse), @@ -361,7 +657,7 @@ pub async fn list_entry_revisions( Extension(repository): Extension, Extension(services): Extension, ) -> Response { - if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentPreviewRead).await { return (status, err).into_response(); } @@ -391,7 +687,7 @@ pub async fn list_entry_revisions( #[utoipa::path( get, - path = "/api/v1/entries/{id}/revisions/{number}", + path = "/api/v1/sites/{site_id}/entries/{id}/revisions/{number}", params( ("id" = String, Path, description = "Entry ID"), ("number" = i64, Path, description = "Revision number"), @@ -413,7 +709,7 @@ pub async fn get_entry_revision( Extension(repository): Extension, Extension(services): Extension, ) -> Response { - if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentPreviewRead).await { return (status, err).into_response(); } @@ -443,15 +739,37 @@ pub async fn get_entry_revision( } } +#[instrument(skip(repository, services, ctx))] +pub async fn restore_entry_revision( + ctx: RequestContext, + Path(RevisionId { id, number }): Path, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentWrite).await { + return (status, err).into_response(); + } + + let created_by = ctx.auth.actor.user_id(); + match services + .entry + .restore_revision(&id, &ctx.site_id, number, created_by) + .await + { + Ok(item) => (StatusCode::OK, Json(item)).into_response(), + Err(e) => e.into_response(), + } +} + #[utoipa::path( post, - path = "/api/v1/entries/{id}/revisions/{number}/restore", + path = "/api/v1/sites/{site_id}/entries/{id}/revisions/{number}/restore", params( ("id" = String, Path, description = "Entry ID"), ("number" = i64, Path, description = "Revision number"), ), responses( - (status = 200, description = "Entry restored", body = Entry), + (status = 200, description = "Entry restored", body = PublicEntry), (status = 401, description = "Unauthorized"), (status = 403, description = "Insufficient permissions"), (status = 404, description = "Revision not found"), @@ -460,23 +778,27 @@ pub async fn get_entry_revision( tag = "entries" )] #[instrument(skip(repository, services, ctx))] -pub async fn restore_entry_revision( +pub async fn restore_public_entry_revision( ctx: RequestContext, Path(RevisionId { id, number }): Path, Extension(repository): Extension, Extension(services): Extension, ) -> Response { - if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentWrite).await { - return (status, err).into_response(); + if let Err((status, error)) = require_site_action(&ctx, &repository, Action::ContentWrite).await { + return (status, error).into_response(); } - - let created_by = ctx.auth.actor.user_id(); match services .entry - .restore_revision(&id, &ctx.site_id, number, created_by) + .restore_revision(&id, &ctx.site_id, number, ctx.auth.actor.user_id()) .await { - Ok(item) => (StatusCode::OK, Json(item)).into_response(), - Err(e) => e.into_response(), + Ok(entry) => match PublicEntry::try_from(entry) { + Ok(entry) => { + let version = entry.version.clone(); + with_etag((StatusCode::OK, Json(entry)).into_response(), &version) + } + Err(_) => AppError::Internal("Invalid stored entry JSON".into()).into_response(), + }, + Err(error) => error.into_response(), } } diff --git a/apps/backend/src/handlers/file_handler.rs b/apps/backend/src/handlers/file_handler.rs index 26d61b8a..a8eacdca 100644 --- a/apps/backend/src/handlers/file_handler.rs +++ b/apps/backend/src/handlers/file_handler.rs @@ -18,6 +18,7 @@ pub struct FileId { } use crate::config::Config; +use crate::error::AppError; use crate::middleware::auth::{RequestContext, require_site_action}; use crate::models::authorization::Action; use crate::models::file::{BatchFileIds, FileWithUrl}; @@ -37,6 +38,20 @@ pub struct FileListParams { pub trashed: Option, } +#[derive(Deserialize, utoipa::IntoParams)] +pub struct PublicFileListParams { + pub cursor: Option, + pub search: Option, + pub r#type: Option, + pub include_total: Option, +} + +#[derive(Deserialize, utoipa::ToSchema)] +pub struct CreateFileUploadUrl { + pub filename: String, + pub content_type: String, +} + fn get_storage_for_site( site_storage_provider: &str, registry: &StorageRegistry, @@ -46,17 +61,6 @@ fn get_storage_for_site( .ok_or(StatusCode::INTERNAL_SERVER_ERROR) } -#[utoipa::path( - get, - path = "/api/v1/files", - params(FileListParams), - responses( - (status = 200, description = "List of files"), - (status = 401, description = "Unauthorized"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "files" -)] #[instrument(skip(repository, services, ctx, params, storage_registry))] pub async fn list_files( ctx: RequestContext, @@ -113,9 +117,88 @@ pub async fn list_files( } } +#[utoipa::path( + get, + path = "/api/v1/sites/{site_id}/files", + params(PublicFileListParams), + responses((status = 200, description = "List of file metadata")), + security(("access_token" = [])), + tag = "files" +)] +#[instrument(skip(repository, services, ctx, params, storage_registry, config))] +pub async fn list_public_files( + ctx: RequestContext, + Query(params): Query, + Extension(repository): Extension, + Extension(services): Extension, + Extension(storage_registry): Extension>, + Extension(config): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::FilesRead).await { + return (status, err).into_response(); + } + let per_page = 50_i64; + let fingerprint = crate::utils::cursor::fingerprint(&(&ctx.site_id, ¶ms.search, ¶ms.r#type, per_page)); + let page = match crate::utils::cursor::resolve_page(params.cursor.as_deref(), &fingerprint, &config.token_index_key) + { + Ok(page) => page, + Err(message) => return crate::utils::cursor::invalid_cursor_response(message), + }; + let storage_provider = match services.file.get_storage_provider(&ctx.site_id).await { + Ok(provider) => provider, + Err(error) => return error.into_response(), + }; + let storage = match get_storage_for_site(&storage_provider, &storage_registry) { + Ok(storage) => storage, + Err(status) => return (status, Json(json!({"error": "storage_not_configured"}))).into_response(), + }; + match services + .file + .list_files(ListFilesParams { + site_id: &ctx.site_id, + trashed: false, + search: params.search.as_deref(), + file_type: params.r#type.as_deref(), + page, + per_page, + }) + .await + { + Ok(result) => { + let items: Vec = result + .items + .iter() + .map(|file| services.file.file_to_with_url(file, &*storage)) + .collect(); + let has_next_page = page.saturating_mul(per_page) < result.total; + let next_cursor = has_next_page.then(|| { + crate::utils::cursor::encode( + &crate::utils::cursor::PageCursor { + version: 1, + page: page + 1, + fingerprint, + }, + &config.token_index_key, + ) + }); + ( + StatusCode::OK, + Json(json!({ + "items": items, + "page_info": {"has_next_page": has_next_page, "next_cursor": next_cursor}, + "total": params.include_total.unwrap_or(false).then_some(result.total), + "per_page": per_page, + })), + ) + .into_response() + } + Err(error) => error.into_response(), + } +} + #[utoipa::path( post, - path = "/api/v1/files", + path = "/api/v1/sites/{site_id}/files", responses( (status = 201, description = "File uploaded", body = FileWithUrl), (status = 400, description = "Bad request"), @@ -156,54 +239,210 @@ pub async fn upload_file( let created_by = ctx.auth.actor.user_id().map(String::from); let max_bytes = settings.current().general.upload_limit_mb * 1024 * 1024; - // Stream the "file" field straight into storage (constant memory); size cap - // and content sniffing are enforced by the service while streaming. - let mut uploaded: Option> = None; - - while let Ok(Some(field)) = multipart.next_field().await { - let name = field.name().unwrap_or("").to_string(); - - if name == "file" && uploaded.is_none() { - let file_name = sanitize_filename(field.file_name().unwrap_or("upload")); - let content_type = field - .content_type() - .map(String::from) - .unwrap_or_else(|| "application/octet-stream".into()); - - let stream = Box::pin(futures_util::stream::unfold(field, |mut field| async move { - match field.chunk().await { - Ok(Some(chunk)) => Some((Ok(chunk), field)), - Ok(None) => None, - Err(e) => Some((Err(Box::new(e) as Box), field)), - } - })); - - uploaded = Some( - services - .file + // Validate the complete multipart framing before signaling EOF to storage. + // A bounded channel keeps the upload streaming while trailing parse errors + // still abort its transaction rather than creating a partial file record. + let mut sender = None; + let mut upload_task = None; + let mut parse_error = None; + let mut field_count = 0; + 'fields: loop { + let mut field = match multipart.next_field().await { + Ok(Some(field)) => field, + Ok(None) => break, + Err(_) => { + parse_error = Some("Malformed multipart body"); + break; + } + }; + field_count += 1; + if field_count > 32 { + parse_error = Some("Too many multipart fields"); + break; + } + if field.name() == Some("file") { + if sender.is_some() { + parse_error = Some("Only one file field is allowed"); + break; + } + let filename = sanitize_filename(field.file_name().unwrap_or("upload")); + let content_type = field.content_type().unwrap_or("application/octet-stream").to_owned(); + let (tx, rx) = + tokio::sync::mpsc::channel::, Box>>(2); + let service = services.file.clone(); + let site_id = site_id.clone(); + let storage = storage.clone(); + let storage_provider = site.storage_provider.clone(); + let created_by = created_by.clone(); + upload_task = Some(tokio::spawn(async move { + let chunks = futures_util::stream::unfold((rx, false), |(mut receiver, ended)| async move { + if ended { + return None; + } + match receiver.recv().await { + Some(Ok(Some(bytes))) => Some((Ok(bytes), (receiver, false))), + Some(Ok(None)) => None, + Some(Err(error)) => Some((Err(error), (receiver, true))), + None => Some(( + Err(Box::new(std::io::Error::other("Upload interrupted")) + as Box), + (receiver, true), + )), + } + }); + service .upload_file_streaming( StreamingUploadRequest { site_id: &site_id, file_id: None, - filename: &file_name, + filename: &filename, content_type: &content_type, created_by: created_by.as_deref(), - storage: storage.clone(), - storage_provider: &site.storage_provider, + storage, + storage_provider: &storage_provider, max_bytes, }, - stream, + chunks, ) - .await, - ); + .await + })); + sender = Some(tx); + loop { + match field.chunk().await { + Ok(Some(chunk)) => { + if sender.as_ref().unwrap().send(Ok(Some(chunk))).await.is_err() { + break 'fields; + } + } + Ok(None) => break, + Err(_) => { + parse_error = Some("Malformed multipart file field"); + break 'fields; + } + } + } + } else { + let mut metadata_bytes = 0usize; + loop { + match field.chunk().await { + Ok(Some(chunk)) => { + metadata_bytes = metadata_bytes.saturating_add(chunk.len()); + if metadata_bytes > 64 * 1024 { + parse_error = Some("Multipart metadata field is too large"); + break 'fields; + } + } + Ok(None) => break, + Err(_) => { + parse_error = Some("Malformed multipart metadata field"); + break 'fields; + } + } + } } } + if let Some(sender) = sender { + let final_chunk = match parse_error { + Some(error) => Err(Box::new(std::io::Error::other(error)) as Box), + None => Ok(None), + }; + let _ = sender.send(final_chunk).await; + } + match upload_task { + Some(task) => match task.await { + Ok(Ok(file)) => (StatusCode::CREATED, Json(file)).into_response(), + Ok(Err(error)) => error.into_response(), + Err(error) => { + tracing::error!(error = ?error, "Upload task failed"); + AppError::Internal("Upload failed".into()).into_response() + } + }, + None => AppError::BadRequest(parse_error.unwrap_or("No file provided").into()).into_response(), + } +} - match uploaded { - Some(Ok(file)) => (StatusCode::CREATED, Json(file)).into_response(), - Some(Err(e)) => e.into_response(), - None => (StatusCode::BAD_REQUEST, Json(json!({"error": "No file provided"}))).into_response(), +/// Mint a one-shot upload URL for clients that cannot or should not stream a +/// multipart request through the API process. The signed PUT is still checked +/// against site, storage profile, content type, and the live upload limit. +#[utoipa::path( + post, + path = "/api/v1/sites/{site_id}/files/upload-url", + request_body = CreateFileUploadUrl, + responses((status = 201, description = "Signed upload URL created")), + security(("access_token" = [])), + tag = "files" +)] +#[instrument(skip(repository, services, ctx, config, headers, payload))] +pub async fn create_file_upload_url( + ctx: RequestContext, + headers: HeaderMap, + Extension(repository): Extension, + Extension(services): Extension, + Extension(config): Extension, + Json(payload): Json, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::FilesWrite).await { + return (status, err).into_response(); + } + if let Err(error) = crate::services::file::FileService::validate_filename(&payload.filename) { + return error.into_response(); } + if !crate::utils::content_types::is_allowed(&payload.content_type) { + return ( + StatusCode::BAD_REQUEST, + Json(json!({"error": "invalid_content_type", "message": "Content type is not allowed"})), + ) + .into_response(); + } + let site = match services.site.get_site(&ctx.site_id).await { + Ok(Some(site)) => site, + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "Site not found"}))).into_response(), + Err(error) => return error.into_response(), + }; + let Some(storage_profile_id) = site.storage_profile_id else { + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "storage_not_configured", "message": "Storage profile not configured"})), + ) + .into_response(); + }; + let (token, upload_path) = SignedUploadToken::generate_with_storage_profile( + &ctx.site_id, + &payload.filename, + &payload.content_type, + &storage_profile_id, + &config.signed_upload_key, + config.upload_token_expiry_secs, + ); + let host = config + .trust_proxy_headers + .then(|| headers.get("x-forwarded-host")) + .flatten() + .or_else(|| headers.get(header::HOST)) + .and_then(|value| value.to_str().ok()) + .unwrap_or(&config.bind_address); + let scheme = config + .trust_proxy_headers + .then(|| headers.get("x-forwarded-proto")) + .flatten() + .and_then(|value| value.to_str().ok()) + .unwrap_or("http"); + let base = config + .public_url + .clone() + .unwrap_or_else(|| format!("{scheme}://{host}")); + let upload_url = format!("{}/api/v1/files/upload/{}", base.trim_end_matches('/'), upload_path); + ( + StatusCode::CREATED, + Json(json!({ + "upload_url": upload_url, + "file_id": token.file_id, + "expires_at": token.expires_at(), + "method": "PUT", + "content_type": payload.content_type, + })), + ) + .into_response() } #[utoipa::path( @@ -220,7 +459,7 @@ pub async fn upload_file( ), tag = "files" )] -#[instrument(skip(services, config, settings, storage_registry, headers, body))] +#[instrument(skip(token, services, config, settings, storage_registry, headers, body))] pub async fn upload_via_signed_url( Path(token): Path, headers: HeaderMap, @@ -300,6 +539,16 @@ pub async fn upload_via_signed_url( Err(status) => return (status, Json(json!({"error": "Storage not configured"}))).into_response(), }; + // Consume the URL before reading bytes. A started upload attempt, including + // a failed or interrupted one, requires a fresh URL for retry. + if let Err(error) = services + .file + .claim_signed_upload(&token.file_id, token.expires_at) + .await + { + return error.into_response(); + } + let file_name = sanitize_filename(&token.filename); let stream = Box::pin( body.into_data_stream() @@ -370,7 +619,7 @@ mod sanitize_tests { #[utoipa::path( get, - path = "/api/v1/files/{id}", + path = "/api/v1/sites/{site_id}/files/{id}", params(("id" = String, Path, description = "File ID")), responses( (status = 200, description = "File item", body = FileWithUrl), @@ -391,7 +640,11 @@ pub async fn get_file( return (status, err).into_response(); } - match services.file.get_file(&id, &ctx.site_id).await { + match if matches!(ctx.auth.actor, crate::middleware::auth::Actor::User(_)) { + services.file.get_file_including_deleted(&id, &ctx.site_id).await + } else { + services.file.get_file(&id, &ctx.site_id).await + } { Ok(Some(file)) => { let storage_provider = match services.file.get_storage_provider(&ctx.site_id).await { Ok(provider) => provider, @@ -411,7 +664,7 @@ pub async fn get_file( #[utoipa::path( delete, - path = "/api/v1/files/{id}", + path = "/api/v1/sites/{site_id}/files/{id}", params(("id" = String, Path, description = "File ID")), responses( (status = 200, description = "File soft-deleted"), @@ -440,7 +693,7 @@ pub async fn delete_file_handler( #[utoipa::path( get, - path = "/api/v1/files/{id}/references", + path = "/api/v1/sites/{site_id}/files/{id}/references", params(("id" = String, Path, description = "File ID")), responses( (status = 200, description = "References found", body = Vec), @@ -459,6 +712,16 @@ pub async fn get_file_references( return (status, err).into_response(); } + match if matches!(ctx.auth.actor, crate::middleware::auth::Actor::User(_)) { + services.file.get_file_including_deleted(&id, &ctx.site_id).await + } else { + services.file.get_file(&id, &ctx.site_id).await + } { + Ok(Some(_)) => {} + Ok(None) => return (StatusCode::NOT_FOUND, Json(json!({"error": "File not found"}))).into_response(), + Err(error) => return error.into_response(), + } + match services.file.get_file_references(&id, &ctx.site_id).await { Ok(refs) => (StatusCode::OK, Json(refs)).into_response(), Err(e) => e.into_response(), @@ -467,7 +730,7 @@ pub async fn get_file_references( #[utoipa::path( post, - path = "/api/v1/files/{id}/restore", + path = "/api/v1/sites/{site_id}/files/{id}/restore", params(("id" = String, Path, description = "File ID")), responses( (status = 200, description = "File restored"), @@ -498,17 +761,6 @@ pub async fn restore_file( } } -#[utoipa::path( - post, - path = "/api/v1/files/batch-delete", - request_body = BatchFileIds, - responses( - (status = 200, description = "Files soft-deleted"), - (status = 400, description = "Bad request"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "files" -)] #[instrument(skip(repository, services, ctx, body))] pub async fn batch_delete_files( ctx: RequestContext, @@ -530,17 +782,6 @@ pub async fn batch_delete_files( } } -#[utoipa::path( - post, - path = "/api/v1/files/batch-restore", - request_body = BatchFileIds, - responses( - (status = 200, description = "Files restored"), - (status = 400, description = "Bad request"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "files" -)] #[instrument(skip(repository, services, ctx, body))] pub async fn batch_restore_files( ctx: RequestContext, @@ -562,17 +803,6 @@ pub async fn batch_restore_files( } } -#[utoipa::path( - post, - path = "/api/v1/files/batch-permanent-delete", - request_body = BatchFileIds, - responses( - (status = 200, description = "Files permanently deleted"), - (status = 400, description = "Bad request"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "files" -)] #[instrument(skip(repository, services, ctx, body, storage_registry))] pub async fn batch_permanent_delete_files( ctx: RequestContext, @@ -669,14 +899,22 @@ async fn serve_file_by_key( Err(status) => return (status, Json(json!({"error": "Storage not configured"}))).into_response(), }; - match services.file.serve_file(id, use_thumbnail, storage).await { - Ok((bytes, content_type, original_name)) => { + match services.file.serve_file_streaming(id, use_thumbnail, storage).await { + Ok((size, stream, content_type, original_name)) => { let mut headers = HeaderMap::new(); headers.insert( header::CONTENT_TYPE, HeaderValue::from_str(&content_type).unwrap_or(HeaderValue::from_static("application/octet-stream")), ); - headers.insert(header::CONTENT_LENGTH, HeaderValue::from(bytes.len() as u64)); + headers.insert(header::CONTENT_LENGTH, HeaderValue::from(size)); + headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); + if matches!(content_type.as_str(), "text/html" | "image/svg+xml") { + // Uploaded active documents must not execute in the dashboard origin. + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src data:"), + ); + } if use_thumbnail { headers.insert( header::CACHE_CONTROL, @@ -684,13 +922,32 @@ async fn serve_file_by_key( ); } else { headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("public, max-age=3600")); + let safe_name: String = original_name + .chars() + .map(|character| { + if character.is_control() || matches!(character, '"' | '\\') { + '_' + } else { + character + } + }) + .collect(); headers.insert( header::CONTENT_DISPOSITION, - HeaderValue::from_str(&format!("inline; filename=\"{}\"", original_name)) - .unwrap_or(HeaderValue::from_static("inline")), + HeaderValue::from_str(&format!( + "inline; filename=\"{}\"; filename*=UTF-8''{}", + safe_name + .chars() + .map(|character| if character.is_ascii() { character } else { '_' }) + .collect::(), + url::form_urlencoded::byte_serialize(safe_name.as_bytes()) + .collect::() + .replace('+', "%20"), + )) + .unwrap_or(HeaderValue::from_static("inline")), ); } - (StatusCode::OK, headers, Body::from(bytes)).into_response() + (StatusCode::OK, headers, Body::from_stream(stream)).into_response() } Err(e) => e.into_response(), } diff --git a/apps/backend/src/handlers/singleton_handler.rs b/apps/backend/src/handlers/singleton_handler.rs index 70e29fd3..ba161ed8 100644 --- a/apps/backend/src/handlers/singleton_handler.rs +++ b/apps/backend/src/handlers/singleton_handler.rs @@ -1,7 +1,7 @@ use axum::{ Json, - extract::{Extension, Path}, - http::StatusCode, + extract::{Extension, Path, Query}, + http::{HeaderMap, StatusCode}, response::{IntoResponse, Response}, }; use serde::Deserialize; @@ -14,7 +14,7 @@ pub struct SingletonSlug { } use crate::error::AppError; -use crate::middleware::auth::{RequestContext, require_site_action}; +use crate::middleware::auth::{Actor, RequestContext, require_site_action}; use crate::models::authorization::Action; use crate::models::collection::{SingletonResponse, UpdateSingletonData}; use crate::repository::Repository; @@ -32,7 +32,7 @@ fn get_storage_for_site( #[utoipa::path( get, - path = "/api/v1/singletons", + path = "/api/v1/sites/{site_id}/singletons", responses( (status = 200, description = "List of singletons", body = Vec), (status = 401, description = "Unauthorized"), @@ -43,14 +43,34 @@ fn get_storage_for_site( #[instrument(skip(repository, services, ctx))] pub async fn list_singletons( ctx: RequestContext, + Query(preview): Query, Extension(repository): Extension, Extension(services): Extension, ) -> Response { - if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { + if let Err((status, err)) = require_site_action( + &ctx, + &repository, + if preview.include_drafts.unwrap_or(false) { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await + { return (status, err).into_response(); } - match services.singleton.list_singletons(&ctx.site_id).await { + match services + .singleton + .list_singletons_visible( + &ctx.site_id, + !preview + .include_drafts + .unwrap_or(matches!(ctx.auth.actor, Actor::User(_))), + ) + .await + { Ok(items) => (StatusCode::OK, Json(items)).into_response(), Err(e) => e.into_response(), } @@ -58,7 +78,7 @@ pub async fn list_singletons( #[utoipa::path( get, - path = "/api/v1/singletons/{slug}", + path = "/api/v1/sites/{site_id}/singletons/{slug}", params(("slug" = String, Path, description = "Singleton slug")), responses( (status = 200, description = "Singleton with data", body = SingletonResponse), @@ -71,12 +91,23 @@ pub async fn list_singletons( #[instrument(skip(repository, services, ctx, storage_registry))] pub async fn get_singleton( ctx: RequestContext, + Query(preview): Query, Path(SingletonSlug { slug }): Path, Extension(repository): Extension, Extension(services): Extension, Extension(storage_registry): Extension>, ) -> Response { - if let Err((status, err)) = require_site_action(&ctx, &repository, Action::ContentRead).await { + if let Err((status, err)) = require_site_action( + &ctx, + &repository, + if preview.include_drafts.unwrap_or(false) { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) + .await + { return (status, err).into_response(); } @@ -89,15 +120,33 @@ pub async fn get_singleton( Err(e) => return e.into_response(), }; - match services.singleton.get_singleton(&ctx.site_id, &slug, storage).await { - Ok(response) => (StatusCode::OK, Json(response)).into_response(), + match services + .singleton + .get_singleton_visible( + &ctx.site_id, + &slug, + storage, + !preview + .include_drafts + .unwrap_or(matches!(ctx.auth.actor, Actor::User(_))), + ) + .await + { + Ok(response) => { + let version = response.version.clone(); + let response = (StatusCode::OK, Json(response)).into_response(); + match version { + Some(version) => crate::handlers::entry_handler::with_etag(response, &version), + None => response, + } + } Err(e) => e.into_response(), } } #[utoipa::path( - put, - path = "/api/v1/singletons/{slug}", + patch, + path = "/api/v1/sites/{site_id}/singletons/{slug}", params(("slug" = String, Path, description = "Singleton slug")), request_body = UpdateSingletonData, responses( @@ -109,9 +158,10 @@ pub async fn get_singleton( security(("bearer" = []), ("access_token" = [])), tag = "singletons" )] -#[instrument(skip(repository, services, ctx, payload))] +#[instrument(skip(repository, services, ctx, headers, payload))] pub async fn update_singleton( ctx: RequestContext, + headers: HeaderMap, Path(SingletonSlug { slug }): Path, Extension(repository): Extension, Extension(services): Extension, @@ -121,6 +171,10 @@ pub async fn update_singleton( return (status, err).into_response(); } + let expected_version = match crate::handlers::entry_handler::if_match_version(&headers) { + Ok(version) => version.or(payload.expected_version.as_deref()), + Err(error) => return error.into_response(), + }; match services .singleton .update_singleton( @@ -129,10 +183,18 @@ pub async fn update_singleton( &payload.data, ctx.auth.actor.user_id(), payload.change_summary.as_deref(), + expected_version, ) .await { - Ok(response) => (StatusCode::OK, Json(response)).into_response(), + Ok(response) => { + let version = response.version.clone(); + let response = (StatusCode::OK, Json(response)).into_response(); + match version { + Some(version) => crate::handlers::entry_handler::with_etag(response, &version), + None => response, + } + } Err(e) => e.into_response(), } } diff --git a/apps/backend/src/handlers/site_handler.rs b/apps/backend/src/handlers/site_handler.rs index fdb565ae..73f12fcb 100644 --- a/apps/backend/src/handlers/site_handler.rs +++ b/apps/backend/src/handlers/site_handler.rs @@ -9,7 +9,7 @@ use serde_json::json; use tracing::instrument; use crate::middleware::auth::{ - AuthContext, RequestContext, require_instance_action, require_site_action, require_user_action, + AuthContext, RequestContext, require_instance_action, require_site_action, require_user_action, scopes_allow_action, }; use crate::models::authorization::Action; use crate::models::site::{CreateSite, InviteMember, UpdateMemberRole, UpdateSite}; @@ -22,11 +22,65 @@ pub struct MemberPath { member_user_id: String, } -// ── Public API: /api/v1/site ── +#[derive(serde::Serialize, utoipa::ToSchema)] +pub struct PublicSite { + pub id: String, + pub name: String, + pub created_at: String, + pub updated_at: String, +} + +fn public_site(site: crate::models::site::Site) -> PublicSite { + PublicSite { + id: site.id, + name: site.name, + created_at: site.created_at, + updated_at: site.updated_at, + } +} + +// ── Public API: /api/v1/sites ── + +#[utoipa::path( + get, + path = "/api/v1/sites", + responses((status = 200, description = "Sites accessible to the token", body = Vec)), + security(("access_token" = [])), + tag = "sites" +)] +#[instrument(skip(services, ctx))] +pub async fn list_public_sites(ctx: AuthContext, Extension(services): Extension) -> Response { + let allowed = match &ctx.actor { + crate::middleware::auth::Actor::ApiKey(key) => scopes_allow_action(&key.scopes, Action::SiteRead), + crate::middleware::auth::Actor::PersonalToken(token) => scopes_allow_action(&token.scopes, Action::SiteRead), + crate::middleware::auth::Actor::User(_) => false, + }; + if !allowed { + return crate::middleware::error::AuthError::insufficient_permission("site.read").into_response(); + } + + match services.site.list_sites_for_actor(&ctx.actor).await { + Ok(sites) => { + let public = sites + .into_iter() + .filter_map(|site| { + Some(PublicSite { + id: site.get("id")?.as_str()?.to_owned(), + name: site.get("name")?.as_str()?.to_owned(), + created_at: site.get("created_at")?.as_str()?.to_owned(), + updated_at: site.get("updated_at")?.as_str()?.to_owned(), + }) + }) + .collect::>(); + (StatusCode::OK, Json(public)).into_response() + } + Err(error) => error.into_response(), + } +} #[utoipa::path( get, - path = "/api/v1/site", + path = "/api/v1/sites/{site_id}", responses( (status = 200, description = "Current site information"), (status = 401, description = "Unauthorized"), @@ -35,10 +89,17 @@ pub struct MemberPath { security(("bearer" = []), ("access_token" = [])), tag = "site" )] -#[instrument(skip(services, ctx))] -pub async fn get_current_site(ctx: RequestContext, Extension(services): Extension) -> Response { +#[instrument(skip(repository, services, ctx))] +pub async fn get_current_site( + ctx: RequestContext, + Extension(repository): Extension, + Extension(services): Extension, +) -> Response { + if let Err((status, err)) = require_site_action(&ctx, &repository, Action::SiteRead).await { + return (status, err).into_response(); + } match services.site.get_site(&ctx.site_id).await { - Ok(Some(site)) => (StatusCode::OK, Json(site)).into_response(), + Ok(Some(site)) => (StatusCode::OK, Json(public_site(site))).into_response(), Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "Site not found"}))).into_response(), Err(e) => e.into_response(), } diff --git a/apps/backend/src/handlers/webhook_handler.rs b/apps/backend/src/handlers/webhook_handler.rs index 7a1c4b59..c4e48067 100644 --- a/apps/backend/src/handlers/webhook_handler.rs +++ b/apps/backend/src/handlers/webhook_handler.rs @@ -25,17 +25,6 @@ pub struct ListDeliveriesParams { pub per_page: Option, } -#[utoipa::path( - get, - path = "/api/v1/webhooks", - responses( - (status = 200, description = "List webhooks for a site"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx))] pub async fn list_webhooks( ctx: RequestContext, @@ -68,19 +57,6 @@ pub async fn list_webhooks( } } -#[utoipa::path( - post, - path = "/api/v1/webhooks", - request_body = CreateWebhook, - responses( - (status = 201, description = "Webhook created"), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx, payload))] pub async fn create_webhook( ctx: RequestContext, @@ -103,18 +79,6 @@ pub async fn create_webhook( } } -#[utoipa::path( - get, - path = "/api/v1/webhooks/{webhook_id}", - params(("webhook_id" = String, Path, description = "Webhook ID")), - responses( - (status = 200, description = "Webhook details"), - (status = 401, description = "Unauthorized"), - (status = 404, description = "Webhook not found"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx))] pub async fn get_webhook( ctx: RequestContext, @@ -133,20 +97,6 @@ pub async fn get_webhook( } } -#[utoipa::path( - put, - path = "/api/v1/webhooks/{webhook_id}", - params(("webhook_id" = String, Path, description = "Webhook ID")), - request_body = UpdateWebhook, - responses( - (status = 200, description = "Webhook updated"), - (status = 400, description = "Invalid request"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx, payload))] pub async fn update_webhook( ctx: RequestContext, @@ -175,18 +125,6 @@ pub async fn update_webhook( } } -#[utoipa::path( - delete, - path = "/api/v1/webhooks/{webhook_id}", - params(("webhook_id" = String, Path, description = "Webhook ID")), - responses( - (status = 204, description = "Webhook deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx))] pub async fn delete_webhook( ctx: RequestContext, @@ -205,19 +143,6 @@ pub async fn delete_webhook( } } -#[utoipa::path( - post, - path = "/api/v1/webhooks/{webhook_id}/trigger", - params(("webhook_id" = String, Path, description = "Webhook ID")), - responses( - (status = 200, description = "Webhook triggered"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 404, description = "Webhook not found"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx))] pub async fn trigger_webhook( ctx: RequestContext, @@ -240,22 +165,6 @@ pub async fn trigger_webhook( } } -#[utoipa::path( - get, - path = "/api/v1/webhooks/{webhook_id}/deliveries", - params( - ("webhook_id" = String, Path, description = "Webhook ID"), - ListDeliveriesParams, - ), - responses( - (status = 200, description = "List of webhook deliveries"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Insufficient permissions"), - (status = 404, description = "Webhook not found"), - ), - security(("bearer" = []), ("access_token" = [])), - tag = "webhooks" -)] #[instrument(skip(repository, services, ctx))] pub async fn list_deliveries( ctx: RequestContext, diff --git a/apps/backend/src/main.rs b/apps/backend/src/main.rs index 6e997aed..9c81b266 100644 --- a/apps/backend/src/main.rs +++ b/apps/backend/src/main.rs @@ -82,7 +82,7 @@ async fn run_mcp_stdio() -> Result<(), Box> { } let base = std::env::var("VCMS_MCP_URL").unwrap_or_else(|_| "http://127.0.0.1:3000".to_owned()); let endpoint = format!("{}/mcp", base.trim_end_matches('/')); - info!(%endpoint, "Starting MCP stdio proxy"); + info!("Starting MCP stdio proxy"); cms::mcp::transports::stdio::serve(endpoint, token).await } diff --git a/apps/backend/src/mcp/auth.rs b/apps/backend/src/mcp/auth.rs index 74e574fd..7c9a361a 100644 --- a/apps/backend/src/mcp/auth.rs +++ b/apps/backend/src/mcp/auth.rs @@ -1,7 +1,7 @@ use axum::{ Json, body::Body, - http::{Request, StatusCode}, + http::{HeaderValue, Request, StatusCode, header}, middleware::Next, response::{IntoResponse, Response}, }; @@ -20,17 +20,24 @@ pub fn mcp_error(code: ErrorCode, message: impl Into) -> ErrorData { } pub fn ok_result(data: &impl serde::Serialize) -> Result { - let json = serde_json::to_string_pretty(data).map_err(|e| { + let value = serde_json::to_value(data).map_err(|e| { mcp_error( ErrorCode::INTERNAL_ERROR, format!("Failed to serialize response: {}", e), ) })?; - Ok(CallToolResult::success(vec![ContentBlock::text(json)])) -} - -pub fn text_result(message: impl Into) -> CallToolResult { - CallToolResult::success(vec![ContentBlock::text(message.into())]) + let text = serde_json::to_string(&value).map_err(|e| { + mcp_error( + ErrorCode::INTERNAL_ERROR, + format!("Failed to serialize response: {}", e), + ) + })?; + let mut result = CallToolResult::structured(value); + // Keep a textual representation alongside structuredContent for clients + // that still render the canonical content array while migrating to the + // 2026-07-28 structured-output contract. + result.content = vec![ContentBlock::text(text)]; + Ok(result) } pub fn map_err(e: impl Into) -> ErrorData { @@ -41,7 +48,25 @@ pub fn map_err(e: impl Into) -> ErrorData /// Use this for business logic errors that the LLM can act on (not found, validation, permission). pub fn tool_error(e: impl Into) -> CallToolResult { let error = e.into(); - CallToolResult::error(vec![ContentBlock::text(error.error_message())]) + let code = match error.status_code() { + http::StatusCode::UNAUTHORIZED => "unauthorized", + http::StatusCode::FORBIDDEN => "forbidden", + http::StatusCode::NOT_FOUND => "not_found", + http::StatusCode::CONFLICT => "conflict", + http::StatusCode::BAD_REQUEST => "invalid_request", + http::StatusCode::PRECONDITION_FAILED => "precondition_failed", + http::StatusCode::PAYLOAD_TOO_LARGE => "payload_too_large", + _ => "internal_error", + }; + let message = error.error_message(); + let mut result = CallToolResult::error(vec![ContentBlock::text(message.clone())]); + result.structured_content = Some(json!({ + "error": { + "code": code, + "message": message, + } + })); + result } pub fn resolve_actor(ctx: &RequestContext) -> Result { @@ -64,21 +89,33 @@ pub fn resolve_actor(ctx: &RequestContext) -> Result, next: Next) -> Response { + if request.uri().path() != "/mcp" && !request.uri().path().starts_with("/mcp/") { + return next.run(request).await; + } + let token = match bearer_token(&request) { + Some(token) if token.starts_with("vcms_site_") || token.starts_with("vcms_pat_") => token, + Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a VCMS access token"), + None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), + }; let repository = match request.extensions().get::>() { Some(repository) => repository.clone(), - None => return auth_response(StatusCode::INTERNAL_SERVER_ERROR, "MCP repository extension missing"), + None => match request.extensions().get::() { + Some(repository) => Arc::new(repository.clone()), + None => return auth_response(StatusCode::INTERNAL_SERVER_ERROR, "MCP repository extension missing"), + }, }; let config = match request.extensions().get::>() { Some(config) => config.clone(), - None => return auth_response(StatusCode::INTERNAL_SERVER_ERROR, "MCP config extension missing"), + None => match request.extensions().get::() { + Some(config) => Arc::new(config.clone()), + None => return auth_response(StatusCode::INTERNAL_SERVER_ERROR, "MCP config extension missing"), + }, }; - let token = match bearer_token(&request) { - Some(token) if token.starts_with("vcms_site_") || token.starts_with("vcms_pat_") => token, - Some(_) => return auth_response(StatusCode::UNAUTHORIZED, "MCP requires a VCMS access token"), - None => return auth_response(StatusCode::UNAUTHORIZED, "Missing Authorization bearer token"), - }; + if !origin_allowed(request.headers(), &config) { + return auth_response(StatusCode::FORBIDDEN, "MCP Origin is not allowed"); + } match verify_access_token(&token, &repository, &config.token_index_key).await { Ok(actor) => { @@ -97,28 +134,96 @@ pub async fn authenticate_mcp_request(mut request: Request, next: Next) -> } } +// rmcp treats an empty origin allowlist as disabling validation. Our empty +// list instead permits only same-origin requests, preserving a safe default. +fn origin_allowed(headers: &http::HeaderMap, config: &Config) -> bool { + let Some(origin) = headers.get(header::ORIGIN) else { + return true; + }; + let Some(origin) = origin.to_str().ok().and_then(|value| url::Url::parse(value).ok()) else { + return false; + }; + if !matches!(origin.scheme(), "http" | "https") + || !origin.username().is_empty() + || origin.password().is_some() + || origin.path() != "/" + || origin.query().is_some() + || origin.fragment().is_some() + { + return false; + } + if !config.mcp_allowed_origins.is_empty() { + return config + .mcp_allowed_origins + .iter() + .any(|allowed| url::Url::parse(allowed).is_ok_and(|allowed| allowed.origin() == origin.origin())); + } + let scheme = if config.trust_proxy_headers { + headers + .get("x-forwarded-proto") + .and_then(|value| value.to_str().ok()) + .unwrap_or("http") + } else { + config + .public_url + .as_deref() + .and_then(|value| value.split_once("://").map(|(scheme, _)| scheme)) + .unwrap_or("http") + }; + headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .and_then(|host| url::Url::parse(&format!("{scheme}://{host}")).ok()) + .is_some_and(|request_origin| request_origin.origin() == origin.origin()) +} + +pub async fn mcp_rate_limit_middleware(request: Request, next: Next) -> Response { + if request.uri().path() != "/mcp" && !request.uri().path().starts_with("/mcp/") { + return next.run(request).await; + } + let Some(crate::middleware::rate_limit::ApiRateLimiter(limiter)) = request + .extensions() + .get::( + ) else { + return auth_response(StatusCode::INTERNAL_SERVER_ERROR, "MCP rate limiter unavailable"); + }; + let key = limiter.client_key(&request); + if limiter.check(&key) { + next.run(request).await + } else { + auth_response(StatusCode::TOO_MANY_REQUESTS, "Too many MCP requests") + } +} + fn bearer_token(request: &Request) -> Option { let auth_header = request.headers().get("Authorization")?.to_str().ok()?; - let token = auth_header.strip_prefix("Bearer ")?; - Some(token.trim().to_string()) + crate::middleware::auth::parse_bearer_header(auth_header).map(str::to_owned) } fn auth_response(status: StatusCode, message: &str) -> Response { - (status, Json(json!({ "error": message }))).into_response() + let mut response = (status, Json(json!({ "error": message }))).into_response(); + if status == StatusCode::UNAUTHORIZED { + response + .headers_mut() + .insert(header::WWW_AUTHENTICATE, HeaderValue::from_static("Bearer")); + } else if status == StatusCode::FORBIDDEN { + response.headers_mut().insert( + header::WWW_AUTHENTICATE, + HeaderValue::from_static("Bearer error=\"insufficient_scope\", scope=\"mcp.use\""), + ); + } + response } pub fn service_error_to_mcp(error: crate::services::error::ServiceError) -> ErrorData { let message = error.error_message(); - let code = match &error { - crate::services::error::ServiceError::Unauthorized(_) => ErrorCode::INVALID_REQUEST, - crate::services::error::ServiceError::Forbidden(_) - | crate::services::error::ServiceError::InsufficientPermission(_) - | crate::services::error::ServiceError::SiteTokenDenied => ErrorCode::INVALID_REQUEST, - crate::services::error::ServiceError::NotFound(_) => ErrorCode::RESOURCE_NOT_FOUND, - crate::services::error::ServiceError::BadRequest(_) => ErrorCode::INVALID_PARAMS, - crate::services::error::ServiceError::Conflict(_) => ErrorCode::INVALID_PARAMS, - crate::services::error::ServiceError::MissingSiteContext => ErrorCode::INVALID_PARAMS, - crate::services::error::ServiceError::Internal(_) => ErrorCode::INTERNAL_ERROR, + let code = match error.status_code() { + StatusCode::NOT_FOUND => ErrorCode::RESOURCE_NOT_FOUND, + StatusCode::BAD_REQUEST + | StatusCode::UNPROCESSABLE_ENTITY + | StatusCode::CONFLICT + | StatusCode::PRECONDITION_FAILED => ErrorCode::INVALID_PARAMS, + StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => ErrorCode::INVALID_REQUEST, _ => ErrorCode::INTERNAL_ERROR, }; ErrorData::new(code, message, None) diff --git a/apps/backend/src/mcp/resources/site_schema.rs b/apps/backend/src/mcp/resources/site_schema.rs index a7e77817..0e1cb6ec 100644 --- a/apps/backend/src/mcp/resources/site_schema.rs +++ b/apps/backend/src/mcp/resources/site_schema.rs @@ -2,7 +2,10 @@ use std::sync::Arc; use chrono::Utc; use rmcp::ErrorData as McpError; -use rmcp::model::{Annotations, ListResourcesResult, ReadResourceResult, Resource, ResourceContents}; +use rmcp::model::{ + Annotations, CacheScope, ListResourceTemplatesResult, ListResourcesResult, ReadResourceResult, Resource, + ResourceContents, ResourceTemplate, +}; use crate::middleware::auth::Actor; use crate::models::authorization::Action; @@ -27,6 +30,27 @@ fn make_resource(uri: &str, name: &str, title: &str, description: &str) -> Resou .with_annotations(Annotations::for_resource(0.5, Utc::now())) } +pub async fn list_resource_templates( + _authorization: &Arc, + _actor: &Actor, +) -> Result { + Ok(ListResourceTemplatesResult { + resource_templates: vec![ + ResourceTemplate::new("cms://{site_id}/collections/{collection_slug}", "collection-schema") + .with_title("Collection schema") + .with_description("Read a collection's schema before querying or writing entries") + .with_mime_type("application/json"), + ResourceTemplate::new("cms://{site_id}/singletons/{singleton_slug}", "singleton-schema") + .with_title("Singleton schema") + .with_description("Read a singleton's schema") + .with_mime_type("application/json"), + ], + ..Default::default() + } + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Private)) +} + fn collection_to_schema_value(c: &Collection) -> serde_json::Value { let definition: serde_json::Value = serde_json::from_str(&c.definition).unwrap_or(serde_json::json!({"fields": []})); @@ -55,12 +79,12 @@ pub async fn list_resources( .map(|cursor| { cursor .parse::() - .map_err(|_| McpError::invalid_request("Invalid resources/list cursor", None)) + .map_err(|_| McpError::invalid_params("Invalid resources/list cursor", None)) }) .transpose()? .unwrap_or(0); if offset > sites.len() { - return Err(McpError::invalid_request("Invalid resources/list cursor", None)); + return Err(McpError::invalid_params("Invalid resources/list cursor", None)); } let page_end = (offset + SITE_PAGE_SIZE).min(sites.len()); let mut resources = Vec::new(); @@ -79,21 +103,33 @@ pub async fn list_resources( &format!("Content schema for {}", site_name), &format!("Full content schema for {}", site_name), )); - for collection in services.collection.list_collections(site_id).await.map_err(map_err)? { - resources.push(make_resource( - &resource_uri(site_id, &format!("/collections/{}", collection.slug)), - &format!("{}/{}", site_name, collection.name), - &format!("Collection: {}", collection.name), - &format!("Schema for {} collection", collection.name), - )); + if authorization + .require_site_action(actor, site_id, Action::SchemaRead) + .await + .is_ok() + { + for collection in services.collection.list_collections(site_id).await.map_err(map_err)? { + resources.push(make_resource( + &resource_uri(site_id, &format!("/collections/{}", collection.slug)), + &format!("{}/{}", site_name, collection.name), + &format!("Collection: {}", collection.name), + &format!("Schema for {} collection", collection.name), + )); + } } - for singleton in services.singleton.list_singletons(site_id).await.map_err(map_err)? { - resources.push(make_resource( - &resource_uri(site_id, &format!("/singletons/{}", singleton.slug)), - &format!("{}/{}", site_name, singleton.name), - &format!("Singleton: {}", singleton.name), - &format!("Schema for {} singleton", singleton.name), - )); + if authorization + .require_site_action(actor, site_id, Action::SchemaRead) + .await + .is_ok() + { + for singleton in services.singleton.list_singletons(site_id).await.map_err(map_err)? { + resources.push(make_resource( + &resource_uri(site_id, &format!("/singletons/{}", singleton.slug)), + &format!("{}/{}", site_name, singleton.name), + &format!("Singleton: {}", singleton.name), + &format!("Schema for {} singleton", singleton.name), + )); + } } } @@ -101,7 +137,10 @@ pub async fn list_resources( resources, meta: None, next_cursor: (page_end < sites.len()).then(|| page_end.to_string()), - }) + ..Default::default() + } + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Private)) } pub async fn read_resource( @@ -112,11 +151,11 @@ pub async fn read_resource( ) -> Result { let remainder = uri .strip_prefix("cms://") - .ok_or_else(|| McpError::invalid_request("Invalid resource URI", None))?; + .ok_or_else(|| McpError::resource_not_found("Resource not found", Some(serde_json::json!({"uri": uri}))))?; let (site_id, path) = remainder .split_once('/') - .ok_or_else(|| McpError::invalid_request("Invalid resource URI", None))?; + .ok_or_else(|| McpError::resource_not_found("Resource not found", Some(serde_json::json!({"uri": uri}))))?; authorization .require_site_action(actor, site_id, Action::SiteRead) @@ -124,16 +163,33 @@ pub async fn read_resource( .map_err(map_err)?; match path { - "schema" => read_schema_resource(services, site_id, uri).await, + "schema" => { + authorization + .require_site_action(actor, site_id, Action::SchemaRead) + .await + .map_err(map_err)?; + read_schema_resource(services, site_id, uri).await + } p if p.starts_with("collections/") => { + authorization + .require_site_action(actor, site_id, Action::SchemaRead) + .await + .map_err(map_err)?; let slug = &p["collections/".len()..]; read_collection_resource(services, site_id, slug, uri).await } p if p.starts_with("singletons/") => { + authorization + .require_site_action(actor, site_id, Action::SchemaRead) + .await + .map_err(map_err)?; let slug = &p["singletons/".len()..]; read_singleton_resource(services, site_id, slug, uri).await } - _ => Err(McpError::invalid_request("Unknown resource path", None)), + _ => Err(McpError::resource_not_found( + "Resource not found", + Some(serde_json::json!({"uri": uri})), + )), } } @@ -147,12 +203,26 @@ async fn read_schema_resource( .get_site(site_id) .await .map_err(map_err)? - .ok_or_else(|| McpError::invalid_request("Site not found", None))?; + .ok_or_else(|| McpError::resource_not_found("Resource not found", Some(serde_json::json!({"uri": uri}))))?; let collections = services.collection.list_collections(site_id).await.map_err(map_err)?; let singletons = services.singleton.list_singletons(site_id).await.map_err(map_err)?; let collections_json: Vec = collections.iter().map(collection_to_schema_value).collect(); + let singletons_json: Vec = singletons + .iter() + .map(|singleton| { + serde_json::json!({ + "id": singleton.id, + "site_id": singleton.site_id, + "name": singleton.name, + "slug": singleton.slug, + "definition": singleton.definition, + "created_at": singleton.created_at, + "updated_at": singleton.updated_at, + }) + }) + .collect(); let schema = serde_json::json!({ "site": { @@ -160,7 +230,7 @@ async fn read_schema_resource( "name": site.name, }, "collections": collections_json, - "singletons": singletons, + "singletons": singletons_json, "field_types": [ {"type": "text", "label": "Text"}, {"type": "textarea", "label": "Text Area"}, @@ -195,7 +265,9 @@ async fn read_schema_resource( mime_type: Some("application/json".to_string()), text: schema_json, meta: None, - }])) + }]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Private)) } async fn read_collection_resource( @@ -209,7 +281,7 @@ async fn read_collection_resource( .get_collection(site_id, slug) .await .map_err(map_err)? - .ok_or_else(|| McpError::invalid_request("Collection not found", None))?; + .ok_or_else(|| McpError::resource_not_found("Resource not found", Some(serde_json::json!({"uri": uri}))))?; let value = collection_to_schema_value(&collection); let json = serde_json::to_string_pretty(&value) @@ -220,7 +292,9 @@ async fn read_collection_resource( mime_type: Some("application/json".to_string()), text: json, meta: None, - }])) + }]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Private)) } async fn read_singleton_resource( @@ -234,9 +308,15 @@ async fn read_singleton_resource( let singleton = singletons .iter() .find(|s| s.slug == slug) - .ok_or_else(|| McpError::invalid_request("Singleton not found", None))?; + .ok_or_else(|| McpError::resource_not_found("Resource not found", Some(serde_json::json!({"uri": uri}))))?; - let json = serde_json::to_string_pretty(singleton) + let schema = serde_json::json!({ + "id": singleton.id, "site_id": singleton.site_id, + "name": singleton.name, "slug": singleton.slug, + "definition": singleton.definition, + "created_at": singleton.created_at, "updated_at": singleton.updated_at, + }); + let json = serde_json::to_string_pretty(&schema) .map_err(|e| McpError::internal_error(format!("Failed to serialize: {}", e), None))?; Ok(ReadResourceResult::new(vec![ResourceContents::TextResourceContents { @@ -244,7 +324,9 @@ async fn read_singleton_resource( mime_type: Some("application/json".to_string()), text: json, meta: None, - }])) + }]) + .with_ttl_ms(10_000) + .with_cache_scope(CacheScope::Private)) } #[cfg(test)] diff --git a/apps/backend/src/mcp/server.rs b/apps/backend/src/mcp/server.rs index 12d54c60..497146ad 100644 --- a/apps/backend/src/mcp/server.rs +++ b/apps/backend/src/mcp/server.rs @@ -1,11 +1,12 @@ +use std::borrow::Cow; use std::sync::Arc; use std::time::Instant; use rmcp::handler::server::wrapper::Parameters; use rmcp::model::{ - CallToolRequestParams, CallToolResult, Implementation, InitializeRequestParams, ListResourcesResult, - ListToolsResult, PaginatedRequestParams, ReadResourceRequestParams, ReadResourceResult, ServerCapabilities, - ServerInfo, + CallToolRequestParams, CallToolResponse, CallToolResult, Implementation, ListResourceTemplatesResult, + ListResourcesResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion, ReadResourceRequestParams, + ReadResourceResponse, ServerCapabilities, ServerConfig, ToolAnnotations, }; use rmcp::service::RequestContext; use rmcp::service::RoleServer; @@ -19,7 +20,7 @@ use crate::services::authorization::AuthorizationService; use crate::storage::StorageRegistry; use crate::mcp::resources::site_schema; -use crate::mcp::tools::{collection, entry, file, singleton, site, webhook}; +use crate::mcp::tools::{entry, file, singleton, site}; #[derive(Clone)] pub struct CmsServer { @@ -51,8 +52,38 @@ impl CmsServer { #[tool(description = "List sites accessible to the authenticated user")] async fn list_sites(&self, ctx: RequestContext) -> Result { let actor = self.resolve_actor(&ctx)?; + let allowed = match &actor { + Actor::ApiKey(key) => crate::middleware::auth::scopes_allow_action( + &key.scopes, + crate::models::authorization::Action::SiteRead, + ), + Actor::PersonalToken(token) => crate::middleware::auth::scopes_allow_action( + &token.scopes, + crate::models::authorization::Action::SiteRead, + ), + Actor::User(_) => true, + }; + if !allowed { + return Ok(crate::mcp::auth::tool_error( + crate::services::error::ServiceError::InsufficientPermission("site.read".into()), + )); + } match self.services.site.list_sites_for_actor(&actor).await { - Ok(v) => crate::mcp::auth::ok_result(&v), + Ok(v) => { + let sites: Vec = v + .into_iter() + .map(|site| { + serde_json::json!({ + "id": site.get("id"), + "name": site.get("name"), + "created_at": site.get("created_at"), + "updated_at": site.get("updated_at"), + "role": site.get("role"), + }) + }) + .collect(); + crate::mcp::auth::ok_result(&serde_json::json!({ "sites": sites })) + } Err(e) => Err(crate::mcp::auth::map_err(e)), } } @@ -67,66 +98,6 @@ impl CmsServer { site::get_site(&self.authorizer, &self.services, &actor, params).await } - #[tool(description = "Update a site's name")] - async fn update_site( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - site::update_site(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List collections in a site")] - async fn list_collections( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::list_collections(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a collection by slug")] - async fn get_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::get_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a new collection")] - async fn create_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::create_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a collection's definition")] - async fn update_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::update_collection(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a collection")] - async fn delete_collection( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - collection::delete_collection(&self.authorizer, &self.services, &actor, params).await - } - #[tool(description = "List entries in a site, optionally filtered by collection and status")] async fn list_entries( &self, @@ -177,24 +148,14 @@ impl CmsServer { entry::delete_entry(&self.authorizer, &self.services, &actor, params).await } - #[tool(description = "Publish an entry")] - async fn publish_entry( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - entry::publish_entry(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Unpublish an entry")] - async fn unpublish_entry( + #[tool(description = "Publish or unpublish an entry. Set published explicitly; requires content.publish.")] + async fn set_entry_publication( &self, ctx: RequestContext, - params: Parameters, + params: Parameters, ) -> Result { let actor = self.resolve_actor(&ctx)?; - entry::unpublish_entry(&self.authorizer, &self.services, &actor, params).await + entry::set_publication(&self.authorizer, &self.services, &actor, params).await } #[tool(description = "List singletons in a site")] @@ -248,9 +209,9 @@ impl CmsServer { } #[tool( - description = "Create a single-use signed upload URL. To upload: send an HTTP PUT to upload_url with the raw file bytes as the request body and a Content-Type header equal to content_type. The URL expires at expires_at and can be used exactly once. The PUT response body is the created file record (JSON, includes the file id and url)." + description = "Create a single-use signed upload URL. PUT raw bytes to upload_url with the exact content_type before expires_at; the URL is bound to this site and storage profile." )] - async fn create_upload_url( + async fn create_file_upload( &self, ctx: RequestContext, params: Parameters, @@ -278,76 +239,6 @@ impl CmsServer { file::delete_file(&self.authorizer, &self.services, &actor, params).await } - #[tool(description = "List webhooks for a site")] - async fn list_webhooks( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhooks(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Create a webhook")] - async fn create_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::create_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Trigger a webhook")] - async fn trigger_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::trigger_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Delete a webhook")] - async fn delete_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::delete_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Get a webhook by ID")] - async fn get_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::get_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "Update a webhook")] - async fn update_webhook( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::update_webhook(&self.authorizer, &self.services, &actor, params).await - } - - #[tool(description = "List delivery attempts for a webhook")] - async fn list_webhook_deliveries( - &self, - ctx: RequestContext, - params: Parameters, - ) -> Result { - let actor = self.resolve_actor(&ctx)?; - webhook::list_webhook_deliveries(&self.authorizer, &self.services, &actor, params).await - } - #[tool(description = "Restore a soft-deleted file")] async fn restore_file( &self, @@ -368,7 +259,17 @@ impl CmsServer { entry::list_revisions(&self.authorizer, &self.services, &actor, params).await } - #[tool(description = "Restore an entry to a previous revision")] + #[tool(description = "Read one entry revision by number")] + async fn get_revision( + &self, + ctx: RequestContext, + params: Parameters, + ) -> Result { + let actor = self.resolve_actor(&ctx)?; + entry::get_revision(&self.authorizer, &self.services, &actor, params).await + } + + #[tool(description = "Restore an entry to a previous revision; this creates a new current revision")] async fn restore_revision( &self, ctx: RequestContext, @@ -379,32 +280,38 @@ impl CmsServer { } } -use crate::mcp::schema::clean_input_schema; - #[tool_handler] impl ServerHandler for CmsServer { - fn get_info(&self) -> ServerInfo { - ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) - .with_server_info(Implementation::new("cms", env!("CARGO_PKG_VERSION"))) + fn get_info(&self) -> ServerConfig { + ServerConfig::new(ServerCapabilities::builder().enable_tools().enable_resources().build()) + .with_protocol_version(ProtocolVersion::V_2026_07_28) + .with_server_info(Implementation::new("velopulent-cms", env!("CARGO_PKG_VERSION"))) + } + + fn supported_protocol_versions(&self) -> Cow<'static, [ProtocolVersion]> { + // 2025-06-18 is the oldest revision with structured tool output and the + // MCP-Protocol-Version header, both of which this server relies on. + Cow::Borrowed(&[ + ProtocolVersion::V_2025_06_18, + ProtocolVersion::V_2025_11_25, + ProtocolVersion::V_2026_07_28, + ]) } - async fn initialize( + async fn list_prompts( &self, - request: InitializeRequestParams, - mut context: RequestContext, - ) -> Result { - let started = Instant::now(); - self.authenticate_context(&mut context).await?; - if context.peer.peer_info().is_none() { - context.peer.set_peer_info(request.clone()); - } - tracing::info!( - mcp_method = "initialize", - duration_ms = started.elapsed().as_millis(), - outcome = "success", - "MCP operation completed" - ); - Ok(self.get_info().with_protocol_version(request.protocol_version)) + _request: Option, + _ctx: RequestContext, + ) -> Result { + Err(McpError::method_not_found::()) + } + + async fn complete( + &self, + _request: rmcp::model::CompleteRequestParams, + _ctx: RequestContext, + ) -> Result { + Err(McpError::method_not_found::()) } async fn list_tools( @@ -414,19 +321,43 @@ impl ServerHandler for CmsServer { ) -> Result { let started = Instant::now(); self.authenticate_context(&mut ctx).await?; - let tools = Self::tool_router() - .list_all() - .into_iter() - .map(|mut tool| { - tool.input_schema = clean_input_schema(tool.input_schema); - tool + let mut tools = Self::tool_router().list_all(); + let output_schema = Arc::new( + serde_json::json!({ + "type": "object", + "description": "Structured Velopulent CMS result" }) - .collect(); + .as_object() + .cloned() + .expect("object schema"), + ); + for tool in &mut tools { + tool.input_schema = crate::mcp::schema::clean_input_schema(tool.input_schema.clone()); + tool.output_schema = Some(output_schema.clone()); + let read_only = matches!( + tool.name.as_ref(), + "list_sites" + | "get_site" + | "list_entries" + | "get_entry" + | "list_revisions" + | "get_revision" + | "list_singletons" + | "get_singleton" + | "list_files" + | "get_file" + ); + let destructive = matches!(tool.name.as_ref(), "delete_entry" | "delete_file" | "restore_revision"); + tool.annotations = Some(ToolAnnotations::new().read_only(read_only).destructive(destructive)); + } let result = ListToolsResult { tools, meta: None, next_cursor: None, - }; + ..Default::default() + } + .with_ttl_ms(10_000) + .with_cache_scope(rmcp::model::CacheScope::Public); tracing::info!( mcp_method = "tools/list", duration_ms = started.elapsed().as_millis(), @@ -440,7 +371,7 @@ impl ServerHandler for CmsServer { &self, request: CallToolRequestParams, mut ctx: RequestContext, - ) -> Result { + ) -> Result { let started = Instant::now(); let tool_name = request.name.to_string(); self.authenticate_context(&mut ctx).await?; @@ -473,11 +404,28 @@ impl ServerHandler for CmsServer { result } + async fn list_resource_templates( + &self, + _request: Option, + mut ctx: RequestContext, + ) -> Result { + let started = Instant::now(); + let actor = self.authenticate_context(&mut ctx).await?; + let result = site_schema::list_resource_templates(&self.authorizer, &actor).await; + tracing::info!( + mcp_method = "resources/templates/list", + duration_ms = started.elapsed().as_millis(), + outcome = if result.is_ok() { "success" } else { "error" }, + "MCP operation completed" + ); + result + } + async fn read_resource( &self, request: ReadResourceRequestParams, mut ctx: RequestContext, - ) -> Result { + ) -> Result { let started = Instant::now(); let actor = self.authenticate_context(&mut ctx).await?; let result = site_schema::read_resource(&self.authorizer, &self.services, &actor, &request.uri).await; @@ -487,7 +435,7 @@ impl ServerHandler for CmsServer { outcome = if result.is_ok() { "success" } else { "error" }, "MCP operation completed" ); - result + result.map(Into::into) } } @@ -507,13 +455,19 @@ impl CmsServer { let parts = ctx.extensions.get::()?; let headers = &parts.headers; - let host = headers - .get("x-forwarded-host") + let host = self + .config + .trust_proxy_headers + .then(|| headers.get("x-forwarded-host")) + .flatten() .or_else(|| headers.get("host"))? .to_str() .ok()?; - let proto = headers - .get("x-forwarded-proto") + let proto = self + .config + .trust_proxy_headers + .then(|| headers.get("x-forwarded-proto")) + .flatten() .and_then(|value| value.to_str().ok()) .unwrap_or("http"); @@ -532,9 +486,13 @@ mod tests { fn tool_router_lists_registered_tools() { let tools = CmsServer::tool_router().list_all(); assert!(tools.iter().any(|tool| tool.name == "get_site")); + assert!(tools.iter().any(|tool| tool.name == "set_entry_publication")); + assert!(tools.iter().any(|tool| tool.name == "get_revision")); assert!(!tools.iter().any(|tool| tool.name.contains("token"))); assert!(tools.iter().any(|tool| tool.name == "list_sites")); - assert!(tools.len() > 15); + assert!(!tools.iter().any(|tool| tool.name == "list_collections")); + assert!(!tools.iter().any(|tool| tool.name == "get_collection")); + assert!(tools.len() >= 18); } fn all_tools() -> Vec { diff --git a/apps/backend/src/mcp/tools/collection.rs b/apps/backend/src/mcp/tools/collection.rs deleted file mode 100644 index c126e123..00000000 --- a/apps/backend/src/mcp/tools/collection.rs +++ /dev/null @@ -1,186 +0,0 @@ -use std::sync::Arc; - -use rmcp::ErrorData as McpError; -use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::CallToolResult; -use schemars::JsonSchema; -use serde::Deserialize; - -use crate::mcp::auth::{ok_result, tool_error}; -use crate::mcp::schema::ArbitraryJson; -use crate::middleware::auth::Actor; -use crate::models::authorization::Action; -use crate::services::{Services, authorization::AuthorizationService}; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ListCollectionsParams { - pub site_id: String, -} - -pub async fn list_collections( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id; - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SchemaRead) - .await - { - return Ok(tool_error(e)); - } - match services.collection.list_collections(&site_id).await { - Ok(collections) => ok_result(&collections), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct GetCollectionParams { - pub site_id: String, - pub slug: String, -} - -pub async fn get_collection( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SchemaRead) - .await - { - return Ok(tool_error(e)); - } - match services.collection.get_collection(&site_id, ¶ms.0.slug).await { - Ok(Some(collection)) => ok_result(&collection), - Ok(None) => Ok(tool_error(crate::services::error::ServiceError::NotFound( - "Collection not found".into(), - ))), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct CreateCollectionParams { - pub site_id: String, - pub name: String, - pub slug: Option, - #[schemars(with = "ArbitraryJson")] - pub definition: serde_json::Value, - pub is_singleton: Option, -} - -pub async fn create_collection( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SchemaWrite) - .await - { - return Ok(tool_error(e)); - } - let slug = params - .0 - .slug - .unwrap_or_else(|| params.0.name.to_lowercase().replace(' ', "-")); - let normalized = crate::services::definition_validation::normalize_definition(¶ms.0.definition) - .map_err(|e| McpError::invalid_request(e, None))?; - let definition = normalized.to_string(); - let is_singleton = params.0.is_singleton.unwrap_or(false); - match services - .collection - .create_collection(&site_id, ¶ms.0.name, &slug, &definition, is_singleton) - .await - { - Ok(collection) => ok_result(&collection), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct UpdateCollectionParams { - pub site_id: String, - pub slug: String, - pub name: Option, - pub new_slug: Option, - #[schemars(with = "ArbitraryJson")] - pub definition: Option, -} - -pub async fn update_collection( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SchemaWrite) - .await - { - return Ok(tool_error(e)); - } - let definition_str = match params.0.definition { - Some(d) => { - let normalized = crate::services::definition_validation::normalize_definition(&d) - .map_err(|e| McpError::invalid_request(e, None))?; - Some(normalized.to_string()) - } - None => None, - }; - match services - .collection - .update_collection( - &site_id, - ¶ms.0.slug, - params.0.name.as_deref(), - params.0.new_slug.as_deref(), - definition_str.as_deref(), - ) - .await - { - Ok(collection) => ok_result(&collection), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct DeleteCollectionParams { - pub site_id: String, - pub slug: String, -} - -pub async fn delete_collection( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SchemaWrite) - .await - { - return Ok(tool_error(e)); - } - match services.collection.delete_collection(&site_id, ¶ms.0.slug).await { - Ok(n) => { - if n > 0 { - ok_result(&serde_json::json!({"deleted": true})) - } else { - Ok(tool_error(crate::services::error::ServiceError::NotFound( - "Collection not found".into(), - ))) - } - } - Err(e) => Ok(tool_error(e)), - } -} diff --git a/apps/backend/src/mcp/tools/entry.rs b/apps/backend/src/mcp/tools/entry.rs index f290a6d9..5daa441e 100644 --- a/apps/backend/src/mcp/tools/entry.rs +++ b/apps/backend/src/mcp/tools/entry.rs @@ -10,16 +10,23 @@ use crate::mcp::auth::{ok_result, tool_error}; use crate::mcp::schema::ArbitraryJson; use crate::middleware::auth::Actor; use crate::models::authorization::Action; +use crate::models::entry::PublicEntry; use crate::repository::traits::ListEntriesParams as RepoListEntriesParams; use crate::services::entry::UpdateEntryInput; use crate::services::{Services, authorization::AuthorizationService}; use crate::storage::StorageRegistry; +fn public_entry(entry: crate::models::entry::Entry) -> Result { + PublicEntry::try_from(entry).map_err(|_| McpError::internal_error("Stored entry data is invalid JSON", None)) +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct ListEntriesParams { pub site_id: String, pub collection_slug: Option, - pub published_only: Option, + /// Include drafts; requires content.preview.read. + pub include_drafts: Option, + pub status: Option, pub page: Option, pub per_page: Option, pub search: Option, @@ -32,20 +39,28 @@ pub async fn list_entries( params: Parameters, ) -> Result { let site_id = params.0.site_id.clone(); + let published_only = !params.0.include_drafts.unwrap_or(false); if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentRead) + .require_site_action( + actor, + &site_id, + if published_only { + Action::ContentRead + } else { + Action::ContentPreviewRead + }, + ) .await { return Ok(tool_error(e)); } - let published_only = params.0.published_only.unwrap_or(true); let page = params.0.page.unwrap_or(1).max(1); let per_page = params.0.per_page.unwrap_or(25).clamp(1, 100); let list_params = RepoListEntriesParams { site_id: &site_id, collection_slug: params.0.collection_slug.as_deref(), collection_id: None, - status: None, + status: params.0.status.as_deref(), search: params.0.search.as_deref(), published_only, page, @@ -53,8 +68,9 @@ pub async fn list_entries( }; match services.entry.list_entries(list_params).await { Ok(result) => { + let items: Result, _> = result.items.into_iter().map(public_entry).collect(); let response = serde_json::json!({ - "items": result.items, + "items": items?, "total": result.total, "page": result.page, "per_page": result.per_page, @@ -69,6 +85,7 @@ pub async fn list_entries( pub struct GetEntryParams { pub site_id: String, pub id: String, + pub include_drafts: Option, } pub async fn get_entry( @@ -79,14 +96,23 @@ pub async fn get_entry( params: Parameters, ) -> Result { let site_id = params.0.site_id.clone(); + let include_drafts = params.0.include_drafts.unwrap_or(false); if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentRead) + .require_site_action( + actor, + &site_id, + if include_drafts { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) .await { return Ok(tool_error(e)); } - match services.entry.get_entry(¶ms.0.id, &site_id, true).await { - Ok(Some(entry)) => ok_result(&entry), + match services.entry.get_entry(¶ms.0.id, &site_id, !include_drafts).await { + Ok(Some(entry)) => ok_result(&public_entry(entry)?), Ok(None) => Ok(tool_error(crate::services::error::ServiceError::NotFound( "Entry not found".into(), ))), @@ -118,22 +144,26 @@ pub async fn create_entry( return Ok(tool_error(e)); } - // Validate entry data against collection definition - if let Ok(Some(collection)) = services.collection.get_by_id(¶ms.0.collection_id).await - && let Ok(definition) = serde_json::from_str::(&collection.definition) - && let Some(fields) = definition.get("fields").and_then(|f| f.as_array()) - && let Some(err) = crate::services::definition_validation::validate_entry_data(¶ms.0.values, fields) - { - return Ok(tool_error(crate::services::error::ServiceError::BadRequest(err))); + if params.0.published == Some(true) { + return Err(McpError::invalid_params( + "Create entries as drafts, then use set_entry_publication", + None, + )); } let slug = params.0.slug.unwrap_or_else(|| uuid::Uuid::now_v7().to_string()); match services .entry - .create_entry(&site_id, ¶ms.0.collection_id, ¶ms.0.values, &slug, None) + .create_entry( + &site_id, + ¶ms.0.collection_id, + ¶ms.0.values, + &slug, + actor.user_id(), + ) .await { - Ok(entry) => ok_result(&entry), + Ok(entry) => ok_result(&public_entry(entry)?), Err(e) => Ok(tool_error(e)), } } @@ -147,6 +177,7 @@ pub struct UpdateEntryParams { pub slug: Option, pub published: Option, pub change_summary: Option, + pub expected_version: Option, } pub async fn update_entry( @@ -162,6 +193,13 @@ pub async fn update_entry( { return Ok(tool_error(e)); } + if params.0.published.is_some() + && let Err(error) = authorization + .require_site_action(actor, &site_id, Action::ContentPublish) + .await + { + return Ok(tool_error(error)); + } match services .entry .update_entry(UpdateEntryInput { @@ -170,12 +208,13 @@ pub async fn update_entry( data: params.0.values.as_ref(), slug: params.0.slug.as_deref(), status: params.0.published.map(|b| if b { "published" } else { "draft" }), - created_by: None, + created_by: actor.user_id(), change_summary: params.0.change_summary.as_deref(), + expected_version: params.0.expected_version.as_deref(), }) .await { - Ok(entry) => ok_result(&entry), + Ok(entry) => ok_result(&public_entry(entry)?), Err(e) => Ok(tool_error(e)), } } @@ -214,52 +253,33 @@ pub async fn delete_entry( } #[derive(Debug, Deserialize, JsonSchema)] -pub struct PublishEntryParams { - pub site_id: String, - pub id: String, -} - -pub async fn publish_entry( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentWrite) - .await - { - return Ok(tool_error(e)); - } - match services.entry.publish_entry(¶ms.0.id, &site_id).await { - Ok(entry) => ok_result(&entry), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct UnpublishEntryParams { +pub struct SetPublicationParams { pub site_id: String, pub id: String, + pub published: bool, } -pub async fn unpublish_entry( +pub async fn set_publication( authorization: &Arc, services: &Arc, actor: &Actor, - params: Parameters, + params: Parameters, ) -> Result { let site_id = params.0.site_id.clone(); if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentWrite) + .require_site_action(actor, &site_id, Action::ContentPublish) .await { return Ok(tool_error(e)); } - match services.entry.unpublish_entry(¶ms.0.id, &site_id).await { - Ok(entry) => ok_result(&entry), - Err(e) => Ok(tool_error(e)), + let result = if params.0.published { + services.entry.publish_entry(¶ms.0.id, &site_id).await + } else { + services.entry.unpublish_entry(¶ms.0.id, &site_id).await + }; + match result { + Ok(entry) => ok_result(&public_entry(entry)?), + Err(error) => Ok(tool_error(error)), } } @@ -279,7 +299,7 @@ pub async fn list_revisions( ) -> Result { let site_id = params.0.site_id.clone(); if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentRead) + .require_site_action(actor, &site_id, Action::ContentPreviewRead) .await { return Ok(tool_error(e)); @@ -304,6 +324,39 @@ pub async fn list_revisions( } } +#[derive(Debug, Deserialize, JsonSchema)] +pub struct GetRevisionParams { + pub site_id: String, + pub entry_id: String, + pub revision_number: i64, +} + +pub async fn get_revision( + authorization: &Arc, + services: &Arc, + actor: &Actor, + params: Parameters, +) -> Result { + let site_id = params.0.site_id.clone(); + if let Err(e) = authorization + .require_site_action(actor, &site_id, Action::ContentPreviewRead) + .await + { + return Ok(tool_error(e)); + } + match services + .entry + .get_revision(¶ms.0.entry_id, &site_id, params.0.revision_number) + .await + { + Ok(Some(revision)) => ok_result(&revision), + Ok(None) => Ok(tool_error(crate::services::error::ServiceError::NotFound( + "Revision not found".into(), + ))), + Err(error) => Ok(tool_error(error)), + } +} + #[derive(Debug, Deserialize, JsonSchema)] pub struct RestoreRevisionParams { pub site_id: String, @@ -326,10 +379,10 @@ pub async fn restore_revision( } match services .entry - .restore_revision(¶ms.0.entry_id, &site_id, params.0.revision_number, None) + .restore_revision(¶ms.0.entry_id, &site_id, params.0.revision_number, actor.user_id()) .await { - Ok(entry) => ok_result(&entry), + Ok(entry) => ok_result(&public_entry(entry)?), Err(e) => Ok(tool_error(e)), } } diff --git a/apps/backend/src/mcp/tools/file.rs b/apps/backend/src/mcp/tools/file.rs index ee0d736c..3489c9c2 100644 --- a/apps/backend/src/mcp/tools/file.rs +++ b/apps/backend/src/mcp/tools/file.rs @@ -7,7 +7,7 @@ use schemars::JsonSchema; use serde::Deserialize; use crate::config::Config; -use crate::mcp::auth::{ok_result, text_result, tool_error}; +use crate::mcp::auth::{ok_result, tool_error}; use crate::middleware::auth::Actor; use crate::models::authorization::Action; use crate::services::{Services, authorization::AuthorizationService}; @@ -99,14 +99,10 @@ pub async fn get_file( pub struct CreateUploadUrlParams { pub site_id: String, pub filename: String, - #[serde(default = "default_content_type")] + /// MIME type accepted by the CMS upload whitelist. pub content_type: String, } -fn default_content_type() -> String { - "application/octet-stream".to_string() -} - pub async fn create_upload_url( authorization: &Arc, services: &Arc, @@ -123,6 +119,10 @@ pub async fn create_upload_url( return Ok(tool_error(e)); } + if let Err(error) = crate::services::file::FileService::validate_filename(¶ms.0.filename) { + return Ok(tool_error(error)); + } + // Fail fast at mint time instead of returning a URL doomed to a 400 PUT. if !crate::utils::content_types::is_allowed(¶ms.0.content_type) { return Ok(tool_error(crate::services::error::ServiceError::BadRequest(format!( @@ -202,7 +202,10 @@ pub async fn delete_file( } match services.file.soft_delete(¶ms.0.file_id, &site_id).await { - Ok(_) => Ok(text_result("File deleted")), + Ok(n) if n > 0 => ok_result(&serde_json::json!({"deleted": true})), + Ok(_) => Ok(tool_error(crate::services::error::ServiceError::NotFound( + "File not found".into(), + ))), Err(e) => Ok(tool_error(e)), } } @@ -230,7 +233,7 @@ pub async fn restore_file( match services.file.restore(¶ms.0.file_id, &site_id).await { Ok(n) => { if n > 0 { - Ok(text_result("File restored")) + ok_result(&serde_json::json!({"restored": true})) } else { Ok(tool_error(crate::services::error::ServiceError::NotFound( "File not found or not deleted".into(), diff --git a/apps/backend/src/mcp/tools/mod.rs b/apps/backend/src/mcp/tools/mod.rs index 461bd92f..ed3f84c1 100644 --- a/apps/backend/src/mcp/tools/mod.rs +++ b/apps/backend/src/mcp/tools/mod.rs @@ -1,6 +1,4 @@ -pub mod collection; pub mod entry; pub mod file; pub mod singleton; pub mod site; -pub mod webhook; diff --git a/apps/backend/src/mcp/tools/singleton.rs b/apps/backend/src/mcp/tools/singleton.rs index 390b7654..a18366a2 100644 --- a/apps/backend/src/mcp/tools/singleton.rs +++ b/apps/backend/src/mcp/tools/singleton.rs @@ -16,6 +16,7 @@ use crate::storage::StorageRegistry; #[derive(Debug, Deserialize, JsonSchema)] pub struct ListSingletonsParams { pub site_id: String, + pub include_drafts: Option, } pub async fn list_singletons( @@ -26,13 +27,25 @@ pub async fn list_singletons( ) -> Result { let site_id = params.0.site_id; if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentRead) + .require_site_action( + actor, + &site_id, + if params.0.include_drafts.unwrap_or(false) { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) .await { return Ok(tool_error(e)); } - match services.singleton.list_singletons(&site_id).await { - Ok(singletons) => ok_result(&singletons), + match services + .singleton + .list_singletons_visible(&site_id, !params.0.include_drafts.unwrap_or(false)) + .await + { + Ok(singletons) => ok_result(&serde_json::json!({ "singletons": singletons })), Err(e) => Ok(tool_error(e)), } } @@ -40,6 +53,7 @@ pub async fn list_singletons( #[derive(Debug, Deserialize, JsonSchema)] pub struct GetSingletonParams { pub site_id: String, + pub include_drafts: Option, pub slug: String, } @@ -52,7 +66,15 @@ pub async fn get_singleton( ) -> Result { let site_id = params.0.site_id.clone(); if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::ContentRead) + .require_site_action( + actor, + &site_id, + if params.0.include_drafts.unwrap_or(false) { + Action::ContentPreviewRead + } else { + Action::ContentRead + }, + ) .await { return Ok(tool_error(e)); @@ -74,7 +96,12 @@ pub async fn get_singleton( match services .singleton - .get_singleton(&site_id, ¶ms.0.slug, storage) + .get_singleton_visible( + &site_id, + ¶ms.0.slug, + storage, + !params.0.include_drafts.unwrap_or(false), + ) .await { Ok(singleton) => ok_result(&singleton), @@ -89,6 +116,7 @@ pub struct UpdateSingletonParams { #[schemars(with = "ArbitraryJson")] pub data: serde_json::Value, pub change_summary: Option, + pub expected_version: Option, } pub async fn update_singleton( @@ -114,6 +142,7 @@ pub async fn update_singleton( ¶ms.0.data, created_by, params.0.change_summary.as_deref(), + params.0.expected_version.as_deref(), ) .await { diff --git a/apps/backend/src/mcp/tools/site.rs b/apps/backend/src/mcp/tools/site.rs index 89e114aa..abdd761c 100644 --- a/apps/backend/src/mcp/tools/site.rs +++ b/apps/backend/src/mcp/tools/site.rs @@ -37,28 +37,3 @@ pub async fn get_site( Err(e) => Ok(tool_error(e)), } } - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct UpdateSiteParams { - pub site_id: String, - pub name: Option, -} - -pub async fn update_site( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::SiteManage) - .await - { - return Ok(tool_error(e)); - } - match services.site.update_site(&site_id, params.0.name.as_deref()).await { - Ok(site) => ok_result(&site), - Err(e) => Ok(tool_error(e)), - } -} diff --git a/apps/backend/src/mcp/tools/webhook.rs b/apps/backend/src/mcp/tools/webhook.rs deleted file mode 100644 index a98dcf83..00000000 --- a/apps/backend/src/mcp/tools/webhook.rs +++ /dev/null @@ -1,238 +0,0 @@ -use std::collections::HashMap; -use std::sync::Arc; - -use rmcp::ErrorData as McpError; -use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::CallToolResult; -use schemars::JsonSchema; -use serde::Deserialize; - -use crate::mcp::auth::{ok_result, tool_error}; -use crate::mcp::schema::ArbitraryJson; -use crate::middleware::auth::Actor; -use crate::models::authorization::Action; -use crate::services::{Services, authorization::AuthorizationService}; - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ListWebhooksParams { - pub site_id: String, -} - -pub async fn list_webhooks( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id; - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksRead) - .await - { - return Ok(tool_error(e)); - } - match services.webhook.list_webhooks(&site_id).await { - Ok(webhooks) => ok_result(&webhooks), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct GetWebhookParams { - pub site_id: String, - pub webhook_id: String, -} - -pub async fn get_webhook( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksRead) - .await - { - return Ok(tool_error(e)); - } - match services.webhook.get_webhook(¶ms.0.webhook_id, &site_id).await { - Ok(Some(webhook)) => ok_result(&webhook), - Ok(None) => Ok(tool_error(crate::services::error::ServiceError::NotFound( - "Webhook not found".into(), - ))), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct CreateWebhookParams { - pub site_id: String, - pub label: String, - pub url: String, - #[schemars(with = "ArbitraryJson")] - pub headers: Option, -} - -pub async fn create_webhook( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksWrite) - .await - { - return Ok(tool_error(e)); - } - let headers: HashMap = params - .0 - .headers - .and_then(|h| serde_json::from_value(h).ok()) - .unwrap_or_default(); - match services - .webhook - .create_webhook(&site_id, ¶ms.0.label, ¶ms.0.url, &headers, None) - .await - { - Ok(webhook) => ok_result(&webhook), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct UpdateWebhookParams { - pub site_id: String, - pub webhook_id: String, - pub label: Option, - pub url: Option, - #[schemars(with = "ArbitraryJson")] - pub headers: Option, -} - -pub async fn update_webhook( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksWrite) - .await - { - return Ok(tool_error(e)); - } - let headers: Option> = params.0.headers.and_then(|h| serde_json::from_value(h).ok()); - match services - .webhook - .update_webhook( - ¶ms.0.webhook_id, - &site_id, - params.0.label.as_deref(), - params.0.url.as_deref(), - headers.as_ref(), - ) - .await - { - Ok(webhook) => ok_result(&webhook), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct TriggerWebhookParams { - pub site_id: String, - pub webhook_id: String, -} - -pub async fn trigger_webhook( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksWrite) - .await - { - return Ok(tool_error(e)); - } - match services - .webhook - .trigger_webhook(¶ms.0.webhook_id, &site_id, None) - .await - { - Ok(delivery) => ok_result(&delivery), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct DeleteWebhookParams { - pub site_id: String, - pub webhook_id: String, -} - -pub async fn delete_webhook( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksWrite) - .await - { - return Ok(tool_error(e)); - } - match services.webhook.delete_webhook(¶ms.0.webhook_id, &site_id).await { - Ok(_) => ok_result(&serde_json::json!({"deleted": true})), - Err(e) => Ok(tool_error(e)), - } -} - -#[derive(Debug, Deserialize, JsonSchema)] -pub struct ListWebhookDeliveriesParams { - pub site_id: String, - pub webhook_id: String, - pub page: Option, - pub per_page: Option, -} - -pub async fn list_webhook_deliveries( - authorization: &Arc, - services: &Arc, - actor: &Actor, - params: Parameters, -) -> Result { - let site_id = params.0.site_id.clone(); - if let Err(e) = authorization - .require_site_action(actor, &site_id, Action::WebhooksRead) - .await - { - return Ok(tool_error(e)); - } - let page = params.0.page.unwrap_or(1).max(1); - let per_page = params.0.per_page.unwrap_or(50).clamp(1, 200); - match services - .webhook - .list_deliveries(¶ms.0.webhook_id, &site_id, page, per_page) - .await - { - Ok((deliveries, total)) => { - let response = serde_json::json!({ - "items": deliveries, - "total": total, - "page": page, - "per_page": per_page, - }); - ok_result(&response) - } - Err(e) => Ok(tool_error(e)), - } -} diff --git a/apps/backend/src/mcp/transports/http.rs b/apps/backend/src/mcp/transports/http.rs index 9d254037..871d5872 100644 --- a/apps/backend/src/mcp/transports/http.rs +++ b/apps/backend/src/mcp/transports/http.rs @@ -1,8 +1,8 @@ use std::sync::Arc; -use axum::{Extension, Router, middleware}; +use axum::{Extension, Router}; use rmcp::transport::streamable_http_server::{ - StreamableHttpServerConfig, StreamableHttpService, session::local::LocalSessionManager, + StreamableHttpServerConfig, StreamableHttpService, session::never::NeverSessionManager, }; use tokio_util::sync::CancellationToken; @@ -20,7 +20,11 @@ pub fn mcp_router( cancellation_token: CancellationToken, ) -> Router { let server_config = StreamableHttpServerConfig::default() - .with_stateful_mode(false) + .with_legacy_session_mode(false) + // Per-request metadata is validated by the handler whenever a request + // declares 2026-07-28+, so session-lifecycle clients (2025-06-18, + // 2025-11-25) that initialize and then send only the version header work. + .with_stateless_protocol_metadata_required(false) .with_json_response(true) .with_allowed_hosts(config.mcp_allowed_hosts.clone()) .with_allowed_origins(config.mcp_allowed_origins.clone()) @@ -38,16 +42,12 @@ pub fn mcp_router( service_config.clone(), )) }, - LocalSessionManager::default().into(), + NeverSessionManager::default().into(), server_config, ); Router::new() .nest_service("/mcp", mcp_service) - // `route_layer` (not `layer`) so MCP auth wraps only the matched `/mcp` routes - // and never the router's fallback — otherwise, once merged into the main router, - // this fallback would authenticate every unmatched path (e.g. `/dashboard/`). - .route_layer(middleware::from_fn(crate::mcp::auth::authenticate_mcp_request)) .layer(Extension(repository.clone())) .layer(Extension(config.clone())) } diff --git a/apps/backend/src/mcp/transports/stdio.rs b/apps/backend/src/mcp/transports/stdio.rs index 84c48295..9f6b99c4 100644 --- a/apps/backend/src/mcp/transports/stdio.rs +++ b/apps/backend/src/mcp/transports/stdio.rs @@ -13,25 +13,53 @@ //! newline-delimited JSON-RPC message from stdin, POST it, write the JSON reply back. use serde_json::Value; +use std::time::Duration; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +const LATEST_PROTOCOL_VERSION: &str = "2026-07-28"; +const PROTOCOL_VERSION_META: &str = "io.modelcontextprotocol/protocolVersion"; + +/// Protocol version the client negotiated through `initialize`, if it used the +/// session lifecycle. Requests then carry that version in `MCP-Protocol-Version`. +#[derive(Default)] +struct ProxyState { + negotiated_version: Option, +} + /// Run the proxy loop until stdin closes (EOF → clean exit). /// /// `endpoint` is the fully-qualified MCP URL (e.g. `http://127.0.0.1:3000/mcp`) and /// `token` is the VCMS access token forwarded as the bearer credential. pub async fn serve(endpoint: String, token: String) -> Result<(), Box> { - let client = reqwest::Client::new(); - let mut lines = BufReader::new(tokio::io::stdin()).lines(); + let parsed_endpoint = reqwest::Url::parse(&endpoint)?; + if !matches!(parsed_endpoint.scheme(), "http" | "https") + || !parsed_endpoint.username().is_empty() + || parsed_endpoint.password().is_some() + || parsed_endpoint.query().is_some() + || parsed_endpoint.fragment().is_some() + { + return Err("MCP endpoint must use HTTP(S) without embedded credentials, query, or fragment".into()); + } + let client = reqwest::Client::builder().timeout(Duration::from_secs(30)).build()?; + let mut input = BufReader::new(tokio::io::stdin()); let mut stdout = tokio::io::stdout(); + let mut state = ProxyState::default(); tracing::info!(%endpoint, "MCP stdio proxy active"); - while let Some(line) = lines.next_line().await? { + while let Some(line) = read_message(&mut input).await? { + let Some(line) = line else { + let response = error_line(true, &None, "MCP stdio message exceeds the 8 MiB limit".into()).unwrap(); + stdout.write_all(response.as_bytes()).await?; + stdout.write_all(b"\n").await?; + stdout.flush().await?; + continue; + }; let message = line.trim(); if message.is_empty() { continue; } - if let Some(response) = forward(&client, &endpoint, &token, message).await { + if let Some(response) = forward(&client, &endpoint, &token, &mut state, message).await { stdout.write_all(response.as_bytes()).await?; stdout.write_all(b"\n").await?; stdout.flush().await?; @@ -42,11 +70,62 @@ pub async fn serve(endpoint: String, token: String) -> Result<(), Box(input: &mut R) -> std::io::Result>> { + const LIMIT: usize = 8 * 1024 * 1024; + let mut bytes = Vec::new(); + let mut oversized = false; + loop { + let available = input.fill_buf().await?; + if available.is_empty() { + return if bytes.is_empty() && !oversized { + Ok(None) + } else { + Ok(Some(if oversized { + None + } else { + Some( + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + ) + })) + }; + } + let newline = available.iter().position(|byte| *byte == b'\n'); + let count = newline.map_or(available.len(), |index| index + 1); + if !oversized { + if bytes.len() + count > LIMIT { + oversized = true; + bytes.clear(); + } else { + bytes.extend_from_slice(&available[..count]); + } + } + input.consume(count); + if newline.is_some() { + return Ok(Some(if oversized { + None + } else { + Some( + String::from_utf8(bytes) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::InvalidData, error))?, + ) + })); + } + } +} + /// Forward one JSON-RPC message to the server and return the line to write to stdout, /// or `None` when nothing should be written (a notification, which carries no `id` /// and expects no reply). On a transport failure for a request we synthesize a /// JSON-RPC error so the client sees a clean failure and the proxy keeps running. -async fn forward(client: &reqwest::Client, endpoint: &str, token: &str, message: &str) -> Option { +async fn forward( + client: &reqwest::Client, + endpoint: &str, + token: &str, + state: &mut ProxyState, + message: &str, +) -> Option { let parsed = serde_json::from_str::(message).ok(); // Requests and batches expect a response; a lone notification object (no `id`) // does not. Unparseable input is forwarded so the server returns a proper @@ -57,12 +136,76 @@ async fn forward(client: &reqwest::Client, endpoint: &str, token: &str, message: _ => (true, None), // unparseable → let the server reject it }; + let method = parsed + .as_ref() + .and_then(|value| value.get("method")) + .and_then(Value::as_str) + .unwrap_or_default(); + let tool_name = if method == "tools/call" { + parsed + .as_ref() + .and_then(|value| value.get("params")) + .and_then(|value| value.get("name")) + .and_then(Value::as_str) + .unwrap_or(method) + } else if method == "resources/read" { + parsed + .as_ref() + .and_then(|value| value.get("params")) + .and_then(|value| value.get("uri")) + .and_then(Value::as_str) + .unwrap_or(method) + } else { + method + }; + + // Forward the client's own protocol version: from `initialize`, from inline + // request metadata, or from an earlier `initialize`. A client that sends none + // is treated as a latest-revision client and gets the required metadata. + let params = parsed.as_ref().and_then(|value| value.get("params")); + let version_from = |value: Option<&Value>| value.and_then(Value::as_str).map(str::to_owned); + let initialize_version = (method == "initialize") + .then(|| version_from(params.and_then(|params| params.get("protocolVersion")))) + .flatten(); + let declared_version = initialize_version + .clone() + .or_else(|| version_from(params.and_then(|params| params.get("_meta")?.get(PROTOCOL_VERSION_META)))) + .or_else(|| state.negotiated_version.clone()); + let outbound_message = match (&declared_version, parsed.clone()) { + (None, Some(Value::Object(mut object))) => { + let mut params = object.remove("params").unwrap_or_else(|| serde_json::json!({})); + if let Some(meta) = params.as_object_mut().and_then(|params| { + params + .entry("_meta") + .or_insert_with(|| serde_json::json!({})) + .as_object_mut() + }) { + meta.insert(PROTOCOL_VERSION_META.into(), LATEST_PROTOCOL_VERSION.into()); + meta.insert( + "io.modelcontextprotocol/clientInfo".into(), + serde_json::json!({"name": "vcms-stdio-proxy", "version": env!("CARGO_PKG_VERSION")}), + ); + meta.insert( + "io.modelcontextprotocol/clientCapabilities".into(), + serde_json::json!({}), + ); + } + object.insert("params".into(), params); + Value::Object(object).to_string() + } + _ => message.to_string(), + }; + let protocol_version = declared_version.as_deref().unwrap_or(LATEST_PROTOCOL_VERSION); + let result = client .post(endpoint) .header(reqwest::header::AUTHORIZATION, format!("Bearer {token}")) .header(reqwest::header::CONTENT_TYPE, "application/json") .header(reqwest::header::ACCEPT, "application/json, text/event-stream") - .body(message.to_string()) + .header("MCP-Protocol-Version", protocol_version) + .header("Mcp-Method", method) + .header("Mcp-Name", tool_name) + .body(outbound_message) .send() .await; @@ -103,6 +246,15 @@ async fn forward(client: &reqwest::Client, endpoint: &str, token: &str, message: // A non-2xx is an auth/host/transport failure (e.g. 401 for a bad token), not a // JSON-RPC reply. Wrap it so the client sees a proper JSON-RPC error envelope. if !status.is_success() { + // Modern HTTP errors can be valid JSON-RPC replies. Preserve their + // protocol code instead of replacing every 400/404 with -32603. + if let Ok(value) = serde_json::from_str::(&body) + && value["jsonrpc"] == "2.0" + && value["error"].is_object() + && value["id"] == id.clone().unwrap_or(Value::Null) + { + return Some(value.to_string()); + } let detail = http_error_detail(&body); return error_line(needs_response, &id, format!("vcms server returned {status}: {detail}")); } @@ -125,11 +277,17 @@ async fn forward(client: &reqwest::Client, endpoint: &str, token: &str, message: // Re-serialize compactly so the line carries no embedded newlines (MCP stdio // framing is one message per line). Fall back to the raw payload if it isn't JSON. - Some( - serde_json::from_str::(payload) - .map(|value| value.to_string()) - .unwrap_or_else(|_| payload.replace('\n', " ")), - ) + match serde_json::from_str::(payload) { + Ok(value) => { + if initialize_version.is_some() + && let Some(version) = value.pointer("/result/protocolVersion").and_then(Value::as_str) + { + state.negotiated_version = Some(version.to_owned()); + } + Some(value.to_string()) + } + Err(_) => error_line(needs_response, &id, "vcms server returned invalid JSON".into()), + } } /// Build a JSON-RPC error line for a failed request, or `None` for a notification @@ -196,10 +354,16 @@ mod tests { match parsed.get("id") { None => (StatusCode::ACCEPTED, response_headers, String::new()), Some(id) => { + let protocol = header("mcp-protocol-version"); let reply = serde_json::json!({ "jsonrpc": "2.0", "id": id, - "result": { "auth": auth, "accept": accept }, + "result": { + "auth": auth, + "accept": accept, + "protocol": protocol, + "protocolVersion": parsed.pointer("/params/protocolVersion"), + }, }); (StatusCode::OK, response_headers, reply.to_string()) } @@ -220,6 +384,7 @@ mod tests { &reqwest::Client::new(), &endpoint, "vcms_site_test", + &mut ProxyState::default(), r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#, ) .await @@ -238,6 +403,7 @@ mod tests { &reqwest::Client::new(), &endpoint, "t", + &mut ProxyState::default(), r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#, ) .await; @@ -251,6 +417,7 @@ mod tests { &reqwest::Client::new(), "http://127.0.0.1:1/mcp", "t", + &mut ProxyState::default(), r#"{"jsonrpc":"2.0","id":7,"method":"tools/list"}"#, ) .await @@ -266,9 +433,35 @@ mod tests { &reqwest::Client::new(), "http://127.0.0.1:1/mcp", "t", + &mut ProxyState::default(), r#"{"jsonrpc":"2.0","method":"ping"}"#, ) .await; assert!(out.is_none()); } + + #[tokio::test] + async fn session_clients_keep_their_negotiated_protocol_version() { + let addr = spawn_stub().await; + let endpoint = format!("http://{addr}/mcp"); + let client = reqwest::Client::new(); + let mut state = ProxyState::default(); + let initialize = r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"1"}}}"#; + let out = forward(&client, &endpoint, "vcms_site_test", &mut state, initialize) + .await + .unwrap(); + let value: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(value["result"]["protocol"], "2025-06-18"); + let out = forward( + &client, + &endpoint, + "vcms_site_test", + &mut state, + r#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#, + ) + .await + .unwrap(); + let value: Value = serde_json::from_str(&out).unwrap(); + assert_eq!(value["result"]["protocol"], "2025-06-18"); + } } diff --git a/apps/backend/src/middleware/api_auth.rs b/apps/backend/src/middleware/api_auth.rs index 86183deb..8d8bd43f 100644 --- a/apps/backend/src/middleware/api_auth.rs +++ b/apps/backend/src/middleware/api_auth.rs @@ -7,7 +7,7 @@ use axum::{ }; use crate::config::Config; -use crate::middleware::auth::verify_access_token; +use crate::middleware::auth::{Actor, AuthContext, AuthMethod, verify_access_token}; use crate::repository::Repository; pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { @@ -15,7 +15,7 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { .headers() .get("Authorization") .and_then(|v| v.to_str().ok()) - .and_then(|v| v.strip_prefix("Bearer ")) + .and_then(crate::middleware::auth::parse_bearer_header) .map(|v| v.trim().to_string()); let token = match auth_header { @@ -56,6 +56,15 @@ pub async fn api_auth_middleware(mut request: Request, next: Next) -> Response { Err((status, err)) => return (status, err).into_response(), }; + let auth_method = match &actor { + Actor::ApiKey(_) => AuthMethod::ApiKey, + Actor::PersonalToken(_) => AuthMethod::PersonalToken, + Actor::User(_) => AuthMethod::Session, + }; + request.extensions_mut().insert(AuthContext { + actor: actor.clone(), + auth_method, + }); request.extensions_mut().insert(actor); next.run(request).await } diff --git a/apps/backend/src/middleware/auth.rs b/apps/backend/src/middleware/auth.rs index 43131be4..f19f4e72 100644 --- a/apps/backend/src/middleware/auth.rs +++ b/apps/backend/src/middleware/auth.rs @@ -172,11 +172,16 @@ fn extract_cookie_value(parts: &Parts, name: &str) -> Option { None } +pub(crate) fn parse_bearer_header(value: &str) -> Option<&str> { + let (scheme, credential) = value.split_once(' ')?; + let credential = credential.trim(); + (scheme.eq_ignore_ascii_case("Bearer") && !credential.is_empty() && !credential.chars().any(char::is_whitespace)) + .then_some(credential) +} + fn extract_bearer_token(parts: &Parts) -> Option { let auth_header = parts.headers.get("Authorization")?.to_str().ok()?; - auth_header - .strip_prefix("Bearer ") - .map(|token| token.trim().to_string()) + parse_bearer_header(auth_header).map(str::to_owned) } fn extract_csrf_token(parts: &Parts) -> Option { @@ -191,19 +196,7 @@ fn extract_csrf_token(parts: &Parts) -> Option { /// read-only for most requests. pub(crate) const TOUCH_INTERVAL_SECS: i64 = 60; -/// Lenient parse of the backend-specific timestamp texts: RFC3339, Postgres -/// `::text` (`YYYY-MM-DD HH:MM:SS[.fff]+00`), or naive UTC (SQLite). -pub(crate) fn parse_db_timestamp(s: &str) -> Option> { - if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { - return Some(dt.with_timezone(&chrono::Utc)); - } - if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%#z") { - return Some(dt.with_timezone(&chrono::Utc)); - } - chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") - .ok() - .map(|naive| naive.and_utc()) -} +pub(crate) use crate::utils::timestamp::parse_db_timestamp; /// True when a "last seen/used" timestamp is missing, unparseable, or older /// than [`TOUCH_INTERVAL_SECS`] — i.e. it should be rewritten (safe fallback). @@ -253,7 +246,10 @@ pub(crate) async fn verify_access_token( .access_token .find_personal_by_hmac(&token_hmac) .await - .map_err(|_| AuthError::unauthorized("Internal server error"))?; + .map_err(|error| { + tracing::error!(error = ?error, "Token authentication lookup failed"); + AuthError::internal() + })?; let Some((id, user_id, _stored_hmac, expires_at, revoked_at, scopes_json, last_used_at)) = row else { return Err(AuthError::unauthorized("Invalid personal access token")); }; @@ -280,7 +276,10 @@ pub(crate) async fn verify_access_token( .access_token .find_by_hmac(&token_hmac) .await - .map_err(|_| AuthError::unauthorized("Internal server error"))?; + .map_err(|error| { + tracing::error!(error = ?error, "Token authentication lookup failed"); + AuthError::internal() + })?; let Some((token_id, site_id, _stored_hmac, expires_at, revoked_at, scopes_json, last_used_at)) = row else { tracing::warn!("Invalid access token attempt"); return Err(AuthError::unauthorized("Invalid access token")); @@ -329,10 +328,10 @@ pub fn is_token_not_expired(expires_at: Option<&str>) -> bool { let now = chrono::Utc::now(); - match chrono::DateTime::parse_from_rfc3339(exp) { - Ok(dt) => dt >= now, - Err(e) => { - tracing::warn!(error = %e, expires_at = %exp, "Invalid expiry format"); + match parse_db_timestamp(exp) { + Some(datetime) => datetime > now, + None => { + tracing::warn!(expires_at = %exp, "Invalid expiry format"); false } } @@ -350,12 +349,12 @@ pub async fn require_site_action( if !Authorizer::token_hard_denied(action) && scopes_allow_action(&key.scopes, action) { Ok(()) } else { - Err(AuthError::insufficient_permission("token scope")) + Err(token_scope_denied(action)) } } Actor::PersonalToken(token) => { if Authorizer::token_hard_denied(action) || !scopes_allow_action(&token.scopes, action) { - return Err(AuthError::insufficient_permission("token scope")); + return Err(token_scope_denied(action)); } check_site_action_repo(repository, &token.user_id, &ctx.site_id, action).await } @@ -363,49 +362,41 @@ pub async fn require_site_action( } } +fn token_scope_denied(action: Action) -> (StatusCode, Json) { + match scope_for_action(action) { + Some(scope) if !Authorizer::token_hard_denied(action) => AuthError::insufficient_permission(scope.as_str()), + _ => AuthError::site_token_denied(), + } +} + pub const fn scope_for_action(action: Action) -> Option { Some(match action { Action::SiteRead => TokenScope::SiteRead, - Action::SiteManage => TokenScope::SiteSettingsWrite, Action::ContentRead => TokenScope::ContentRead, + Action::ContentPreviewRead => TokenScope::ContentPreviewRead, Action::ContentWrite => TokenScope::ContentWrite, + Action::ContentPublish => TokenScope::ContentPublish, Action::SchemaRead => TokenScope::SchemaRead, - Action::SchemaWrite => TokenScope::SchemaWrite, Action::FilesRead => TokenScope::FilesRead, Action::FilesWrite => TokenScope::FilesWrite, - Action::WebhooksRead => TokenScope::WebhooksRead, - Action::WebhooksWrite => TokenScope::WebhooksWrite, - Action::WebhooksTrigger => TokenScope::WebhooksTrigger, - Action::DeploymentsRead => TokenScope::DeploymentsRead, - Action::DeploymentsWrite => TokenScope::DeploymentsWrite, - Action::DeploymentsTrigger => TokenScope::DeploymentsTrigger, _ => return None, }) } pub fn scopes_allow_action(scopes: &TokenScopes, action: Action) -> bool { - match action { - Action::SiteRead => scopes.contains(&TokenScope::SiteRead) || scopes.contains(&TokenScope::SiteSettingsRead), - _ => scope_for_action(action).is_some_and(|scope| scopes.contains(&scope)), - } + scope_for_action(action).is_some_and(|scope| scopes.contains(&scope)) } pub const fn action_for_scope(scope: TokenScope) -> Option { Some(match scope { - TokenScope::SiteRead | TokenScope::SiteSettingsRead => Action::SiteRead, - TokenScope::SiteSettingsWrite => Action::SiteManage, + TokenScope::SiteRead => Action::SiteRead, TokenScope::ContentRead => Action::ContentRead, + TokenScope::ContentPreviewRead => Action::ContentPreviewRead, TokenScope::ContentWrite => Action::ContentWrite, + TokenScope::ContentPublish => Action::ContentPublish, TokenScope::SchemaRead => Action::SchemaRead, - TokenScope::SchemaWrite => Action::SchemaWrite, TokenScope::FilesRead => Action::FilesRead, TokenScope::FilesWrite => Action::FilesWrite, - TokenScope::WebhooksRead => Action::WebhooksRead, - TokenScope::WebhooksWrite => Action::WebhooksWrite, - TokenScope::WebhooksTrigger => Action::WebhooksTrigger, - TokenScope::DeploymentsRead => Action::DeploymentsRead, - TokenScope::DeploymentsWrite => Action::DeploymentsWrite, - TokenScope::DeploymentsTrigger => Action::DeploymentsTrigger, TokenScope::McpUse => return None, }) } @@ -562,10 +553,9 @@ mod tests { #[test] fn editor_token_scope_ceiling_follows_site_rbac() { for scope in [ - TokenScope::WebhooksRead, - TokenScope::DeploymentsRead, - TokenScope::DeploymentsTrigger, + TokenScope::ContentPreviewRead, TokenScope::ContentWrite, + TokenScope::ContentPublish, TokenScope::FilesWrite, TokenScope::McpUse, ] { @@ -573,13 +563,11 @@ mod tests { } for scope in [ - TokenScope::SiteSettingsWrite, - TokenScope::SchemaWrite, - TokenScope::WebhooksWrite, - TokenScope::WebhooksTrigger, - TokenScope::DeploymentsWrite, + TokenScope::ContentPreviewRead, + TokenScope::ContentWrite, + TokenScope::ContentPublish, ] { - assert!(!site_role_allows_token_scope(SiteRole::Editor, scope), "{scope:?}"); + assert!(!site_role_allows_token_scope(SiteRole::Viewer, scope), "{scope:?}"); } } } diff --git a/apps/backend/src/middleware/error.rs b/apps/backend/src/middleware/error.rs index 7e44496b..610f1367 100644 --- a/apps/backend/src/middleware/error.rs +++ b/apps/backend/src/middleware/error.rs @@ -1,10 +1,6 @@ use axum::{Json, http::StatusCode}; use serde::Serialize; -pub fn unauthorized_error(message: &str) -> (StatusCode, String) { - (StatusCode::UNAUTHORIZED, message.to_string()) -} - #[derive(Debug, Clone, Serialize)] pub struct AuthError { pub error: String, @@ -12,6 +8,16 @@ pub struct AuthError { } impl AuthError { + pub fn internal() -> (StatusCode, Json) { + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(Self { + error: "internal_error".into(), + message: "Internal server error".into(), + }), + ) + } + pub fn site_token_required() -> (StatusCode, Json) { ( StatusCode::UNAUTHORIZED, @@ -27,7 +33,7 @@ impl AuthError { StatusCode::FORBIDDEN, Json(Self { error: "insufficient_permission".into(), - message: format!("Token requires '{}' permission.", permission), + message: format!("Token requires the '{}' scope.", permission), }), ) } diff --git a/apps/backend/src/middleware/rate_limit.rs b/apps/backend/src/middleware/rate_limit.rs index decc06f4..623fc0a9 100644 --- a/apps/backend/src/middleware/rate_limit.rs +++ b/apps/backend/src/middleware/rate_limit.rs @@ -70,7 +70,7 @@ impl RateLimiter { /// otherwise the TCP peer IP (via `ConnectInfo`) is used so clients cannot /// spoof their bucket. Never keys on the `Authorization` header, which would /// leak bearer tokens into server state and let a stolen token grief its owner. - fn extract_client_key(&self, req: &Request) -> String { + pub(crate) fn client_key(&self, req: &Request) -> String { if self.trust_proxy_headers { if let Some(ip) = req .headers() @@ -99,6 +99,41 @@ impl RateLimiter { "unknown".to_string() } + + /// Bucket key for a gRPC call, following the same proxy-trust rule as HTTP. + pub(crate) fn grpc_client_key(&self, request: &tonic::Request) -> String { + if self.trust_proxy_headers + && let Some(ip) = ["x-forwarded-for", "x-real-ip"].iter().find_map(|name| { + request + .metadata() + .get(*name) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.split(',').next()) + .map(|value| value.trim().to_owned()) + .filter(|value| !value.is_empty()) + }) + { + return ip; + } + request + .remote_addr() + .map_or_else(|| "unknown".to_owned(), |addr| addr.ip().to_string()) + } +} + +/// Content API limiter, kept apart from the login limiter so API traffic and +/// sign-in attempts never share a budget. +#[derive(Clone)] +pub struct ApiRateLimiter(pub RateLimiter); + +impl ApiRateLimiter { + pub fn from_config(config: &crate::config::Config) -> Self { + Self(RateLimiter::new( + config.api_rate_limit_max_requests, + 60, + config.trust_proxy_headers, + )) + } } pub async fn rate_limit_middleware( @@ -106,9 +141,19 @@ pub async fn rate_limit_middleware( req: Request, next: Next, ) -> Response { - let key = limiter.extract_client_key(&req); + limited(&limiter, req, next).await +} + +pub async fn api_rate_limit_middleware( + axum::extract::Extension(ApiRateLimiter(limiter)): axum::extract::Extension, + req: Request, + next: Next, +) -> Response { + limited(&limiter, req, next).await +} - if limiter.check(&key) { +async fn limited(limiter: &RateLimiter, req: Request, next: Next) -> Response { + if limiter.check(&limiter.client_key(&req)) { next.run(req).await } else { ( diff --git a/apps/backend/src/middleware/site_resolver.rs b/apps/backend/src/middleware/site_resolver.rs index 5218fe61..9b449a7a 100644 --- a/apps/backend/src/middleware/site_resolver.rs +++ b/apps/backend/src/middleware/site_resolver.rs @@ -17,7 +17,7 @@ pub struct SiteIdParam { site_id: String, } -pub async fn api_site_resolver(mut request: Request, next: Next) -> Response { +pub async fn api_site_resolver(request: Request, next: Next) -> Response { let actor = match request.extensions().get::() { Some(actor) => actor.clone(), None => { @@ -29,55 +29,32 @@ pub async fn api_site_resolver(mut request: Request, next: Next) -> Response { } }; - let site_id = match &actor { - Actor::ApiKey(k) => k.site_id.clone(), - Actor::PersonalToken(_) => match request - .headers() - .get("x-vcms-site") - .and_then(|value| value.to_str().ok()) - .map(str::trim) - { - Some(value) if !value.is_empty() => value.to_string(), - _ => { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "missing_site_context", - "message": "X-VCMS-Site is required for personal tokens" - })), - ) - .into_response(); - } - }, + let (mut parts, body) = request.into_parts(); + let site_id = match Path::::from_request_parts(&mut parts, &()).await { + Ok(params) if !params.site_id.trim().is_empty() => params.site_id.clone(), _ => { return ( - StatusCode::FORBIDDEN, + StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "forbidden", - "message": "Public API requires API key authentication" + "error": "missing_site_context", + "message": "site_id is required in the public API path" })), ) .into_response(); } }; - if let Actor::PersonalToken(token) = &actor { - let repository = match request.extensions().get::() { - Some(value) => value.clone(), - None => { - return ( - StatusCode::INTERNAL_SERVER_ERROR, - Json(serde_json::json!({"error":"internal_error"})), - ) - .into_response(); - } - }; - if let Err((status, error)) = - crate::middleware::auth::check_site_action_repo(&repository, &token.user_id, &site_id, Action::SiteRead) - .await - { - return (status, error).into_response(); - } + if let Actor::ApiKey(key) = &actor + && key.site_id != site_id + { + return ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "site_scope_mismatch", + "message": "Token is not authorized for this site" + })), + ) + .into_response(); } let auth_method = if matches!(actor, Actor::PersonalToken(_)) { @@ -88,8 +65,8 @@ pub async fn api_site_resolver(mut request: Request, next: Next) -> Response { let auth = AuthContext { actor, auth_method }; let ctx = RequestContext { site_id, auth }; - request.extensions_mut().insert(ctx); - next.run(request).await + parts.extensions.insert(ctx); + next.run(Request::from_parts(parts, body)).await } pub async fn dashboard_site_resolver(request: Request, next: Next) -> Response { diff --git a/apps/backend/src/models/access_token.rs b/apps/backend/src/models/access_token.rs index 6965c111..719dc04b 100644 --- a/apps/backend/src/models/access_token.rs +++ b/apps/backend/src/models/access_token.rs @@ -8,55 +8,42 @@ use utoipa::ToSchema; pub enum TokenScope { #[serde(rename = "site.read")] SiteRead, - #[serde(rename = "site.settings.read")] - SiteSettingsRead, - #[serde(rename = "site.settings.write")] - SiteSettingsWrite, #[serde(rename = "content.read")] ContentRead, + #[serde(rename = "content.preview.read")] + ContentPreviewRead, #[serde(rename = "content.write")] ContentWrite, + #[serde(rename = "content.publish")] + ContentPublish, #[serde(rename = "files.read")] FilesRead, #[serde(rename = "files.write")] FilesWrite, #[serde(rename = "schema.read")] SchemaRead, - #[serde(rename = "schema.write")] - SchemaWrite, - #[serde(rename = "webhooks.read")] - WebhooksRead, - #[serde(rename = "webhooks.write")] - WebhooksWrite, - #[serde(rename = "webhooks.trigger")] - WebhooksTrigger, - #[serde(rename = "deployments.read")] - DeploymentsRead, - #[serde(rename = "deployments.write")] - DeploymentsWrite, - #[serde(rename = "deployments.trigger")] - DeploymentsTrigger, #[serde(rename = "mcp.use")] McpUse, } -pub type TokenScopes = BTreeSet; -pub fn scopes_can_write(scopes: &TokenScopes) -> bool { - scopes.iter().any(|scope| { - matches!( - scope, - TokenScope::SiteSettingsWrite - | TokenScope::ContentWrite - | TokenScope::FilesWrite - | TokenScope::SchemaWrite - | TokenScope::WebhooksWrite - | TokenScope::WebhooksTrigger - | TokenScope::DeploymentsWrite - | TokenScope::DeploymentsTrigger - ) - }) +impl TokenScope { + /// Wire name, identical to the serde representation. + pub const fn as_str(self) -> &'static str { + match self { + TokenScope::SiteRead => "site.read", + TokenScope::ContentRead => "content.read", + TokenScope::ContentPreviewRead => "content.preview.read", + TokenScope::ContentWrite => "content.write", + TokenScope::ContentPublish => "content.publish", + TokenScope::FilesRead => "files.read", + TokenScope::FilesWrite => "files.write", + TokenScope::SchemaRead => "schema.read", + TokenScope::McpUse => "mcp.use", + } + } } +pub type TokenScopes = BTreeSet; pub fn encode_scopes(scopes: &TokenScopes) -> Result { serde_json::to_string(scopes) } @@ -150,3 +137,25 @@ pub struct PersonalAccessTokenResponse { pub token_info: PersonalAccessTokenView, pub token: String, } + +#[cfg(test)] +mod scope_name_tests { + use super::TokenScope; + + #[test] + fn scope_names_match_their_wire_format() { + for scope in [ + TokenScope::SiteRead, + TokenScope::ContentRead, + TokenScope::ContentPreviewRead, + TokenScope::ContentWrite, + TokenScope::ContentPublish, + TokenScope::FilesRead, + TokenScope::FilesWrite, + TokenScope::SchemaRead, + TokenScope::McpUse, + ] { + assert_eq!(serde_json::to_value(scope).unwrap(), scope.as_str()); + } + } +} diff --git a/apps/backend/src/models/authorization.rs b/apps/backend/src/models/authorization.rs index 773c6c17..9f7191b5 100644 --- a/apps/backend/src/models/authorization.rs +++ b/apps/backend/src/models/authorization.rs @@ -86,7 +86,9 @@ pub enum Action { SiteManage, SiteDelete, ContentRead, + ContentPreviewRead, ContentWrite, + ContentPublish, SchemaRead, SchemaWrite, FilesRead, @@ -152,7 +154,8 @@ impl Authorizer { | Action::FilesRead | Action::WebhooksRead | Action::DeploymentsRead => true, - Action::ContentWrite | Action::FilesWrite | Action::DeploymentsTrigger => { + Action::ContentPreviewRead => matches!(role, SiteRole::Editor), + Action::ContentWrite | Action::ContentPublish | Action::FilesWrite | Action::DeploymentsTrigger => { matches!(role, SiteRole::Editor) } _ => false, @@ -181,12 +184,14 @@ impl Authorizer { match action { Action::SiteRead | Action::ContentRead + | Action::ContentPreviewRead | Action::SchemaRead | Action::FilesRead | Action::WebhooksRead | Action::DeploymentsRead => true, Action::SiteManage | Action::ContentWrite + | Action::ContentPublish | Action::SchemaWrite | Action::FilesWrite | Action::WebhooksWrite diff --git a/apps/backend/src/models/collection.rs b/apps/backend/src/models/collection.rs index 9e2a16f4..599ac450 100644 --- a/apps/backend/src/models/collection.rs +++ b/apps/backend/src/models/collection.rs @@ -14,6 +14,37 @@ pub struct Collection { pub updated_at: String, } +#[derive(Serialize, ToSchema, Clone)] +pub struct PublicCollection { + pub id: String, + pub site_id: String, + pub name: String, + pub slug: String, + pub definition: serde_json::Value, + pub is_singleton: bool, + pub created_at: String, + pub updated_at: String, +} + +impl TryFrom for PublicCollection { + type Error = String; + + fn try_from(collection: Collection) -> Result { + let definition = serde_json::from_str(&collection.definition) + .map_err(|error| format!("stored collection definition is invalid JSON: {error}"))?; + Ok(Self { + id: collection.id, + site_id: collection.site_id, + name: collection.name, + slug: collection.slug, + definition, + is_singleton: collection.is_singleton, + created_at: collection.created_at, + updated_at: collection.updated_at, + }) + } +} + #[derive(Deserialize, ToSchema)] pub struct CreateCollection { pub name: String, @@ -33,6 +64,7 @@ pub struct UpdateCollection { pub struct UpdateSingletonData { pub data: serde_json::Value, pub change_summary: Option, + pub expected_version: Option, } #[derive(Serialize, ToSchema)] @@ -44,6 +76,7 @@ pub struct SingletonResponse { pub definition: serde_json::Value, pub data: Option, pub entry_id: Option, + pub version: Option, pub created_at: String, pub updated_at: String, } diff --git a/apps/backend/src/models/entry.rs b/apps/backend/src/models/entry.rs index a4f04165..48513783 100644 --- a/apps/backend/src/models/entry.rs +++ b/apps/backend/src/models/entry.rs @@ -13,9 +13,50 @@ pub struct Entry { pub singleton_collection_id: Option, pub created_at: String, pub updated_at: String, + /// Opaque optimistic-concurrency version; changes on every database update. + pub version: String, pub published_at: Option, } +/// Stable public representation. Database rows keep JSON text for backend +/// portability, but external APIs must never expose serialized JSON strings. +#[derive(Serialize, ToSchema, Clone)] +pub struct PublicEntry { + pub id: String, + pub site_id: String, + pub collection_id: String, + pub data: serde_json::Value, + pub slug: String, + pub status: String, + pub singleton_collection_id: Option, + pub created_at: String, + pub updated_at: String, + pub version: String, + pub published_at: Option, +} + +impl TryFrom for PublicEntry { + type Error = String; + + fn try_from(entry: Entry) -> Result { + let data = + serde_json::from_str(&entry.data).map_err(|error| format!("stored entry data is invalid JSON: {error}"))?; + Ok(Self { + id: entry.id, + site_id: entry.site_id, + collection_id: entry.collection_id, + data, + slug: entry.slug, + status: entry.status, + singleton_collection_id: entry.singleton_collection_id, + created_at: entry.created_at, + updated_at: entry.updated_at, + version: entry.version.clone(), + published_at: entry.published_at, + }) + } +} + #[derive(Deserialize, ToSchema)] pub struct CreateEntry { pub collection_id: String, @@ -29,6 +70,7 @@ pub struct UpdateEntry { pub slug: Option, pub status: Option, pub change_summary: Option, + pub expected_version: Option, } #[derive(Serialize, FromRow, Clone)] diff --git a/apps/backend/src/repository/error.rs b/apps/backend/src/repository/error.rs index 66467d05..d1d41eb0 100644 --- a/apps/backend/src/repository/error.rs +++ b/apps/backend/src/repository/error.rs @@ -2,6 +2,9 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum RepositoryError { + #[error("Version precondition failed")] + PreconditionFailed, + #[error("Record not found")] NotFound, diff --git a/apps/backend/src/repository/postgres/collection.rs b/apps/backend/src/repository/postgres/collection.rs index 11544168..2df57e69 100644 --- a/apps/backend/src/repository/postgres/collection.rs +++ b/apps/backend/src/repository/postgres/collection.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use sqlx::PgPool; use crate::models::collection::Collection; -use crate::models::entry::Entry; use crate::repository::error::RepositoryError; use crate::repository::traits::CollectionRepository; @@ -87,18 +86,66 @@ impl CollectionRepository for PostgresCollectionRepository { self.get_by_id(id).await?.ok_or(RepositoryError::NotFound) } - async fn update(&self, id: &str, name: &str, slug: &str, definition: &str) -> Result { - sqlx::query( - "UPDATE collections SET name = $1, slug = $2, definition = $3::jsonb, updated_at = NOW() WHERE id = $4", + async fn update( + &self, + id: &str, + name: &str, + slug: &str, + definition: &str, + rename_map: &std::collections::HashMap, + expected_definition: &str, + ) -> Result { + let mut transaction = self.pool.begin().await?; + sqlx::query("SELECT id FROM collections WHERE id=$1 FOR UPDATE") + .bind(id) + .execute(&mut *transaction) + .await?; + let changed = sqlx::query( + "UPDATE collections SET name = $1, slug = $2, definition = $3::jsonb, updated_at = NOW() WHERE id = $4 AND definition = $5::jsonb", ) .bind(name) .bind(slug) .bind(definition) .bind(id) - .execute(&self.pool) + .bind(expected_definition) + .execute(&mut *transaction) .await?; - - self.get_by_id(id).await?.ok_or(RepositoryError::NotFound) + if changed.rows_affected() == 0 { + return Err(RepositoryError::PreconditionFailed); + } + let collection = sqlx::query_as::<_,Collection>("SELECT id,site_id,name,slug,definition::text as definition,is_singleton,created_at::text as created_at,updated_at::text as updated_at FROM collections WHERE id = $1").bind(id).fetch_one(&mut *transaction).await?; + if !rename_map.is_empty() || collection.is_singleton { + let ids: Vec = sqlx::query_scalar("SELECT id FROM entries WHERE collection_id=$1 ORDER BY id") + .bind(id) + .fetch_all(&mut *transaction) + .await?; + for entry_id in ids { + let current: Option = + sqlx::query_scalar("SELECT data::text FROM entries WHERE id=$1 FOR UPDATE") + .bind(&entry_id) + .fetch_optional(&mut *transaction) + .await?; + let Some(current) = current else { + continue; + }; + let mut data: serde_json::Value = + serde_json::from_str(¤t).map_err(|error| RepositoryError::Database(error.to_string()))?; + if !rename_map.is_empty() { + let object = data + .as_object_mut() + .ok_or_else(|| RepositoryError::Database("Stored entry data is not an object".into()))?; + let renamed = object + .iter() + .map(|(key, value)| (rename_map.get(key).unwrap_or(key).clone(), value.clone())) + .collect(); + *object = renamed; + } + sqlx::query("UPDATE entries SET data=$1::jsonb,slug=CASE WHEN singleton_collection_id IS NOT NULL THEN $2 ELSE slug END,updated_at=NOW() WHERE id=$3").bind(data.to_string()).bind(slug).bind(&entry_id).execute(&mut *transaction).await?; + super::entry::sync_references_tx(&mut transaction, &entry_id, &collection.site_id, &data).await?; + } + } + transaction.commit().await?; + Ok(collection) } async fn delete(&self, site_id: &str, slug: &str) -> Result { @@ -110,46 +157,4 @@ impl CollectionRepository for PostgresCollectionRepository { Ok(result.rows_affected()) } - - async fn get_content_for_migration(&self, collection_id: &str) -> Result, RepositoryError> { - let result = sqlx::query_as::<_, Entry>( - "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, published_at::text as published_at FROM entries WHERE collection_id = $1", - ) - .bind(collection_id) - .fetch_all(&self.pool) - .await?; - - Ok(result) - } - - async fn migrate_content_field_renames( - &self, - entry_items: &[Entry], - rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - // One transaction for the whole migration: per-statement commit overhead - // dominated this loop, and a partial rename is never left behind. - let mut tx = self.pool.begin().await?; - for entry in entry_items { - if let Ok(mut data) = serde_json::from_str::(&entry.data) - && let Some(obj) = data.as_object_mut() - { - let mut renamed = serde_json::Map::new(); - for (key, value) in obj.iter() { - let new_key = rename_map.get(key).cloned().unwrap_or_else(|| key.clone()); - renamed.insert(new_key, value.clone()); - } - let new_data_str = - serde_json::to_string(&serde_json::Value::Object(renamed)).unwrap_or_else(|_| entry.data.clone()); - - sqlx::query("UPDATE entries SET data = $1::jsonb, updated_at = NOW() WHERE id = $2") - .bind(&new_data_str) - .bind(&entry.id) - .execute(&mut *tx) - .await?; - } - } - tx.commit().await?; - Ok(()) - } } diff --git a/apps/backend/src/repository/postgres/entry.rs b/apps/backend/src/repository/postgres/entry.rs index cbeaca43..b4cc6163 100644 --- a/apps/backend/src/repository/postgres/entry.rs +++ b/apps/backend/src/repository/postgres/entry.rs @@ -1,8 +1,6 @@ use async_trait::async_trait; -use regex::Regex; use serde_json::Value; use sqlx::PgPool; -use std::sync::LazyLock; use uuid::Uuid; use crate::models::entry::{Entry, EntryRevision}; @@ -25,7 +23,7 @@ impl PostgresEntryRepository { impl EntryRepository for PostgresEntryRepository { async fn get_by_id(&self, id: &str, site_id: &str, published_only: bool) -> Result, RepositoryError> { let mut query = String::from( - "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, published_at::text as published_at + "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1 AND site_id = $2", ); @@ -42,9 +40,33 @@ impl EntryRepository for PostgresEntryRepository { Ok(result) } + async fn get_by_ids( + &self, + site_id: &str, + ids: &[String], + published_only: bool, + ) -> Result, RepositoryError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let mut query = sqlx::QueryBuilder::::new( + "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE site_id = ", + ); + query.push_bind(site_id).push(" AND id IN ("); + let mut ids_query = query.separated(","); + for id in ids { + ids_query.push_bind(id); + } + query.push(")"); + if published_only { + query.push(" AND status = 'published'"); + } + Ok(query.build_query_as::().fetch_all(&self.pool).await?) + } + async fn get_by_id_any_site(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Entry>( - "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, published_at::text as published_at + "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1", ) .bind(id) @@ -61,7 +83,7 @@ impl EntryRepository for PostgresEntryRepository { WHERE e.site_id = $1", ); let mut query = String::from( - "SELECT e.id, e.site_id, e.collection_id, e.data::text as data, e.slug, e.status, e.singleton_collection_id, e.created_at::text as created_at, e.updated_at::text as updated_at, e.published_at::text as published_at + "SELECT e.id, e.site_id, e.collection_id, e.data::text as data, e.slug, e.status, e.singleton_collection_id, e.created_at::text as created_at, e.updated_at::text as updated_at, e.version::text as version, e.published_at::text as published_at FROM entries e JOIN collections col ON e.collection_id = col.id WHERE e.site_id = $1", @@ -110,9 +132,9 @@ impl EntryRepository for PostgresEntryRepository { q.fetch_one(&self.pool).await? }; - let offset = (params.page - 1) * params.per_page; + let offset = params.page.saturating_sub(1).saturating_mul(params.per_page); query.push_str(&format!( - " ORDER BY e.updated_at DESC LIMIT ${} OFFSET ${}", + " ORDER BY e.updated_at DESC, e.id DESC LIMIT ${} OFFSET ${}", param_index, param_index + 1 )); @@ -140,7 +162,7 @@ impl EntryRepository for PostgresEntryRepository { published_only: bool, ) -> Result, RepositoryError> { let mut query = String::from( - "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, published_at::text as published_at + "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE collection_id = $1", ); let mut bindings: Vec = vec![collection_id.to_string()]; @@ -149,7 +171,8 @@ impl EntryRepository for PostgresEntryRepository { if let Some(s) = status { query.push_str(&format!(" AND status = ${}", param_index)); bindings.push(s.to_string()); - } else if published_only { + } + if published_only { query.push_str(" AND status = 'published'"); } @@ -178,7 +201,7 @@ impl EntryRepository for PostgresEntryRepository { .collect::>() .join(", "); let mut query = format!( - "SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, published_at::text as published_at + "SELECT ROW_NUMBER() OVER (PARTITION BY collection_id ORDER BY created_at DESC, id DESC) AS collection_rank, id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE collection_id IN ({placeholders})" ); let mut bindings: Vec = collection_ids.to_vec(); @@ -186,12 +209,16 @@ impl EntryRepository for PostgresEntryRepository { if let Some(s) = status { query.push_str(&format!(" AND status = ${}", collection_ids.len() + 1)); bindings.push(s.to_string()); - } else if published_only { + } + if published_only { query.push_str(" AND status = 'published'"); } query.push_str(" ORDER BY updated_at DESC"); + // Nested GraphQL reads are bounded per collection. Use the paginated + // entry query for complete traversal rather than materializing all rows. + query = format!("SELECT * FROM ({query}) AS bounded WHERE collection_rank <= 200"); let mut q = sqlx::query_as::<_, Entry>(sqlx::AssertSqlSafe(query.as_str())); for b in &bindings { q = q.bind(b); @@ -200,17 +227,19 @@ impl EntryRepository for PostgresEntryRepository { Ok(q.fetch_all(&self.pool).await?) } - async fn create( - &self, - id: &str, - site_id: &str, - collection_id: &str, - data: &str, - slug: &str, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, params: crate::repository::traits::CreateEntryParams<'_>) -> Result { + let crate::repository::traits::CreateEntryParams { + id, + site_id, + collection_id, + data, + slug, + created_by, + expected_definition, + } = params; let mut tx = self.pool.begin().await?; + lock_definition_tx(&mut tx, collection_id, site_id, expected_definition).await?; sqlx::query("INSERT INTO entries (id, site_id, collection_id, data, slug) VALUES ($1, $2, $3, $4::jsonb, $5)") .bind(id) .bind(site_id) @@ -228,18 +257,20 @@ impl EntryRepository for PostgresEntryRepository { ) .bind(&revision_id) .bind(id) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1").bind(id).fetch_one(&mut *tx).await?; tx.commit().await?; - - self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } async fn update(&self, params: UpdateEntryParams<'_>) -> Result { let UpdateEntryParams { + expected_definition, id, site_id, data, @@ -247,15 +278,28 @@ impl EntryRepository for PostgresEntryRepository { status, created_by, change_summary, + expected_version, } = params; let mut tx = self.pool.begin().await?; - sqlx::query( - "SELECT revision_number FROM entry_revisions WHERE entry_id = $1 ORDER BY revision_number DESC LIMIT 1 FOR UPDATE", - ) - .bind(id) - .execute(&mut *tx) - .await?; + let collection_id: Option = + sqlx::query_scalar("SELECT collection_id FROM entries WHERE id=$1 AND site_id=$2") + .bind(id) + .bind(site_id) + .fetch_optional(&mut *tx) + .await?; + let collection_id = collection_id.ok_or(RepositoryError::NotFound)?; + lock_definition_tx(&mut tx, &collection_id, site_id, expected_definition).await?; + let result = sqlx::query("UPDATE entries SET data = $1::jsonb, slug = $2, published_at = CASE WHEN $3 = 'draft' THEN NULL WHEN status = 'draft' THEN NOW() ELSE published_at END, status = $3, updated_at = NOW() WHERE id = $4 AND site_id = $5 AND ($6::text IS NULL OR version::text = $6)") + .bind(data).bind(slug).bind(status).bind(id).bind(site_id).bind(expected_version) + .execute(&mut *tx).await?; + if result.rows_affected() == 0 { + return Err(if expected_version.is_some() { + RepositoryError::PreconditionFailed + } else { + RepositoryError::NotFound + }); + } let next_number: i64 = sqlx::query_scalar( "SELECT (COALESCE(MAX(revision_number), 0) + 1)::BIGINT FROM entry_revisions WHERE entry_id = $1", @@ -264,17 +308,6 @@ impl EntryRepository for PostgresEntryRepository { .fetch_one(&mut *tx) .await?; - sqlx::query( - "UPDATE entries SET data = $1::jsonb, slug = $2, status = $3, updated_at = NOW() WHERE id = $4 AND site_id = $5", - ) - .bind(data) - .bind(slug) - .bind(status) - .bind(id) - .bind(site_id) - .execute(&mut *tx) - .await?; - let data_json: serde_json::Value = serde_json::from_str(data).unwrap_or(Value::Null); let revision_id = Uuid::now_v7().to_string(); sqlx::query( @@ -284,15 +317,16 @@ impl EntryRepository for PostgresEntryRepository { .bind(&revision_id) .bind(id) .bind(next_number) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1").bind(id).fetch_one(&mut *tx).await?; tx.commit().await?; - - self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } async fn delete(&self, id: &str, site_id: &str) -> Result { @@ -337,31 +371,8 @@ impl EntryRepository for PostgresEntryRepository { } async fn sync_file_references(&self, entry_id: &str, site_id: &str, data: &Value) -> Result<(), RepositoryError> { - let file_ids = extract_file_ids_from_value(data); - let mut tx = self.pool.begin().await?; - - sqlx::query("DELETE FROM entry_file_references WHERE entry_id = $1") - .bind(entry_id) - .execute(&mut *tx) - .await?; - - if !file_ids.is_empty() { - // One multi-row statement instead of a query per file id; the - // SELECT keeps the "file must exist in this site" filter. - sqlx::query( - "INSERT INTO entry_file_references (entry_id, file_id, site_id) - SELECT $1, id, $2 FROM files WHERE site_id = $3 AND id = ANY($4) - ON CONFLICT DO NOTHING", - ) - .bind(entry_id) - .bind(site_id) - .bind(site_id) - .bind(&file_ids) - .execute(&mut *tx) - .await?; - } - + sync_references_tx(&mut tx, entry_id, site_id, data).await?; tx.commit().await?; Ok(()) } @@ -377,7 +388,7 @@ impl EntryRepository for PostgresEntryRepository { .fetch_one(&self.pool) .await?; - let offset = (page - 1) * per_page; + let offset = page.saturating_sub(1).saturating_mul(per_page); let items = sqlx::query_as::<_, EntryRevision>( "SELECT id, entry_id, revision_number, data, created_by, created_at::text as created_at, change_summary FROM entry_revisions WHERE entry_id = $1 ORDER BY revision_number DESC LIMIT $2 OFFSET $3", @@ -477,7 +488,7 @@ impl EntryRepository for PostgresEntryRepository { async fn get_singleton_entry(&self, site_id: &str, slug: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Entry>( - "SELECT e.id, e.site_id, e.collection_id, e.data::text as data, e.slug, e.status, e.singleton_collection_id, e.created_at::text as created_at, e.updated_at::text as updated_at, e.published_at::text as published_at + "SELECT e.id, e.site_id, e.collection_id, e.data::text as data, e.slug, e.status, e.singleton_collection_id, e.created_at::text as created_at, e.updated_at::text as updated_at, e.version::text as version, e.published_at::text as published_at FROM entries e JOIN collections c ON c.id = e.singleton_collection_id WHERE e.site_id = $1 AND c.slug = $2", @@ -492,15 +503,27 @@ impl EntryRepository for PostgresEntryRepository { async fn upsert_singleton_entry( &self, - site_id: &str, - collection_id: &str, - slug: &str, - data: &str, - created_by: Option<&str>, - change_summary: Option<&str>, + params: crate::repository::traits::UpsertSingletonParams<'_>, ) -> Result { + let crate::repository::traits::UpsertSingletonParams { + site_id, + collection_id, + slug, + data, + created_by, + change_summary, + expected_definition, + } = params; let mut tx = self.pool.begin().await?; + // Lock the stable parent row, including when no singleton entry exists yet. + sqlx::query("SELECT id FROM collections WHERE id = $1 AND site_id = $2 FOR UPDATE") + .bind(collection_id) + .bind(site_id) + .execute(&mut *tx) + .await?; + + lock_definition_tx(&mut tx, collection_id, site_id, expected_definition).await?; let existing: Option = sqlx::query_scalar("SELECT id FROM entries WHERE singleton_collection_id = $1 AND site_id = $2") .bind(collection_id) @@ -532,16 +555,16 @@ impl EntryRepository for PostgresEntryRepository { .bind(&revision_id) .bind(&existing_id) .bind(next_number) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, &existing_id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1").bind(&existing_id).fetch_one(&mut *tx).await?; tx.commit().await?; - self.get_by_id_any_site(&existing_id) - .await? - .ok_or(RepositoryError::NotFound) + Ok(entry) } else { let id = Uuid::now_v7().to_string(); sqlx::query( @@ -563,85 +586,60 @@ impl EntryRepository for PostgresEntryRepository { ) .bind(&revision_id) .bind(&id) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, &id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data::text as data, slug, status, singleton_collection_id, created_at::text as created_at, updated_at::text as updated_at, version::text as version, published_at::text as published_at FROM entries WHERE id = $1").bind(&id).fetch_one(&mut *tx).await?; tx.commit().await?; - self.get_by_id_any_site(&id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } } +} - async fn migrate_singleton_field_renames( - &self, - site_id: &str, - collection_id: &str, - rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - let mut tx = self.pool.begin().await?; - - let existing: Option<(String, String)> = - sqlx::query_as("SELECT id, data::text FROM entries WHERE singleton_collection_id = $1 AND site_id = $2") - .bind(collection_id) - .bind(site_id) - .fetch_optional(&mut *tx) - .await?; - - if let Some((id, data_str)) = existing - && let Ok(mut data) = serde_json::from_str::(&data_str) - && let Some(obj) = data.as_object_mut() - { - let mut renamed = serde_json::Map::new(); - for (key, value) in obj.iter() { - let new_key = rename_map.get(key).cloned().unwrap_or_else(|| key.clone()); - renamed.insert(new_key, value.clone()); - } - let new_data_str = - serde_json::to_string(&serde_json::Value::Object(renamed)).unwrap_or_else(|_| data_str.clone()); - - sqlx::query("UPDATE entries SET data = $1::jsonb, updated_at = NOW() WHERE id = $2") - .bind(&new_data_str) - .bind(&id) - .execute(&mut *tx) - .await?; - } - - tx.commit().await?; - Ok(()) +pub use crate::utils::file_references::{extract_file_ids_from_value, extract_file_references_from_value}; + +async fn lock_definition_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + collection_id: &str, + site_id: &str, + expected_definition: Option<&str>, +) -> Result<(), RepositoryError> { + let found: Option = sqlx::query_scalar("SELECT id FROM collections WHERE id=$1 AND site_id=$2 AND ($3::text IS NULL OR definition=$3::jsonb) FOR KEY SHARE").bind(collection_id).bind(site_id).bind(expected_definition).fetch_optional(&mut **transaction).await?; + if found.is_none() { + return Err(RepositoryError::PreconditionFailed); } + Ok(()) } -pub fn extract_file_ids_from_value(value: &Value) -> Vec { - static FILE_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"/api/files/([^/]+)(?:/thumbnail)?").unwrap()); - - let mut ids = Vec::new(); - collect_file_ids(value, &FILE_URL_RE, &mut ids); - ids.sort(); - ids.dedup(); - ids -} +pub(crate) async fn sync_references_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + entry_id: &str, + site_id: &str, + data: &Value, +) -> Result<(), RepositoryError> { + let file_references = extract_file_references_from_value(data); + sqlx::query("DELETE FROM entry_file_references WHERE entry_id = $1") + .bind(entry_id) + .execute(&mut **tx) + .await?; -fn collect_file_ids(value: &Value, re: &Regex, ids: &mut Vec) { - match value { - Value::String(s) => { - for cap in re.captures_iter(s) { - if let Some(m) = cap.get(1) { - ids.push(m.as_str().to_string()); - } - } - } - Value::Array(arr) => { - for item in arr { - collect_file_ids(item, re, ids); - } - } - Value::Object(obj) => { - for val in obj.values() { - collect_file_ids(val, re, ids); - } - } - _ => {} + for references in file_references.chunks(200) { + let mut query = sqlx::QueryBuilder::::new( + "INSERT INTO entry_file_references (entry_id, file_id, site_id, field_name) ", + ); + query.push_values(references, |mut row, (file_id, field_name)| { + row.push_bind(entry_id) + .push_bind(file_id) + .push_bind(site_id) + .push_bind(field_name); + }); + query.push(" ON CONFLICT DO NOTHING"); + query.build().execute(&mut **tx).await?; } + + Ok(()) } diff --git a/apps/backend/src/repository/postgres/file.rs b/apps/backend/src/repository/postgres/file.rs index 98b463c1..268144fc 100644 --- a/apps/backend/src/repository/postgres/file.rs +++ b/apps/backend/src/repository/postgres/file.rs @@ -17,6 +17,28 @@ impl PostgresFileRepository { #[async_trait] impl FileRepository for PostgresFileRepository { + async fn claim_signed_upload(&self, file_id: &str, expires_at: i64) -> Result { + let now = chrono::Utc::now().timestamp(); + if expires_at <= now { + return Ok(false); + } + let mut transaction = self.pool.begin().await?; + sqlx::query("DELETE FROM signed_upload_uses WHERE expires_at <= $1") + .bind(now) + .execute(&mut *transaction) + .await?; + let claimed = + sqlx::query("INSERT INTO signed_upload_uses(file_id, expires_at) VALUES ($1, $2) ON CONFLICT DO NOTHING") + .bind(file_id) + .bind(expires_at) + .execute(&mut *transaction) + .await? + .rows_affected() + > 0; + transaction.commit().await?; + Ok(claimed) + } + async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, File>( "SELECT id, site_id, filename, original_name, mime_type, size, storage_provider, storage_key, thumbnail_key, width, height, deleted_at::text as deleted_at, created_by, created_at::text as created_at @@ -101,7 +123,7 @@ impl FileRepository for PostgresFileRepository { let count_bindings = bindings.clone(); - let offset = (params.page - 1) * params.per_page; + let offset = params.page.saturating_sub(1).saturating_mul(params.per_page); let per_page = params.per_page; query.push_str(&format!( " ORDER BY created_at DESC LIMIT ${} OFFSET ${}", @@ -289,34 +311,13 @@ impl FileRepository for PostgresFileRepository { Ok(result.rows_affected()) } - async fn get_references(&self, file_id: &str) -> Result, RepositoryError> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT DISTINCT e.id, col.name FROM entry_file_references efr - JOIN entries e ON efr.entry_id = e.id - JOIN collections col ON e.collection_id = col.id - WHERE efr.file_id = $1", - ) - .bind(file_id) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .into_iter() - .map(|(entry_id, collection_name)| FileReference { - entry_id, - collection_name, - field_name: String::new(), - }) - .collect()) - } - async fn get_references_for_site( &self, file_id: &str, site_id: &str, ) -> Result, RepositoryError> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT DISTINCT e.id, col.name FROM entry_file_references efr + let rows: Vec<(String, String, String)> = sqlx::query_as( + "SELECT DISTINCT e.id, col.name, efr.field_name FROM entry_file_references efr JOIN entries e ON efr.entry_id = e.id JOIN collections col ON e.collection_id = col.id WHERE efr.file_id = $1 AND e.site_id = $2", @@ -328,10 +329,10 @@ impl FileRepository for PostgresFileRepository { Ok(rows .into_iter() - .map(|(entry_id, collection_name)| FileReference { + .map(|(entry_id, collection_name, field_name)| FileReference { entry_id, collection_name, - field_name: String::new(), + field_name, }) .collect()) } diff --git a/apps/backend/src/repository/postgres/webhook.rs b/apps/backend/src/repository/postgres/webhook.rs index ff12ef30..06bc3b8f 100644 --- a/apps/backend/src/repository/postgres/webhook.rs +++ b/apps/backend/src/repository/postgres/webhook.rs @@ -67,27 +67,29 @@ impl WebhookRepository for PostgresWebhookRepository { async fn update( &self, id: &str, + site_id: &str, label: Option<&str>, url: Option<&str>, headers_encrypted: Option<&str>, ) -> Result { - let existing = self.get_by_id_unscoped(id).await?.ok_or(RepositoryError::NotFound)?; + let existing = self.get_by_id(id, site_id).await?.ok_or(RepositoryError::NotFound)?; let label = label.unwrap_or(&existing.label); let url = url.unwrap_or(&existing.url); let headers = headers_encrypted.unwrap_or(&existing.headers_encrypted); sqlx::query( - "UPDATE site_webhooks SET label = $1, url = $2, headers_encrypted = $3, updated_at = NOW() WHERE id = $4", + "UPDATE site_webhooks SET label = $1, url = $2, headers_encrypted = $3, updated_at = NOW() WHERE id = $4 AND site_id = $5", ) .bind(label) .bind(url) .bind(headers) .bind(id) + .bind(site_id) .execute(&self.pool) .await?; - self.get_by_id_unscoped(id).await?.ok_or(RepositoryError::NotFound) + self.get_by_id(id, site_id).await?.ok_or(RepositoryError::NotFound) } async fn delete(&self, id: &str, site_id: &str) -> Result { @@ -143,7 +145,7 @@ impl WebhookRepository for PostgresWebhookRepository { .fetch_one(&self.pool) .await?; - let offset = (page - 1) * per_page; + let offset = page.saturating_sub(1).saturating_mul(per_page); let items = sqlx::query_as::<_, WebhookDelivery>( "SELECT id, webhook_id, status, status_code, response_body, duration_ms, triggered_by, triggered_at::text as triggered_at FROM site_webhook_deliveries WHERE webhook_id = $1 ORDER BY triggered_at DESC LIMIT $2 OFFSET $3", ) @@ -156,15 +158,3 @@ impl WebhookRepository for PostgresWebhookRepository { Ok((items, total.0)) } } - -impl PostgresWebhookRepository { - async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { - sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at::text as created_at, updated_at::text as updated_at FROM site_webhooks WHERE id = $1", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(RepositoryError::from) - } -} diff --git a/apps/backend/src/repository/sqlite/collection.rs b/apps/backend/src/repository/sqlite/collection.rs index 147ed49d..d00e8822 100644 --- a/apps/backend/src/repository/sqlite/collection.rs +++ b/apps/backend/src/repository/sqlite/collection.rs @@ -2,7 +2,6 @@ use async_trait::async_trait; use sqlx::SqlitePool; use crate::models::collection::Collection; -use crate::models::entry::Entry; use crate::repository::error::RepositoryError; use crate::repository::traits::CollectionRepository; @@ -87,18 +86,66 @@ impl CollectionRepository for SqliteCollectionRepository { self.get_by_id(id).await?.ok_or(RepositoryError::NotFound) } - async fn update(&self, id: &str, name: &str, slug: &str, definition: &str) -> Result { - sqlx::query( - "UPDATE collections SET name = ?, slug = ?, definition = ?, updated_at = datetime('now') WHERE id = ?", + async fn update( + &self, + id: &str, + name: &str, + slug: &str, + definition: &str, + rename_map: &std::collections::HashMap, + expected_definition: &str, + ) -> Result { + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + let changed = sqlx::query( + "UPDATE collections SET name = ?, slug = ?, definition = ?, updated_at = datetime('now') WHERE id = ? AND definition = ?", ) .bind(name) .bind(slug) .bind(definition) .bind(id) - .execute(&self.pool) + .bind(expected_definition) + .execute(&mut *transaction) .await?; - - self.get_by_id(id).await?.ok_or(RepositoryError::NotFound) + if changed.rows_affected() == 0 { + return Err(RepositoryError::PreconditionFailed); + } + let collection = sqlx::query_as::<_, Collection>( + "SELECT id,site_id,name,slug,definition,is_singleton,created_at,updated_at FROM collections WHERE id = ?", + ) + .bind(id) + .fetch_one(&mut *transaction) + .await?; + if !rename_map.is_empty() || collection.is_singleton { + let ids: Vec = sqlx::query_scalar("SELECT id FROM entries WHERE collection_id=? ORDER BY id") + .bind(id) + .fetch_all(&mut *transaction) + .await?; + for entry_id in ids { + let current: Option = sqlx::query_scalar("SELECT data FROM entries WHERE id=?") + .bind(&entry_id) + .fetch_optional(&mut *transaction) + .await?; + let Some(current) = current else { + continue; + }; + let mut data: serde_json::Value = + serde_json::from_str(¤t).map_err(|error| RepositoryError::Database(error.to_string()))?; + if !rename_map.is_empty() { + let object = data + .as_object_mut() + .ok_or_else(|| RepositoryError::Database("Stored entry data is not an object".into()))?; + let renamed = object + .iter() + .map(|(key, value)| (rename_map.get(key).unwrap_or(key).clone(), value.clone())) + .collect(); + *object = renamed; + } + sqlx::query("UPDATE entries SET data=?,slug=CASE WHEN singleton_collection_id IS NOT NULL THEN ? ELSE slug END,updated_at=datetime('now') WHERE id=?").bind(data.to_string()).bind(slug).bind(&entry_id).execute(&mut *transaction).await?; + super::entry::sync_references_tx(&mut transaction, &entry_id, &collection.site_id, &data).await?; + } + } + transaction.commit().await?; + Ok(collection) } async fn delete(&self, site_id: &str, slug: &str) -> Result { @@ -110,46 +157,4 @@ impl CollectionRepository for SqliteCollectionRepository { Ok(result.rows_affected()) } - - async fn get_content_for_migration(&self, collection_id: &str) -> Result, RepositoryError> { - let result = sqlx::query_as::<_, Entry>( - "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, published_at FROM entries WHERE collection_id = ?", - ) - .bind(collection_id) - .fetch_all(&self.pool) - .await?; - - Ok(result) - } - - async fn migrate_content_field_renames( - &self, - entry_items: &[Entry], - rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - // One transaction for the whole migration: per-statement commit overhead - // dominated this loop, and a partial rename is never left behind. - let mut tx = self.pool.begin().await?; - for entry in entry_items { - if let Ok(mut data) = serde_json::from_str::(&entry.data) - && let Some(obj) = data.as_object_mut() - { - let mut renamed = serde_json::Map::new(); - for (key, value) in obj.iter() { - let new_key = rename_map.get(key).cloned().unwrap_or_else(|| key.clone()); - renamed.insert(new_key, value.clone()); - } - let new_data_str = - serde_json::to_string(&serde_json::Value::Object(renamed)).unwrap_or_else(|_| entry.data.clone()); - - sqlx::query("UPDATE entries SET data = ?, updated_at = datetime('now') WHERE id = ?") - .bind(&new_data_str) - .bind(&entry.id) - .execute(&mut *tx) - .await?; - } - } - tx.commit().await?; - Ok(()) - } } diff --git a/apps/backend/src/repository/sqlite/entry.rs b/apps/backend/src/repository/sqlite/entry.rs index 3d709416..d926ee4e 100644 --- a/apps/backend/src/repository/sqlite/entry.rs +++ b/apps/backend/src/repository/sqlite/entry.rs @@ -1,8 +1,6 @@ use async_trait::async_trait; -use regex::Regex; use serde_json::Value; use sqlx::SqlitePool; -use std::sync::LazyLock; use tracing::{debug, error}; use uuid::Uuid; @@ -12,8 +10,6 @@ use crate::repository::traits::{ EntriesListResult, EntryRepository, ListEntriesParams, RevisionsListResult, UpdateEntryParams, }; -static FILE_URL_RE: LazyLock = LazyLock::new(|| Regex::new(r"/api/files/([^/]+)(?:/thumbnail)?").unwrap()); - pub struct SqliteEntryRepository { pool: SqlitePool, } @@ -33,7 +29,7 @@ impl EntryRepository for SqliteEntryRepository { ); let mut query = String::from( - "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, published_at + "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ? AND site_id = ?", ); @@ -56,9 +52,33 @@ impl EntryRepository for SqliteEntryRepository { Ok(result) } + async fn get_by_ids( + &self, + site_id: &str, + ids: &[String], + published_only: bool, + ) -> Result, RepositoryError> { + if ids.is_empty() { + return Ok(Vec::new()); + } + let mut query = sqlx::QueryBuilder::::new( + "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE site_id = ", + ); + query.push_bind(site_id).push(" AND id IN ("); + let mut ids_query = query.separated(","); + for id in ids { + ids_query.push_bind(id); + } + query.push(")"); + if published_only { + query.push(" AND status = 'published'"); + } + Ok(query.build_query_as::().fetch_all(&self.pool).await?) + } + async fn get_by_id_any_site(&self, id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Entry>( - "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, published_at + "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ?", ) .bind(id) @@ -82,7 +102,7 @@ impl EntryRepository for SqliteEntryRepository { ); let mut query = String::from( - "SELECT e.id, e.site_id, e.collection_id, e.data, e.slug, e.status, e.singleton_collection_id, e.created_at, e.updated_at, e.published_at + "SELECT e.id, e.site_id, e.collection_id, e.data, e.slug, e.status, e.singleton_collection_id, e.created_at, e.updated_at, CAST(e.version AS TEXT) AS version, e.published_at FROM entries e JOIN collections col ON e.collection_id = col.id WHERE e.site_id = ?", @@ -145,10 +165,10 @@ impl EntryRepository for SqliteEntryRepository { "Fetching entries page: page={}, per_page={}, offset={}", params.page, params.per_page, - (params.page - 1) * params.per_page + params.page.saturating_sub(1).saturating_mul(params.per_page) ); - let offset = (params.page - 1) * params.per_page; - query.push_str(" ORDER BY e.updated_at DESC LIMIT ? OFFSET ?"); + let offset = params.page.saturating_sub(1).saturating_mul(params.per_page); + query.push_str(" ORDER BY e.updated_at DESC, e.id DESC LIMIT ? OFFSET ?"); let mut q = sqlx::query_as::<_, Entry>(sqlx::AssertSqlSafe(query.as_str())); for b in &bindings { @@ -181,7 +201,7 @@ impl EntryRepository for SqliteEntryRepository { published_only: bool, ) -> Result, RepositoryError> { let mut query = String::from( - "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, published_at + "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE collection_id = ?", ); let mut bindings: Vec = vec![collection_id.to_string()]; @@ -189,7 +209,8 @@ impl EntryRepository for SqliteEntryRepository { if let Some(s) = status { query.push_str(" AND status = ?"); bindings.push(s.to_string()); - } else if published_only { + } + if published_only { query.push_str(" AND status = 'published'"); } @@ -215,7 +236,7 @@ impl EntryRepository for SqliteEntryRepository { } let placeholders = vec!["?"; collection_ids.len()].join(", "); let mut query = format!( - "SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, published_at + "SELECT ROW_NUMBER() OVER (PARTITION BY collection_id ORDER BY created_at DESC, id DESC) AS collection_rank, id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE collection_id IN ({placeholders})" ); let mut bindings: Vec = collection_ids.to_vec(); @@ -223,12 +244,16 @@ impl EntryRepository for SqliteEntryRepository { if let Some(s) = status { query.push_str(" AND status = ?"); bindings.push(s.to_string()); - } else if published_only { + } + if published_only { query.push_str(" AND status = 'published'"); } query.push_str(" ORDER BY updated_at DESC"); + // Nested GraphQL reads are bounded per collection. Use the paginated + // entry query for complete traversal rather than materializing all rows. + query = format!("SELECT * FROM ({query}) AS bounded WHERE collection_rank <= 200"); let mut q = sqlx::query_as::<_, Entry>(sqlx::AssertSqlSafe(query.as_str())); for b in &bindings { q = q.bind(b); @@ -237,17 +262,19 @@ impl EntryRepository for SqliteEntryRepository { Ok(q.fetch_all(&self.pool).await?) } - async fn create( - &self, - id: &str, - site_id: &str, - collection_id: &str, - data: &str, - slug: &str, - created_by: Option<&str>, - ) -> Result { - let mut tx = self.pool.begin().await?; + async fn create(&self, params: crate::repository::traits::CreateEntryParams<'_>) -> Result { + let crate::repository::traits::CreateEntryParams { + id, + site_id, + collection_id, + data, + slug, + created_by, + expected_definition, + } = params; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + lock_definition_tx(&mut tx, collection_id, site_id, expected_definition).await?; sqlx::query("INSERT INTO entries (id, site_id, collection_id, data, slug) VALUES (?, ?, ?, ?, ?)") .bind(id) .bind(site_id) @@ -265,18 +292,20 @@ impl EntryRepository for SqliteEntryRepository { ) .bind(&revision_id) .bind(id) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ?").bind(id).fetch_one(&mut *tx).await?; tx.commit().await?; - - self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } async fn update(&self, params: UpdateEntryParams<'_>) -> Result { let UpdateEntryParams { + expected_definition, id, site_id, data, @@ -284,8 +313,29 @@ impl EntryRepository for SqliteEntryRepository { status, created_by, change_summary, + expected_version, } = params; - let mut tx = self.pool.begin().await?; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + + let collection_id: Option = + sqlx::query_scalar("SELECT collection_id FROM entries WHERE id=? AND site_id=?") + .bind(id) + .bind(site_id) + .fetch_optional(&mut *tx) + .await?; + let collection_id = collection_id.ok_or(RepositoryError::NotFound)?; + lock_definition_tx(&mut tx, &collection_id, site_id, expected_definition).await?; + let result = sqlx::query("UPDATE entries SET data = ?, slug = ?, published_at = CASE WHEN ? = 'draft' THEN NULL WHEN status = 'draft' THEN datetime('now') ELSE published_at END, status = ?, updated_at = datetime('now') WHERE id = ? AND site_id = ? AND (? IS NULL OR CAST(version AS TEXT) = ?)") + .bind(data).bind(slug).bind(status).bind(status).bind(id).bind(site_id).bind(expected_version) + .bind(expected_version) + .execute(&mut *tx).await?; + if result.rows_affected() == 0 { + return Err(if expected_version.is_some() { + RepositoryError::PreconditionFailed + } else { + RepositoryError::NotFound + }); + } let next_number: i64 = sqlx::query_scalar("SELECT COALESCE(MAX(revision_number), 0) + 1 FROM entry_revisions WHERE entry_id = ?") @@ -293,17 +343,6 @@ impl EntryRepository for SqliteEntryRepository { .fetch_one(&mut *tx) .await?; - sqlx::query( - "UPDATE entries SET data = ?, slug = ?, status = ?, updated_at = datetime('now') WHERE id = ? AND site_id = ?", - ) - .bind(data) - .bind(slug) - .bind(status) - .bind(id) - .bind(site_id) - .execute(&mut *tx) - .await?; - let data_json: serde_json::Value = serde_json::from_str(data).unwrap_or(Value::Null); let revision_id = Uuid::now_v7().to_string(); sqlx::query( @@ -313,15 +352,16 @@ impl EntryRepository for SqliteEntryRepository { .bind(&revision_id) .bind(id) .bind(next_number) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ?").bind(id).fetch_one(&mut *tx).await?; tx.commit().await?; - - self.get_by_id_any_site(id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } async fn delete(&self, id: &str, site_id: &str) -> Result { @@ -367,33 +407,8 @@ impl EntryRepository for SqliteEntryRepository { } async fn sync_file_references(&self, entry_id: &str, site_id: &str, data: &Value) -> Result<(), RepositoryError> { - let file_ids = extract_file_ids_from_value(data); - - let mut tx = self.pool.begin().await?; - - sqlx::query("DELETE FROM entry_file_references WHERE entry_id = ?") - .bind(entry_id) - .execute(&mut *tx) - .await?; - - if !file_ids.is_empty() { - // One multi-row statement instead of a query per file id; the - // SELECT keeps the "file must exist in this site" filter. - let placeholders = vec!["?"; file_ids.len()].join(", "); - let sql = format!( - "INSERT OR IGNORE INTO entry_file_references (entry_id, file_id, site_id) - SELECT ?, id, ? FROM files WHERE site_id = ? AND id IN ({placeholders})", - ); - let mut query = sqlx::query(sqlx::AssertSqlSafe(sql.as_str())) - .bind(entry_id) - .bind(site_id) - .bind(site_id); - for file_id in &file_ids { - query = query.bind(file_id); - } - query.execute(&mut *tx).await?; - } - + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + sync_references_tx(&mut tx, entry_id, site_id, data).await?; tx.commit().await?; Ok(()) } @@ -409,7 +424,7 @@ impl EntryRepository for SqliteEntryRepository { .fetch_one(&self.pool) .await?; - let offset = (page - 1) * per_page; + let offset = page.saturating_sub(1).saturating_mul(per_page); let items = sqlx::query_as::<_, EntryRevision>( "SELECT id, entry_id, revision_number, data, created_by, created_at, change_summary FROM entry_revisions WHERE entry_id = ? ORDER BY revision_number DESC LIMIT ? OFFSET ?", @@ -451,7 +466,7 @@ impl EntryRepository for SqliteEntryRepository { revision_number: i64, created_by: Option<&str>, ) -> Result { - let mut tx = self.pool.begin().await?; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; let revision: Option = sqlx::query_as( "SELECT id, entry_id, revision_number, data, created_by, created_at, change_summary @@ -501,7 +516,7 @@ impl EntryRepository for SqliteEntryRepository { async fn get_singleton_entry(&self, site_id: &str, slug: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, Entry>( - "SELECT e.id, e.site_id, e.collection_id, e.data, e.slug, e.status, e.singleton_collection_id, e.created_at, e.updated_at, e.published_at + "SELECT e.id, e.site_id, e.collection_id, e.data, e.slug, e.status, e.singleton_collection_id, e.created_at, e.updated_at, CAST(e.version AS TEXT) AS version, e.published_at FROM entries e JOIN collections c ON c.id = e.singleton_collection_id WHERE e.site_id = ? AND c.slug = ?", @@ -516,14 +531,26 @@ impl EntryRepository for SqliteEntryRepository { async fn upsert_singleton_entry( &self, - site_id: &str, - collection_id: &str, - slug: &str, - data: &str, - created_by: Option<&str>, - change_summary: Option<&str>, + params: crate::repository::traits::UpsertSingletonParams<'_>, ) -> Result { - let mut tx = self.pool.begin().await?; + let crate::repository::traits::UpsertSingletonParams { + site_id, + collection_id, + slug, + data, + created_by, + change_summary, + expected_definition, + } = params; + let mut tx = self.pool.begin_with("BEGIN IMMEDIATE").await?; + + lock_definition_tx(&mut tx, collection_id, site_id, expected_definition).await?; + // Serialize first creation and revision allocation on the owning collection. + sqlx::query("UPDATE collections SET id = id WHERE id = ? AND site_id = ?") + .bind(collection_id) + .bind(site_id) + .execute(&mut *tx) + .await?; let existing: Option = sqlx::query_scalar("SELECT id FROM entries WHERE singleton_collection_id = ? AND site_id = ?") @@ -556,16 +583,16 @@ impl EntryRepository for SqliteEntryRepository { .bind(&revision_id) .bind(&existing_id) .bind(next_number) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, &existing_id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ?").bind(&existing_id).fetch_one(&mut *tx).await?; tx.commit().await?; - self.get_by_id_any_site(&existing_id) - .await? - .ok_or(RepositoryError::NotFound) + Ok(entry) } else { let id = Uuid::now_v7().to_string(); sqlx::query( @@ -587,83 +614,221 @@ impl EntryRepository for SqliteEntryRepository { ) .bind(&revision_id) .bind(&id) - .bind(sqlx::types::Json(data_json)) + .bind(sqlx::types::Json(&data_json)) .bind(created_by) .bind(change_summary) .execute(&mut *tx) .await?; + sync_references_tx(&mut tx, &id, site_id, &data_json).await?; + let entry = sqlx::query_as::<_, Entry>("SELECT id, site_id, collection_id, data, slug, status, singleton_collection_id, created_at, updated_at, CAST(version AS TEXT) AS version, published_at FROM entries WHERE id = ?").bind(&id).fetch_one(&mut *tx).await?; tx.commit().await?; - self.get_by_id_any_site(&id).await?.ok_or(RepositoryError::NotFound) + Ok(entry) } } +} - async fn migrate_singleton_field_renames( - &self, - site_id: &str, - collection_id: &str, - rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - let mut tx = self.pool.begin().await?; - - let existing: Option<(String, String)> = - sqlx::query_as("SELECT id, data FROM entries WHERE singleton_collection_id = ? AND site_id = ?") - .bind(collection_id) - .bind(site_id) - .fetch_optional(&mut *tx) - .await?; +pub use crate::utils::file_references::{extract_file_ids_from_value, extract_file_references_from_value}; - if let Some((id, data_str)) = existing - && let Ok(mut data) = serde_json::from_str::(&data_str) - && let Some(obj) = data.as_object_mut() - { - let mut renamed = serde_json::Map::new(); - for (key, value) in obj.iter() { - let new_key = rename_map.get(key).cloned().unwrap_or_else(|| key.clone()); - renamed.insert(new_key, value.clone()); - } - let new_data_str = - serde_json::to_string(&serde_json::Value::Object(renamed)).unwrap_or_else(|_| data_str.clone()); +async fn lock_definition_tx( + transaction: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + collection_id: &str, + site_id: &str, + expected_definition: Option<&str>, +) -> Result<(), RepositoryError> { + let found = sqlx::query("UPDATE collections SET id=id WHERE id=? AND site_id=? AND (? IS NULL OR definition=?)") + .bind(collection_id) + .bind(site_id) + .bind(expected_definition) + .bind(expected_definition) + .execute(&mut **transaction) + .await?; + if found.rows_affected() == 0 { + return Err(RepositoryError::PreconditionFailed); + } + Ok(()) +} - sqlx::query("UPDATE entries SET data = ?, updated_at = datetime('now') WHERE id = ?") - .bind(&new_data_str) - .bind(&id) - .execute(&mut *tx) - .await?; - } +pub(crate) async fn sync_references_tx( + tx: &mut sqlx::Transaction<'_, sqlx::Sqlite>, + entry_id: &str, + site_id: &str, + data: &Value, +) -> Result<(), RepositoryError> { + let file_references = extract_file_references_from_value(data); + sqlx::query("DELETE FROM entry_file_references WHERE entry_id = ?") + .bind(entry_id) + .execute(&mut **tx) + .await?; - tx.commit().await?; - Ok(()) + for references in file_references.chunks(200) { + let mut query = sqlx::QueryBuilder::::new( + "INSERT INTO entry_file_references (entry_id, file_id, site_id, field_name) ", + ); + query.push_values(references, |mut row, (file_id, field_name)| { + row.push_bind(entry_id) + .push_bind(file_id) + .push_bind(site_id) + .push_bind(field_name); + }); + query.push(" ON CONFLICT DO NOTHING"); + query.build().execute(&mut **tx).await?; } -} -pub fn extract_file_ids_from_value(value: &Value) -> Vec { - let mut ids = Vec::new(); - collect_file_ids(value, &FILE_URL_RE, &mut ids); - ids.sort(); - ids.dedup(); - ids + Ok(()) } -fn collect_file_ids(value: &Value, re: &Regex, ids: &mut Vec) { - match value { - Value::String(s) => { - for cap in re.captures_iter(s) { - if let Some(m) = cap.get(1) { - ids.push(m.as_str().to_string()); - } - } - } - Value::Array(arr) => { - for item in arr { - collect_file_ids(item, re, ids); - } - } - Value::Object(obj) => { - for val in obj.values() { - collect_file_ids(val, re, ids); - } - } - _ => {} +#[cfg(test)] +mod audit_tests { + use super::*; + + async fn repository() -> SqliteEntryRepository { + let pool = sqlx::sqlite::SqlitePoolOptions::new() + .max_connections(1) + .connect("sqlite::memory:") + .await + .unwrap(); + sqlx::migrate!("./migrations/sqlite").run(&pool).await.unwrap(); + sqlx::query("INSERT INTO users(id,name,email,password_hash) VALUES ('u','u','u@example.com','x')") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO sites(id,name,created_by) VALUES ('s','s','u')") + .execute(&pool) + .await + .unwrap(); + sqlx::query("INSERT INTO collections(id,site_id,name,slug,definition) VALUES ('c','s','c','c','{}')") + .execute(&pool) + .await + .unwrap(); + SqliteEntryRepository::new(pool) + } + + #[tokio::test] + async fn schema_preconditions_reject_stale_validations_without_writes() { + let repo = repository().await; + let original = repo + .create(crate::repository::traits::CreateEntryParams { + id: "e", + site_id: "s", + collection_id: "c", + data: "{}", + slug: "e", + created_by: None, + expected_definition: Some("{}"), + }) + .await + .unwrap(); + sqlx::query("UPDATE collections SET definition='{}' WHERE id='c'") + .execute(&repo.pool) + .await + .unwrap(); + let stale = "{\"fields\":[]}"; + assert!(matches!( + repo.create(crate::repository::traits::CreateEntryParams { + id: "new", + site_id: "s", + collection_id: "c", + data: "{}", + slug: "new", + created_by: None, + expected_definition: Some(stale) + }) + .await, + Err(RepositoryError::PreconditionFailed) + )); + assert!(matches!( + repo.update(UpdateEntryParams { + id: "e", + site_id: "s", + data: "{\"changed\":true}", + slug: "e", + status: "draft", + created_by: None, + change_summary: None, + expected_version: Some(&original.version), + expected_definition: Some(stale), + }) + .await, + Err(RepositoryError::PreconditionFailed) + )); + assert!(repo.get_by_id("new", "s", false).await.unwrap().is_none()); + let current = repo.get_by_id("e", "s", false).await.unwrap().unwrap(); + assert_eq!(current.data, "{}"); + assert_eq!(current.version, original.version); + assert_eq!(repo.list_revisions("e", 1, 10).await.unwrap().total, 1); + } + + #[tokio::test] + async fn conditional_updates_reject_stale_versions_without_extra_revisions() { + let repo = repository().await; + let original = repo + .create(crate::repository::traits::CreateEntryParams { + id: "e", + site_id: "s", + collection_id: "c", + data: "{}", + slug: "e", + created_by: None, + expected_definition: None, + }) + .await + .unwrap(); + let update = || UpdateEntryParams { + expected_definition: None, + id: "e", + site_id: "s", + data: "{}", + slug: "e", + status: "draft", + created_by: None, + change_summary: None, + expected_version: Some(&original.version), + }; + let changed = repo.update(update()).await.unwrap(); + assert_ne!(original.version, changed.version); + assert!(matches!( + repo.update(update()).await, + Err(RepositoryError::PreconditionFailed) + )); + assert_eq!(repo.list_revisions("e", 1, 10).await.unwrap().total, 2); + assert!( + repo.get_by_collection_id("c", Some("draft"), true) + .await + .unwrap() + .is_empty() + ); + let published = repo.publish("e", "s").await.unwrap(); + assert_ne!(published.version, changed.version); + } + + #[tokio::test] + async fn file_references_preserve_each_field() { + let repo = repository().await; + repo.create(crate::repository::traits::CreateEntryParams { + id: "e", + site_id: "s", + collection_id: "c", + data: "{}", + slug: "e", + created_by: None, + expected_definition: None, + }) + .await + .unwrap(); + sqlx::query("INSERT INTO files(id,site_id,filename,original_name,mime_type,size,storage_provider,storage_key) VALUES ('f','s','f','f','image/png',1,'filesystem','f')").execute(&repo.pool).await.unwrap(); + repo.sync_file_references( + "e", + "s", + &serde_json::json!({"hero":"/api/files/f?size=1", "nested":["/api/files/f/thumbnail"]}), + ) + .await + .unwrap(); + let fields: Vec = + sqlx::query_scalar("SELECT field_name FROM entry_file_references ORDER BY field_name") + .fetch_all(&repo.pool) + .await + .unwrap(); + assert_eq!(fields, ["hero", "nested[0]"]); } } diff --git a/apps/backend/src/repository/sqlite/file.rs b/apps/backend/src/repository/sqlite/file.rs index 45440db8..0d9b379f 100644 --- a/apps/backend/src/repository/sqlite/file.rs +++ b/apps/backend/src/repository/sqlite/file.rs @@ -17,6 +17,28 @@ impl SqliteFileRepository { #[async_trait] impl FileRepository for SqliteFileRepository { + async fn claim_signed_upload(&self, file_id: &str, expires_at: i64) -> Result { + let now = chrono::Utc::now().timestamp(); + if expires_at <= now { + return Ok(false); + } + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; + sqlx::query("DELETE FROM signed_upload_uses WHERE expires_at <= ?") + .bind(now) + .execute(&mut *transaction) + .await?; + let claimed = + sqlx::query("INSERT INTO signed_upload_uses(file_id, expires_at) VALUES (?, ?) ON CONFLICT DO NOTHING") + .bind(file_id) + .bind(expires_at) + .execute(&mut *transaction) + .await? + .rows_affected() + > 0; + transaction.commit().await?; + Ok(claimed) + } + async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let result = sqlx::query_as::<_, File>( "SELECT id, site_id, filename, original_name, mime_type, size, storage_provider, storage_key, thumbnail_key, width, height, deleted_at, created_by, created_at @@ -99,7 +121,7 @@ impl FileRepository for SqliteFileRepository { let count_bindings = bindings.clone(); - let offset = (params.page - 1) * params.per_page; + let offset = params.page.saturating_sub(1).saturating_mul(params.per_page); query.push_str(" ORDER BY created_at DESC LIMIT ? OFFSET ?"); bindings.push(params.per_page.to_string()); bindings.push(offset.to_string()); @@ -284,34 +306,13 @@ impl FileRepository for SqliteFileRepository { Ok(result.rows_affected()) } - async fn get_references(&self, file_id: &str) -> Result, RepositoryError> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT DISTINCT e.id, col.name FROM entry_file_references efr - JOIN entries e ON efr.entry_id = e.id - JOIN collections col ON e.collection_id = col.id - WHERE efr.file_id = ?", - ) - .bind(file_id) - .fetch_all(&self.pool) - .await?; - - Ok(rows - .into_iter() - .map(|(entry_id, collection_name)| FileReference { - entry_id, - collection_name, - field_name: String::new(), - }) - .collect()) - } - async fn get_references_for_site( &self, file_id: &str, site_id: &str, ) -> Result, RepositoryError> { - let rows: Vec<(String, String)> = sqlx::query_as( - "SELECT DISTINCT e.id, col.name FROM entry_file_references efr + let rows: Vec<(String, String, String)> = sqlx::query_as( + "SELECT DISTINCT e.id, col.name, efr.field_name FROM entry_file_references efr JOIN entries e ON efr.entry_id = e.id JOIN collections col ON e.collection_id = col.id WHERE efr.file_id = ? AND e.site_id = ?", @@ -323,10 +324,10 @@ impl FileRepository for SqliteFileRepository { Ok(rows .into_iter() - .map(|(entry_id, collection_name)| FileReference { + .map(|(entry_id, collection_name, field_name)| FileReference { entry_id, collection_name, - field_name: String::new(), + field_name, }) .collect()) } diff --git a/apps/backend/src/repository/sqlite/site.rs b/apps/backend/src/repository/sqlite/site.rs index e13b8bb2..7225fd6a 100644 --- a/apps/backend/src/repository/sqlite/site.rs +++ b/apps/backend/src/repository/sqlite/site.rs @@ -60,7 +60,7 @@ impl SiteRepository for SqliteSiteRepository { storage_profile_id: &str, created_by: &str, ) -> Result { - let mut transaction = self.pool.begin().await?; + let mut transaction = self.pool.begin_with("BEGIN IMMEDIATE").await?; let storage_provider: Option = sqlx::query_scalar("SELECT kind FROM storage_profiles WHERE id = ? AND enabled = 1") .bind(storage_profile_id) diff --git a/apps/backend/src/repository/sqlite/webhook.rs b/apps/backend/src/repository/sqlite/webhook.rs index 420e649a..6ddc51b8 100644 --- a/apps/backend/src/repository/sqlite/webhook.rs +++ b/apps/backend/src/repository/sqlite/webhook.rs @@ -67,25 +67,27 @@ impl WebhookRepository for SqliteWebhookRepository { async fn update( &self, id: &str, + site_id: &str, label: Option<&str>, url: Option<&str>, headers_encrypted: Option<&str>, ) -> Result { - let existing = self.get_by_id_unscoped(id).await?.ok_or(RepositoryError::NotFound)?; + let existing = self.get_by_id(id, site_id).await?.ok_or(RepositoryError::NotFound)?; let label = label.unwrap_or(&existing.label); let url = url.unwrap_or(&existing.url); let headers = headers_encrypted.unwrap_or(&existing.headers_encrypted); - sqlx::query("UPDATE site_webhooks SET label = ?, url = ?, headers_encrypted = ?, updated_at = datetime('now') WHERE id = ?") + sqlx::query("UPDATE site_webhooks SET label = ?, url = ?, headers_encrypted = ?, updated_at = datetime('now') WHERE id = ? AND site_id = ?") .bind(label) .bind(url) .bind(headers) .bind(id) + .bind(site_id) .execute(&self.pool) .await?; - self.get_by_id_unscoped(id).await?.ok_or(RepositoryError::NotFound) + self.get_by_id(id, site_id).await?.ok_or(RepositoryError::NotFound) } async fn delete(&self, id: &str, site_id: &str) -> Result { @@ -141,7 +143,7 @@ impl WebhookRepository for SqliteWebhookRepository { .fetch_one(&self.pool) .await?; - let offset = (page - 1) * per_page; + let offset = page.saturating_sub(1).saturating_mul(per_page); let items = sqlx::query_as::<_, WebhookDelivery>( "SELECT id, webhook_id, status, status_code, response_body, duration_ms, triggered_by, triggered_at FROM site_webhook_deliveries WHERE webhook_id = ? ORDER BY triggered_at DESC LIMIT ? OFFSET ?", ) @@ -154,15 +156,3 @@ impl WebhookRepository for SqliteWebhookRepository { Ok((items, total.0)) } } - -impl SqliteWebhookRepository { - async fn get_by_id_unscoped(&self, id: &str) -> Result, RepositoryError> { - sqlx::query_as::<_, SiteWebhook>( - "SELECT id, site_id, label, url, headers_encrypted, enabled, created_by, created_at, updated_at FROM site_webhooks WHERE id = ?", - ) - .bind(id) - .fetch_optional(&self.pool) - .await - .map_err(RepositoryError::from) - } -} diff --git a/apps/backend/src/repository/traits.rs b/apps/backend/src/repository/traits.rs index 15ab2ec8..682f4765 100644 --- a/apps/backend/src/repository/traits.rs +++ b/apps/backend/src/repository/traits.rs @@ -97,18 +97,42 @@ pub trait CollectionRepository: Send + Sync { definition: &str, is_singleton: bool, ) -> Result; - async fn update(&self, id: &str, name: &str, slug: &str, definition: &str) -> Result; - async fn delete(&self, site_id: &str, slug: &str) -> Result; - async fn get_content_for_migration(&self, collection_id: &str) -> Result, RepositoryError>; - async fn migrate_content_field_renames( + async fn update( &self, - content_items: &[Entry], + id: &str, + name: &str, + slug: &str, + definition: &str, rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError>; + expected_definition: &str, + ) -> Result; + async fn delete(&self, site_id: &str, slug: &str) -> Result; +} + +pub struct CreateEntryParams<'a> { + pub id: &'a str, + pub site_id: &'a str, + pub collection_id: &'a str, + pub data: &'a str, + pub slug: &'a str, + pub created_by: Option<&'a str>, + pub expected_definition: Option<&'a str>, +} + +pub struct UpsertSingletonParams<'a> { + pub site_id: &'a str, + pub collection_id: &'a str, + pub slug: &'a str, + pub data: &'a str, + pub created_by: Option<&'a str>, + pub change_summary: Option<&'a str>, + pub expected_definition: Option<&'a str>, } /// Fields for an entry update (see [`EntryRepository::update`]). pub struct UpdateEntryParams<'a> { + pub expected_definition: Option<&'a str>, + pub expected_version: Option<&'a str>, pub id: &'a str, pub site_id: &'a str, pub data: &'a str, @@ -121,6 +145,22 @@ pub struct UpdateEntryParams<'a> { #[async_trait] pub trait EntryRepository: Send + Sync { async fn get_by_id(&self, id: &str, site_id: &str, published_only: bool) -> Result, RepositoryError>; + /// Hydrate a bounded set of ranked IDs. Database implementations batch this + /// read; the default supports trusted in-memory test repositories. + async fn get_by_ids( + &self, + site_id: &str, + ids: &[String], + published_only: bool, + ) -> Result, RepositoryError> { + let mut entries = Vec::new(); + for id in ids { + if let Some(entry) = self.get_by_id(id, site_id, published_only).await? { + entries.push(entry); + } + } + Ok(entries) + } async fn get_by_id_any_site(&self, id: &str) -> Result, RepositoryError>; async fn list(&self, params: ListEntriesParams<'_>) -> Result; async fn get_by_collection_id( @@ -131,32 +171,18 @@ pub trait EntryRepository: Send + Sync { ) -> Result, RepositoryError>; /// Batched variant of [`get_by_collection_id`] for many collections in one /// query (used by the GraphQL DataLoader to avoid N+1). Each returned entry - /// carries its `collection_id` so callers can group the flat result. + /// carries its `collection_id` so callers can group the flat result. Database + /// implementations cap this nested read at 200 rows per collection; complete + /// traversal uses the paginated list API. async fn get_by_collection_ids( &self, collection_ids: &[String], status: Option<&str>, published_only: bool, ) -> Result, RepositoryError>; - async fn create( - &self, - id: &str, - site_id: &str, - collection_id: &str, - data: &str, - slug: &str, - created_by: Option<&str>, - ) -> Result; + async fn create(&self, params: CreateEntryParams<'_>) -> Result; async fn get_singleton_entry(&self, site_id: &str, slug: &str) -> Result, RepositoryError>; - async fn upsert_singleton_entry( - &self, - site_id: &str, - collection_id: &str, - slug: &str, - data: &str, - created_by: Option<&str>, - change_summary: Option<&str>, - ) -> Result; + async fn upsert_singleton_entry(&self, params: UpsertSingletonParams<'_>) -> Result; async fn update(&self, params: UpdateEntryParams<'_>) -> Result; async fn delete(&self, id: &str, site_id: &str) -> Result; async fn publish(&self, id: &str, site_id: &str) -> Result; @@ -181,12 +207,6 @@ pub trait EntryRepository: Send + Sync { revision_number: i64, created_by: Option<&str>, ) -> Result; - async fn migrate_singleton_field_renames( - &self, - site_id: &str, - collection_id: &str, - rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError>; } #[derive(Clone)] @@ -250,6 +270,8 @@ pub struct NewFile<'a> { #[async_trait] pub trait FileRepository: Send + Sync { + /// Atomically consume a signed URL once, independently of the files row. + async fn claim_signed_upload(&self, file_id: &str, expires_at: i64) -> Result; async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError>; async fn get_by_id_any(&self, id: &str) -> Result, RepositoryError>; async fn list(&self, params: ListFilesParams<'_>) -> Result; @@ -261,7 +283,6 @@ pub trait FileRepository: Send + Sync { async fn get_by_ids(&self, site_id: &str, ids: &[String]) -> Result, RepositoryError>; async fn get_deleted_by_ids(&self, site_id: &str, ids: &[String]) -> Result, RepositoryError>; async fn batch_permanent_delete(&self, site_id: &str, ids: &[String]) -> Result; - async fn get_references(&self, file_id: &str) -> Result, RepositoryError>; async fn get_references_for_site( &self, file_id: &str, @@ -361,6 +382,7 @@ pub trait WebhookRepository: Send + Sync { async fn update( &self, id: &str, + site_id: &str, label: Option<&str>, url: Option<&str>, headers_encrypted: Option<&str>, diff --git a/apps/backend/src/router/collections.rs b/apps/backend/src/router/collections.rs index 844065ae..7596ba7d 100644 --- a/apps/backend/src/router/collections.rs +++ b/apps/backend/src/router/collections.rs @@ -4,16 +4,14 @@ use axum::{ }; use crate::handlers::collection_handler::{ - create_collection, delete_collection, get_collection, list_collections, update_collection, + create_collection, delete_collection, get_collection, get_public_collection, list_collections, + list_public_collections, update_collection, }; pub fn public_routes() -> Router { Router::new() - .route("/collections", get(list_collections)) - .route("/collections", post(create_collection)) - .route("/collections/{collection_slug}", get(get_collection)) - .route("/collections/{collection_slug}", put(update_collection)) - .route("/collections/{collection_slug}", delete(delete_collection)) + .route("/collections", get(list_public_collections)) + .route("/collections/{collection_slug}", get(get_public_collection)) } pub fn dashboard_routes() -> Router { diff --git a/apps/backend/src/router/docs.rs b/apps/backend/src/router/docs.rs index 9c40004a..d053f418 100644 --- a/apps/backend/src/router/docs.rs +++ b/apps/backend/src/router/docs.rs @@ -1,9 +1,18 @@ -use axum::Router; +use axum::{Json, Router, routing::get}; use utoipa::OpenApi; use utoipa_scalar::{Scalar, Servable}; use super::openapi::CmsApiDoc; pub fn docs_routes() -> Router { - Router::new().merge(Scalar::with_url("/api/v1/docs", CmsApiDoc::openapi())) + let spec = CmsApiDoc::openapi(); + Router::new() + .route( + "/api/v1/openapi.json", + get({ + let spec = spec.clone(); + move || async move { Json(spec.clone()) } + }), + ) + .merge(Scalar::with_url("/api/v1/docs", spec)) } diff --git a/apps/backend/src/router/entry.rs b/apps/backend/src/router/entry.rs index a9b27c58..1f1aa1a9 100644 --- a/apps/backend/src/router/entry.rs +++ b/apps/backend/src/router/entry.rs @@ -1,25 +1,32 @@ use axum::{ Router, - routing::{delete, get, post, put}, + routing::{delete, get, patch, post, put}, }; use crate::handlers::entry_handler::{ - create_entry, delete_entry, get_entry, get_entry_revision, list_entries, list_entry_revisions, publish_entry, - restore_entry_revision, unpublish_entry, update_entry, + create_collection_entry, create_entry, delete_entry, get_entry, get_entry_revision, get_public_entry, + list_collection_entries, list_entries, list_entry_revisions, publish_entry, publish_public_entry, + restore_entry_revision, restore_public_entry_revision, unpublish_entry, unpublish_public_entry, update_entry, + update_public_entry, }; pub fn public_routes() -> Router { Router::new() - .route("/entries", get(list_entries)) - .route("/entries", post(create_entry)) - .route("/entries/{id}", get(get_entry)) - .route("/entries/{id}", put(update_entry)) + .route( + "/collections/{collection_slug}/entries", + get(list_collection_entries).post(create_collection_entry), + ) + .route("/entries/{id}", get(get_public_entry)) + .route("/entries/{id}", patch(update_public_entry)) .route("/entries/{id}", delete(delete_entry)) - .route("/entries/{id}/publish", post(publish_entry)) - .route("/entries/{id}/unpublish", post(unpublish_entry)) + .route("/entries/{id}/publish", post(publish_public_entry)) + .route("/entries/{id}/unpublish", post(unpublish_public_entry)) .route("/entries/{id}/revisions", get(list_entry_revisions)) .route("/entries/{id}/revisions/{number}", get(get_entry_revision)) - .route("/entries/{id}/revisions/{number}/restore", post(restore_entry_revision)) + .route( + "/entries/{id}/revisions/{number}/restore", + post(restore_public_entry_revision), + ) } pub fn dashboard_routes() -> Router { diff --git a/apps/backend/src/router/files.rs b/apps/backend/src/router/files.rs index 72a8d0ce..39db42f3 100644 --- a/apps/backend/src/router/files.rs +++ b/apps/backend/src/router/files.rs @@ -5,23 +5,21 @@ use axum::{ }; use crate::handlers::file_handler::{ - batch_delete_files, batch_permanent_delete_files, batch_restore_files, delete_file_handler, get_file, - get_file_references, list_files, restore_file, serve_file, serve_file_thumbnail, upload_file, - upload_via_signed_url, + batch_delete_files, batch_permanent_delete_files, batch_restore_files, create_file_upload_url, delete_file_handler, + get_file, get_file_references, list_files, list_public_files, restore_file, serve_file, serve_file_thumbnail, + upload_file, upload_via_signed_url, }; /// Public API CRUD routes (mounted at /api/v1) pub fn public_routes(_max_upload_bytes: usize) -> Router { Router::new() - .route("/files", get(list_files)) + .route("/files", get(list_public_files)) + .route("/files/upload-url", post(create_file_upload_url)) .merge( Router::new() .route("/files", post(upload_file)) .layer(DefaultBodyLimit::disable()), ) - .route("/files/batch-delete", post(batch_delete_files)) - .route("/files/batch-restore", post(batch_restore_files)) - .route("/files/batch-permanent-delete", post(batch_permanent_delete_files)) .route("/files/{id}", get(get_file)) .route("/files/{id}", delete(delete_file_handler)) .route("/files/{id}/references", get(get_file_references)) diff --git a/apps/backend/src/router/graphql.rs b/apps/backend/src/router/graphql.rs index c6a873f6..18396306 100644 --- a/apps/backend/src/router/graphql.rs +++ b/apps/backend/src/router/graphql.rs @@ -2,7 +2,7 @@ use axum::http::HeaderMap; use axum::{ Extension, Router, response::{Html, IntoResponse}, - routing::get, + routing::{get, post}, }; use std::sync::Arc; @@ -21,7 +21,6 @@ async fn graphql_handler( req: async_graphql_axum::GraphQLRequest, ) -> async_graphql_axum::GraphQLResponse { let auth_header = headers.get("Authorization").and_then(|v| v.to_str().ok()); - let requested_site = headers.get("X-VCMS-Site").and_then(|v| v.to_str().ok()); // Per-request DataLoader (request-scoped cache) to batch nested resolvers. let entry_loader = async_graphql::dataloader::DataLoader::new( @@ -35,12 +34,62 @@ async fn graphql_handler( repository, services, auth_header, - requested_site, &config.token_index_key, + config.clone(), ) .await; - let response = schema.execute(req.into_inner().data(gql_ctx).data(entry_loader)).await; + let mut request = req.into_inner(); + let validation = request.parsed_query().and_then(|document| { + use async_graphql::parser::types::Selection; + let mut sets = document + .operations + .iter() + .map(|(_, operation)| &operation.node.selection_set.node) + .chain( + document + .fragments + .values() + .map(|fragment| &fragment.node.selection_set.node), + ) + .collect::>(); + let mut aliases = 0; + while let Some(set) = sets.pop() { + for selection in &set.items { + match &selection.node { + Selection::Field(field) => { + aliases += usize::from(field.node.alias.is_some()); + if aliases > 256 { + return Err(async_graphql::ServerError::new( + "Query exceeds the 256-alias limit", + None, + )); + } + sets.push(&field.node.selection_set.node); + } + Selection::InlineFragment(fragment) => sets.push(&fragment.node.selection_set.node), + Selection::FragmentSpread(_) => {} + } + } + } + Ok(()) + }); + if let Err(error) = validation { + return async_graphql::Response::from_errors(vec![error]).into(); + } + let request = if gql_ctx.actor.is_none() { + request.disable_introspection() + } else { + request + }; + let response = tokio::time::timeout( + std::time::Duration::from_secs(30), + schema.execute(request.data(gql_ctx).data(entry_loader)), + ) + .await + .unwrap_or_else(|_| { + async_graphql::Response::from_errors(vec![async_graphql::ServerError::new("Request timed out", None)]) + }); async_graphql_axum::GraphQLResponse::from(response) } @@ -53,7 +102,10 @@ async fn graphiql_handler() -> impl IntoResponse { } pub fn graphql_routes(production: bool) -> Router { - Router::new() - .route("/api/graphql", get(graphiql_handler).post(graphql_handler)) - .layer(Extension(Arc::new(build_schema(production)))) + let router = if production { + Router::new().route("/api/graphql", post(graphql_handler)) + } else { + Router::new().route("/api/graphql", get(graphiql_handler).post(graphql_handler)) + }; + router.layer(Extension(Arc::new(build_schema(production)))) } diff --git a/apps/backend/src/router/mod.rs b/apps/backend/src/router/mod.rs index 8f35ec52..7c35811a 100644 --- a/apps/backend/src/router/mod.rs +++ b/apps/backend/src/router/mod.rs @@ -33,11 +33,11 @@ use tower_http::trace::TraceLayer; use crate::config::Config; use crate::database::pool::DbPool; -use crate::handlers::site_handler::get_current_site; +use crate::handlers::site_handler::{get_current_site, list_public_sites}; use crate::middleware::api_auth::api_auth_middleware; use crate::middleware::authz::authz_middleware; use crate::middleware::dashboard_auth::dashboard_auth_middleware; -use crate::middleware::rate_limit::RateLimiter; +use crate::middleware::rate_limit::{ApiRateLimiter, RateLimiter, api_rate_limit_middleware}; use crate::middleware::site_resolver::{api_site_resolver, dashboard_site_resolver}; use crate::repository::Repository; use crate::services::Services; @@ -52,17 +52,33 @@ fn public_api_v1_routes(max_upload_bytes: usize) -> Router { .merge(collections::public_routes()) .merge(entry::public_routes()) .merge(singleton::public_routes()) - .merge(webhooks::public_routes()) .merge(files::public_routes(max_upload_bytes)); - Router::new() - .merge(resource_routes) + let site_routes = resource_routes // Inner — runs third (after site resolver sets RequestContext) .layer(from_fn(authz_middleware)) // Middle — runs second (after api_auth sets Actor) .layer(from_fn(api_site_resolver)) // Outer — runs first (validates Bearer vcms_site_* token) .layer(from_fn(api_auth_middleware)) + .layer(from_fn(api_rate_limit_middleware)); + + Router::new() + .nest("/api/v1/sites/{site_id}", site_routes) + .route( + "/api/v1/sites/{site_id}", + get(get_current_site) + .layer(from_fn(authz_middleware)) + .layer(from_fn(api_site_resolver)) + .layer(from_fn(api_auth_middleware)) + .layer(from_fn(api_rate_limit_middleware)), + ) + .route( + "/api/v1/sites", + get(list_public_sites) + .layer(from_fn(api_auth_middleware)) + .layer(from_fn(api_rate_limit_middleware)), + ) } /// Site-scoped dashboard routes: Auth → SiteResolver → AuthZ. @@ -113,12 +129,6 @@ pub fn create_router( .merge(auth::auth_routes()) // ── Public API (/api/v1/*) ── .merge(public_api_v1_routes(max_upload_bytes)) - .route( - "/api/v1/site", - get(get_current_site) - .layer(from_fn(api_site_resolver)) - .layer(from_fn(api_auth_middleware)), - ) // ── File serving (no auth — file IDs are effectively opaque) ── .merge(files::file_serve_routes()) // ── Signed-URL upload (no auth — the HMAC token is the credential) ── @@ -135,34 +145,46 @@ pub fn create_router( .layer(from_fn(dashboard_auth_middleware)), ) // ── GraphQL (custom auth in handler) ── - .merge(graphql::graphql_routes(config.production)) + .merge(graphql::graphql_routes(config.production).layer(from_fn(api_rate_limit_middleware))) // ── Docs ── .merge(docs::docs_routes()) // ── Dashboard SPA ── .merge(dashboard::dashboard_routes()) // ── Global layers ── - .layer(from_fn(trace_request)) - .layer(TraceLayer::new_for_http()) - .layer(from_fn_with_state(settings.clone(), dynamic_runtime_policy)) - .layer(DefaultBodyLimit::max(1024 * 1024 * 1024)) + .layer(TraceLayer::new_for_http().make_span_with(|request: &Request| { + tracing::debug_span!("http", method = %request.method(), uri = %crate::tracing::safe_request_uri(request.uri())) + })) + // JSON and GraphQL requests are bounded. Multipart and signed uploads + // explicitly disable this layer and enforce their own streaming limits. + .layer(DefaultBodyLimit::max(8 * 1024 * 1024)) .layer(Extension(repository.clone())) .layer(Extension(config.clone())) .layer(Extension(storage_registry.clone())) .layer(Extension(services)) .layer(Extension(backup)) .layer(Extension(settings.clone())) - .layer(Extension(pool)) - .layer(Extension(rate_limiter)); + .layer(Extension(pool)); let mcp_ct = CancellationToken::new(); + let mcp_repository = Arc::new(repository.clone()); + let mcp_config = Arc::new(config.clone()); let mcp_router = mcp::mcp_routes( mcp_services, - Arc::new(repository), - Arc::new(config), + mcp_repository.clone(), + mcp_config.clone(), storage_registry, mcp_ct, ); - router = router.merge(mcp_router.layer(from_fn_with_state(settings, dynamic_runtime_policy))); + router = router.merge(mcp_router); + router = router.layer(from_fn(crate::mcp::auth::authenticate_mcp_request)); + router = router.layer(from_fn(crate::mcp::auth::mcp_rate_limit_middleware)); + router = router + .layer(Extension(mcp_repository)) + .layer(Extension(mcp_config)) + .layer(Extension(rate_limiter)) + .layer(Extension(ApiRateLimiter::from_config(&config))) + .layer(from_fn_with_state(settings, dynamic_runtime_policy)) + .layer(from_fn(trace_request)); router } @@ -213,6 +235,10 @@ fn cors_response(mut response: Response, origin: Option) -> R response .headers_mut() .insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin); + response.headers_mut().insert( + header::ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_static("etag, x-request-id"), + ); response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_CREDENTIALS, HeaderValue::from_static("true"), @@ -223,7 +249,7 @@ fn cors_response(mut response: Response, origin: Option) -> R ); response.headers_mut().insert( header::ACCESS_CONTROL_ALLOW_HEADERS, - HeaderValue::from_static("authorization, content-type, accept, x-csrf-token"), + HeaderValue::from_static("authorization, content-type, accept, x-csrf-token, if-match, mcp-protocol-version, mcp-method, mcp-name"), ); response .headers_mut() diff --git a/apps/backend/src/router/openapi.rs b/apps/backend/src/router/openapi.rs index bbc16f0f..ba38eb06 100644 --- a/apps/backend/src/router/openapi.rs +++ b/apps/backend/src/router/openapi.rs @@ -1,77 +1,64 @@ use utoipa::OpenApi; -use crate::models::collection::{Collection, CreateCollection, UpdateCollection}; -use crate::models::entry::{CreateEntry, Entry, EntryRevisionResponse, RevisionsListResponse, UpdateEntry}; -use crate::models::file::{BatchFileIds, File, FileReference, FileWithUrl}; -use crate::models::site::Site; +use crate::handlers::entry_handler::CreateCollectionEntry; +use crate::handlers::file_handler::CreateFileUploadUrl; +use crate::handlers::site_handler::PublicSite; +use crate::models::collection::{PublicCollection, SingletonResponse, UpdateSingletonData}; +use crate::models::entry::PublicEntry; +use crate::models::entry::{EntryRevisionResponse, RevisionsListResponse, UpdateEntry}; +use crate::models::file::{File, FileReference, FileWithUrl}; #[derive(OpenApi)] #[openapi( info( title = "Velopulent CMS REST API", version = "0.1.0", - description = "Headless CMS unified API. Consumer access uses site-bound vcms_site_* tokens with read or write permission. Dashboard access uses revocable opaque sessions.", + description = "Headless CMS data-plane API. Consumer access uses scoped vcms_site_* keys or vcms_pat_* personal tokens with an explicit site path. Dashboard management remains under /api/dashboard.", contact(name = "Velopulent CMS", url = "https://cms.velopulent.com/docs"), license(name = "AGPL-3.0", url = "https://github.com/velopulent/cms/blob/main/LICENSE"), ), paths( - // Public API: Site info + // Public data plane. Dashboard and management handlers are intentionally + // absent from this document. + crate::handlers::site_handler::list_public_sites, crate::handlers::site_handler::get_current_site, - // Public API: Collections - crate::handlers::collection_handler::list_collections, - crate::handlers::collection_handler::get_collection, - crate::handlers::collection_handler::create_collection, - crate::handlers::collection_handler::update_collection, - crate::handlers::collection_handler::delete_collection, - // Public API: Entries - crate::handlers::entry_handler::list_entries, - crate::handlers::entry_handler::get_entry, - crate::handlers::entry_handler::create_entry, - crate::handlers::entry_handler::update_entry, + crate::handlers::collection_handler::list_public_collections, + crate::handlers::collection_handler::get_public_collection, + crate::handlers::entry_handler::list_collection_entries, + crate::handlers::entry_handler::create_collection_entry, + crate::handlers::entry_handler::get_public_entry, + crate::handlers::entry_handler::update_public_entry, crate::handlers::entry_handler::delete_entry, - crate::handlers::entry_handler::publish_entry, - crate::handlers::entry_handler::unpublish_entry, + crate::handlers::entry_handler::publish_public_entry, + crate::handlers::entry_handler::unpublish_public_entry, crate::handlers::entry_handler::list_entry_revisions, crate::handlers::entry_handler::get_entry_revision, - crate::handlers::entry_handler::restore_entry_revision, - // Public API: Singletons + crate::handlers::entry_handler::restore_public_entry_revision, crate::handlers::singleton_handler::list_singletons, crate::handlers::singleton_handler::get_singleton, crate::handlers::singleton_handler::update_singleton, - // Public API: Files - crate::handlers::file_handler::list_files, + crate::handlers::file_handler::list_public_files, crate::handlers::file_handler::upload_file, + crate::handlers::file_handler::create_file_upload_url, crate::handlers::file_handler::upload_via_signed_url, crate::handlers::file_handler::get_file, crate::handlers::file_handler::delete_file_handler, crate::handlers::file_handler::get_file_references, crate::handlers::file_handler::restore_file, - crate::handlers::file_handler::batch_delete_files, - crate::handlers::file_handler::batch_restore_files, - crate::handlers::file_handler::batch_permanent_delete_files, - // Public API: Webhooks - crate::handlers::webhook_handler::list_webhooks, - crate::handlers::webhook_handler::create_webhook, - crate::handlers::webhook_handler::get_webhook, - crate::handlers::webhook_handler::update_webhook, - crate::handlers::webhook_handler::delete_webhook, - crate::handlers::webhook_handler::trigger_webhook, - crate::handlers::webhook_handler::list_deliveries, ), components(schemas( - Site, - Collection, CreateCollection, UpdateCollection, - Entry, CreateEntry, UpdateEntry, EntryRevisionResponse, RevisionsListResponse, - File, FileWithUrl, FileReference, BatchFileIds, + PublicSite, + PublicCollection, UpdateSingletonData, SingletonResponse, + PublicEntry, UpdateEntry, EntryRevisionResponse, RevisionsListResponse, + File, FileWithUrl, FileReference, CreateCollectionEntry, CreateFileUploadUrl, )), modifiers(&SecurityAddon), tags( - (name = "site", description = "Current site info"), - (name = "collections", description = "Collection management (site-scoped)"), - (name = "entries", description = "Entry management (site-scoped)"), - (name = "singletons", description = "Singleton management (site-scoped)"), - (name = "files", description = "File management (site-scoped)"), - (name = "webhooks", description = "Webhook management (site-scoped)"), + (name = "sites", description = "Discover accessible sites"), + (name = "collections", description = "Read collection schemas"), + (name = "entries", description = "Headless content entries"), + (name = "singletons", description = "Singleton content"), + (name = "files", description = "File metadata and uploads"), ) )] pub struct CmsApiDoc; @@ -80,22 +67,47 @@ pub struct SecurityAddon; impl utoipa::Modify for SecurityAddon { fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) { + for (path, item) in &mut openapi.paths.paths { + if !path.starts_with("/api/v1/files/upload/") { + for operation in [ + &mut item.get, + &mut item.post, + &mut item.put, + &mut item.patch, + &mut item.delete, + ] + .into_iter() + .flatten() + { + operation.security = Some(vec![utoipa::openapi::security::SecurityRequirement::new( + "access_token", + Vec::::new(), + )]); + } + } + if path.contains("{site_id}") { + let parameters = item.parameters.get_or_insert_with(Vec::new); + parameters.push(utoipa::openapi::RefOr::T( + utoipa::openapi::path::ParameterBuilder::new() + .name("site_id") + .parameter_in(utoipa::openapi::path::ParameterIn::Path) + .required(utoipa::openapi::Required::True) + .schema(Some( + utoipa::openapi::schema::ObjectBuilder::new() + .schema_type(utoipa::openapi::schema::Type::String), + )) + .description(Some("Explicit target site; must be authorized by the token")) + .build(), + )); + } + } let components = openapi.components.get_or_insert_with(Default::default); - components.add_security_scheme( - "bearer", - utoipa::openapi::security::SecurityScheme::Http( - utoipa::openapi::security::HttpBuilder::new() - .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) - .bearer_format("Opaque dashboard session") - .build(), - ), - ); components.add_security_scheme( "access_token", utoipa::openapi::security::SecurityScheme::Http( utoipa::openapi::security::HttpBuilder::new() .scheme(utoipa::openapi::security::HttpAuthScheme::Bearer) - .bearer_format("Access Token (vcms_site_...)") + .bearer_format("Scoped access token (vcms_site_* or vcms_pat_*)") .build(), ), ); diff --git a/apps/backend/src/router/singleton.rs b/apps/backend/src/router/singleton.rs index 221beb43..3c99ebbb 100644 --- a/apps/backend/src/router/singleton.rs +++ b/apps/backend/src/router/singleton.rs @@ -1,6 +1,6 @@ use axum::{ Router, - routing::{get, put}, + routing::{get, patch, put}, }; use crate::handlers::singleton_handler::{get_singleton, list_singletons, update_singleton}; @@ -9,7 +9,7 @@ pub fn public_routes() -> Router { Router::new() .route("/singletons", get(list_singletons)) .route("/singletons/{slug}", get(get_singleton)) - .route("/singletons/{slug}", put(update_singleton)) + .route("/singletons/{slug}", patch(update_singleton)) } pub fn dashboard_routes() -> Router { diff --git a/apps/backend/src/router/webhooks.rs b/apps/backend/src/router/webhooks.rs index f43a5572..11e82fc7 100644 --- a/apps/backend/src/router/webhooks.rs +++ b/apps/backend/src/router/webhooks.rs @@ -7,17 +7,6 @@ use crate::handlers::webhook_handler::{ create_webhook, delete_webhook, get_webhook, list_deliveries, list_webhooks, trigger_webhook, update_webhook, }; -pub fn public_routes() -> Router { - Router::new() - .route("/webhooks", get(list_webhooks)) - .route("/webhooks", post(create_webhook)) - .route("/webhooks/{webhook_id}", get(get_webhook)) - .route("/webhooks/{webhook_id}", put(update_webhook)) - .route("/webhooks/{webhook_id}", delete(delete_webhook)) - .route("/webhooks/{webhook_id}/trigger", post(trigger_webhook)) - .route("/webhooks/{webhook_id}/deliveries", get(list_deliveries)) -} - pub fn dashboard_routes() -> Router { Router::new() .route("/webhooks", get(list_webhooks)) diff --git a/apps/backend/src/server.rs b/apps/backend/src/server.rs index e559fe32..6dd5a1e4 100644 --- a/apps/backend/src/server.rs +++ b/apps/backend/src/server.rs @@ -102,7 +102,7 @@ pub async fn run( storage_registry.clone(), services.clone(), backup_service.clone(), - settings, + settings.clone(), ); // Reconcile backups/restore jobs left mid-flight by a previous process: any @@ -218,6 +218,7 @@ pub async fn run( repository_arc.clone(), config_arc.clone(), storage_registry.clone(), + settings.clone(), grpc_listener, Box::pin(wait_for_shutdown(shutdown_rx)), )); diff --git a/apps/backend/src/services/access_token.rs b/apps/backend/src/services/access_token.rs index a9ff992f..142212ee 100644 --- a/apps/backend/src/services/access_token.rs +++ b/apps/backend/src/services/access_token.rs @@ -38,7 +38,10 @@ impl TokenError { let (status, body) = match self { TokenError::NotFound => (StatusCode::NOT_FOUND, Json(json!({"error": "Token not found"}))), TokenError::NameRequired => (StatusCode::BAD_REQUEST, Json(json!({"error": "Name is required"}))), - TokenError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), + TokenError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), }; (status, body).into_response() } @@ -303,7 +306,7 @@ mod tests { .create_site_token( "site-123", "To Delete".to_string(), - [TokenScope::SiteSettingsWrite, TokenScope::ContentWrite] + [TokenScope::ContentPublish, TokenScope::ContentWrite] .into_iter() .collect::(), None, diff --git a/apps/backend/src/services/authorization.rs b/apps/backend/src/services/authorization.rs index b3cd6598..f8f3ca17 100644 --- a/apps/backend/src/services/authorization.rs +++ b/apps/backend/src/services/authorization.rs @@ -21,21 +21,10 @@ impl AuthorizationService { if site_id != k.site_id { return Err(ServiceError::Forbidden("Token is not authorized for this site".into())); } - if site_id == k.site_id - && !Authorizer::token_hard_denied(action) - && scopes_allow_action(&k.scopes, action) - { - Ok(()) - } else if site_id != k.site_id { - Err(ServiceError::Forbidden("Token is not authorized for this site".into())) - } else { - Err(ServiceError::InsufficientPermission("token scope".into())) - } + require_token_scope(&k.scopes, action) } Actor::PersonalToken(token) => { - if Authorizer::token_hard_denied(action) || !scopes_allow_action(&token.scopes, action) { - return Err(ServiceError::InsufficientPermission("token scope".into())); - } + require_token_scope(&token.scopes, action)?; self.check_site_access(&token.user_id, site_id, action).await } Actor::User(user) => self.check_site_access(&user.user_id, site_id, action).await, @@ -88,6 +77,18 @@ impl AuthorizationService { } } +/// Scope gate shared by every token-authenticated adapter. +fn require_token_scope(scopes: &crate::models::access_token::TokenScopes, action: Action) -> Result<(), ServiceError> { + if Authorizer::token_hard_denied(action) { + return Err(ServiceError::SiteTokenDenied); + } + if scopes_allow_action(scopes, action) { + return Ok(()); + } + let scope = crate::middleware::auth::scope_for_action(action).map_or("unavailable", |scope| scope.as_str()); + Err(ServiceError::InsufficientPermission(scope.into())) +} + #[cfg(test)] mod tests { use std::sync::Arc; diff --git a/apps/backend/src/services/backup/meta.rs b/apps/backend/src/services/backup/meta.rs index d651a3c9..2901ce31 100644 --- a/apps/backend/src/services/backup/meta.rs +++ b/apps/backend/src/services/backup/meta.rs @@ -471,45 +471,3 @@ pub async fn due_schedules(pool: &DbPool, now: &str) -> Result, - created_by: Option<&str>, - now: &str, -) -> Result<(), BackupError> { - exec!( - pool, - "INSERT INTO restore_jobs (id, source, scope, target_site_id, status, created_by, started_at, created_at) \ - VALUES (?, ?, ?, ?, 'running', ?, ?, ?)", - id.to_string(), - source.to_string(), - scope.to_string(), - target_site_id.map(|s| s.to_string()), - created_by.map(|s| s.to_string()), - now.to_string(), - now.to_string(), - ); - Ok(()) -} - -pub async fn mark_restore_done( - pool: &DbPool, - id: &str, - status: &str, - error: Option<&str>, - now: &str, -) -> Result<(), BackupError> { - exec!( - pool, - "UPDATE restore_jobs SET status = ?, error = ?, completed_at = ? WHERE id = ?", - status.to_string(), - error.map(|s| s.to_string()), - now.to_string(), - id.to_string(), - ); - Ok(()) -} diff --git a/apps/backend/src/services/backup/mod.rs b/apps/backend/src/services/backup/mod.rs index 3407ca88..8d62bfc0 100644 --- a/apps/backend/src/services/backup/mod.rs +++ b/apps/backend/src/services/backup/mod.rs @@ -1275,6 +1275,11 @@ fn map_to_values(spec: &schema::TableSpec, m: &Row) -> Vec> { spec.columns .iter() .map(|c| match m.get(c.name) { + // Versions are operational, not content: restored rows start in a fresh + // random range so ETags issued before the restore never match again. + _ if spec.name == "entries" && c.name == "version" => { + Some(rand::random_range((1i64 << 60)..(1i64 << 62)).to_string()) + } Some(serde_json::Value::String(s)) => Some(s.clone()), Some(serde_json::Value::Null) | None => None, Some(other) => Some(other.to_string()), @@ -1605,3 +1610,28 @@ mod tests { assert!(validate_restored_settings(&tables).is_err()); } } + +#[cfg(test)] +mod restore_version_tests { + use super::*; + + #[test] + fn restore_never_reuses_archived_entry_versions() { + let entries = schema::table_spec("entries").unwrap(); + let version_index = entries + .columns + .iter() + .position(|column| column.name == "version") + .unwrap(); + let archived = serde_json::json!({"id": "entry", "data": "{}", "version": "7"}) + .as_object() + .unwrap() + .clone(); + let version = map_to_values(entries, &archived)[version_index] + .as_deref() + .unwrap() + .parse::() + .unwrap(); + assert!(((1i64 << 60)..(1i64 << 62)).contains(&version)); + } +} diff --git a/apps/backend/src/services/backup/schema.rs b/apps/backend/src/services/backup/schema.rs index 2d249936..962be4ff 100644 --- a/apps/backend/src/services/backup/schema.rs +++ b/apps/backend/src/services/backup/schema.rs @@ -129,6 +129,7 @@ pub static TABLES: &[TableSpec] = &[ col("slug", Text), col("status", Text), col("singleton_collection_id", Text), + col("version", Int), col("created_at", Timestamp), col("updated_at", Timestamp), col("published_at", Timestamp), @@ -157,7 +158,12 @@ pub static TABLES: &[TableSpec] = &[ TableSpec { name: "entry_file_references", site_where: SiteWhere::SiteId, - columns: &[col("entry_id", Text), col("file_id", Text), col("site_id", Text)], + columns: &[ + col("entry_id", Text), + col("file_id", Text), + col("site_id", Text), + col("field_name", Text), + ], }, TableSpec { name: "entry_revisions", @@ -397,7 +403,10 @@ pub async fn apply_restore(pool: &DbPool, plan: &RestorePlan) -> Result<(), Back match pool { DbPool::Sqlite(p) => { - let mut tx = p.begin().await.map_err(|e| BackupError::Db(e.to_string()))?; + let mut tx = p + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|e| BackupError::Db(e.to_string()))?; exec!(tx); tx.commit().await.map_err(|e| BackupError::Db(e.to_string()))?; } @@ -433,6 +442,7 @@ mod registry_tests { #[test] fn backup_registry_excludes_tokens_and_encrypted_credentials() { assert!(table_spec("access_tokens").is_none()); + assert!(table_spec("signed_upload_uses").is_none()); let settings = table_spec("instance_settings").unwrap(); assert!( !settings diff --git a/apps/backend/src/services/collection.rs b/apps/backend/src/services/collection.rs index 890c82ed..0f2a2f9f 100644 --- a/apps/backend/src/services/collection.rs +++ b/apps/backend/src/services/collection.rs @@ -9,12 +9,11 @@ use uuid::Uuid; use crate::models::collection::Collection; use crate::repository::error::RepositoryError; -use crate::repository::traits::{CollectionRepository, EntryRepository}; +use crate::repository::traits::CollectionRepository; #[derive(Clone)] pub struct CollectionService { collection_repo: Arc, - entry_repo: Arc, } #[derive(Error, Debug)] @@ -25,6 +24,9 @@ pub enum CollectionError { #[error("Collection with this name or slug already exists")] AlreadyExists, + #[error("Schema changed while the update was being prepared")] + PreconditionFailed, + #[error("Invalid definition: {0}")] InvalidDefinition(String), @@ -40,19 +42,23 @@ impl CollectionError { StatusCode::CONFLICT, Json(json!({"error": "Collection with this name or slug already exists"})), ), + CollectionError::PreconditionFailed => ( + StatusCode::PRECONDITION_FAILED, + Json(json!({"error":"Schema changed; refresh and retry"})), + ), CollectionError::InvalidDefinition(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - CollectionError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), + CollectionError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), }; (status, body).into_response() } } impl CollectionService { - pub fn new(collection_repo: Arc, entry_repo: Arc) -> Self { - Self { - collection_repo, - entry_repo, - } + pub fn new(collection_repo: Arc) -> Self { + Self { collection_repo } } pub async fn list_collections(&self, site_id: &str) -> Result, CollectionError> { @@ -188,53 +194,38 @@ impl CollectionService { name_changed, slug_changed, definition_changed ); - if let Some(new_def) = definition { - debug!("Processing definition changes for migration"); - let old_def: Option = serde_json::from_str(&existing.definition).ok(); - let new_def_parsed: Option = serde_json::from_str(new_def).ok(); - - if let (Some(old_d), Some(new_d)) = (old_def, new_def_parsed) { - let rename_map = compute_field_rename_map(&old_d, &new_d); - - if !rename_map.is_empty() { - info!("Field renames detected: {:?}", rename_map); - if existing.is_singleton { - debug!("Migrating singleton field renames via entry repo"); - match self - .entry_repo - .migrate_singleton_field_renames(&existing.site_id, &existing.id, &rename_map) - .await - { - Ok(_) => debug!("Singleton field renames completed"), - Err(e) => error!("Failed to migrate singleton field renames: error={}", e), - } - } else if let Ok(items) = self.collection_repo.get_content_for_migration(&existing.id).await { - debug!("Migrating content field renames for {} items", items.len()); - match self - .collection_repo - .migrate_content_field_renames(&items, &rename_map) - .await - { - Ok(_) => debug!("Content field renames completed"), - Err(e) => error!("Failed to migrate content field renames: error={}", e), - } - } - } else { - debug!("No field renames detected"); - } - } - } + let rename_map = if definition.is_some() { + let old: serde_json::Value = serde_json::from_str(&existing.definition) + .map_err(|error| CollectionError::DatabaseError(error.to_string()))?; + let new: serde_json::Value = serde_json::from_str(&definition_str) + .map_err(|error| CollectionError::InvalidDefinition(error.to_string()))?; + compute_field_rename_map(&old, &new) + } else { + HashMap::new() + }; debug!("Updating collection in repository: id={}", existing.id); self.collection_repo - .update(&existing.id, name, new_slug, &definition_str) + .update( + &existing.id, + name, + new_slug, + &definition_str, + &rename_map, + &existing.definition, + ) .await .map_err(|e| { error!( "Failed to update collection in repository: id={}, error={}", existing.id, e ); - CollectionError::DatabaseError(e.to_string()) + match e { + RepositoryError::UniqueViolation(_) => CollectionError::AlreadyExists, + RepositoryError::PreconditionFailed => CollectionError::PreconditionFailed, + RepositoryError::NotFound => CollectionError::NotFound, + _ => CollectionError::DatabaseError(e.to_string()), + } })?; info!( @@ -282,7 +273,19 @@ pub fn compute_field_rename_map(old_def: &serde_json::Value, new_def: &serde_jso let mut used_old = vec![false; old_fields.len()]; let mut used_new = vec![false; new_fields.len()]; + // Names that survive are the same field regardless of display order or + // changed validation. Matching positions first rotated data on reorder. + for (old_index, old) in old_fields.iter().enumerate() { + if let Some(new_index) = new_fields.iter().position(|new| new["name"] == old["name"]) { + used_old[old_index] = true; + used_new[new_index] = true; + } + } + for i in 0..old_fields.len().min(new_fields.len()) { + if used_old[i] || used_new[i] { + continue; + } let of = &old_fields[i]; let nf = &new_fields[i]; if of["name"] != nf["name"] @@ -326,6 +329,7 @@ pub fn compute_field_rename_map(old_def: &serde_json::Value, new_def: &serde_jso #[cfg(test)] mod tests { use super::*; + use crate::repository::traits::EntryRepository; use crate::test_helpers::{InMemoryCollectionRepository, InMemoryEntryRepository}; use std::sync::Arc; @@ -338,7 +342,7 @@ mod tests { } fn make_service() -> CollectionService { - CollectionService::new(test_repo(), test_entry_repo()) + CollectionService::new(test_repo()) } fn create_test_collection() -> Collection { @@ -418,7 +422,7 @@ mod tests { async fn test_update_collection_success() { let repo = test_repo(); repo.add_collection(create_test_collection()); - let service = CollectionService::new(repo, test_entry_repo()); + let service = CollectionService::new(repo); let result = service .update_collection("site-123", "test-collection", Some("Updated"), None, None) @@ -441,7 +445,7 @@ mod tests { async fn test_update_collection_with_slug_change() { let repo = test_repo(); repo.add_collection(create_test_collection()); - let service = CollectionService::new(repo, test_entry_repo()); + let service = CollectionService::new(repo); let result = service .update_collection("site-123", "test-collection", None, Some("new-slug"), None) @@ -454,7 +458,7 @@ mod tests { async fn test_update_collection_with_definition_change() { let repo = test_repo(); repo.add_collection(create_test_collection()); - let service = CollectionService::new(repo, test_entry_repo()); + let service = CollectionService::new(repo); let new_def = r#"{"fields": [{"name": "new_title", "type": "text"}]}"#; let result = service @@ -467,7 +471,7 @@ mod tests { async fn test_delete_collection_success() { let repo = test_repo(); repo.add_collection(create_test_collection()); - let service = CollectionService::new(repo, test_entry_repo()); + let service = CollectionService::new(repo); let result = service.delete_collection("site-123", "test-collection").await; assert!(result.is_ok()); @@ -490,18 +494,33 @@ mod tests { let mut collection = create_test_collection(); collection.is_singleton = true; repo.add_collection(collection.clone()); - let service = CollectionService::new(repo, entry_repo); - - let result = service - .entry_repo - .upsert_singleton_entry( + let service = crate::services::singleton::SingletonService::new( + repo, + entry_repo.clone(), + Arc::new(crate::test_helpers::InMemoryFileRepository::new()), + ); + let response = service + .update_singleton( "site-123", - &collection.id, "test-collection", - r#"{"title":"Hello"}"#, + &serde_json::json!({"title":"Hello"}), + None, None, None, ) + .await + .unwrap(); + assert_eq!(response.data.unwrap()["title"], "Hello"); + let result = entry_repo + .upsert_singleton_entry(crate::repository::traits::UpsertSingletonParams { + site_id: "site-123", + collection_id: &collection.id, + slug: "test-collection", + data: r#"{"title":"Hello"}"#, + created_by: None, + change_summary: None, + expected_definition: None, + }) .await; assert!(result.is_ok()); } @@ -510,11 +529,26 @@ mod tests { async fn test_update_singleton_data_creates_entry() { let entry_repo = test_entry_repo(); let result = entry_repo - .upsert_singleton_entry("site-123", "col-123", "x", r#"{"title":"Hello"}"#, None, None) + .upsert_singleton_entry(crate::repository::traits::UpsertSingletonParams { + site_id: "site-123", + collection_id: "col-123", + slug: "x", + data: r#"{"title":"Hello"}"#, + created_by: None, + change_summary: None, + expected_definition: None, + }) .await; assert!(result.is_ok()); } + #[test] + fn field_reordering_and_validation_changes_never_rename_surviving_names() { + let old = serde_json::json!({"fields":[{"name":"first","type":"text"},{"name":"second","type":"text"}]}); + let reordered = serde_json::json!({"fields":[{"name":"second","type":"text"},{"name":"first","type":"text","required":true}]}); + assert!(compute_field_rename_map(&old, &reordered).is_empty()); + } + #[test] fn test_compute_field_rename_map_empty() { let old_def = json!({"fields": []}); diff --git a/apps/backend/src/services/deployment.rs b/apps/backend/src/services/deployment.rs index d92df37a..6b9dbc10 100644 --- a/apps/backend/src/services/deployment.rs +++ b/apps/backend/src/services/deployment.rs @@ -177,7 +177,10 @@ impl DeploymentService { let id = Uuid::now_v7().to_string(); match &self.pool { DbPool::Sqlite(pool) => { - let mut tx = pool.begin().await.map_err(|error| error.to_string())?; + let mut tx = pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| error.to_string())?; if value.is_primary { sqlx::query("UPDATE deployment_triggers SET is_primary=0 WHERE site_id=?") .bind(site_id) @@ -257,7 +260,10 @@ impl DeploymentService { .map_err(|error| error.to_string())?; match &self.pool { DbPool::Sqlite(pool) => { - let mut tx = pool.begin().await.map_err(|error| error.to_string())?; + let mut tx = pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| error.to_string())?; if value.is_primary { sqlx::query("UPDATE deployment_triggers SET is_primary=0 WHERE site_id=?") .bind(site_id) diff --git a/apps/backend/src/services/entry.rs b/apps/backend/src/services/entry.rs index 410912dc..02042dcc 100644 --- a/apps/backend/src/services/entry.rs +++ b/apps/backend/src/services/entry.rs @@ -38,6 +38,7 @@ pub struct UpdateEntryInput<'a> { pub status: Option<&'a str>, pub created_by: Option<&'a str>, pub change_summary: Option<&'a str>, + pub expected_version: Option<&'a str>, } #[derive(Error, Debug)] @@ -51,6 +52,9 @@ pub enum EntryError { #[error("Entry with this slug already exists for this collection")] AlreadyExists, + #[error("Version precondition failed")] + PreconditionFailed, + #[error("Validation failed: {0}")] ValidationFailed(String), @@ -67,8 +71,15 @@ impl EntryError { StatusCode::CONFLICT, Json(json!({"error": "Entry with this slug already exists for this collection"})), ), + EntryError::PreconditionFailed => ( + StatusCode::PRECONDITION_FAILED, + Json(json!({"error": "precondition_failed", "message": "The entry changed since it was read"})), + ), EntryError::ValidationFailed(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - EntryError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), + EntryError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), }; (status, body).into_response() } @@ -189,6 +200,24 @@ impl EntryService { Ok(()) } + async fn validate_file_references(&self, site_id: &str, data: &Value) -> Result<(), EntryError> { + let ids = self.extract_file_ids_from_value(data); + if ids.is_empty() { + return Ok(()); + } + let files = self + .file_repo + .get_by_ids(site_id, &ids) + .await + .map_err(|error| EntryError::DatabaseError(error.to_string()))?; + if files.len() != ids.len() || files.iter().any(|file| file.deleted_at.is_some()) { + return Err(EntryError::ValidationFailed( + "Entry references a missing, deleted, or cross-site file".into(), + )); + } + Ok(()) + } + /// After an entry is deleted, delete any entries that reference it through a /// relation field marked `cascade_delete`. Best-effort and recursive (chains /// of cascades). Failures are logged, not propagated — the primary delete has @@ -260,6 +289,11 @@ impl EntryService { { match self.search_via_index(search, ¶ms, query).await { Ok(result) => return Ok(result), + Err(SearchError::PaginationLimit) => { + return Err(EntryError::ValidationFailed( + "Search pagination exceeds the 10000-result window".into(), + )); + } Err(e) => warn!("Search index query failed; falling back to SQL: {}", e), } } @@ -308,17 +342,19 @@ impl EntryService { per_page: params.per_page, })?; - let mut items = Vec::with_capacity(hits.ids.len()); - for id in &hits.ids { - // Re-check site + publish scope at the DB; the index may briefly lag. - if let Ok(Some(entry)) = self - .entry_repo - .get_by_id(id, params.site_id, params.published_only) - .await - { - items.push(entry); - } - } + // Batch-hydrate ranks and recheck current visibility/status against the + // source of truth: index rows may lag writes or refer to deleted entries. + let rows = self + .entry_repo + .get_by_ids(params.site_id, &hits.ids, params.published_only) + .await + .map_err(|error| SearchError::Repository(error.to_string()))?; + let mut rows = rows + .into_iter() + .filter(|entry| params.status.is_none_or(|status| entry.status == status)) + .map(|entry| (entry.id.clone(), entry)) + .collect::>(); + let items = hits.ids.iter().filter_map(|id| rows.remove(id)).collect(); Ok(EntriesListResult { items, @@ -359,20 +395,29 @@ impl EntryService { .await .map_err(|e| EntryError::DatabaseError(e.to_string()))?; - if let Some(ref c) = collection - && c.is_singleton + if collection + .as_ref() + .is_none_or(|collection| collection.site_id != site_id) { + return Err(EntryError::NotFound); + } + + if collection.as_ref().is_some_and(|collection| collection.is_singleton) { warn!( "Attempted to create entry for singleton collection: id={}, slug={}", - collection_id, c.slug + collection_id, + collection + .as_ref() + .map(|collection| collection.slug.as_str()) + .unwrap_or_default() ); return Err(EntryError::ValidationFailed( "Cannot create entries for singleton collections via /entries; use /singletons/{slug}".into(), )); } - if let Some(ref c) = collection - && let Ok(definition) = serde_json::from_str::(&c.definition) + if let Some(collection) = collection.as_ref() + && let Ok(definition) = serde_json::from_str::(&collection.definition) && let Some(fields) = definition.get("fields").and_then(|f| f.as_array()) { if let Some(err) = super::definition_validation::validate_entry_data(data, fields) { @@ -380,11 +425,21 @@ impl EntryService { } self.validate_relations(site_id, fields, data).await?; } + self.validate_file_references(site_id, data).await?; let data_str = data.to_string(); - self.entry_repo - .create(&id, site_id, collection_id, &data_str, slug, created_by) + let entry = self + .entry_repo + .create(crate::repository::traits::CreateEntryParams { + id: &id, + site_id, + collection_id, + data: &data_str, + slug, + created_by, + expected_definition: collection.as_ref().map(|collection| collection.definition.as_str()), + }) .await .map_err(|e| { error!( @@ -393,36 +448,16 @@ impl EntryService { ); match e { RepositoryError::UniqueViolation(_) => EntryError::AlreadyExists, + RepositoryError::PreconditionFailed => EntryError::PreconditionFailed, + RepositoryError::NotFound => EntryError::NotFound, _ => EntryError::DatabaseError(e.to_string()), } })?; debug!("Entry created in repository: id={}", id); - // Sync file references - if let Err(e) = self.entry_repo.sync_file_references(&id, site_id, data).await { - warn!("Failed to sync file references for entry {}: {}", id, e); - // Continue anyway as this is not critical - } - - match self.entry_repo.get_by_id(&id, site_id, false).await { - Ok(Some(entry)) => { - info!( - "Entry created successfully: id={}, site_id={}, slug={}", - id, site_id, slug - ); - self.enqueue_upsert(&entry).await; - Ok(entry) - } - Ok(None) => { - error!("Entry not found after creation: id={}", id); - Err(EntryError::NotFound) - } - Err(e) => { - error!("Failed to fetch entry after creation: id={}, error={}", id, e); - Err(EntryError::DatabaseError(e.to_string())) - } - } + self.enqueue_upsert(&entry).await; + Ok(entry) } pub async fn update_entry(&self, input: UpdateEntryInput<'_>) -> Result { @@ -434,6 +469,7 @@ impl EntryService { status, created_by, change_summary, + expected_version, } = input; debug!("Updating entry: id={}, site_id={}", id, site_id); @@ -450,6 +486,10 @@ impl EntryService { })? .ok_or(EntryError::NotFound)?; + if expected_version.is_some_and(|version| version != existing.version) { + return Err(EntryError::PreconditionFailed); + } + debug!("Fetched existing entry: id={}, site_id={}", id, site_id); let resolved_data = match data { @@ -458,7 +498,18 @@ impl EntryService { }; // Validate entry data against collection definition - if let Ok(Some(collection)) = self.collection_repo.get_by_id(&existing.collection_id).await + let collection = self + .collection_repo + .get_by_id(&existing.collection_id) + .await + .map_err(|e| EntryError::DatabaseError(e.to_string()))?; + if collection + .as_ref() + .is_none_or(|collection| collection.site_id != site_id) + { + return Err(EntryError::NotFound); + } + if let Some(collection) = collection.as_ref() && let Ok(definition) = serde_json::from_str::(&collection.definition) && let Some(fields) = definition.get("fields").and_then(|f| f.as_array()) { @@ -467,11 +518,18 @@ impl EntryService { } self.validate_relations(site_id, fields, &resolved_data).await?; } + self.validate_file_references(site_id, &resolved_data).await?; let data_str = resolved_data.to_string(); let final_slug = slug.unwrap_or(&existing.slug); let final_status = status.unwrap_or(&existing.status); + if !matches!(final_status, "draft" | "published") { + return Err(EntryError::ValidationFailed( + "status must be either 'draft' or 'published'".into(), + )); + } + debug!( "Updating entry fields: data_changed={}, slug_changed={}, status_changed={}", data.is_some(), @@ -479,8 +537,11 @@ impl EntryService { status.is_some() ); - self.entry_repo + let entry = self + .entry_repo .update(UpdateEntryParams { + expected_definition: collection.as_ref().map(|collection| collection.definition.as_str()), + expected_version: Some(expected_version.unwrap_or(&existing.version)), id, site_id, data: &data_str, @@ -497,33 +558,16 @@ impl EntryService { ); match e { RepositoryError::UniqueViolation(_) => EntryError::AlreadyExists, + RepositoryError::PreconditionFailed => EntryError::PreconditionFailed, + RepositoryError::NotFound => EntryError::NotFound, _ => EntryError::DatabaseError(e.to_string()), } })?; debug!("Entry updated in repository: id={}", id); - // Sync file references - if let Err(e) = self.entry_repo.sync_file_references(id, site_id, &resolved_data).await { - warn!("Failed to sync file references for entry {}: {}", id, e); - // Continue anyway as this is not critical - } - - match self.entry_repo.get_by_id(id, site_id, false).await { - Ok(Some(entry)) => { - info!("Entry updated successfully: id={}, site_id={}", id, site_id); - self.enqueue_upsert(&entry).await; - Ok(entry) - } - Ok(None) => { - error!("Entry not found after update: id={}", id); - Err(EntryError::NotFound) - } - Err(e) => { - error!("Failed to fetch entry after update: id={}, error={}", id, e); - Err(EntryError::DatabaseError(e.to_string())) - } - } + self.enqueue_upsert(&entry).await; + Ok(entry) } pub async fn delete_entry(&self, id: &str, site_id: &str) -> Result { @@ -628,22 +672,24 @@ impl EntryService { revision_number: i64, created_by: Option<&str>, ) -> Result { - self.entry_repo - .get_by_id(entry_id, site_id, false) - .await - .map_err(|e| EntryError::DatabaseError(e.to_string()))? - .ok_or(EntryError::NotFound)?; - - let entry = self - .entry_repo - .restore_revision(entry_id, revision_number, created_by) - .await - .map_err(|e| match e { - RepositoryError::NotFound => EntryError::RevisionNotFound, - _ => EntryError::DatabaseError(e.to_string()), - })?; - self.enqueue_upsert(&entry).await; - Ok(entry) + let revision = self + .get_revision(entry_id, site_id, revision_number) + .await? + .ok_or(EntryError::RevisionNotFound)?; + let summary = format!("Restored from revision {revision_number}"); + // Apply current schema, relation and file validation; historical content + // must not bypass invariants established since its revision was saved. + self.update_entry(UpdateEntryInput { + id: entry_id, + site_id, + data: Some(&revision.data.0), + slug: None, + status: None, + created_by, + change_summary: Some(&summary), + expected_version: None, + }) + .await } pub async fn resolve_entry_files( @@ -680,7 +726,7 @@ impl EntryService { if !file_ids.is_empty() && let Ok(file_items) = self.file_repo.get_by_ids(site_id, &file_ids).await { - for f in file_items { + for f in file_items.into_iter().filter(|file| file.deleted_at.is_none()) { let url = storage.url(&f.storage_key, &f.id); file_map.insert( @@ -708,11 +754,7 @@ impl EntryService { } fn extract_file_ids_from_value(&self, data: &Value) -> Vec { - let re = regex::Regex::new(r"/api/files/([a-f0-9-]+)").unwrap(); - let json_str = data.to_string(); - re.captures_iter(&json_str) - .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) - .collect() + crate::utils::file_references::extract_file_ids_from_value(data) } } @@ -734,7 +776,18 @@ mod tests { } fn test_collection_repo() -> Arc { - Arc::new(InMemoryCollectionRepository::new()) + let repository = Arc::new(InMemoryCollectionRepository::new()); + repository.add_collection(crate::models::collection::Collection { + id: "col-123".into(), + site_id: "site-123".into(), + name: "Posts".into(), + slug: "posts".into(), + definition: r#"{"fields":[{"name":"title","type":"text"}]}"#.into(), + is_singleton: false, + created_at: "2024-01-01 00:00:00".into(), + updated_at: "2024-01-01 00:00:00".into(), + }); + repository } fn create_test_entry() -> Entry { @@ -748,6 +801,7 @@ mod tests { singleton_collection_id: None, created_at: "2024-01-01 00:00:00".to_string(), updated_at: "2024-01-01 00:00:00".to_string(), + version: "2024-01-01 00:00:00".to_string(), published_at: None, } } @@ -830,6 +884,7 @@ mod tests { status: None, created_by: None, change_summary: None, + expected_version: None, }) .await; assert!(result.is_ok()); @@ -852,6 +907,7 @@ mod tests { status: None, created_by: None, change_summary: None, + expected_version: None, }) .await; assert!(matches!(result, Err(EntryError::NotFound))); @@ -873,6 +929,7 @@ mod tests { status: Some("published"), created_by: None, change_summary: None, + expected_version: None, }) .await; assert!(result.is_ok()); @@ -995,7 +1052,7 @@ mod tests { "images": ["/api/files/abc-123-def/image.png", "/api/files/456-789-abc/image.png"] }); let ids = service.extract_file_ids_from_value(&data); - assert_eq!(ids, vec!["abc-123-def", "456-789-abc"]); + assert_eq!(ids, vec!["456-789-abc", "abc-123-def"]); } #[test] @@ -1130,6 +1187,7 @@ mod tests { status: None, created_by: None, change_summary: None, + expected_version: None, }) .await .unwrap(); @@ -1167,7 +1225,7 @@ mod tests { async fn test_create_entry_validation_failed() { let entry_repo = test_entry_repo(); let file_repo = test_file_repo(); - let col_repo = test_collection_repo(); + let col_repo = Arc::new(InMemoryCollectionRepository::new()); let collection = crate::models::collection::Collection { id: "col-123".to_string(), @@ -1194,7 +1252,7 @@ mod tests { let entry_repo = test_entry_repo(); entry_repo.add_entry(create_test_entry()); let file_repo = test_file_repo(); - let col_repo = test_collection_repo(); + let col_repo = Arc::new(InMemoryCollectionRepository::new()); let collection = crate::models::collection::Collection { id: "col-123".to_string(), @@ -1219,6 +1277,7 @@ mod tests { status: None, created_by: None, change_summary: None, + expected_version: None, }) .await; assert!(matches!(result, Err(EntryError::ValidationFailed(_)))); diff --git a/apps/backend/src/services/error.rs b/apps/backend/src/services/error.rs index 95065a3b..f56e9a08 100644 --- a/apps/backend/src/services/error.rs +++ b/apps/backend/src/services/error.rs @@ -1,5 +1,4 @@ -use axum::{Json, http::StatusCode, response::IntoResponse}; -use serde_json::json; +use axum::{http::StatusCode, response::IntoResponse}; use thiserror::Error; use crate::services::access_token::TokenError; @@ -88,6 +87,7 @@ impl ServiceError { ServiceError::Collection(e) => match e { CollectionError::NotFound => StatusCode::NOT_FOUND, CollectionError::AlreadyExists => StatusCode::CONFLICT, + CollectionError::PreconditionFailed => StatusCode::PRECONDITION_FAILED, CollectionError::InvalidDefinition(_) => StatusCode::BAD_REQUEST, CollectionError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, }, @@ -95,6 +95,7 @@ impl ServiceError { EntryError::NotFound => StatusCode::NOT_FOUND, EntryError::RevisionNotFound => StatusCode::NOT_FOUND, EntryError::AlreadyExists => StatusCode::CONFLICT, + EntryError::PreconditionFailed => StatusCode::PRECONDITION_FAILED, EntryError::ValidationFailed(_) => StatusCode::BAD_REQUEST, EntryError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, }, @@ -103,7 +104,7 @@ impl ServiceError { FileError::NotFoundOrNotDeleted => StatusCode::NOT_FOUND, FileError::NoFileProvided => StatusCode::BAD_REQUEST, FileError::FileTooLarge(_) => StatusCode::PAYLOAD_TOO_LARGE, - FileError::InvalidContentType(_) => StatusCode::BAD_REQUEST, + FileError::InvalidContentType(_) | FileError::InvalidFilename(_) => StatusCode::BAD_REQUEST, FileError::StorageError(_) => StatusCode::INTERNAL_SERVER_ERROR, FileError::NoStorageConfigured => StatusCode::INTERNAL_SERVER_ERROR, FileError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, @@ -113,6 +114,7 @@ impl ServiceError { ServiceError::Singleton(e) => match e { SingletonError::NotFound | SingletonError::NotASingleton => StatusCode::NOT_FOUND, SingletonError::ValidationFailed(_) => StatusCode::BAD_REQUEST, + SingletonError::PreconditionFailed => StatusCode::PRECONDITION_FAILED, SingletonError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, }, ServiceError::Webhook(e) => match e { @@ -130,6 +132,9 @@ impl ServiceError { } pub fn error_message(&self) -> String { + if self.status_code().is_server_error() { + return "Internal server error".into(); + } match self { ServiceError::Unauthorized(msg) => msg.clone(), ServiceError::Forbidden(msg) => msg.clone(), @@ -154,12 +159,22 @@ impl ServiceError { impl IntoResponse for ServiceError { fn into_response(self) -> axum::response::Response { // Log full error details server-side - if matches!(self, ServiceError::Internal(_)) { + if self.status_code().is_server_error() { tracing::error!("Internal error: {}", self); } let status = self.status_code(); let message = self.error_message(); - (status, Json(json!({"error": message}))).into_response() + let code = match status { + StatusCode::UNAUTHORIZED => "unauthorized", + StatusCode::FORBIDDEN => "forbidden", + StatusCode::NOT_FOUND => "not_found", + StatusCode::CONFLICT => "conflict", + StatusCode::BAD_REQUEST => "invalid_request", + StatusCode::PAYLOAD_TOO_LARGE => "payload_too_large", + StatusCode::PRECONDITION_FAILED => "precondition_failed", + _ => "internal_error", + }; + crate::error::problem_response(status, code, message) } } @@ -167,6 +182,18 @@ impl IntoResponse for ServiceError { mod tests { use super::*; + #[test] + fn nested_infrastructure_errors_never_reach_public_messages() { + for error in [ + ServiceError::Site(SiteError::DatabaseError("password=secret".into())), + ServiceError::Entry(EntryError::DatabaseError("private query".into())), + ServiceError::File(FileError::StorageError("private bucket credentials".into())), + ] { + assert!(error.status_code().is_server_error()); + assert_eq!(error.error_message(), "Internal server error"); + } + } + #[test] fn test_status_code_mapping() { assert_eq!( diff --git a/apps/backend/src/services/file.rs b/apps/backend/src/services/file.rs index 842c1207..027878e0 100644 --- a/apps/backend/src/services/file.rs +++ b/apps/backend/src/services/file.rs @@ -34,6 +34,55 @@ pub struct FileService { in_flight: Arc>, } +/// Object-store multipart uploads need explicit abort even if their driving +/// future is cancelled before it can take a normal error-return path. +struct AbortOnDropUpload { + inner: Option>, + completed: bool, +} + +impl std::fmt::Debug for AbortOnDropUpload { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("AbortOnDropUpload") + } +} + +#[async_trait::async_trait] +impl object_store::MultipartUpload for AbortOnDropUpload { + fn put_part(&mut self, data: object_store::PutPayload) -> object_store::UploadPart { + self.inner.as_mut().expect("active multipart upload").put_part(data) + } + + async fn complete(&mut self) -> object_store::Result { + let result = self.inner.as_mut().expect("active multipart upload").complete().await?; + self.completed = true; + Ok(result) + } + + async fn abort(&mut self) -> object_store::Result<()> { + if let Some(mut inner) = self.inner.take() { + inner.abort().await + } else { + Ok(()) + } + } +} + +impl Drop for AbortOnDropUpload { + fn drop(&mut self) { + if !self.completed + && let Some(mut inner) = self.inner.take() + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + runtime.spawn(async move { + if let Err(error) = inner.abort().await { + tracing::warn!(error = ?error, "Cancelled multipart upload cleanup failed"); + } + }); + } + } +} + /// Inputs for [`FileService::upload_file`]. pub struct UploadFileRequest<'a> { pub site_id: &'a str, @@ -103,6 +152,9 @@ pub enum FileError { #[error("Invalid content type: {0}")] InvalidContentType(String), + #[error("Invalid filename: {0}")] + InvalidFilename(String), + #[error("Storage error: {0}")] StorageError(String), @@ -129,16 +181,21 @@ impl FileError { ), FileError::NoFileProvided => (StatusCode::BAD_REQUEST, Json(json!({"error": "No file provided"}))), FileError::FileTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, Json(json!({"error": msg}))), - FileError::StorageError(msg) => ( + FileError::StorageError(_) => ( StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": format!("Failed to store file: {}", msg)})), + Json(json!({"error": "storage_error", "message": "File storage failed"})), ), FileError::NoStorageConfigured => ( StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "No storage providers configured"})), ), - FileError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), - FileError::InvalidContentType(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), + FileError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), + FileError::InvalidContentType(msg) | FileError::InvalidFilename(msg) => { + (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))) + } FileError::AlreadyExists => ( StatusCode::CONFLICT, Json(json!({"error": "File already exists (upload URL already used)"})), @@ -161,6 +218,28 @@ impl FileService { } } + pub fn validate_filename(filename: &str) -> Result<(), FileError> { + if filename.trim().is_empty() || filename.len() > 255 || filename.chars().any(char::is_control) { + return Err(FileError::InvalidFilename( + "Use a nonempty filename of at most 255 UTF-8 bytes without control characters".into(), + )); + } + Ok(()) + } + + pub async fn claim_signed_upload(&self, file_id: &str, expires_at: i64) -> Result<(), FileError> { + if self + .file_repo + .claim_signed_upload(file_id, expires_at) + .await + .map_err(|error| FileError::DatabaseError(error.to_string()))? + { + Ok(()) + } else { + Err(FileError::AlreadyExists) + } + } + pub async fn list_files(&self, params: ListFilesParams<'_>) -> Result { self.file_repo .list(params) @@ -169,6 +248,12 @@ impl FileService { } pub async fn get_file(&self, id: &str, site_id: &str) -> Result, FileError> { + self.get_file_including_deleted(id, site_id) + .await + .map(|file| file.filter(|file| file.deleted_at.is_none())) + } + + pub async fn get_file_including_deleted(&self, id: &str, site_id: &str) -> Result, FileError> { self.file_repo .get_by_id(id, site_id) .await @@ -220,10 +305,10 @@ impl FileService { pub async fn upload_file_streaming( &self, req: StreamingUploadRequest<'_>, - mut stream: S, + stream: S, ) -> Result where - S: Stream>> + Unpin + Send, + S: Stream>> + Send, { let StreamingUploadRequest { site_id, @@ -235,6 +320,8 @@ impl FileService { storage_provider, max_bytes, } = req; + futures_util::pin_mut!(stream); + Self::validate_filename(filename)?; info!( "Uploading file (streaming): site_id={}, content_type={}", @@ -275,12 +362,15 @@ impl FileService { .extension() .and_then(|e| e.to_str()) .unwrap_or(""); - let generated_filename = if ext.is_empty() { - format!("{}.{}", &file_id[..8], self.mime_to_ext(content_type)) - } else { - format!("{}.{}", &file_id[..8], ext) - }; - let storage_key = format!("s_{}/f_{}/{}", site_id, file_id, generated_filename); + let generated_filename = + if ext.is_empty() || ext.len() > 16 || !ext.bytes().all(|byte| byte.is_ascii_alphanumeric()) { + format!("{}.{}", &file_id[..8], self.mime_to_ext(content_type)) + } else { + format!("{}.{}", &file_id[..8], ext) + }; + // Attempts must never overwrite the object belonging to another process + // that wins the durable files primary-key claim for the same signed URL. + let storage_key = format!("s_{}/f_{}/{}/{}", site_id, file_id, Uuid::now_v7(), generated_filename); let mime_type = content_type.to_string(); let max = max_bytes; @@ -288,6 +378,10 @@ impl FileService { error!("Failed to start multipart upload: key={}, error={}", storage_key, e); FileError::StorageError(e.to_string()) })?; + let upload = Box::new(AbortOnDropUpload { + inner: Some(upload), + completed: false, + }); let mut writer = WriteMultipart::new_with_chunk_size(upload, MULTIPART_CHUNK_SIZE); let too_large = @@ -309,7 +403,7 @@ impl FileService { None => break, }; - total += chunk.len(); + total = total.saturating_add(chunk.len()); if total > max { let _ = writer.abort().await; warn!("File too large: site_id={}, size>{} bytes, max={}", site_id, total, max); @@ -377,9 +471,10 @@ impl FileService { { Ok(file) => file, Err(RepositoryError::UniqueViolation(_)) => { - // A concurrent upload with the same pre-generated id won the - // insert. Do NOT delete the blob — the winner shares the key. - warn!("Duplicate file id on insert (upload URL reused): id={}", file_id); + // Each attempt owns a distinct key; clean only this losing blob. + if let Err(error) = storage.delete(&storage_key).await { + warn!(error = ?error, "Failed to clean duplicate upload blob"); + } return Err(FileError::AlreadyExists); } Err(e) => { @@ -571,6 +666,20 @@ impl FileService { use_thumbnail: bool, storage: Arc, ) -> Result<(Bytes, String, String), FileError> { + let (_, mut stream, content_type, filename) = self.serve_file_streaming(id, use_thumbnail, storage).await?; + let mut bytes = bytes::BytesMut::new(); + while let Some(chunk) = stream.next().await { + bytes.extend_from_slice(&chunk.map_err(|error| FileError::StorageError(error.to_string()))?); + } + Ok((bytes.freeze(), content_type, filename)) + } + + pub async fn serve_file_streaming( + &self, + id: &str, + use_thumbnail: bool, + storage: Arc, + ) -> Result<(u64, crate::storage::StorageStream, String, String), FileError> { debug!("Serving file: id={}, use_thumbnail={}", id, use_thumbnail); let file = self @@ -616,26 +725,11 @@ impl FileService { (file.storage_key.as_str(), file.mime_type.as_str()) }; - let bytes = match storage.get(key).await { - Ok(data) => { - debug!( - "File served successfully: id={}, size={} bytes, storage_key={}", - id, - data.len(), - key - ); - data - } - Err(e) => { - error!( - "Failed to retrieve file from storage: id={}, key={}, error={}", - id, key, e - ); - return Err(FileError::StorageError(e.to_string())); - } - }; - - Ok((bytes, content_type.to_string(), file.original_name)) + let (size, stream) = storage.get_stream(key).await.map_err(|error| { + tracing::error!(error = ?error, "File delivery failed"); + FileError::StorageError(error.to_string()) + })?; + Ok((size, stream, content_type.to_string(), file.original_name)) } pub(crate) fn file_to_with_url(&self, file: &File, storage: &dyn StorageProvider) -> FileWithUrl { @@ -747,6 +841,46 @@ mod tests { } } + #[tokio::test] + async fn cancelling_upload_future_aborts_its_external_multipart_resource() { + #[derive(Debug)] + struct RecordingUpload(Arc); + #[async_trait::async_trait] + impl object_store::MultipartUpload for RecordingUpload { + fn put_part(&mut self, _: object_store::PutPayload) -> object_store::UploadPart { + Box::pin(async { Ok(()) }) + } + async fn complete(&mut self) -> object_store::Result { + unreachable!() + } + async fn abort(&mut self) -> object_store::Result<()> { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + } + let aborted = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let flag = aborted.clone(); + let (started, ready) = tokio::sync::oneshot::channel(); + let task = tokio::spawn(async move { + let _writer = WriteMultipart::new(Box::new(AbortOnDropUpload { + inner: Some(Box::new(RecordingUpload(flag))), + completed: false, + })); + started.send(()).unwrap(); + std::future::pending::<()>().await; + }); + ready.await.unwrap(); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + tokio::time::timeout(std::time::Duration::from_secs(1), async { + while !aborted.load(std::sync::atomic::Ordering::SeqCst) { + tokio::task::yield_now().await; + } + }) + .await + .expect("Cancelled upload leaked its multipart resource"); + } + #[tokio::test] async fn test_list_files() { let file_repo = test_file_repo(); diff --git a/apps/backend/src/services/mod.rs b/apps/backend/src/services/mod.rs index ad895723..5b15e1dd 100644 --- a/apps/backend/src/services/mod.rs +++ b/apps/backend/src/services/mod.rs @@ -38,12 +38,10 @@ pub struct Services { pub storage_profile: Arc, /// Full-text search engine used for **reads** (ranked queries). `None` when /// search is disabled or the index couldn't be opened — callers then fall back - /// to the SQL `LIKE` path. Read-write in the server process, read-only in - /// `vcms mcp stdio`. + /// to the SQL `LIKE` path. pub search: Option>, /// Durable queue used for **writes**: content changes enqueue here and the - /// server's indexer applies them. Present whenever search is enabled, even in - /// read-only processes (so their writes still reach the server's index). + /// server's indexer applies them. Present whenever search is enabled. pub search_queue: Option>, } @@ -51,24 +49,7 @@ impl Services { /// Build services for the running server: opens the search index **read-write** /// (this process owns the single writer and runs the indexer). pub fn new(repository: Arc, pool: &DbPool, config: &Config) -> Self { - let search = build_search(config, IndexAccess::ReadWrite); - Self::assemble(repository, pool, config, search) - } - - /// Build services for an auxiliary process (e.g. `vcms mcp stdio`): opens the - /// index **read-only** so it can search without contending for the writer lock. - /// Its writes still enqueue for the server to index. - pub fn new_read_only(repository: Arc, pool: &DbPool, config: &Config) -> Self { - let search = build_search(config, IndexAccess::ReadOnly); - Self::assemble(repository, pool, config, search) - } - - fn assemble( - repository: Arc, - pool: &DbPool, - config: &Config, - search: Option>, - ) -> Self { + let search = build_search(config); let config = Arc::new(config.clone()); let search_queue = if config.search_enabled { @@ -97,10 +78,7 @@ impl Services { repository.access_token.clone(), config.token_index_key.clone(), )), - collection: Arc::new(collection::CollectionService::new( - repository.collection.clone(), - repository.entry.clone(), - )), + collection: Arc::new(collection::CollectionService::new(repository.collection.clone())), entry: Arc::new( entry::EntryService::new( repository.entry.clone(), @@ -131,20 +109,14 @@ impl Services { } } -enum IndexAccess { - ReadWrite, - ReadOnly, -} - fn search_index_path(config: &Config) -> Option { config.search_index_path.clone().map(PathBuf::from) } -/// Open the search index for queries. Returns `None` when search is disabled or the -/// index can't be opened, so the app degrades to the SQL `LIKE` fallback rather than -/// failing to start. A missing index in read-only mode is expected (the server -/// hasn't built it yet) and logged softly. -fn build_search(config: &Config, access: IndexAccess) -> Option> { +/// Open the search index read-write: the server owns the single writer and runs +/// the indexer. Returns `None` when search is disabled or the index can't be +/// opened, so the app degrades to the SQL `LIKE` fallback instead of failing. +fn build_search(config: &Config) -> Option> { if !config.search_enabled { return None; } @@ -152,27 +124,14 @@ fn build_search(config: &Config, access: IndexAccess) -> Option SearchService::open(&path), - IndexAccess::ReadOnly => SearchService::open_read_only(&path), - }; - match opened { - Ok(svc) => Some(Arc::new(svc)), - Err(e) => { - match access { - IndexAccess::ReadWrite => { - tracing::error!( - "Full-text search disabled: failed to open index at {}: {}", - path.display(), - e - ) - } - IndexAccess::ReadOnly => tracing::warn!( - "Read-only search index unavailable at {} ({}); using SQL LIKE fallback", - path.display(), - e - ), - } + match SearchService::open(&path) { + Ok(search) => Some(Arc::new(search)), + Err(error) => { + tracing::error!( + "Full-text search disabled: failed to open index at {}: {}", + path.display(), + error + ); None } } diff --git a/apps/backend/src/services/search/mod.rs b/apps/backend/src/services/search/mod.rs index 80cf35a3..26df5889 100644 --- a/apps/backend/src/services/search/mod.rs +++ b/apps/backend/src/services/search/mod.rs @@ -6,19 +6,12 @@ //! English-stemmed tokens (so "running" matches "run"). Typo tolerance is left as a //! follow-up: Tantivy's fuzzy queries score by constant, which would flatten ranking. //! -//! ## Single writer, many readers, cross-process sync +//! ## Single writer, durable queue //! -//! Tantivy permits one `IndexWriter` per directory. Rather than let that limit who -//! can search, we split the roles: -//! -//! - **Reading** needs no lock. Any process opens the index [read-only] -//! ([`SearchService::open_read_only`]) and gets full ranked search — including a -//! separate `vcms mcp stdio` process running alongside the server. -//! - **Writing** goes through a durable database queue ([`queue`]) instead of the -//! index directly. Any process enqueues on a content change; the one running -//! server owns the writer ([`SearchService::open`]) and is the sole consumer -//! ([`indexer`]) that drains the queue into the index. This makes sync work across -//! processes and survive restarts, while keeping the embedded single-writer model. +//! Tantivy permits one `IndexWriter` per directory, owned by the running server +//! ([`SearchService::open`]). Content writes go through a durable database queue +//! ([`queue`]) instead of the index directly; the server's [`indexer`] is the sole +//! consumer and drains it into the index, so pending updates survive restarts. //! //! [Tantivy]: https://github.com/quickwit-oss/tantivy @@ -44,6 +37,8 @@ use schema::EntryFields; const WRITER_HEAP_BYTES: usize = 50_000_000; /// Page size used when scanning the database during a full rebuild. const REBUILD_PAGE_SIZE: i64 = 500; +/// Bound the heap used by ranked offset collection until search-after cursors exist. +pub const MAX_SEARCH_WINDOW: i64 = 10_000; #[derive(Debug, thiserror::Error)] pub enum SearchError { @@ -59,8 +54,8 @@ pub enum SearchError { Repository(String), #[error("queue db error: {0}")] Db(String), - #[error("search index is read-only in this process")] - ReadOnly, + #[error("Search pagination exceeds the 10000-result window")] + PaginationLimit, } /// Filters + pagination for a search query. Mirrors the relevant subset of @@ -82,16 +77,10 @@ pub struct SearchHits { } /// Embedded full-text search engine for entries. -/// -/// Read-write when opened with [`open`](Self::open) (the running server), read-only -/// when opened with [`open_read_only`](Self::open_read_only) (e.g. `vcms mcp stdio`). -/// Read-only instances can [`search_entries`](Self::search_entries) but return -/// [`SearchError::ReadOnly`] from any write/commit/rebuild call. pub struct SearchService { index: Index, reader: IndexReader, - /// `Some` only for the writer-owning process; `None` for read-only openers. - writer: Option>, + writer: Mutex, fields: EntryFields, } @@ -112,29 +101,7 @@ impl SearchService { Ok(Self { index, reader, - writer: Some(Mutex::new(writer)), - fields, - }) - } - - /// Open the index read-only (no writer, no directory lock). Fails if the index - /// does not exist yet. Used by processes that only search — they can run - /// concurrently with the writer-owning server. The reader auto-reloads on the - /// server's commits so results stay fresh. - pub fn open_read_only(index_path: &Path) -> Result { - let dir = MmapDirectory::open(index_path)?; - let index = Index::open(dir)?; - schema::register_tokenizers(&index); - let fields = schema::fields_from(&index.schema())?; - let reader = index - .reader_builder() - .reload_policy(ReloadPolicy::OnCommitWithDelay) - .try_into()?; - - Ok(Self { - index, - reader, - writer: None, + writer: Mutex::new(writer), fields, }) } @@ -144,12 +111,8 @@ impl SearchService { self.reader.searcher().num_docs() == 0 } - /// Lock the writer, or fail if this instance is read-only. fn writer_guard(&self) -> Result, SearchError> { - self.writer - .as_ref() - .ok_or(SearchError::ReadOnly) - .map(|m| m.lock().expect("search writer poisoned")) + Ok(self.writer.lock().expect("search writer poisoned")) } /// Stage an upsert of one entry without committing (the indexer batches commits). @@ -192,19 +155,23 @@ impl SearchService { if let Some(cid) = params.collection_id { clauses.push((Occur::Must, term_query(self.fields.collection_id, cid))); } - // `published_only` is the stricter constraint; otherwise honor an explicit status. - let status_filter = if params.published_only { - Some("published") - } else { - params.status - }; - if let Some(status) = status_filter { + if params.published_only { + clauses.push((Occur::Must, term_query(self.fields.status, "published"))); + } + if let Some(status) = params.status { clauses.push((Occur::Must, term_query(self.fields.status, status))); } let query = BooleanQuery::new(clauses); - let per_page = params.per_page.max(1) as usize; - let offset = ((params.page.max(1) - 1) * params.per_page.max(1)) as usize; + let per_page = params.per_page.max(1); + let window = params + .page + .max(1) + .checked_mul(per_page) + .filter(|window| *window <= MAX_SEARCH_WINDOW) + .ok_or(SearchError::PaginationLimit)?; + let offset = (window - per_page) as usize; + let per_page = per_page as usize; let top_docs = TopDocs::with_limit(per_page).and_offset(offset).order_by_score(); let (top, total) = searcher.search(&query, &(top_docs, Count))?; @@ -392,6 +359,7 @@ mod tests { singleton_collection_id: None, created_at: "2026-01-01 00:00:00".to_string(), updated_at: "2026-01-01 00:00:00".to_string(), + version: "2026-01-01 00:00:00".to_string(), published_at: None, } } @@ -427,6 +395,22 @@ mod tests { .ids } + #[test] + fn extreme_pagination_is_rejected_without_unbounded_heap_or_overflow() { + let directory = tempfile::tempdir().unwrap(); + let search = SearchService::open(directory.path()).unwrap(); + let result = search.search_entries(&SearchParams { + site_id: "site", + collection_id: None, + status: None, + published_only: true, + query: "test", + page: i64::MAX, + per_page: 200, + }); + assert!(matches!(result, Err(SearchError::PaginationLimit))); + } + #[test] fn stems_and_finds_words() { let (svc, _d) = service(); @@ -486,24 +470,6 @@ mod tests { assert_eq!(search(&svc, "s1", "second"), vec!["e1"]); } - #[test] - fn read_only_cannot_write() { - let dir = tempfile::tempdir().expect("temp dir"); - // Create the index first (read_only fails on a nonexistent index). - { - let svc = SearchService::open(dir.path()).unwrap(); - put(&svc, &entry("e1", "s1", "c1", "draft", "a", r#"{"title":"hello"}"#)); - } - let ro = SearchService::open_read_only(dir.path()).unwrap(); - // Reads work… - assert_eq!(search(&ro, "s1", "hello"), vec!["e1"]); - // …writes are rejected. - assert!(matches!( - ro.index_doc(&entry("e2", "s1", "c1", "draft", "b", r#"{"title":"x"}"#)), - Err(SearchError::ReadOnly) - )); - } - #[test] fn status_filter_applies() { let (svc, _d) = service(); diff --git a/apps/backend/src/services/search/schema.rs b/apps/backend/src/services/search/schema.rs index 12695abc..6617997e 100644 --- a/apps/backend/src/services/search/schema.rs +++ b/apps/backend/src/services/search/schema.rs @@ -67,19 +67,6 @@ pub fn build_schema() -> (Schema, EntryFields) { ) } -/// Resolve field handles from an already-open index's schema (used when opening -/// an existing index read-only, where we don't rebuild the schema ourselves). -pub fn fields_from(schema: &Schema) -> tantivy::Result { - Ok(EntryFields { - id: schema.get_field("id")?, - site_id: schema.get_field("site_id")?, - collection_id: schema.get_field("collection_id")?, - status: schema.get_field("status")?, - slug: schema.get_field("slug")?, - body: schema.get_field("body")?, - }) -} - /// Register the English-stemming tokenizer used by the `body`/`slug` fields. pub fn register_tokenizers(index: &Index) { let en_stem = TextAnalyzer::builder(SimpleTokenizer::default()) diff --git a/apps/backend/src/services/singleton.rs b/apps/backend/src/services/singleton.rs index f349070e..8ea593b5 100644 --- a/apps/backend/src/services/singleton.rs +++ b/apps/backend/src/services/singleton.rs @@ -31,6 +31,9 @@ pub enum SingletonError { #[error("Validation failed: {0}")] ValidationFailed(String), + #[error("Version precondition failed")] + PreconditionFailed, + #[error("Database error: {0}")] DatabaseError(String), } @@ -47,9 +50,13 @@ impl SingletonError { Json(json!({"error": "Singleton not found"})), ), SingletonError::ValidationFailed(msg) => (axum::http::StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - SingletonError::DatabaseError(msg) => ( + SingletonError::PreconditionFailed => ( + axum::http::StatusCode::PRECONDITION_FAILED, + Json(json!({"error": "precondition_failed", "message": "The singleton changed since it was read"})), + ), + SingletonError::DatabaseError(_) => ( axum::http::StatusCode::INTERNAL_SERVER_ERROR, - Json(json!({"error": msg})), + Json(json!({"error": "internal_error", "message": "Internal server error"})), ), }; (status, body).into_response() @@ -141,6 +148,24 @@ impl SingletonService { Ok(()) } + async fn validate_file_references(&self, site_id: &str, data: &Value) -> Result<(), SingletonError> { + let ids = self.extract_file_ids_from_value(data); + if ids.is_empty() { + return Ok(()); + } + let files = self + .file_repo + .get_by_ids(site_id, &ids) + .await + .map_err(|error| SingletonError::DatabaseError(error.to_string()))?; + if files.len() != ids.len() || files.iter().any(|file| file.deleted_at.is_some()) { + return Err(SingletonError::ValidationFailed( + "Singleton references a missing, deleted, or cross-site file".into(), + )); + } + Ok(()) + } + fn build_response(c: &Collection, entry: Option<&crate::models::entry::Entry>) -> SingletonResponse { let definition: Value = serde_json::from_str(&c.definition).unwrap_or(json!({"fields": []})); let data = entry.and_then(|e| serde_json::from_str(&e.data).ok()); @@ -153,6 +178,7 @@ impl SingletonService { definition, data, entry_id: entry.map(|e| e.id.clone()), + version: entry.map(|e| e.version.clone()), created_at: c.created_at.clone(), updated_at: entry .map(|e| e.updated_at.clone()) @@ -161,6 +187,14 @@ impl SingletonService { } pub async fn list_singletons(&self, site_id: &str) -> Result, SingletonError> { + self.list_singletons_visible(site_id, false).await + } + + pub async fn list_singletons_visible( + &self, + site_id: &str, + published_only: bool, + ) -> Result, SingletonError> { let collections = self .collection_repo .list_singletons_only(site_id) @@ -174,7 +208,10 @@ impl SingletonService { .get_singleton_entry(site_id, &c.slug) .await .map_err(|e| SingletonError::DatabaseError(e.to_string()))?; - out.push(Self::build_response(c, entry.as_ref())); + out.push(Self::build_response( + c, + entry.as_ref().filter(|e| !published_only || e.status == "published"), + )); } Ok(out) } @@ -184,6 +221,16 @@ impl SingletonService { site_id: &str, slug: &str, storage: Arc, + ) -> Result { + self.get_singleton_visible(site_id, slug, storage, false).await + } + + pub async fn get_singleton_visible( + &self, + site_id: &str, + slug: &str, + storage: Arc, + published_only: bool, ) -> Result { debug!("Fetching singleton: site_id={}, slug={}", site_id, slug); @@ -214,6 +261,7 @@ impl SingletonService { .await .map_err(|e| SingletonError::DatabaseError(e.to_string()))?; + let entry = entry.filter(|e| !published_only || e.status == "published"); let mut response = Self::build_response(&collection, entry.as_ref()); if let (Some(entry), Some(data)) = (entry.as_ref(), response.data.as_ref()) { @@ -236,6 +284,7 @@ impl SingletonService { data: &Value, created_by: Option<&str>, change_summary: Option<&str>, + expected_version: Option<&str>, ) -> Result { debug!("Updating singleton: site_id={}, slug={}", site_id, slug); @@ -268,19 +317,48 @@ impl SingletonService { } self.validate_relations(site_id, fields, data).await?; } + self.validate_file_references(site_id, data).await?; let data_str = data.to_string(); debug!("Upserting singleton entry for collection: id={}", collection.id); - let entry = self - .entry_repo - .upsert_singleton_entry(site_id, &collection.id, slug, &data_str, created_by, change_summary) - .await - .map_err(|e| { - error!("Failed to upsert singleton entry: id={}, error={}", collection.id, e); - SingletonError::DatabaseError(e.to_string()) - })?; - + let result = if let Some(version) = expected_version { + let existing = self + .entry_repo + .get_singleton_entry(site_id, slug) + .await + .map_err(|e| SingletonError::DatabaseError(e.to_string()))? + .ok_or(SingletonError::PreconditionFailed)?; + self.entry_repo + .update(crate::repository::traits::UpdateEntryParams { + expected_definition: Some(&collection.definition), + id: &existing.id, + site_id, + data: &data_str, + slug, + status: &existing.status, + created_by, + change_summary, + expected_version: Some(version), + }) + .await + } else { + self.entry_repo + .upsert_singleton_entry(crate::repository::traits::UpsertSingletonParams { + site_id, + collection_id: &collection.id, + slug, + data: &data_str, + created_by, + change_summary, + expected_definition: Some(&collection.definition), + }) + .await + }; + let entry = result.map_err(|e| match e { + crate::repository::error::RepositoryError::PreconditionFailed => SingletonError::PreconditionFailed, + _ => SingletonError::DatabaseError(e.to_string()), + })?; // Enqueue for the server's indexer (best-effort; the index is rebuildable). if let Some(queue) = &self.search_queue && let Err(e) = queue.enqueue(&entry.id, &entry.site_id, OP_INDEX).await @@ -303,7 +381,7 @@ impl SingletonService { if !file_ids.is_empty() && let Ok(file_items) = self.file_repo.get_by_ids(site_id, &file_ids).await { - for f in file_items { + for f in file_items.into_iter().filter(|file| file.deleted_at.is_none()) { let url = storage.url(&f.storage_key, &f.id); file_map.insert( @@ -331,11 +409,7 @@ impl SingletonService { } fn extract_file_ids_from_value(&self, data: &Value) -> Vec { - let re = regex::Regex::new(r"/api/files/([a-f0-9-]+)").unwrap(); - let json_str = data.to_string(); - re.captures_iter(&json_str) - .filter_map(|cap| cap.get(1).map(|m| m.as_str().to_string())) - .collect() + crate::utils::file_references::extract_file_ids_from_value(data) } } @@ -421,7 +495,15 @@ mod tests { let coll = create_test_collection(); collection_repo.add_collection(coll.clone()); entry_repo - .upsert_singleton_entry("site-123", &coll.id, &coll.slug, r#"{"title":"Hello"}"#, None, None) + .upsert_singleton_entry(crate::repository::traits::UpsertSingletonParams { + site_id: "site-123", + collection_id: &coll.id, + slug: &coll.slug, + data: r#"{"title":"Hello"}"#, + created_by: None, + change_summary: None, + expected_definition: None, + }) .await .unwrap(); @@ -439,7 +521,14 @@ mod tests { async fn test_update_singleton_not_found() { let service = make_service(); let result = service - .update_singleton("site-123", "nonexistent", &json!({"title": "Updated"}), None, None) + .update_singleton( + "site-123", + "nonexistent", + &json!({"title": "Updated"}), + None, + None, + None, + ) .await; assert!(matches!(result, Err(SingletonError::NotFound))); } @@ -451,7 +540,7 @@ mod tests { let service = SingletonService::new(collection_repo, test_entry_repo(), test_file_repo()); let result = service - .update_singleton("site-123", "regular", &json!({"title": "Updated"}), None, None) + .update_singleton("site-123", "regular", &json!({"title": "Updated"}), None, None, None) .await; assert!(matches!(result, Err(SingletonError::NotASingleton))); } @@ -470,6 +559,7 @@ mod tests { &json!({"title": "Updated Title"}), Some("user-1"), Some("initial write"), + None, ) .await; assert!(result.is_ok()); @@ -484,14 +574,15 @@ mod tests { let coll = create_test_collection(); collection_repo.add_collection(coll.clone()); entry_repo - .upsert_singleton_entry( - "site-123", - &coll.id, - &coll.slug, - r#"{"title":"v1"}"#, - Some("user-1"), - Some("v1"), - ) + .upsert_singleton_entry(crate::repository::traits::UpsertSingletonParams { + site_id: "site-123", + collection_id: &coll.id, + slug: &coll.slug, + data: r#"{"title":"v1"}"#, + created_by: Some("user-1"), + change_summary: Some("v1"), + expected_definition: None, + }) .await .unwrap(); @@ -503,6 +594,7 @@ mod tests { &json!({"title": "v2"}), Some("user-1"), Some("v2"), + None, ) .await; assert!(result.is_ok()); @@ -523,6 +615,7 @@ mod tests { &json!({"title": "v1"}), Some("user-1"), Some("v1"), + None, ) .await .unwrap(); @@ -541,6 +634,7 @@ mod tests { &json!({"title": "v2"}), Some("user-1"), Some("v2"), + None, ) .await .unwrap(); @@ -566,7 +660,7 @@ mod tests { "images": ["/api/files/abc-123-def/image.png", "/api/files/456-789-abc/image.png"] }); let ids = service.extract_file_ids_from_value(&data); - assert_eq!(ids, vec!["abc-123-def", "456-789-abc"]); + assert_eq!(ids, vec!["456-789-abc", "abc-123-def"]); } #[test] diff --git a/apps/backend/src/services/site.rs b/apps/backend/src/services/site.rs index c6187a33..ec9d5d85 100644 --- a/apps/backend/src/services/site.rs +++ b/apps/backend/src/services/site.rs @@ -74,7 +74,10 @@ impl SiteError { StatusCode::BAD_REQUEST, Json(json!({"error": "Instance operators already have full access and cannot be added as members"})), ), - SiteError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), + SiteError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), }; (status, body).into_response() } diff --git a/apps/backend/src/services/storage_profile.rs b/apps/backend/src/services/storage_profile.rs index 3ec04400..67eb0ef0 100644 --- a/apps/backend/src/services/storage_profile.rs +++ b/apps/backend/src/services/storage_profile.rs @@ -76,7 +76,10 @@ impl StorageProfileService { )?; let profile = match &self.pool { DbPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(|error| error.to_string())?; + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| error.to_string())?; let profile = sqlx::query_as( "INSERT INTO storage_profiles(id,name,kind,endpoint,region,bucket,public_url,credentials_encrypted,created_by) \ VALUES(?,?,'s3',?,?,?,?,?,?) \ @@ -188,7 +191,10 @@ impl StorageProfileService { }; let profile = match &self.pool { DbPool::Sqlite(pool) => { - let mut transaction = pool.begin().await.map_err(|error| error.to_string())?; + let mut transaction = pool + .begin_with("BEGIN IMMEDIATE") + .await + .map_err(|error| error.to_string())?; let profile = sqlx::query_as( "UPDATE storage_profiles SET name=?,endpoint=?,region=?,bucket=?,public_url=?,enabled=?, \ credentials_encrypted=COALESCE(?,credentials_encrypted),updated_at=datetime('now') WHERE id=? \ @@ -322,35 +328,6 @@ impl StorageProfileService { }?; Ok(()) } - pub async fn assign_site(&self, site: &str, profile: &str) -> Result<(), String> { - let p = self - .list() - .await? - .into_iter() - .find(|v| v.id == profile && v.enabled) - .ok_or("storage_profile_not_found")?; - match &self.pool { - DbPool::Sqlite(db) => sqlx::query("UPDATE sites SET storage_profile_id=?,storage_provider=? WHERE id=?") - .bind(profile) - .bind(&p.kind) - .bind(site) - .execute(db) - .await - .map(|_| ()) - .map_err(|e| e.to_string()), - DbPool::Postgres(db) => { - sqlx::query("UPDATE sites SET storage_profile_id=$1,storage_provider=$2 WHERE id=$3") - .bind(profile) - .bind(&p.kind) - .bind(site) - .execute(db) - .await - .map(|_| ()) - .map_err(|e| e.to_string()) - } - }?; - Ok(()) - } pub async fn register_all(&self, registry: &StorageRegistry) -> Result<(), String> { if let Some(local) = registry.get("filesystem") { registry.register("local-filesystem", local); diff --git a/apps/backend/src/services/webhook.rs b/apps/backend/src/services/webhook.rs index db5f2605..b190468f 100644 --- a/apps/backend/src/services/webhook.rs +++ b/apps/backend/src/services/webhook.rs @@ -66,7 +66,10 @@ impl WebhookError { WebhookError::NotFound => (StatusCode::NOT_FOUND, Json(json!({"error": "Webhook not found"}))), WebhookError::InvalidUrl(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), WebhookError::InvalidLabel(msg) => (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))), - WebhookError::DatabaseError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": msg}))), + WebhookError::DatabaseError(_) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(json!({"error": "internal_error", "message": "Internal server error"})), + ), WebhookError::DeliveryFailed(_) => ( StatusCode::BAD_GATEWAY, Json(json!({"error": "Webhook delivery failed"})), @@ -220,7 +223,7 @@ impl WebhookService { pub async fn update_webhook( &self, id: &str, - _site_id: &str, + site_id: &str, label: Option<&str>, url: Option<&str>, headers: Option<&HashMap>, @@ -259,7 +262,7 @@ impl WebhookService { debug!("Updating webhook in repository: id={}", id); match self .webhook_repo - .update(id, label, url, headers_encrypted.as_deref()) + .update(id, site_id, label, url, headers_encrypted.as_deref()) .await { Ok(webhook) => { diff --git a/apps/backend/src/signed_upload.rs b/apps/backend/src/signed_upload.rs index 1cff9b07..4bdd1eae 100644 --- a/apps/backend/src/signed_upload.rs +++ b/apps/backend/src/signed_upload.rs @@ -91,7 +91,7 @@ impl SignedUploadToken { let token: SignedUploadTokenInternal = serde_json::from_str(&json_str).map_err(|_| SignedUploadError::InvalidFormat)?; - if token.expires_at < chrono::Utc::now().timestamp() { + if token.expires_at <= chrono::Utc::now().timestamp() { return Err(SignedUploadError::Expired); } @@ -171,6 +171,16 @@ fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { mod tests { use super::*; + #[test] + fn expiry_deadline_is_exclusive() { + let (_, encoded) = + SignedUploadToken::generate_with_storage_profile("site", "file.txt", "text/plain", "profile", "secret", 0); + assert!(matches!( + SignedUploadToken::verify(&encoded, "secret"), + Err(SignedUploadError::Expired) + )); + } + #[test] fn test_generate_and_verify() { let secret = "test-hmac-secret"; diff --git a/apps/backend/src/storage/filesystem.rs b/apps/backend/src/storage/filesystem.rs index 6094df41..6268d02c 100644 --- a/apps/backend/src/storage/filesystem.rs +++ b/apps/backend/src/storage/filesystem.rs @@ -74,6 +74,19 @@ impl StorageProvider for FileSystemStorage { self.get(key).await } + async fn get_stream( + &self, + key: &str, + ) -> Result<(u64, crate::storage::StorageStream), Box> { + use futures_util::StreamExt; + let object = self.store.get(&ObjectPath::from(key)).await?; + let size = object.meta.size; + let stream = object + .into_stream() + .map(|chunk| chunk.map_err(|error| Box::new(error) as Box)); + Ok((size, Box::pin(stream))) + } + async fn delete(&self, key: &str) -> Result<(), Box> { self.delete(key).await } @@ -96,6 +109,30 @@ mod tests { use super::*; use tempfile::TempDir; + #[tokio::test] + async fn delivery_streams_large_files_in_bounded_chunks() { + use futures_util::StreamExt; + let directory = TempDir::new().unwrap(); + let storage = FileSystemStorage::new(directory.path().to_str().unwrap()).unwrap(); + let size = 9 * 1024 * 1024; + storage + .put("large.txt", Bytes::from(vec![42u8; size]), "text/plain") + .await + .unwrap(); + let (length, mut stream) = StorageProvider::get_stream(&storage, "large.txt").await.unwrap(); + assert_eq!(length, size as u64); + let first = stream.next().await.unwrap().unwrap(); + assert!(first.len() < size, "Delivery buffered the complete file"); + let mut received = first.len(); + assert!(first.iter().all(|byte| *byte == 42)); + while let Some(chunk) = stream.next().await { + let chunk = chunk.unwrap(); + assert!(chunk.iter().all(|byte| *byte == 42)); + received += chunk.len(); + } + assert_eq!(received, size); + } + #[tokio::test] async fn test_filesystem_storage_put_and_get() { let temp_dir = TempDir::new().unwrap(); diff --git a/apps/backend/src/storage/mod.rs b/apps/backend/src/storage/mod.rs index ee555660..302a5eea 100644 --- a/apps/backend/src/storage/mod.rs +++ b/apps/backend/src/storage/mod.rs @@ -77,6 +77,9 @@ impl Default for StorageRegistry { } } +pub type StorageStream = + std::pin::Pin>> + Send>>; + #[async_trait] pub trait StorageProvider: Send + Sync { async fn put( @@ -86,6 +89,15 @@ pub trait StorageProvider: Send + Sync { content_type: &str, ) -> Result<(), Box>; async fn get(&self, key: &str) -> Result>; + /// Native providers stream delivery without buffering a complete object. + /// The default supports existing trusted in-memory/test implementations. + async fn get_stream(&self, key: &str) -> Result<(u64, StorageStream), Box> { + let bytes = self.get(key).await?; + Ok(( + bytes.len() as u64, + Box::pin(futures_util::stream::once(async { Ok(bytes) })), + )) + } async fn delete(&self, key: &str) -> Result<(), Box>; fn url(&self, key: &str, file_id: &str) -> String; /// Begin a streaming (multipart) upload to `key`. Drive it with diff --git a/apps/backend/src/storage/s3.rs b/apps/backend/src/storage/s3.rs index cff1db56..506a1d31 100644 --- a/apps/backend/src/storage/s3.rs +++ b/apps/backend/src/storage/s3.rs @@ -68,7 +68,10 @@ impl S3Storage { pub fn url(&self, key: &str, _file_id: &str) -> String { match &self.public_url { Some(base) => format!("{}/{}", base.trim_end_matches('/'), key), - None => format!("/api/files?key={}", key), + // Keep delivery behind VCMS when no public bucket URL exists. The + // `/api/files/{id}` handler resolves the storage key from the + // database and prevents exposing arbitrary object keys. + None => format!("/api/files/{}", _file_id), } } } @@ -88,6 +91,19 @@ impl StorageProvider for S3Storage { self.get(key).await } + async fn get_stream( + &self, + key: &str, + ) -> Result<(u64, crate::storage::StorageStream), Box> { + use futures_util::StreamExt; + let object = self.store.get(&ObjectPath::from(key)).await?; + let size = object.meta.size; + let stream = object + .into_stream() + .map(|chunk| chunk.map_err(|error| Box::new(error) as Box)); + Ok((size, Box::pin(stream))) + } + async fn delete(&self, key: &str) -> Result<(), Box> { self.delete(key).await } diff --git a/apps/backend/src/storage/trait_def.rs b/apps/backend/src/storage/trait_def.rs deleted file mode 100644 index ccce2915..00000000 --- a/apps/backend/src/storage/trait_def.rs +++ /dev/null @@ -1,11 +0,0 @@ -use async_trait::async_trait; -use bytes::Bytes; - -#[async_trait] -pub trait StorageProvider: Send + Sync { - async fn put(&self, key: &str, data: Bytes, content_type: &str) -> Result<(), Box>; - async fn get(&self, key: &str) -> Result>; - async fn delete(&self, key: &str) -> Result<(), Box>; - fn url(&self, key: &str) -> String; - fn provider_name(&self) -> &str; -} diff --git a/apps/backend/src/test_helpers.rs b/apps/backend/src/test_helpers.rs index fc103a5f..c0db6ce6 100644 --- a/apps/backend/src/test_helpers.rs +++ b/apps/backend/src/test_helpers.rs @@ -46,11 +46,6 @@ impl InMemoryUserRepository { by_id.insert(user.id.clone(), user.id.clone()); users.push(user); } - - pub fn with_user(&self, user: User) -> Self { - self.add_user(user.clone()); - self.clone() - } } impl Default for InMemoryUserRepository { @@ -284,11 +279,6 @@ impl InMemorySiteRepository { sites.push(site); } - pub fn add_site_with_role(&self, site: SiteWithRole) { - let mut sites = self.site_with_roles.lock().unwrap(); - sites.push(site); - } - pub fn add_member(&self, member: SiteMember) { let mut members = self.members.lock().unwrap(); members.push(member); @@ -495,9 +485,20 @@ impl CollectionRepository for InMemoryCollectionRepository { Ok(collection) } - async fn update(&self, id: &str, name: &str, slug: &str, definition: &str) -> Result { + async fn update( + &self, + id: &str, + name: &str, + slug: &str, + definition: &str, + _rename_map: &std::collections::HashMap, + expected_definition: &str, + ) -> Result { let mut collections = self.collections.lock().unwrap(); if let Some(col) = collections.iter_mut().find(|c| c.id == id) { + if col.definition != expected_definition { + return Err(RepositoryError::PreconditionFailed); + } col.name = name.to_string(); col.slug = slug.to_string(); col.definition = definition.to_string(); @@ -513,18 +514,6 @@ impl CollectionRepository for InMemoryCollectionRepository { collections.retain(|c| !(c.site_id == site_id && c.slug == slug)); Ok((len - collections.len()) as u64) } - - async fn get_content_for_migration(&self, _collection_id: &str) -> Result, RepositoryError> { - Ok(Vec::new()) - } - - async fn migrate_content_field_renames( - &self, - _content_items: &[Entry], - _rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - Ok(()) - } } #[derive(Clone)] @@ -628,15 +617,16 @@ impl EntryRepository for InMemoryEntryRepository { .collect()) } - async fn create( - &self, - id: &str, - site_id: &str, - collection_id: &str, - data: &str, - slug: &str, - created_by: Option<&str>, - ) -> Result { + async fn create(&self, params: crate::repository::traits::CreateEntryParams<'_>) -> Result { + let crate::repository::traits::CreateEntryParams { + id, + site_id, + collection_id, + data, + slug, + created_by, + expected_definition: _, + } = params; let mut entries = self.entries.lock().unwrap(); let entry = Entry { id: id.to_string(), @@ -648,6 +638,7 @@ impl EntryRepository for InMemoryEntryRepository { singleton_collection_id: None, created_at: now_timestamp(), updated_at: now_timestamp(), + version: now_timestamp(), published_at: None, }; entries.push(entry.clone()); @@ -669,6 +660,7 @@ impl EntryRepository for InMemoryEntryRepository { async fn update(&self, params: UpdateEntryParams<'_>) -> Result { let UpdateEntryParams { + expected_definition: _, id, site_id, data, @@ -676,9 +668,14 @@ impl EntryRepository for InMemoryEntryRepository { status, created_by, change_summary, + expected_version, } = params; let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries.iter_mut().find(|e| e.id == id && e.site_id == site_id) { + if expected_version.is_some_and(|version| version != entry.version) { + return Err(RepositoryError::PreconditionFailed); + } + entry.version = uuid::Uuid::now_v7().to_string(); entry.data = data.to_string(); entry.slug = slug.to_string(); entry.status = status.to_string(); @@ -833,13 +830,17 @@ impl EntryRepository for InMemoryEntryRepository { async fn upsert_singleton_entry( &self, - site_id: &str, - collection_id: &str, - slug: &str, - data: &str, - created_by: Option<&str>, - change_summary: Option<&str>, + params: crate::repository::traits::UpsertSingletonParams<'_>, ) -> Result { + let crate::repository::traits::UpsertSingletonParams { + site_id, + collection_id, + slug, + data, + created_by, + change_summary, + expected_definition: _, + } = params; let mut entries = self.entries.lock().unwrap(); if let Some(entry) = entries .iter_mut() @@ -881,6 +882,7 @@ impl EntryRepository for InMemoryEntryRepository { singleton_collection_id: Some(collection_id.to_string()), created_at: now_timestamp(), updated_at: now_timestamp(), + version: now_timestamp(), published_at: None, }; entries.push(entry.clone()); @@ -899,26 +901,19 @@ impl EntryRepository for InMemoryEntryRepository { Ok(entry) } - - async fn migrate_singleton_field_renames( - &self, - _site_id: &str, - _collection_id: &str, - _rename_map: &std::collections::HashMap, - ) -> Result<(), RepositoryError> { - Ok(()) - } } #[derive(Clone)] pub struct InMemoryFileRepository { files: Arc>>, + signed_upload_uses: Arc>>, } impl InMemoryFileRepository { pub fn new() -> Self { Self { files: Arc::new(Mutex::new(Vec::new())), + signed_upload_uses: Arc::new(Mutex::new(std::collections::HashMap::new())), } } @@ -936,12 +931,22 @@ impl Default for InMemoryFileRepository { #[async_trait] impl FileRepository for InMemoryFileRepository { + async fn claim_signed_upload(&self, file_id: &str, expires_at: i64) -> Result { + if expires_at <= chrono::Utc::now().timestamp() { + return Ok(false); + } + let mut uses = self.signed_upload_uses.lock().unwrap(); + uses.retain(|_, expiry| *expiry > chrono::Utc::now().timestamp()); + if uses.contains_key(file_id) { + return Ok(false); + } + uses.insert(file_id.to_owned(), expires_at); + Ok(true) + } + async fn get_by_id(&self, id: &str, site_id: &str) -> Result, RepositoryError> { let files = self.files.lock().unwrap(); - Ok(files - .iter() - .find(|f| f.id == id && f.site_id == site_id && f.deleted_at.is_none()) - .cloned()) + Ok(files.iter().find(|f| f.id == id && f.site_id == site_id).cloned()) } async fn get_by_id_any(&self, id: &str) -> Result, RepositoryError> { @@ -1074,10 +1079,6 @@ impl FileRepository for InMemoryFileRepository { Ok((len - files.len()) as u64) } - async fn get_references(&self, _file_id: &str) -> Result, RepositoryError> { - Ok(Vec::new()) - } - async fn get_references_for_site( &self, _file_id: &str, @@ -1129,11 +1130,6 @@ impl InMemoryAccessTokenRepository { personal_hmacs: Arc::new(Mutex::new(std::collections::HashMap::new())), } } - - pub fn add_token(&self, token: AccessToken) { - let mut tokens = self.tokens.lock().unwrap(); - tokens.push(token); - } } impl Default for InMemoryAccessTokenRepository { @@ -1354,12 +1350,13 @@ impl crate::repository::traits::WebhookRepository for InMemoryWebhookRepository async fn update( &self, id: &str, + site_id: &str, label: Option<&str>, url: Option<&str>, headers_encrypted: Option<&str>, ) -> Result { let mut webhooks = self.webhooks.lock().unwrap(); - if let Some(webhook) = webhooks.iter_mut().find(|w| w.id == id) { + if let Some(webhook) = webhooks.iter_mut().find(|w| w.id == id && w.site_id == site_id) { if let Some(l) = label { webhook.label = l.to_string(); } diff --git a/apps/backend/src/tracing.rs b/apps/backend/src/tracing.rs index e6943904..a280963f 100644 --- a/apps/backend/src/tracing.rs +++ b/apps/backend/src/tracing.rs @@ -68,16 +68,72 @@ pub async fn trace_request(req: Request, next: Next) -> Response { .map(String::from) .unwrap_or_else(|| Uuid::now_v7().to_string()); + let public_api = req.uri().path().starts_with("/api/v1/"); let span = info_span!( "http_request", request_id = %request_id, method = %req.method(), - uri = %req.uri(), + uri = %safe_request_uri(req.uri()), ); let mut response = next.run(req).instrument(span).await; + if public_api && (response.status().is_client_error() || response.status().is_server_error()) { + response = public_problem(response, &request_id).await; + } let _ = response .headers_mut() .insert(REQUEST_ID_HEADER.clone(), request_id.parse().unwrap()); response } + +/// Signed upload URLs contain bearer credentials in their path. +pub fn safe_request_uri(uri: &axum::http::Uri) -> String { + if uri.path().starts_with("/api/v1/files/upload/") { + "/api/v1/files/upload/[redacted]".into() + } else { + uri.path().to_owned() + } +} + +async fn public_problem(response: Response, request_id: &str) -> Response { + use axum::response::IntoResponse; + let (mut parts, body) = response.into_parts(); + let status = parts.status; + let bytes = axum::body::to_bytes(body, 64 * 1024).await.unwrap_or_default(); + let value = serde_json::from_slice::(&bytes).unwrap_or_default(); + let code = value + .get("code") + .and_then(serde_json::Value::as_str) + .unwrap_or(match status.as_u16() { + 400 | 422 => "invalid_request", + 401 => "unauthorized", + 403 => "forbidden", + 404 => "not_found", + 405 => "method_not_allowed", + 409 => "conflict", + 412 => "precondition_failed", + 413 => "payload_too_large", + 429 => "rate_limited", + 500..=599 => "internal_error", + _ => "request_failed", + }); + let detail = if status.is_server_error() { + "Internal server error".to_owned() + } else { + value + .get("detail") + .or_else(|| value.get("message")) + .or_else(|| value.get("error")) + .and_then(serde_json::Value::as_str) + .map(str::to_owned) + .unwrap_or_else(|| status.canonical_reason().unwrap_or("Request failed").to_owned()) + }; + let body = serde_json::json!({"type":"about:blank", "title":status.canonical_reason().unwrap_or("Request failed"), + "status":status.as_u16(), "code":code, "detail":detail, "error":detail, "request_id":request_id}); + parts.headers.remove(axum::http::header::CONTENT_LENGTH); + parts.headers.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("application/problem+json"), + ); + Response::from_parts(parts, axum::Json(body).into_response().into_body()) +} diff --git a/apps/backend/src/utils/content_types.rs b/apps/backend/src/utils/content_types.rs index 929206a8..f9ff9a28 100644 --- a/apps/backend/src/utils/content_types.rs +++ b/apps/backend/src/utils/content_types.rs @@ -48,17 +48,6 @@ pub const ARCHIVE_TYPES: &[&str] = &[ "application/x-rar-compressed", ]; -pub fn all_allowed() -> Vec<&'static str> { - IMAGE_TYPES - .iter() - .chain(VIDEO_TYPES) - .chain(AUDIO_TYPES) - .chain(DOCUMENT_TYPES) - .chain(ARCHIVE_TYPES) - .copied() - .collect() -} - pub const CATEGORIES: &[&str] = &["image", "video", "audio", "document", "archive"]; pub fn types_for_category(category: &str) -> &'static [&'static str] { @@ -100,10 +89,6 @@ pub fn is_file_type_category(category: &str) -> bool { CATEGORIES.contains(&category) } -pub fn filter_for_category(category: &str) -> Vec { - types_for_category(category).iter().map(|s| s.to_string()).collect() -} - #[cfg(test)] mod tests { use super::*; diff --git a/apps/backend/src/utils/cursor.rs b/apps/backend/src/utils/cursor.rs new file mode 100644 index 00000000..3235872d --- /dev/null +++ b/apps/backend/src/utils/cursor.rs @@ -0,0 +1,90 @@ +use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD}; +use hmac::digest::KeyInit; +use hmac::{Hmac, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +type HmacSha256 = Hmac; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PageCursor { + pub version: u8, + pub page: i64, + pub fingerprint: String, +} + +pub fn fingerprint(value: &T) -> String { + let bytes = serde_json::to_vec(value).unwrap_or_default(); + hex::encode(Sha256::digest(bytes)) +} + +pub fn encode(cursor: &PageCursor, secret: &str) -> String { + let payload = serde_json::to_vec(cursor).expect("page cursor is serializable"); + let encoded = URL_SAFE_NO_PAD.encode(payload); + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("HMAC accepts every key length"); + mac.update(encoded.as_bytes()); + format!("{}.{}", encoded, hex::encode(mac.finalize().into_bytes())) +} + +pub fn decode(value: &str, secret: &str) -> Result { + let (payload, signature) = value.split_once('.').ok_or("invalid cursor")?; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).map_err(|_| "invalid cursor")?; + mac.update(payload.as_bytes()); + let expected = hex::encode(mac.finalize().into_bytes()); + if signature.len() != expected.len() || !constant_time_eq(signature.as_bytes(), expected.as_bytes()) { + return Err("invalid cursor"); + } + let bytes = URL_SAFE_NO_PAD.decode(payload).map_err(|_| "invalid cursor")?; + let cursor = serde_json::from_slice::(&bytes).map_err(|_| "invalid cursor")?; + if cursor.version != 1 || cursor.page < 1 { + return Err("invalid cursor"); + } + Ok(cursor) +} + +/// Resolve the page a public list request starts on: page 1 without a cursor, +/// otherwise the page carried by a valid cursor issued for this exact query. +pub fn resolve_page(cursor: Option<&str>, fingerprint: &str, secret: &str) -> Result { + let Some(cursor) = cursor else { + return Ok(1); + }; + let cursor = decode(cursor, secret).map_err(|_| "Cursor is malformed or was not issued by this server")?; + if cursor.fingerprint != fingerprint { + return Err("Cursor does not match this query"); + } + Ok(cursor.page) +} + +/// 400 response for a cursor that `resolve_page` rejected. +pub fn invalid_cursor_response(message: &str) -> axum::response::Response { + use axum::response::IntoResponse; + ( + axum::http::StatusCode::BAD_REQUEST, + axum::Json(serde_json::json!({"error": "invalid_cursor", "message": message})), + ) + .into_response() +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter().zip(right).fold(0u8, |acc, (a, b)| acc | (a ^ b)) == 0 +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trips_and_rejects_tampering() { + let cursor = PageCursor { + version: 1, + page: 4, + fingerprint: "abc".into(), + }; + let encoded = encode(&cursor, "secret"); + assert_eq!(decode(&encoded, "secret").unwrap().page, 4); + assert!(decode(&format!("{}x", encoded), "secret").is_err()); + } +} diff --git a/apps/backend/src/utils/file_references.rs b/apps/backend/src/utils/file_references.rs new file mode 100644 index 00000000..9e2f570b --- /dev/null +++ b/apps/backend/src/utils/file_references.rs @@ -0,0 +1,76 @@ +use regex::Regex; +use serde_json::Value; +use std::sync::LazyLock; + +pub fn extract_file_ids_from_value(value: &Value) -> Vec { + let mut ids = extract_file_references_from_value(value) + .into_iter() + .map(|(id, _)| id) + .collect::>(); + ids.sort(); + ids.dedup(); + ids +} + +pub fn extract_file_references_from_value(value: &Value) -> Vec<(String, String)> { + static FILE_URL_RE: LazyLock = + LazyLock::new(|| Regex::new(r#"/api/files/([a-fA-F0-9-]+)(?:[/?.#\s"<>]|$)"#).unwrap()); + let mut refs = Vec::new(); + collect_file_references(value, &FILE_URL_RE, "", &mut refs); + refs.sort(); + refs.dedup(); + refs +} + +fn collect_file_references(value: &Value, re: &Regex, path: &str, refs: &mut Vec<(String, String)>) { + match value { + Value::String(s) => { + // Absolute URLs are external links, even if another CMS uses the + // same route shape. Internal references use canonical relative URLs. + if url::Url::parse(s).is_ok_and(|url| matches!(url.scheme(), "http" | "https")) { + return; + } + for cap in re.captures_iter(s) { + let start = cap.get(0).expect("matched file URL").start(); + let prefix = s[..start] + .rsplit([' ', '\n', '\r', '\t', '"', '\'', '<', '>']) + .next() + .unwrap_or_default(); + if prefix.contains("://") { + continue; + } + if let Some(m) = cap.get(1) { + refs.push((m.as_str().to_ascii_lowercase(), path.to_string())); + } + } + } + Value::Array(arr) => { + for (index, item) in arr.iter().enumerate() { + collect_file_references(item, re, &format!("{path}[{index}]"), refs); + } + } + Value::Object(obj) => { + for (key, val) in obj { + let next = if path.is_empty() { + key.clone() + } else { + format!("{path}.{key}") + }; + collect_file_references(val, re, &next, refs); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + #[test] + fn external_cms_urls_are_not_internal_ownership_references() { + let value = serde_json::json!({"external":"https://other.example/api/files/abc-123","html":"","internal":"/api/files/abc-123"}); + assert_eq!( + super::extract_file_references_from_value(&value), + vec![("abc-123".into(), "internal".into())] + ); + } +} diff --git a/apps/backend/src/utils/mod.rs b/apps/backend/src/utils/mod.rs index 8c8fd09a..cf98234b 100644 --- a/apps/backend/src/utils/mod.rs +++ b/apps/backend/src/utils/mod.rs @@ -1,3 +1,8 @@ pub mod content_types; +pub mod cursor; pub mod diff; pub mod magic_bytes; + +pub mod file_references; + +pub mod timestamp; diff --git a/apps/backend/src/utils/timestamp.rs b/apps/backend/src/utils/timestamp.rs new file mode 100644 index 00000000..93b6403d --- /dev/null +++ b/apps/backend/src/utils/timestamp.rs @@ -0,0 +1,33 @@ +/// Lenient parse of the backend-specific timestamp texts: RFC3339, Postgres +/// `::text` (`YYYY-MM-DD HH:MM:SS[.fff]+00`), or naive UTC (SQLite). +pub fn parse_db_timestamp(s: &str) -> Option> { + if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) { + return Some(dt.with_timezone(&chrono::Utc)); + } + if let Ok(dt) = chrono::DateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f%#z") { + return Some(dt.with_timezone(&chrono::Utc)); + } + chrono::NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S%.f") + .ok() + .map(|naive| naive.and_utc()) +} + +#[cfg(test)] +mod tests { + #[test] + fn future_token_expiry_accepts_both_database_formats() { + let future = chrono::Utc::now() + chrono::Duration::hours(1); + for value in [ + future.to_rfc3339(), + future.format("%Y-%m-%d %H:%M:%S%.f+00").to_string(), + future.format("%Y-%m-%d %H:%M:%S%.f").to_string(), + ] { + assert!(crate::middleware::auth::is_token_not_expired(Some(&value)), "{value}"); + } + let past = (chrono::Utc::now() - chrono::Duration::hours(1)) + .format("%Y-%m-%d %H:%M:%S%.f+00") + .to_string(); + assert!(!crate::middleware::auth::is_token_not_expired(Some(&past))); + assert!(!crate::middleware::auth::is_token_not_expired(Some("invalid"))); + } +} diff --git a/apps/backend/tests/common/fixtures.rs b/apps/backend/tests/common/fixtures.rs index b665849a..6a173882 100644 --- a/apps/backend/tests/common/fixtures.rs +++ b/apps/backend/tests/common/fixtures.rs @@ -28,26 +28,13 @@ pub async fn setup(server: &TestServer) -> (String, String, String) { /// Test-only scope presets for scoped machine site keys. pub fn site_key_scopes(access: &str) -> Vec<&'static str> { - let mut scopes = vec![ - "site.read", - "site.settings.read", - "content.read", - "files.read", - "schema.read", - "webhooks.read", - "deployments.read", - "mcp.use", - ]; + let mut scopes = vec!["site.read", "schema.read", "content.read", "files.read", "mcp.use"]; if access == "write" { scopes.extend([ - "site.settings.write", + "content.preview.read", "content.write", + "content.publish", "files.write", - "schema.write", - "webhooks.write", - "webhooks.trigger", - "deployments.write", - "deployments.trigger", ]); } scopes diff --git a/apps/backend/tests/common/grpc.rs b/apps/backend/tests/common/grpc.rs index 0cda7421..782d4b23 100644 --- a/apps/backend/tests/common/grpc.rs +++ b/apps/backend/tests/common/grpc.rs @@ -10,7 +10,6 @@ use cms::config::Config; use cms::database::init_db_with_config; use cms::grpc::interceptor::AuthInterceptor; use cms::grpc::services::admin_site::SiteServiceImpl; -use cms::grpc::services::admin_webhook::WebhookServiceImpl; use cms::grpc::services::collection::CollectionServiceImpl; use cms::grpc::services::entry::EntryServiceImpl; use cms::grpc::services::file::FileServiceImpl; @@ -132,7 +131,7 @@ impl GrpcTestContext { storage_registry.clone(), services.clone(), backup_service, - settings, + settings.clone(), ); let (axum_shutdown_tx, axum_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); @@ -153,15 +152,20 @@ impl GrpcTestContext { let incoming = TcpListenerStream::new(grpc_listener); let collection_svc = CollectionServiceImpl::new(services.collection.clone(), repository_arc.clone()); - let entry_svc = EntryServiceImpl::new(services.entry.clone(), repository_arc.clone()); + let entry_svc = EntryServiceImpl::new(services.entry.clone(), repository_arc.clone(), config.clone()); let singleton_svc = SingletonServiceImpl::new( services.singleton.clone(), storage_registry.clone(), repository_arc.clone(), ); - let file_svc = FileServiceImpl::new(services.file.clone(), repository_arc.clone()); + let file_svc = FileServiceImpl::new( + services.file.clone(), + repository_arc.clone(), + storage_registry.clone(), + config.clone(), + settings, + ); let site_svc = SiteServiceImpl::new(services.site.clone(), repository_arc.clone()); - let webhook_svc = WebhookServiceImpl::new(services.webhook.clone(), repository_arc); let interceptor = AuthInterceptor::new(config.clone()); @@ -182,10 +186,6 @@ impl GrpcTestContext { cms::grpc::cms::v1::file_service_server::FileServiceServer::with_interceptor(file_svc, interceptor.clone()); let site_server = cms::grpc::cms::v1::site_service_server::SiteServiceServer::with_interceptor(site_svc, interceptor.clone()); - let webhook_server = cms::grpc::cms::v1::webhook_service_server::WebhookServiceServer::with_interceptor( - webhook_svc, - interceptor, - ); let (grpc_shutdown_tx, grpc_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); tokio::spawn(async move { @@ -195,7 +195,6 @@ impl GrpcTestContext { .add_service(singleton_server) .add_service(file_server) .add_service(site_server) - .add_service(webhook_server) .serve_with_incoming_shutdown(incoming, async move { let _ = grpc_shutdown_rx.await; }) @@ -308,6 +307,43 @@ impl GrpcTestContext { resp.json().await.unwrap() } + + pub async fn create_collection( + &self, + site_id: &str, + name: &str, + slug: &str, + definition: serde_json::Value, + is_singleton: bool, + ) -> serde_json::Value { + let client = http_client(); + let resp = client + .post(format!("{}/api/auth/login", self.rest_base_url)) + .json(&serde_json::json!({"email": "admin@cms.local", "password": "admin"})) + .send() + .await + .expect("Failed to login"); + let (token, csrf) = extract_cookies(&resp); + client + .post(format!( + "{}/api/dashboard/sites/{}/collections", + self.rest_base_url, site_id + )) + .header("Cookie", format!("token={token}; csrf={csrf}")) + .header("X-CSRF-Token", &csrf) + .json(&serde_json::json!({ + "name": name, + "slug": slug, + "definition": definition, + "is_singleton": is_singleton, + })) + .send() + .await + .expect("Failed to create collection") + .json() + .await + .expect("Invalid collection response") + } } pub fn auth_interceptor(token: &str) -> impl tonic::service::Interceptor + Clone + use<> { @@ -320,3 +356,14 @@ pub fn auth_interceptor(token: &str) -> impl tonic::service::Interceptor + Clone Ok(req) } } + +/// Parse a JSON object literal into the protobuf Struct used for content data. +pub fn content(json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(json).expect("valid JSON object"); + cms::grpc::json_to_struct(&value) +} + +/// Content Struct back to JSON for assertions. +pub fn content_json(value: &Option) -> serde_json::Value { + cms::grpc::struct_to_json(value.as_ref().expect("content present")).expect("valid content") +} diff --git a/apps/backend/tests/common/mcp.rs b/apps/backend/tests/common/mcp.rs index eecebcce..76a26bb4 100644 --- a/apps/backend/tests/common/mcp.rs +++ b/apps/backend/tests/common/mcp.rs @@ -35,31 +35,45 @@ pub async fn mcp_request(base_url: &str, token: &str, method: &str, params: Opti "id": id, "method": method, }); - if let Some(p) = params { - body["params"] = p; + let mut params = params.unwrap_or_else(|| serde_json::json!({})); + if !params.is_object() { + panic!("MCP params must be an object"); } + params["_meta"] = serde_json::json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "vcms-test-client", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {} + }); + body["params"] = params; - let resp = client + let mcp_name = if method == "tools/call" { + body["params"]["name"].as_str().unwrap_or(method) + } else if method == "resources/read" { + body["params"]["uri"].as_str().unwrap_or(method) + } else { + method + }; + let request = client .post(format!("{}/mcp", base_url)) .header("Content-Type", "application/json") .header("Accept", "application/json, text/event-stream") - .header("Authorization", format!("Bearer {}", token)) - .json(&body) - .send() - .await - .expect("Failed to send MCP request"); + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", method) + .header("Mcp-Name", mcp_name) + .header("Authorization", format!("Bearer {}", token)); + let resp = request.json(&body).send().await.expect("Failed to send MCP request"); if !resp.status().is_success() { - let status = resp.status().as_u16(); - let text = resp.text().await.unwrap_or_default(); - return serde_json::json!({ - "jsonrpc": "2.0", - "id": id, - "error": { - "code": -32000 + status as i64, - "message": text, - } - }); + let status = resp.status(); + let text = resp.text().await.unwrap(); + let response: Value = serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("Non-JSON MCP error response ({status}): {error}: {text}")); + if response["jsonrpc"] == "2.0" { + assert_eq!(response["id"], id, "MCP error changed request id"); + return response; + } + // Authentication is a transport response, not a JSON-RPC error. + return serde_json::json!({"http_status": status.as_u16(), "transport_error": response}); } let content_type = resp @@ -116,7 +130,7 @@ pub fn mcp_tool_text(result: &Value) -> String { let content = result .get("content") .and_then(|c| c.as_array()) - .expect("result missing 'content' array"); + .unwrap_or_else(|| panic!("result missing 'content' array: {result}")); assert!(!content.is_empty(), "result content is empty"); content[0] .get("text") @@ -126,6 +140,9 @@ pub fn mcp_tool_text(result: &Value) -> String { } pub fn mcp_tool_json(result: &Value) -> Value { + if let Some(value) = result.get("structuredContent") { + return value.clone(); + } let text = mcp_tool_text(result); serde_json::from_str(&text).unwrap_or_else(|e| { panic!("Failed to parse tool result as JSON: {}\nText: {}", e, text); @@ -133,17 +150,7 @@ pub fn mcp_tool_json(result: &Value) -> Value { } pub async fn mcp_initialize(base_url: &str, token: &str) -> Value { - let resp = mcp_request( - base_url, - token, - "initialize", - Some(serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test-client", "version": "1.0.0"} - })), - ) - .await; + let resp = mcp_request(base_url, token, "server/discover", Some(serde_json::json!({}))).await; mcp_result(&resp).clone() } @@ -196,25 +203,52 @@ pub async fn mcp_read_resource(base_url: &str, token: &str, uri: &str) -> Value mcp_result(&resp).clone() } -pub async fn create_test_collection(base_url: &str, token: &str, site_id: &str, name: &str, slug: &str) -> Value { - let result = mcp_call_site_tool( - base_url, - token, - site_id, - "create_collection", - serde_json::json!({ +pub async fn create_test_collection(base_url: &str, _token: &str, site_id: &str, name: &str, slug: &str) -> Value { + let value = dashboard_create_collection(base_url, site_id, name, slug, false).await; + serde_json::json!({ + "content": [{"type": "text", "text": value.to_string()}], + "structuredContent": value, + "isError": false, + }) +} + +pub async fn create_test_singleton(base_url: &str, site_id: &str, name: &str, slug: &str) -> Value { + dashboard_create_collection(base_url, site_id, name, slug, true).await +} + +async fn dashboard_create_collection( + base_url: &str, + site_id: &str, + name: &str, + slug: &str, + is_singleton: bool, +) -> Value { + let client = http_client(); + let login = client + .post(format!("{base_url}/api/auth/login")) + .json(&serde_json::json!({"email": "admin@cms.local", "password": "admin"})) + .send() + .await + .expect("login failed"); + let (session, csrf) = super::auth::extract_cookies(&login); + let response = client + .post(format!("{base_url}/api/dashboard/sites/{site_id}/collections")) + .headers(super::auth::auth_header(&session, &csrf)) + .json(&serde_json::json!({ "name": name, "slug": slug, - "definition": {"fields": [{"name": "title", "type": "text", "required": true}]}, - }), - ) - .await; + "definition": {"fields": [{"name": if is_singleton { "site_title" } else { "title" }, "type": "text", "required": true}]}, + "is_singleton": is_singleton, + })) + .send() + .await + .expect("collection creation failed"); assert!( - !mcp_is_error(&result), - "create_collection failed: {}", - mcp_tool_text(&result) + response.status().is_success(), + "dashboard collection creation failed: {}", + response.status() ); - result + response.json().await.expect("invalid collection response") } pub async fn create_test_entry( diff --git a/apps/backend/tests/graphql/auth_tests.rs b/apps/backend/tests/graphql/auth_tests.rs index aa256d2c..02916985 100644 --- a/apps/backend/tests/graphql/auth_tests.rs +++ b/apps/backend/tests/graphql/auth_tests.rs @@ -15,7 +15,7 @@ async fn gql(server: &TestServer, token: Option<&str>, query: &str) -> reqwest:: req.send().await.unwrap() } -async fn setup_site_token(server: &TestServer) -> (reqwest::Client, String) { +async fn setup_site_token(server: &TestServer) -> (String, String) { let client = reqwest::Client::builder().build().unwrap(); let resp = server.login_user(&client, "admin@cms.local", "admin").await; @@ -65,7 +65,7 @@ async fn setup_site_token(server: &TestServer) -> (reqwest::Client, String) { let token_val: Value = resp.json().await.unwrap(); let api_key = token_val["token"].as_str().unwrap().to_string(); - (client, api_key) + (site_id.to_owned(), api_key) } async fn setup_read_token(server: &TestServer) -> String { @@ -122,17 +122,17 @@ async fn setup_read_token(server: &TestServer) -> String { #[tokio::test] async fn test_unauthenticated_query() { let server = TestServer::start().await; - let resp = gql(&server, None, "{ currentSite { id name } }").await; + let resp = gql(&server, None, "{ sites { id name } }").await; let body: Value = resp.json().await.unwrap(); assert!(body["errors"].is_array()); - let msg = body["errors"][0]["message"].as_str().unwrap(); - assert!(msg.contains("authentication") || msg.contains("token")); + let msg = body["errors"][0]["message"].as_str().unwrap().to_lowercase(); + assert!(msg.contains("authentication") || msg.contains("token"), "{msg}"); } #[tokio::test] async fn test_invalid_token() { let server = TestServer::start().await; - let resp = gql(&server, Some("cms_invalid_token"), "{ currentSite { id name } }").await; + let resp = gql(&server, Some("cms_invalid_token"), "{ sites { id name } }").await; let body: Value = resp.json().await.unwrap(); assert!(body["errors"].is_array()); } @@ -145,13 +145,13 @@ async fn test_read_token_cannot_write() { let resp = gql( &server, Some(&token), - r#"mutation { createCollection(input: {name: "Test", slug: "test", definition: "{}"}) { id } }"#, + r#"mutation { createEntry(siteId: "unavailable", input: {collectionId: "unavailable", slug: "test", data: {title: "Test"}}) { id } }"#, ) .await; let body: Value = resp.json().await.unwrap(); assert!(body["errors"].is_array()); let msg = body["errors"][0]["message"].as_str().unwrap(); - assert!(msg.contains("write") || msg.contains("permission")); + assert_eq!(body["errors"][0]["extensions"]["code"], "FORBIDDEN", "{msg}"); } #[tokio::test] @@ -217,12 +217,12 @@ async fn test_wrong_site_token() { let token_val: Value = resp.json().await.unwrap(); let token_a = token_val["token"].as_str().unwrap(); - let query = format!(r#"{{ webhooks(siteId: "{}") {{ id label }} }}"#, site_b_id); + let query = format!(r#"{{ site(id: "{}") {{ id name }} }}"#, site_b_id); let resp = gql(&server, Some(token_a), &query).await; let body: Value = resp.json().await.unwrap(); assert!(body["errors"].is_array()); let msg = body["errors"][0]["message"].as_str().unwrap(); - assert!(msg.contains("access") || msg.contains("site")); + assert_eq!(body["errors"][0]["extensions"]["code"], "FORBIDDEN", "{msg}"); } #[tokio::test] @@ -259,26 +259,36 @@ async fn test_valid_read_token_query() { let server = TestServer::start().await; let token = setup_read_token(&server).await; - let resp = gql(&server, Some(&token), "{ currentSite { id name } }").await; + let resp = gql(&server, Some(&token), "{ sites { id name } }").await; let body: Value = resp.json().await.unwrap(); - assert!(body["data"].is_object()); + assert_eq!(body["data"]["sites"].as_array().unwrap().len(), 1, "{body}"); assert!(body["errors"].is_null() || body["errors"].as_array().unwrap().is_empty()); } #[tokio::test] async fn test_valid_write_token_mutation() { let server = TestServer::start().await; - let (_, token) = setup_site_token(&server).await; - - let resp = gql( - &server, - Some(&token), - r#"mutation { createCollection(input: {name: "Test", slug: "test-mut", definition: "{}"}) { id name } }"#, - ) - .await; - let body: Value = resp.json().await.unwrap(); - assert!(body["data"].is_object()); - assert!(body["data"]["createCollection"]["name"].as_str().unwrap() == "Test"); + let (site_id, token) = setup_site_token(&server).await; + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = extract_cookies(&login); + let collection: Value = client + .post(format!("{}/api/dashboard/sites/{site_id}/collections", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name":"Posts","slug":"posts","definition":{"fields":[{"name":"title","type":"text"}]}})) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let query = format!( + "mutation {{ createEntry(siteId:\"{site_id}\",input:{{collectionId:\"{}\",slug:\"test\",data:{{title:\"Test\"}}}}) {{ id data }} }}", + collection["id"].as_str().unwrap() + ); + let body: Value = gql(&server, Some(&token), &query).await.json().await.unwrap(); + assert!(body["errors"].is_null(), "{body}"); + assert_eq!(body["data"]["createEntry"]["data"]["title"], "Test"); } #[tokio::test] @@ -336,10 +346,7 @@ async fn test_viewer_personal_token_cannot_write() { "scopes": [ "site.read", "content.read", - "content.write", - "webhooks.read", - "deployments.read", - "deployments.trigger" + "content.write" ], "expires_at": null })) @@ -353,9 +360,8 @@ async fn test_viewer_personal_token_cannot_write() { let response = client .post(format!("{}/api/graphql", server.base_url)) .header("Authorization", format!("Bearer {personal_token}")) - .header("X-VCMS-Site", site_id) .json(&json!({ - "query": r#"mutation { createCollection(input: {name: "Forbidden", slug: "forbidden", definition: "{}"}) { id } }"# + "query": format!("mutation {{ createEntry(siteId:\"{site_id}\",input:{{collectionId:\"unavailable\",slug:\"forbidden\",data:{{title:\"Test\"}}}}) {{ id }} }}") })) .send() .await @@ -366,9 +372,5 @@ async fn test_viewer_personal_token_cannot_write() { body["errors"].is_array(), "viewer PAT unexpectedly wrote through GraphQL: {body}" ); - assert!( - body["errors"][0]["message"] - .as_str() - .is_some_and(|message| message.contains("write") || message.contains("permission")) - ); + assert_eq!(body["errors"][0]["extensions"]["code"], "FORBIDDEN"); } diff --git a/apps/backend/tests/graphql/collections_tests.rs b/apps/backend/tests/graphql/collections_tests.rs index 1f1432ea..7f37dbe7 100644 --- a/apps/backend/tests/graphql/collections_tests.rs +++ b/apps/backend/tests/graphql/collections_tests.rs @@ -67,163 +67,82 @@ async fn gql(server: &TestServer, token: &str, query: &str) -> Value { resp.json().await.unwrap() } -async fn gql_with_vars(server: &TestServer, token: &str, query: &str, variables: Value) -> Value { - let client = reqwest::Client::builder().build().unwrap(); - let resp = client - .post(format!("{}/api/graphql", server.base_url)) - .header("Authorization", format!("Bearer {}", token)) - .json(&json!({"query": query, "variables": variables})) +async fn create_collection(server: &TestServer, site_id: &str, name: &str, slug: &str) { + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = crate::common::auth::extract_cookies(&login); + let response = client + .post(format!("{}/api/dashboard/sites/{site_id}/collections", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name":name,"slug":slug,"definition":{"fields":[{"name":"title","type":"text"}]}})) .send() .await .unwrap(); - resp.json().await.unwrap() -} - -async fn create_collection(server: &TestServer, token: &str, name: &str, slug: &str) -> Value { - let query = r#"mutation CreateCollection($input: CreateCollectionInput!) { - createCollection(input: $input) { id name slug } - }"#; - let vars = json!({"input": {"name": name, "slug": slug, "definition": json!({"fields": [{"name": "title", "type": "text"}]})}}); - gql_with_vars(server, token, query, vars).await -} - -async fn create_singleton_collection(server: &TestServer, token: &str, name: &str, slug: &str) -> Value { - let query = r#"mutation CreateCollection($input: CreateCollectionInput!) { - createCollection(input: $input) { id name slug isSingleton } - }"#; - let vars = json!({"input": {"name": name, "slug": slug, "definition": json!({"fields": [{"name": "title", "type": "text"}]}), "isSingleton": true}}); - gql_with_vars(server, token, query, vars).await + assert_eq!(response.status(), 201); } #[tokio::test] async fn test_collections_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - create_collection(&server, &token, "Posts", "posts").await; + create_collection(&server, &site_id, "Posts", "posts").await; - let body = gql(&server, &token, "{ collections { id name slug } }").await; + let query = format!(r#"{{ site(id: "{site_id}") {{ collections {{ id name slug }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - let cols = body["data"]["collections"].as_array().unwrap(); + let cols = body["data"]["site"]["collections"].as_array().unwrap(); assert!(!cols.is_empty()); } #[tokio::test] async fn test_collection_by_slug() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - create_collection(&server, &token, "Pages", "pages").await; + create_collection(&server, &site_id, "Pages", "pages").await; - let body = gql(&server, &token, r#"{ collection(slug: "pages") { id name slug } }"#).await; + let query = format!(r#"{{ site(id: "{site_id}") {{ collection(slug: "pages") {{ id name slug }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["collection"]["name"].as_str().unwrap(), "Pages"); + assert_eq!(body["data"]["site"]["collection"]["name"].as_str().unwrap(), "Pages"); } #[tokio::test] async fn test_collection_not_found() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let body = gql(&server, &token, r#"{ collection(slug: "nonexistent") { id } }"#).await; + let query = format!(r#"{{ site(id: "{site_id}") {{ collection(slug: "nonexistent") {{ id }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_array()); let msg = body["errors"][0]["message"].as_str().unwrap(); assert!(msg.contains("not found")); } #[tokio::test] -async fn test_create_collection_mutation() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - let body = create_collection(&server, &token, "New Col", "new-col").await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["createCollection"]["name"].as_str().unwrap(), "New Col"); - assert_eq!(body["data"]["createCollection"]["slug"].as_str().unwrap(), "new-col"); -} - -#[tokio::test] -async fn test_update_collection_mutation() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - create_collection(&server, &token, "Old Name", "old-name").await; - - let query = r#"mutation { updateCollection(slug: "old-name", input: {name: "New Name", slug: "new-name"}) { id name slug } }"#; - let body = gql(&server, &token, query).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["updateCollection"]["name"].as_str().unwrap(), "New Name"); - assert_eq!(body["data"]["updateCollection"]["slug"].as_str().unwrap(), "new-name"); -} - -#[tokio::test] -async fn test_delete_collection_mutation() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - create_collection(&server, &token, "To Delete", "to-delete").await; - - let body = gql(&server, &token, r#"mutation { deleteCollection(slug: "to-delete") }"#).await; - assert!(body["errors"].is_null()); - assert!(body["data"]["deleteCollection"].as_bool().unwrap()); -} - -#[tokio::test] -async fn test_create_singleton_collection() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - let body = create_singleton_collection(&server, &token, "Settings", "settings").await; - assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - assert_eq!(body["data"]["createCollection"]["name"].as_str().unwrap(), "Settings"); - assert_eq!(body["data"]["createCollection"]["slug"].as_str().unwrap(), "settings"); - assert!(body["data"]["createCollection"]["isSingleton"].as_bool().unwrap()); -} - -#[tokio::test] -async fn test_create_collection_invalid_field_type() { +async fn management_mutations_are_absent_from_public_schema() { let server = TestServer::start().await; let (_, token) = setup(&server).await; - - let query = r#"mutation CreateCollection($input: CreateCollectionInput!) { - createCollection(input: $input) { id name } - }"#; - let vars = json!({"input": {"name": "Bad", "slug": "bad", "definition": json!({"fields": [{"name": "title", "type": "string"}]})}}); - let body = gql_with_vars(&server, &token, query, vars).await; - - assert!(body["errors"].is_array()); - let msg = body["errors"][0]["message"].as_str().unwrap(); - assert!( - msg.contains("Invalid definition") || msg.contains("invalid type"), - "Expected validation error: {}", - msg - ); -} - -#[tokio::test] -async fn test_create_collection_duplicate_slug() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - create_collection(&server, &token, "Posts", "dup-slug").await; - - let query = r#"mutation CreateCollection($input: CreateCollectionInput!) { - createCollection(input: $input) { id } - }"#; - let vars = json!({"input": {"name": "Posts Again", "slug": "dup-slug", "definition": json!({"fields": [{"name": "title", "type": "text"}]})}}); - let body = gql_with_vars(&server, &token, query, vars).await; - - assert!(body["errors"].is_array()); -} - -#[tokio::test] -async fn test_collection_not_singleton() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - create_collection(&server, &token, "Regular", "regular").await; - - let body = gql(&server, &token, r#"{ collection(slug: "regular") { id isSingleton } }"#).await; - assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - assert!(!body["data"]["collection"]["isSingleton"].as_bool().unwrap()); + let body = gql(&server, &token, "{ __schema { mutationType { fields { name } } } }").await; + assert!(body["errors"].is_null(), "{body}"); + let names = body["data"]["__schema"]["mutationType"]["fields"] + .as_array() + .unwrap() + .iter() + .map(|field| field["name"].as_str().unwrap()) + .collect::>(); + for name in [ + "createCollection", + "updateCollection", + "deleteCollection", + "createWebhook", + "updateWebhook", + "deleteWebhook", + "batchDeleteFiles", + "batchRestoreFiles", + ] { + assert!(!names.contains(&name), "Public management mutation exposed: {name}"); + } + assert!(names.contains(&"createEntry")); } diff --git a/apps/backend/tests/graphql/contract_tests.rs b/apps/backend/tests/graphql/contract_tests.rs new file mode 100644 index 00000000..ad35c04f --- /dev/null +++ b/apps/backend/tests/graphql/contract_tests.rs @@ -0,0 +1,279 @@ +use serde_json::{Value, json}; + +use crate::common::{TestServer, auth::extract_cookies}; + +async fn setup(server: &TestServer) -> (String, String, String, String) { + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = extract_cookies(&login); + let site_response = client + .post(format!("{}/api/dashboard/sites", server.base_url)) + .header("Cookie", format!("token={session}; csrf={csrf}")) + .header("X-CSRF-Token", &csrf) + .json(&json!({"name": "GraphQL Contract Site", "storage_provider": "filesystem"})) + .send() + .await + .unwrap(); + let site: Value = site_response.json().await.unwrap(); + let site_id = site["id"].as_str().unwrap().to_owned(); + let token_response = client + .post(format!("{}/api/dashboard/sites/{site_id}/tokens", server.base_url)) + .header("Cookie", format!("token={session}; csrf={csrf}")) + .header("X-CSRF-Token", &csrf) + .json(&json!({"name": "GraphQL Contract", "scopes": crate::common::fixtures::site_key_scopes("write")})) + .send() + .await + .unwrap(); + let token = token_response.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_owned(); + let collection = client + .post(format!("{}/api/dashboard/sites/{site_id}/collections", server.base_url)) + .header("Cookie", format!("token={session}; csrf={csrf}")) + .header("X-CSRF-Token", &csrf) + .json(&json!({ + "name": "Posts", + "slug": "posts", + "definition": {"fields": [{"name": "title", "type": "text"}]} + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap(); + (site_id, token, collection["id"].as_str().unwrap().to_owned(), session) +} + +async fn gql(server: &TestServer, token: &str, query: &str, variables: Value) -> Value { + reqwest::Client::new() + .post(format!("{}/api/graphql", server.base_url)) + .header("Authorization", format!("Bearer {token}")) + .json(&json!({"query": query, "variables": variables})) + .send() + .await + .unwrap() + .json() + .await + .unwrap() +} + +#[tokio::test] +async fn site_namespace_and_explicit_entry_mutation_work() { + let server = TestServer::start().await; + let (site_id, token, collection_id, _) = setup(&server).await; + let body = gql( + &server, + &token, + "mutation Create($siteId: String!, $input: CreateEntryInput!) { createEntry(siteId: $siteId, input: $input) { id data version } }", + json!({"siteId": site_id, "input": {"collectionId": collection_id, "slug": "hello", "data": {"title": "Hello"}}}), + ) + .await; + assert!(body["errors"].is_null(), "GraphQL errors: {}", body["errors"]); + if body["data"]["createEntry"]["data"]["title"] != "Hello" { + panic!("body: {body}"); + } + assert!(!body["data"]["createEntry"]["version"].as_str().unwrap().is_empty()); + + let body = gql( + &server, + &token, + &format!("{{ site(id: \"{site_id}\") {{ id entries(includeDrafts: true) {{ nodes {{ slug }} pageInfo {{ hasNextPage }} totalCount }} }} }}"), + json!({}), + ) + .await; + assert!(body["errors"].is_null(), "GraphQL errors: {}", body["errors"]); + assert_eq!(body["data"]["site"]["entries"]["nodes"][0]["slug"], "hello"); +} + +#[tokio::test] +async fn introspection_requires_authentication() { + let server = TestServer::start().await; + let response = reqwest::Client::new() + .post(format!("{}/api/graphql", server.base_url)) + .json(&json!({"query": "{ __schema { queryType { name } } }"})) + .send() + .await + .unwrap(); + let body: Value = response.json().await.unwrap(); + assert!( + body["data"]["__schema"].is_null(), + "Unauthenticated schema exposed: {body}" + ); +} + +#[tokio::test] +async fn draft_status_cannot_override_visibility_and_writers_cannot_publish() { + let server = TestServer::start().await; + let (site, writer, collection, _) = setup(&server).await; + let created = gql( + &server, + &writer, + "mutation($site: String!, $input: CreateEntryInput!) { createEntry(siteId:$site,input:$input) { id } }", + json!({"site":site,"input":{"collectionId":collection,"slug":"private","data":{"title":"Secret"}}}), + ) + .await; + assert!(created["errors"].is_null(), "{created}"); + let id = created["data"]["createEntry"]["id"].as_str().unwrap(); + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = extract_cookies(&login); + let response = client + .post(format!("{}/api/dashboard/sites/{site}/tokens", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name":"Limited", "scopes":["site.read","schema.read","content.read","content.write"]})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let limited = response.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_owned(); + for query in [format!( + "{{ site(id:\"{site}\") {{ entries(status:\"draft\",includeDrafts:false) {{ nodes {{ id data }} }} }} }}" + )] { + let body = gql(&server, &limited, &query, json!({})).await; + assert!(body["errors"].is_null(), "{body}"); + assert!(!body.to_string().contains("Secret"), "draft leaked: {body}"); + assert!(!body.to_string().contains(id), "draft ID leaked: {body}"); + } + let body = gql( + &server, + &limited, + &format!("mutation {{ updateEntry(siteId:\"{site}\",id:\"{id}\",input:{{status:\"published\"}}) {{ id }} }}"), + json!({}), + ) + .await; + assert!(body["errors"].is_array(), "publication scope bypass: {body}"); + let body = gql( + &server, + &writer, + &format!("{{ site(id:\"{site}\") {{ entry(id:\"{id}\",includeDrafts:true) {{ status }} }} }}"), + json!({}), + ) + .await; + assert_eq!(body["data"]["site"]["entry"]["status"], "draft"); +} + +#[tokio::test] +async fn namespaced_nested_collections_use_explicit_pat_site() { + let server = TestServer::start().await; + let (site, writer, collection, _) = setup(&server).await; + let created = gql( + &server, + &writer, + "mutation($site: String!, $input: CreateEntryInput!) { createEntry(siteId:$site,input:$input) { id } }", + json!({"site":site,"input":{"collectionId":collection,"slug":"private","data":{"title":"Secret"}}}), + ) + .await; + assert!(created["errors"].is_null(), "{created}"); + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = extract_cookies(&login); + let response = client + .post(format!("{}/api/dashboard/account/tokens", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name":"PAT","scopes":["site.read","schema.read","content.preview.read"],"expires_at":null})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let pat = response.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_owned(); + let body = gql( + &server, + &pat, + &format!("{{ site(id:\"{site}\") {{ collections {{ entries(status:\"draft\") {{ slug }} }} }} }}"), + json!({}), + ) + .await; + assert!( + body["errors"].is_null(), + "Nested resolver ignored explicit site: {body}" + ); + assert_eq!(body["data"]["site"]["collections"][0]["entries"][0]["slug"], "private"); +} + +#[tokio::test] +async fn excessive_aliases_are_rejected_before_resolver_work() { + let server = TestServer::start().await; + let query = format!( + "{{ {} }}", + (0..257) + .map(|index| format!("field{index}:__typename")) + .collect::>() + .join(" ") + ); + let body = gql(&server, "invalid", &query, json!({})).await; + assert!(body["errors"].is_array(), "{body}"); + assert!( + body["errors"][0]["message"] + .as_str() + .unwrap() + .contains("256-alias limit") + ); + assert!(body["data"].is_null()); +} + +#[tokio::test] +async fn content_only_personal_tokens_read_with_explicit_site_context() { + let server = TestServer::start().await; + let (site, writer, collection, _) = setup(&server).await; + let created = gql( + &server, + &writer, + "mutation($site:String!,$input:CreateEntryInput!){createEntry(siteId:$site,input:$input){id}}", + json!({"site":site,"input":{"collectionId":collection,"slug":"published","data":{"title":"Public"}}}), + ) + .await; + assert!(created["errors"].is_null(), "{created}"); + let id = created["data"]["createEntry"]["id"].as_str().unwrap(); + let published = gql( + &server, + &writer, + &format!("mutation {{ setEntryPublication(siteId:\"{site}\",id:\"{id}\",published:true) {{ id }} }}"), + json!({}), + ) + .await; + assert!(published["errors"].is_null(), "{published}"); + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = extract_cookies(&login); + let response = client + .post(format!("{}/api/dashboard/account/tokens", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name":"Content only","scopes":["content.read"],"expires_at":null})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let pat = response.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_owned(); + let response = client + .post(format!("{}/api/graphql", server.base_url)) + .bearer_auth(&pat) + .json(&json!({"query":format!("{{ site(id:\"{site}\") {{ entry(id:\"{id}\") {{ id data }} }} }}")})) + .send() + .await + .unwrap(); + let body: Value = response.json().await.unwrap(); + assert!( + body["errors"].is_null(), + "Context selection required unrelated site.read scope: {body}" + ); + assert_eq!(body["data"]["site"]["entry"]["data"]["title"], "Public"); + let other_site = gql( + &server, + &pat, + &format!("{{ site(id:\"not-a-member\") {{ entry(id:\"{id}\") {{ id }} }} }}"), + json!({}), + ) + .await; + assert!(other_site["errors"].is_array(), "PAT read a site it does not belong to"); +} diff --git a/apps/backend/tests/graphql/entries_tests.rs b/apps/backend/tests/graphql/entries_tests.rs index 2c8dfcf8..71a8e850 100644 --- a/apps/backend/tests/graphql/entries_tests.rs +++ b/apps/backend/tests/graphql/entries_tests.rs @@ -79,36 +79,52 @@ async fn gql_with_vars(server: &TestServer, token: &str, query: &str, variables: resp.json().await.unwrap() } -async fn create_collection(server: &TestServer, token: &str, name: &str, slug: &str) -> String { - let query = r#"mutation CreateCollection($input: CreateCollectionInput!) { - createCollection(input: $input) { id } - }"#; - let vars = json!({"input": {"name": name, "slug": slug, "definition": json!({"fields": [{"name": "title", "type": "text"}]})}}); - let body = gql_with_vars(server, token, query, vars).await; - body["data"]["createCollection"]["id"].as_str().unwrap().to_string() +async fn create_collection(server: &TestServer, site_id: &str, name: &str, slug: &str) -> String { + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = crate::common::auth::extract_cookies(&login); + let response = client + .post(format!("{}/api/dashboard/sites/{site_id}/collections", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&json!({"name": name, "slug": slug, "definition":{"fields":[{"name":"title","type":"text"}]}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + response.json::().await.unwrap()["id"] + .as_str() + .unwrap() + .to_owned() } -async fn create_entry(server: &TestServer, token: &str, collection_id: &str, slug: &str, data: Value) -> Value { - let query = r#"mutation CreateEntry($input: CreateEntryInput!) { - createEntry(input: $input) { id slug status collectionId } +async fn create_entry( + server: &TestServer, + token: &str, + site_id: &str, + collection_id: &str, + slug: &str, + data: Value, +) -> Value { + let query = r#"mutation CreateEntry($siteId: String!, $input: CreateEntryInput!) { + createEntry(siteId: $siteId, input: $input) { id slug status collectionId } }"#; - let vars = json!({"input": {"collectionId": collection_id, "slug": slug, "data": data}}); + let vars = json!({"siteId":site_id, "input": {"collectionId": collection_id, "slug": slug, "data": data}}); gql_with_vars(server, token, query, vars).await } #[tokio::test] async fn test_entries_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "entry-1", json!({"title": "First"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "entry-1", json!({"title": "First"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); let pub_body = gql( &server, &token, - &format!(r#"mutation {{ publishEntry(id: "{}") {{ id }} }}"#, entry_id), + &format!(r#"mutation {{ publishEntry: setEntryPublication(siteId: "{site_id}", published: true, id: "{}") {{ id }} }}"#, entry_id), ) .await; assert!( @@ -117,57 +133,86 @@ async fn test_entries_query() { pub_body["errors"] ); - let body = gql(&server, &token, "{ entries { id slug status } }").await; + let query = format!(r#"{{ site(id: "{site_id}") {{ entries {{ nodes {{ id slug status }} }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - let entries = body["data"]["entries"].as_array().unwrap(); + let entries = body["data"]["site"]["entries"]["nodes"].as_array().unwrap(); assert!(!entries.is_empty()); } #[tokio::test] async fn test_entries_with_status_filter() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - create_entry(&server, &token, &col_id, "draft-entry", json!({"title": "Draft"})).await; + create_entry( + &server, + &token, + &site_id, + &col_id, + "draft-entry", + json!({"title": "Draft"}), + ) + .await; - let body = gql(&server, &token, r#"{ entries(status: "draft") { id slug status } }"#).await; + let query = format!( + r#"{{ site(id: "{site_id}") {{ entries(status: "draft", includeDrafts: true) {{ nodes {{ id slug status }} }} }} }}"# + ); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - let entries = body["data"]["entries"].as_array().unwrap(); + let entries = body["data"]["site"]["entries"]["nodes"].as_array().unwrap(); + assert_eq!(entries.len(), 1); assert!(entries.iter().all(|e| e["status"].as_str().unwrap() == "draft")); } #[tokio::test] async fn test_entries_with_collection_id_filter() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - create_entry(&server, &token, &col_id, "my-post", json!({"title": "My Post"})).await; + create_entry( + &server, + &token, + &site_id, + &col_id, + "my-post", + json!({"title": "My Post"}), + ) + .await; let query = format!( - r#"{{ entries(collectionId: "{}") {{ id slug collectionId }} }}"#, - col_id + r#"{{ site(id: "{site_id}") {{ entries(includeDrafts: true, collectionSlug: "posts") {{ nodes {{ id slug collectionId }} }} }} }}"# ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - let entries = body["data"]["entries"].as_array().unwrap(); + let entries = body["data"]["site"]["entries"]["nodes"].as_array().unwrap(); + assert_eq!(entries.len(), 1); assert!(entries.iter().all(|e| e["collectionId"].as_str().unwrap() == col_id)); } #[tokio::test] async fn test_entries_with_search() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "searchable", json!({"title": "Unique Title"})).await; + let created = create_entry( + &server, + &token, + &site_id, + &col_id, + "searchable", + json!({"title": "Unique Title"}), + ) + .await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); let pub_body = gql( &server, &token, - &format!(r#"mutation {{ publishEntry(id: "{}") {{ id }} }}"#, entry_id), + &format!(r#"mutation {{ publishEntry: setEntryPublication(siteId: "{site_id}", published: true, id: "{}") {{ id }} }}"#, entry_id), ) .await; assert!( @@ -176,9 +221,10 @@ async fn test_entries_with_search() { pub_body["errors"] ); - let body = gql(&server, &token, r#"{ entries(search: "Unique") { id slug } }"#).await; + let query = format!(r#"{{ site(id: "{site_id}") {{ entries(search: "Unique") {{ nodes {{ id slug }} }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - let entries = body["data"]["entries"].as_array().unwrap(); + let entries = body["data"]["site"]["entries"]["nodes"].as_array().unwrap(); let slugs: Vec<&str> = entries.iter().filter_map(|e| e["slug"].as_str()).collect(); assert!( slugs.contains(&"searchable"), @@ -190,13 +236,14 @@ async fn test_entries_with_search() { #[tokio::test] async fn test_entries_with_pagination() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; for i in 0..5 { create_entry( &server, &token, + &site_id, &col_id, &format!("entry-{}", i), json!({"title": format!("Post {}", i)}), @@ -204,33 +251,44 @@ async fn test_entries_with_pagination() { .await; } - let body = gql(&server, &token, "{ entries(page: 1, perPage: 2) { id } }").await; + let body = gql( + &server, + &token, + &format!( + r#"{{ site(id: "{site_id}") {{ entries(includeDrafts: true, page: 1, perPage: 2) {{ nodes {{ id }} totalCount pageInfo {{ hasNextPage }} }} }} }}"# + ), + ) + .await; assert!(body["errors"].is_null()); - let entries = body["data"]["entries"].as_array().unwrap(); - assert!(entries.len() <= 2); + let connection = &body["data"]["site"]["entries"]; + assert_eq!(connection["nodes"].as_array().unwrap().len(), 2); + assert_eq!(connection["totalCount"], 5); + assert_eq!(connection["pageInfo"]["hasNextPage"], true); } #[tokio::test] async fn test_entry_by_id() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "get-me", json!({"title": "Get Me"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "get-me", json!({"title": "Get Me"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let query = format!(r#"{{ entry(id: "{}") {{ id slug data }} }}"#, entry_id); + let query = + format!(r#"{{ site(id: "{site_id}") {{ entry(id: "{entry_id}", includeDrafts: true) {{ id slug data }} }} }}"#); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["entry"]["slug"].as_str().unwrap(), "get-me"); + assert_eq!(body["data"]["site"]["entry"]["slug"].as_str().unwrap(), "get-me"); } #[tokio::test] async fn test_entry_not_found() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let body = gql(&server, &token, r#"{ entry(id: "nonexistent") { id } }"#).await; + let query = format!(r#"{{ site(id: "{site_id}") {{ entry(id: "nonexistent") {{ id }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_array()); let msg = body["errors"][0]["message"].as_str().unwrap(); assert!(msg.contains("not found")); @@ -239,10 +297,10 @@ async fn test_entry_not_found() { #[tokio::test] async fn test_create_entry_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let body = create_entry(&server, &token, &col_id, "new-entry", json!({"title": "New"})).await; + let body = create_entry(&server, &token, &site_id, &col_id, "new-entry", json!({"title": "New"})).await; assert!(body["errors"].is_null()); assert_eq!(body["data"]["createEntry"]["slug"].as_str().unwrap(), "new-entry"); assert_eq!(body["data"]["createEntry"]["status"].as_str().unwrap(), "draft"); @@ -251,25 +309,33 @@ async fn test_create_entry_mutation() { #[tokio::test] async fn test_create_entry_nonexistent_collection() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let body = create_entry(&server, &token, "nonexistent", "fail", json!({"title": "Fail"})).await; + let body = create_entry( + &server, + &token, + &site_id, + "nonexistent", + "fail", + json!({"title": "Fail"}), + ) + .await; assert!(body["errors"].is_array()); } #[tokio::test] async fn test_update_entry_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "to-update", json!({"title": "Old"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "to-update", json!({"title": "Old"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let query = r#"mutation UpdateEntry($id: String!, $input: UpdateEntryInput!) { - updateEntry(id: $id, input: $input) { id data } + let query = r#"mutation UpdateEntry($siteId: String!, $id: String!, $input: UpdateEntryInput!) { + updateEntry(siteId: $siteId, id: $id, input: $input) { id data } }"#; - let vars = json!({"id": entry_id, "input": {"data": json!({"title": "Updated"})}}); + let vars = json!({"siteId":site_id, "id": entry_id, "input": {"data": json!({"title": "Updated"})}}); let body = gql_with_vars(&server, &token, query, vars).await; assert!(body["errors"].is_null()); } @@ -277,13 +343,13 @@ async fn test_update_entry_mutation() { #[tokio::test] async fn test_delete_entry_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "to-delete", json!({"title": "Bye"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "to-delete", json!({"title": "Bye"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let query = format!(r#"mutation {{ deleteEntry(id: "{}") }}"#, entry_id); + let query = format!(r#"mutation {{ deleteEntry(siteId: "{site_id}", id: "{}") }}"#, entry_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); assert!(body["data"]["deleteEntry"].as_bool().unwrap()); @@ -292,13 +358,24 @@ async fn test_delete_entry_mutation() { #[tokio::test] async fn test_publish_entry_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "to-publish", json!({"title": "Draft"})).await; + let created = create_entry( + &server, + &token, + &site_id, + &col_id, + "to-publish", + json!({"title": "Draft"}), + ) + .await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let query = format!(r#"mutation {{ publishEntry(id: "{}") {{ id status }} }}"#, entry_id); + let query = format!( + r#"mutation {{ publishEntry: setEntryPublication(siteId: "{site_id}", published: true, id: "{}") {{ id status }} }}"#, + entry_id + ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); assert_eq!(body["data"]["publishEntry"]["status"].as_str().unwrap(), "published"); @@ -307,16 +384,24 @@ async fn test_publish_entry_mutation() { #[tokio::test] async fn test_unpublish_entry_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "to-unpublish", json!({"title": "Published"})).await; + let created = create_entry( + &server, + &token, + &site_id, + &col_id, + "to-unpublish", + json!({"title": "Published"}), + ) + .await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); let pub_body = gql( &server, &token, - &format!(r#"mutation {{ publishEntry(id: "{}") {{ status }} }}"#, entry_id), + &format!(r#"mutation {{ publishEntry: setEntryPublication(siteId: "{site_id}", published: true, id: "{}") {{ status }} }}"#, entry_id), ) .await; assert!( @@ -325,7 +410,10 @@ async fn test_unpublish_entry_mutation() { pub_body["errors"] ); - let query = format!(r#"mutation {{ unpublishEntry(id: "{}") {{ id status }} }}"#, entry_id); + let query = format!( + r#"mutation {{ unpublishEntry: setEntryPublication(siteId: "{site_id}", published: false, id: "{}") {{ id status }} }}"#, + entry_id + ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); assert_eq!(body["data"]["unpublishEntry"]["status"].as_str().unwrap(), "draft"); @@ -334,22 +422,22 @@ async fn test_unpublish_entry_mutation() { #[tokio::test] async fn test_restore_revision_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "revisioned", json!({"title": "V1"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "revisioned", json!({"title": "V1"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let update_query = r#"mutation UpdateEntry($id: String!, $input: UpdateEntryInput!) { - updateEntry(id: $id, input: $input) { id } + let update_query = r#"mutation UpdateEntry($siteId: String!, $id: String!, $input: UpdateEntryInput!) { + updateEntry(siteId: $siteId, id: $id, input: $input) { id } }"#; - let vars = json!({"id": entry_id, "input": {"data": json!({"title": "V2"})}}); + let vars = json!({"siteId":site_id, "id": entry_id, "input": {"data": json!({"title": "V2"})}}); let body = gql_with_vars(&server, &token, update_query, vars).await; assert!(body["errors"].is_null(), "update failed: {:?}", body["errors"]); assert_eq!(body["data"]["updateEntry"]["id"].as_str().unwrap(), entry_id); let restore_query = format!( - r#"mutation {{ restoreRevision(entryId: "{}", revisionNumber: 1) {{ id data }} }}"#, + r#"mutation {{ restoreRevision(siteId: "{site_id}", entryId: "{}", revisionNumber: 1) {{ id data }} }}"#, entry_id ); let body = gql(&server, &token, &restore_query).await; @@ -359,14 +447,14 @@ async fn test_restore_revision_mutation() { #[tokio::test] async fn test_entry_revisions_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "rev-test", json!({"title": "V1"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "rev-test", json!({"title": "V1"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let update_query = r#"mutation { updateEntry(id: "%ID%", input: {data: {title: "V2"}}) { id } }"#; - let update_query = update_query.replace("%ID%", entry_id); + let update_query = r#"mutation { updateEntry(siteId: "%SITE%", id: "%ID%", input: {data: {title: "V2"}}) { id } }"#; + let update_query = update_query.replace("%ID%", entry_id).replace("%SITE%", &site_id); let update_body = gql(&server, &token, &update_query).await; assert!( update_body["errors"].is_null(), @@ -375,44 +463,47 @@ async fn test_entry_revisions_query() { ); let query = format!( - r#"{{ entryRevisions(entryId: "{}") {{ items {{ revisionNumber data }} total }} }}"#, - entry_id + r#"{{ site(id: "{site_id}") {{ entryRevisions(entryId: "{entry_id}") {{ items {{ revisionNumber data }} total }} }} }}"# ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - let items = body["data"]["entryRevisions"]["items"].as_array().unwrap(); + let items = body["data"]["site"]["entryRevisions"]["items"].as_array().unwrap(); assert!(items.len() >= 2, "expected >= 2 revisions, got {}", items.len()); } #[tokio::test] async fn test_entry_revision_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "rev-single", json!({"title": "V1"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "rev-single", json!({"title": "V1"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); let query = format!( - r#"{{ entryRevision(entryId: "{}", revisionNumber: 1) {{ revisionNumber data }} }}"#, - entry_id + r#"{{ site(id: "{site_id}") {{ entryRevision(entryId: "{entry_id}", revisionNumber: 1) {{ revisionNumber data }} }} }}"# ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["entryRevision"]["revisionNumber"].as_i64().unwrap(), 1); + assert_eq!( + body["data"]["site"]["entryRevision"]["revisionNumber"] + .as_i64() + .unwrap(), + 1 + ); } #[tokio::test] async fn test_entry_revision_with_diff() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - let col_id = create_collection(&server, &token, "Posts", "posts").await; + let (site_id, token) = setup(&server).await; + let col_id = create_collection(&server, &site_id, "Posts", "posts").await; - let created = create_entry(&server, &token, &col_id, "diff-test", json!({"title": "V1"})).await; + let created = create_entry(&server, &token, &site_id, &col_id, "diff-test", json!({"title": "V1"})).await; let entry_id = created["data"]["createEntry"]["id"].as_str().unwrap(); - let update_query = r#"mutation { updateEntry(id: "%ID%", input: {data: {title: "V2"}}) { id } }"#; - let update_query = update_query.replace("%ID%", entry_id); + let update_query = r#"mutation { updateEntry(siteId: "%SITE%", id: "%ID%", input: {data: {title: "V2"}}) { id } }"#; + let update_query = update_query.replace("%ID%", entry_id).replace("%SITE%", &site_id); let update_body = gql(&server, &token, &update_query).await; assert!( update_body["errors"].is_null(), @@ -421,23 +512,27 @@ async fn test_entry_revision_with_diff() { ); let query = format!( - r#"{{ entryRevision(entryId: "{}", revisionNumber: 2, diff: true) {{ revisionNumber diffFromPrevious }} }}"#, - entry_id + r#"{{ site(id: "{site_id}") {{ entryRevision(entryId: "{entry_id}", revisionNumber: 2, diff: true) {{ revisionNumber diffFromPrevious }} }} }}"# ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - assert_eq!(body["data"]["entryRevision"]["revisionNumber"].as_i64().unwrap(), 2); + assert_eq!( + body["data"]["site"]["entryRevision"]["revisionNumber"] + .as_i64() + .unwrap(), + 2 + ); } #[tokio::test] async fn test_create_entry_with_invalid_collection_id() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let query = r#"mutation CreateEntry($input: CreateEntryInput!) { - createEntry(input: $input) { id } + let query = r#"mutation CreateEntry($siteId: String!, $input: CreateEntryInput!) { + createEntry(siteId: $siteId, input: $input) { id } }"#; - let vars = json!({"input": {"collectionId": "nonexistent", "slug": "orphan", "data": json!({"title": "Hello"})}}); + let vars = json!({"siteId":site_id, "input": {"collectionId": "nonexistent", "slug": "orphan", "data": json!({"title": "Hello"})}}); let body = gql_with_vars(&server, &token, query, vars).await; assert!(body["errors"].is_array()); diff --git a/apps/backend/tests/graphql/files_tests.rs b/apps/backend/tests/graphql/files_tests.rs index ebb57d63..3645bf77 100644 --- a/apps/backend/tests/graphql/files_tests.rs +++ b/apps/backend/tests/graphql/files_tests.rs @@ -67,19 +67,7 @@ async fn gql(server: &TestServer, token: &str, query: &str) -> Value { resp.json().await.unwrap() } -async fn gql_with_vars(server: &TestServer, token: &str, query: &str, variables: Value) -> Value { - let client = reqwest::Client::builder().build().unwrap(); - let resp = client - .post(format!("{}/api/graphql", server.base_url)) - .header("Authorization", format!("Bearer {}", token)) - .json(&json!({"query": query, "variables": variables})) - .send() - .await - .unwrap(); - resp.json().await.unwrap() -} - -async fn upload_file_via_rest(server: &TestServer, token: &str) -> String { +async fn upload_file_via_rest(server: &TestServer, token: &str, site_id: &str) -> String { let client = reqwest::Client::builder().build().unwrap(); let part = reqwest::multipart::Part::bytes(b"test content".to_vec()) .file_name("test.txt") @@ -88,7 +76,7 @@ async fn upload_file_via_rest(server: &TestServer, token: &str) -> String { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{site_id}/files", server.base_url)) .header("Authorization", format!("Bearer {}", token)) .multipart(form) .send() @@ -101,41 +89,48 @@ async fn upload_file_via_rest(server: &TestServer, token: &str) -> String { #[tokio::test] async fn test_files_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - upload_file_via_rest(&server, &token).await; + upload_file_via_rest(&server, &token, &site_id).await; let body = gql( &server, &token, - "{ files { id filename originalName mimeType size url } }", + &format!(r#"{{ site(id: "{site_id}") {{ files {{ id filename originalName mimeType size url }} }} }}"#), ) .await; assert!(body["errors"].is_null()); - let files = body["data"]["files"].as_array().unwrap(); + let files = body["data"]["site"]["files"].as_array().unwrap(); assert!(!files.is_empty()); } #[tokio::test] async fn test_file_by_id() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let file_id = upload_file_via_rest(&server, &token).await; + let file_id = upload_file_via_rest(&server, &token, &site_id).await; - let query = format!(r#"{{ file(id: "{}") {{ id filename url thumbnailUrl }} }}"#, file_id); + let query = + format!(r#"{{ site(id: "{site_id}") {{ file(id: "{file_id}") {{ id filename url thumbnailUrl }} }} }}"#); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - assert_eq!(body["data"]["file"]["id"].as_str().unwrap(), file_id); - assert!(body["data"]["file"]["url"].as_str().unwrap().contains("/api/files/")); + assert_eq!(body["data"]["site"]["file"]["id"].as_str().unwrap(), file_id); + assert!( + body["data"]["site"]["file"]["url"] + .as_str() + .unwrap() + .contains("/api/files/") + ); } #[tokio::test] async fn test_file_not_found() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let body = gql(&server, &token, r#"{ file(id: "nonexistent") { id } }"#).await; + let query = format!(r#"{{ site(id: "{site_id}") {{ file(id: "nonexistent") {{ id }} }} }}"#); + let body = gql(&server, &token, &query).await; assert!(body["errors"].is_array()); let msg = body["errors"][0]["message"].as_str().unwrap(); assert!(msg.contains("not found")); @@ -144,30 +139,49 @@ async fn test_file_not_found() { #[tokio::test] async fn test_file_references_query() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - let file_id = upload_file_via_rest(&server, &token).await; - + let (site_id, token) = setup(&server).await; + + let file_id = upload_file_via_rest(&server, &token, &site_id).await; + + let client = reqwest::Client::new(); + let login = server.login_user(&client, "admin@cms.local", "admin").await; + let (session, csrf) = crate::common::auth::extract_cookies(&login); + let response = client.post(format!("{}/api/dashboard/sites/{site_id}/collections",server.base_url)) + .headers(crate::common::auth::auth_header(&session,&csrf)) + .json(&json!({"name":"Media","slug":"media","definition":{"fields":[{"name":"hero","type":"file"},{"name":"gallery","type":"file","multiple":true}]}})) + .send().await.unwrap(); + assert_eq!(response.status(), 201); + let collection: Value = response.json().await.unwrap(); + let response = client.post(format!("{}/api/v1/sites/{site_id}/collections/media/entries",server.base_url)) + .bearer_auth(&token).json(&json!({"slug":"media","data":{"hero":format!("/api/files/{file_id}"),"gallery":[format!("/api/files/{file_id}/thumbnail")]}})) + .send().await.unwrap(); + assert_eq!(response.status(), 201, "duplicate file references must validate"); + let entry: Value = response.json().await.unwrap(); let query = format!( - r#"{{ fileReferences(fileId: "{}") {{ entryId collectionName fieldName }} }}"#, - file_id + r#"{{ site(id: "{site_id}") {{ fileReferences(fileId: "{file_id}") {{ entryId collectionName fieldName }} }} }}"# ); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); - let _ = body["data"]["fileReferences"].as_array().unwrap(); - // TODO: we should create some entries that reference the file and verify they are returned here, but for now just check the structure of the response - // let refs = body["data"]["fileReferences"].as_array().unwrap(); - // assert!(!refs.is_empty(), "expected file references"); + let refs = body["data"]["site"]["fileReferences"].as_array().unwrap(); + assert_eq!(refs.len(), 2); + assert!(refs.iter().all(|reference| reference["entryId"] == entry["id"])); + let mut fields = refs + .iter() + .map(|reference| reference["fieldName"].as_str().unwrap()) + .collect::>(); + fields.sort(); + assert_eq!(fields, ["gallery[0]", "hero"]); + assert_eq!(entry["collection_id"], collection["id"]); } #[tokio::test] async fn test_delete_file_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let file_id = upload_file_via_rest(&server, &token).await; + let file_id = upload_file_via_rest(&server, &token, &site_id).await; - let query = format!(r#"mutation {{ deleteFile(id: "{}") }}"#, file_id); + let query = format!(r#"mutation {{ deleteFile(siteId: "{site_id}", id: "{}") }}"#, file_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); assert!(body["data"]["deleteFile"].as_bool().unwrap()); @@ -176,14 +190,14 @@ async fn test_delete_file_mutation() { #[tokio::test] async fn test_restore_file_mutation() { let server = TestServer::start().await; - let (_, token) = setup(&server).await; + let (site_id, token) = setup(&server).await; - let file_id = upload_file_via_rest(&server, &token).await; + let file_id = upload_file_via_rest(&server, &token, &site_id).await; let del_body = gql( &server, &token, - &format!(r#"mutation {{ deleteFile(id: "{}") }}"#, file_id), + &format!(r#"mutation {{ deleteFile(siteId: "{site_id}", id: "{}") }}"#, file_id), ) .await; assert!( @@ -192,51 +206,8 @@ async fn test_restore_file_mutation() { del_body["errors"] ); - let query = format!(r#"mutation {{ restoreFile(id: "{}") }}"#, file_id); + let query = format!(r#"mutation {{ restoreFile(siteId: "{site_id}", id: "{}") }}"#, file_id); let body = gql(&server, &token, &query).await; assert!(body["errors"].is_null()); assert!(body["data"]["restoreFile"].as_bool().unwrap()); } - -#[tokio::test] -async fn test_batch_delete_files_mutation() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - let mut ids = Vec::new(); - for _ in 0..3 { - ids.push(upload_file_via_rest(&server, &token).await); - } - - let query = r#"mutation BatchDelete($ids: [String!]!) { batchDeleteFiles(ids: $ids) }"#; - let vars = json!({"ids": ids}); - let body = gql_with_vars(&server, &token, query, vars).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["batchDeleteFiles"].as_i64().unwrap(), 3); -} - -#[tokio::test] -async fn test_batch_restore_files_mutation() { - let server = TestServer::start().await; - let (_, token) = setup(&server).await; - - let mut ids = Vec::new(); - for _ in 0..2 { - ids.push(upload_file_via_rest(&server, &token).await); - } - - for id in &ids { - let del_body = gql(&server, &token, &format!(r#"mutation {{ deleteFile(id: "{}") }}"#, id)).await; - assert!( - del_body["errors"].is_null(), - "deleteFile should succeed: {:?}", - del_body["errors"] - ); - } - - let query = r#"mutation BatchRestore($ids: [String!]!) { batchRestoreFiles(ids: $ids) }"#; - let vars = json!({"ids": ids}); - let body = gql_with_vars(&server, &token, query, vars).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["batchRestoreFiles"].as_i64().unwrap(), 2); -} diff --git a/apps/backend/tests/graphql/main.rs b/apps/backend/tests/graphql/main.rs index 2c2b3693..8c7720c1 100644 --- a/apps/backend/tests/graphql/main.rs +++ b/apps/backend/tests/graphql/main.rs @@ -1,9 +1,12 @@ #[path = "../common/mod.rs"] mod common; -mod auth_tests; -mod collections_tests; +mod contract_tests; +mod sites_tests; + mod entries_tests; + +mod auth_tests; mod files_tests; -mod sites_tests; -mod webhooks_tests; + +mod collections_tests; diff --git a/apps/backend/tests/graphql/sites_tests.rs b/apps/backend/tests/graphql/sites_tests.rs index 17cf90d8..37f3bb3f 100644 --- a/apps/backend/tests/graphql/sites_tests.rs +++ b/apps/backend/tests/graphql/sites_tests.rs @@ -68,15 +68,12 @@ async fn gql(server: &TestServer, token: &str, query: &str) -> Value { } #[tokio::test] -async fn test_current_site() { +async fn test_site_by_id() { let server = TestServer::start().await; - let (_, _, token) = setup(&server).await; + let (_, site_id, token) = setup(&server).await; - let body = gql(&server, &token, "{ currentSite { id name storageProvider } }").await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["currentSite"]["name"].as_str().unwrap(), "GQL Site"); - assert_eq!( - body["data"]["currentSite"]["storageProvider"].as_str().unwrap(), - "filesystem" - ); + let query = format!(r#"{{ site(id: "{site_id}") {{ id name createdAt }} }}"#); + let body = gql(&server, &token, &query).await; + assert!(body["errors"].is_null(), "{body}"); + assert_eq!(body["data"]["site"]["name"].as_str().unwrap(), "GQL Site"); } diff --git a/apps/backend/tests/graphql/webhooks_tests.rs b/apps/backend/tests/graphql/webhooks_tests.rs deleted file mode 100644 index a488be51..00000000 --- a/apps/backend/tests/graphql/webhooks_tests.rs +++ /dev/null @@ -1,242 +0,0 @@ -use serde_json::{Value, json}; -use wiremock::matchers::method; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use crate::common::TestServer; - -async fn setup(server: &TestServer) -> (String, String) { - let client = reqwest::Client::builder().build().unwrap(); - - let resp = server.login_user(&client, "admin@cms.local", "admin").await; - let headers = resp.headers(); - let mut token = String::new(); - let mut csrf = String::new(); - for cookie in headers.get_all("set-cookie").iter() { - if let Ok(val) = cookie.to_str() { - if val.starts_with("token=") { - token = val - .split(';') - .next() - .and_then(|c| c.strip_prefix("token=")) - .unwrap_or("") - .to_string(); - } - if val.starts_with("csrf=") { - csrf = val - .split(';') - .next() - .and_then(|c| c.strip_prefix("csrf=")) - .unwrap_or("") - .to_string(); - } - } - } - - let resp = client - .post(format!("{}/api/dashboard/sites", server.base_url)) - .header("Cookie", format!("token={}; csrf={}", token, csrf)) - .header("X-CSRF-Token", &csrf) - .json(&json!({"name": "Webhook Site", "storage_provider": "filesystem"})) - .send() - .await - .unwrap(); - let site: Value = resp.json().await.unwrap(); - let site_id = site["id"].as_str().unwrap().to_string(); - - let resp = client - .post(format!("{}/api/dashboard/sites/{}/tokens", server.base_url, site_id)) - .header("Cookie", format!("token={}; csrf={}", token, csrf)) - .header("X-CSRF-Token", &csrf) - .json(&json!({"name": "Token", "scopes": crate::common::fixtures::site_key_scopes("write")})) - .send() - .await - .unwrap(); - let token_val: Value = resp.json().await.unwrap(); - let token = token_val["token"].as_str().unwrap().to_string(); - - (site_id, token) -} - -async fn gql(server: &TestServer, token: &str, query: &str) -> Value { - let client = reqwest::Client::builder().build().unwrap(); - let resp = client - .post(format!("{}/api/graphql", server.base_url)) - .header("Authorization", format!("Bearer {}", token)) - .json(&json!({"query": query})) - .send() - .await - .unwrap(); - resp.json().await.unwrap() -} - -async fn gql_with_vars(server: &TestServer, token: &str, query: &str, variables: Value) -> Value { - let client = reqwest::Client::builder().build().unwrap(); - let resp = client - .post(format!("{}/api/graphql", server.base_url)) - .header("Authorization", format!("Bearer {}", token)) - .json(&json!({"query": query, "variables": variables})) - .send() - .await - .unwrap(); - resp.json().await.unwrap() -} - -async fn create_webhook(server: &TestServer, token: &str, site_id: &str, label: &str, url: &str) -> Value { - let query = r#"mutation CreateWebhook($siteId: String!, $label: String!, $url: String!) { - createWebhook(siteId: $siteId, label: $label, url: $url) { id label url } - }"#; - let vars = json!({"siteId": site_id, "label": label, "url": url}); - gql_with_vars(server, token, query, vars).await -} - -#[tokio::test] -async fn test_webhooks_query() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let query = format!(r#"{{ webhooks(siteId: "{}") {{ id label url }} }}"#, site_id); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - let hooks = body["data"]["webhooks"].as_array().unwrap(); - assert!(hooks.is_empty()); -} - -#[tokio::test] -async fn test_webhook_not_found() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let query = format!( - r#"{{ webhook(siteId: "{}", webhookId: "nonexistent") {{ id }} }}"#, - site_id - ); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_array()); - let msg = body["errors"][0]["message"].as_str().unwrap(); - assert!(msg.contains("not found")); -} - -#[tokio::test] -async fn test_create_webhook_mutation() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let body = create_webhook(&server, &token, &site_id, "New Hook", "https://example.com/hook").await; - assert!(body["errors"].is_null(), "errors: {:?}", body["errors"]); - assert_eq!(body["data"]["createWebhook"]["label"].as_str().unwrap(), "New Hook"); -} - -#[tokio::test] -async fn test_webhook_by_id() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let created = create_webhook(&server, &token, &site_id, "My Hook", "https://example.com/hook").await; - assert!( - created["errors"].is_null(), - "create_webhook errors: {:?}", - created["errors"] - ); - let hook_id = created["data"]["createWebhook"]["id"].as_str().unwrap(); - - let query = format!( - r#"{{ webhook(siteId: "{}", webhookId: "{}") {{ id label }} }}"#, - site_id, hook_id - ); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["webhook"]["label"].as_str().unwrap(), "My Hook"); -} - -#[tokio::test] -async fn test_delete_webhook_mutation() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let created = create_webhook(&server, &token, &site_id, "Delete Me", "https://example.com/hook").await; - let hook_id = created["data"]["createWebhook"]["id"].as_str().unwrap(); - - let query = format!( - r#"mutation {{ deleteWebhook(siteId: "{}", webhookId: "{}") }}"#, - site_id, hook_id - ); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_null()); - assert!(body["data"]["deleteWebhook"].as_bool().unwrap()); -} - -#[tokio::test] -async fn test_update_webhook_mutation() { - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let created = create_webhook(&server, &token, &site_id, "Old Label", "https://example.com/hook").await; - let hook_id = created["data"]["createWebhook"]["id"].as_str().unwrap(); - - let query = r#"mutation UpdateWebhook($siteId: String!, $webhookId: String!, $label: String!) { - updateWebhook(siteId: $siteId, webhookId: $webhookId, label: $label) { id label } - }"#; - let vars = json!({"siteId": site_id, "webhookId": hook_id, "label": "New Label"}); - let body = gql_with_vars(&server, &token, query, vars).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["updateWebhook"]["label"].as_str().unwrap(), "New Label"); -} - -#[tokio::test] -async fn test_trigger_webhook_mutation() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let created = create_webhook(&server, &token, &site_id, "Trigger Me", &mock_server.uri()).await; - let hook_id = created["data"]["createWebhook"]["id"].as_str().unwrap(); - - let query = format!( - r#"mutation {{ triggerWebhook(siteId: "{}", webhookId: "{}") {{ id status }} }}"#, - site_id, hook_id - ); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_null()); - assert_eq!(body["data"]["triggerWebhook"]["status"].as_str().unwrap(), "success"); -} - -#[tokio::test] -async fn test_webhook_deliveries_query() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200)) - .mount(&mock_server) - .await; - - let server = TestServer::start().await; - let (site_id, token) = setup(&server).await; - - let created = create_webhook(&server, &token, &site_id, "Delivery Hook", &mock_server.uri()).await; - let hook_id = created["data"]["createWebhook"]["id"].as_str().unwrap(); - - let query = format!( - r#"mutation {{ triggerWebhook(siteId: "{}", webhookId: "{}") {{ id status }} }}"#, - site_id, hook_id - ); - let trigger_body = gql(&server, &token, &query).await; - assert!( - trigger_body["errors"].is_null(), - "triggerWebhook should succeed: {:?}", - trigger_body["errors"] - ); - - let query = format!( - r#"{{ webhookDeliveries(siteId: "{}", webhookId: "{}") {{ id status }} }}"#, - site_id, hook_id - ); - let body = gql(&server, &token, &query).await; - assert!(body["errors"].is_null()); - let deliveries = body["data"]["webhookDeliveries"].as_array().unwrap(); - assert!(!deliveries.is_empty(), "expected webhook deliveries"); -} diff --git a/apps/backend/tests/grpc/collections_tests.rs b/apps/backend/tests/grpc/collections_tests.rs index 8fd4d9f4..ccb405ab 100644 --- a/apps/backend/tests/grpc/collections_tests.rs +++ b/apps/backend/tests/grpc/collections_tests.rs @@ -1,222 +1,58 @@ use cms::grpc::cms::v1::collection_service_client::CollectionServiceClient; -use cms::grpc::cms::v1::{ - CreateCollectionRequest, DeleteCollectionRequest, GetCollectionRequest, ListCollectionsRequest, - UpdateCollectionRequest, -}; +use cms::grpc::cms::v1::{GetCollectionRequest, ListCollectionsRequest}; use crate::common::{GrpcTestContext, grpc::auth_interceptor}; -async fn setup() -> (GrpcTestContext, String, String) { +#[tokio::test] +async fn schema_discovery_is_read_only_and_site_scoped() { let ctx = GrpcTestContext::start().await; let (site_id, token) = ctx.setup_site_and_token().await; - (ctx, site_id, token) -} - -#[tokio::test] -async fn test_create_collection() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let resp = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Posts".into(), - slug: "posts".into(), - definition: r#"{"fields":[{"name":"title","type":"text"}]}"#.into(), - is_singleton: false, - })) - .await - .unwrap() - .into_inner(); - - assert_eq!(resp.name, "Posts"); - assert_eq!(resp.slug, "posts"); - assert_eq!(resp.site_id, site_id); - assert!(!resp.is_singleton); - assert!(!resp.id.is_empty()); -} + let created = ctx + .create_collection( + &site_id, + "Posts", + "posts", + serde_json::json!({"fields":[{"name":"title","type":"text"}]}), + false, + ) + .await; -#[tokio::test] -async fn test_get_collection() { - let (ctx, _site_id, token) = setup().await; let channel = ctx.connect().await; let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Pages".into(), - slug: "pages".into(), - definition: "{}".into(), - is_singleton: false, + let listed = client + .list_collections(tonic::Request::new(ListCollectionsRequest { + site_id: site_id.clone(), })) .await .unwrap() .into_inner(); + assert_eq!(listed.collections.len(), 1); + assert_eq!(listed.collections[0].id, created["id"].as_str().unwrap()); let fetched = client - .get_collection(tonic::Request::new(GetCollectionRequest { slug: "pages".into() })) - .await - .unwrap() - .into_inner(); - - assert_eq!(fetched.id, created.id); - assert_eq!(fetched.name, "Pages"); - assert_eq!(fetched.slug, "pages"); -} - -#[tokio::test] -async fn test_list_collections() { - let (ctx, _site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let _created = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Posts".into(), + .get_collection(tonic::Request::new(GetCollectionRequest { + site_id, slug: "posts".into(), - definition: "{}".into(), - is_singleton: false, - })) - .await - .unwrap(); - - let _created = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Pages".into(), - slug: "pages".into(), - definition: "{}".into(), - is_singleton: false, - })) - .await - .unwrap(); - - let resp = client - .list_collections(tonic::Request::new(ListCollectionsRequest {})) - .await - .unwrap() - .into_inner(); - - assert_eq!(resp.collections.len(), 2); - let names: Vec<&str> = resp.collections.iter().map(|c| c.name.as_str()).collect(); - assert!(names.contains(&"Posts")); - assert!(names.contains(&"Pages")); -} - -#[tokio::test] -async fn test_update_collection() { - let (ctx, _site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Old Name".into(), - slug: "old-name".into(), - definition: "{}".into(), - is_singleton: false, - })) - .await - .unwrap() - .into_inner(); - - let updated = client - .update_collection(tonic::Request::new(UpdateCollectionRequest { - id: created.id, - name: Some("New Name".into()), - slug: Some("new-name".into()), - definition: None, })) .await .unwrap() .into_inner(); - - assert_eq!(updated.name, "New Name"); - assert_eq!(updated.slug, "new-name"); + assert_eq!(fetched.slug, "posts"); + assert!(fetched.definition.is_some()); } #[tokio::test] -async fn test_delete_collection() { - let (ctx, _site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "To Delete".into(), - slug: "to-delete".into(), - definition: "{}".into(), - is_singleton: false, - })) - .await - .unwrap() - .into_inner(); - - let resp = client - .delete_collection(tonic::Request::new(DeleteCollectionRequest { - id: created.id, - slug: "to-delete".into(), - })) - .await - .unwrap() - .into_inner(); - - assert!(resp.success); - - let result = client - .get_collection(tonic::Request::new(GetCollectionRequest { - slug: "to-delete".into(), - })) - .await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn test_grpc_unauthenticated() { +async fn schema_get_missing_collection_is_not_found() { let ctx = GrpcTestContext::start().await; + let (site_id, token) = ctx.setup_site_and_token().await; let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor("invalid-token-12345")); - - let result = client - .list_collections(tonic::Request::new(ListCollectionsRequest {})) - .await; - - assert!(result.is_err()); - let status = result.unwrap_err(); - assert_eq!(status.code(), tonic::Code::Unauthenticated); -} - -#[tokio::test] -async fn test_grpc_create_collection_invalid_definition() { - let (_ctx, _site_id, token) = setup().await; - let channel = _ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let result = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Bad".into(), - slug: "bad".into(), - definition: r#"{"fields":[{"name":"title","type":"string"}]}"#.into(), - is_singleton: false, - })) - .await; - - assert!(result.is_err()); -} - -#[tokio::test] -async fn test_grpc_get_collection_not_found() { - let (_ctx, _site_id, token) = setup().await; - let channel = _ctx.connect().await; let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let result = client + let error = client .get_collection(tonic::Request::new(GetCollectionRequest { - slug: "nonexistent".into(), + site_id, + slug: "missing".into(), })) - .await; - - assert!(result.is_err()); - let status = result.unwrap_err(); - assert_eq!(status.code(), tonic::Code::NotFound); + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::NotFound); } diff --git a/apps/backend/tests/grpc/entries_tests.rs b/apps/backend/tests/grpc/entries_tests.rs index 7442dd96..0d1436c5 100644 --- a/apps/backend/tests/grpc/entries_tests.rs +++ b/apps/backend/tests/grpc/entries_tests.rs @@ -1,36 +1,32 @@ -use cms::grpc::cms::v1::collection_service_client::CollectionServiceClient; use cms::grpc::cms::v1::entry_service_client::EntryServiceClient; use cms::grpc::cms::v1::{ - CreateCollectionRequest, CreateEntryRequest, DeleteEntryRequest, GetEntryRequest, GetEntryRevisionRequest, - ListEntriesRequest, ListEntriesResponse, ListEntryRevisionsRequest, PublishEntryRequest, - RestoreEntryRevisionRequest, UnpublishEntryRequest, UpdateEntryRequest, + CreateEntryRequest, DeleteEntryRequest, GetEntryRequest, GetEntryRevisionRequest, ListEntriesRequest, + ListEntriesResponse, ListEntryRevisionsRequest, PublishEntryRequest, RestoreEntryRevisionRequest, + UnpublishEntryRequest, UpdateEntryRequest, }; -use crate::common::{GrpcTestContext, grpc::auth_interceptor}; +use crate::common::{GrpcTestContext, grpc::auth_interceptor, grpc::content, grpc::content_json}; async fn setup() -> (GrpcTestContext, String, String, String) { let ctx = GrpcTestContext::start().await; let (site_id, token) = ctx.setup_site_and_token().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let collection = client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Posts".into(), - slug: "posts".into(), - definition: r#"{"fields":[{"name":"title","type":"text"}]}"#.into(), - is_singleton: false, - })) - .await - .unwrap() - .into_inner(); + let collection = ctx + .create_collection( + &site_id, + "Posts", + "posts", + serde_json::json!({"fields":[{"name":"title","type":"text"}]}), + false, + ) + .await; - (ctx, site_id, token, collection.id) + (ctx, site_id, token, collection["id"].as_str().unwrap().to_string()) } async fn wait_for_search( client: &mut EntryServiceClient>, + site_id: &str, collection_id: &str, search: &str, expected_slug: &str, @@ -41,11 +37,12 @@ where for _ in 0..50 { let response = client .list_entries(tonic::Request::new(ListEntriesRequest { + site_id: site_id.to_owned(), collection_id: Some(collection_id.to_owned()), status: None, search: Some(search.to_owned()), - page: 1, - per_page: 10, + page_size: 10, + ..Default::default() })) .await .unwrap() @@ -61,14 +58,15 @@ where #[tokio::test] async fn test_create_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let resp = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Hello World"}"#.into(), + data: content(r#"{"title":"Hello World"}"#), slug: "hello-world".into(), })) .await @@ -83,14 +81,15 @@ async fn test_create_entry() { #[tokio::test] async fn test_get_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Test"}"#.into(), + data: content(r#"{"title":"Test"}"#), slug: "test-entry".into(), })) .await @@ -98,7 +97,11 @@ async fn test_get_entry() { .into_inner(); let fetched = client - .get_entry(tonic::Request::new(GetEntryRequest { id: created.id.clone() })) + .get_entry(tonic::Request::new(GetEntryRequest { + site_id: site_id.clone(), + id: created.id.clone(), + include_drafts: true, + })) .await .unwrap() .into_inner(); @@ -109,15 +112,16 @@ async fn test_get_entry() { #[tokio::test] async fn test_list_entries() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); for i in 0..3 { let _created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: format!(r#"{{"title":"Entry {}"}}"#, i), + data: content(&format!(r#"{{"title":"Entry {}"}}"#, i)), slug: format!("entry-{}", i), })) .await @@ -126,30 +130,33 @@ async fn test_list_entries() { let resp = client .list_entries(tonic::Request::new(ListEntriesRequest { + site_id: site_id.clone(), collection_id: Some(collection_id), status: None, search: None, - page: 1, - per_page: 10, + page_size: 10, + include_drafts: true, + ..Default::default() })) .await .unwrap() .into_inner(); assert_eq!(resp.items.len(), 3); - assert_eq!(resp.total, 3); + assert_eq!(resp.total_size, 3); } #[tokio::test] async fn test_list_entries_with_search() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Unique Title"}"#.into(), + data: content(r#"{"title":"Unique Title"}"#), slug: "searchable".into(), })) .await @@ -157,13 +164,16 @@ async fn test_list_entries_with_search() { .into_inner(); let _published = client - .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id.clone() })) + .publish_entry(tonic::Request::new(PublishEntryRequest { + site_id: site_id.clone(), + id: created.id.clone(), + })) .await .unwrap(); // Search indexing is asynchronous in production. Poll briefly until the // queue consumer publishes the committed document to the reader. - let resp = wait_for_search(&mut client, &collection_id, "Unique", "searchable").await; + let resp = wait_for_search(&mut client, &site_id, &collection_id, "Unique", "searchable").await; let slugs: Vec<&str> = resp.items.iter().map(|i| i.slug.as_str()).collect(); assert!( @@ -175,7 +185,7 @@ async fn test_list_entries_with_search() { #[tokio::test] async fn test_search_indexes_are_isolated_across_concurrent_contexts() { - let ((ctx_a, _site_a, token_a, collection_a), (ctx_b, _site_b, token_b, collection_b)) = + let ((ctx_a, site_a, token_a, collection_a), (ctx_b, site_b, token_b, collection_b)) = tokio::join!(setup(), setup()); let channel_a = ctx_a.connect().await; @@ -183,31 +193,35 @@ async fn test_search_indexes_are_isolated_across_concurrent_contexts() { let mut client_a = EntryServiceClient::with_interceptor(channel_a, auth_interceptor(&token_a)); let mut client_b = EntryServiceClient::with_interceptor(channel_b, auth_interceptor(&token_b)); - for (client, collection_id, slug) in [ - (&mut client_a, collection_a.clone(), "context-a"), - (&mut client_b, collection_b.clone(), "context-b"), + for (client, site_id, collection_id, slug) in [ + (&mut client_a, site_a.clone(), collection_a.clone(), "context-a"), + (&mut client_b, site_b.clone(), collection_b.clone(), "context-b"), ] { let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id, - data: r#"{"title":"Running"}"#.into(), + data: content(r#"{"title":"Running"}"#), slug: slug.into(), })) .await .unwrap() .into_inner(); client - .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id })) + .publish_entry(tonic::Request::new(PublishEntryRequest { + site_id: site_id.clone(), + id: created.id, + })) .await .unwrap(); } - for (client, collection_id, expected_slug, other_slug) in [ - (&mut client_a, collection_a, "context-a", "context-b"), - (&mut client_b, collection_b, "context-b", "context-a"), + for (client, site_id, collection_id, expected_slug, other_slug) in [ + (&mut client_a, site_a, collection_a, "context-a", "context-b"), + (&mut client_b, site_b, collection_b, "context-b", "context-a"), ] { // Exercise Tantivy's stemming rather than an exact stored-value match. - let response = wait_for_search(client, &collection_id, "run", expected_slug).await; + let response = wait_for_search(client, &site_id, &collection_id, "run", expected_slug).await; let slugs: Vec<&str> = response.items.iter().map(|item| item.slug.as_str()).collect(); assert!( !slugs.contains(&other_slug), @@ -218,14 +232,15 @@ async fn test_search_indexes_are_isolated_across_concurrent_contexts() { #[tokio::test] async fn test_update_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Old"}"#.into(), + data: content(r#"{"title":"Old"}"#), slug: "update-me".into(), })) .await @@ -234,29 +249,35 @@ async fn test_update_entry() { let updated = client .update_entry(tonic::Request::new(UpdateEntryRequest { + site_id: site_id.clone(), id: created.id, - data: Some(r#"{"title":"New"}"#.into()), + data: content(r#"{"title":"New"}"#), slug: None, status: None, change_summary: Some("Updated title".into()), + ..Default::default() })) .await .unwrap() .into_inner(); - assert_eq!(updated.data, r#"{"title":"New"}"#); + assert_eq!( + content_json(&updated.data), + serde_json::from_str::(r#"{"title":"New"}"#).unwrap() + ); } #[tokio::test] async fn test_delete_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Delete Me"}"#.into(), + data: content(r#"{"title":"Delete Me"}"#), slug: "delete-me".into(), })) .await @@ -264,16 +285,21 @@ async fn test_delete_entry() { .into_inner(); let resp = client - .delete_entry(tonic::Request::new(DeleteEntryRequest { id: created.id })) + .delete_entry(tonic::Request::new(DeleteEntryRequest { + site_id: site_id.clone(), + id: created.id, + })) .await .unwrap() .into_inner(); - assert!(resp.success); + assert!(resp.deleted); let result = client .get_entry(tonic::Request::new(GetEntryRequest { + site_id: site_id.clone(), id: "nonexistent".into(), + ..Default::default() })) .await; assert!(result.is_err()); @@ -281,14 +307,15 @@ async fn test_delete_entry() { #[tokio::test] async fn test_publish_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Publish Me"}"#.into(), + data: content(r#"{"title":"Publish Me"}"#), slug: "publish-me".into(), })) .await @@ -298,7 +325,10 @@ async fn test_publish_entry() { assert_eq!(created.status, "draft"); let published = client - .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id })) + .publish_entry(tonic::Request::new(PublishEntryRequest { + site_id: site_id.clone(), + id: created.id, + })) .await .unwrap() .into_inner(); @@ -309,14 +339,15 @@ async fn test_publish_entry() { #[tokio::test] async fn test_unpublish_entry() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Unpublish Me"}"#.into(), + data: content(r#"{"title":"Unpublish Me"}"#), slug: "unpublish-me".into(), })) .await @@ -324,12 +355,18 @@ async fn test_unpublish_entry() { .into_inner(); let _published = client - .publish_entry(tonic::Request::new(PublishEntryRequest { id: created.id.clone() })) + .publish_entry(tonic::Request::new(PublishEntryRequest { + site_id: site_id.clone(), + id: created.id.clone(), + })) .await .unwrap(); let unpublished = client - .unpublish_entry(tonic::Request::new(UnpublishEntryRequest { id: created.id })) + .unpublish_entry(tonic::Request::new(UnpublishEntryRequest { + site_id: site_id.clone(), + id: created.id, + })) .await .unwrap() .into_inner(); @@ -340,14 +377,15 @@ async fn test_unpublish_entry() { #[tokio::test] async fn test_list_entry_revisions() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"V1"}"#.into(), + data: content(r#"{"title":"V1"}"#), slug: "revision-test".into(), })) .await @@ -356,39 +394,43 @@ async fn test_list_entry_revisions() { let _updated = client .update_entry(tonic::Request::new(UpdateEntryRequest { + site_id: site_id.clone(), id: created.id.clone(), - data: Some(r#"{"title":"V2"}"#.into()), + data: content(r#"{"title":"V2"}"#), slug: None, status: None, change_summary: Some("Updated to V2".into()), + ..Default::default() })) .await .unwrap(); let resp = client .list_entry_revisions(tonic::Request::new(ListEntryRevisionsRequest { + site_id: site_id.clone(), entry_id: created.id, - page: 1, - per_page: 10, + page_size: 10, + ..Default::default() })) .await .unwrap() .into_inner(); assert!(resp.items.len() >= 2); - assert!(resp.total >= 2); + assert!(resp.total_size >= 2); } #[tokio::test] async fn test_get_entry_revision() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"V1"}"#.into(), + data: content(r#"{"title":"V1"}"#), slug: "get-revision".into(), })) .await @@ -397,17 +439,20 @@ async fn test_get_entry_revision() { let _updated = client .update_entry(tonic::Request::new(UpdateEntryRequest { + site_id: site_id.clone(), id: created.id.clone(), - data: Some(r#"{"title":"V2"}"#.into()), + data: content(r#"{"title":"V2"}"#), slug: None, status: None, change_summary: None, + ..Default::default() })) .await .unwrap(); let revision = client .get_entry_revision(tonic::Request::new(GetEntryRevisionRequest { + site_id: site_id.clone(), entry_id: created.id, revision_number: 1, })) @@ -416,19 +461,20 @@ async fn test_get_entry_revision() { .into_inner(); assert_eq!(revision.revision_number, 1); - assert!(revision.data.contains("V1")); + assert!(content_json(&revision.data).to_string().contains("V1")); } #[tokio::test] async fn test_restore_entry_revision() { - let (ctx, _site_id, token, collection_id) = setup().await; + let (ctx, site_id, token, collection_id) = setup().await; let channel = ctx.connect().await; let mut client = EntryServiceClient::with_interceptor(channel, auth_interceptor(&token)); let created = client .create_entry(tonic::Request::new(CreateEntryRequest { + site_id: site_id.clone(), collection_id: collection_id.clone(), - data: r#"{"title":"Original"}"#.into(), + data: content(r#"{"title":"Original"}"#), slug: "restore-revision".into(), })) .await @@ -437,17 +483,20 @@ async fn test_restore_entry_revision() { let _updated = client .update_entry(tonic::Request::new(UpdateEntryRequest { + site_id: site_id.clone(), id: created.id.clone(), - data: Some(r#"{"title":"Changed"}"#.into()), + data: content(r#"{"title":"Changed"}"#), slug: None, status: None, change_summary: None, + ..Default::default() })) .await .unwrap(); let restored = client .restore_entry_revision(tonic::Request::new(RestoreEntryRevisionRequest { + site_id: site_id.clone(), entry_id: created.id, revision_number: 1, })) @@ -455,5 +504,150 @@ async fn test_restore_entry_revision() { .unwrap() .into_inner(); - assert!(restored.data.contains("Original")); + assert!(content_json(&restored.data).to_string().contains("Original")); +} + +#[tokio::test] +async fn typed_values_and_versions_fail_without_mutating_entries() { + let (ctx, site_id, token, collection_id) = setup().await; + let mut client = EntryServiceClient::with_interceptor(ctx.connect().await, auth_interceptor(&token)); + for kind in [ + None, + Some(prost_types::value::Kind::NumberValue(f64::NAN)), + Some(prost_types::value::Kind::NullValue(7)), + ] { + let error = client + .create_entry(CreateEntryRequest { + site_id: site_id.clone(), + collection_id: collection_id.clone(), + slug: "invalid".into(), + data: Some(prost_types::Struct { + fields: [("title".into(), prost_types::Value { kind })].into_iter().collect(), + }), + }) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + } + let created = client + .create_entry(CreateEntryRequest { + site_id: site_id.clone(), + collection_id: collection_id.clone(), + slug: "versioned".into(), + data: content(r#"{"title":"Original"}"#), + }) + .await + .unwrap() + .into_inner(); + let error = client + .update_entry(UpdateEntryRequest { + site_id: site_id.clone(), + id: created.id.clone(), + data: content(r#"{"title":"Lost"}"#), + expected_version: "stale".into(), + ..Default::default() + }) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::FailedPrecondition); + let fetched = client + .get_entry(GetEntryRequest { + site_id: site_id.clone(), + id: created.id, + include_drafts: true, + }) + .await + .unwrap() + .into_inner(); + assert_eq!( + content_json(&fetched.data), + serde_json::from_str::(r#"{"title":"Original"}"#).unwrap() + ); + let page = client + .list_entries(ListEntriesRequest { + site_id, + collection_id: Some(collection_id), + page_size: 1, + include_drafts: true, + ..Default::default() + }) + .await + .unwrap() + .into_inner(); + assert_eq!(page.total_size, 1); + assert!( + page.next_page_token.is_empty(), + "exactly full final page must terminate" + ); +} + +#[tokio::test] +async fn update_status_requires_publish_scope() { + let (ctx, site_id, token, collection_id) = setup().await; + let http = crate::common::client::http_client(); + let response = http + .post(format!("{}/api/auth/login", ctx.rest_base_url)) + .json(&serde_json::json!({"email":"admin@cms.local","password":"admin"})) + .send() + .await + .unwrap(); + let (session, csrf) = crate::common::auth::extract_cookies(&response); + let response = http + .post(format!("{}/api/dashboard/sites/{site_id}/tokens", ctx.rest_base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&serde_json::json!({"name":"writer","scopes":["content.write"]})) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + let credential: serde_json::Value = response.json().await.unwrap(); + let mut writer = EntryServiceClient::with_interceptor( + ctx.connect().await, + auth_interceptor(credential["token"].as_str().unwrap()), + ); + let created = writer + .create_entry(CreateEntryRequest { + site_id: site_id.clone(), + collection_id, + slug: "draft".into(), + data: content(r#"{"title":"Draft"}"#), + }) + .await + .unwrap() + .into_inner(); + let error = writer + .update_entry(UpdateEntryRequest { + site_id: site_id.clone(), + id: created.id.clone(), + status: Some("published".into()), + ..Default::default() + }) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::PermissionDenied); + let mut reader = EntryServiceClient::with_interceptor(ctx.connect().await, auth_interceptor(&token)); + let fetched = reader + .get_entry(GetEntryRequest { + site_id, + id: created.id, + include_drafts: true, + }) + .await + .unwrap() + .into_inner(); + assert_eq!(fetched.status, "draft"); +} + +#[tokio::test] +async fn site_scoped_requests_require_an_explicit_site_id() { + let (ctx, _site_id, token, collection_id) = setup().await; + let mut client = EntryServiceClient::with_interceptor(ctx.connect().await, auth_interceptor(&token)); + let error = client + .list_entries(ListEntriesRequest { + collection_id: Some(collection_id), + ..Default::default() + }) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); } diff --git a/apps/backend/tests/grpc/files_tests.rs b/apps/backend/tests/grpc/files_tests.rs index e6bd89fb..41450273 100644 --- a/apps/backend/tests/grpc/files_tests.rs +++ b/apps/backend/tests/grpc/files_tests.rs @@ -1,8 +1,5 @@ use cms::grpc::cms::v1::file_service_client::FileServiceClient; -use cms::grpc::cms::v1::{ - BatchDeleteFilesRequest, BatchRestoreFilesRequest, DeleteFileRequest, GetFileRequest, ListFilesRequest, - RestoreFileRequest, -}; +use cms::grpc::cms::v1::{DeleteFileRequest, GetFileRequest, ListFilesRequest, RestoreFileRequest}; use crate::common::{GrpcTestContext, grpc::auth_interceptor}; @@ -54,17 +51,18 @@ async fn test_list_files() { let resp = client .list_files(tonic::Request::new(ListFilesRequest { + site_id: site_id.clone(), search: None, file_type: None, - page: 1, - per_page: 10, + page_size: 10, + ..Default::default() })) .await .unwrap() .into_inner(); assert_eq!(resp.files.len(), 2); - assert_eq!(resp.total, 2); + assert_eq!(resp.total_size, 2); } #[tokio::test] @@ -78,19 +76,18 @@ async fn test_list_files_default_pagination() { let resp = client .list_files(tonic::Request::new(ListFilesRequest { + site_id: site_id.clone(), search: None, file_type: None, - page: 0, - per_page: 0, + page_size: 0, + ..Default::default() })) .await .unwrap() .into_inner(); assert_eq!(resp.files.len(), 2); - assert_eq!(resp.total, 2); - assert!(resp.page >= 1); - assert!(resp.per_page >= 1); + assert_eq!(resp.total_size, 2); } #[tokio::test] @@ -105,17 +102,18 @@ async fn test_list_files_filter_by_category() { let resp = client .list_files(tonic::Request::new(ListFilesRequest { + site_id: site_id.clone(), search: None, file_type: Some("image".into()), - page: 1, - per_page: 10, + page_size: 10, + ..Default::default() })) .await .unwrap() .into_inner(); assert_eq!(resp.files.len(), 2); - assert_eq!(resp.total, 2); + assert_eq!(resp.total_size, 2); let types: Vec<&str> = resp.files.iter().map(|f| f.mime_type.as_str()).collect(); assert!(types.iter().all(|t| t.starts_with("image/"))); } @@ -132,17 +130,18 @@ async fn test_list_files_filter_by_exact_mime_type() { let resp = client .list_files(tonic::Request::new(ListFilesRequest { + site_id: site_id.clone(), search: None, file_type: Some("image/png".into()), - page: 1, - per_page: 10, + page_size: 10, + ..Default::default() })) .await .unwrap() .into_inner(); assert_eq!(resp.files.len(), 1); - assert_eq!(resp.total, 1); + assert_eq!(resp.total_size, 1); assert_eq!(resp.files[0].mime_type, "image/png"); } @@ -155,7 +154,10 @@ async fn test_get_file() { let file_id = seed_file(&ctx, &site_id, "get-me").await; let resp = client - .get_file(tonic::Request::new(GetFileRequest { id: file_id.clone() })) + .get_file(tonic::Request::new(GetFileRequest { + site_id: site_id.clone(), + id: file_id.clone(), + })) .await .unwrap() .into_inner(); @@ -174,19 +176,23 @@ async fn test_delete_file() { let file_id = seed_file(&ctx, &site_id, "delete-me").await; let resp = client - .delete_file(tonic::Request::new(DeleteFileRequest { id: file_id.clone() })) + .delete_file(tonic::Request::new(DeleteFileRequest { + site_id: site_id.clone(), + id: file_id.clone(), + })) .await .unwrap() .into_inner(); - assert!(resp.success); + assert!(resp.deleted); let list_resp = client .list_files(tonic::Request::new(ListFilesRequest { + site_id: site_id.clone(), search: None, file_type: None, - page: 1, - per_page: 100, + page_size: 100, + ..Default::default() })) .await .unwrap() @@ -208,12 +214,18 @@ async fn test_restore_file() { let file_id = seed_file(&ctx, &site_id, "restore-me").await; let _deleted = client - .delete_file(tonic::Request::new(DeleteFileRequest { id: file_id.clone() })) + .delete_file(tonic::Request::new(DeleteFileRequest { + site_id: site_id.clone(), + id: file_id.clone(), + })) .await .unwrap(); let resp = client - .restore_file(tonic::Request::new(RestoreFileRequest { id: file_id.clone() })) + .restore_file(tonic::Request::new(RestoreFileRequest { + site_id: site_id.clone(), + id: file_id.clone(), + })) .await .unwrap() .into_inner(); @@ -223,44 +235,52 @@ async fn test_restore_file() { } #[tokio::test] -async fn test_batch_delete_files() { +async fn streaming_upload_preserves_bytes_and_rejects_changed_metadata() { + use cms::grpc::cms::v1::UploadFileRequest; let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = FileServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let id1 = seed_file(&ctx, &site_id, "batch1").await; - let id2 = seed_file(&ctx, &site_id, "batch2").await; - - let resp = client - .batch_delete_files(tonic::Request::new(BatchDeleteFilesRequest { ids: vec![id1, id2] })) + let mut client = FileServiceClient::with_interceptor(ctx.connect().await, auth_interceptor(&token)); + let metadata = UploadFileRequest { + site_id: site_id.clone(), + filename: "test.txt".into(), + content_type: "text/plain".into(), + chunk: b"hello ".to_vec(), + }; + let file = client + .upload_file(tokio_stream::iter(vec![ + metadata.clone(), + UploadFileRequest { + chunk: b"world".to_vec(), + ..Default::default() + }, + ])) .await .unwrap() .into_inner(); - - assert_eq!(resp.affected, 2); -} - -#[tokio::test] -async fn test_batch_restore_files() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = FileServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let id1 = seed_file(&ctx, &site_id, "batch-r1").await; - let id2 = seed_file(&ctx, &site_id, "batch-r2").await; - - let _deleted = client - .batch_delete_files(tonic::Request::new(BatchDeleteFilesRequest { - ids: vec![id1.clone(), id2.clone()], - })) + assert_eq!(file.size, 11); + let response = reqwest::get(format!("{}/api/files/{}", ctx.rest_base_url, file.id)) .await .unwrap(); - - let resp = client - .batch_restore_files(tonic::Request::new(BatchRestoreFilesRequest { ids: vec![id1, id2] })) + assert_eq!(response.status(), 200); + assert_eq!(response.text().await.unwrap(), "hello world"); + let error = client + .upload_file(tokio_stream::iter(vec![ + metadata, + UploadFileRequest { + site_id: "another-site".into(), + chunk: b"bad".to_vec(), + ..Default::default() + }, + ])) + .await + .unwrap_err(); + assert_eq!(error.code(), tonic::Code::InvalidArgument); + let files = client + .list_files(ListFilesRequest { + site_id, + ..Default::default() + }) .await .unwrap() .into_inner(); - - assert_eq!(resp.affected, 2); + assert_eq!(files.total_size, 1, "Rejected upload left a file record"); } diff --git a/apps/backend/tests/grpc/main.rs b/apps/backend/tests/grpc/main.rs index dbf4688e..92c9ac86 100644 --- a/apps/backend/tests/grpc/main.rs +++ b/apps/backend/tests/grpc/main.rs @@ -6,4 +6,3 @@ mod entries_tests; mod files_tests; mod singletons_tests; mod sites_tests; -mod webhooks_tests; diff --git a/apps/backend/tests/grpc/singletons_tests.rs b/apps/backend/tests/grpc/singletons_tests.rs index 2f01a404..13d2e75f 100644 --- a/apps/backend/tests/grpc/singletons_tests.rs +++ b/apps/backend/tests/grpc/singletons_tests.rs @@ -1,37 +1,34 @@ -use cms::grpc::cms::v1::collection_service_client::CollectionServiceClient; use cms::grpc::cms::v1::singleton_service_client::SingletonServiceClient; -use cms::grpc::cms::v1::{CreateCollectionRequest, GetSingletonRequest, UpdateSingletonRequest}; +use cms::grpc::cms::v1::{GetSingletonRequest, UpdateSingletonRequest}; -use crate::common::{GrpcTestContext, grpc::auth_interceptor}; +use crate::common::{GrpcTestContext, grpc::auth_interceptor, grpc::content, grpc::content_json}; async fn setup() -> (GrpcTestContext, String, String) { let ctx = GrpcTestContext::start().await; let (site_id, token) = ctx.setup_site_and_token().await; - let channel = ctx.connect().await; - let mut client = CollectionServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - client - .create_collection(tonic::Request::new(CreateCollectionRequest { - name: "Settings".into(), - slug: "settings".into(), - definition: r#"{"fields":[{"name":"site_name","type":"text"}]}"#.into(), - is_singleton: true, - })) - .await - .unwrap(); + ctx.create_collection( + &site_id, + "Settings", + "settings", + serde_json::json!({"fields":[{"name":"site_name","type":"text"}]}), + true, + ) + .await; (ctx, site_id, token) } #[tokio::test] async fn test_get_singleton() { - let (ctx, _site_id, token) = setup().await; + let (ctx, site_id, token) = setup().await; let channel = ctx.connect().await; let mut client = SingletonServiceClient::with_interceptor(channel, auth_interceptor(&token)); let resp = client .get_singleton(tonic::Request::new(GetSingletonRequest { + site_id: site_id.clone(), + include_drafts: true, slug: "settings".into(), })) .await @@ -45,15 +42,17 @@ async fn test_get_singleton() { #[tokio::test] async fn test_update_singleton() { - let (ctx, _site_id, token) = setup().await; + let (ctx, site_id, token) = setup().await; let channel = ctx.connect().await; let mut client = SingletonServiceClient::with_interceptor(channel, auth_interceptor(&token)); let resp = client .update_singleton(tonic::Request::new(UpdateSingletonRequest { + site_id: site_id.clone(), slug: "settings".into(), - data: r#"{"site_name":"My Site"}"#.into(), + data: content(r#"{"site_name":"My Site"}"#), change_summary: None, + ..Default::default() })) .await .unwrap() @@ -61,6 +60,5 @@ async fn test_update_singleton() { assert_eq!(resp.slug, "settings"); assert!(resp.data.is_some()); - let data = resp.data.unwrap(); - assert!(data.contains("My Site")); + assert!(content_json(&resp.data).to_string().contains("My Site")); } diff --git a/apps/backend/tests/grpc/sites_tests.rs b/apps/backend/tests/grpc/sites_tests.rs index 101c779f..85028058 100644 --- a/apps/backend/tests/grpc/sites_tests.rs +++ b/apps/backend/tests/grpc/sites_tests.rs @@ -1,5 +1,5 @@ use cms::grpc::cms::v1::site_service_client::SiteServiceClient; -use cms::grpc::cms::v1::{GetSiteRequest, UpdateSiteRequest}; +use cms::grpc::cms::v1::{GetSiteRequest, ListSitesRequest}; use crate::common::{GrpcTestContext, grpc::auth_interceptor}; @@ -25,24 +25,50 @@ async fn test_get_site() { assert_eq!(resp.id, site_id); assert_eq!(resp.name, "Test Site"); - assert_eq!(resp.storage_provider, "filesystem"); } #[tokio::test] -async fn test_update_site() { +async fn test_list_sites() { let (ctx, site_id, token) = setup().await; let channel = ctx.connect().await; let mut client = SiteServiceClient::with_interceptor(channel, auth_interceptor(&token)); let resp = client - .update_site(tonic::Request::new(UpdateSiteRequest { - site_id: site_id.clone(), - name: Some("Updated Site".into()), - })) + .list_sites(tonic::Request::new(ListSitesRequest {})) .await .unwrap() .into_inner(); - assert_eq!(resp.id, site_id); - assert_eq!(resp.name, "Updated Site"); + assert!(resp.sites.iter().any(|site| site.id == site_id)); +} + +#[tokio::test] +async fn future_expiry_and_typed_timestamps_work_with_both_database_backends() { + use serde_json::{Value, json}; + let ctx = crate::common::GrpcTestContext::start().await; + let (site, _) = ctx.setup_site_and_token().await; + let http = reqwest::Client::new(); + let response = http + .post(format!("{}/api/auth/login", ctx.rest_base_url)) + .json(&json!({"email":"admin@cms.local","password":"admin"})) + .send() + .await + .unwrap(); + let (session, csrf) = crate::common::auth::extract_cookies(&response); + let response = http.post(format!("{}/api/dashboard/sites/{site}/tokens",ctx.rest_base_url)) + .headers(crate::common::auth::auth_header(&session,&csrf)) + .json(&json!({"name":"Future","scopes":["site.read"],"expires_at":(chrono::Utc::now()+chrono::Duration::hours(1)).to_rfc3339()})).send().await.unwrap(); + assert_eq!(response.status(), 201); + let credential: Value = response.json().await.unwrap(); + let authorization = format!("bearer {}", credential["token"].as_str().unwrap()); + let mut client = cms::grpc::cms::v1::site_service_client::SiteServiceClient::new(ctx.connect().await); + let mut request = tonic::Request::new(cms::grpc::cms::v1::GetSiteRequest { site_id: site.clone() }); + request + .metadata_mut() + .insert("authorization", authorization.parse().unwrap()); + let response = client.get_site(request).await.unwrap().into_inner(); + assert_eq!(response.id, site); + let created = response.created_at.expect("Database timestamp was omitted"); + assert!((chrono::Utc::now().timestamp() - created.seconds).abs() < 60); + assert!(response.updated_at.is_some()); } diff --git a/apps/backend/tests/grpc/webhooks_tests.rs b/apps/backend/tests/grpc/webhooks_tests.rs deleted file mode 100644 index a2ca0072..00000000 --- a/apps/backend/tests/grpc/webhooks_tests.rs +++ /dev/null @@ -1,267 +0,0 @@ -use cms::grpc::cms::v1::webhook_service_client::WebhookServiceClient; -use cms::grpc::cms::v1::{ - CreateWebhookRequest, DeleteWebhookRequest, GetWebhookRequest, ListWebhookDeliveriesRequest, ListWebhooksRequest, - TriggerWebhookRequest, UpdateWebhookRequest, -}; -use wiremock::matchers::method; -use wiremock::{Mock, MockServer, ResponseTemplate}; - -use crate::common::{GrpcTestContext, grpc::auth_interceptor}; - -async fn setup() -> (GrpcTestContext, String, String) { - let ctx = GrpcTestContext::start().await; - let (site_id, token) = ctx.setup_site_and_token().await; - (ctx, site_id, token) -} - -#[tokio::test] -async fn test_create_webhook() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let resp = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Test Webhook".into(), - url: "https://example.com/hook".into(), - headers: [("X-Custom".into(), "value".into())].into(), - })) - .await - .unwrap() - .into_inner(); - - assert_eq!(resp.label, "Test Webhook"); - assert_eq!(resp.url, "https://example.com/hook"); - assert_eq!(resp.site_id, site_id); - assert!(resp.headers.contains_key("X-Custom")); - assert!(!resp.id.is_empty()); -} - -#[tokio::test] -async fn test_get_webhook() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Get Webhook".into(), - url: "https://example.com/get".into(), - headers: Default::default(), - })) - .await - .unwrap() - .into_inner(); - - let fetched = client - .get_webhook(tonic::Request::new(GetWebhookRequest { - site_id: site_id.clone(), - webhook_id: created.id.clone(), - })) - .await - .unwrap() - .into_inner(); - - assert_eq!(fetched.id, created.id); - assert_eq!(fetched.label, "Get Webhook"); -} - -#[tokio::test] -async fn test_list_webhooks() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let _created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Hook 1".into(), - url: "https://example.com/h1".into(), - headers: Default::default(), - })) - .await - .unwrap(); - - let _created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Hook 2".into(), - url: "https://example.com/h2".into(), - headers: Default::default(), - })) - .await - .unwrap(); - - let resp = client - .list_webhooks(tonic::Request::new(ListWebhooksRequest { - site_id: site_id.clone(), - })) - .await - .unwrap() - .into_inner(); - - assert_eq!(resp.webhooks.len(), 2); -} - -#[tokio::test] -async fn test_update_webhook() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Old Hook".into(), - url: "https://example.com/old".into(), - headers: Default::default(), - })) - .await - .unwrap() - .into_inner(); - - let mut new_headers = std::collections::HashMap::new(); - new_headers.insert("X-Updated".into(), "yes".into()); - - let updated = client - .update_webhook(tonic::Request::new(UpdateWebhookRequest { - site_id: site_id.clone(), - webhook_id: created.id, - label: Some("New Hook".into()), - url: Some("https://example.com/new".into()), - headers: new_headers, - })) - .await - .unwrap() - .into_inner(); - - assert_eq!(updated.label, "New Hook"); - assert_eq!(updated.url, "https://example.com/new"); - assert!(updated.headers.contains_key("X-Updated")); -} - -#[tokio::test] -async fn test_delete_webhook() { - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Delete Me".into(), - url: "https://example.com/delete".into(), - headers: Default::default(), - })) - .await - .unwrap() - .into_inner(); - - let resp = client - .delete_webhook(tonic::Request::new(DeleteWebhookRequest { - site_id: site_id.clone(), - webhook_id: created.id, - })) - .await - .unwrap() - .into_inner(); - - assert!(resp.success); - - let result = client - .list_webhooks(tonic::Request::new(ListWebhooksRequest { - site_id: site_id.clone(), - })) - .await - .unwrap() - .into_inner(); - - assert!(result.webhooks.is_empty()); -} - -#[tokio::test] -async fn test_trigger_webhook() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200)) - .expect(1) - .mount(&mock_server) - .await; - - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Trigger Me".into(), - url: mock_server.uri(), - headers: Default::default(), - })) - .await - .unwrap() - .into_inner(); - - let created_id = created.id.clone(); - let resp = client - .trigger_webhook(tonic::Request::new(TriggerWebhookRequest { - site_id: site_id.clone(), - webhook_id: created.id, - })) - .await - .unwrap() - .into_inner(); - - assert!(!resp.id.is_empty()); - assert_eq!(resp.webhook_id, created_id); - assert_eq!(resp.status, "success"); -} - -#[tokio::test] -async fn test_list_webhook_deliveries() { - let mock_server = MockServer::start().await; - Mock::given(method("POST")) - .respond_with(ResponseTemplate::new(200)) - .mount(&mock_server) - .await; - - let (ctx, site_id, token) = setup().await; - let channel = ctx.connect().await; - let mut client = WebhookServiceClient::with_interceptor(channel, auth_interceptor(&token)); - - let created = client - .create_webhook(tonic::Request::new(CreateWebhookRequest { - site_id: site_id.clone(), - label: "Deliveries".into(), - url: mock_server.uri(), - headers: Default::default(), - })) - .await - .unwrap() - .into_inner(); - - let _triggered = client - .trigger_webhook(tonic::Request::new(TriggerWebhookRequest { - site_id: site_id.clone(), - webhook_id: created.id.clone(), - })) - .await - .unwrap(); - - let resp = client - .list_webhook_deliveries(tonic::Request::new(ListWebhookDeliveriesRequest { - site_id: site_id.clone(), - webhook_id: created.id, - page: 1, - per_page: 10, - })) - .await - .unwrap() - .into_inner(); - - assert!(!resp.items.is_empty()); - assert!(resp.total >= 1); -} diff --git a/apps/backend/tests/mcp/collection_tests.rs b/apps/backend/tests/mcp/collection_tests.rs deleted file mode 100644 index 8dbc2df7..00000000 --- a/apps/backend/tests/mcp/collection_tests.rs +++ /dev/null @@ -1,285 +0,0 @@ -use crate::common::mcp::*; - -#[tokio::test] -async fn test_list_collections_empty() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_collections", - serde_json::json!({}), - ) - .await; - let data = mcp_tool_json(&result); - - assert!(data.is_array(), "Expected array, got: {}", data); - assert!(data.as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_create_collection() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_collection", - serde_json::json!({ - "name": "Posts", - "slug": "posts", - "definition": {"fields": [{"name": "title", "type": "text", "required": true}]}, - }), - ) - .await; - let col = mcp_tool_json(&result); - - assert_eq!(col["name"].as_str().unwrap(), "Posts"); - assert_eq!(col["slug"].as_str().unwrap(), "posts"); - assert!(!col["id"].as_str().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_create_collection_auto_slug() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_collection", - serde_json::json!({ - "name": "My Collection", - "definition": {"fields": []}, - }), - ) - .await; - let col = mcp_tool_json(&result); - - assert_eq!(col["slug"].as_str().unwrap(), "my-collection"); -} - -#[tokio::test] -async fn test_get_collection() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_collection", - serde_json::json!({"slug": "posts"}), - ) - .await; - let col = mcp_tool_json(&result); - - assert_eq!(col["name"].as_str().unwrap(), "Posts"); - assert_eq!(col["slug"].as_str().unwrap(), "posts"); -} - -#[tokio::test] -async fn test_get_collection_not_found() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_collection", - serde_json::json!({"slug": "nonexistent"}), - ) - .await; - assert!( - mcp_is_error(&result), - "Should return isError for non-existent collection" - ); -} - -#[tokio::test] -async fn test_update_collection_name() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_collection", - serde_json::json!({ - "slug": "posts", - "name": "Articles" - }), - ) - .await; - let col = mcp_tool_json(&result); - assert_eq!(col["name"].as_str().unwrap(), "Articles"); -} - -#[tokio::test] -async fn test_update_collection_definition() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_collection", - serde_json::json!({ - "slug": "posts", - "definition": {"fields": [ - {"name": "title", "type": "text", "required": true}, - {"name": "body", "type": "textarea", "required": false} - ]} - }), - ) - .await; - assert!(!mcp_is_error(&result), "update_collection should succeed"); -} - -#[tokio::test] -async fn test_delete_collection() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "delete_collection", - serde_json::json!({"slug": "posts"}), - ) - .await; - let data = mcp_tool_json(&result); - assert!(data["deleted"].as_bool().unwrap()); - - let list = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_collections", - serde_json::json!({}), - ) - .await; - let list_data = mcp_tool_json(&list); - assert!(list_data.as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_delete_collection_not_found() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "delete_collection", - serde_json::json!({"slug": "nonexistent"}), - ) - .await; - assert!( - mcp_is_error(&result), - "Should return isError for non-existent collection" - ); -} - -#[tokio::test] -async fn test_create_collection_requires_admin() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_read_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_collection", - serde_json::json!({ - "name": "Posts", - "definition": {"fields": []}, - }), - ) - .await; - assert!(mcp_is_error(&result), "Viewer should not create collection"); -} - -#[tokio::test] -async fn test_collection_full_lifecycle() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; - let col = mcp_tool_json(&result); - let col_id = col["id"].as_str().unwrap(); - assert!(!col_id.is_empty()); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_collection", - serde_json::json!({"slug": "posts"}), - ) - .await; - let fetched = mcp_tool_json(&result); - assert_eq!(fetched["id"].as_str().unwrap(), col_id); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_collection", - serde_json::json!({"slug": "posts", "name": "Articles"}), - ) - .await; - let updated = mcp_tool_json(&result); - assert_eq!(updated["name"].as_str().unwrap(), "Articles"); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_collections", - serde_json::json!({}), - ) - .await; - let list = mcp_tool_json(&result); - assert_eq!(list.as_array().unwrap().len(), 1); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "delete_collection", - serde_json::json!({"slug": "posts"}), - ) - .await; - let data = mcp_tool_json(&result); - assert!(data["deleted"].as_bool().unwrap()); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_collections", - serde_json::json!({}), - ) - .await; - let list = mcp_tool_json(&result); - assert!(list.as_array().unwrap().is_empty()); -} diff --git a/apps/backend/tests/mcp/contract_tests.rs b/apps/backend/tests/mcp/contract_tests.rs new file mode 100644 index 00000000..40789eb3 --- /dev/null +++ b/apps/backend/tests/mcp/contract_tests.rs @@ -0,0 +1,100 @@ +use crate::common::mcp::*; + +#[tokio::test] +async fn entry_workflow_uses_modern_tools_and_structured_output() { + let server = start_mcp_server().await; + let (site_id, token) = setup_site_token(&server).await; + let collection = create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; + let collection_id = mcp_tool_json(&collection)["id"].as_str().unwrap().to_owned(); + let created = create_test_entry( + &server.base_url, + &token, + &site_id, + &collection_id, + "hello", + serde_json::json!({"title": "Hello"}), + ) + .await; + let entry = mcp_tool_json(&created); + let entry_id = entry["id"].as_str().unwrap(); + + let fetched = mcp_call_site_tool( + &server.base_url, + &token, + &site_id, + "get_entry", + serde_json::json!({"id": entry_id, "include_drafts": true}), + ) + .await; + assert!(!mcp_is_error(&fetched)); + assert_eq!(mcp_tool_json(&fetched)["slug"], "hello"); + + let listed = mcp_call_site_tool( + &server.base_url, + &token, + &site_id, + "list_entries", + serde_json::json!({"include_drafts": true}), + ) + .await; + assert_eq!(mcp_tool_json(&listed)["items"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn site_discovery_and_schema_resources_are_available() { + let server = start_mcp_server().await; + let (site_id, token) = setup_site_token(&server).await; + let sites = mcp_tool_json(&mcp_call_tool(&server.base_url, &token, "list_sites", serde_json::json!({})).await); + assert_eq!(sites["sites"][0]["id"], site_id); + let schema = mcp_read_resource(&server.base_url, &token, &format!("cms://{site_id}/schema")).await; + assert!(schema["contents"].is_array()); +} + +#[tokio::test] +async fn update_publication_requires_separate_scope() { + let server = start_mcp_server().await; + let (site_id, token) = setup_site_token(&server).await; + let collection = create_test_collection(&server.base_url, &token, &site_id, "Posts", "posts").await; + let collection = mcp_tool_json(&collection); + let created = create_test_entry( + &server.base_url, + &token, + &site_id, + collection["id"].as_str().unwrap(), + "draft", + serde_json::json!({"title":"Draft"}), + ) + .await; + let entry = mcp_tool_json(&created); + let http = crate::common::client::http_client(); + let response = server.login_user(&http, "admin@cms.local", "admin").await; + let (session, csrf) = crate::common::auth::extract_cookies(&response); + let response = http + .post(format!("{}/api/dashboard/sites/{site_id}/tokens", server.base_url)) + .headers(crate::common::auth::auth_header(&session, &csrf)) + .json(&serde_json::json!({"name":"writer","scopes":["content.write","mcp.use"]})) + .send() + .await + .unwrap(); + assert!(response.status().is_success()); + let credential: serde_json::Value = response.json().await.unwrap(); + let denied = mcp_call_site_tool( + &server.base_url, + credential["token"].as_str().unwrap(), + &site_id, + "update_entry", + serde_json::json!({"id":entry["id"],"published":true}), + ) + .await; + assert!(mcp_is_error(&denied)); + assert_eq!(mcp_tool_json(&denied)["error"]["code"], "forbidden"); + let fetched = mcp_call_site_tool( + &server.base_url, + &token, + &site_id, + "get_entry", + serde_json::json!({"id":entry["id"],"include_drafts":true}), + ) + .await; + assert_eq!(mcp_tool_json(&fetched)["status"], "draft"); +} diff --git a/apps/backend/tests/mcp/entry_tests.rs b/apps/backend/tests/mcp/entry_tests.rs index 0f4cd435..3b776cbc 100644 --- a/apps/backend/tests/mcp/entry_tests.rs +++ b/apps/backend/tests/mcp/entry_tests.rs @@ -71,8 +71,8 @@ async fn test_get_entry() { &server.base_url, &token, &site_id, - "publish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": true}), ) .await; let wrapped = serde_json::json!({"result": result}); @@ -187,8 +187,8 @@ async fn test_publish_entry() { &server.base_url, &token, &site_id, - "publish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": true}), ) .await; let entry = mcp_tool_json(&result); @@ -216,8 +216,8 @@ async fn test_unpublish_entry() { &server.base_url, &token, &site_id, - "publish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": true}), ) .await; let wrapped = serde_json::json!({"result": result}); @@ -227,8 +227,8 @@ async fn test_unpublish_entry() { &server.base_url, &token, &site_id, - "unpublish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": false}), ) .await; let entry = mcp_tool_json(&result); @@ -317,8 +317,8 @@ async fn test_entry_full_lifecycle() { &server.base_url, &token, &site_id, - "publish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": true}), ) .await; let wrapped = serde_json::json!({"result": result}); @@ -351,8 +351,8 @@ async fn test_entry_full_lifecycle() { &server.base_url, &token, &site_id, - "unpublish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": false}), ) .await; let wrapped = serde_json::json!({"result": result}); @@ -405,7 +405,7 @@ async fn test_list_entries_filter_by_collection() { &token, &site_id, "list_entries", - serde_json::json!({"collection_slug": "posts", "published_only": false}), + serde_json::json!({"collection_slug": "posts", "include_drafts": true}), ) .await; let data = mcp_tool_json(&result); @@ -434,8 +434,8 @@ async fn test_list_entries_with_search() { &server.base_url, &token, &site_id, - "publish_entry", - serde_json::json!({"id": entry_id}), + "set_entry_publication", + serde_json::json!({"id": entry_id, "published": true}), ) .await; let wrapped = serde_json::json!({"result": publish_result}); @@ -446,7 +446,7 @@ async fn test_list_entries_with_search() { &token, &site_id, "list_entries", - serde_json::json!({"search": "Unique", "published_only": false}), + serde_json::json!({"search": "Unique", "include_drafts": true}), ) .await; let data = mcp_tool_json(&result); diff --git a/apps/backend/tests/mcp/file_tests.rs b/apps/backend/tests/mcp/file_tests.rs index 82eda8f6..80c1d9f4 100644 --- a/apps/backend/tests/mcp/file_tests.rs +++ b/apps/backend/tests/mcp/file_tests.rs @@ -29,7 +29,7 @@ async fn test_get_file_not_found() { } #[tokio::test] -async fn test_create_upload_url() { +async fn test_create_file_upload() { let server = start_mcp_server().await; let (site_id, token) = setup_site_token(&server).await; @@ -37,7 +37,7 @@ async fn test_create_upload_url() { &server.base_url, &token, &site_id, - "create_upload_url", + "create_file_upload", serde_json::json!({ "filename": "test.txt", "content_type": "text/plain", @@ -58,7 +58,7 @@ async fn test_create_upload_url() { /// End-to-end: mint an upload URL via MCP, PUT the bytes to it, and verify the /// file exists (both via the MCP get_file tool and the returned record). #[tokio::test] -async fn test_create_upload_url_then_put_uploads_file() { +async fn test_create_file_upload_then_put_uploads_file() { let server = start_mcp_server().await; let (site_id, token) = setup_site_token(&server).await; @@ -66,7 +66,7 @@ async fn test_create_upload_url_then_put_uploads_file() { &server.base_url, &token, &site_id, - "create_upload_url", + "create_file_upload", serde_json::json!({ "filename": "e2e.txt", "content_type": "text/plain", @@ -109,7 +109,7 @@ async fn test_create_upload_url_then_put_uploads_file() { } #[tokio::test] -async fn test_create_upload_url_requires_editor() { +async fn test_create_file_upload_requires_editor() { let server = start_mcp_server().await; let (site_id, token) = setup_site_read_token(&server).await; @@ -117,7 +117,7 @@ async fn test_create_upload_url_requires_editor() { &server.base_url, &token, &site_id, - "create_upload_url", + "create_file_upload", serde_json::json!({"filename": "x.txt", "content_type": "text/plain"}), ) .await; @@ -125,7 +125,7 @@ async fn test_create_upload_url_requires_editor() { } #[tokio::test] -async fn test_create_upload_url_rejects_disallowed_content_type() { +async fn test_create_file_upload_rejects_disallowed_content_type() { let server = start_mcp_server().await; let (site_id, token) = setup_site_token(&server).await; @@ -133,7 +133,7 @@ async fn test_create_upload_url_rejects_disallowed_content_type() { &server.base_url, &token, &site_id, - "create_upload_url", + "create_file_upload", serde_json::json!({"filename": "x.exe", "content_type": "application/x-executable"}), ) .await; diff --git a/apps/backend/tests/mcp/main.rs b/apps/backend/tests/mcp/main.rs index 050aa844..636332d0 100644 --- a/apps/backend/tests/mcp/main.rs +++ b/apps/backend/tests/mcp/main.rs @@ -1,12 +1,12 @@ #[path = "../common/mod.rs"] mod common; -mod collection_tests; +mod contract_tests; mod entry_tests; mod file_tests; mod protocol_tests; mod resource_tests; mod singleton_tests; -mod site_tests; mod stdio_proxy_tests; -mod webhook_tests; + +mod site_tests; diff --git a/apps/backend/tests/mcp/protocol_tests.rs b/apps/backend/tests/mcp/protocol_tests.rs index 52b607dd..aa2e6b5b 100644 --- a/apps/backend/tests/mcp/protocol_tests.rs +++ b/apps/backend/tests/mcp/protocol_tests.rs @@ -7,8 +7,10 @@ async fn test_initialize_returns_server_info() { let info = mcp_initialize(&server.base_url, &token).await; - let server_info = info.get("serverInfo").expect("missing serverInfo"); - assert_eq!(server_info["name"].as_str().unwrap(), "cms"); + let server_info = info["_meta"]["io.modelcontextprotocol/serverInfo"] + .as_object() + .expect("missing namespaced serverInfo"); + assert_eq!(server_info["name"].as_str().unwrap(), "velopulent-cms"); assert!(server_info.get("version").is_some()); let capabilities = info.get("capabilities").expect("missing capabilities"); @@ -17,23 +19,59 @@ async fn test_initialize_returns_server_info() { } #[tokio::test] -async fn test_initialize_reflects_protocol_version() { +async fn test_discover_advertises_modern_protocol() { let server = start_mcp_server().await; let (_, token) = setup_site_token(&server).await; - let resp = mcp_request( - &server.base_url, - &token, - "initialize", - Some(serde_json::json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "test", "version": "1.0"} - })), - ) - .await; - let result = mcp_result(&resp); - assert_eq!(result["protocolVersion"].as_str().unwrap(), "2024-11-05"); + let result = mcp_initialize(&server.base_url, &token).await; + let versions = result["supportedVersions"].as_array().unwrap(); + for version in ["2025-06-18", "2025-11-25", "2026-07-28"] { + assert!( + versions.contains(&serde_json::json!(version)), + "{version} not advertised" + ); + } +} + +#[tokio::test] +async fn session_lifecycle_clients_initialize_and_call_tools() { + let server = start_mcp_server().await; + let (_, token) = setup_site_token(&server).await; + let client = crate::common::client::http_client(); + let post = |version: &'static str, body: serde_json::Value| { + client + .post(format!("{}/mcp", server.base_url)) + .bearer_auth(&token) + .header("accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", version) + .json(&body) + .send() + }; + for version in ["2025-06-18", "2025-11-25"] { + let initialized: serde_json::Value = post( + version, + serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{ + "protocolVersion": version, "capabilities": {}, "clientInfo": {"name":"test","version":"1"}}}), + ) + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(initialized["result"]["protocolVersion"], version); + let tools: serde_json::Value = post( + version, + serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/list"}), + ) + .await + .unwrap() + .json() + .await + .unwrap(); + let tools = tools["result"]["tools"].as_array().expect("tools/list result"); + assert!(tools.iter().all(|tool| tool["inputSchema"].get("$schema").is_none())); + assert!(tools.iter().all(|tool| tool["outputSchema"]["type"] == "object")); + } } #[tokio::test] @@ -43,40 +81,28 @@ async fn test_list_tools_returns_all_tools() { let tools = mcp_list_tools(&server.base_url, &token).await; - assert!(tools.len() >= 28, "Expected at least 28 tools, got {}", tools.len()); + assert!(tools.len() >= 15, "Expected at least 15 tools, got {}", tools.len()); let names: Vec<&str> = tools.iter().filter_map(|t| t["name"].as_str()).collect(); assert!(names.contains(&"get_site")); - assert!(names.contains(&"update_site")); - assert!(names.contains(&"list_collections")); - assert!(names.contains(&"get_collection")); - assert!(names.contains(&"create_collection")); - assert!(names.contains(&"update_collection")); - assert!(names.contains(&"delete_collection")); assert!(names.contains(&"list_entries")); assert!(names.contains(&"get_entry")); assert!(names.contains(&"create_entry")); assert!(names.contains(&"update_entry")); assert!(names.contains(&"delete_entry")); - assert!(names.contains(&"publish_entry")); - assert!(names.contains(&"unpublish_entry")); + assert!(names.contains(&"set_entry_publication")); assert!(names.contains(&"list_revisions")); + assert!(names.contains(&"get_revision")); assert!(names.contains(&"restore_revision")); assert!(names.contains(&"list_singletons")); assert!(names.contains(&"get_singleton")); assert!(names.contains(&"update_singleton")); assert!(names.contains(&"list_files")); assert!(names.contains(&"get_file")); - assert!(names.contains(&"create_upload_url")); + assert!(names.contains(&"create_file_upload")); assert!(names.contains(&"delete_file")); assert!(names.contains(&"restore_file")); - assert!(names.contains(&"list_webhooks")); - assert!(names.contains(&"get_webhook")); - assert!(names.contains(&"create_webhook")); - assert!(names.contains(&"update_webhook")); - assert!(names.contains(&"trigger_webhook")); - assert!(names.contains(&"delete_webhook")); - assert!(names.contains(&"list_webhook_deliveries")); + assert!(!names.iter().any(|name| name.contains("webhook"))); } #[tokio::test] @@ -98,13 +124,6 @@ async fn test_tool_schemas_are_valid() { "tool '{}' inputSchema must have type 'object'", name ); - assert!( - schema.get("$schema").is_none(), - "tool '{}' inputSchema has $schema", - name - ); - assert!(schema.get("title").is_none(), "tool '{}' inputSchema has title", name); - if let Some(props) = schema.get("properties").and_then(|p| p.as_object()) { for (key, prop) in props { assert!( @@ -176,60 +195,92 @@ async fn test_auth_missing_token_returns_401() { #[tokio::test] async fn test_auth_wrong_token_type_returns_401() { let server = start_mcp_server().await; - - let resp = mcp_request(&server.base_url, "not-a-cms-token", "tools/list", None).await; - - let error = resp.get("error"); - assert!(error.is_some(), "Expected error for wrong token type, got: {}", resp); - let error = error.unwrap(); - assert_eq!( - error["code"].as_i64().unwrap(), - -32000 + 401, - "Expected synthesized 401 error code, got: {}", - error["code"] - ); - let msg = error["message"].as_str().unwrap(); - assert!( - msg.contains("MCP requires a VCMS access token"), - "Expected auth error message, got: {}", - msg - ); + let response = mcp_request(&server.base_url, "not-a-cms-token", "tools/list", None).await; + assert_eq!(response["http_status"], 401); + assert_eq!(response["transport_error"]["error"], "MCP requires a VCMS access token"); } #[tokio::test] async fn test_auth_invalid_token_returns_401() { let server = start_mcp_server().await; - - let resp = mcp_request(&server.base_url, "vcms_site_invalid_token_abc123", "tools/list", None).await; - - let error = resp.get("error"); - assert!(error.is_some(), "Expected error for invalid token, got: {}", resp); - let msg = error.unwrap()["message"].as_str().unwrap(); - assert!( - msg.contains("Invalid access token") || msg.contains("error"), - "Expected auth error message, got: {}", - msg - ); + let response = mcp_request(&server.base_url, "vcms_site_invalid_token_abc123", "tools/list", None).await; + assert_eq!(response["http_status"], 401); + assert_eq!(response["transport_error"]["error"], "Invalid access token"); } #[tokio::test] async fn test_auth_instance_token_rejected() { let server = start_mcp_server().await; - - let resp = mcp_request( + let response = mcp_request( &server.base_url, "cms_inst_abcdefghijklmnopqrstuvwxyz", "tools/list", None, ) .await; + assert_eq!(response["http_status"], 401); + assert_eq!(response["transport_error"]["error"], "MCP requires a VCMS access token"); +} - let error = resp.get("error"); - assert!(error.is_some(), "Instance token should be rejected, got: {}", resp); - let msg = error.unwrap()["message"].as_str().unwrap(); - assert!( - msg.contains("MCP requires a VCMS access token"), - "Expected MCP token error, got: {}", - msg - ); +#[tokio::test] +async fn unsupported_capabilities_do_not_inherit_successful_sdk_defaults() { + let server = start_mcp_server().await; + let (_, token) = setup_site_token(&server).await; + for method in ["prompts/list", "completion/complete"] { + let params = if method == "completion/complete" { + Some( + serde_json::json!({"ref":{"type":"ref/prompt","name":"unsupported"},"argument":{"name":"value","value":"x"}}), + ) + } else { + None + }; + let response = mcp_request(&server.base_url, &token, method, params).await; + assert_eq!( + response["error"]["code"], -32601, + "Undeclared capability handled: {response}" + ); + } + let response = mcp_request(&server.base_url, &token, "tools/list", None).await; + assert_eq!(response["result"]["ttlMs"], 10_000); + assert_eq!(response["result"]["cacheScope"], "public"); +} + +#[tokio::test] +async fn default_origin_policy_accepts_same_origin_and_rejects_other_origins() { + let server = start_mcp_server().await; + let (_, token) = setup_site_token(&server).await; + let body = serde_json::json!({"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28", + "io.modelcontextprotocol/clientCapabilities":{}}}}); + let client = crate::common::client::http_client(); + for (origin, status) in [(&server.base_url, 200), (&"http://evil.example".to_owned(), 403)] { + let response = client + .post(format!("{}/mcp", server.base_url)) + .bearer_auth(&token) + .header("origin", origin) + .header("accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "tools/list") + .json(&body) + .send() + .await + .unwrap(); + assert_eq!(response.status().as_u16(), status, "Origin policy failed for {origin}"); + } +} + +#[tokio::test] +async fn unknown_resource_uses_native_not_found_code_and_uri() { + let server = start_mcp_server().await; + let (_, token) = setup_site_token(&server).await; + let uri = "test://missing-resource"; + let response = mcp_request( + &server.base_url, + &token, + "resources/read", + Some(serde_json::json!({"uri":uri})), + ) + .await; + assert_eq!(response["error"]["code"], -32602); + assert_eq!(response["error"]["data"]["uri"], uri); } diff --git a/apps/backend/tests/mcp/singleton_tests.rs b/apps/backend/tests/mcp/singleton_tests.rs index d272559c..da27be85 100644 --- a/apps/backend/tests/mcp/singleton_tests.rs +++ b/apps/backend/tests/mcp/singleton_tests.rs @@ -1,24 +1,8 @@ use crate::common::mcp::*; -async fn setup_singleton(base_url: &str, token: &str, site_id: &str, slug: &str) { - let result = mcp_call_site_tool( - base_url, - token, - site_id, - "create_collection", - serde_json::json!({ - "name": "Settings", - "slug": slug, - "definition": {"fields": [{"name": "site_title", "type": "text"}]}, - "is_singleton": true, - }), - ) - .await; - assert!( - !mcp_is_error(&result), - "setup_singleton: create_collection failed: {}", - mcp_tool_text(&result) - ); +async fn setup_singleton(base_url: &str, _token: &str, site_id: &str, slug: &str) { + let result = create_test_singleton(base_url, site_id, "Settings", slug).await; + assert!(result["id"].as_str().is_some()); } #[tokio::test] @@ -36,8 +20,7 @@ async fn test_list_singletons_empty() { .await; let data = mcp_tool_json(&result); - assert!(data.is_array()); - assert!(data.as_array().unwrap().is_empty()); + assert!(data["singletons"].as_array().unwrap().is_empty()); } #[tokio::test] @@ -52,7 +35,7 @@ async fn test_get_singleton() { &token, &site_id, "get_singleton", - serde_json::json!({"slug": "settings"}), + serde_json::json!({"include_drafts": true, "slug": "settings"}), ) .await; assert!(!mcp_is_error(&result), "get_singleton should succeed"); @@ -76,7 +59,7 @@ async fn test_update_singleton_data() { }), ) .await; - assert!(!mcp_is_error(&result), "update_singleton should succeed"); + assert!(!mcp_is_error(&result), "update_singleton should succeed: {result}"); } #[tokio::test] @@ -95,5 +78,5 @@ async fn test_list_singletons_after_create() { ) .await; let data = mcp_tool_json(&result); - assert_eq!(data.as_array().unwrap().len(), 1); + assert_eq!(data["singletons"].as_array().unwrap().len(), 1); } diff --git a/apps/backend/tests/mcp/site_tests.rs b/apps/backend/tests/mcp/site_tests.rs index d3603ad0..84470de1 100644 --- a/apps/backend/tests/mcp/site_tests.rs +++ b/apps/backend/tests/mcp/site_tests.rs @@ -12,24 +12,6 @@ async fn test_get_site() { assert_eq!(site["name"].as_str().unwrap(), "Test Site"); } -#[tokio::test] -async fn test_update_site_name() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_site", - serde_json::json!({"name": "Updated Site"}), - ) - .await; - let site = mcp_tool_json(&result); - - assert_eq!(site["name"].as_str().unwrap(), "Updated Site"); -} - #[tokio::test] async fn test_get_site_works_with_read_token() { let server = start_mcp_server().await; @@ -43,17 +25,21 @@ async fn test_get_site_works_with_read_token() { } #[tokio::test] -async fn test_update_site_requires_admin() { +async fn site_management_tools_are_not_exposed() { let server = start_mcp_server().await; - let (site_id, token) = setup_site_read_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, + let (_, token) = setup_site_read_token(&server).await; + let tools = mcp_list_tools(&server.base_url, &token).await; + assert!(tools.iter().any(|tool| tool["name"] == "get_site")); + for name in [ "update_site", - serde_json::json!({"name": "Should Fail"}), - ) - .await; - assert!(mcp_is_error(&result), "update_site should fail with read-only token"); + "create_collection", + "update_collection", + "delete_collection", + "create_webhook", + ] { + assert!( + !tools.iter().any(|tool| tool["name"] == name), + "Public management tool exposed: {name}" + ); + } } diff --git a/apps/backend/tests/mcp/stdio_proxy_tests.rs b/apps/backend/tests/mcp/stdio_proxy_tests.rs index 58c2d1a1..5f053654 100644 --- a/apps/backend/tests/mcp/stdio_proxy_tests.rs +++ b/apps/backend/tests/mcp/stdio_proxy_tests.rs @@ -58,15 +58,6 @@ impl StdioClient { serde_json::from_str(&line).unwrap_or_else(|error| panic!("stdout was not pure MCP JSON: {line:?}: {error}")) } - async fn notify(&mut self, method: &str) { - let request = json!({"jsonrpc": "2.0", "method": method}); - self.stdin - .write_all(format!("{request}\n").as_bytes()) - .await - .expect("write MCP notification"); - self.stdin.flush().await.expect("flush MCP notification"); - } - async fn close(mut self) -> std::process::ExitStatus { drop(self.stdin); let mut stderr = self.child.stderr.take().expect("child stderr"); @@ -78,19 +69,7 @@ impl StdioClient { } async fn initialize(client: &mut StdioClient) -> Value { - let response = client - .request( - 1, - "initialize", - Some(json!({ - "protocolVersion": "2024-11-05", - "capabilities": {}, - "clientInfo": {"name": "stdio-proxy-test", "version": "1.0"} - })), - ) - .await; - client.notify("notifications/initialized").await; - response + client.request(1, "server/discover", Some(json!({}))).await } #[tokio::test] @@ -100,7 +79,11 @@ async fn stdio_proxy_round_trips_tools_and_calls_through_the_server() { let mut client = StdioClient::start(&server.base_url, &token).await; let init = initialize(&mut client).await; - assert_eq!(init["result"]["serverInfo"]["name"], "cms"); + assert!( + init["result"]["_meta"]["io.modelcontextprotocol/serverInfo"]["name"] == "velopulent-cms" + || init["result"]["serverInfo"]["name"] == "velopulent-cms", + "unexpected initialize response: {init}" + ); let tools = client.request(2, "tools/list", None).await; assert!( @@ -137,8 +120,8 @@ async fn stdio_proxy_enforces_token_permission() { 2, "tools/call", Some(json!({ - "name": "update_site", - "arguments": {"site_id": site_id, "name": "Forbidden"} + "name": "delete_entry", + "arguments": {"site_id": site_id, "id": "forbidden-entry"} })), ) .await; diff --git a/apps/backend/tests/mcp/webhook_tests.rs b/apps/backend/tests/mcp/webhook_tests.rs deleted file mode 100644 index 54981f27..00000000 --- a/apps/backend/tests/mcp/webhook_tests.rs +++ /dev/null @@ -1,272 +0,0 @@ -use crate::common::mcp::*; - -#[tokio::test] -async fn test_list_webhooks_empty() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_webhooks", - serde_json::json!({}), - ) - .await; - let data = mcp_tool_json(&result); - - assert!(data.is_array()); - assert!(data.as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_create_webhook() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Test Hook", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - - assert_eq!(webhook["label"].as_str().unwrap(), "Test Hook"); - assert_eq!(webhook["url"].as_str().unwrap(), "https://example.com/hook"); - assert!(!webhook["id"].as_str().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_get_webhook() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Test Hook", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - let hook_id = webhook["id"].as_str().unwrap().to_string(); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_webhook", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let fetched = mcp_tool_json(&result); - assert_eq!(fetched["label"].as_str().unwrap(), "Test Hook"); -} - -#[tokio::test] -async fn test_get_webhook_not_found() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_webhook", - serde_json::json!({"webhook_id": "nonexistent"}), - ) - .await; - assert!(mcp_is_error(&result), "Should return isError for non-existent webhook"); -} - -#[tokio::test] -async fn test_update_webhook() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Old Label", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - let hook_id = webhook["id"].as_str().unwrap().to_string(); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_webhook", - serde_json::json!({ - "webhook_id": hook_id, - "label": "New Label", - }), - ) - .await; - let updated = mcp_tool_json(&result); - assert_eq!(updated["label"].as_str().unwrap(), "New Label"); -} - -#[tokio::test] -async fn test_delete_webhook() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "To Delete", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - let hook_id = webhook["id"].as_str().unwrap().to_string(); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "delete_webhook", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let data = mcp_tool_json(&result); - assert!(data["deleted"].as_bool().unwrap()); -} - -#[tokio::test] -async fn test_list_webhook_deliveries() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Delivery Test", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - let hook_id = webhook["id"].as_str().unwrap().to_string(); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_webhook_deliveries", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let data = mcp_tool_json(&result); - assert!(data["items"].as_array().unwrap().is_empty()); -} - -#[tokio::test] -async fn test_create_webhook_requires_admin() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_read_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Should Fail", - "url": "https://example.com/hook", - }), - ) - .await; - assert!(mcp_is_error(&result), "Viewer should not create webhook"); -} - -#[tokio::test] -async fn test_webhook_full_lifecycle() { - let server = start_mcp_server().await; - let (site_id, token) = setup_site_token(&server).await; - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "create_webhook", - serde_json::json!({ - "label": "Lifecycle Hook", - "url": "https://example.com/hook", - }), - ) - .await; - let webhook = mcp_tool_json(&result); - let hook_id = webhook["id"].as_str().unwrap().to_string(); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "get_webhook", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let fetched = mcp_tool_json(&result); - assert_eq!(fetched["label"].as_str().unwrap(), "Lifecycle Hook"); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "update_webhook", - serde_json::json!({ - "webhook_id": hook_id, - "label": "Updated Hook", - }), - ) - .await; - let updated = mcp_tool_json(&result); - assert_eq!(updated["label"].as_str().unwrap(), "Updated Hook"); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "list_webhook_deliveries", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let deliveries = mcp_tool_json(&result); - assert!(deliveries["items"].as_array().unwrap().is_empty()); - - let result = mcp_call_site_tool( - &server.base_url, - &token, - &site_id, - "delete_webhook", - serde_json::json!({"webhook_id": hook_id}), - ) - .await; - let data = mcp_tool_json(&result); - assert!(data["deleted"].as_bool().unwrap()); -} diff --git a/apps/backend/tests/rest/access_tokens_tests.rs b/apps/backend/tests/rest/access_tokens_tests.rs index 45858a57..17af7994 100644 --- a/apps/backend/tests/rest/access_tokens_tests.rs +++ b/apps/backend/tests/rest/access_tokens_tests.rs @@ -122,7 +122,7 @@ async fn test_token_can_authenticate_public_api() { let api_key = token_val["token"].as_str().unwrap(); let resp = client - .get(format!("{}/api/v1/site", server.base_url)) + .get(format!("{}/api/v1/sites/{}", server.base_url, site_id)) .header("Authorization", format!("Bearer {}", api_key)) .send() .await diff --git a/apps/backend/tests/rest/collections_tests.rs b/apps/backend/tests/rest/collections_tests.rs index 1b02cf1f..6d6a155c 100644 --- a/apps/backend/tests/rest/collections_tests.rs +++ b/apps/backend/tests/rest/collections_tests.rs @@ -207,7 +207,7 @@ async fn test_public_api_collections() { let api_key = token_val["token"].as_str().unwrap(); let resp = client - .get(format!("{}/collections", server.base_url)) + .get(format!("{}/api/v1/sites/{}/collections", server.base_url, site_id)) .header("Authorization", format!("Bearer {}", api_key)) .send() .await diff --git a/apps/backend/tests/rest/files_tests.rs b/apps/backend/tests/rest/files_tests.rs index fc218909..e1e2fc45 100644 --- a/apps/backend/tests/rest/files_tests.rs +++ b/apps/backend/tests/rest/files_tests.rs @@ -39,7 +39,7 @@ async fn test_upload_file() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -67,7 +67,7 @@ async fn test_list_files() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -81,7 +81,10 @@ async fn test_list_files() { ); let resp = client - .get(format!("{}/files", server.base_url)) + .get(format!( + "{}/api/v1/sites/{}/files?include_total=true", + server.base_url, site_id + )) .headers(api_key_header(&api_key)) .send() .await @@ -107,7 +110,7 @@ async fn test_get_file() { let form = reqwest::multipart::Form::new().part("file", part); let upload_resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -117,7 +120,10 @@ async fn test_get_file() { let file_id = uploaded["id"].as_str().unwrap(); let resp = client - .get(format!("{}/files/{}", server.base_url, file_id)) + .get(format!( + "{}/api/v1/sites/{}/files/{}", + server.base_url, site_id, file_id + )) .headers(api_key_header(&api_key)) .send() .await @@ -142,7 +148,7 @@ async fn test_delete_file() { let form = reqwest::multipart::Form::new().part("file", part); let upload_resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -152,7 +158,10 @@ async fn test_delete_file() { let file_id = uploaded["id"].as_str().unwrap(); let resp = client - .delete(format!("{}/files/{}", server.base_url, file_id)) + .delete(format!( + "{}/api/v1/sites/{}/files/{}", + server.base_url, site_id, file_id + )) .headers(api_key_header(&api_key)) .send() .await @@ -175,7 +184,7 @@ async fn test_get_file_references() { let form = reqwest::multipart::Form::new().part("file", part); let upload_resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -185,7 +194,10 @@ async fn test_get_file_references() { let file_id = uploaded["id"].as_str().unwrap(); let resp = client - .get(format!("{}/files/{}/references", server.base_url, file_id)) + .get(format!( + "{}/api/v1/sites/{}/files/{}/references", + server.base_url, site_id, file_id + )) .headers(api_key_header(&api_key)) .send() .await @@ -195,7 +207,7 @@ async fn test_get_file_references() { } #[tokio::test] -async fn test_batch_delete_files() { +async fn test_public_batch_delete_is_not_exposed() { let server = TestServer::start().await; let (token, csrf, site_id) = setup(&server).await; let api_key = get_api_key(&server, &token, &csrf, &site_id).await; @@ -210,7 +222,7 @@ async fn test_batch_delete_files() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -221,16 +233,17 @@ async fn test_batch_delete_files() { } let resp = client - .post(format!("{}/files/batch-delete", server.base_url)) + .post(format!( + "{}/api/v1/sites/{}/files/batch-delete", + server.base_url, site_id + )) .headers(api_key_header(&api_key)) .json(&json!({"ids": ids})) .send() .await .unwrap(); - assert_eq!(resp.status(), 200); - let body: Value = resp.json().await.unwrap(); - assert_eq!(body["deleted"], 3); + assert_eq!(resp.status(), 405); } /// Regression: a file larger than the global 10MB body limit but within the @@ -253,7 +266,7 @@ async fn test_upload_file_above_global_body_limit() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -288,7 +301,7 @@ async fn test_upload_file_exceeds_max_size() { let form = reqwest::multipart::Form::new().part("file", part); let result = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -326,7 +339,7 @@ async fn test_upload_file_invalid_mime_type() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() @@ -543,11 +556,11 @@ async fn test_multipart_upload_magic_byte_mismatch() { let form = reqwest::multipart::Form::new().part("file", part); let resp = client - .post(format!("{}/files", server.base_url)) + .post(format!("{}/api/v1/sites/{}/files", server.base_url, site_id)) .headers(api_key_header(&api_key)) .multipart(form) .send() .await .unwrap(); assert_eq!(resp.status(), 400); -} +} diff --git a/apps/backend/tests/rest/main.rs b/apps/backend/tests/rest/main.rs index 4c42a3b3..7c77804e 100644 --- a/apps/backend/tests/rest/main.rs +++ b/apps/backend/tests/rest/main.rs @@ -14,3 +14,5 @@ mod singletons_tests; mod sites_tests; mod storage_profiles_tests; mod webhooks_tests; + +mod public_contract_tests; diff --git a/apps/backend/tests/rest/public_contract_tests.rs b/apps/backend/tests/rest/public_contract_tests.rs new file mode 100644 index 00000000..cdd8c3c1 --- /dev/null +++ b/apps/backend/tests/rest/public_contract_tests.rs @@ -0,0 +1,654 @@ +use crate::common::{TestServer, auth::auth_header, fixtures::setup}; +use serde_json::{Value, json}; + +async fn token(server: &TestServer, site: &str, session: &str, csrf: &str, scopes: &[&str]) -> String { + let response = reqwest::Client::new() + .post(format!("{}/api/dashboard/sites/{site}/tokens", server.base_url)) + .headers(auth_header(session, csrf)) + .json(&json!({"name":"Audit", "scopes": scopes})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + response.json::().await.unwrap()["token"] + .as_str() + .unwrap() + .to_owned() +} + +#[tokio::test] +async fn public_entries_enforce_preview_publication_and_atomic_versions() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let client = reqwest::Client::new(); + let collection = client + .post(format!("{}/api/dashboard/sites/{site}/collections", server.base_url)) + .headers(auth_header(&session, &csrf)) + .json(&json!({"name":"Posts","slug":"posts","definition":{"fields":[{"name":"title","type":"text"}]}})) + .send() + .await + .unwrap(); + assert_eq!(collection.status(), 201); + let writer = token( + &server, + &site, + &session, + &csrf, + &["content.read", "content.write", "content.preview.read"], + ) + .await; + let reader = token(&server, &site, &session, &csrf, &["content.read"]).await; + let base = format!("{}/api/v1/sites/{site}", server.base_url); + let response = client + .post(format!("{base}/collections/posts/entries")) + .bearer_auth(&writer) + .json(&json!({"slug":"draft","data":{"title":"Private"}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let entry: Value = response.json().await.unwrap(); + let id = entry["id"].as_str().unwrap(); + for suffix in [ + format!("entries/{id}"), + format!("entries/{id}/revisions"), + format!("entries/{id}/revisions/1"), + ] { + let response = client + .get(format!("{base}/{suffix}")) + .bearer_auth(&reader) + .send() + .await + .unwrap(); + assert!( + matches!(response.status().as_u16(), 403 | 404), + "{suffix}: {}", + response.status() + ); + } + let response = client + .patch(format!("{base}/entries/{id}")) + .bearer_auth(&writer) + .json(&json!({"status":"published"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 403); + let response = client + .get(format!( + "{base}/collections/posts/entries?include_drafts=true&per_page=1&include_total=true" + )) + .bearer_auth(&writer) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let page: Value = response.json().await.unwrap(); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + assert_eq!(page["page_info"]["has_next_page"], false); + let version = format!("\"{}\"", entry["version"].as_str().unwrap()); + let update = |title: &'static str| { + client + .patch(format!("{base}/entries/{id}")) + .bearer_auth(&writer) + .header("If-Match", &version) + .json(&json!({"data":{"title":title}})) + .send() + }; + let (first, second) = tokio::join!(update("First"), update("Second")); + let mut statuses = [first.unwrap().status().as_u16(), second.unwrap().status().as_u16()]; + statuses.sort(); + assert_eq!(statuses, [200, 412]); + let current = client + .get(format!("{base}/entries/{id}?include_drafts=true")) + .bearer_auth(&writer) + .send() + .await + .unwrap(); + assert_eq!(current.status(), 200); + assert_ne!(current.headers()["etag"], version); + let body: Value = current.json().await.unwrap(); + assert!(matches!(body["data"]["title"].as_str(), Some("First" | "Second"))); +} + +#[tokio::test] +async fn openapi_matches_public_entry_routes() { + let server = TestServer::start().await; + let doc: Value = reqwest::get(format!("{}/api/v1/openapi.json", server.base_url)) + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(doc["paths"]["/api/v1/sites/{site_id}/entries/{id}"]["patch"].is_object()); + assert!(doc["paths"]["/api/v1/sites/{site_id}/entries/{id}"]["delete"].is_object()); + assert!( + doc["paths"] + .as_object() + .unwrap() + .keys() + .all(|key| !key.starts_with("/api/v1/entries")) + ); +} + +#[tokio::test] +async fn public_errors_have_problem_type_and_request_correlation() { + let server = TestServer::start().await; + let response = reqwest::get(format!("{}/api/v1/sites", server.base_url)).await.unwrap(); + assert_eq!(response.status(), 401); + assert_eq!(response.headers()["content-type"], "application/problem+json"); + let request_id = response.headers()["x-request-id"].to_str().unwrap().to_owned(); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["status"], 401); + assert_eq!(body["code"], "unauthorized"); + assert_eq!(body["request_id"], request_id); + assert!(body["detail"].is_string()); +} + +#[tokio::test] +async fn active_uploaded_documents_cannot_execute_in_dashboard_origin() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let writer = token(&server, &site, &session, &csrf, &["files.write"]).await; + let client = reqwest::Client::new(); + for (mime, filename, content) in [ + ("text/html", "test.html", ""), + ( + "image/svg+xml", + "test.svg", + "", + ), + ] { + let response = client + .post(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .multipart( + reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::text(content) + .file_name(filename) + .mime_str(mime) + .unwrap(), + ), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let file: Value = response.json().await.unwrap(); + let response = client + .get(format!( + "{}/api/files/{}", + server.base_url, + file["id"].as_str().unwrap() + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + assert_eq!(response.headers()["x-content-type-options"], "nosniff"); + assert!( + response.headers()["content-security-policy"] + .to_str() + .unwrap() + .contains("sandbox") + ); + assert_eq!(response.text().await.unwrap(), content); + } +} + +#[tokio::test] +async fn malformed_trailing_multipart_does_not_commit_a_file() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let writer = token(&server, &site, &session, &csrf, &["files.write", "files.read"]).await; + let client = reqwest::Client::new(); + let body = "--audit\r\nContent-Disposition: form-data; name=\"file\"; filename=\"test.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--audit\r\nMalformed header\r\n\r\nignored\r\n--audit--\r\n"; + let response = client + .post(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .header("content-type", "multipart/form-data; boundary=audit") + .body(body) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let files: Value = client + .get(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!( + files["items"].as_array().unwrap().len(), + 0, + "Malformed upload left persisted metadata" + ); +} + +#[tokio::test] +async fn singleton_preview_and_versions_are_enforced() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let client = reqwest::Client::new(); + let response = client.post(format!("{}/api/dashboard/sites/{site}/collections",server.base_url)) + .headers(auth_header(&session,&csrf)).json(&json!({"name":"Settings","slug":"settings","is_singleton":true,"definition":{"fields":[{"name":"title","type":"text"}]}})) + .send().await.unwrap(); + assert_eq!(response.status(), 201); + let writer = token( + &server, + &site, + &session, + &csrf, + &["content.write", "content.preview.read", "content.read"], + ) + .await; + let reader = token(&server, &site, &session, &csrf, &["content.read"]).await; + let url = format!("{}/api/v1/sites/{site}/singletons/settings", server.base_url); + let response = client + .patch(&url) + .bearer_auth(&writer) + .json(&json!({"data":{"title":"Private"}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let version = response.headers()["etag"].to_str().unwrap().to_owned(); + let response = client.get(&url).bearer_auth(&reader).send().await.unwrap(); + let hidden: Value = response.json().await.unwrap(); + assert!(hidden["data"].is_null()); + assert!(hidden["entry_id"].is_null()); + assert!(hidden["version"].is_null()); + let response = client + .get(format!("{url}?include_drafts=true")) + .bearer_auth(&reader) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 403); + let response = client + .get(format!("{url}?include_drafts=true")) + .bearer_auth(&writer) + .send() + .await + .unwrap(); + assert_eq!(response.headers()["etag"], version); + let visible: Value = response.json().await.unwrap(); + assert_eq!(visible["data"]["title"], "Private"); + let response = client + .patch(&url) + .bearer_auth(&writer) + .header("If-Match", &version) + .json(&json!({"data":{"title":"First"}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let response = client + .patch(&url) + .bearer_auth(&writer) + .header("If-Match", &version) + .json(&json!({"data":{"title":"Stale"}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 412); + let response = client + .patch(&url) + .bearer_auth(&writer) + .header("If-Match", "invalid-unquoted") + .json(&json!({"data":{"title":"Malformed"}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let current: Value = client + .get(format!("{url}?include_drafts=true")) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(current["data"]["title"], "First"); +} + +#[tokio::test] +async fn signed_upload_cannot_be_replayed_after_permanent_file_deletion() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let writer = token(&server, &site, &session, &csrf, &["files.write", "files.read"]).await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/api/v1/sites/{site}/files/upload-url", server.base_url)) + .bearer_auth(&writer) + .json(&json!({"filename":"once.txt","content_type":"text/plain"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let upload: Value = response.json().await.unwrap(); + let url = upload["upload_url"].as_str().unwrap(); + let response = client + .put(url) + .header("Content-Type", "text/plain") + .body("original") + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let file: Value = response.json().await.unwrap(); + let id = file["id"].as_str().unwrap(); + let response = client + .delete(format!("{}/api/v1/sites/{site}/files/{id}", server.base_url)) + .bearer_auth(&writer) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + for suffix in [format!("files/{id}"), format!("files/{id}/references")] { + let response = client + .get(format!("{}/api/v1/sites/{site}/{suffix}", server.base_url)) + .bearer_auth(&writer) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 404, "Public metadata exposed deleted file"); + } + let dashboard = client + .get(format!("{}/api/dashboard/sites/{site}/files/{id}", server.base_url)) + .headers(auth_header(&session, &csrf)) + .send() + .await + .unwrap(); + assert_eq!(dashboard.status(), 200, "Dashboard trash metadata contract changed"); + let response = client + .post(format!( + "{}/api/dashboard/sites/{site}/files/batch-permanent-delete", + server.base_url + )) + .headers(auth_header(&session, &csrf)) + .json(&json!({"ids":[id]})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let response = client + .put(url) + .header("Content-Type", "text/plain") + .body("replayed") + .send() + .await + .unwrap(); + assert_eq!( + response.status(), + 409, + "File deletion erased signed URL replay protection" + ); + let files: Value = client + .get(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!(files["items"].as_array().unwrap().is_empty()); +} + +#[tokio::test] +async fn field_renames_preserve_references_and_failed_schema_updates_are_atomic() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let client = reqwest::Client::new(); + let writer = token( + &server, + &site, + &session, + &csrf, + &["content.write", "content.preview.read", "files.write", "files.read"], + ) + .await; + for (name, slug) in [("Media", "media"), ("Conflict", "conflict")] { + let response = client + .post(format!("{}/api/dashboard/sites/{site}/collections", server.base_url)) + .headers(auth_header(&session, &csrf)) + .json(&json!({"name":name,"slug":slug,"definition":{"fields":[{"name":"hero","type":"file"}]}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + } + let response = client + .post(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .multipart( + reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::text("content") + .file_name("file.txt") + .mime_str("text/plain") + .unwrap(), + ), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let file: Value = response.json().await.unwrap(); + let id = file["id"].as_str().unwrap(); + let url = format!("/api/files/{id}"); + let response = client + .post(format!( + "{}/api/v1/sites/{site}/collections/media/entries", + server.base_url + )) + .bearer_auth(&writer) + .json(&json!({"slug":"entry","data":{"hero":url}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201); + let entry: Value = response.json().await.unwrap(); + let entry_id = entry["id"].as_str().unwrap(); + let schema_url = format!("{}/api/dashboard/sites/{site}/collections/media", server.base_url); + let response = client + .put(&schema_url) + .headers(auth_header(&session, &csrf)) + .json(&json!({"slug":"conflict","definition":{"fields":[{"name":"cover","type":"file"}]}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 409); + let unchanged: Value = client + .get(format!( + "{}/api/v1/sites/{site}/entries/{entry_id}?include_drafts=true", + server.base_url + )) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(unchanged["data"]["hero"], url); + assert_eq!(unchanged["version"], entry["version"]); + let response = client + .put(&schema_url) + .headers(auth_header(&session, &csrf)) + .json(&json!({"definition":{"fields":[{"name":"cover","type":"file"}]}})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let current: Value = client + .get(format!( + "{}/api/v1/sites/{site}/entries/{entry_id}?include_drafts=true", + server.base_url + )) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(current["data"]["cover"], url); + assert!(current["data"].get("hero").is_none()); + assert_ne!(current["version"], entry["version"]); + let references: Value = client + .get(format!("{}/api/v1/sites/{site}/files/{id}/references", server.base_url)) + .bearer_auth(&writer) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert_eq!(references.as_array().unwrap().len(), 1); + assert_eq!(references[0]["field_name"], "cover"); +} + +#[tokio::test] +async fn filename_limits_and_unicode_delivery_headers_are_consistent() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let writer = token(&server, &site, &session, &csrf, &["files.write"]).await; + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/api/v1/sites/{site}/files/upload-url", server.base_url)) + .bearer_auth(&writer) + .json(&json!({"filename":"x".repeat(256),"content_type":"text/plain"})) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + for name in ["résumé.txt".to_owned(), format!("a.{}", "x".repeat(253))] { + let response = client + .post(format!("{}/api/v1/sites/{site}/files", server.base_url)) + .bearer_auth(&writer) + .multipart( + reqwest::multipart::Form::new().part( + "file", + reqwest::multipart::Part::text("bytes") + .file_name(name.clone()) + .mime_str("text/plain") + .unwrap(), + ), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 201, "Valid filename could not be stored: {name}"); + let file: Value = response.json().await.unwrap(); + let response = client + .get(format!( + "{}/api/files/{}", + server.base_url, + file["id"].as_str().unwrap() + )) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200); + let disposition = response.headers()["content-disposition"].to_str().unwrap(); + assert!(disposition.contains("filename*=UTF-8''")); + if name.starts_with("résumé") { + assert!(disposition.contains("r%C3%A9sum%C3%A9.txt")); + } + assert_eq!(response.text().await.unwrap(), "bytes"); + } +} + +#[tokio::test] +async fn expiring_tokens_and_bearer_scheme_case_work_across_http_protocols() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let client = reqwest::Client::new(); + let response = client.post(format!("{}/api/dashboard/sites/{site}/tokens",server.base_url)).headers(auth_header(&session,&csrf)) + .json(&json!({"name":"Expires later","scopes":["site.read","mcp.use"],"expires_at":(chrono::Utc::now()+chrono::Duration::hours(1)).to_rfc3339()})) + .send().await.unwrap(); + assert_eq!(response.status(), 201); + let credential: Value = response.json().await.unwrap(); + let authorization = format!("bEaReR {}", credential["token"].as_str().unwrap()); + let response = client + .get(format!("{}/api/v1/sites/{site}", server.base_url)) + .header("Authorization", &authorization) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200, "Future token rejected by REST"); + let response = client + .post(format!("{}/api/graphql", server.base_url)) + .header("Authorization", &authorization) + .json(&json!({"query":"{ sites { id } }"})) + .send() + .await + .unwrap(); + let body: Value = response.json().await.unwrap(); + assert!(body["errors"].is_null(), "Future token rejected by GraphQL: {body}"); + assert_eq!(body["data"]["sites"][0]["id"], site); + let response = client + .post(format!("{}/mcp", server.base_url)) + .header("Authorization", &authorization) + .header("Accept", "application/json, text/event-stream") + .header("MCP-Protocol-Version", "2026-07-28") + .header("Mcp-Method", "server/discover") + .json( + &json!({"jsonrpc":"2.0","id":1,"method":"server/discover","params":{"_meta":{ + "io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}), + ) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 200, "Future token rejected by MCP"); + let body: Value = response.json().await.unwrap(); + assert!( + body["result"]["supportedVersions"] + .as_array() + .unwrap() + .contains(&json!("2026-07-28")) + ); +} + +#[tokio::test] +async fn public_lists_reject_unknown_collections_and_bad_cursors_with_actionable_errors() { + let server = TestServer::start().await; + let (session, csrf, site) = setup(&server).await; + let client = reqwest::Client::new(); + let created = client + .post(format!("{}/api/dashboard/sites/{site}/collections", server.base_url)) + .headers(auth_header(&session, &csrf)) + .json(&json!({"name":"Posts","slug":"posts","definition":{"fields":[{"name":"title","type":"text"}]}})) + .send() + .await + .unwrap(); + assert_eq!(created.status(), 201); + let reader = token(&server, &site, &session, &csrf, &["content.read", "files.read"]).await; + let base = format!("{}/api/v1/sites/{site}", server.base_url); + let get = |path: String| client.get(format!("{base}{path}")).bearer_auth(&reader).send(); + + assert_eq!(get("/collections/missing/entries".into()).await.unwrap().status(), 404); + for path in ["/collections/posts/entries?cursor=garbage", "/files?cursor=garbage"] { + let response = get(path.into()).await.unwrap(); + assert_eq!(response.status(), 400, "{path}"); + let body: Value = response.json().await.unwrap(); + assert!(body["detail"].as_str().unwrap().contains("Cursor"), "{path}: {body}"); + } + + let response = get("/collections/posts/entries?include_drafts=true".into()) + .await + .unwrap(); + assert_eq!(response.status(), 403); + let body: Value = response.json().await.unwrap(); + assert!( + body["detail"].as_str().unwrap().contains("content.preview.read"), + "403 must name the missing scope: {body}" + ); +} diff --git a/apps/dashboard/bun.lock b/apps/dashboard/bun.lock index bb0b5f5f..468a32b7 100644 --- a/apps/dashboard/bun.lock +++ b/apps/dashboard/bun.lock @@ -10,12 +10,9 @@ "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", "@tailwindcss/vite": "^4.2.2", - "@tanstack/react-devtools": "^0.10.2", "@tanstack/react-form": "^1.28.6", "@tanstack/react-query": "^5.97.0", - "@tanstack/react-query-devtools": "^5.97.0", "@tanstack/react-router": "^1.168.10", - "@tanstack/react-router-devtools": "^1.166.11", "@tanstack/react-table": "^8.21.3", "@tanstack/router-plugin": "^1.167.12", "@tiptap/extension-image": "^3.22.3", @@ -355,7 +352,6 @@ "@tailwindcss/vite": ["@tailwindcss/vite@4.2.2", "", { "dependencies": { "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "tailwindcss": "4.2.2" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, "sha512-mEiF5HO1QqCLXoNEfXVA1Tzo+cYsrqV7w9Juj2wdUFyW07JRenqMG225MvPwr3ZD9N1bFQj46X7r33iHxLUW0w=="], - "@tanstack/devtools": ["@tanstack/devtools@0.11.2", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/keyboard": "^1.3.3", "@solid-primitives/resize-observer": "^2.1.3", "@tanstack/devtools-client": "0.0.6", "@tanstack/devtools-event-bus": "0.4.1", "@tanstack/devtools-ui": "0.5.1", "clsx": "^2.1.1", "goober": "^2.1.16", "solid-js": "^1.9.9" }, "bin": { "intent": "bin/intent.js" } }, "sha512-K8+tsBx+ptTLqqd4dOF10B6laj1g+XYImqYZL9n0jBINGaT+sOf17PKV9pbBt8kdbZeIGsHaJ5OZWCyZoHqN4A=="], "@tanstack/devtools-client": ["@tanstack/devtools-client@0.0.6", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.1" } }, "sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ=="], @@ -373,19 +369,15 @@ "@tanstack/query-core": ["@tanstack/query-core@5.97.0", "", {}, "sha512-QdpLP5VzVMgo4VtaPppRA2W04UFjIqX+bxke/ZJhE5cfd5UPkRzqIAJQt9uXkQJjqE8LBOMbKv7f8HCsZltXlg=="], - "@tanstack/query-devtools": ["@tanstack/query-devtools@5.97.0", "", {}, "sha512-ZMjAuYhQCKwKLKFMrD+HJDehHwWBVTGOuWBf4vEjR9unO+UGUjQ1mw2TuVbQKoLN/eRwB7qtlPsWBqobBoRBMQ=="], - "@tanstack/react-devtools": ["@tanstack/react-devtools@0.10.2", "", { "dependencies": { "@tanstack/devtools": "0.11.2" }, "peerDependencies": { "@types/react": ">=16.8", "@types/react-dom": ">=16.8", "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-1BmZyxOrI5SqmRJ5MgkYZNNdnlLsJxQRI2YgorrAvcF2MxK6x5RcuStvD8+YlXoMw3JtNukPxoITirKAnKYDQA=="], "@tanstack/react-form": ["@tanstack/react-form@1.28.6", "", { "dependencies": { "@tanstack/form-core": "1.28.6", "@tanstack/react-store": "^0.9.1" }, "peerDependencies": { "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-dRxwKeNW3uuJvf0sXsIQ2compFMnIJNk9B436Lx0fqkqK+CBvA1tNmEdX+faoCpuQ5Wua3c8ahVibJ65cpkijA=="], "@tanstack/react-query": ["@tanstack/react-query@5.97.0", "", { "dependencies": { "@tanstack/query-core": "5.97.0" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-y4So4eGcQoK2WVMAcDNZE9ofB/p5v1OlKvtc1F3uqHwrtifobT7q+ZnXk2mRkc8E84HKYSlAE9z6HXl2V0+ySQ=="], - "@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.97.0", "", { "dependencies": { "@tanstack/query-devtools": "5.97.0" }, "peerDependencies": { "@tanstack/react-query": "^5.97.0", "react": "^18 || ^19" } }, "sha512-X4/VZKCbBIRj8cVD/oZCKTwwPmFXrY1VOfwUT5qI/+/JZYAUS+8vGNMqwBXbaAu1ZsVzzDzkT/wtBE/5OtQYGg=="], "@tanstack/react-router": ["@tanstack/react-router@1.168.10", "", { "dependencies": { "@tanstack/history": "1.161.6", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.168.9", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-/RmDlOwDkCug609KdPB3U+U1zmrtadJpvsmRg2zEn8TRCKRNri7dYZIjQZbNg8PgUiRL4T6njrZBV1ChzblNaA=="], - "@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.166.11", "", { "dependencies": { "@tanstack/router-devtools-core": "1.167.1" }, "peerDependencies": { "@tanstack/react-router": "^1.168.2", "@tanstack/router-core": "^1.168.2", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-WYR3q4Xui5yPT/5PXtQh8i03iUA7q8dONBjWpV3nsGdM8Cs1FxpfhLstW0wZO1dOvSyElscwTRCJ6nO5N8r3Lg=="], "@tanstack/react-store": ["@tanstack/react-store@0.9.2", "", { "dependencies": { "@tanstack/store": "0.9.2", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Vt5usJE5sHG/cMechQfmwvwne6ktGCELe89Lmvoxe3LKRoFrhPa8OCKWs0NliG8HTJElEIj7PLtaBQIcux5pAQ=="], @@ -393,7 +385,6 @@ "@tanstack/router-core": ["@tanstack/router-core@1.168.9", "", { "dependencies": { "@tanstack/history": "1.161.6", "cookie-es": "^2.0.0", "seroval": "^1.4.2", "seroval-plugins": "^1.4.2" }, "bin": { "intent": "bin/intent.js" } }, "sha512-18oeEwEDyXOIuO1VBP9ACaK7tYHZUjynGDCoUh/5c/BNhia9vCJCp9O0LfhZXOorDc/PmLSgvmweFhVmIxF10g=="], - "@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.167.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.168.2", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-ECMM47J4KmifUvJguGituSiBpfN8SyCUEoxQks5RY09hpIBfR2eswCv2e6cJimjkKwBQXOVTPkTUk/yRvER+9w=="], "@tanstack/router-generator": ["@tanstack/router-generator@1.166.24", "", { "dependencies": { "@tanstack/router-core": "1.168.9", "@tanstack/router-utils": "1.161.6", "@tanstack/virtual-file-routes": "1.161.7", "prettier": "^3.5.0", "recast": "^0.23.11", "source-map": "^0.7.4", "tsx": "^4.19.2", "zod": "^3.24.2" } }, "sha512-vdaGKwuH+r+DPe6R1mjk+TDDmDH6NTG7QqwxHqGEvOH4aGf9sPjhmRKNJZqQr8cPIbfp6u5lXyZ1TeDcSNMVEA=="], diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 518ff353..ea3e5e25 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -11,54 +11,51 @@ "check": "biome check" }, "dependencies": { - "@base-ui/react": "^1.5.0", + "@base-ui/react": "^1.7.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "@fontsource-variable/geist": "^5.2.9", - "@tailwindcss/vite": "^4.3.0", - "@tanstack/react-devtools": "^0.10.5", - "@tanstack/react-form": "^1.33.0", - "@tanstack/react-query": "^5.100.14", - "@tanstack/react-query-devtools": "^5.100.14", - "@tanstack/react-router": "^1.170.9", - "@tanstack/react-router-devtools": "^1.167.0", - "@tanstack/react-table": "^8.21.3", - "@tanstack/router-plugin": "^1.168.12", - "@tiptap/extension-image": "^3.23.6", - "@tiptap/extension-link": "^3.23.6", - "@tiptap/extension-placeholder": "^3.23.6", - "@tiptap/pm": "^3.23.6", - "@tiptap/react": "^3.23.6", - "@tiptap/starter-kit": "^3.23.6", - "@videojs/react": "10.0.0-beta.24", + "@fontsource-variable/geist": "^5.3.0", + "@tailwindcss/vite": "^4.3.3", + "@tanstack/react-form": "^1.33.4", + "@tanstack/react-query": "^5.101.4", + "@tanstack/react-router": "^1.170.25", + "@tanstack/react-table": "^9.1.2", + "@tanstack/router-plugin": "^1.168.29", + "@tiptap/extension-image": "^3.29.2", + "@tiptap/extension-link": "^3.29.2", + "@tiptap/extension-placeholder": "^3.29.2", + "@tiptap/pm": "^3.29.2", + "@tiptap/react": "^3.29.2", + "@tiptap/starter-kit": "^3.29.2", + "@videojs/react": "10.0.0-beta.26", "boring-avatars": "^2.0.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", - "date-fns": "^4.3.0", + "date-fns": "^4.4.0", "hashvatar": "^0.1.2", - "lucide-react": "^1.17.0", + "lucide-react": "^1.31.0", "next-themes": "^0.4.6", - "react": "^19.2.6", - "react-dom": "^19.2.6", - "shadcn": "^4.11.0", - "sonner": "^2.0.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "shadcn": "^4.16.2", + "sonner": "^2.0.8", "tailwind-merge": "^3.6.0", - "tailwindcss": "^4.3.0", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "vaul": "^1.1.2", "zod": "^4.4.3" }, "devDependencies": { - "@biomejs/biome": "2.5.0", - "@types/node": "^26.0.0", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^6.0.2", - "jsdom": "^29.1.1", - "typescript": "^6.0.3", - "vite": "^8.0.14", - "web-vitals": "^5.3.0" + "@biomejs/biome": "2.5.7", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "typescript": "^7.0.2", + "vite": "^8.2.1", + "web-vitals": "^6.1.0" } -} \ No newline at end of file +} diff --git a/apps/dashboard/src/components/account/personal-tokens-card.tsx b/apps/dashboard/src/components/account/personal-tokens-card.tsx index c301c0e8..1fd79aca 100644 --- a/apps/dashboard/src/components/account/personal-tokens-card.tsx +++ b/apps/dashboard/src/components/account/personal-tokens-card.tsx @@ -36,19 +36,13 @@ import { const SCOPES = [ "site.read", - "site.settings.read", + "schema.read", "content.read", + "content.preview.read", "content.write", + "content.publish", "files.read", "files.write", - "schema.read", - "schema.write", - "webhooks.read", - "webhooks.write", - "webhooks.trigger", - "deployments.read", - "deployments.write", - "deployments.trigger", "mcp.use", ]; @@ -61,13 +55,9 @@ export function PersonalTokensCard() { const [name, setName] = useState(""); const [scopes, setScopes] = useState([ "site.read", + "schema.read", "content.read", - "content.write", "files.read", - "files.write", - "schema.read", - "deployments.read", - "deployments.trigger", "mcp.use", ]); const [secret, setSecret] = useState(null); diff --git a/apps/dashboard/src/components/entries/columns.tsx b/apps/dashboard/src/components/entries/columns.tsx index 64761fc2..3c46bd91 100644 --- a/apps/dashboard/src/components/entries/columns.tsx +++ b/apps/dashboard/src/components/entries/columns.tsx @@ -1,7 +1,6 @@ import { Link } from "@tanstack/react-router"; import type { ColumnDef } from "@tanstack/react-table"; import { ArrowUpDown, Globe, GlobeLock, Pencil, Trash2 } from "lucide-react"; - import { AlertDialog, AlertDialogAction, @@ -15,6 +14,7 @@ import { } from "@/components/ui/alert-dialog"; import { Badge } from "@/components/ui/badge"; import { Button, buttonVariants } from "@/components/ui/button"; +import type { dataTableFeatures } from "@/components/ui/data-table"; import type { Entry } from "@/lib/api"; function extractTitle(item: Entry): string { @@ -56,7 +56,7 @@ export function createColumns({ isPublishPending, isUnpublishPending, isDeletePending, -}: CreateColumnsParams): ColumnDef[] { +}: CreateColumnsParams): ColumnDef[] { return [ { accessorKey: "data", @@ -73,7 +73,7 @@ export function createColumns({ const title = extractTitle(row.original); return {title}; }, - sortingFn: (rowA, rowB) => { + sortFn: (rowA, rowB) => { const titleA = extractTitle(rowA.original).toLowerCase(); const titleB = extractTitle(rowB.original).toLowerCase(); return titleA.localeCompare(titleB); diff --git a/apps/dashboard/src/components/ui/data-table.tsx b/apps/dashboard/src/components/ui/data-table.tsx index da68dc4b..ccaa7a6d 100644 --- a/apps/dashboard/src/components/ui/data-table.tsx +++ b/apps/dashboard/src/components/ui/data-table.tsx @@ -2,16 +2,22 @@ import type { ColumnDef, + RowData, + ReactTable, SortingState, - Table as TanStackTable, - VisibilityState, + ColumnVisibilityState, } from "@tanstack/react-table"; import { + columnVisibilityFeature, + createPaginatedRowModel, + createSortedRowModel, flexRender, - getCoreRowModel, - getPaginationRowModel, - getSortedRowModel, - useReactTable, + rowPaginationFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + tableFeatures, + useTable, } from "@tanstack/react-table"; import * as React from "react"; import { @@ -40,8 +46,18 @@ import { } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -interface DataTableProps { - columns: ColumnDef[]; +export const dataTableFeatures = tableFeatures({ + columnVisibilityFeature, + rowSelectionFeature, + rowSortingFeature, + sortFns, + sortedRowModel: createSortedRowModel(), + rowPaginationFeature, + paginatedRowModel: createPaginatedRowModel(), +}); + +interface DataTableProps { + columns: ColumnDef[]; data: TData[]; isLoading?: boolean; loadingRowCount?: number; @@ -54,7 +70,7 @@ interface DataTableProps { onPageSizeChange?: (pageSize: number) => void; } -export function DataTable({ +export function DataTable({ columns, data, isLoading = false, @@ -66,17 +82,15 @@ export function DataTable({ pageSize, onPageChange, onPageSizeChange, -}: DataTableProps) { +}: DataTableProps) { const [sorting, setSorting] = React.useState([]); const [columnVisibility, setColumnVisibility] = - React.useState({}); + React.useState({}); - const table = useReactTable({ + const table = useTable({ + features: dataTableFeatures, data, columns, - getCoreRowModel: getCoreRowModel(), - getPaginationRowModel: getPaginationRowModel(), - getSortedRowModel: getSortedRowModel(), onSortingChange: setSorting, onColumnVisibilityChange: setColumnVisibility, state: { @@ -194,8 +208,8 @@ export function DataTable({ // --- Pagination sub-component --- -interface DataTablePaginationProps { - table: TanStackTable; +interface DataTablePaginationProps { + table: ReactTable; total?: number; page?: number; pageSize?: number; @@ -203,7 +217,7 @@ interface DataTablePaginationProps { onPageSizeChange?: (pageSize: number) => void; } -function DataTablePagination({ +function DataTablePagination({ table, total, page, @@ -213,8 +227,8 @@ function DataTablePagination({ }: DataTablePaginationProps) { const isServerSide = total !== undefined && onPageChange !== undefined; const pageCount = isServerSide ? Math.ceil(total / (pageSize || 20)) : table.getPageCount(); - const currentPage = isServerSide ? (page || 1) : table.getState().pagination.pageIndex + 1; - const currentPageSize = isServerSide ? (pageSize || 20) : table.getState().pagination.pageSize; + const currentPage = isServerSide ? (page || 1) : table.state.pagination.pageIndex + 1; + const currentPageSize = isServerSide ? (pageSize || 20) : table.state.pagination.pageSize; const canPreviousPage = currentPage > 1; const canNextPage = currentPage < pageCount; @@ -232,7 +246,7 @@ function DataTablePagination({ return (
- {isServerSide ? `${total} item(s) total.` : `${table.getFilteredRowModel().rows.length} row(s) total.`} + {isServerSide ? `${total} item(s) total.` : `${table.getPrePaginatedRowModel().rows.length} row(s) total.`}
diff --git a/apps/dashboard/src/integrations/tanstack-query/devtools.tsx b/apps/dashboard/src/integrations/tanstack-query/devtools.tsx deleted file mode 100644 index 7c85d88a..00000000 --- a/apps/dashboard/src/integrations/tanstack-query/devtools.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ReactQueryDevtoolsPanel } from "@tanstack/react-query-devtools"; - -export default { - name: "Tanstack Query", - render: , -}; diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index 4a738efd..792ace16 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -987,41 +987,16 @@ export async function createApiKey( accessMode === "write" ? [ "site.read", - "site.settings.read", - "site.settings.write", + "schema.read", "content.read", + "content.preview.read", "content.write", + "content.publish", "files.read", "files.write", - "schema.read", - "schema.write", - "webhooks.read", - "webhooks.write", - "webhooks.trigger", - "deployments.read", - "deployments.write", - "deployments.trigger", "mcp.use", ] - : accessMode === "read" - ? [ - "site.read", - "site.settings.read", - "content.read", - "files.read", - "schema.read", - "webhooks.read", - "deployments.read", - ] - : [ - "site.read", - "site.settings.read", - "content.read", - "files.read", - "schema.read", - "webhooks.read", - "deployments.read", - ]; + : ["site.read", "schema.read", "content.read", "files.read", "mcp.use"]; const token = await api(`/sites/${siteId}/tokens`, { method: "POST", body: JSON.stringify({ name, scopes }), diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index d81fce4b..37b31a6d 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -9,40 +9,39 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' -import { Route as RegisterRouteImport } from './routes/register' -import { Route as LoginRouteImport } from './routes/login' import { Route as AdminRouteImport } from './routes/_admin' -import { Route as AdminSitesRouteImport } from './routes/_admin/sites' +import { Route as LoginRouteImport } from './routes/login' +import { Route as RegisterRouteImport } from './routes/register' import { Route as AdminShellRouteImport } from './routes/_admin/_shell' +import { Route as AdminSitesRouteImport } from './routes/_admin/sites' import { Route as AdminShellIndexRouteImport } from './routes/_admin/_shell/index' -import { Route as AdminSitesSiteIdRouteImport } from './routes/_admin/sites.$siteId' -import { Route as AdminShellSettingsRouteImport } from './routes/_admin/_shell/settings' import { Route as AdminShellAccountRouteImport } from './routes/_admin/_shell/account' -import { Route as AdminSitesSiteIdIndexRouteImport } from './routes/_admin/sites.$siteId/index' +import { Route as AdminShellSettingsRouteImport } from './routes/_admin/_shell/settings' +import { Route as AdminSitesSiteIdRouteImport } from './routes/_admin/sites.$siteId' import { Route as AdminShellSettingsIndexRouteImport } from './routes/_admin/_shell/settings/index' -import { Route as AdminSitesSiteIdSettingsRouteImport } from './routes/_admin/sites.$siteId/settings' -import { Route as AdminSitesSiteIdFilesRouteImport } from './routes/_admin/sites.$siteId/files' -import { Route as AdminSitesSiteIdDeploymentsRouteImport } from './routes/_admin/sites.$siteId/deployments' -import { Route as AdminSitesSiteIdCollectionsRouteImport } from './routes/_admin/sites.$siteId/collections' -import { Route as AdminShellSettingsUsersRouteImport } from './routes/_admin/_shell/settings/users' -import { Route as AdminShellSettingsStorageRouteImport } from './routes/_admin/_shell/settings/storage' -import { Route as AdminShellSettingsSecurityRouteImport } from './routes/_admin/_shell/settings/security' import { Route as AdminShellSettingsBackupsRouteImport } from './routes/_admin/_shell/settings/backups' +import { Route as AdminShellSettingsSecurityRouteImport } from './routes/_admin/_shell/settings/security' +import { Route as AdminShellSettingsStorageRouteImport } from './routes/_admin/_shell/settings/storage' +import { Route as AdminShellSettingsUsersRouteImport } from './routes/_admin/_shell/settings/users' +import { Route as AdminSitesSiteIdIndexRouteImport } from './routes/_admin/sites.$siteId/index' +import { Route as AdminSitesSiteIdCollectionsRouteImport } from './routes/_admin/sites.$siteId/collections' +import { Route as AdminSitesSiteIdDeploymentsRouteImport } from './routes/_admin/sites.$siteId/deployments' +import { Route as AdminSitesSiteIdFilesRouteImport } from './routes/_admin/sites.$siteId/files' +import { Route as AdminSitesSiteIdSettingsRouteImport } from './routes/_admin/sites.$siteId/settings' +import { Route as AdminSitesSiteIdEntriesCollectionSlugRouteImport } from './routes/_admin/sites.$siteId/entries.$collectionSlug' import { Route as AdminSitesSiteIdSettingsIndexRouteImport } from './routes/_admin/sites.$siteId/settings/index' -import { Route as AdminSitesSiteIdSingletonsSlugRouteImport } from './routes/_admin/sites.$siteId/singletons.$slug' -import { Route as AdminSitesSiteIdSettingsWebhooksRouteImport } from './routes/_admin/sites.$siteId/settings/webhooks' -import { Route as AdminSitesSiteIdSettingsMembersRouteImport } from './routes/_admin/sites.$siteId/settings/members' -import { Route as AdminSitesSiteIdSettingsMcpRouteImport } from './routes/_admin/sites.$siteId/settings/mcp' -import { Route as AdminSitesSiteIdSettingsBackupsRouteImport } from './routes/_admin/sites.$siteId/settings/backups' import { Route as AdminSitesSiteIdSettingsApiKeysRouteImport } from './routes/_admin/sites.$siteId/settings/api-keys' -import { Route as AdminSitesSiteIdEntriesCollectionSlugRouteImport } from './routes/_admin/sites.$siteId/entries.$collectionSlug' +import { Route as AdminSitesSiteIdSettingsBackupsRouteImport } from './routes/_admin/sites.$siteId/settings/backups' +import { Route as AdminSitesSiteIdSettingsMcpRouteImport } from './routes/_admin/sites.$siteId/settings/mcp' +import { Route as AdminSitesSiteIdSettingsMembersRouteImport } from './routes/_admin/sites.$siteId/settings/members' +import { Route as AdminSitesSiteIdSettingsWebhooksRouteImport } from './routes/_admin/sites.$siteId/settings/webhooks' +import { Route as AdminSitesSiteIdSingletonsSlugRouteImport } from './routes/_admin/sites.$siteId/singletons.$slug' import { Route as AdminSitesSiteIdEntriesCollectionSlugIndexRouteImport } from './routes/_admin/sites.$siteId/entries.$collectionSlug/index' import { Route as AdminSitesSiteIdEntriesCollectionSlugNewRouteImport } from './routes/_admin/sites.$siteId/entries.$collectionSlug/new' import { Route as AdminSitesSiteIdEntriesCollectionSlugIdEditRouteImport } from './routes/_admin/sites.$siteId/entries.$collectionSlug/$id.edit' -const RegisterRoute = RegisterRouteImport.update({ - id: '/register', - path: '/register', +const AdminRoute = AdminRouteImport.update({ + id: '/_admin', getParentRoute: () => rootRouteImport, } as any) const LoginRoute = LoginRouteImport.update({ @@ -50,94 +49,101 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) -const AdminRoute = AdminRouteImport.update({ - id: '/_admin', +const RegisterRoute = RegisterRouteImport.update({ + id: '/register', + path: '/register', getParentRoute: () => rootRouteImport, } as any) +const AdminShellRoute = AdminShellRouteImport.update({ + id: '/_shell', + getParentRoute: () => AdminRoute, +} as any) const AdminSitesRoute = AdminSitesRouteImport.update({ id: '/sites', path: '/sites', getParentRoute: () => AdminRoute, } as any) -const AdminShellRoute = AdminShellRouteImport.update({ - id: '/_shell', - getParentRoute: () => AdminRoute, -} as any) const AdminShellIndexRoute = AdminShellIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AdminShellRoute, } as any) -const AdminSitesSiteIdRoute = AdminSitesSiteIdRouteImport.update({ - id: '/$siteId', - path: '/$siteId', - getParentRoute: () => AdminSitesRoute, +const AdminShellAccountRoute = AdminShellAccountRouteImport.update({ + id: '/account', + path: '/account', + getParentRoute: () => AdminShellRoute, } as any) const AdminShellSettingsRoute = AdminShellSettingsRouteImport.update({ id: '/settings', path: '/settings', getParentRoute: () => AdminShellRoute, } as any) -const AdminShellAccountRoute = AdminShellAccountRouteImport.update({ - id: '/account', - path: '/account', - getParentRoute: () => AdminShellRoute, -} as any) -const AdminSitesSiteIdIndexRoute = AdminSitesSiteIdIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => AdminSitesSiteIdRoute, +const AdminSitesSiteIdRoute = AdminSitesSiteIdRouteImport.update({ + id: '/$siteId', + path: '/$siteId', + getParentRoute: () => AdminSitesRoute, } as any) const AdminShellSettingsIndexRoute = AdminShellSettingsIndexRouteImport.update({ id: '/', path: '/', getParentRoute: () => AdminShellSettingsRoute, } as any) -const AdminSitesSiteIdSettingsRoute = - AdminSitesSiteIdSettingsRouteImport.update({ - id: '/settings', - path: '/settings', - getParentRoute: () => AdminSitesSiteIdRoute, +const AdminShellSettingsBackupsRoute = + AdminShellSettingsBackupsRouteImport.update({ + id: '/backups', + path: '/backups', + getParentRoute: () => AdminShellSettingsRoute, } as any) -const AdminSitesSiteIdFilesRoute = AdminSitesSiteIdFilesRouteImport.update({ - id: '/files', - path: '/files', +const AdminShellSettingsSecurityRoute = + AdminShellSettingsSecurityRouteImport.update({ + id: '/security', + path: '/security', + getParentRoute: () => AdminShellSettingsRoute, + } as any) +const AdminShellSettingsStorageRoute = + AdminShellSettingsStorageRouteImport.update({ + id: '/storage', + path: '/storage', + getParentRoute: () => AdminShellSettingsRoute, + } as any) +const AdminShellSettingsUsersRoute = AdminShellSettingsUsersRouteImport.update({ + id: '/users', + path: '/users', + getParentRoute: () => AdminShellSettingsRoute, +} as any) +const AdminSitesSiteIdIndexRoute = AdminSitesSiteIdIndexRouteImport.update({ + id: '/', + path: '/', getParentRoute: () => AdminSitesSiteIdRoute, } as any) -const AdminSitesSiteIdDeploymentsRoute = - AdminSitesSiteIdDeploymentsRouteImport.update({ - id: '/deployments', - path: '/deployments', - getParentRoute: () => AdminSitesSiteIdRoute, - } as any) const AdminSitesSiteIdCollectionsRoute = AdminSitesSiteIdCollectionsRouteImport.update({ id: '/collections', path: '/collections', getParentRoute: () => AdminSitesSiteIdRoute, } as any) -const AdminShellSettingsUsersRoute = AdminShellSettingsUsersRouteImport.update({ - id: '/users', - path: '/users', - getParentRoute: () => AdminShellSettingsRoute, -} as any) -const AdminShellSettingsStorageRoute = - AdminShellSettingsStorageRouteImport.update({ - id: '/storage', - path: '/storage', - getParentRoute: () => AdminShellSettingsRoute, +const AdminSitesSiteIdDeploymentsRoute = + AdminSitesSiteIdDeploymentsRouteImport.update({ + id: '/deployments', + path: '/deployments', + getParentRoute: () => AdminSitesSiteIdRoute, } as any) -const AdminShellSettingsSecurityRoute = - AdminShellSettingsSecurityRouteImport.update({ - id: '/security', - path: '/security', - getParentRoute: () => AdminShellSettingsRoute, +const AdminSitesSiteIdFilesRoute = AdminSitesSiteIdFilesRouteImport.update({ + id: '/files', + path: '/files', + getParentRoute: () => AdminSitesSiteIdRoute, +} as any) +const AdminSitesSiteIdSettingsRoute = + AdminSitesSiteIdSettingsRouteImport.update({ + id: '/settings', + path: '/settings', + getParentRoute: () => AdminSitesSiteIdRoute, } as any) -const AdminShellSettingsBackupsRoute = - AdminShellSettingsBackupsRouteImport.update({ - id: '/backups', - path: '/backups', - getParentRoute: () => AdminShellSettingsRoute, +const AdminSitesSiteIdEntriesCollectionSlugRoute = + AdminSitesSiteIdEntriesCollectionSlugRouteImport.update({ + id: '/entries/$collectionSlug', + path: '/entries/$collectionSlug', + getParentRoute: () => AdminSitesSiteIdRoute, } as any) const AdminSitesSiteIdSettingsIndexRoute = AdminSitesSiteIdSettingsIndexRouteImport.update({ @@ -145,22 +151,16 @@ const AdminSitesSiteIdSettingsIndexRoute = path: '/', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) -const AdminSitesSiteIdSingletonsSlugRoute = - AdminSitesSiteIdSingletonsSlugRouteImport.update({ - id: '/singletons/$slug', - path: '/singletons/$slug', - getParentRoute: () => AdminSitesSiteIdRoute, - } as any) -const AdminSitesSiteIdSettingsWebhooksRoute = - AdminSitesSiteIdSettingsWebhooksRouteImport.update({ - id: '/webhooks', - path: '/webhooks', +const AdminSitesSiteIdSettingsApiKeysRoute = + AdminSitesSiteIdSettingsApiKeysRouteImport.update({ + id: '/api-keys', + path: '/api-keys', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) -const AdminSitesSiteIdSettingsMembersRoute = - AdminSitesSiteIdSettingsMembersRouteImport.update({ - id: '/members', - path: '/members', +const AdminSitesSiteIdSettingsBackupsRoute = + AdminSitesSiteIdSettingsBackupsRouteImport.update({ + id: '/backups', + path: '/backups', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) const AdminSitesSiteIdSettingsMcpRoute = @@ -169,22 +169,22 @@ const AdminSitesSiteIdSettingsMcpRoute = path: '/mcp', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) -const AdminSitesSiteIdSettingsBackupsRoute = - AdminSitesSiteIdSettingsBackupsRouteImport.update({ - id: '/backups', - path: '/backups', +const AdminSitesSiteIdSettingsMembersRoute = + AdminSitesSiteIdSettingsMembersRouteImport.update({ + id: '/members', + path: '/members', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) -const AdminSitesSiteIdSettingsApiKeysRoute = - AdminSitesSiteIdSettingsApiKeysRouteImport.update({ - id: '/api-keys', - path: '/api-keys', +const AdminSitesSiteIdSettingsWebhooksRoute = + AdminSitesSiteIdSettingsWebhooksRouteImport.update({ + id: '/webhooks', + path: '/webhooks', getParentRoute: () => AdminSitesSiteIdSettingsRoute, } as any) -const AdminSitesSiteIdEntriesCollectionSlugRoute = - AdminSitesSiteIdEntriesCollectionSlugRouteImport.update({ - id: '/entries/$collectionSlug', - path: '/entries/$collectionSlug', +const AdminSitesSiteIdSingletonsSlugRoute = + AdminSitesSiteIdSingletonsSlugRouteImport.update({ + id: '/singletons/$slug', + path: '/singletons/$slug', getParentRoute: () => AdminSitesSiteIdRoute, } as any) const AdminSitesSiteIdEntriesCollectionSlugIndexRoute = @@ -394,11 +394,11 @@ export interface RootRouteChildren { declare module '@tanstack/react-router' { interface FileRoutesByPath { - '/register': { - id: '/register' - path: '/register' - fullPath: '/register' - preLoaderRoute: typeof RegisterRouteImport + '/_admin': { + id: '/_admin' + path: '' + fullPath: '/' + preLoaderRoute: typeof AdminRouteImport parentRoute: typeof rootRouteImport } '/login': { @@ -408,12 +408,19 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } - '/_admin': { - id: '/_admin' + '/register': { + id: '/register' + path: '/register' + fullPath: '/register' + preLoaderRoute: typeof RegisterRouteImport + parentRoute: typeof rootRouteImport + } + '/_admin/_shell': { + id: '/_admin/_shell' path: '' fullPath: '/' - preLoaderRoute: typeof AdminRouteImport - parentRoute: typeof rootRouteImport + preLoaderRoute: typeof AdminShellRouteImport + parentRoute: typeof AdminRoute } '/_admin/sites': { id: '/_admin/sites' @@ -422,13 +429,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminSitesRouteImport parentRoute: typeof AdminRoute } - '/_admin/_shell': { - id: '/_admin/_shell' - path: '' - fullPath: '/' - preLoaderRoute: typeof AdminShellRouteImport - parentRoute: typeof AdminRoute - } '/_admin/_shell/': { id: '/_admin/_shell/' path: '/' @@ -436,12 +436,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminShellIndexRouteImport parentRoute: typeof AdminShellRoute } - '/_admin/sites/$siteId': { - id: '/_admin/sites/$siteId' - path: '/$siteId' - fullPath: '/sites/$siteId' - preLoaderRoute: typeof AdminSitesSiteIdRouteImport - parentRoute: typeof AdminSitesRoute + '/_admin/_shell/account': { + id: '/_admin/_shell/account' + path: '/account' + fullPath: '/account' + preLoaderRoute: typeof AdminShellAccountRouteImport + parentRoute: typeof AdminShellRoute } '/_admin/_shell/settings': { id: '/_admin/_shell/settings' @@ -450,19 +450,12 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminShellSettingsRouteImport parentRoute: typeof AdminShellRoute } - '/_admin/_shell/account': { - id: '/_admin/_shell/account' - path: '/account' - fullPath: '/account' - preLoaderRoute: typeof AdminShellAccountRouteImport - parentRoute: typeof AdminShellRoute - } - '/_admin/sites/$siteId/': { - id: '/_admin/sites/$siteId/' - path: '/' - fullPath: '/sites/$siteId/' - preLoaderRoute: typeof AdminSitesSiteIdIndexRouteImport - parentRoute: typeof AdminSitesSiteIdRoute + '/_admin/sites/$siteId': { + id: '/_admin/sites/$siteId' + path: '/$siteId' + fullPath: '/sites/$siteId' + preLoaderRoute: typeof AdminSitesSiteIdRouteImport + parentRoute: typeof AdminSitesRoute } '/_admin/_shell/settings/': { id: '/_admin/_shell/settings/' @@ -471,25 +464,39 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminShellSettingsIndexRouteImport parentRoute: typeof AdminShellSettingsRoute } - '/_admin/sites/$siteId/settings': { - id: '/_admin/sites/$siteId/settings' - path: '/settings' - fullPath: '/sites/$siteId/settings' - preLoaderRoute: typeof AdminSitesSiteIdSettingsRouteImport - parentRoute: typeof AdminSitesSiteIdRoute + '/_admin/_shell/settings/backups': { + id: '/_admin/_shell/settings/backups' + path: '/backups' + fullPath: '/settings/backups' + preLoaderRoute: typeof AdminShellSettingsBackupsRouteImport + parentRoute: typeof AdminShellSettingsRoute } - '/_admin/sites/$siteId/files': { - id: '/_admin/sites/$siteId/files' - path: '/files' - fullPath: '/sites/$siteId/files' - preLoaderRoute: typeof AdminSitesSiteIdFilesRouteImport - parentRoute: typeof AdminSitesSiteIdRoute + '/_admin/_shell/settings/security': { + id: '/_admin/_shell/settings/security' + path: '/security' + fullPath: '/settings/security' + preLoaderRoute: typeof AdminShellSettingsSecurityRouteImport + parentRoute: typeof AdminShellSettingsRoute } - '/_admin/sites/$siteId/deployments': { - id: '/_admin/sites/$siteId/deployments' - path: '/deployments' - fullPath: '/sites/$siteId/deployments' - preLoaderRoute: typeof AdminSitesSiteIdDeploymentsRouteImport + '/_admin/_shell/settings/storage': { + id: '/_admin/_shell/settings/storage' + path: '/storage' + fullPath: '/settings/storage' + preLoaderRoute: typeof AdminShellSettingsStorageRouteImport + parentRoute: typeof AdminShellSettingsRoute + } + '/_admin/_shell/settings/users': { + id: '/_admin/_shell/settings/users' + path: '/users' + fullPath: '/settings/users' + preLoaderRoute: typeof AdminShellSettingsUsersRouteImport + parentRoute: typeof AdminShellSettingsRoute + } + '/_admin/sites/$siteId/': { + id: '/_admin/sites/$siteId/' + path: '/' + fullPath: '/sites/$siteId/' + preLoaderRoute: typeof AdminSitesSiteIdIndexRouteImport parentRoute: typeof AdminSitesSiteIdRoute } '/_admin/sites/$siteId/collections': { @@ -499,33 +506,33 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminSitesSiteIdCollectionsRouteImport parentRoute: typeof AdminSitesSiteIdRoute } - '/_admin/_shell/settings/users': { - id: '/_admin/_shell/settings/users' - path: '/users' - fullPath: '/settings/users' - preLoaderRoute: typeof AdminShellSettingsUsersRouteImport - parentRoute: typeof AdminShellSettingsRoute + '/_admin/sites/$siteId/deployments': { + id: '/_admin/sites/$siteId/deployments' + path: '/deployments' + fullPath: '/sites/$siteId/deployments' + preLoaderRoute: typeof AdminSitesSiteIdDeploymentsRouteImport + parentRoute: typeof AdminSitesSiteIdRoute } - '/_admin/_shell/settings/storage': { - id: '/_admin/_shell/settings/storage' - path: '/storage' - fullPath: '/settings/storage' - preLoaderRoute: typeof AdminShellSettingsStorageRouteImport - parentRoute: typeof AdminShellSettingsRoute + '/_admin/sites/$siteId/files': { + id: '/_admin/sites/$siteId/files' + path: '/files' + fullPath: '/sites/$siteId/files' + preLoaderRoute: typeof AdminSitesSiteIdFilesRouteImport + parentRoute: typeof AdminSitesSiteIdRoute } - '/_admin/_shell/settings/security': { - id: '/_admin/_shell/settings/security' - path: '/security' - fullPath: '/settings/security' - preLoaderRoute: typeof AdminShellSettingsSecurityRouteImport - parentRoute: typeof AdminShellSettingsRoute + '/_admin/sites/$siteId/settings': { + id: '/_admin/sites/$siteId/settings' + path: '/settings' + fullPath: '/sites/$siteId/settings' + preLoaderRoute: typeof AdminSitesSiteIdSettingsRouteImport + parentRoute: typeof AdminSitesSiteIdRoute } - '/_admin/_shell/settings/backups': { - id: '/_admin/_shell/settings/backups' - path: '/backups' - fullPath: '/settings/backups' - preLoaderRoute: typeof AdminShellSettingsBackupsRouteImport - parentRoute: typeof AdminShellSettingsRoute + '/_admin/sites/$siteId/entries/$collectionSlug': { + id: '/_admin/sites/$siteId/entries/$collectionSlug' + path: '/entries/$collectionSlug' + fullPath: '/sites/$siteId/entries/$collectionSlug' + preLoaderRoute: typeof AdminSitesSiteIdEntriesCollectionSlugRouteImport + parentRoute: typeof AdminSitesSiteIdRoute } '/_admin/sites/$siteId/settings/': { id: '/_admin/sites/$siteId/settings/' @@ -534,25 +541,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminSitesSiteIdSettingsIndexRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } - '/_admin/sites/$siteId/singletons/$slug': { - id: '/_admin/sites/$siteId/singletons/$slug' - path: '/singletons/$slug' - fullPath: '/sites/$siteId/singletons/$slug' - preLoaderRoute: typeof AdminSitesSiteIdSingletonsSlugRouteImport - parentRoute: typeof AdminSitesSiteIdRoute - } - '/_admin/sites/$siteId/settings/webhooks': { - id: '/_admin/sites/$siteId/settings/webhooks' - path: '/webhooks' - fullPath: '/sites/$siteId/settings/webhooks' - preLoaderRoute: typeof AdminSitesSiteIdSettingsWebhooksRouteImport + '/_admin/sites/$siteId/settings/api-keys': { + id: '/_admin/sites/$siteId/settings/api-keys' + path: '/api-keys' + fullPath: '/sites/$siteId/settings/api-keys' + preLoaderRoute: typeof AdminSitesSiteIdSettingsApiKeysRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } - '/_admin/sites/$siteId/settings/members': { - id: '/_admin/sites/$siteId/settings/members' - path: '/members' - fullPath: '/sites/$siteId/settings/members' - preLoaderRoute: typeof AdminSitesSiteIdSettingsMembersRouteImport + '/_admin/sites/$siteId/settings/backups': { + id: '/_admin/sites/$siteId/settings/backups' + path: '/backups' + fullPath: '/sites/$siteId/settings/backups' + preLoaderRoute: typeof AdminSitesSiteIdSettingsBackupsRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } '/_admin/sites/$siteId/settings/mcp': { @@ -562,25 +562,25 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AdminSitesSiteIdSettingsMcpRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } - '/_admin/sites/$siteId/settings/backups': { - id: '/_admin/sites/$siteId/settings/backups' - path: '/backups' - fullPath: '/sites/$siteId/settings/backups' - preLoaderRoute: typeof AdminSitesSiteIdSettingsBackupsRouteImport + '/_admin/sites/$siteId/settings/members': { + id: '/_admin/sites/$siteId/settings/members' + path: '/members' + fullPath: '/sites/$siteId/settings/members' + preLoaderRoute: typeof AdminSitesSiteIdSettingsMembersRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } - '/_admin/sites/$siteId/settings/api-keys': { - id: '/_admin/sites/$siteId/settings/api-keys' - path: '/api-keys' - fullPath: '/sites/$siteId/settings/api-keys' - preLoaderRoute: typeof AdminSitesSiteIdSettingsApiKeysRouteImport + '/_admin/sites/$siteId/settings/webhooks': { + id: '/_admin/sites/$siteId/settings/webhooks' + path: '/webhooks' + fullPath: '/sites/$siteId/settings/webhooks' + preLoaderRoute: typeof AdminSitesSiteIdSettingsWebhooksRouteImport parentRoute: typeof AdminSitesSiteIdSettingsRoute } - '/_admin/sites/$siteId/entries/$collectionSlug': { - id: '/_admin/sites/$siteId/entries/$collectionSlug' - path: '/entries/$collectionSlug' - fullPath: '/sites/$siteId/entries/$collectionSlug' - preLoaderRoute: typeof AdminSitesSiteIdEntriesCollectionSlugRouteImport + '/_admin/sites/$siteId/singletons/$slug': { + id: '/_admin/sites/$siteId/singletons/$slug' + path: '/singletons/$slug' + fullPath: '/sites/$siteId/singletons/$slug' + preLoaderRoute: typeof AdminSitesSiteIdSingletonsSlugRouteImport parentRoute: typeof AdminSitesSiteIdRoute } '/_admin/sites/$siteId/entries/$collectionSlug/': { diff --git a/apps/dashboard/src/routes/__root.tsx b/apps/dashboard/src/routes/__root.tsx index 8a3551fb..f557d1ea 100644 --- a/apps/dashboard/src/routes/__root.tsx +++ b/apps/dashboard/src/routes/__root.tsx @@ -1,10 +1,7 @@ -import { TanStackDevtools } from "@tanstack/react-devtools"; import type { QueryClient } from "@tanstack/react-query"; import { createRootRouteWithContext, Outlet } from "@tanstack/react-router"; -import { TanStackRouterDevtoolsPanel } from "@tanstack/react-router-devtools"; import { Toaster } from "@/components/ui/sonner"; import { AuthProvider } from "@/contexts/auth-context"; -import TanStackQueryDevtools from "../integrations/tanstack-query/devtools"; interface MyRouterContext { queryClient: QueryClient; @@ -15,18 +12,6 @@ export const Route = createRootRouteWithContext()({ - , - }, - TanStackQueryDevtools, - ]} - /> ), }); diff --git a/apps/dashboard/src/styles.css b/apps/dashboard/src/styles.css index 527d2993..c6faa9ba 100644 --- a/apps/dashboard/src/styles.css +++ b/apps/dashboard/src/styles.css @@ -616,16 +616,16 @@ /* Floating overlays: fully OPAQUE tinted surface (`--popover`), no blur — they cover arbitrary content, so nothing may bleed through. */ :is(.theme-dark, .theme-light) - :is( - [data-slot="popover-content"], - [data-slot="dropdown-menu-content"], - [data-slot="select-content"], - [data-slot="dialog-content"], - [data-slot="alert-dialog-content"], - [data-slot="sheet-content"], - [data-slot="drawer-content"], - [data-slot="tooltip-content"] - ) { +:is( + [data-slot="popover-content"], + [data-slot="dropdown-menu-content"], + [data-slot="select-content"], + [data-slot="dialog-content"], + [data-slot="alert-dialog-content"], + [data-slot="sheet-content"], + [data-slot="drawer-content"], + [data-slot="tooltip-content"] +) { background: var(--popover); } @@ -715,15 +715,15 @@ /* Premium hover timing (150–180ms) on the surfaces users actually touch. */ :is(.theme-dark, .theme-light) - :is( - a, - [data-slot="button"], - [data-slot="table-row"], - [data-slot="sidebar-menu-button"], - [data-slot="input"], - [data-slot="textarea"], - [data-slot="card"] - ) { +:is( + a, + [data-slot="button"], + [data-slot="table-row"], + [data-slot="sidebar-menu-button"], + [data-slot="input"], + [data-slot="textarea"], + [data-slot="card"] +) { transition-property: color, background-color, border-color, box-shadow, transform, opacity; transition-duration: 165ms; @@ -733,15 +733,15 @@ /* Mirror the selector above so specificity ties and this later rule wins — neutralizes the added motion without needing !important. */ :is(.theme-dark, .theme-light) - :is( - a, - [data-slot="button"], - [data-slot="table-row"], - [data-slot="sidebar-menu-button"], - [data-slot="input"], - [data-slot="textarea"], - [data-slot="card"] - ) { + :is( + a, + [data-slot="button"], + [data-slot="table-row"], + [data-slot="sidebar-menu-button"], + [data-slot="input"], + [data-slot="textarea"], + [data-slot="card"] + ) { transition-duration: 0.01ms; } } @@ -749,12 +749,12 @@ /* Layered shadows on the shadow-bearing slots (lg menus, xl modals; the card shadow lives in the card rule above, merged with its hairline ring). */ :is(.theme-dark, .theme-light) - :is( - [data-slot="popover-content"], - [data-slot="dropdown-menu-content"], - [data-slot="select-content"], - [data-slot="tooltip-content"] - ) { +:is( + [data-slot="popover-content"], + [data-slot="dropdown-menu-content"], + [data-slot="select-content"], + [data-slot="tooltip-content"] +) { box-shadow: var(--cm-shadow-lg); } :is(.theme-dark, .theme-light) @@ -797,7 +797,7 @@ /* Inputs: recessed translucent fill, themed focus glow. */ :is(.theme-dark, .theme-light) - :is([data-slot="input"], [data-slot="textarea"]) { +:is([data-slot="input"], [data-slot="textarea"]) { background: var(--input); } :is(.theme-dark, .theme-light) diff --git a/apps/dashboard/vite.config.ts b/apps/dashboard/vite.config.ts index 35162a0f..0f38b4ba 100644 --- a/apps/dashboard/vite.config.ts +++ b/apps/dashboard/vite.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ ], resolve: { alias: { - "@": resolve(__dirname, "./src"), + "@": resolve(import.meta.dirname, "./src"), }, }, server: { diff --git a/apps/web/content/docs/(cms)/explanation/content-lifecycle.mdx b/apps/web/content/docs/(cms)/explanation/content-lifecycle.mdx new file mode 100644 index 00000000..ed863dd4 --- /dev/null +++ b/apps/web/content/docs/(cms)/explanation/content-lifecycle.mdx @@ -0,0 +1,12 @@ +--- +title: Content lifecycle +description: Understand drafts, publishing, revisions, deletion, and deployment triggers. +--- + +An entry starts as a draft. Editors can save work without signaling that consumers should present it as published content. Publishing changes the status; unpublishing returns it to draft. + +Each meaningful change can carry a summary and produces revision history. Restoring a prior revision makes its data current while preserving later history for audit and recovery. + +Deleting an entry removes it directly. Files use a separate trash workflow because stored binaries often need reference checks and a recovery window. + +Deployment triggers remain separate from publishing. This lets a team publish several entries, review them, then run one frontend build. Webhooks serve event receivers that need individual content notifications. diff --git a/apps/web/content/docs/(cms)/explanation/content-model.mdx b/apps/web/content/docs/(cms)/explanation/content-model.mdx new file mode 100644 index 00000000..7692ac8a --- /dev/null +++ b/apps/web/content/docs/(cms)/explanation/content-model.mdx @@ -0,0 +1,22 @@ +--- +title: How content modeling works +description: Understand schemas, entries, singletons, relationships, and channel-neutral content. +--- + +Velopulent CMS separates content structure from presentation. A collection definition describes fields. Entries provide values. Frontends decide how to render those values. + +## Collections describe repeatable ideas + +An Authors collection can hold Maya, Arun, and Lena. A Posts collection can hold hundreds of articles. Each collection gives every entry the same editorial form and validation rules. + +## Singletons describe one site-wide idea + +Site Settings has one value set. Editors update that record instead of creating another. This makes singleton models useful for navigation, contact details, or a home page. + +## Relationships preserve shared meaning + +A Post relationship points to an Author entry. Updating the author changes the shared record instead of copied author data across posts. Consumers choose whether to fetch or resolve related content. + +## Sites form isolation boundaries + +Each site owns its schemas, entries, singletons, files, webhooks, deployment triggers, keys, members, and backups. Use separate sites when content, credentials, or teams need separate lifecycles. diff --git a/apps/web/content/docs/(cms)/explanation/meta.json b/apps/web/content/docs/(cms)/explanation/meta.json new file mode 100644 index 00000000..bf3678f9 --- /dev/null +++ b/apps/web/content/docs/(cms)/explanation/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Concepts", + "icon": "Lightbulb", + "defaultOpen": true, + "pages": ["content-model", "content-lifecycle", "sites-and-roles"] +} diff --git a/apps/web/content/docs/(cms)/explanation/sites-and-roles.mdx b/apps/web/content/docs/(cms)/explanation/sites-and-roles.mdx new file mode 100644 index 00000000..112df453 --- /dev/null +++ b/apps/web/content/docs/(cms)/explanation/sites-and-roles.mdx @@ -0,0 +1,20 @@ +--- +title: Sites and two-tier roles +description: Understand installation authority and site collaboration. +--- + +Velopulent CMS separates installation operations from site collaboration. + +## Instance roles + +The owner controls the trust boundary: role grants and instance-wide backup or restore. Admins manage users and sites. Both operator roles receive full authority inside every site without membership records. + +## Site roles + +Editors create content, manage files, and trigger deployments for assigned sites. Viewers read. Site roles cannot change schemas, integrations, keys, members, or site identity. + +This boundary keeps editorial work available without giving collaborators control over credentials or infrastructure-facing hooks. + +## Credentials follow the same boundary + +Site keys stay scoped to one site and can never manage membership, other keys, instance settings, or backups. Personal tokens carry one user's identity and scopes. Dashboard sessions represent interactive sign-ins. diff --git a/apps/web/content/docs/(cms)/guides/account.mdx b/apps/web/content/docs/(cms)/guides/account.mdx new file mode 100644 index 00000000..aba73134 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/account.mdx @@ -0,0 +1,22 @@ +--- +title: Secure your account +description: Change profile details, password, sessions, and personal access tokens. +--- + +## Update profile and password + +Open the account menu and select **Account**. Your email identifies the login; display names do not need to be unique. Change the temporary first-run password immediately. + +## Review sessions + +The sessions list shows active sign-ins. Revoke all other sessions after a lost device, unexpected access, or password exposure. + +## Create a personal token + +Personal tokens act with the user's identity and assigned scopes. Give each integration a descriptive name, minimal scopes, and an expiry date when possible. + +Copy the raw token when created. The dashboard stores and displays only its prefix afterward. + +## Revoke a token + +Revoke the token from the account page, then replace it in the integration. Token revocation does not sign out dashboard sessions. diff --git a/apps/web/content/docs/(cms)/guides/backups.mdx b/apps/web/content/docs/(cms)/guides/backups.mdx new file mode 100644 index 00000000..ed8ee586 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/backups.mdx @@ -0,0 +1,35 @@ +--- +title: Back up and restore from the dashboard +description: Protect one site or the full installation with on-demand and scheduled backups. +--- + +Site backups contain one self-contained site. Instance backups include all sites plus users and roles. Sessions, `secrets.toml`, and backup metadata stay outside backup payloads. + + + +## Create a backup + +Open **Backups** under site or instance settings. Choose whether to include files, enable encryption when required, select a destination profile, then create the backup. + +Instance-wide backup and restore require the instance owner. Instance admins can operate site backups. + +## Schedule backups + +Choose a daily, weekly, monthly, or custom cron schedule. Set retention, file inclusion, encryption, destination, and enabled state. Use **Run now** to test it. + +## Restore + +Inspect the artifact, choose in-place restore or import-as-new where available, and enter the exact typed confirmation. Restore replaces data within the selected scope in one transaction. + + +Create and verify a separate backup before restoration. Keep the encryption key apart from encrypted artifacts. + + +Backups normalize data for SQLite/PostgreSQL portability. A server refuses artifacts created on a newer database schema. + +For a server that cannot start, use [offline recovery](/docs/server/guides/recovery). diff --git a/apps/web/content/docs/(cms)/guides/content-modeling.mdx b/apps/web/content/docs/(cms)/guides/content-modeling.mdx new file mode 100644 index 00000000..84690916 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/content-modeling.mdx @@ -0,0 +1,48 @@ +--- +title: Model content +description: Create collections and singletons that remain useful across channels. +--- + +Instance owners and admins can change a site's schema. Editors and viewers can read it but cannot alter it. + + + +## Choose collection or singleton + +Use a **collection** when editors create many records: posts, authors, products, or events. Use a **singleton** when the site owns one record: global settings, navigation, home page, or contact details. + +## Create a model + +1. Open a site and select **Collections**. +2. Choose **New collection**. +3. Enter a human-readable name and stable slug. +4. Select collection or singleton. +5. Add fields and validation rules. +6. Save, then create a sample record before expanding the model. + + +Frontend code and relationships can depend on collection and entry slugs. Plan slug changes with the developers who consume the content. + + +## Design useful editorial fields + +- Give fields labels that editors recognize. +- Mark a field required only when every record needs it. +- Use relationship fields instead of copying shared data. +- Use file restrictions to keep pickers relevant. +- Put site-wide values in a singleton instead of repeating them on entries. + +## Change an existing model + +Open **Collections**, select the model, change its definition, and save. Test the updated form against old and new entries. Existing entry data may predate a newly required field, so schedule editorial cleanup before consumers assume the field exists. + +## Delete a model + +Confirm that no frontend, relationship, automation, or agent uses the model. Export or back up the site, then delete it. Schema deletion is an operator-only action. + +See [Field reference](/docs/reference/fields) for supported controls and [Content model concepts](/docs/explanation/content-model) for design guidance. diff --git a/apps/web/content/docs/(cms)/guides/entries.mdx b/apps/web/content/docs/(cms)/guides/entries.mdx new file mode 100644 index 00000000..a100c28b --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/entries.mdx @@ -0,0 +1,42 @@ +--- +title: Work with entries +description: Create, publish, unpublish, revise, restore, search, and remove content. +--- + +## Create a draft + + + +Open a collection from the site sidebar, choose **New entry**, fill its required fields, assign a unique slug, and save. Velopulent CMS validates entry data against the current collection definition. + +## Publish or unpublish + +A draft stays out of published-content workflows until you choose **Publish**. Choose **Unpublish** to return published content to draft state. Coordinate unpublishing with frontend owners because links may stop resolving. + +## Record a useful revision + +When editing an entry, add a change summary such as `Correct author attribution`. The revisions panel lists numbered snapshots. Open a revision to inspect it or restore it as the current entry state. + +Restoring creates a new current state; it does not erase revision history. + +## Find content + +Use collection filters for status and search. The API accepts page and per-page controls; the dashboard applies them while browsing larger collections. + +## Delete content + +Check relationships and frontend usage first. Entry deletion removes the record rather than moving it to the file trash. Create a site backup before a bulk cleanup. + +### Common validation failures + +| Symptom | Check | +| --- | --- | +| Save button returns an error | Required fields, field types, and relationship target | +| Slug rejected | Use a unique slug valid for the collection | +| File missing from picker | File media type and field restriction | +| Relationship target absent | Target collection and your site access | diff --git a/apps/web/content/docs/(cms)/guides/files.mdx b/apps/web/content/docs/(cms)/guides/files.mdx new file mode 100644 index 00000000..b1383006 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/files.mdx @@ -0,0 +1,32 @@ +--- +title: Manage files +description: Upload, reference, serve, trash, restore, and permanently delete site media. +--- + +Files belong to one site and use that site's selected storage profile. + +## Upload files + +Open **Files**, choose **Upload**, select the files, and wait for each upload to complete. Fresh instances accept files up to 50 MB unless an owner changes the upload limit. + +Images can receive thumbnails. Other media remains available through its original file endpoint. + +## Use a file in content + +Open an entry and use its file field. The picker filters by the field's accepted media category. Select the file and save the entry. + +## Check references + +Before deletion, open the file's references. Replace or remove references from entries that still need valid media. + +## Trash and restore + +Delete moves a file to trash. Open the trash view and choose **Restore** to return it. Batch actions can trash or restore several selected files. + +## Permanently delete + +Permanent deletion removes selected trashed files from storage and cannot be undone through the dashboard. Confirm references and create a backup first. + + +Deleting a file record does not rewrite content that points to it. Resolve references before permanent deletion. + diff --git a/apps/web/content/docs/(cms)/guides/meta.json b/apps/web/content/docs/(cms)/guides/meta.json new file mode 100644 index 00000000..6b776f94 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/meta.json @@ -0,0 +1,14 @@ +{ + "title": "How-to guides", + "icon": "Wrench", + "defaultOpen": true, + "pages": [ + "content-modeling", + "entries", + "files", + "team", + "webhooks-deployments", + "backups", + "account" + ] +} diff --git a/apps/web/content/docs/(cms)/guides/team.mdx b/apps/web/content/docs/(cms)/guides/team.mdx new file mode 100644 index 00000000..0f3297ba --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/team.mdx @@ -0,0 +1,30 @@ +--- +title: Manage users and collaborators +description: Create installation users, grant operator roles, and assign site access. +--- + +## Create a user + +An instance owner or admin opens **Instance settings → Users**, creates the account, and sets an initial password. Send credentials through a secure channel and ask the user to change the password after sign-in. + +## Grant an instance role + +Only the instance owner can grant or revoke instance roles. + +- **Instance owner** controls the installation, grants roles, and performs instance-wide backup and restore. +- **Instance admin** manages users and sites and has full authority within every site. + +Operators do not need site memberships. Velopulent CMS rejects attempts to invite them as collaborators. + +## Invite a site collaborator + +Open **Site settings → Members**, invite an existing user by email, and choose: + +- **Editor** for content and file changes. +- **Viewer** for read-only access. + +Operators manage schemas, webhooks, API keys, members, and site deletion. Site roles do not receive those powers. + +## Remove access + +Remove a site membership to revoke that site's access. Delete or disable broader credentials separately: personal tokens belong to the user, site keys belong to the site, and active dashboard sessions remain visible from the account page. diff --git a/apps/web/content/docs/(cms)/guides/webhooks-deployments.mdx b/apps/web/content/docs/(cms)/guides/webhooks-deployments.mdx new file mode 100644 index 00000000..0c58e924 --- /dev/null +++ b/apps/web/content/docs/(cms)/guides/webhooks-deployments.mdx @@ -0,0 +1,30 @@ +--- +title: Connect webhooks and deployments +description: Notify other systems and trigger frontend builds from the dashboard. +--- + +## Create a webhook + +1. Open **Site settings → Webhooks**. +2. Add the destination URL and select the events it should receive. +3. Save, then use the manual trigger to verify the receiver. +4. Inspect delivery history for response status and failures. + +Private network targets are blocked by default. An instance owner can allow them under security settings when the network design requires it. + +## Create a deployment trigger + +Open **Deploy**, add a label and deployment URL, then configure cooldown and daily quota. Enable the trigger and mark one primary when appropriate. + +Editors can run an enabled trigger. Operators create and change trigger definitions. The history view records queued work, status codes, error categories, retry delays, and completion time. + + +Use deployment triggers for build hooks. Use webhooks when receivers need event-specific content notifications. + + +## Troubleshoot delivery + +- Confirm the receiver accepts requests from the VCMS Server host. +- Inspect status code and retry information. +- Check cooldown and daily quota for deployment triggers. +- Ask an owner to review the private-target setting when using an internal address. diff --git a/apps/web/content/docs/(cms)/index.mdx b/apps/web/content/docs/(cms)/index.mdx new file mode 100644 index 00000000..6563d312 --- /dev/null +++ b/apps/web/content/docs/(cms)/index.mdx @@ -0,0 +1,39 @@ +--- +title: Velopulent CMS +description: Model, edit, publish, and protect structured content from one dashboard. +--- + +Velopulent CMS gives content teams a dashboard while developers consume the same content through APIs. One installation can host many isolated sites. + + + +## Choose a path + + + } title="Create an editorial site" href="/docs/tutorials/editorial-site" description="Learn the product through a complete working example." /> + } title="Model content" href="/docs/guides/content-modeling" description="Create collections, singletons, fields, and relationships." /> + } title="Manage entries" href="/docs/guides/entries" description="Draft, publish, revise, and restore content." /> + } title="Understand permissions" href="/docs/reference/permissions" description="Compare owners, admins, editors, viewers, and tokens." /> + + +## Product map + +| Area | Purpose | +| --- | --- | +| Sites | Isolate schemas, content, files, integrations, and collaborators | +| Collections | Define repeatable records such as posts and authors | +| Singletons | Define one record such as site settings or a home page | +| Files | Upload, browse, reference, trash, restore, and serve media | +| Deploy | Notify a frontend build service when content changes | +| Settings | Manage site identity, members, keys, MCP, webhooks, and backups | + +Instance operators also receive installation-wide settings for users, security, storage, and backups. + + +Menu items depend on your role. A viewer sees readable site resources. An editor can change content and files. Instance operators manage schemas and site settings. + diff --git a/apps/web/content/docs/(cms)/meta.json b/apps/web/content/docs/(cms)/meta.json new file mode 100644 index 00000000..4d5b23bf --- /dev/null +++ b/apps/web/content/docs/(cms)/meta.json @@ -0,0 +1,27 @@ +{ + "title": "Velopulent CMS", + "description": "Create and manage structured content", + "icon": "PanelsTopLeft", + "root": true, + "pages": [ + "index", + "---[GraduationCap]Learn---", + "tutorials/editorial-site", + "---[Wrench]Workflows---", + "guides/content-modeling", + "guides/entries", + "guides/files", + "guides/team", + "guides/webhooks-deployments", + "guides/backups", + "guides/account", + "---[BookOpenText]Reference---", + "reference/fields", + "reference/permissions", + "reference/dashboard", + "---[Lightbulb]Concepts---", + "explanation/content-model", + "explanation/content-lifecycle", + "explanation/sites-and-roles" + ] +} diff --git a/apps/web/content/docs/(cms)/reference/dashboard.mdx b/apps/web/content/docs/(cms)/reference/dashboard.mdx new file mode 100644 index 00000000..9ee4e0c1 --- /dev/null +++ b/apps/web/content/docs/(cms)/reference/dashboard.mdx @@ -0,0 +1,23 @@ +--- +title: Dashboard reference +description: Routes, areas, and controls in the Velopulent CMS dashboard. +--- + +| Location | Contents | +| --- | --- | +| Sites | Accessible sites and site creation for operators | +| Site overview | Collection counts, published and draft totals, recent content | +| Collections | Schema list and model editor for operators | +| Collection name | Entry list, filters, create and edit actions | +| Singleton name | The site's one record for that model | +| Files | Media grid, search, categories, trash, batch actions | +| Deploy | Build triggers and run history | +| Site settings | General, webhooks, API keys, MCP, members, backups | +| Account | Profile, password, sessions, personal tokens | +| Instance settings | General, security, storage, users, backups | + +The dashboard lives at `/dashboard`. Authentication routes live under `/api/auth`; dashboard resource requests use `/api/dashboard/v1`. + +## Troubleshooting navigation + +If a menu item is absent, compare your role with the [permission reference](./permissions). If the whole site is absent, ask an operator to verify membership. Operators should verify that the requested site still exists and that the user is not already an instance operator before inviting them. diff --git a/apps/web/content/docs/(cms)/reference/fields.mdx b/apps/web/content/docs/(cms)/reference/fields.mdx new file mode 100644 index 00000000..cdf1aba9 --- /dev/null +++ b/apps/web/content/docs/(cms)/reference/fields.mdx @@ -0,0 +1,40 @@ +--- +title: Field reference +description: Supported content controls and common validation settings. +--- + +Collection definitions store named fields with a type and type-specific options. The dashboard builds entry forms from that definition. + +| Field | Use it for | Common settings | +| --- | --- | --- | +| Text | Titles, names, slugs, short labels | Required, minimum/maximum length, pattern | +| Long text | Summaries and plain multi-line copy | Required, length limits | +| Rich text | Formatted editorial body content | Required | +| Number | Counts, prices, rankings | Required, minimum, maximum | +| Boolean | On/off editorial choices | Default value | +| Email | Email addresses | Required | +| URL | Web addresses | Required, allowed or excluded domains | +| Date/time | Publication and event times | Required | +| Select | One value from a controlled list | Options, required | +| JSON | Structured values without a dedicated control | Required, valid JSON | +| File | Images, documents, audio, or video | Accepted MIME types, single/multiple, selection bounds | +| Relationship | A reference to another collection entry | Target collection, single/multiple, selection bounds, cascade deletion | + + +The live collection editor shows the options supported by the running release. Test any schema change against an existing entry before treating a new field as universally present. + + +## Naming + +- **Label** appears to editors. +- **Key** appears in stored entry data and API payloads. +- **Collection slug** identifies the model in URLs and queries. +- **Entry slug** identifies one content record within its collection. + +Keep keys and slugs stable after integrations begin consuming them. + +## Shared field options + +Fields can provide editor help text and mark one value as the presentable label for relationship pickers. Text supports length bounds; plain text also supports a validation pattern. Rich text and JSON support a maximum stored size. Select, file, and relationship fields can hold one value or an array with minimum and maximum selection counts. + +Options marked as stored-only in the collection editor do not change runtime behavior yet. Do not depend on hidden, protected-file, or rich-text URL-stripping flags until the running product exposes their behavior. diff --git a/apps/web/content/docs/(cms)/reference/meta.json b/apps/web/content/docs/(cms)/reference/meta.json new file mode 100644 index 00000000..b8bcc42e --- /dev/null +++ b/apps/web/content/docs/(cms)/reference/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Reference", + "icon": "BookOpenText", + "defaultOpen": true, + "pages": ["fields", "permissions", "dashboard"] +} diff --git a/apps/web/content/docs/(cms)/reference/permissions.mdx b/apps/web/content/docs/(cms)/reference/permissions.mdx new file mode 100644 index 00000000..71c77706 --- /dev/null +++ b/apps/web/content/docs/(cms)/reference/permissions.mdx @@ -0,0 +1,48 @@ +--- +title: Permission reference +description: Exact authority boundaries for users, site collaborators, and access tokens. +--- + +## Human roles + +| Capability | Owner | Admin | Editor | Viewer | +| --- | :---: | :---: | :---: | :---: | +| Manage installation and users | ✓ | ✓ | | | +| Grant instance roles | ✓ | | | | +| Create and delete sites | ✓ | ✓ | | | +| Full access to every site | ✓ | ✓ | | | +| Read assigned site | ✓ | ✓ | ✓ | ✓ | +| Write content and files | ✓ | ✓ | ✓ | | +| Trigger deployments | ✓ | ✓ | ✓ | | +| Change schema and site settings | ✓ | ✓ | | | +| Manage webhooks, keys, and members | ✓ | ✓ | | | +| Site backup and restore | ✓ | ✓ | | | +| Instance backup and restore | ✓ | | | | + +Owners and admins act as instance operators. They receive site authority without membership. + +## Access tokens + +Site keys and personal tokens reach content through the public APIs only. Their scopes decide what they can do: + +| Scope | Allows | +| --- | --- | +| `site.read` | List sites and read site details | +| `schema.read` | Read collection and singleton definitions | +| `content.read` | Read published entries and singletons | +| `content.preview.read` | Read drafts and revision history | +| `content.write` | Create, update, and delete entries; update singletons; restore revisions | +| `content.publish` | Publish and unpublish entries | +| `files.read` | Read file metadata and references | +| `files.write` | Upload, trash, and restore files | +| `mcp.use` | Connect to the MCP endpoint | + +A **Read only** site key carries the read scopes and `mcp.use`. A **Read & write** key carries every scope. No token can change schemas, webhooks, deployments, members, keys, instance settings, or backups, or delete a site. + +## Personal tokens + +Personal tokens belong to a user. Each request needs both the token scope and the user's current role on the target site, so a viewer's token cannot read drafts or write even if it carries those scopes. Revoking the user's access or the token removes that path independently. + +## Dashboard sessions + +Browser sessions use secure HTTP-only cookies and CSRF protection for state-changing dashboard requests. Session lifetime and secure-cookie behavior come from instance settings. diff --git a/apps/web/content/docs/(cms)/tutorials/editorial-site.mdx b/apps/web/content/docs/(cms)/tutorials/editorial-site.mdx new file mode 100644 index 00000000..3d128792 --- /dev/null +++ b/apps/web/content/docs/(cms)/tutorials/editorial-site.mdx @@ -0,0 +1,78 @@ +--- +title: Create an editorial site +description: Build Authors, Posts, and Site Settings, then publish the first article. +--- + +This tutorial starts with a fresh installation. You will build the same **Editorial** site used throughout these docs. + +## Before you begin + +You need an instance-owner account and a running server. For a local portable server, follow [Install VCMS Server](/docs/server/install). + + + +### Sign in and change the temporary password + +Open `http://127.0.0.1:3000/dashboard`. Sign in with `admin@cms.local` and `admin`, open **Account**, then replace the password. + +### Create the site + +Return to **Sites**, choose **New site**, enter `Editorial`, select the default filesystem storage profile, and create the site. + +### Create Authors + +Open **Collections**, choose **New collection**, and enter: + +| Setting | Value | +| --- | --- | +| Name | `Authors` | +| Slug | `authors` | +| Type | Collection | + +Add these fields: + +| Field | Type | Settings | +| --- | --- | --- | +| Name | Text | Required | +| Bio | Long text | Optional | +| Portrait | File | Images | + +Save the collection. + +### Create Posts + +Create another collection named `Posts` with slug `posts`. Add Title as required text, Excerpt as long text, Body as rich text, Cover as an image file, Author as a relationship to Authors, and Published at as date/time. + +### Create Site Settings + +Create a **singleton** named `Site Settings` with slug `site-settings`. Add Site title as required text, Description as long text, and Logo as an image file. + +Single-instance content appears in the sidebar by name. Open **Site Settings**, enter the publication name and description, then save. + +### Add an author + +Open **Authors**, choose **New entry**, enter `Maya Chen`, add a short biography, and save. The entry begins as a draft. + +### Upload a cover image + +Open **Files** and upload an image. Velopulent CMS records its media type, size, storage provider, and generated thumbnail when supported. + +### Write and publish a post + +Open **Posts**, create `Designing an editorial workflow`, select Maya as author, choose the uploaded cover, write the body, and save. Review the entry, then choose **Publish**. + +### Read the published content + +Create a read-only key under **Settings → API keys**. Copy it when the dashboard displays it because the full token appears once. Continue with the [API quickstart](/docs/api/quickstart). + + + +## Result + +Your site now has reusable author records, publishable posts, one site-settings record, stored media, and a credential for content delivery. + +## Continue + +- [Model content safely](/docs/guides/content-modeling) +- [Manage drafts and revisions](/docs/guides/entries) +- [Connect a static site](/docs/api/guides/static-sites) diff --git a/apps/web/content/docs/(cms)/tutorials/meta.json b/apps/web/content/docs/(cms)/tutorials/meta.json new file mode 100644 index 00000000..5ecfc4e9 --- /dev/null +++ b/apps/web/content/docs/(cms)/tutorials/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Tutorials", + "icon": "GraduationCap", + "defaultOpen": true, + "pages": ["editorial-site"] +} diff --git a/apps/web/content/docs/api/authentication.mdx b/apps/web/content/docs/api/authentication.mdx new file mode 100644 index 00000000..c2c43fe6 --- /dev/null +++ b/apps/web/content/docs/api/authentication.mdx @@ -0,0 +1,78 @@ +--- +title: Authentication +description: Choose, send, scope, rotate, and revoke VCMS API credentials. +--- + +Send API credentials as a bearer token: + +```http +Authorization: Bearer vcms_site_… +``` + +## Credential types + +| Credential | Prefix | Acts as | Sites | +| --- | --- | --- | --- | +| Site key | `vcms_site_` | The site itself | Only the site that issued it | +| Personal token | `vcms_pat_` | One user, limited by the token's scopes | Any site where that user currently has a role | + +Create site keys under **Site settings → API keys**. Create personal tokens under **Account → Personal tokens**. Use site keys for applications and build pipelines. Use personal tokens for tools that act for one person, such as a local MCP client. + +A personal token never exceeds its user's live role. When the user loses access to a site, the token loses it too. + +## Scopes + +Every request needs the scope for its action: + +| Scope | Allows | +| --- | --- | +| `site.read` | List sites and read site details | +| `schema.read` | Read collection and singleton definitions | +| `content.read` | Read published entries and singletons | +| `content.preview.read` | Read drafts and revision history | +| `content.write` | Create, update, and delete entries; update singletons; restore revisions | +| `content.publish` | Publish and unpublish entries, or change an entry's status | +| `files.read` | Read file metadata and references | +| `files.write` | Upload, trash, and restore files | +| `mcp.use` | Connect to the MCP endpoint | + +A **Read only** site key gets `site.read`, `schema.read`, `content.read`, `files.read`, and `mcp.use`. A **Read & write** key adds `content.preview.read`, `content.write`, `content.publish`, and `files.write`. + +A personal token can carry any of these scopes, but each request still checks the user's role on the target site. Drafts, revisions, writes, and publication need the editor role or an instance role there, so a viewer's write-scoped token is refused. + +A `403` response names the missing scope: + +```json +{ "status": 403, "code": "forbidden", "detail": "Token requires the 'content.preview.read' scope." } +``` + +Tokens never manage schemas, webhooks, deployments, members, keys, or backups. Those operations stay in the dashboard. + +## Choose the site + +Every site-scoped request names its site explicitly, for both credential types: + +| Protocol | Where the site goes | +| --- | --- | +| REST | Path: `/api/v1/sites/{site_id}/…` | +| GraphQL | Query root: `site(id: "…") { … }`; mutation argument `siteId` | +| gRPC | `site_id` field on every site-scoped request | +| MCP | `site_id` argument on every site tool | + +A site key sent with another site's ID gets `403`. Discover IDs with `GET /api/v1/sites`, GraphQL `sites`, gRPC `ListSites`, or the MCP `list_sites` tool. + +## Dashboard sessions + +The dashboard uses a cookie session and CSRF token. Do not automate `/api/dashboard` with a site key. Public integrations should use protocol endpoints and bearer credentials. + +## Credential handling + +- Store tokens in your deployment platform's secret store. +- Give each integration its own token and descriptive name. +- Select the smallest scope set. +- Copy a new raw token once, then close the dialog. +- Revoke exposed or unused tokens and replace them at the consumer. + + +URLs, screenshots, logs, and source code are unsafe places for bearer tokens. Never pass a token in a query string. + diff --git a/apps/web/content/docs/api/guides/files.mdx b/apps/web/content/docs/api/guides/files.mdx new file mode 100644 index 00000000..05b75092 --- /dev/null +++ b/apps/web/content/docs/api/guides/files.mdx @@ -0,0 +1,46 @@ +--- +title: Deliver and upload files +description: Serve originals and thumbnails, upload through any protocol, and reference files from entries. +--- + +## Serve a file + +Originals and thumbnails use opaque file IDs: + +```text +GET /api/files/{id} +GET /api/files/{id}/thumbnail +``` + +These delivery routes do not require bearer authentication. An opaque file URL is not access control; do not store confidential media in VCMS. + +File records carry `url` and `thumbnail_url`. With S3 storage and a public bucket URL configured, `url` points at the bucket; otherwise it is the `/api/files/{id}` route above. + +## Upload + +All uploads need `files.write`. Filenames must be nonempty, contain no control characters, and fit within 255 UTF-8 bytes. The instance upload limit applies to every route. + +| Protocol | How | +| --- | --- | +| REST | `POST /api/v1/sites/{site_id}/files` with multipart field `file`, or create a signed URL with `POST /api/v1/sites/{site_id}/files/upload-url` | +| GraphQL | `createFileUpload(siteId, filename, contentType)` returns a signed URL | +| gRPC | Client-streaming `FileService.UploadFile` | +| MCP | `create_file_upload` returns a signed URL | + +### Signed upload URLs + +Send one HTTP `PUT` to the signed URL with the raw bytes and the exact `Content-Type` used to create it: + +```bash +curl -X PUT "$UPLOAD_URL" \ + -H "Content-Type: image/webp" \ + --data-binary @cover.webp +``` + +The response contains the new file record. A signed URL expires and allows one upload attempt. Request a fresh URL after a failed or interrupted attempt; deleting the file does not make its URL reusable. + +## Reference a file from an entry + +Store the relative URL `/api/files/{id}` in a file field, or anywhere in rich text. VCMS records these references per field, rejects entries that point at missing, trashed, or another site's files, and lists them through `GET /api/v1/sites/{site_id}/files/{id}/references`. + +Absolute `http(s)://` URLs are treated as external links and are not tracked. diff --git a/apps/web/content/docs/api/guides/graphql.mdx b/apps/web/content/docs/api/guides/graphql.mdx new file mode 100644 index 00000000..8b358c5e --- /dev/null +++ b/apps/web/content/docs/api/guides/graphql.mdx @@ -0,0 +1,70 @@ +--- +title: Use GraphQL +description: Query and change site content through the GraphQL endpoint. +--- + +GraphQL uses `/api/graphql` for the API. Outside production mode, opening the same URL in a browser serves the GraphiQL explorer. + +## Shape of the schema + +The query root has two fields: + +- `sites` lists the sites the credential can read (needs `site.read`). +- `site(id:)` selects one site. Everything about that site's content lives beneath it. + +```graphql +{ + site(id: "SITE_ID") { + collections { slug name } + entries(collectionSlug: "posts", perPage: 20) { + nodes { id slug data publishedAt } + totalCount + pageInfo { hasNextPage } + } + singletons { slug data } + } +} +``` + +Each field under `site` checks its own scope, so a token with only `content.read` can read entries without `site.read`. The `name`, `createdAt`, and `updatedAt` fields of a site need `site.read`. + +`Site` also exposes `collection(slug:)`, `entry(id:)`, `entryRevisions(entryId:)`, `entryRevision(entryId:, revisionNumber:)`, `files`, `file(id:)`, and `fileReferences(fileId:)`. Reads return published content unless you pass `includeDrafts: true`, which needs `content.preview.read`. Revision fields always need it. + +## Send a request + +```bash +curl http://127.0.0.1:3000/api/graphql \ + -H "Authorization: Bearer $VCMS_TOKEN" \ + -H "Content-Type: application/json" \ + -d "{\"query\":\"{ site(id: \\\"$VCMS_SITE_ID\\\") { entries(collectionSlug: \\\"posts\\\") { nodes { slug data } } } }\"}" +``` + +## Mutations + +Mutations take an explicit `siteId`: + +| Mutation | Scope | +| --- | --- | +| `createEntry`, `updateEntry`, `deleteEntry`, `restoreRevision` | `content.write` | +| `setEntryPublication`, or `updateEntry` with a `status` | `content.publish` | +| `updateSingleton` | `content.write` | +| `createFileUpload`, `deleteFile`, `restoreFile` | `files.write` | + +`updateEntry` and `updateSingleton` accept `expectedVersion`. When it no longer matches the stored version, the mutation fails and writes nothing. + +```graphql +mutation { + setEntryPublication(siteId: "SITE_ID", id: "ENTRY_ID", published: true) { + id + status + } +} +``` + +## Limits + +Queries are limited to depth 15, complexity 2000, and 256 aliases. `Collection.entries` returns at most 200 entries; page through `site.entries` for more. Introspection needs an authenticated request. + + +GraphQL authorization matches REST authorization. A query cannot expand a token's site or action permissions. + diff --git a/apps/web/content/docs/api/guides/grpc.mdx b/apps/web/content/docs/api/guides/grpc.mdx new file mode 100644 index 00000000..ef6ddca2 --- /dev/null +++ b/apps/web/content/docs/api/guides/grpc.mdx @@ -0,0 +1,63 @@ +--- +title: Use gRPC +description: Connect typed service clients to the VCMS gRPC listener. +--- + +VCMS Server listens for gRPC on `127.0.0.1:50051` by default. Generate a client from `libs/proto/cms.proto`; it imports the standard `google/protobuf/struct.proto` and `timestamp.proto`. + +## Services + +| Service | Operations | +| --- | --- | +| `SiteService` | `ListSites`, `GetSite` | +| `CollectionService` | `ListCollections`, `GetCollection` | +| `EntryService` | List, get, create, update, delete, publish, unpublish, and revisions | +| `SingletonService` | `ListSingletons`, `GetSingleton`, `UpdateSingleton` | +| `FileService` | List, get, references, streaming upload, trash, restore | + +Schema, webhook, and member management stay in the dashboard. + +## Authentication and site selection + +Attach a site key or personal token as metadata: + +```text +authorization: Bearer vcms_site_… +``` + +Every site-scoped request carries `site_id`. A request without it fails with `INVALID_ARGUMENT`. A site key sent with another site's ID fails with `PERMISSION_DENIED`, as does a missing scope; the message names the scope. + +## Content and timestamps + +Entry data, singleton data, and collection definitions use `google.protobuf.Struct`. Timestamps use `google.protobuf.Timestamp`. Struct numbers are doubles, so integers above 2^53 lose precision. + +Reads return published content unless the request sets `include_drafts`, which needs `content.preview.read`. Revisions always need it. + +## Paging + +List RPCs take `page_size` (default 50, maximum 200) and `page_token`. Pass each response's `next_page_token` back unchanged with the same filters; an empty token means the last page. `total_size` reports the total match count. + +## Conditional updates + +`Entry.version` and `Singleton.version` change on every write. Set `expected_version` on `UpdateEntry` or `UpdateSingleton` to make the write conditional. A stale version fails with `FAILED_PRECONDITION` and nothing is written. + +## Upload a file + +`UploadFile` is client-streaming. The first message carries `site_id`, `filename`, `content_type`, and the first chunk. Later messages carry only chunks. The instance upload limit applies to the whole stream. + +## Inspect with grpcurl + +The server supports reflection: + +```bash +grpcurl -plaintext -H "authorization: Bearer $VCMS_TOKEN" \ + 127.0.0.1:50051 list + +grpcurl -plaintext -H "authorization: Bearer $VCMS_TOKEN" \ + -d "{\"site_id\": \"$VCMS_SITE_ID\"}" \ + 127.0.0.1:50051 cms.v1.CollectionService/ListCollections +``` + +## Network placement + +The default listener accepts local connections only. To accept remote clients, change `[server].grpc_address` in `config.toml` or put a gRPC-capable proxy in front of it, and terminate TLS there. The health and reflection services do not require authentication, so keep the listener inside a controlled network. diff --git a/apps/web/content/docs/api/guides/mcp.mdx b/apps/web/content/docs/api/guides/mcp.mdx new file mode 100644 index 00000000..3d779bdc --- /dev/null +++ b/apps/web/content/docs/api/guides/mcp.mdx @@ -0,0 +1,49 @@ +--- +title: Connect an MCP client +description: Give AI tools scoped access through Streamable HTTP or the stdio proxy. +--- + +VCMS Server exposes MCP over Streamable HTTP at `/mcp`. MCP must be enabled in instance settings, and the request host and origin must satisfy the configured allowlists. + +Every MCP credential needs the `mcp.use` scope plus the scopes for the work it does. See [Authentication](/docs/api/authentication#scopes). + +## Direct HTTP + +Point the client at `http://127.0.0.1:3000/mcp` and send a site key or personal token as a bearer token. + +The server supports MCP `2025-06-18`, `2025-11-25`, and `2026-07-28`. Clients on the session-based revisions call `initialize` as usual; `2026-07-28` clients call `server/discover` and send per-request metadata. The server is stateless and does not issue session IDs. + +## Stdio proxy + +Some clients launch a local command instead of connecting to HTTP: + +```json +{ + "command": "vcms", + "args": ["mcp", "stdio"], + "env": { + "VCMS_MCP_TOKEN": "vcms_site_replace_me", + "VCMS_MCP_URL": "http://127.0.0.1:3000" + } +} +``` + +The proxy forwards JSON-RPC to the running server and passes the client's protocol version through. It opens no database or search index of its own. Protocol messages use stdout; logs use stderr. + +## Tools and resources + +Call `tools/list` for tool names and input schemas. Every tool except `list_sites` takes an explicit `site_id`. + +| Area | Tools | +| --- | --- | +| Sites | `list_sites`, `get_site` | +| Entries | `list_entries`, `get_entry`, `create_entry`, `update_entry`, `delete_entry`, `set_entry_publication` | +| Revisions | `list_revisions`, `get_revision`, `restore_revision` | +| Singletons | `list_singletons`, `get_singleton`, `update_singleton` | +| Files | `list_files`, `get_file`, `create_file_upload`, `delete_file`, `restore_file` | + +Tools return structured JSON objects. Reads return published content unless the call sets `include_drafts: true`, which needs `content.preview.read`. + +Collection and singleton schemas are resources, not tools. Use `resources/list` and `resources/templates/list` to find them; `cms://{site_id}/schema` returns a site's full schema. + +The server advertises tools and resources only. It does not offer OAuth discovery, prompts, sampling, or elicitation; provision a VCMS token explicitly. diff --git a/apps/web/content/docs/api/guides/meta.json b/apps/web/content/docs/api/guides/meta.json new file mode 100644 index 00000000..4b42af22 --- /dev/null +++ b/apps/web/content/docs/api/guides/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Protocol guides", + "icon": "Cable", + "defaultOpen": true, + "pages": [ + "rest", + "graphql", + "grpc", + "mcp", + "files", + "webhooks-deployments", + "static-sites" + ] +} diff --git a/apps/web/content/docs/api/guides/rest.mdx b/apps/web/content/docs/api/guides/rest.mdx new file mode 100644 index 00000000..87543af9 --- /dev/null +++ b/apps/web/content/docs/api/guides/rest.mdx @@ -0,0 +1,84 @@ +--- +title: Use the REST API +description: Read and change site content through `/api/v1`. +--- + +REST serves site content under `/api/v1/sites/{site_id}`. Site keys and personal tokens both use this explicit site path. `GET /api/v1/sites` lists the sites a credential can read. + +The examples assume these shell variables: + +```bash +export VCMS_URL='http://127.0.0.1:3000' +export VCMS_SITE_ID='replace_site_id' +export VCMS_TOKEN='vcms_site_replace_me' +``` + +## Read published content + +```bash +# One collection's published entries +curl "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/collections/posts/entries" \ + -H "Authorization: Bearer $VCMS_TOKEN" + +# One entry +curl "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/entries/ENTRY_ID" \ + -H "Authorization: Bearer $VCMS_TOKEN" + +# One singleton +curl "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/singletons/site-settings" \ + -H "Authorization: Bearer $VCMS_TOKEN" +``` + +Reads return published content by default. Add `include_drafts=true` to read drafts; that needs `content.preview.read`. + +## Page through a list + +List responses contain `items` and `page_info`. While `page_info.has_next_page` is true, send `page_info.next_cursor` back as `cursor` with the same query: + +```bash +curl "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/collections/posts/entries?per_page=100&cursor=NEXT_CURSOR" \ + -H "Authorization: Bearer $VCMS_TOKEN" +``` + +A cursor only works with the query that produced it. See [Queries and errors](/docs/api/reference/queries-errors) for every parameter. + +## Create an entry + +Creating an entry needs `content.write`. New entries start as drafts. + +```bash +curl -X POST "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/collections/posts/entries" \ + -H "Authorization: Bearer $VCMS_WRITE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "slug": "api-created-post", + "data": {"title": "Created through REST"} + }' +``` + +## Update without overwriting someone else's edit + +Entry and singleton reads return an `ETag` holding the entry's version. Send it back in `If-Match` with a `PATCH`. If the entry changed in between, the update fails with `412 Precondition Failed` and nothing is written: + +```bash +curl -X PATCH "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/entries/ENTRY_ID" \ + -H "Authorization: Bearer $VCMS_WRITE_TOKEN" \ + -H 'If-Match: "VERSION"' \ + -H "Content-Type: application/json" \ + -d '{"data": {"title": "Edited"}}' +``` + +A JSON body field `expected_version` works the same way. Omit both to update unconditionally. Changing `status` in a `PATCH` also needs `content.publish`. + +## Publish an entry + +Publishing needs `content.publish`: + +```bash +curl -X POST "$VCMS_URL/api/v1/sites/$VCMS_SITE_ID/entries/ENTRY_ID/publish" \ + -H "Authorization: Bearer $VCMS_WRITE_TOKEN" +``` + +`POST …/unpublish` returns the entry to draft. + +Open `/api/v1/docs` on your server for every request body and response schema. diff --git a/apps/web/content/docs/api/guides/static-sites.mdx b/apps/web/content/docs/api/guides/static-sites.mdx new file mode 100644 index 00000000..9aa9cd65 --- /dev/null +++ b/apps/web/content/docs/api/guides/static-sites.mdx @@ -0,0 +1,38 @@ +--- +title: Connect a static site +description: Fetch published content during a frontend build and trigger rebuilds after editorial changes. +--- + +## Store the credential + +Create a read-only site key and save it in your host's build-time secret store as `VCMS_TOKEN`. Save the server's public URL as `VCMS_URL` and site ID as `VCMS_SITE_ID`. + +## Fetch during build + +```ts +const response = await fetch( + `${process.env.VCMS_URL}/api/v1/sites/${process.env.VCMS_SITE_ID}/collections/posts/entries?per_page=100`, + { + headers: { + Authorization: `Bearer ${process.env.VCMS_TOKEN}`, + }, + }, +); + +if (!response.ok) { + throw new Error(`VCMS request failed: ${response.status}`); +} + +const { items: posts, page_info: pageInfo } = await response.json(); +// Continue with ?cursor=pageInfo.next_cursor while pageInfo.has_next_page. +``` + +Keep this code on the build server. Shipping the bearer token to browser JavaScript exposes it. + +## Rebuild after publication + +Add the hosting provider's build-hook URL under **Deploy**. Editors can publish several entries, then run the trigger. Configure cooldown and daily quota to prevent accidental build storms. + +## Handle missing content + +Treat optional fields as optional. A newly required field does not retroactively fill old entries. Decide whether the build skips incomplete content, supplies a fallback, or fails with a specific error. diff --git a/apps/web/content/docs/api/guides/webhooks-deployments.mdx b/apps/web/content/docs/api/guides/webhooks-deployments.mdx new file mode 100644 index 00000000..354a4f95 --- /dev/null +++ b/apps/web/content/docs/api/guides/webhooks-deployments.mdx @@ -0,0 +1,24 @@ +--- +title: Automate webhooks and deployments +description: Integrate event receivers and frontend build hooks. +--- + +Webhooks and deployment triggers are configured in the dashboard. The public REST, GraphQL, gRPC, and MCP APIs do not expose them, and access tokens cannot manage them. + +## Webhooks + +Site operators add webhooks under **Site settings → Webhooks**. Each webhook stores a receiver URL and optional headers, which are encrypted at rest. Operators can send a test delivery and review delivery history. + +Private-address receiver URLs need the instance security setting. Leave it off unless the server must call a trusted internal receiver. + +## Deployment triggers + +Deployment triggers live under **Deploy**. Each trigger stores a provider build-hook URL, a cooldown, a daily quota, an enabled state, and an optional primary marker. Editors and operators can run a trigger after publishing a batch of changes. + +## Receiver checklist + +- Return a success status promptly. +- Queue heavy work in the receiver. +- Make repeated events safe. +- Keep receiver credentials out of URLs when the provider supports headers. +- Review delivery history after changing receiver behavior. diff --git a/apps/web/content/docs/api/index.mdx b/apps/web/content/docs/api/index.mdx new file mode 100644 index 00000000..ee4d181d --- /dev/null +++ b/apps/web/content/docs/api/index.mdx @@ -0,0 +1,26 @@ +--- +title: VCMS API +description: Deliver Velopulent CMS content through REST, GraphQL, gRPC, and MCP. +--- + +One VCMS Server exposes four protocols. Choose the client model that fits your application; all protocols use the same content and authorization rules. + + + } title="API quickstart" href="/docs/api/quickstart" description="Fetch the Editorial site's first published post." /> + } title="Choose a protocol" href="/docs/api/reference/protocols" description="Compare REST, GraphQL, gRPC, and MCP." /> + } title="Authentication" href="/docs/api/authentication" description="Use site keys and personal tokens safely." /> + } title="Operation catalog" href="/docs/api/reference/operations" description="Find resources, live references, and protocol coverage." /> + + +## Live schema references + +Your running server remains the source of truth for generated schemas: + +| Protocol | Reference | +| --- | --- | +| REST | `http://127.0.0.1:3000/api/v1/docs` | +| GraphQL | `http://127.0.0.1:3000/api/graphql` | +| gRPC | `cms.proto` distributed with the project/source release | +| MCP | `tools/list` and `resources/list` after initialization | + +Collection definitions can vary by site, so inspect the active site schema before generating consumer types. diff --git a/apps/web/content/docs/api/meta.json b/apps/web/content/docs/api/meta.json new file mode 100644 index 00000000..0cfbe55d --- /dev/null +++ b/apps/web/content/docs/api/meta.json @@ -0,0 +1,24 @@ +{ + "title": "VCMS API", + "description": "Deliver content to applications and agents", + "icon": "Braces", + "root": true, + "pages": [ + "index", + "---[Rocket]Start---", + "quickstart", + "authentication", + "---[Cable]Protocol guides---", + "guides/rest", + "guides/graphql", + "guides/grpc", + "guides/mcp", + "guides/files", + "guides/webhooks-deployments", + "guides/static-sites", + "---[BookOpenText]Reference---", + "reference/protocols", + "reference/queries-errors", + "reference/operations" + ] +} diff --git a/apps/web/content/docs/api/quickstart.mdx b/apps/web/content/docs/api/quickstart.mdx new file mode 100644 index 00000000..5704ed7a --- /dev/null +++ b/apps/web/content/docs/api/quickstart.mdx @@ -0,0 +1,59 @@ +--- +title: API quickstart +description: Authenticate and read content from the Editorial site over REST. +--- + +## Prerequisites + +- A running server at `http://127.0.0.1:3000` +- The Editorial site from the [product tutorial](/docs/tutorials/editorial-site) +- A read-only site key copied from **Site settings → API keys** + + + +### Set the token + +Use a shell-local variable. Do not commit the token. + +```bash +export VCMS_TOKEN='vcms_site_replace_me' +export VCMS_SITE_ID='replace_site_id' +``` + +PowerShell: + +```powershell +$env:VCMS_TOKEN = "vcms_site_replace_me" +$env:VCMS_SITE_ID = "replace_site_id" +``` + +### Inspect the current site + +```bash +curl http://127.0.0.1:3000/api/v1/sites/$VCMS_SITE_ID \ + -H "Authorization: Bearer $VCMS_TOKEN" +``` + +All site-scoped REST paths require a site ID, including requests made with site keys. Use `GET /api/v1/sites` to discover accessible sites. + +### Find the Posts collection + +```bash +curl http://127.0.0.1:3000/api/v1/sites/$VCMS_SITE_ID/collections/posts \ + -H "Authorization: Bearer $VCMS_TOKEN" +``` + +### List published posts + +```bash +curl "http://127.0.0.1:3000/api/v1/sites/$VCMS_SITE_ID/collections/posts/entries" \ + -H "Authorization: Bearer $VCMS_TOKEN" +``` + + + +## Next steps + +- [Use REST](/docs/api/guides/rest) +- [Connect a static site](/docs/api/guides/static-sites) +- [Understand pagination and errors](/docs/api/reference/queries-errors) diff --git a/apps/web/content/docs/api/reference/meta.json b/apps/web/content/docs/api/reference/meta.json new file mode 100644 index 00000000..d538f109 --- /dev/null +++ b/apps/web/content/docs/api/reference/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Reference", + "icon": "BookOpenText", + "defaultOpen": true, + "pages": ["protocols", "queries-errors", "operations"] +} diff --git a/apps/web/content/docs/api/reference/operations.mdx b/apps/web/content/docs/api/reference/operations.mdx new file mode 100644 index 00000000..ea6ac9ca --- /dev/null +++ b/apps/web/content/docs/api/reference/operations.mdx @@ -0,0 +1,47 @@ +--- +title: Operation catalog +description: Find supported operations in each protocol and their live field-level references. +--- + +## Coverage + +| Capability | REST | GraphQL | gRPC | MCP | +| --- | --- | --- | --- | --- | +| Site discovery | Yes | Yes | Yes | Yes | +| Collection and singleton schemas | Yes | Yes | Yes | Resources | +| Entry list, search, read | Yes | Yes | Yes | Yes | +| Entry create, update, delete | Yes | Yes | Yes | Yes | +| Publish and unpublish | Yes | Yes | Yes | Yes | +| Revisions and restore | Yes | Yes | Yes | Yes | +| Singleton read and update | Yes | Yes | Yes | Yes | +| File metadata and references | Yes | Yes | Yes | Metadata | +| File upload | Multipart or signed URL | Signed URL | Client streaming | Signed URL | +| File trash and restore | Yes | Yes | Yes | Yes | + +Schema changes, webhooks, deployments, members, API keys, and backups are dashboard-only. + +## REST + +Content routes live under `/api/v1/sites/{site_id}`; `GET /api/v1/sites` lists accessible sites. Entry and singleton updates use `PATCH`, and publication uses separate `POST …/publish` and `POST …/unpublish` operations. Binary delivery uses `/api/files/{id}`; signed uploads use `PUT /api/v1/files/upload/{token}`. + +`/api/v1/docs` serves interactive documentation and `/api/v1/openapi.json` the installed OpenAPI contract. + +## GraphQL + +The query root holds `sites` and `site(id:)`; all content reads sit under `site`. Mutations take an explicit `siteId`. Authenticated introspection is the field-level reference. Production mode turns off GraphiQL. + +## gRPC + +| Service | Operations | +| --- | --- | +| `SiteService` | List and get | +| `CollectionService` | List and get schema | +| `EntryService` | List, get, create, update, delete, publish, unpublish, revisions | +| `SingletonService` | List, get, update | +| `FileService` | List, get, references, streaming upload, trash, restore | + +`cms.proto` and server reflection describe every message. + +## MCP + +Tools cover sites, entries, publication, revisions, singletons, files, and signed upload URLs. Collection and singleton schemas are resources. Call `tools/list`, `resources/list`, and `resources/templates/list` for the installed surface. diff --git a/apps/web/content/docs/api/reference/protocols.mdx b/apps/web/content/docs/api/reference/protocols.mdx new file mode 100644 index 00000000..76280143 --- /dev/null +++ b/apps/web/content/docs/api/reference/protocols.mdx @@ -0,0 +1,30 @@ +--- +title: Protocol comparison +description: Choose the VCMS interface that fits each consumer. +--- + +| Protocol | Endpoint | Best fit | Canonical schema | +| --- | --- | --- | --- | +| REST | `/api/v1/*` | Web builds, scripts, broad HTTP tooling | `/api/v1/docs` | +| GraphQL | `/api/graphql` | Clients choosing response fields | Authenticated schema introspection | +| gRPC | port `50051` | Typed service-to-service clients | `cms.proto` | +| MCP | `/mcp` | AI agents and tool clients | `tools/list`, `resources/list` | + +All four protocols read and write the same content through the same services and authorization policy. A token gets the same answer for the same operation whichever protocol it uses. See the [operation catalog](/docs/api/reference/operations) for what each protocol covers. + +## Selection guidance + +- Choose REST for small integrations and static builds. +- Choose GraphQL when a consumer benefits from GraphQL tooling or selective response shapes. +- Choose gRPC when generated clients and binary transport fit the service environment. +- Choose MCP when an agent should discover tools and site schemas at runtime. + +One application can use more than one protocol. Give each consumer its own minimal credential. + +## Known limits + +- Lists continue through offsets internally, so concurrent inserts and deletes can shift items between pages. +- Entry queries filter by collection, status, and full-text search. Filtering on schema fields and custom sort orders are not available. +- Publication, deletion, and revision restore do not accept an expected version; only updates are conditional. +- gRPC content uses `google.protobuf.Struct`, which stores numbers as doubles. +- File delivery URLs are public to anyone who knows the opaque file ID. diff --git a/apps/web/content/docs/api/reference/queries-errors.mdx b/apps/web/content/docs/api/reference/queries-errors.mdx new file mode 100644 index 00000000..4b5617f1 --- /dev/null +++ b/apps/web/content/docs/api/reference/queries-errors.mdx @@ -0,0 +1,65 @@ +--- +title: Queries and errors +description: Pagination, filtering, search, versions, status codes, and rate limits. +--- + +## Entry queries + +The REST entry list (`GET /api/v1/sites/{site_id}/collections/{slug}/entries`) accepts: + +| Parameter | Meaning | +| --- | --- | +| `status` | `draft` or `published` | +| `search` | Full-text search text | +| `include_drafts` | Include drafts; requires `content.preview.read` | +| `per_page` | Results per page, 1–200, default 50 | +| `cursor` | Continuation token from `page_info.next_cursor` | +| `include_total` | Add the total match count as `total` | + +An unknown collection slug returns `404`. Responses contain `items` and `page_info` with `has_next_page` and `next_cursor`. A cursor is signed and bound to the query that produced it; changing any other parameter makes it invalid. + +The public file list (`GET /api/v1/sites/{site_id}/files`) returns pages of 50 and accepts `cursor`, `search`, `type`, and `include_total`. + +GraphQL `site.entries` pages with `page` and `perPage`. gRPC lists use `page_size` and `page_token`. + +Continuation currently uses offsets internally, so inserts and deletes between requests can shift items across pages. Ranked search covers the first 10,000 results; narrow the query to go deeper. + +## Versions + +Every entry has an opaque `version` that changes on each write, including publication. REST reads return it as an `ETag`. Pass it back as `If-Match` (REST), `expectedVersion` (GraphQL), or `expected_version` (REST body, gRPC, MCP) to make an update conditional. A stale version returns `412` (gRPC `FAILED_PRECONDITION`) and writes nothing. Singletons work the same way. + +## Errors + +Public REST failures use `application/problem+json`: + +```json +{ + "type": "about:blank", + "title": "Forbidden", + "status": 403, + "code": "forbidden", + "detail": "Token requires the 'content.preview.read' scope.", + "request_id": "01a11a30-2413-7742-b4b4-6f0b62d706d9" +} +``` + +| Status | Meaning | First check | +| --- | --- | --- | +| `400` | Invalid input | Request body, field validation, cursor | +| `401` | Authentication failed | Token value, revocation, expiry | +| `403` | Authenticated but not allowed | The scope named in `detail`, user role, site ID | +| `404` | Resource or disabled surface absent | ID, slug, site ID, MCP enabled state | +| `409` | State conflict | Duplicate slug | +| `412` | Stale expected version | Re-read the entry and retry the change | +| `413` | Upload too large | Instance upload limit | +| `422` | Invalid JSON shape | Request field types | +| `429` | Rate limit reached | Request volume | +| `500` | Server failure | Server logs, health, and `vcms doctor` | + +Log the status, path, and `request_id`. Do not log bearer tokens or request bodies that contain secrets. + +## Rate limits + +Content APIs allow 1,200 requests per minute per client address. REST, GraphQL, and MCP share that budget; gRPC counts separately. Sign-in has its own, stricter limit, so heavy API traffic never locks users out. + +Behind a reverse proxy, enable trusted proxy headers in the instance security settings. Otherwise every request appears to come from the proxy and shares one budget. diff --git a/apps/web/content/docs/index.mdx b/apps/web/content/docs/index.mdx deleted file mode 100644 index 9634cf9d..00000000 --- a/apps/web/content/docs/index.mdx +++ /dev/null @@ -1,13 +0,0 @@ ---- -title: Documentation Coming Soon -description: Velopulent CMS is currently in active development. Official documentation has not been published yet. ---- - -Velopulent CMS is still being actively built and improved, and the documentation is not available at this time. - -Until the docs are ready, please refer to the project source code, examples, and setup instructions on GitHub for the latest information about installation, configuration, and development. - -You can follow development progress, report issues, and explore the codebase here: -[Velopulent CMS GitHub Repository](https://github.com/velopulent/cms) - -More complete documentation, guides, and API references will be added in future releases. diff --git a/apps/web/content/docs/meta.json b/apps/web/content/docs/meta.json new file mode 100644 index 00000000..94a98901 --- /dev/null +++ b/apps/web/content/docs/meta.json @@ -0,0 +1,3 @@ +{ + "pages": ["(cms)", "api", "server"] +} diff --git a/apps/web/content/docs/server/first-start.mdx b/apps/web/content/docs/server/first-start.mdx new file mode 100644 index 00000000..1a560430 --- /dev/null +++ b/apps/web/content/docs/server/first-start.mdx @@ -0,0 +1,49 @@ +--- +title: First start +description: Initialize the root, inspect effective configuration, and secure the owner account. +--- + + + +### Start the process or native service + +Portable: + +```bash +vcms serve +``` + +For a package installation, use the native service manager after reviewing `config.toml`. + +### Check readiness + +```bash +curl http://127.0.0.1:3000/health/ready +``` + +Readiness includes a database check. Use `vcms doctor` when it fails. + +### Inspect the resolved root + +```bash +vcms config show +vcms service status +``` + +The config command redacts secret values and reports mode, root, database kind, listener addresses, and log location. + +### Secure the owner + +Open `/dashboard`, sign in as `admin@cms.local` with `admin`, and change the password under **Account**. + +### Create a backup + +```bash +vcms backup create --scope instance --out first-start.vcms-backup --encrypt +``` + +Store the artifact apart from the server and protect the corresponding `backup_encryption_key`. + + + +Follow the [production checklist](/docs/server/guides/production) before binding to a non-loopback address. diff --git a/apps/web/content/docs/server/guides/backups.mdx b/apps/web/content/docs/server/guides/backups.mdx new file mode 100644 index 00000000..417ff6ca --- /dev/null +++ b/apps/web/content/docs/server/guides/backups.mdx @@ -0,0 +1,25 @@ +--- +title: Design a backup policy +description: Combine scope, destination, encryption, schedules, retention, and restore testing. +--- + +## Choose scope + +- **Site** backups move or recover one self-contained site. +- **Instance** backups protect all sites, users, and roles. Only the owner can create or restore them. + +Backup metadata, schedules, restore jobs, sessions, search indexes, and `secrets.toml` stay outside the logical payload. + +## Separate artifact and key + +Encrypted artifacts use AES-256-GCM with `backup_encryption_key`. Copy `secrets.toml` through a separate protected recovery path. An encrypted backup without its key cannot restore. + +## Schedule and retain + +Set a cron schedule and retention count. Include files unless another tested system protects storage objects. Send backups to a separate failure domain and monitor failed records. + +## Test restoration + +Restore a recent site artifact into a disposable installation or use import-as-new. Confirm content, files, users, and frontend queries. Record the server version, artifact location, key location, and test date. + +Logical rows use a database-neutral representation, so a supported artifact can move between SQLite and PostgreSQL. The target rejects backups created on a newer schema. diff --git a/apps/web/content/docs/server/guides/meta.json b/apps/web/content/docs/server/guides/meta.json new file mode 100644 index 00000000..cfb9ddec --- /dev/null +++ b/apps/web/content/docs/server/guides/meta.json @@ -0,0 +1,14 @@ +{ + "title": "Operations guides", + "icon": "Settings2", + "defaultOpen": true, + "pages": [ + "production", + "services", + "storage", + "backups", + "upgrades", + "recovery", + "troubleshooting" + ] +} diff --git a/apps/web/content/docs/server/guides/production.mdx b/apps/web/content/docs/server/guides/production.mdx new file mode 100644 index 00000000..7c3ab4e4 --- /dev/null +++ b/apps/web/content/docs/server/guides/production.mdx @@ -0,0 +1,26 @@ +--- +title: Prepare a production server +description: Configure network, identity, cookies, storage, monitoring, and backups. +--- + +1. Install a native package so the service runs under its restricted account. +2. Keep the HTTP and gRPC listeners on loopback when a reverse proxy shares the host. +3. Terminate TLS at the reverse proxy and set the public URL in instance settings. +4. Enable secure cookies after the public dashboard uses HTTPS. +5. Add exact frontend origins that need cross-origin API access. +6. Trust proxy headers only when requests can reach VCMS Server through a trusted proxy. +7. Select durable storage profiles and test their connection. +8. Configure encrypted scheduled backups to a separate failure domain. +9. Monitor `/health/live`, `/health/ready`, native service state, disk space, and logs. +10. Change the first-run password and create named accounts instead of sharing the owner login. + +## Reverse proxy boundary + +Forward HTTP to `127.0.0.1:3000`. Preserve the host. Send `X-Forwarded-Proto` and `X-Forwarded-Host` only from the trusted proxy. Configure request-body limits at or above the VCMS upload limit when uploads pass through the proxy. + +## Exposure review + +- The file delivery routes are unauthenticated and use opaque IDs. +- Scalar and GraphQL endpoints share the HTTP listener. +- MCP checks allowed hosts and origins in addition to bearer authentication. +- gRPC uses a separate listener and needs its own TLS or protected network path. diff --git a/apps/web/content/docs/server/guides/recovery.mdx b/apps/web/content/docs/server/guides/recovery.mdx new file mode 100644 index 00000000..30dbab01 --- /dev/null +++ b/apps/web/content/docs/server/guides/recovery.mdx @@ -0,0 +1,41 @@ +--- +title: Recover an offline server +description: Diagnose startup failures and restore data without the HTTP server. +--- + +## Diagnose first + +```bash +vcms config show +vcms service status +vcms doctor +``` + +Preserve the root before changing files. Copy logs and record the first error. + +## Missing secrets + +If `secrets.toml` is missing beside an existing SQLite database, startup fails. Restore the original secrets file. `vcms secrets reset --yes` creates a new trust root and invalidates credentials; use it only when the original trust root cannot return. + +## List known backups + +```bash +vcms backup list +``` + +## Restore an instance artifact + +```bash +vcms restore --file PATH --scope instance --yes +``` + +## Restore or import a site + +```bash +vcms restore --file PATH --scope site --site SITE_ID --yes +vcms restore --file PATH --scope site --site SITE_ID --import-as-new --yes +``` + +Restore is destructive within its chosen scope. Confirm the root and artifact before adding `--yes`. + +After restore, start the service, check readiness, run `vcms doctor`, inspect recovery warnings, and verify search. The restore process rebuilds derived search data when possible. diff --git a/apps/web/content/docs/server/guides/services.mdx b/apps/web/content/docs/server/guides/services.mdx new file mode 100644 index 00000000..9a603b87 --- /dev/null +++ b/apps/web/content/docs/server/guides/services.mdx @@ -0,0 +1,40 @@ +--- +title: Operate the native service +description: Start, stop, inspect, and diagnose systemd, launchd, and Windows services. +--- + +## Normal inspection + +Run these product-level checks first: + +```bash +vcms service status +vcms config show +vcms doctor +``` + +`service status` normalizes manager state. `doctor` checks configuration, root permissions, database schema, storage, listener availability, and service identity without creating or migrating a database. + +## Linux + +```bash +sudo systemctl start vcms +sudo systemctl stop vcms +sudo systemctl restart vcms +systemctl status vcms +journalctl -u vcms +``` + +The unit runs `/usr/bin/vcms service run` as user and group `vcms`. + +## macOS + +The package registers label `com.velopulent.vcms` and runs under `_vcms`. Use `launchctl` with the installed plist. Package logs also target `/Library/Logs/vcms`. + +## Windows + +Use the Services console or `sc.exe` to inspect and control the `vcms` service. The MSI configures automatic startup. + + +`vcms service run` is a hidden package entry point. Do not launch it manually. Use `vcms serve` for portable operation or the native manager for installed operation. + diff --git a/apps/web/content/docs/server/guides/storage.mdx b/apps/web/content/docs/server/guides/storage.mdx new file mode 100644 index 00000000..0ba89e10 --- /dev/null +++ b/apps/web/content/docs/server/guides/storage.mdx @@ -0,0 +1,20 @@ +--- +title: Configure file storage +description: Use the built-in filesystem or an S3-compatible storage profile. +--- + +Fresh installations create an immutable default filesystem profile backed by `/storage`. + +## Add an S3 profile + +Open **Instance settings → Storage**, create a profile, and provide its bucket, region, endpoint when required, public URL when applicable, and credentials. Probe the profile before assigning it to a new site. + +Credentials are encrypted in the database with a key derived from `master_key`. They do not appear in `config.toml`. + +## Assign storage to a site + +Choose a storage profile while creating the site. Files uploaded to that site use its profile. Plan migration before changing storage strategy for an established site because database records and stored objects must remain aligned. + +## Separate backup storage + +Backup destinations use independent configuration and credentials. Prefer a different bucket, account, or system from primary files so one storage incident cannot remove both data and recovery artifacts. diff --git a/apps/web/content/docs/server/guides/troubleshooting.mdx b/apps/web/content/docs/server/guides/troubleshooting.mdx new file mode 100644 index 00000000..cfe739e5 --- /dev/null +++ b/apps/web/content/docs/server/guides/troubleshooting.mdx @@ -0,0 +1,19 @@ +--- +title: Troubleshoot VCMS Server +description: Resolve configuration, database, listener, storage, search, and service failures. +--- + +| Symptom | Check | Action | +| --- | --- | --- | +| Server rejects `config.toml` | Unknown fields, socket addresses, log filter | Remove unsupported keys and correct the reported value | +| Existing database will not start | `secrets.toml` presence and validity | Restore the matching trust root; do not generate one silently | +| HTTP or gRPC bind fails | Port owner and configured address | Stop the conflicting process or choose a free address | +| Dashboard login loops | public URL, secure cookies, proxy headers | Align HTTPS and trusted-proxy settings | +| Browser API request blocked | exact allowed origin | Add the scheme, host, and port in security settings | +| Upload returns `413` | app and reverse-proxy body limits | Raise the lower limit or upload a smaller file | +| S3 action fails | profile probe, endpoint, region, credentials | Correct profile and probe again | +| MCP returns `404` | enabled setting | Enable MCP, then check host and origin policy | +| Search misses restored data | restore warnings and search state | Run site or instance reindex from Backups settings | +| Native service uses unexpected root | registration and service identity | Run `vcms service status`; mode never depends on directory existence | + +Start with `vcms doctor`. It reports checks without creating or migrating data. diff --git a/apps/web/content/docs/server/guides/upgrades.mdx b/apps/web/content/docs/server/guides/upgrades.mdx new file mode 100644 index 00000000..cc937d2e --- /dev/null +++ b/apps/web/content/docs/server/guides/upgrades.mdx @@ -0,0 +1,26 @@ +--- +title: Upgrade or remove VCMS Server +description: Preserve data through package upgrades, forward migrations, and removal. +--- + +## Before upgrading + +1. Read release notes. +2. Create an instance backup with files. +3. Copy `secrets.toml` through a protected path. +4. Verify available disk space. +5. Record `vcms config show` and `vcms doctor` output without exposing secrets. + +## Upgrade + +Use the same native package manager that installed the service. Startup applies database migrations automatically. There is no separate migration command. + +Run readiness and one content-delivery check after restart. Inspect logs and `vcms doctor` before allowing editorial changes. + +## Downgrade + +Database migrations move forward. Restore a backup created under the older version when a downgrade becomes necessary; replacing only the executable can leave an incompatible schema. + +## Remove + +Normal package removal keeps the system data root. Purging that directory separately deletes configuration, secrets, database, files, backups, logs, and search state. Verify an external backup before any purge. diff --git a/apps/web/content/docs/server/index.mdx b/apps/web/content/docs/server/index.mdx new file mode 100644 index 00000000..4f2bec51 --- /dev/null +++ b/apps/web/content/docs/server/index.mdx @@ -0,0 +1,25 @@ +--- +title: VCMS Server +description: Install and operate the self-hosted service behind Velopulent CMS. +--- + +VCMS Server runs the dashboard, REST, GraphQL, gRPC, MCP, health endpoints, search, scheduler, and static application from one executable. + + + } title="Install" href="/docs/server/install" description="Choose portable or native package operation." /> + } title="First start" href="/docs/server/first-start" description="Create a root, sign in, and secure the instance." /> + } title="Production checklist" href="/docs/server/guides/production" description="Set public URL, TLS, cookies, storage, and backups." /> + } title="Configuration" href="/docs/server/reference/configuration" description="Review every disk and database-backed setting." /> + + +## Runtime surfaces + +| Surface | Default | +| --- | --- | +| Dashboard and HTTP APIs | `127.0.0.1:3000` | +| gRPC | `127.0.0.1:50051` | +| Liveness | `/health/live` | +| Readiness | `/health/ready` | +| Portable data | `/vcms_data` | + +Native service registration selects installed mode. Directory existence and old paths never select a runtime mode. diff --git a/apps/web/content/docs/server/install.mdx b/apps/web/content/docs/server/install.mdx new file mode 100644 index 00000000..8e594a46 --- /dev/null +++ b/apps/web/content/docs/server/install.mdx @@ -0,0 +1,40 @@ +--- +title: Install VCMS Server +description: Choose a portable archive or native Linux, macOS, and Windows package. +--- + +## Portable archive + +Extract the release for your platform, open a terminal in the directory where you want the data root, and run: + +```bash +vcms serve +``` + +The process creates `./vcms_data` and remains attached to the terminal. Portable mode fits evaluation, local development, and manually supervised deployments. + +## Debian and Ubuntu + +Install the release `.deb` with your package manager. The package registers and enables the `vcms` systemd service but does not start it. Review configuration, then start it through systemd. + +## RPM distributions + +Install the release `.rpm`. The package registers and enables `vcms` without starting it. Use your distribution's systemd tools for start, stop, restart, status, and logs. + +## Arch Linux + +Use the maintained Arch package recipe. It consumes published Linux archives and registers the same service identity and data root. + +## macOS + +Install the signed `.pkg`. It registers the `com.velopulent.vcms` launchd service under the `_vcms` account but does not start it. Review `/Library/Application Support/vcms/config.toml` before loading or starting the service. + +## Windows + +Run the MSI as an administrator. It installs the `vcms` Windows service for automatic startup and starts it immediately. Installed data lives in `C:\ProgramData\vcms`. + + +Package upgrades preserve the fixed service identity and data root. Normal removal preserves configuration, secrets, databases, uploads, backups, logs, and search state. + + +Continue with [First start](./first-start). diff --git a/apps/web/content/docs/server/meta.json b/apps/web/content/docs/server/meta.json new file mode 100644 index 00000000..aa3bf276 --- /dev/null +++ b/apps/web/content/docs/server/meta.json @@ -0,0 +1,25 @@ +{ + "title": "VCMS Server", + "description": "Run and protect a Velopulent CMS installation", + "icon": "ServerCog", + "root": true, + "pages": [ + "index", + "---[PackageOpen]Get started---", + "install", + "first-start", + "---[Settings2]Operations---", + "guides/production", + "guides/services", + "guides/storage", + "guides/backups", + "guides/upgrades", + "guides/recovery", + "guides/troubleshooting", + "---[BookOpenText]Reference---", + "reference/configuration", + "reference/data-layout", + "reference/cli", + "reference/health-security" + ] +} diff --git a/apps/web/content/docs/server/reference/cli.mdx b/apps/web/content/docs/server/reference/cli.mdx new file mode 100644 index 00000000..a4e3a1d8 --- /dev/null +++ b/apps/web/content/docs/server/reference/cli.mdx @@ -0,0 +1,51 @@ +--- +title: CLI reference +description: Public `vcms` commands, arguments, defaults, and safety properties. +--- + +Running `vcms` without a subcommand prints help. + +## Server and diagnostics + +| Command | Purpose | +| --- | --- | +| `vcms serve` | Run a portable server from `./vcms_data` | +| `vcms config show` | Print resolved mode, root, listeners, logging, database kind, and redacted secret presence | +| `vcms service status` | Print normalized native-service and manager status | +| `vcms doctor` | Validate config, storage, database, ports, permissions, and service identity | + +## Account and trust recovery + +```text +vcms admin reset-password --email EMAIL --password PASSWORD +vcms secrets reset --yes +``` + +Secret reset replaces the trust root and invalidates credentials. It preserves the configured database URL but can make values encrypted under the old key unusable. + +## Backup + +```text +vcms backup create [--scope instance|site] [--site SITE_ID] + [--out PATH] [--no-files] [--encrypt] +vcms backup list +``` + +`--scope` defaults to `instance`. Site scope requires the site identifier. Without `--out`, the configured backup destination chooses the artifact location. + +## Restore + +```text +vcms restore --file PATH [--scope instance|site] [--site SITE_ID] + [--storage-profile-id PROFILE_ID] [--import-as-new] --yes +``` + +Scope defaults to `instance`. Restore refuses to run without `--yes` because it replaces data inside the chosen scope. + +## MCP stdio + +```text +vcms mcp stdio +``` + +This command reads `VCMS_MCP_TOKEN` and optional `VCMS_MCP_URL`. The URL defaults to `http://127.0.0.1:3000`. These are client variables; the server itself ignores environment configuration. diff --git a/apps/web/content/docs/server/reference/configuration.mdx b/apps/web/content/docs/server/reference/configuration.mdx new file mode 100644 index 00000000..83e406c0 --- /dev/null +++ b/apps/web/content/docs/server/reference/configuration.mdx @@ -0,0 +1,60 @@ +--- +title: Configuration reference +description: Bootstrap files, secrets, and database-backed instance settings. +--- + +VCMS Server uses three configuration layers. It does not load `.env`, server environment overrides, config search paths, or CLI overrides. + +## `config.toml` + +The strict bootstrap file accepts only server addresses and logging: + +```toml +[server] +http_address = "127.0.0.1:3000" +grpc_address = "127.0.0.1:50051" + +[log] +level = "cms=info,vcms=info" +output = "file" +``` + +`http_address` and `grpc_address` must parse as socket addresses. `log.level` must be a valid tracing filter. `log.output` accepts `file` or `stdout`. Unknown keys stop startup. + +## `secrets.toml` + +```toml +master_key = "64 hexadecimal characters" +backup_encryption_key = "64 hexadecimal characters" +database_url = "postgres://user:password@host/database" # optional +``` + +The two keys each encode 32 bytes. `master_key` derives independent keys for sessions, token lookup, signed uploads, webhooks, and encrypted integration credentials. `backup_encryption_key` protects encrypted backup artifacts. Omit `database_url` to use root-local SQLite. + +Restrict this file to the service identity. Losing it can invalidate credentials and encrypted data. + +## Instance settings + +Owners manage these values through the dashboard. They live in the database: + +### General + +| Setting | Fresh value | +| --- | --- | +| Public URL | unset | +| Public registration | disabled | +| Session lifetime | 24 hours | +| Upload limit | 50 MB | +| MCP | enabled | + +### Security + +Secure cookies and trusted proxy headers start disabled. Allowed origins and MCP origins start empty. MCP allowed hosts start with localhost and `127.0.0.1`. Private webhook targets start disabled. + +### Storage and backups + +Primary storage and backup destination start as filesystem. S3-compatible settings include bucket, region, endpoint, public URL where applicable, and separately encrypted credentials. Default backup retention is seven. + + +Edit bootstrap files while coordinating a service restart. Use the dashboard for database-backed settings so validation and credential encryption run correctly. + diff --git a/apps/web/content/docs/server/reference/data-layout.mdx b/apps/web/content/docs/server/reference/data-layout.mdx new file mode 100644 index 00000000..5df4611d --- /dev/null +++ b/apps/web/content/docs/server/reference/data-layout.mdx @@ -0,0 +1,35 @@ +--- +title: Runtime modes and data layout +description: Fixed roots, mode selection, permissions, and directory contents. +--- + +## Mode selection + +Native service registration selects installed mode. Without a registered service, `vcms serve` uses portable mode. Existing directories, legacy paths, and environment values do not select mode. Detection errors fail closed. + +## Roots + +| Mode | Root | +| --- | --- | +| Portable | `/vcms_data` | +| Installed Linux | `/var/lib/vcms` | +| Installed macOS | `/Library/Application Support/vcms` | +| Installed Windows | `C:\ProgramData\vcms` | + +## Layout + + + + + + + + + + + + + +`search` contains derived data and does not belong in logical backup payloads. `backups` holds local artifacts when filesystem destination is active. + +Fresh Unix roots use mode `0700`; secrets use `0600`. Windows applies explicit ACLs for the current/service identity, SYSTEM, and Administrators. diff --git a/apps/web/content/docs/server/reference/health-security.mdx b/apps/web/content/docs/server/reference/health-security.mdx new file mode 100644 index 00000000..e19191ed --- /dev/null +++ b/apps/web/content/docs/server/reference/health-security.mdx @@ -0,0 +1,36 @@ +--- +title: Health and security reference +description: Probes, trust boundaries, cookies, CORS, rate limits, and secret ownership. +--- + +## Health probes + +| Endpoint | Authentication | Meaning | +| --- | --- | --- | +| `/health/live` | None | Process can answer HTTP | +| `/health/ready` | None | Process and database are ready to serve traffic | + +Keep detailed failures in logs and `vcms doctor`; probes expose minimal operational status. + +## Request policy + +- Dashboard requests authenticate with cookie sessions. +- State-changing dashboard requests use CSRF protection. +- Public protocol requests authenticate with bearer credentials. +- CORS allows only exact origins configured in instance security settings. +- Upload bodies must fit the database-backed upload limit. +- Rate limiting protects HTTP requests; trusted proxy headers affect client identification only when enabled. +- MCP can be disabled and checks host/origin policy. + +## Secret inventory + +| Secret | Location | Protects | +| --- | --- | --- | +| Master key | `secrets.toml` | Derived runtime keys and encrypted integration credentials | +| Backup encryption key | `secrets.toml` | Encrypted backup artifacts | +| PostgreSQL URL | `secrets.toml` | External database access | +| Storage credentials | Encrypted database row | Primary S3-compatible storage | +| Backup credentials | Encrypted database row | Backup S3-compatible destination | +| Site/personal tokens | Shown once; verifier stored | Protocol access | + +Back up `secrets.toml` separately from normal logical artifacts. Limit filesystem access to the service identity and recovery administrators. diff --git a/apps/web/content/docs/server/reference/meta.json b/apps/web/content/docs/server/reference/meta.json new file mode 100644 index 00000000..0f486104 --- /dev/null +++ b/apps/web/content/docs/server/reference/meta.json @@ -0,0 +1,6 @@ +{ + "title": "Reference", + "icon": "BookOpenText", + "defaultOpen": true, + "pages": ["configuration", "data-layout", "cli", "health-security"] +} diff --git a/apps/web/package.json b/apps/web/package.json index b0246a4a..19611a64 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -13,31 +13,30 @@ }, "dependencies": { "@iconify-icon/react": "^3.0.3", - "@orama/orama": "^3.1.18", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "fumadocs-core": "16.10.5", - "fumadocs-mdx": "15.0.12", - "fumadocs-ui": "16.10.5", - "lucide-react": "^1.17.0", - "next": "16.2.9", - "radix-ui": "^1.4.3", - "react": "^19.2.5", - "react-dom": "^19.2.5", - "shadcn": "^4.8.3", - "tailwind-merge": "^3.5.0", + "fumadocs-core": "16.14.3", + "fumadocs-mdx": "15.2.3", + "fumadocs-ui": "16.14.3", + "lucide-react": "^1.31.0", + "next": "16.4.0", + "radix-ui": "^1.6.7", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "shadcn": "^4.16.2", + "tailwind-merge": "^3.6.0", "tw-animate-css": "^1.4.0" }, "devDependencies": { - "@tailwindcss/postcss": "^4.2.4", - "@types/mdx": "^2.0.13", - "@types/node": "^26.0.0", - "@types/react": "^19.2.15", - "@types/react-dom": "^19.2.3", - "postcss": "^8.5.15", + "@tailwindcss/postcss": "^4.3.3", + "@types/mdx": "^2.0.14", + "@types/node": "^26.2.0", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "postcss": "^8.5.26", "serve": "^14.2.6", - "tailwindcss": "^4.2.4", - "typescript": "^6.0.3", - "@biomejs/biome": "^2.5.0" + "tailwindcss": "^4.3.3", + "typescript": "^7.0.2", + "@biomejs/biome": "^2.5.7" } } diff --git a/apps/web/public/docs/screenshots/backups-dark.png b/apps/web/public/docs/screenshots/backups-dark.png new file mode 100644 index 00000000..0393e591 Binary files /dev/null and b/apps/web/public/docs/screenshots/backups-dark.png differ diff --git a/apps/web/public/docs/screenshots/backups-light.png b/apps/web/public/docs/screenshots/backups-light.png new file mode 100644 index 00000000..6ac7f458 Binary files /dev/null and b/apps/web/public/docs/screenshots/backups-light.png differ diff --git a/apps/web/public/docs/screenshots/collections-dark.png b/apps/web/public/docs/screenshots/collections-dark.png new file mode 100644 index 00000000..4cda83f0 Binary files /dev/null and b/apps/web/public/docs/screenshots/collections-dark.png differ diff --git a/apps/web/public/docs/screenshots/collections-light.png b/apps/web/public/docs/screenshots/collections-light.png new file mode 100644 index 00000000..d1105ec1 Binary files /dev/null and b/apps/web/public/docs/screenshots/collections-light.png differ diff --git a/apps/web/public/docs/screenshots/dashboard-overview-dark.png b/apps/web/public/docs/screenshots/dashboard-overview-dark.png new file mode 100644 index 00000000..fb77357b Binary files /dev/null and b/apps/web/public/docs/screenshots/dashboard-overview-dark.png differ diff --git a/apps/web/public/docs/screenshots/dashboard-overview-light.png b/apps/web/public/docs/screenshots/dashboard-overview-light.png new file mode 100644 index 00000000..c3dcee15 Binary files /dev/null and b/apps/web/public/docs/screenshots/dashboard-overview-light.png differ diff --git a/apps/web/public/docs/screenshots/posts-dark.png b/apps/web/public/docs/screenshots/posts-dark.png new file mode 100644 index 00000000..e57eb599 Binary files /dev/null and b/apps/web/public/docs/screenshots/posts-dark.png differ diff --git a/apps/web/public/docs/screenshots/posts-light.png b/apps/web/public/docs/screenshots/posts-light.png new file mode 100644 index 00000000..30cb48cb Binary files /dev/null and b/apps/web/public/docs/screenshots/posts-light.png differ diff --git a/apps/web/src/app/docs/docs.css b/apps/web/src/app/docs/docs.css index da1cd2f0..02d91958 100644 --- a/apps/web/src/app/docs/docs.css +++ b/apps/web/src/app/docs/docs.css @@ -10,3 +10,32 @@ html > body[data-scroll-locked] { margin-right: 0px; --removed-body-scroll-bar-size: 0px; } + +#nd-sidebar svg { + color: var(--color-fd-primary); +} + +.docs-card-icon { + border-radius: 0.5rem; + padding: 0.4rem; +} + +.docs-icon-violet { + color: var(--color-violet-500); + background: color-mix(in oklab, var(--color-violet-500) 12%, transparent); +} + +.docs-icon-blue { + color: var(--color-blue-500); + background: color-mix(in oklab, var(--color-blue-500) 12%, transparent); +} + +.docs-icon-amber { + color: var(--color-amber-500); + background: color-mix(in oklab, var(--color-amber-500) 12%, transparent); +} + +.docs-icon-green { + color: var(--color-emerald-500); + background: color-mix(in oklab, var(--color-emerald-500) 12%, transparent); +} diff --git a/apps/web/src/components/adaptive-screenshot.tsx b/apps/web/src/components/adaptive-screenshot.tsx new file mode 100644 index 00000000..544bb697 --- /dev/null +++ b/apps/web/src/components/adaptive-screenshot.tsx @@ -0,0 +1,46 @@ +import { ImageZoom } from "fumadocs-ui/components/image-zoom"; + +type AdaptiveScreenshotProps = { + light: string; + dark: string; + alt: string; + caption?: string; + width?: number; + height?: number; +}; + +/** Displays the dashboard capture that matches the active documentation theme. */ +export function AdaptiveScreenshot({ + light, + dark, + alt, + caption, + width = 1920, + height = 1080, +}: AdaptiveScreenshotProps) { + return ( +
+
+ + +
+ {caption ? ( +
+ {caption} +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/landing/how-it-works-section.tsx b/apps/web/src/components/landing/how-it-works-section.tsx index 37d5b53c..ee3ccc77 100644 --- a/apps/web/src/components/landing/how-it-works-section.tsx +++ b/apps/web/src/components/landing/how-it-works-section.tsx @@ -163,7 +163,7 @@ export function HowItWorksSection() { {/* Code Preview - Large terminal */}
-