From eef1cb8bf5aa1dbeb17e76cc623c8b6e316da00f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 30 Aug 2026 15:03:37 +0200 Subject: [PATCH 1/2] Beautify the README with a centered hero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restyle the README to match the Nvisy Studio README: a centered hero (shared logo, tagline, and a Build/Release/Security/License badge row plus a site link row), the active-development warning, and consistent sections — Features, Requirements, Quick start, Commands, Documentation, Contributing, License, and Support. Add the shared brand logo under .github/assets/. Every linked path resolves to a file that exists. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .github/assets/logo.png | Bin 0 -> 31807 bytes README.md | 101 ++++++++++++++++++++++++++++++---------- 2 files changed, 76 insertions(+), 25 deletions(-) create mode 100644 .github/assets/logo.png diff --git a/.github/assets/logo.png b/.github/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..55caf05c49123ad613d380c2466b7037094ceea8 GIT binary patch literal 31807 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

aH6L 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 IM6N<$g0AM4r~m)} literal 0 HcmV?d00001 diff --git a/README.md b/README.md index 3a766bcc..4a264484 100644 --- a/README.md +++ b/README.md @@ -1,47 +1,98 @@ +

+ +Nvisy Server + # Nvisy Server -[![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/server/build.yml?branch=main&label=build%20%26%20test&style=flat-square)](https://github.com/nvisycom/server/actions/workflows/build.yml) +**Detect and redact sensitive data across documents, images, and audio.** + +The open-source multimodal redaction API: an LLM-powered engine and HTTP service +that finds PII and applies your redaction policies, wrapped in a multi-tenant, +self-hostable Rust server. + +[![Build](https://img.shields.io/github/actions/workflow/status/nvisycom/server/build.yml?branch=main&label=build&style=flat-square)](https://github.com/nvisycom/server/actions/workflows/build.yml) +[![Release](https://img.shields.io/github/actions/workflow/status/nvisycom/server/release.yml?branch=main&label=release&style=flat-square)](https://github.com/nvisycom/server/actions/workflows/release.yml) +[![Security](https://img.shields.io/github/actions/workflow/status/nvisycom/server/security.yml?branch=main&label=security&style=flat-square)](https://github.com/nvisycom/server/actions/workflows/security.yml) +[![License](https://img.shields.io/badge/license-Apache%202.0-blue?style=flat-square)](LICENSE.txt) -Open-source multimodal redaction API. Detect and redact PII and sensitive data -across documents, images, and audio. +[**nvisy.com**](https://nvisy.com) · [**docs.nvisy.com**](https://docs.nvisy.com) · [**app.nvisy.com**](https://app.nvisy.com) + +
+ +A document flows through two phases: **detection** analyzes it for sensitive +entities and stores a reviewable report; **redaction** applies the pipeline's +policies (with optional reviewer edits) to produce a redacted file. Detection +runs asynchronously off a transactional work queue; redaction is synchronous and +repeatable. Everything is scoped to isolated workspaces with per-workspace +credential encryption. > [!WARNING] -> **Active development: API not stable.** This project is under active -> development. Public APIs, configuration shapes, on-disk formats, and -> wire protocols may change without notice between releases. Pin a -> specific commit if you depend on this in production. +> **Active development. API not stable.** Public APIs, configuration shapes, +> on-disk formats, and wire protocols may change without notice between releases. +> Pin a specific commit if you depend on this in production. ## Features -- **Multimodal Redaction:** Detect and remove sensitive data across PDFs, images, and audio -- **AI-Powered Detection:** LLM-driven PII and entity recognition with configurable redaction policies -- **Workspace Isolation:** Multi-tenant workspaces with HKDF-derived credential encryption -- **Real-Time Collaboration:** WebSocket and NATS pub/sub for live document editing -- **Interactive Docs:** Auto-generated OpenAPI with Scalar UI +- **Multimodal redaction** — detect and remove sensitive data across PDFs, office documents, images, and audio. +- **AI-powered detection** — LLM- and pattern-driven PII/entity recognition, governed by configurable redaction policies. +- **Reviewer edits** — suppress a false positive, retag a detection, or add one the analysis missed, then re-redact — as many times as needed. +- **Workspace isolation** — multi-tenant workspaces with HKDF-derived, per-workspace credential encryption. +- **Real-time collaboration** — WebSocket and NATS pub/sub for live status and document editing. +- **Interactive docs** — auto-generated OpenAPI served through a Scalar UI. + +## Requirements -## Quick Start +- **Rust + Cargo** — 1.95+, Edition 2024 +- **PostgreSQL** 18+ and **NATS** 2.10+ (JetStream) — the dev compose file provides both -The fastest way to get started is with [Nvisy Cloud](https://nvisy.com). +## Quick start -For self-hosted deployments, refer to [`docker/`](docker/) for compose files and -infrastructure requirements, and [`.env.example`](.env.example) for configuration. +The fastest way to get started is with [Nvisy Cloud](https://nvisy.com). To run a +server locally: + +```bash +make install-all # Install tools and make scripts executable +make generate-all # Generate .env, auth keys, and apply migrations + +docker compose -f docker/docker-compose.dev.yml up -d # Start Postgres + NATS +make run # Run the server +``` + +The API then serves interactive OpenAPI docs (Scalar UI) at the running server's +docs path. For self-hosted deployments, see [`docker/`](docker/) for compose +files and infrastructure requirements, and [`.env.example`](.env.example) for +configuration. + +## Commands + +| Command | What it does | +| --- | --- | +| `make run` | Run the server (starts Postgres and NATS first) | +| `make ci` | Run all CI checks locally (check, fmt, clippy, test, docs) | +| `make fmt` | Fix code formatting (nightly rustfmt) | +| `make security` | Run security checks (`cargo deny`) | +| `make generate-migrations` | Apply migrations and regenerate `schema.rs` | +| `make reset-docker` | Reset the dev containers (`down -v`, then `up -d`) | ## Documentation -See [`docs/`](docs/) for architecture, intelligence capabilities, provider -design, and security documentation. +See [`docs/`](docs/) for the details: + +- [Architecture](docs/ARCHITECTURE.md) — the crates, the detect/redact pipeline, and how they fit together. +- [Intelligence](docs/INTELLIGENCE.md) — detection capabilities and the redaction engine. +- [Providers](docs/PROVIDERS.md) — inference and object-store provider design. +- [Security](docs/SECURITY.md) — the encryption, authentication, and isolation model. -## Changelog +## Contributing -See [CHANGELOG.md](CHANGELOG.md) for release notes and version history. +See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup and guidelines, and +[CHANGELOG.md](CHANGELOG.md) for release notes. ## License -Apache 2.0 License, see [LICENSE.txt](LICENSE.txt) +Apache 2.0 License, see [LICENSE.txt](LICENSE.txt). ## Support -- **Documentation:** [docs.nvisy.com](https://docs.nvisy.com) -- **Issues:** [GitHub Issues](https://github.com/nvisycom/server/issues) -- **Email:** [support@nvisy.com](mailto:support@nvisy.com) -- **API Status:** [nvisy.openstatus.dev](https://nvisy.openstatus.dev) +- **Documentation**: [docs.nvisy.com](https://docs.nvisy.com) +- **Email**: [support@nvisy.com](mailto:support@nvisy.com) From ae063e1b83c105af2d1dd73ff6c75786808684e4 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Sun, 30 Aug 2026 15:05:40 +0200 Subject: [PATCH 2/2] Mark the health endpoint's auth as optional in the OpenAPI spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The health handler extracts Option (it runs a real-time check for an authenticated caller and returns the cached status otherwise), but aide's blanket Option OperationInput delegates to AuthState's impl, which stamps a required Bearer requirement. The spec therefore marked /health/ as auth-required even though it is mounted on the public router and accepts unauthenticated requests, which misled SDK clients. Add an OptionalAuth extractor (its own file) that carries the same optional value but declares the token as optional in the spec — an empty security requirement (no auth) alongside the Bearer one. Use it in the health handler. A unit test asserts the emitted security offers the unauthenticated alternative. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/extract/auth/auth_state.rs | 3 +- crates/nvisy-server/src/extract/auth/mod.rs | 2 + .../src/extract/auth/optional_auth.rs | 91 +++++++++++++++++++ crates/nvisy-server/src/extract/mod.rs | 2 +- crates/nvisy-server/src/handler/monitors.rs | 4 +- 5 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 crates/nvisy-server/src/extract/auth/optional_auth.rs diff --git a/crates/nvisy-server/src/extract/auth/auth_state.rs b/crates/nvisy-server/src/extract/auth/auth_state.rs index d660b892..45a81f9c 100644 --- a/crates/nvisy-server/src/extract/auth/auth_state.rs +++ b/crates/nvisy-server/src/extract/auth/auth_state.rs @@ -397,7 +397,8 @@ where T: Clone + Send + Sync + for<'de> Deserialize<'de> + 'static, { fn operation_input(_ctx: &mut GenContext, operation: &mut Operation) { - // Add security requirement for Bearer token + // The Bearer token is required: the only way to satisfy the operation is + // to present it. operation.security = vec![[("BearerAuth".to_string(), vec![])].into()]; } } diff --git a/crates/nvisy-server/src/extract/auth/mod.rs b/crates/nvisy-server/src/extract/auth/mod.rs index d4590925..a56f9e1f 100644 --- a/crates/nvisy-server/src/extract/auth/mod.rs +++ b/crates/nvisy-server/src/extract/auth/mod.rs @@ -8,6 +8,7 @@ mod auth_provider; mod auth_state; mod jwt_claims; mod jwt_header; +mod optional_auth; mod permission; use uuid::Uuid; @@ -16,6 +17,7 @@ pub use self::auth_provider::AuthProvider; pub use self::auth_state::AuthState; pub use self::jwt_claims::AuthClaims; pub use self::jwt_header::AuthHeader; +pub use self::optional_auth::OptionalAuth; pub use self::permission::{AuthResult, Permission}; impl AuthProvider for AuthClaims { diff --git a/crates/nvisy-server/src/extract/auth/optional_auth.rs b/crates/nvisy-server/src/extract/auth/optional_auth.rs new file mode 100644 index 00000000..0177d4d1 --- /dev/null +++ b/crates/nvisy-server/src/extract/auth/optional_auth.rs @@ -0,0 +1,91 @@ +//! Optional authentication extractor for endpoints that vary by whether a caller +//! authenticated, without marking themselves auth-required in the OpenAPI spec. + +use aide::OperationInput; +use aide::generate::GenContext; +use aide::openapi::{Operation, SecurityRequirement}; +use axum::extract::{FromRef, FromRequestParts, OptionalFromRequestParts}; +use axum::http::request::Parts; +use derive_more::{Deref, DerefMut}; +use nvisy_postgres::PgClient; +use serde::Deserialize; + +use super::AuthState; +use crate::handler::{Error, Result}; +use crate::service::SessionKeys; + +/// Optional [`AuthState`] for an endpoint that runs with or without a token. +/// +/// Extracting a bare `Option` authenticates the same way, but its +/// generated OpenAPI security comes from the blanket `Option` `OperationInput`, +/// which delegates to [`AuthState`] and so wrongly marks the operation +/// auth-required. This wrapper carries the same optional value while declaring the +/// token as *optional* in the spec (an empty requirement alongside the Bearer one, +/// so a public probe is not shown as needing a token). Use it for endpoints that +/// vary their behavior by whether a caller authenticated — e.g. the health check. +#[derive(Debug, Clone, Deref, DerefMut)] +pub struct OptionalAuth(pub Option>); + +impl FromRequestParts for OptionalAuth +where + T: Clone + Send + Sync + for<'de> Deserialize<'de> + 'static, + S: Sync + Send + 'static, + PgClient: FromRef, + SessionKeys: FromRef, +{ + type Rejection = Error<'static>; + + async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { + // Reuses the optional extraction: a valid token authenticates, an absent or + // invalid one yields `None` rather than rejecting. + as OptionalFromRequestParts>::from_request_parts(parts, state) + .await + .map(OptionalAuth) + } +} + +impl OperationInput for OptionalAuth +where + T: Clone + Send + Sync + for<'de> Deserialize<'de> + 'static, +{ + fn operation_input(_ctx: &mut GenContext, operation: &mut Operation) { + // Two alternatives: an empty requirement (no auth) and the Bearer one, so + // the operation is documented as accessible with or without a token. + operation.security = vec![ + SecurityRequirement::new(), + [("BearerAuth".to_string(), vec![])].into(), + ]; + } +} + +#[cfg(test)] +mod tests { + use aide::OperationInput; + use aide::openapi::Operation; + + use super::OptionalAuth; + + /// The OpenAPI security for an optional-auth operation must offer an + /// unauthenticated alternative (an empty requirement) alongside the Bearer + /// one, so the endpoint is not documented as requiring a token — a public + /// probe hitting the health check must not appear to need credentials. + #[test] + fn documents_auth_as_optional_not_required() { + let mut operation = Operation::default(); + aide::generate::in_context(|ctx| { + OptionalAuth::<()>::operation_input(ctx, &mut operation); + }); + + assert!( + operation.security.iter().any(|req| req.is_empty()), + "an empty requirement must be present so no auth also satisfies the operation", + ); + assert!( + operation + .security + .iter() + .any(|req| req.contains_key("BearerAuth")), + "the Bearer alternative must still be offered for authenticated callers", + ); + } +} diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index 686600ac..9cb99d89 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -17,7 +17,7 @@ mod version; mod workspace_context; pub use crate::extract::auth::{ - AuthClaims, AuthHeader, AuthProvider, AuthResult, AuthState, Permission, + AuthClaims, AuthHeader, AuthProvider, AuthResult, AuthState, OptionalAuth, Permission, }; pub use crate::extract::avatar::Avatar; pub use crate::extract::connection_info::{AppConnectInfo, ClientIp}; diff --git a/crates/nvisy-server/src/handler/monitors.rs b/crates/nvisy-server/src/handler/monitors.rs index 48544a2b..4350d852 100644 --- a/crates/nvisy-server/src/handler/monitors.rs +++ b/crates/nvisy-server/src/handler/monitors.rs @@ -11,7 +11,7 @@ use axum::http::StatusCode; use nvisy_core::health::HealthStatus; use super::response::Health; -use crate::extract::{AuthState, Json, Version}; +use crate::extract::{Json, OptionalAuth, Version}; use crate::handler::Result; use crate::service::{HealthCache, ServiceState}; @@ -43,7 +43,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::monitors"; )] async fn health_status( State(health_service): State, - auth_state: Option, + OptionalAuth(auth_state): OptionalAuth, version: Version, ) -> Result<(StatusCode, Json)> { let is_authenticated = auth_state.is_some();