diff --git a/.github/assets/logo.png b/.github/assets/logo.png
new file mode 100644
index 00000000..55caf05c
Binary files /dev/null and b/.github/assets/logo.png differ
diff --git a/README.md b/README.md
index 3a766bcc..4a264484 100644
--- a/README.md
+++ b/README.md
@@ -1,47 +1,98 @@
+
+
+

+
# Nvisy Server
-[](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.
+
+[](https://github.com/nvisycom/server/actions/workflows/build.yml)
+[](https://github.com/nvisycom/server/actions/workflows/release.yml)
+[](https://github.com/nvisycom/server/actions/workflows/security.yml)
+[](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)
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();