From f9ce0b00d32a6874d4adacc2176b7b4fd68c88d6 Mon Sep 17 00:00:00 2001 From: munenick Date: Wed, 7 Jan 2026 11:16:59 +0900 Subject: [PATCH 01/45] add: e2ee base --- api/crates/domain/src/documents/keys.rs | 55 +++++++ api/crates/domain/src/documents/mod.rs | 1 + api/crates/domain/src/identity/keys.rs | 122 ++++++++++++++ api/crates/domain/src/identity/mod.rs | 1 + api/crates/domain/src/workspaces/keys.rs | 15 ++ api/crates/domain/src/workspaces/mod.rs | 1 + .../202701020001_add_e2ee_tables.sql | 151 ++++++++++++++++++ 7 files changed, 346 insertions(+) create mode 100644 api/crates/domain/src/documents/keys.rs create mode 100644 api/crates/domain/src/identity/keys.rs create mode 100644 api/crates/domain/src/workspaces/keys.rs create mode 100644 api/migrations/202701020001_add_e2ee_tables.sql diff --git a/api/crates/domain/src/documents/keys.rs b/api/crates/domain/src/documents/keys.rs new file mode 100644 index 00000000..2ad254ea --- /dev/null +++ b/api/crates/domain/src/documents/keys.rs @@ -0,0 +1,55 @@ +//! E2EE key types for documents + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::identity::keys::KdfParams; + +/// Document encrypted key (DEK encrypted with workspace KEK) +#[derive(Debug, Clone)] +pub struct DocumentEncryptedKey { + pub document_id: Uuid, + pub encrypted_dek: Vec, + pub nonce: Vec, + pub key_version: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// Share encrypted key (DEK encrypted for share access) +#[derive(Debug, Clone)] +pub struct ShareEncryptedKey { + pub share_id: Uuid, + pub encrypted_dek: Vec, + /// Salt for password-protected shares (optional) + pub salt: Option>, + /// KDF params for password-protected shares (optional) + pub kdf_params: Option, + pub created_at: DateTime, +} + +impl ShareEncryptedKey { + pub fn is_password_protected(&self) -> bool { + self.salt.is_some() + } +} + +/// Public document content (plaintext for published documents) +#[derive(Debug, Clone)] +pub struct PublicDocumentContent { + pub document_id: Uuid, + pub content: String, + pub title: String, + pub content_hash: String, + pub updated_at: DateTime, +} + +/// Encrypted tag index entry (deterministic encryption for searchable tags) +#[derive(Debug, Clone)] +pub struct EncryptedTagIndex { + pub id: Uuid, + pub workspace_id: Uuid, + pub document_id: Uuid, + pub encrypted_tag: Vec, + pub created_at: DateTime, +} diff --git a/api/crates/domain/src/documents/mod.rs b/api/crates/domain/src/documents/mod.rs index a2d007e2..a2fd50cb 100644 --- a/api/crates/domain/src/documents/mod.rs +++ b/api/crates/domain/src/documents/mod.rs @@ -3,6 +3,7 @@ pub mod delete_plan; pub mod doc_type; pub mod document; pub mod hierarchy; +pub mod keys; pub mod meta; pub mod path; pub mod permissions; diff --git a/api/crates/domain/src/identity/keys.rs b/api/crates/domain/src/identity/keys.rs new file mode 100644 index 00000000..4a6b8b63 --- /dev/null +++ b/api/crates/domain/src/identity/keys.rs @@ -0,0 +1,122 @@ +//! E2EE key types for user identity + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +pub const KDF_TYPE_ARGON2ID: &str = "argon2id"; +pub const KDF_TYPE_PBKDF2: &str = "pbkdf2"; +pub const KEY_TYPE_ECDH_P256: &str = "ecdh-p256"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KdfType { + Argon2id, + Pbkdf2, +} + +impl KdfType { + pub fn parse(s: &str) -> Option { + match s { + KDF_TYPE_ARGON2ID => Some(Self::Argon2id), + KDF_TYPE_PBKDF2 => Some(Self::Pbkdf2), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::Argon2id => KDF_TYPE_ARGON2ID, + Self::Pbkdf2 => KDF_TYPE_PBKDF2, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyType { + EcdhP256, +} + +impl KeyType { + pub fn parse(s: &str) -> Option { + match s { + KEY_TYPE_ECDH_P256 => Some(Self::EcdhP256), + _ => None, + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::EcdhP256 => KEY_TYPE_ECDH_P256, + } + } +} + +/// KDF parameters for key derivation +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct KdfParams { + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iterations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallelism: Option, +} + +impl Default for KdfParams { + fn default() -> Self { + Self { + memory: Some(65536), + iterations: Some(3), + parallelism: Some(4), + } + } +} + +/// User's public key for ECDH key exchange +#[derive(Debug, Clone)] +pub struct UserPublicKey { + pub user_id: Uuid, + pub public_key: Vec, + pub key_type: KeyType, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// User's encrypted master key (for recovery via passphrase) +#[derive(Debug, Clone)] +pub struct UserEncryptedMasterKey { + pub user_id: Uuid, + pub encrypted_key: Vec, + pub salt: Vec, + pub kdf_type: KdfType, + pub kdf_params: KdfParams, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +/// User's encrypted private key (encrypted with UMK) +#[derive(Debug, Clone)] +pub struct UserEncryptedPrivateKey { + pub user_id: Uuid, + pub encrypted_private_key: Vec, + pub nonce: Vec, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn kdf_type_parses() { + assert_eq!(KdfType::parse("argon2id"), Some(KdfType::Argon2id)); + assert_eq!(KdfType::parse("pbkdf2"), Some(KdfType::Pbkdf2)); + assert_eq!(KdfType::parse("unknown"), None); + } + + #[test] + fn key_type_parses() { + assert_eq!(KeyType::parse("ecdh-p256"), Some(KeyType::EcdhP256)); + assert_eq!(KeyType::parse("unknown"), None); + } +} diff --git a/api/crates/domain/src/identity/mod.rs b/api/crates/domain/src/identity/mod.rs index a178a951..8148e7ad 100644 --- a/api/crates/domain/src/identity/mod.rs +++ b/api/crates/domain/src/identity/mod.rs @@ -1,4 +1,5 @@ // Identity (auth/sessions/api_tokens) domain lives here. pub mod api_token; +pub mod keys; pub mod policy; diff --git a/api/crates/domain/src/workspaces/keys.rs b/api/crates/domain/src/workspaces/keys.rs new file mode 100644 index 00000000..c5691313 --- /dev/null +++ b/api/crates/domain/src/workspaces/keys.rs @@ -0,0 +1,15 @@ +//! E2EE key types for workspaces + +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +/// Workspace encrypted key (KEK encrypted with user's public key) +#[derive(Debug, Clone)] +pub struct WorkspaceEncryptedKey { + pub id: Uuid, + pub workspace_id: Uuid, + pub user_id: Uuid, + pub encrypted_kek: Vec, + pub key_version: i32, + pub created_at: DateTime, +} diff --git a/api/crates/domain/src/workspaces/mod.rs b/api/crates/domain/src/workspaces/mod.rs index e6f32be6..e2f4f774 100644 --- a/api/crates/domain/src/workspaces/mod.rs +++ b/api/crates/domain/src/workspaces/mod.rs @@ -1,2 +1,3 @@ +pub mod keys; pub mod permissions; pub mod roles; diff --git a/api/migrations/202701020001_add_e2ee_tables.sql b/api/migrations/202701020001_add_e2ee_tables.sql new file mode 100644 index 00000000..96cbef72 --- /dev/null +++ b/api/migrations/202701020001_add_e2ee_tables.sql @@ -0,0 +1,151 @@ +-- E2EE (End-to-End Encryption) Schema Migration +-- Phase 0: Database schema changes for E2EE support + +-------------------------------------------------------------------------------- +-- Part 1: New tables for key management +-------------------------------------------------------------------------------- + +-- User public keys (ECDH P-256) +CREATE TABLE IF NOT EXISTS user_public_keys ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + public_key BYTEA NOT NULL, + key_type TEXT NOT NULL DEFAULT 'ecdh-p256', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- User encrypted master keys (for recovery) +CREATE TABLE IF NOT EXISTS user_encrypted_master_keys ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + encrypted_key BYTEA NOT NULL, + salt BYTEA NOT NULL, + kdf_type TEXT NOT NULL DEFAULT 'argon2id', + kdf_params JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- User encrypted private keys (encrypted with UMK) +CREATE TABLE IF NOT EXISTS user_encrypted_private_keys ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + encrypted_private_key BYTEA NOT NULL, + nonce BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Workspace encrypted keys (KEK encrypted with each member's public key) +CREATE TABLE IF NOT EXISTS workspace_encrypted_keys ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + encrypted_kek BYTEA NOT NULL, + key_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (workspace_id, user_id, key_version) +); + +-- Document encrypted keys (DEK encrypted with workspace KEK) +CREATE TABLE IF NOT EXISTS document_encrypted_keys ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + encrypted_dek BYTEA NOT NULL, + nonce BYTEA NOT NULL, + key_version INT NOT NULL DEFAULT 1, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Share encrypted keys (DEK encrypted with share key) +CREATE TABLE IF NOT EXISTS share_encrypted_keys ( + share_id UUID PRIMARY KEY REFERENCES shares(id) ON DELETE CASCADE, + encrypted_dek BYTEA NOT NULL, + salt BYTEA, + kdf_params JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Public document contents (plaintext for published documents) +CREATE TABLE IF NOT EXISTS public_document_contents ( + document_id UUID PRIMARY KEY REFERENCES documents(id) ON DELETE CASCADE, + content TEXT NOT NULL, + title TEXT NOT NULL, + content_hash TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Encrypted tag index (deterministic encryption for searchable tags) +CREATE TABLE IF NOT EXISTS encrypted_tag_index ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE, + encrypted_tag BYTEA NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-------------------------------------------------------------------------------- +-- Part 2: Add columns to existing tables +-------------------------------------------------------------------------------- + +-- Users: E2EE setup tracking +ALTER TABLE users + ADD COLUMN IF NOT EXISTS e2ee_setup_completed_at TIMESTAMPTZ; + +-- Documents: encrypted title +ALTER TABLE documents + ADD COLUMN IF NOT EXISTS encrypted_title BYTEA, + ADD COLUMN IF NOT EXISTS encrypted_title_nonce BYTEA; + +-- Document updates: encryption metadata and signature +ALTER TABLE document_updates + ADD COLUMN IF NOT EXISTS nonce BYTEA, + ADD COLUMN IF NOT EXISTS signature BYTEA, + ADD COLUMN IF NOT EXISTS public_key BYTEA; + +-- Document snapshots: encryption metadata and signature +ALTER TABLE document_snapshots + ADD COLUMN IF NOT EXISTS nonce BYTEA, + ADD COLUMN IF NOT EXISTS signature BYTEA; + +-- Files: encrypted metadata +ALTER TABLE files + ADD COLUMN IF NOT EXISTS encrypted_metadata BYTEA, + ADD COLUMN IF NOT EXISTS encrypted_metadata_nonce BYTEA, + ADD COLUMN IF NOT EXISTS encrypted_hash TEXT; + +-- Git configs: encrypted auth data +ALTER TABLE git_configs + ADD COLUMN IF NOT EXISTS encrypted_auth_data BYTEA, + ADD COLUMN IF NOT EXISTS encrypted_auth_nonce BYTEA; + +-- Plugin KV: encrypted value +ALTER TABLE plugin_kv + ADD COLUMN IF NOT EXISTS encrypted_value BYTEA, + ADD COLUMN IF NOT EXISTS nonce BYTEA; + +-- Plugin records: encrypted data +ALTER TABLE plugin_records + ADD COLUMN IF NOT EXISTS encrypted_data BYTEA, + ADD COLUMN IF NOT EXISTS nonce BYTEA; + +-------------------------------------------------------------------------------- +-- Part 3: Indexes +-------------------------------------------------------------------------------- + +-- Workspace encrypted keys lookup +CREATE INDEX IF NOT EXISTS idx_workspace_encrypted_keys_workspace + ON workspace_encrypted_keys(workspace_id); + +CREATE INDEX IF NOT EXISTS idx_workspace_encrypted_keys_user + ON workspace_encrypted_keys(user_id); + +-- Encrypted tag index lookup (for deterministic encryption search) +CREATE INDEX IF NOT EXISTS idx_encrypted_tag_index_workspace_tag + ON encrypted_tag_index(workspace_id, encrypted_tag); + +CREATE INDEX IF NOT EXISTS idx_encrypted_tag_index_document + ON encrypted_tag_index(document_id); + +-- E2EE setup status lookup +CREATE INDEX IF NOT EXISTS idx_users_e2ee_setup + ON users(e2ee_setup_completed_at) + WHERE e2ee_setup_completed_at IS NOT NULL; From 2b1701b1d61d00647acf86ab2370c197d6d602cb Mon Sep 17 00:00:00 2001 From: munenick Date: Wed, 7 Jan 2026 15:02:09 +0900 Subject: [PATCH 02/45] add: key management --- api/Cargo.lock | 1 + .../application/src/documents/dtos/keys.rs | 28 ++ .../application/src/documents/dtos/mod.rs | 2 + .../ports/document_keys_repository.rs | 35 +++ .../application/src/documents/ports/mod.rs | 2 + .../documents/ports/share_keys_repository.rs | 42 +++ .../src/documents/services/keys.rs | 211 +++++++++++++++ .../application/src/documents/services/mod.rs | 1 + .../application/src/identity/dtos/keys.rs | 32 +++ .../application/src/identity/dtos/mod.rs | 2 + .../application/src/identity/ports/mod.rs | 1 + .../identity/ports/user_keys_repository.rs | 76 ++++++ .../application/src/identity/services/mod.rs | 1 + .../src/identity/services/user_keys/mod.rs | 197 ++++++++++++++ .../application/src/workspaces/dtos/keys.rs | 11 + .../application/src/workspaces/dtos/mod.rs | 4 +- .../application/src/workspaces/ports/mod.rs | 1 + .../ports/workspace_keys_repository.rs | 52 ++++ .../src/workspaces/services/mod.rs | 1 + .../src/workspaces/services/workspace_keys.rs | 165 +++++++++++ api/crates/bootstrap/src/app/build_runtime.rs | 32 +++ api/crates/bootstrap/src/http.rs | 8 + .../document_keys_repository_sqlx/mod.rs | 101 +++++++ .../src/documents/db/repositories/mod.rs | 2 + .../share_keys_repository_sqlx/mod.rs | 146 ++++++++++ .../src/identity/db/repositories/mod.rs | 1 + .../user_keys_repository_sqlx/mod.rs | 256 ++++++++++++++++++ .../src/workspaces/db/repositories/mod.rs | 1 + .../workspace_keys_repository_sqlx/mod.rs | 175 ++++++++++++ api/crates/presentation/Cargo.toml | 1 + api/crates/presentation/src/context.rs | 24 ++ .../presentation/src/context/subcontexts.rs | 19 ++ .../src/http/documents/keys/handlers/mod.rs | 217 +++++++++++++++ .../src/http/documents/keys/mod.rs | 38 +++ .../src/http/documents/keys/types.rs | 218 +++++++++++++++ .../presentation/src/http/documents/mod.rs | 1 + .../src/http/identity/keys/handlers/mod.rs | 245 +++++++++++++++++ .../src/http/identity/keys/mod.rs | 39 +++ .../src/http/identity/keys/types.rs | 200 ++++++++++++++ .../presentation/src/http/identity/mod.rs | 1 + .../presentation/src/http/workspaces/keys.rs | 213 +++++++++++++++ .../presentation/src/http/workspaces/mod.rs | 18 ++ .../presentation/src/http/workspaces/types.rs | 103 +++++++ api/crates/presentation/src/openapi.rs | 50 +++- api/openapi.json | 4 +- 45 files changed, 2975 insertions(+), 3 deletions(-) create mode 100644 api/crates/application/src/documents/dtos/keys.rs create mode 100644 api/crates/application/src/documents/ports/document_keys_repository.rs create mode 100644 api/crates/application/src/documents/ports/share_keys_repository.rs create mode 100644 api/crates/application/src/documents/services/keys.rs create mode 100644 api/crates/application/src/identity/dtos/keys.rs create mode 100644 api/crates/application/src/identity/ports/user_keys_repository.rs create mode 100644 api/crates/application/src/identity/services/user_keys/mod.rs create mode 100644 api/crates/application/src/workspaces/dtos/keys.rs create mode 100644 api/crates/application/src/workspaces/ports/workspace_keys_repository.rs create mode 100644 api/crates/application/src/workspaces/services/workspace_keys.rs create mode 100644 api/crates/infrastructure/src/documents/db/repositories/document_keys_repository_sqlx/mod.rs create mode 100644 api/crates/infrastructure/src/documents/db/repositories/share_keys_repository_sqlx/mod.rs create mode 100644 api/crates/infrastructure/src/identity/db/repositories/user_keys_repository_sqlx/mod.rs create mode 100644 api/crates/infrastructure/src/workspaces/db/repositories/workspace_keys_repository_sqlx/mod.rs create mode 100644 api/crates/presentation/src/http/documents/keys/handlers/mod.rs create mode 100644 api/crates/presentation/src/http/documents/keys/mod.rs create mode 100644 api/crates/presentation/src/http/documents/keys/types.rs create mode 100644 api/crates/presentation/src/http/identity/keys/handlers/mod.rs create mode 100644 api/crates/presentation/src/http/identity/keys/mod.rs create mode 100644 api/crates/presentation/src/http/identity/keys/types.rs create mode 100644 api/crates/presentation/src/http/workspaces/keys.rs diff --git a/api/Cargo.lock b/api/Cargo.lock index 6956e571..b4b2d9c0 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -4026,6 +4026,7 @@ dependencies = [ "anyhow", "application", "axum", + "base64 0.22.1", "chrono", "contracts", "domain", diff --git a/api/crates/application/src/documents/dtos/keys.rs b/api/crates/application/src/documents/dtos/keys.rs new file mode 100644 index 00000000..f5bdc039 --- /dev/null +++ b/api/crates/application/src/documents/dtos/keys.rs @@ -0,0 +1,28 @@ +use uuid::Uuid; + +use domain::identity::keys::KdfParams; + +#[derive(Debug, Clone)] +pub struct DocumentEncryptedKeyDto { + pub document_id: Uuid, + pub encrypted_dek: Vec, + pub nonce: Vec, + pub key_version: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +pub struct ShareEncryptedKeyDto { + pub share_id: Uuid, + pub encrypted_dek: Vec, + pub salt: Option>, + pub kdf_params: Option, + pub created_at: chrono::DateTime, +} + +impl ShareEncryptedKeyDto { + pub fn is_password_protected(&self) -> bool { + self.salt.is_some() + } +} diff --git a/api/crates/application/src/documents/dtos/mod.rs b/api/crates/application/src/documents/dtos/mod.rs index 480b3893..7a36d186 100644 --- a/api/crates/application/src/documents/dtos/mod.rs +++ b/api/crates/application/src/documents/dtos/mod.rs @@ -1,11 +1,13 @@ mod document_export; mod documents; +mod keys; mod public; mod shares; mod tags; pub use document_export::*; pub use documents::*; +pub use keys::*; pub use public::*; pub use shares::*; pub use tags::*; diff --git a/api/crates/application/src/documents/ports/document_keys_repository.rs b/api/crates/application/src/documents/ports/document_keys_repository.rs new file mode 100644 index 00000000..37323f5f --- /dev/null +++ b/api/crates/application/src/documents/ports/document_keys_repository.rs @@ -0,0 +1,35 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; + +#[derive(Debug, Clone)] +pub struct DocumentEncryptedKeyRow { + pub document_id: Uuid, + pub encrypted_dek: Vec, + pub nonce: Vec, + pub key_version: i32, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[async_trait] +pub trait DocumentKeysRepository: Send + Sync { + /// Get the encrypted DEK for a document + async fn get_encrypted_dek( + &self, + document_id: Uuid, + ) -> PortResult>; + + /// Store or update an encrypted DEK for a document + async fn upsert_encrypted_dek( + &self, + document_id: Uuid, + encrypted_dek: &[u8], + nonce: &[u8], + key_version: i32, + ) -> PortResult; + + /// Delete an encrypted DEK (when document is deleted) + async fn delete_encrypted_dek(&self, document_id: Uuid) -> PortResult; +} diff --git a/api/crates/application/src/documents/ports/mod.rs b/api/crates/application/src/documents/ports/mod.rs index 09fd2b4b..fa998afc 100644 --- a/api/crates/application/src/documents/ports/mod.rs +++ b/api/crates/application/src/documents/ports/mod.rs @@ -1,6 +1,7 @@ pub mod access_repository; pub mod doc_event_log; pub mod document_exporter; +pub mod document_keys_repository; pub mod document_path_repository; pub mod document_repository; pub mod document_snapshot_archive_repository; @@ -8,6 +9,7 @@ pub mod files; pub mod linkgraph_repository; pub mod publishing; pub mod realtime; +pub mod share_keys_repository; pub mod sharing; pub mod tagging; pub mod tx_runner; diff --git a/api/crates/application/src/documents/ports/share_keys_repository.rs b/api/crates/application/src/documents/ports/share_keys_repository.rs new file mode 100644 index 00000000..b37be59c --- /dev/null +++ b/api/crates/application/src/documents/ports/share_keys_repository.rs @@ -0,0 +1,42 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; +use domain::identity::keys::KdfParams; + +#[derive(Debug, Clone)] +pub struct ShareEncryptedKeyRow { + pub share_id: Uuid, + pub encrypted_dek: Vec, + pub salt: Option>, + pub kdf_params: Option, + pub created_at: chrono::DateTime, +} + +#[async_trait] +pub trait ShareKeysRepository: Send + Sync { + /// Get the encrypted DEK for a share + async fn get_encrypted_dek(&self, share_id: Uuid) -> PortResult>; + + /// Get the salt for a password-protected share (for client-side KDF) + async fn get_salt(&self, share_id: Uuid) -> PortResult>>; + + /// Store an encrypted DEK for a share (URL fragment based, no password) + async fn store_encrypted_dek( + &self, + share_id: Uuid, + encrypted_dek: &[u8], + ) -> PortResult; + + /// Store an encrypted DEK for a password-protected share + async fn store_password_protected_dek( + &self, + share_id: Uuid, + encrypted_dek: &[u8], + salt: &[u8], + kdf_params: &KdfParams, + ) -> PortResult; + + /// Delete an encrypted DEK (when share is deleted) + async fn delete_encrypted_dek(&self, share_id: Uuid) -> PortResult; +} diff --git a/api/crates/application/src/documents/services/keys.rs b/api/crates/application/src/documents/services/keys.rs new file mode 100644 index 00000000..1848cb30 --- /dev/null +++ b/api/crates/application/src/documents/services/keys.rs @@ -0,0 +1,211 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::services::errors::ServiceError; +use crate::documents::dtos::{DocumentEncryptedKeyDto, ShareEncryptedKeyDto}; +use crate::documents::ports::document_keys_repository::DocumentKeysRepository; +use crate::documents::ports::share_keys_repository::ShareKeysRepository; +use domain::identity::keys::KdfParams; + +pub struct DocumentKeysService { + document_keys_repo: Arc, + share_keys_repo: Arc, +} + +#[async_trait] +pub trait DocumentKeysServiceFacade: Send + Sync { + // Document keys + async fn get_document_key( + &self, + document_id: Uuid, + ) -> Result, ServiceError>; + + async fn store_document_key( + &self, + document_id: Uuid, + encrypted_dek: Vec, + nonce: Vec, + key_version: i32, + ) -> Result; + + // Share keys + async fn get_share_key( + &self, + share_id: Uuid, + ) -> Result, ServiceError>; + + async fn get_share_salt(&self, share_id: Uuid) -> Result>, ServiceError>; + + async fn store_share_key( + &self, + share_id: Uuid, + encrypted_dek: Vec, + ) -> Result; + + async fn store_password_protected_share_key( + &self, + share_id: Uuid, + encrypted_dek: Vec, + salt: Vec, + kdf_params: KdfParams, + ) -> Result; + + /// Rotate document DEK + /// Returns the new key version + async fn rotate_document_key( + &self, + document_id: Uuid, + encrypted_dek: Vec, + nonce: Vec, + ) -> Result; +} + +impl DocumentKeysService { + pub fn new( + document_keys_repo: Arc, + share_keys_repo: Arc, + ) -> Self { + Self { + document_keys_repo, + share_keys_repo, + } + } +} + +#[async_trait] +impl DocumentKeysServiceFacade for DocumentKeysService { + async fn get_document_key( + &self, + document_id: Uuid, + ) -> Result, ServiceError> { + let row = self + .document_keys_repo + .get_encrypted_dek(document_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(|r| DocumentEncryptedKeyDto { + document_id: r.document_id, + encrypted_dek: r.encrypted_dek, + nonce: r.nonce, + key_version: r.key_version, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + async fn store_document_key( + &self, + document_id: Uuid, + encrypted_dek: Vec, + nonce: Vec, + key_version: i32, + ) -> Result { + let row = self + .document_keys_repo + .upsert_encrypted_dek(document_id, &encrypted_dek, &nonce, key_version) + .await + .map_err(ServiceError::from)?; + Ok(DocumentEncryptedKeyDto { + document_id: row.document_id, + encrypted_dek: row.encrypted_dek, + nonce: row.nonce, + key_version: row.key_version, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + + async fn get_share_key( + &self, + share_id: Uuid, + ) -> Result, ServiceError> { + let row = self + .share_keys_repo + .get_encrypted_dek(share_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(|r| ShareEncryptedKeyDto { + share_id: r.share_id, + encrypted_dek: r.encrypted_dek, + salt: r.salt, + kdf_params: r.kdf_params, + created_at: r.created_at, + })) + } + + async fn get_share_salt(&self, share_id: Uuid) -> Result>, ServiceError> { + self.share_keys_repo + .get_salt(share_id) + .await + .map_err(ServiceError::from) + } + + async fn store_share_key( + &self, + share_id: Uuid, + encrypted_dek: Vec, + ) -> Result { + let row = self + .share_keys_repo + .store_encrypted_dek(share_id, &encrypted_dek) + .await + .map_err(ServiceError::from)?; + Ok(ShareEncryptedKeyDto { + share_id: row.share_id, + encrypted_dek: row.encrypted_dek, + salt: row.salt, + kdf_params: row.kdf_params, + created_at: row.created_at, + }) + } + + async fn store_password_protected_share_key( + &self, + share_id: Uuid, + encrypted_dek: Vec, + salt: Vec, + kdf_params: KdfParams, + ) -> Result { + let row = self + .share_keys_repo + .store_password_protected_dek(share_id, &encrypted_dek, &salt, &kdf_params) + .await + .map_err(ServiceError::from)?; + Ok(ShareEncryptedKeyDto { + share_id: row.share_id, + encrypted_dek: row.encrypted_dek, + salt: row.salt, + kdf_params: row.kdf_params, + created_at: row.created_at, + }) + } + + async fn rotate_document_key( + &self, + document_id: Uuid, + encrypted_dek: Vec, + nonce: Vec, + ) -> Result { + // Get current key version + let current_version = self + .document_keys_repo + .get_encrypted_dek(document_id) + .await + .map_err(ServiceError::from)? + .map(|r| r.key_version) + .unwrap_or(0); + + // Increment version + let new_version = current_version + 1; + + // Store new key with incremented version + self.document_keys_repo + .upsert_encrypted_dek(document_id, &encrypted_dek, &nonce, new_version) + .await + .map_err(ServiceError::from)?; + + Ok(new_version) + } +} diff --git a/api/crates/application/src/documents/services/mod.rs b/api/crates/application/src/documents/services/mod.rs index 74d21ea7..5126efae 100644 --- a/api/crates/application/src/documents/services/mod.rs +++ b/api/crates/application/src/documents/services/mod.rs @@ -31,6 +31,7 @@ mod downloads; mod events; pub mod files; mod jobs; +pub mod keys; mod lifecycle; pub mod linkgraph; mod links; diff --git a/api/crates/application/src/identity/dtos/keys.rs b/api/crates/application/src/identity/dtos/keys.rs new file mode 100644 index 00000000..cc5c7b53 --- /dev/null +++ b/api/crates/application/src/identity/dtos/keys.rs @@ -0,0 +1,32 @@ +use uuid::Uuid; + +use domain::identity::keys::{KdfParams, KdfType, KeyType}; + +#[derive(Debug, Clone)] +pub struct UserPublicKeyDto { + pub user_id: Uuid, + pub public_key: Vec, + pub key_type: KeyType, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +pub struct UserEncryptedMasterKeyDto { + pub user_id: Uuid, + pub encrypted_key: Vec, + pub salt: Vec, + pub kdf_type: KdfType, + pub kdf_params: KdfParams, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +pub struct UserEncryptedPrivateKeyDto { + pub user_id: Uuid, + pub encrypted_private_key: Vec, + pub nonce: Vec, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} diff --git a/api/crates/application/src/identity/dtos/mod.rs b/api/crates/application/src/identity/dtos/mod.rs index 80430ce7..3f3954a9 100644 --- a/api/crates/application/src/identity/dtos/mod.rs +++ b/api/crates/application/src/identity/dtos/mod.rs @@ -1,7 +1,9 @@ mod api_tokens; mod auth; +mod keys; mod user_shortcuts; pub use api_tokens::*; pub use auth::*; +pub use keys::*; pub use user_shortcuts::*; diff --git a/api/crates/application/src/identity/ports/mod.rs b/api/crates/application/src/identity/ports/mod.rs index 4f5afe38..6150d863 100644 --- a/api/crates/application/src/identity/ports/mod.rs +++ b/api/crates/application/src/identity/ports/mod.rs @@ -1,6 +1,7 @@ pub mod api_token_repository; pub mod jwt_codec; pub mod secret_hasher; +pub mod user_keys_repository; pub mod user_repository; pub mod user_session_repository; pub mod user_shortcuts; diff --git a/api/crates/application/src/identity/ports/user_keys_repository.rs b/api/crates/application/src/identity/ports/user_keys_repository.rs new file mode 100644 index 00000000..5ec7f811 --- /dev/null +++ b/api/crates/application/src/identity/ports/user_keys_repository.rs @@ -0,0 +1,76 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; +use domain::identity::keys::{KdfParams, KdfType, KeyType}; + +#[derive(Debug, Clone)] +pub struct UserPublicKeyRow { + pub user_id: Uuid, + pub public_key: Vec, + pub key_type: KeyType, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +pub struct UserEncryptedMasterKeyRow { + pub user_id: Uuid, + pub encrypted_key: Vec, + pub salt: Vec, + pub kdf_type: KdfType, + pub kdf_params: KdfParams, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone)] +pub struct UserEncryptedPrivateKeyRow { + pub user_id: Uuid, + pub encrypted_private_key: Vec, + pub nonce: Vec, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[async_trait] +pub trait UserKeysRepository: Send + Sync { + // Public keys + async fn get_public_key(&self, user_id: Uuid) -> PortResult>; + async fn upsert_public_key( + &self, + user_id: Uuid, + public_key: &[u8], + key_type: KeyType, + ) -> PortResult; + + // Encrypted master keys (for recovery) + async fn get_encrypted_master_key( + &self, + user_id: Uuid, + ) -> PortResult>; + async fn upsert_encrypted_master_key( + &self, + user_id: Uuid, + encrypted_key: &[u8], + salt: &[u8], + kdf_type: KdfType, + kdf_params: &KdfParams, + ) -> PortResult; + + // Encrypted private keys + async fn get_encrypted_private_key( + &self, + user_id: Uuid, + ) -> PortResult>; + async fn upsert_encrypted_private_key( + &self, + user_id: Uuid, + encrypted_private_key: &[u8], + nonce: &[u8], + ) -> PortResult; + + // E2EE setup status + async fn mark_e2ee_setup_completed(&self, user_id: Uuid) -> PortResult<()>; + async fn is_e2ee_setup_completed(&self, user_id: Uuid) -> PortResult; +} diff --git a/api/crates/application/src/identity/services/mod.rs b/api/crates/application/src/identity/services/mod.rs index de652f14..5971e04d 100644 --- a/api/crates/application/src/identity/services/mod.rs +++ b/api/crates/application/src/identity/services/mod.rs @@ -1,3 +1,4 @@ pub mod api_tokens; pub mod auth; +pub mod user_keys; pub mod user_shortcuts; diff --git a/api/crates/application/src/identity/services/user_keys/mod.rs b/api/crates/application/src/identity/services/user_keys/mod.rs new file mode 100644 index 00000000..339949cb --- /dev/null +++ b/api/crates/application/src/identity/services/user_keys/mod.rs @@ -0,0 +1,197 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::services::errors::ServiceError; +use crate::identity::dtos::{ + UserEncryptedMasterKeyDto, UserEncryptedPrivateKeyDto, UserPublicKeyDto, +}; +use crate::identity::ports::user_keys_repository::UserKeysRepository; +use domain::identity::keys::{KdfParams, KdfType, KeyType}; + +pub struct UserKeysService { + repo: Arc, +} + +#[async_trait] +pub trait UserKeysServiceFacade: Send + Sync { + // Public keys + async fn get_public_key(&self, user_id: Uuid) -> Result, ServiceError>; + async fn register_public_key( + &self, + user_id: Uuid, + public_key: Vec, + key_type: KeyType, + ) -> Result; + + // Master key backup (for recovery) + async fn get_master_key_backup( + &self, + user_id: Uuid, + ) -> Result, ServiceError>; + async fn store_master_key_backup( + &self, + user_id: Uuid, + encrypted_key: Vec, + salt: Vec, + kdf_type: KdfType, + kdf_params: KdfParams, + ) -> Result; + + // Private key (encrypted with UMK) + async fn get_encrypted_private_key( + &self, + user_id: Uuid, + ) -> Result, ServiceError>; + async fn store_encrypted_private_key( + &self, + user_id: Uuid, + encrypted_private_key: Vec, + nonce: Vec, + ) -> Result; + + // E2EE setup status + async fn mark_e2ee_setup_completed(&self, user_id: Uuid) -> Result<(), ServiceError>; + async fn is_e2ee_setup_completed(&self, user_id: Uuid) -> Result; +} + +impl UserKeysService { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +#[async_trait] +impl UserKeysServiceFacade for UserKeysService { + async fn get_public_key(&self, user_id: Uuid) -> Result, ServiceError> { + let row = self + .repo + .get_public_key(user_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(|r| UserPublicKeyDto { + user_id: r.user_id, + public_key: r.public_key, + key_type: r.key_type, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + async fn register_public_key( + &self, + user_id: Uuid, + public_key: Vec, + key_type: KeyType, + ) -> Result { + let row = self + .repo + .upsert_public_key(user_id, &public_key, key_type) + .await + .map_err(ServiceError::from)?; + Ok(UserPublicKeyDto { + user_id: row.user_id, + public_key: row.public_key, + key_type: row.key_type, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + + async fn get_master_key_backup( + &self, + user_id: Uuid, + ) -> Result, ServiceError> { + let row = self + .repo + .get_encrypted_master_key(user_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(|r| UserEncryptedMasterKeyDto { + user_id: r.user_id, + encrypted_key: r.encrypted_key, + salt: r.salt, + kdf_type: r.kdf_type, + kdf_params: r.kdf_params, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + async fn store_master_key_backup( + &self, + user_id: Uuid, + encrypted_key: Vec, + salt: Vec, + kdf_type: KdfType, + kdf_params: KdfParams, + ) -> Result { + let row = self + .repo + .upsert_encrypted_master_key(user_id, &encrypted_key, &salt, kdf_type, &kdf_params) + .await + .map_err(ServiceError::from)?; + Ok(UserEncryptedMasterKeyDto { + user_id: row.user_id, + encrypted_key: row.encrypted_key, + salt: row.salt, + kdf_type: row.kdf_type, + kdf_params: row.kdf_params, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + + async fn get_encrypted_private_key( + &self, + user_id: Uuid, + ) -> Result, ServiceError> { + let row = self + .repo + .get_encrypted_private_key(user_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(|r| UserEncryptedPrivateKeyDto { + user_id: r.user_id, + encrypted_private_key: r.encrypted_private_key, + nonce: r.nonce, + created_at: r.created_at, + updated_at: r.updated_at, + })) + } + + async fn store_encrypted_private_key( + &self, + user_id: Uuid, + encrypted_private_key: Vec, + nonce: Vec, + ) -> Result { + let row = self + .repo + .upsert_encrypted_private_key(user_id, &encrypted_private_key, &nonce) + .await + .map_err(ServiceError::from)?; + Ok(UserEncryptedPrivateKeyDto { + user_id: row.user_id, + encrypted_private_key: row.encrypted_private_key, + nonce: row.nonce, + created_at: row.created_at, + updated_at: row.updated_at, + }) + } + + async fn mark_e2ee_setup_completed(&self, user_id: Uuid) -> Result<(), ServiceError> { + self.repo + .mark_e2ee_setup_completed(user_id) + .await + .map_err(ServiceError::from) + } + + async fn is_e2ee_setup_completed(&self, user_id: Uuid) -> Result { + self.repo + .is_e2ee_setup_completed(user_id) + .await + .map_err(ServiceError::from) + } +} diff --git a/api/crates/application/src/workspaces/dtos/keys.rs b/api/crates/application/src/workspaces/dtos/keys.rs new file mode 100644 index 00000000..add648b1 --- /dev/null +++ b/api/crates/application/src/workspaces/dtos/keys.rs @@ -0,0 +1,11 @@ +use uuid::Uuid; + +#[derive(Debug, Clone)] +pub struct WorkspaceEncryptedKeyDto { + pub id: Uuid, + pub workspace_id: Uuid, + pub user_id: Uuid, + pub encrypted_kek: Vec, + pub key_version: i32, + pub created_at: chrono::DateTime, +} diff --git a/api/crates/application/src/workspaces/dtos/mod.rs b/api/crates/application/src/workspaces/dtos/mod.rs index 33573843..1da1a6fc 100644 --- a/api/crates/application/src/workspaces/dtos/mod.rs +++ b/api/crates/application/src/workspaces/dtos/mod.rs @@ -1 +1,3 @@ -// Intentionally left empty for now. +mod keys; + +pub use keys::*; diff --git a/api/crates/application/src/workspaces/ports/mod.rs b/api/crates/application/src/workspaces/ports/mod.rs index 895c5f07..a15a601f 100644 --- a/api/crates/application/src/workspaces/ports/mod.rs +++ b/api/crates/application/src/workspaces/ports/mod.rs @@ -1 +1,2 @@ +pub mod workspace_keys_repository; pub mod workspace_repository; diff --git a/api/crates/application/src/workspaces/ports/workspace_keys_repository.rs b/api/crates/application/src/workspaces/ports/workspace_keys_repository.rs new file mode 100644 index 00000000..07ecf8df --- /dev/null +++ b/api/crates/application/src/workspaces/ports/workspace_keys_repository.rs @@ -0,0 +1,52 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; + +#[derive(Debug, Clone)] +pub struct WorkspaceEncryptedKeyRow { + pub id: Uuid, + pub workspace_id: Uuid, + pub user_id: Uuid, + pub encrypted_kek: Vec, + pub key_version: i32, + pub created_at: chrono::DateTime, +} + +#[async_trait] +pub trait WorkspaceKeysRepository: Send + Sync { + /// Get the encrypted KEK for a user in a workspace + async fn get_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + ) -> PortResult>; + + /// Get all encrypted KEKs for a workspace (for re-encryption during key rotation) + async fn list_encrypted_keks( + &self, + workspace_id: Uuid, + ) -> PortResult>; + + /// Store or update an encrypted KEK for a user + async fn upsert_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: &[u8], + key_version: i32, + ) -> PortResult; + + /// Delete an encrypted KEK (when user is removed from workspace) + async fn delete_encrypted_kek(&self, workspace_id: Uuid, user_id: Uuid) -> PortResult; + + /// Delete a specific key version for a workspace (for key rotation cleanup) + async fn delete_encrypted_kek_version( + &self, + workspace_id: Uuid, + key_version: i32, + ) -> PortResult; + + /// Get the current key version for a workspace + async fn get_current_key_version(&self, workspace_id: Uuid) -> PortResult>; +} diff --git a/api/crates/application/src/workspaces/services/mod.rs b/api/crates/application/src/workspaces/services/mod.rs index d52b15cc..a2b4775b 100644 --- a/api/crates/application/src/workspaces/services/mod.rs +++ b/api/crates/application/src/workspaces/services/mod.rs @@ -12,6 +12,7 @@ use domain::workspaces::roles::{WorkspaceBaseRole, WorkspaceRoleKind, WorkspaceS pub mod permission_snapshot; mod slug; +pub mod workspace_keys; use crate::core::services::errors::ServiceError; use crate::workspaces::ports::workspace_repository::{ WorkspaceInvitationRecord, WorkspaceListItem, WorkspaceMemberDetail, WorkspaceMemberRow, diff --git a/api/crates/application/src/workspaces/services/workspace_keys.rs b/api/crates/application/src/workspaces/services/workspace_keys.rs new file mode 100644 index 00000000..ad41861d --- /dev/null +++ b/api/crates/application/src/workspaces/services/workspace_keys.rs @@ -0,0 +1,165 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::services::errors::ServiceError; +use crate::workspaces::dtos::WorkspaceEncryptedKeyDto; +use crate::workspaces::ports::workspace_keys_repository::{ + WorkspaceEncryptedKeyRow, WorkspaceKeysRepository, +}; + +pub struct WorkspaceKeysService { + repo: Arc, +} + +#[async_trait] +pub trait WorkspaceKeysServiceFacade: Send + Sync { + /// Get the encrypted KEK for the current user in a workspace + async fn get_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + ) -> Result, ServiceError>; + + /// Store an encrypted KEK for a user (used when sharing workspace key) + async fn store_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: Vec, + key_version: i32, + ) -> Result; + + /// Get all encrypted KEKs for a workspace (for key rotation) + async fn list_encrypted_keks( + &self, + workspace_id: Uuid, + ) -> Result, ServiceError>; + + /// Get the current key version for a workspace + async fn get_current_key_version(&self, workspace_id: Uuid) -> Result, ServiceError>; + + /// Delete a specific key version (for key rotation cleanup) + async fn delete_key_version( + &self, + workspace_id: Uuid, + key_version: i32, + ) -> Result; + + /// Rotate workspace KEK for all members + /// Returns the new key version and number of keys updated + async fn rotate_keys( + &self, + workspace_id: Uuid, + member_keys: Vec<(Uuid, Vec)>, // (user_id, encrypted_kek) + ) -> Result<(i32, usize), ServiceError>; +} + +impl WorkspaceKeysService { + pub fn new(repo: Arc) -> Self { + Self { repo } + } +} + +fn row_to_dto(row: WorkspaceEncryptedKeyRow) -> WorkspaceEncryptedKeyDto { + WorkspaceEncryptedKeyDto { + id: row.id, + workspace_id: row.workspace_id, + user_id: row.user_id, + encrypted_kek: row.encrypted_kek, + key_version: row.key_version, + created_at: row.created_at, + } +} + +#[async_trait] +impl WorkspaceKeysServiceFacade for WorkspaceKeysService { + async fn get_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + ) -> Result, ServiceError> { + let row = self + .repo + .get_encrypted_kek(workspace_id, user_id) + .await + .map_err(ServiceError::from)?; + Ok(row.map(row_to_dto)) + } + + async fn store_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: Vec, + key_version: i32, + ) -> Result { + let row = self + .repo + .upsert_encrypted_kek(workspace_id, user_id, &encrypted_kek, key_version) + .await + .map_err(ServiceError::from)?; + Ok(row_to_dto(row)) + } + + async fn list_encrypted_keks( + &self, + workspace_id: Uuid, + ) -> Result, ServiceError> { + let rows = self + .repo + .list_encrypted_keks(workspace_id) + .await + .map_err(ServiceError::from)?; + Ok(rows.into_iter().map(row_to_dto).collect()) + } + + async fn get_current_key_version(&self, workspace_id: Uuid) -> Result, ServiceError> { + self.repo + .get_current_key_version(workspace_id) + .await + .map_err(ServiceError::from) + } + + async fn delete_key_version( + &self, + workspace_id: Uuid, + key_version: i32, + ) -> Result { + self.repo + .delete_encrypted_kek_version(workspace_id, key_version) + .await + .map_err(ServiceError::from) + } + + async fn rotate_keys( + &self, + workspace_id: Uuid, + member_keys: Vec<(Uuid, Vec)>, + ) -> Result<(i32, usize), ServiceError> { + if member_keys.is_empty() { + return Err(ServiceError::BadRequest("no_member_keys_provided")); + } + + // Get current key version and increment + let current_version = self + .repo + .get_current_key_version(workspace_id) + .await + .map_err(ServiceError::from)?; + let new_version = current_version.unwrap_or(0) + 1; + + // Store encrypted KEKs for all members with new version + let mut updated_count = 0; + for (user_id, encrypted_kek) in member_keys { + self.repo + .upsert_encrypted_kek(workspace_id, user_id, &encrypted_kek, new_version) + .await + .map_err(ServiceError::from)?; + updated_count += 1; + } + + Ok((new_version, updated_count)) + } +} diff --git a/api/crates/bootstrap/src/app/build_runtime.rs b/api/crates/bootstrap/src/app/build_runtime.rs index 167036f5..ef45021a 100644 --- a/api/crates/bootstrap/src/app/build_runtime.rs +++ b/api/crates/bootstrap/src/app/build_runtime.rs @@ -20,6 +20,7 @@ use application::core::services::storage::reconcile_scheduler::StorageReconcileS use application::documents::ports::doc_event_log::DocEventLog; use application::documents::services::DocumentService; use application::documents::services::files::FileService; +use application::documents::services::keys::DocumentKeysService; use application::documents::services::publishing::PublicService; use application::documents::services::realtime::snapshot::MarkdownExportProvider; use application::documents::services::sharing::ShareService; @@ -28,6 +29,7 @@ use application::identity::ports::secret_hasher::SecretHasher; use application::identity::services::api_tokens::ApiTokenService; use application::identity::services::auth::account::AccountService; use application::identity::services::auth::token_validation::TokenValidationService; +use application::identity::services::user_keys::UserKeysService; use application::identity::services::user_shortcuts::UserShortcutService; use application::plugins::ports::plugin_event_publisher::PluginEventPublisher; use application::plugins::ports::plugin_event_subscriber::PluginEventSubscriber; @@ -36,6 +38,7 @@ use application::plugins::services::data::PluginDataService; use application::plugins::services::execution::PluginExecutionService; use application::plugins::services::management::PluginManagementService; use application::plugins::services::permissions::PluginPermissionService; +use application::workspaces::services::workspace_keys::WorkspaceKeysService; use application::workspaces::services::{WorkspacePermissionResolver, WorkspaceService}; use infrastructure::core::storage::{ FsIngestWatcher, PgStorageIngestQueue, PgStorageReconcileJobs, StorageConsistencyMonitor, @@ -154,6 +157,20 @@ pub async fn build_runtime( ), ); let share_service = Arc::new(ShareService::new(shares_repo_impl.clone())); + let document_keys_repo = Arc::new( + infrastructure::documents::db::repositories::document_keys_repository_sqlx::SqlxDocumentKeysRepository::new( + pool.clone(), + ), + ); + let share_keys_repo = Arc::new( + infrastructure::documents::db::repositories::share_keys_repository_sqlx::SqlxShareKeysRepository::new( + pool.clone(), + ), + ); + let document_keys_service = Arc::new(DocumentKeysService::new( + document_keys_repo.clone(), + share_keys_repo.clone(), + )); let access_repo = Arc::new( infrastructure::documents::db::repositories::access_repository_sqlx::SqlxAccessRepository::new( pool.clone(), @@ -192,6 +209,12 @@ pub async fn build_runtime( ), ); let workspace_service = Arc::new(WorkspaceService::new(workspace_repo.clone())); + let workspace_keys_repo = Arc::new( + infrastructure::workspaces::db::repositories::workspace_keys_repository_sqlx::SqlxWorkspaceKeysRepository::new( + pool.clone(), + ), + ); + let workspace_keys_service = Arc::new(WorkspaceKeysService::new(workspace_keys_repo.clone())); let workspace_permissions: Arc = workspace_service.clone(); { let reconcile_service = Arc::new(StorageReconcileService::new( @@ -259,6 +282,12 @@ pub async fn build_runtime( ); let user_shortcut_service = Arc::new(UserShortcutService::new(user_shortcuts.clone(), 32 * 1024)); + let user_keys_repo = Arc::new( + infrastructure::identity::db::repositories::user_keys_repository_sqlx::SqlxUserKeysRepository::new( + pool.clone(), + ), + ); + let user_keys_service = Arc::new(UserKeysService::new(user_keys_repo.clone())); let realtime_stack = realtime::build_realtime_stack( &cfg, &pool, @@ -442,6 +471,7 @@ pub async fn build_runtime( }, documents: DocumentServicesDeps { document_service: document_service.clone(), + document_keys_service: document_keys_service.clone(), share_service: share_service.clone(), file_service: file_service.clone(), public_service: public_service.clone(), @@ -454,6 +484,7 @@ pub async fn build_runtime( identity: IdentityServicesDeps { api_token_service: api_token_service.clone(), user_shortcut_service: user_shortcut_service.clone(), + user_keys_service: user_keys_service.clone(), account_service: account_service.clone(), auth_service: auth_stack.auth_service.clone(), session_service: auth_stack.session_service.clone(), @@ -468,6 +499,7 @@ pub async fn build_runtime( }, workspaces: WorkspaceServicesDeps { workspace_service: workspace_service.clone(), + workspace_keys_service: workspace_keys_service.clone(), }, }); diff --git a/api/crates/bootstrap/src/http.rs b/api/crates/bootstrap/src/http.rs index 2b682ae9..65e54c7a 100644 --- a/api/crates/bootstrap/src/http.rs +++ b/api/crates/bootstrap/src/http.rs @@ -35,6 +35,10 @@ pub async fn build_api_router(cfg: &Config, ctx: AppContext) -> anyhow::Result anyhow::Result Self { + Self { pool } + } +} + +#[async_trait] +impl DocumentKeysRepository for SqlxDocumentKeysRepository { + async fn get_encrypted_dek( + &self, + document_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT document_id, encrypted_dek, nonce, key_version, created_at, updated_at + FROM document_encrypted_keys + WHERE document_id = $1"#, + ) + .bind(document_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| DocumentEncryptedKeyRow { + document_id: row.get("document_id"), + encrypted_dek: row.get("encrypted_dek"), + nonce: row.get("nonce"), + key_version: row.get("key_version"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + })) + } + .await; + out.map_err(Into::into) + } + + async fn upsert_encrypted_dek( + &self, + document_id: Uuid, + encrypted_dek: &[u8], + nonce: &[u8], + key_version: i32, + ) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"INSERT INTO document_encrypted_keys (document_id, encrypted_dek, nonce, key_version, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + ON CONFLICT (document_id) + DO UPDATE SET + encrypted_dek = EXCLUDED.encrypted_dek, + nonce = EXCLUDED.nonce, + key_version = EXCLUDED.key_version, + updated_at = now() + RETURNING document_id, encrypted_dek, nonce, key_version, created_at, updated_at"#, + ) + .bind(document_id) + .bind(encrypted_dek) + .bind(nonce) + .bind(key_version) + .fetch_one(&self.pool) + .await?; + + Ok(DocumentEncryptedKeyRow { + document_id: row.get("document_id"), + encrypted_dek: row.get("encrypted_dek"), + nonce: row.get("nonce"), + key_version: row.get("key_version"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn delete_encrypted_dek(&self, document_id: Uuid) -> PortResult { + let out: anyhow::Result = async { + let result = sqlx::query(r#"DELETE FROM document_encrypted_keys WHERE document_id = $1"#) + .bind(document_id) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/infrastructure/src/documents/db/repositories/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/mod.rs index ee581066..0779757b 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/mod.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/mod.rs @@ -1,9 +1,11 @@ pub mod access_repository_sqlx; +pub mod document_keys_repository_sqlx; pub mod document_repository_sqlx; pub mod document_snapshot_archive_repository_sqlx; pub mod files_repository_sqlx; pub mod linkgraph_repository_sqlx; pub mod public_repository_sqlx; +pub mod share_keys_repository_sqlx; pub mod shares_repository_sqlx; pub mod tag_repository_sqlx; pub mod tagging_repository_sqlx; diff --git a/api/crates/infrastructure/src/documents/db/repositories/share_keys_repository_sqlx/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/share_keys_repository_sqlx/mod.rs new file mode 100644 index 00000000..e392e771 --- /dev/null +++ b/api/crates/infrastructure/src/documents/db/repositories/share_keys_repository_sqlx/mod.rs @@ -0,0 +1,146 @@ +use async_trait::async_trait; +use sqlx::Row; +use uuid::Uuid; + +use crate::core::db::PgPool; +use application::core::ports::errors::PortResult; +use application::documents::ports::share_keys_repository::{ShareEncryptedKeyRow, ShareKeysRepository}; +use domain::identity::keys::KdfParams; + +pub struct SqlxShareKeysRepository { + pool: PgPool, +} + +impl SqlxShareKeysRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl ShareKeysRepository for SqlxShareKeysRepository { + async fn get_encrypted_dek(&self, share_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT share_id, encrypted_dek, salt, kdf_params, created_at + FROM share_encrypted_keys + WHERE share_id = $1"#, + ) + .bind(share_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| { + let kdf_params_json: Option = row.get("kdf_params"); + ShareEncryptedKeyRow { + share_id: row.get("share_id"), + encrypted_dek: row.get("encrypted_dek"), + salt: row.get("salt"), + kdf_params: kdf_params_json.and_then(|v| serde_json::from_value(v).ok()), + created_at: row.get("created_at"), + } + })) + } + .await; + out.map_err(Into::into) + } + + async fn get_salt(&self, share_id: Uuid) -> PortResult>> { + let out: anyhow::Result>> = async { + let row = sqlx::query(r#"SELECT salt FROM share_encrypted_keys WHERE share_id = $1"#) + .bind(share_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.and_then(|r| r.get("salt"))) + } + .await; + out.map_err(Into::into) + } + + async fn store_encrypted_dek( + &self, + share_id: Uuid, + encrypted_dek: &[u8], + ) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"INSERT INTO share_encrypted_keys (share_id, encrypted_dek, created_at) + VALUES ($1, $2, now()) + ON CONFLICT (share_id) + DO UPDATE SET + encrypted_dek = EXCLUDED.encrypted_dek, + salt = NULL, + kdf_params = NULL + RETURNING share_id, encrypted_dek, salt, kdf_params, created_at"#, + ) + .bind(share_id) + .bind(encrypted_dek) + .fetch_one(&self.pool) + .await?; + + let kdf_params_json: Option = row.get("kdf_params"); + Ok(ShareEncryptedKeyRow { + share_id: row.get("share_id"), + encrypted_dek: row.get("encrypted_dek"), + salt: row.get("salt"), + kdf_params: kdf_params_json.and_then(|v| serde_json::from_value(v).ok()), + created_at: row.get("created_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn store_password_protected_dek( + &self, + share_id: Uuid, + encrypted_dek: &[u8], + salt: &[u8], + kdf_params: &KdfParams, + ) -> PortResult { + let out: anyhow::Result = async { + let kdf_params_json = serde_json::to_value(kdf_params)?; + let row = sqlx::query( + r#"INSERT INTO share_encrypted_keys (share_id, encrypted_dek, salt, kdf_params, created_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (share_id) + DO UPDATE SET + encrypted_dek = EXCLUDED.encrypted_dek, + salt = EXCLUDED.salt, + kdf_params = EXCLUDED.kdf_params + RETURNING share_id, encrypted_dek, salt, kdf_params, created_at"#, + ) + .bind(share_id) + .bind(encrypted_dek) + .bind(salt) + .bind(&kdf_params_json) + .fetch_one(&self.pool) + .await?; + + let kdf_params_json: Option = row.get("kdf_params"); + Ok(ShareEncryptedKeyRow { + share_id: row.get("share_id"), + encrypted_dek: row.get("encrypted_dek"), + salt: row.get("salt"), + kdf_params: kdf_params_json.and_then(|v| serde_json::from_value(v).ok()), + created_at: row.get("created_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn delete_encrypted_dek(&self, share_id: Uuid) -> PortResult { + let out: anyhow::Result = async { + let result = sqlx::query(r#"DELETE FROM share_encrypted_keys WHERE share_id = $1"#) + .bind(share_id) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/infrastructure/src/identity/db/repositories/mod.rs b/api/crates/infrastructure/src/identity/db/repositories/mod.rs index 2e6528ce..3c1a9aaf 100644 --- a/api/crates/infrastructure/src/identity/db/repositories/mod.rs +++ b/api/crates/infrastructure/src/identity/db/repositories/mod.rs @@ -1,4 +1,5 @@ pub mod api_token_repository_sqlx; +pub mod user_keys_repository_sqlx; pub mod user_repository_sqlx; pub mod user_session_repository_sqlx; pub mod user_shortcut_repository_sqlx; diff --git a/api/crates/infrastructure/src/identity/db/repositories/user_keys_repository_sqlx/mod.rs b/api/crates/infrastructure/src/identity/db/repositories/user_keys_repository_sqlx/mod.rs new file mode 100644 index 00000000..19942445 --- /dev/null +++ b/api/crates/infrastructure/src/identity/db/repositories/user_keys_repository_sqlx/mod.rs @@ -0,0 +1,256 @@ +use async_trait::async_trait; +use sqlx::Row; +use uuid::Uuid; + +use crate::core::db::PgPool; +use application::core::ports::errors::PortResult; +use application::identity::ports::user_keys_repository::{ + UserEncryptedMasterKeyRow, UserEncryptedPrivateKeyRow, UserKeysRepository, UserPublicKeyRow, +}; +use domain::identity::keys::{KdfParams, KdfType, KeyType}; + +pub struct SqlxUserKeysRepository { + pool: PgPool, +} + +impl SqlxUserKeysRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl UserKeysRepository for SqlxUserKeysRepository { + async fn get_public_key(&self, user_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT user_id, public_key, key_type, created_at, updated_at + FROM user_public_keys + WHERE user_id = $1"#, + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| { + let key_type_str: String = row.get("key_type"); + UserPublicKeyRow { + user_id: row.get("user_id"), + public_key: row.get("public_key"), + key_type: KeyType::parse(&key_type_str).unwrap_or(KeyType::EcdhP256), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + })) + } + .await; + out.map_err(Into::into) + } + + async fn upsert_public_key( + &self, + user_id: Uuid, + public_key: &[u8], + key_type: KeyType, + ) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"INSERT INTO user_public_keys (user_id, public_key, key_type, created_at, updated_at) + VALUES ($1, $2, $3, now(), now()) + ON CONFLICT (user_id) + DO UPDATE SET + public_key = EXCLUDED.public_key, + key_type = EXCLUDED.key_type, + updated_at = now() + RETURNING user_id, public_key, key_type, created_at, updated_at"#, + ) + .bind(user_id) + .bind(public_key) + .bind(key_type.as_str()) + .fetch_one(&self.pool) + .await?; + + let key_type_str: String = row.get("key_type"); + Ok(UserPublicKeyRow { + user_id: row.get("user_id"), + public_key: row.get("public_key"), + key_type: KeyType::parse(&key_type_str).unwrap_or(KeyType::EcdhP256), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn get_encrypted_master_key( + &self, + user_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT user_id, encrypted_key, salt, kdf_type, kdf_params, created_at, updated_at + FROM user_encrypted_master_keys + WHERE user_id = $1"#, + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| { + let kdf_type_str: String = row.get("kdf_type"); + let kdf_params_json: serde_json::Value = row.get("kdf_params"); + UserEncryptedMasterKeyRow { + user_id: row.get("user_id"), + encrypted_key: row.get("encrypted_key"), + salt: row.get("salt"), + kdf_type: KdfType::parse(&kdf_type_str).unwrap_or(KdfType::Argon2id), + kdf_params: serde_json::from_value(kdf_params_json).unwrap_or_default(), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + } + })) + } + .await; + out.map_err(Into::into) + } + + async fn upsert_encrypted_master_key( + &self, + user_id: Uuid, + encrypted_key: &[u8], + salt: &[u8], + kdf_type: KdfType, + kdf_params: &KdfParams, + ) -> PortResult { + let out: anyhow::Result = async { + let kdf_params_json = serde_json::to_value(kdf_params)?; + let row = sqlx::query( + r#"INSERT INTO user_encrypted_master_keys (user_id, encrypted_key, salt, kdf_type, kdf_params, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, now(), now()) + ON CONFLICT (user_id) + DO UPDATE SET + encrypted_key = EXCLUDED.encrypted_key, + salt = EXCLUDED.salt, + kdf_type = EXCLUDED.kdf_type, + kdf_params = EXCLUDED.kdf_params, + updated_at = now() + RETURNING user_id, encrypted_key, salt, kdf_type, kdf_params, created_at, updated_at"#, + ) + .bind(user_id) + .bind(encrypted_key) + .bind(salt) + .bind(kdf_type.as_str()) + .bind(&kdf_params_json) + .fetch_one(&self.pool) + .await?; + + let kdf_type_str: String = row.get("kdf_type"); + let kdf_params_json: serde_json::Value = row.get("kdf_params"); + Ok(UserEncryptedMasterKeyRow { + user_id: row.get("user_id"), + encrypted_key: row.get("encrypted_key"), + salt: row.get("salt"), + kdf_type: KdfType::parse(&kdf_type_str).unwrap_or(KdfType::Argon2id), + kdf_params: serde_json::from_value(kdf_params_json).unwrap_or_default(), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn get_encrypted_private_key( + &self, + user_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT user_id, encrypted_private_key, nonce, created_at, updated_at + FROM user_encrypted_private_keys + WHERE user_id = $1"#, + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| UserEncryptedPrivateKeyRow { + user_id: row.get("user_id"), + encrypted_private_key: row.get("encrypted_private_key"), + nonce: row.get("nonce"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + })) + } + .await; + out.map_err(Into::into) + } + + async fn upsert_encrypted_private_key( + &self, + user_id: Uuid, + encrypted_private_key: &[u8], + nonce: &[u8], + ) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"INSERT INTO user_encrypted_private_keys (user_id, encrypted_private_key, nonce, created_at, updated_at) + VALUES ($1, $2, $3, now(), now()) + ON CONFLICT (user_id) + DO UPDATE SET + encrypted_private_key = EXCLUDED.encrypted_private_key, + nonce = EXCLUDED.nonce, + updated_at = now() + RETURNING user_id, encrypted_private_key, nonce, created_at, updated_at"#, + ) + .bind(user_id) + .bind(encrypted_private_key) + .bind(nonce) + .fetch_one(&self.pool) + .await?; + + Ok(UserEncryptedPrivateKeyRow { + user_id: row.get("user_id"), + encrypted_private_key: row.get("encrypted_private_key"), + nonce: row.get("nonce"), + created_at: row.get("created_at"), + updated_at: row.get("updated_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn mark_e2ee_setup_completed(&self, user_id: Uuid) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query( + r#"UPDATE users SET e2ee_setup_completed_at = now() WHERE id = $1"#, + ) + .bind(user_id) + .execute(&self.pool) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } + + async fn is_e2ee_setup_completed(&self, user_id: Uuid) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"SELECT e2ee_setup_completed_at FROM users WHERE id = $1"#, + ) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row + .and_then(|r| r.try_get::>, _>("e2ee_setup_completed_at").ok()) + .flatten() + .is_some()) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/infrastructure/src/workspaces/db/repositories/mod.rs b/api/crates/infrastructure/src/workspaces/db/repositories/mod.rs index 501757ec..43437fe6 100644 --- a/api/crates/infrastructure/src/workspaces/db/repositories/mod.rs +++ b/api/crates/infrastructure/src/workspaces/db/repositories/mod.rs @@ -1 +1,2 @@ +pub mod workspace_keys_repository_sqlx; pub mod workspace_repository_sqlx; diff --git a/api/crates/infrastructure/src/workspaces/db/repositories/workspace_keys_repository_sqlx/mod.rs b/api/crates/infrastructure/src/workspaces/db/repositories/workspace_keys_repository_sqlx/mod.rs new file mode 100644 index 00000000..5ad7f6cd --- /dev/null +++ b/api/crates/infrastructure/src/workspaces/db/repositories/workspace_keys_repository_sqlx/mod.rs @@ -0,0 +1,175 @@ +use async_trait::async_trait; +use sqlx::Row; +use uuid::Uuid; + +use crate::core::db::PgPool; +use application::core::ports::errors::PortResult; +use application::workspaces::ports::workspace_keys_repository::{ + WorkspaceEncryptedKeyRow, WorkspaceKeysRepository, +}; + +pub struct SqlxWorkspaceKeysRepository { + pool: PgPool, +} + +impl SqlxWorkspaceKeysRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl WorkspaceKeysRepository for SqlxWorkspaceKeysRepository { + async fn get_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT id, workspace_id, user_id, encrypted_kek, key_version, created_at + FROM workspace_encrypted_keys + WHERE workspace_id = $1 AND user_id = $2 + ORDER BY key_version DESC + LIMIT 1"#, + ) + .bind(workspace_id) + .bind(user_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.map(|row| WorkspaceEncryptedKeyRow { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + user_id: row.get("user_id"), + encrypted_kek: row.get("encrypted_kek"), + key_version: row.get("key_version"), + created_at: row.get("created_at"), + })) + } + .await; + out.map_err(Into::into) + } + + async fn list_encrypted_keks( + &self, + workspace_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT DISTINCT ON (user_id) id, workspace_id, user_id, encrypted_kek, key_version, created_at + FROM workspace_encrypted_keys + WHERE workspace_id = $1 + ORDER BY user_id, key_version DESC"#, + ) + .bind(workspace_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| WorkspaceEncryptedKeyRow { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + user_id: row.get("user_id"), + encrypted_kek: row.get("encrypted_kek"), + key_version: row.get("key_version"), + created_at: row.get("created_at"), + }) + .collect()) + } + .await; + out.map_err(Into::into) + } + + async fn upsert_encrypted_kek( + &self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: &[u8], + key_version: i32, + ) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query( + r#"INSERT INTO workspace_encrypted_keys (workspace_id, user_id, encrypted_kek, key_version, created_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (workspace_id, user_id, key_version) + DO UPDATE SET + encrypted_kek = EXCLUDED.encrypted_kek + RETURNING id, workspace_id, user_id, encrypted_kek, key_version, created_at"#, + ) + .bind(workspace_id) + .bind(user_id) + .bind(encrypted_kek) + .bind(key_version) + .fetch_one(&self.pool) + .await?; + + Ok(WorkspaceEncryptedKeyRow { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + user_id: row.get("user_id"), + encrypted_kek: row.get("encrypted_kek"), + key_version: row.get("key_version"), + created_at: row.get("created_at"), + }) + } + .await; + out.map_err(Into::into) + } + + async fn delete_encrypted_kek(&self, workspace_id: Uuid, user_id: Uuid) -> PortResult { + let out: anyhow::Result = async { + let result = sqlx::query( + r#"DELETE FROM workspace_encrypted_keys + WHERE workspace_id = $1 AND user_id = $2"#, + ) + .bind(workspace_id) + .bind(user_id) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected() > 0) + } + .await; + out.map_err(Into::into) + } + + async fn delete_encrypted_kek_version( + &self, + workspace_id: Uuid, + key_version: i32, + ) -> PortResult { + let out: anyhow::Result = async { + let result = sqlx::query( + r#"DELETE FROM workspace_encrypted_keys + WHERE workspace_id = $1 AND key_version = $2"#, + ) + .bind(workspace_id) + .bind(key_version) + .execute(&self.pool) + .await?; + + Ok(result.rows_affected()) + } + .await; + out.map_err(Into::into) + } + + async fn get_current_key_version(&self, workspace_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT MAX(key_version) as max_version + FROM workspace_encrypted_keys + WHERE workspace_id = $1"#, + ) + .bind(workspace_id) + .fetch_optional(&self.pool) + .await?; + + Ok(row.and_then(|r| r.try_get::, _>("max_version").ok().flatten())) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/presentation/Cargo.toml b/api/crates/presentation/Cargo.toml index 91d59542..0cdf731c 100644 --- a/api/crates/presentation/Cargo.toml +++ b/api/crates/presentation/Cargo.toml @@ -9,6 +9,7 @@ domain = { path = "../domain" } contracts = { path = "../contracts", features = ["openapi"] } anyhow = "1" +base64 = "0.22" axum = { version = "0.7", features = ["macros", "json", "multipart", "ws"] } http = "1" serde = { version = "1.0", features = ["derive"] } diff --git a/api/crates/presentation/src/context.rs b/api/crates/presentation/src/context.rs index e48799bd..61e56521 100644 --- a/api/crates/presentation/src/context.rs +++ b/api/crates/presentation/src/context.rs @@ -15,6 +15,7 @@ pub use application::documents::ports::realtime::realtime_types::{ }; use application::documents::services::DocumentServiceFacade; use application::documents::services::files::FileServiceFacade; +use application::documents::services::keys::DocumentKeysServiceFacade; use application::documents::services::publishing::PublicServiceFacade; use application::documents::services::sharing::ShareServiceFacade; use application::documents::services::tagging::TagServiceFacade; @@ -24,6 +25,7 @@ use application::identity::services::auth::account::AccountServiceFacade; use application::identity::services::auth::auth_service::AuthServiceFacade; use application::identity::services::auth::external::ExternalAuthRegistryFacade; use application::identity::services::auth::user_sessions::UserSessionServiceFacade; +use application::identity::services::user_keys::UserKeysServiceFacade; use application::identity::services::user_shortcuts::UserShortcutServiceFacade; use application::plugins::ports::plugin_event_publisher::PluginScopedEvent; use application::plugins::ports::plugin_event_subscriber::PluginEventSubscriber; @@ -32,6 +34,7 @@ use application::plugins::services::execution::PluginExecutionServiceFacade; use application::plugins::services::management::PluginManagementServiceFacade; use application::plugins::services::permissions::PluginPermissionServiceFacade; use application::workspaces::services::WorkspaceServiceFacade; +use application::workspaces::services::workspace_keys::WorkspaceKeysServiceFacade; mod traits; pub use traits::{HasAuthServices, HasAuthorizationService, HasShareService, HasWorkspaceService}; @@ -83,6 +86,7 @@ pub struct CoreServicesDeps { #[derive(Clone)] pub struct DocumentServicesDeps { pub document_service: Arc, + pub document_keys_service: Arc, pub share_service: Arc, pub file_service: Arc, pub public_service: Arc, @@ -99,6 +103,7 @@ pub struct GitServicesDeps { pub struct IdentityServicesDeps { pub api_token_service: Arc, pub user_shortcut_service: Arc, + pub user_keys_service: Arc, pub account_service: Arc, pub auth_service: Arc, pub session_service: Arc, @@ -117,6 +122,7 @@ pub struct PluginServicesDeps { #[derive(Clone)] pub struct WorkspaceServicesDeps { pub workspace_service: Arc, + pub workspace_keys_service: Arc, } #[derive(Clone)] @@ -131,6 +137,7 @@ struct CoreServices { #[derive(Clone)] struct DocumentServices { document_service: Arc, + document_keys_service: Arc, share_service: Arc, file_service: Arc, public_service: Arc, @@ -147,6 +154,7 @@ struct GitServices { struct IdentityServices { api_token_service: Arc, user_shortcut_service: Arc, + user_keys_service: Arc, account_service: Arc, auth_service: Arc, session_service: Arc, @@ -165,6 +173,7 @@ struct PluginServices { #[derive(Clone)] struct WorkspaceServices { workspace_service: Arc, + workspace_keys_service: Arc, } mod subcontexts; @@ -185,6 +194,7 @@ impl AppServices { }, documents: DocumentServices { document_service: deps.documents.document_service, + document_keys_service: deps.documents.document_keys_service, share_service: deps.documents.share_service, file_service: deps.documents.file_service, public_service: deps.documents.public_service, @@ -197,6 +207,7 @@ impl AppServices { identity: IdentityServices { api_token_service: deps.identity.api_token_service, user_shortcut_service: deps.identity.user_shortcut_service, + user_keys_service: deps.identity.user_keys_service, account_service: deps.identity.account_service, auth_service: deps.identity.auth_service, session_service: deps.identity.session_service, @@ -211,6 +222,7 @@ impl AppServices { }, workspaces: WorkspaceServices { workspace_service: deps.workspaces.workspace_service, + workspace_keys_service: deps.workspaces.workspace_keys_service, }, } } @@ -237,6 +249,10 @@ impl AppContext { self.services.documents.document_service.clone() } + pub fn document_keys_service(&self) -> Arc { + self.services.documents.document_keys_service.clone() + } + pub fn share_service(&self) -> Arc { self.services.documents.share_service.clone() } @@ -269,6 +285,10 @@ impl AppContext { self.services.workspaces.workspace_service.clone() } + pub fn workspace_keys_service(&self) -> Arc { + self.services.workspaces.workspace_keys_service.clone() + } + pub fn storage_ingest_queue(&self) -> Arc { self.services.core.storage_ingest_queue.clone() } @@ -336,6 +356,10 @@ impl AppContext { self.services.identity.api_token_service.clone() } + pub fn user_keys_service(&self) -> Arc { + self.services.identity.user_keys_service.clone() + } + pub fn realtime_engine(&self) -> Arc { self.services.documents.realtime_engine.clone() } diff --git a/api/crates/presentation/src/context/subcontexts.rs b/api/crates/presentation/src/context/subcontexts.rs index 242b6266..79d836ae 100644 --- a/api/crates/presentation/src/context/subcontexts.rs +++ b/api/crates/presentation/src/context/subcontexts.rs @@ -1,4 +1,5 @@ use super::*; +use application::documents::services::keys::DocumentKeysServiceFacade; #[derive(Clone)] pub struct CoreContext { @@ -90,6 +91,7 @@ pub struct DocumentsContext { pub cfg: PresentationConfig, authorization: Arc, document_service: Arc, + document_keys_service: Arc, file_service: Arc, public_service: Arc, share_service: Arc, @@ -124,6 +126,10 @@ impl DocumentsContext { self.document_service.clone() } + pub fn document_keys_service(&self) -> Arc { + self.document_keys_service.clone() + } + pub fn file_service(&self) -> Arc { self.file_service.clone() } @@ -171,6 +177,7 @@ impl FromRef for DocumentsContext { cfg: ctx.cfg.clone(), authorization: ctx.authorization(), document_service: ctx.document_service(), + document_keys_service: ctx.document_keys_service(), file_service: ctx.file_service(), public_service: ctx.public_service(), share_service: ctx.share_service(), @@ -242,6 +249,7 @@ pub struct IdentityContext { pub cfg: PresentationConfig, api_token_service: Arc, user_shortcut_service: Arc, + user_keys_service: Arc, account_service: Arc, auth_service: Arc, session_service: Arc, @@ -258,6 +266,10 @@ impl IdentityContext { self.user_shortcut_service.clone() } + pub fn user_keys_service(&self) -> Arc { + self.user_keys_service.clone() + } + pub fn account_service(&self) -> Arc { self.account_service.clone() } @@ -301,6 +313,7 @@ impl FromRef for IdentityContext { cfg: ctx.cfg.clone(), api_token_service: ctx.api_token_service(), user_shortcut_service: ctx.user_shortcut_service(), + user_keys_service: ctx.user_keys_service(), account_service: ctx.account_service(), auth_service: ctx.auth_service(), session_service: ctx.session_service(), @@ -422,6 +435,7 @@ impl FromRef for PluginsContext { pub struct WorkspacesContext { pub cfg: PresentationConfig, workspace_service: Arc, + workspace_keys_service: Arc, account_service: Arc, document_service: Arc, auth_service: Arc, @@ -433,6 +447,10 @@ impl WorkspacesContext { self.workspace_service.clone() } + pub fn workspace_keys_service(&self) -> Arc { + self.workspace_keys_service.clone() + } + pub fn account_service(&self) -> Arc { self.account_service.clone() } @@ -471,6 +489,7 @@ impl FromRef for WorkspacesContext { Self { cfg: ctx.cfg.clone(), workspace_service: ctx.workspace_service(), + workspace_keys_service: ctx.workspace_keys_service(), account_service: ctx.account_service(), document_service: ctx.document_service(), auth_service: ctx.auth_service(), diff --git a/api/crates/presentation/src/http/documents/keys/handlers/mod.rs b/api/crates/presentation/src/http/documents/keys/handlers/mod.rs new file mode 100644 index 00000000..5d9f0021 --- /dev/null +++ b/api/crates/presentation/src/http/documents/keys/handlers/mod.rs @@ -0,0 +1,217 @@ +use axum::{extract::State, Json}; +use uuid::Uuid; + +use crate::context::DocumentsContext; +use crate::http::error::ApiError; +use crate::http::extractors::WorkspaceAuth; +use application::core::services::errors::ServiceError; + +use super::types::{ + DocumentKeyResponse, RotateDocumentKeyRequest, RotateDocumentKeyResponse, ShareKeyResponse, + ShareSaltResponse, StoreDocumentKeyRequest, StorePasswordProtectedShareKeyRequest, + StoreShareKeyRequest, +}; + +fn map_keys_error(err: ServiceError) -> ApiError { + crate::http::error::map_service_error(err, "document_keys_service_error") +} + +// ============================================================================ +// Document Key Endpoints +// ============================================================================ + +#[utoipa::path( + get, + path = "/api/documents/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Document ID")), + responses( + (status = 200, body = DocumentKeyResponse), + (status = 404, description = "Document key not found") + ) +)] +pub async fn get_document_key( + State(ctx): State, + _auth: WorkspaceAuth, + axum::extract::Path(document_id): axum::extract::Path, +) -> Result, ApiError> { + let service = ctx.document_keys_service(); + let dto = service + .get_document_key(document_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("document_key_not_found"))?; + + Ok(Json(DocumentKeyResponse::from(dto))) +} + +#[utoipa::path( + post, + path = "/api/documents/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = StoreDocumentKeyRequest, + responses((status = 200, body = DocumentKeyResponse)) +)] +pub async fn store_document_key( + State(ctx): State, + _auth: WorkspaceAuth, + axum::extract::Path(document_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + let (encrypted_dek, nonce) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.document_keys_service(); + let dto = service + .store_document_key(document_id, encrypted_dek, nonce, payload.key_version) + .await + .map_err(map_keys_error)?; + + Ok(Json(DocumentKeyResponse::from(dto))) +} + +// ============================================================================ +// Share Key Endpoints +// ============================================================================ + +#[utoipa::path( + get, + path = "/api/shares/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Share ID")), + responses( + (status = 200, body = ShareKeyResponse), + (status = 404, description = "Share key not found") + ) +)] +pub async fn get_share_key( + State(ctx): State, + axum::extract::Path(share_id): axum::extract::Path, +) -> Result, ApiError> { + let service = ctx.document_keys_service(); + let dto = service + .get_share_key(share_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("share_key_not_found"))?; + + Ok(Json(ShareKeyResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/shares/{id}/salt", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Share ID")), + responses((status = 200, body = ShareSaltResponse)) +)] +pub async fn get_share_salt( + State(ctx): State, + axum::extract::Path(share_id): axum::extract::Path, +) -> Result, ApiError> { + use base64::Engine; + let service = ctx.document_keys_service(); + let salt = service + .get_share_salt(share_id) + .await + .map_err(map_keys_error)?; + + Ok(Json(ShareSaltResponse { + share_id, + salt: salt.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + })) +} + +#[utoipa::path( + post, + path = "/api/shares/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Share ID")), + request_body = StoreShareKeyRequest, + responses((status = 200, body = ShareKeyResponse)) +)] +pub async fn store_share_key( + State(ctx): State, + _auth: WorkspaceAuth, + axum::extract::Path(share_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + let encrypted_dek = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.document_keys_service(); + let dto = service + .store_share_key(share_id, encrypted_dek) + .await + .map_err(map_keys_error)?; + + Ok(Json(ShareKeyResponse::from(dto))) +} + +#[utoipa::path( + post, + path = "/api/shares/{id}/keys/password-protected", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Share ID")), + request_body = StorePasswordProtectedShareKeyRequest, + responses((status = 200, body = ShareKeyResponse)) +)] +pub async fn store_password_protected_share_key( + State(ctx): State, + _auth: WorkspaceAuth, + axum::extract::Path(share_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + let (encrypted_dek, salt, kdf_params) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.document_keys_service(); + let dto = service + .store_password_protected_share_key(share_id, encrypted_dek, salt, kdf_params) + .await + .map_err(map_keys_error)?; + + Ok(Json(ShareKeyResponse::from(dto))) +} + +// ============================================================================ +// Document Key Rotation +// ============================================================================ + +#[utoipa::path( + post, + path = "/api/documents/{id}/keys/rotate", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = RotateDocumentKeyRequest, + responses( + (status = 200, body = RotateDocumentKeyResponse), + (status = 400, description = "Invalid request"), + (status = 403, description = "Permission denied") + ) +)] +pub async fn rotate_document_key( + State(ctx): State, + _auth: WorkspaceAuth, + axum::extract::Path(document_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + let (encrypted_dek, nonce) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.document_keys_service(); + let new_version = service + .rotate_document_key(document_id, encrypted_dek, nonce) + .await + .map_err(map_keys_error)?; + + Ok(Json(RotateDocumentKeyResponse { + document_id, + new_key_version: new_version, + })) +} diff --git a/api/crates/presentation/src/http/documents/keys/mod.rs b/api/crates/presentation/src/http/documents/keys/mod.rs new file mode 100644 index 00000000..33672927 --- /dev/null +++ b/api/crates/presentation/src/http/documents/keys/mod.rs @@ -0,0 +1,38 @@ +mod handlers; +pub mod types; + +use axum::routing::{get, post}; +use axum::Router; + +use crate::context::AppContext; + +pub use handlers::{ + get_document_key, get_share_key, get_share_salt, rotate_document_key, store_document_key, + store_password_protected_share_key, store_share_key, +}; +pub use types::*; + +pub mod openapi { + pub use super::handlers::*; +} + +pub fn routes(ctx: AppContext) -> Router { + Router::new() + // Document key endpoints + .route( + "/documents/:id/keys", + get(get_document_key).post(store_document_key), + ) + .route("/documents/:id/keys/rotate", post(rotate_document_key)) + // Share key endpoints + .route( + "/shares/:id/keys", + get(get_share_key).post(store_share_key), + ) + .route( + "/shares/:id/keys/password-protected", + post(store_password_protected_share_key), + ) + .route("/shares/:id/salt", get(get_share_salt)) + .with_state(ctx) +} diff --git a/api/crates/presentation/src/http/documents/keys/types.rs b/api/crates/presentation/src/http/documents/keys/types.rs new file mode 100644 index 00000000..1ea37b31 --- /dev/null +++ b/api/crates/presentation/src/http/documents/keys/types.rs @@ -0,0 +1,218 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use application::documents::dtos::{DocumentEncryptedKeyDto, ShareEncryptedKeyDto}; +use domain::identity::keys::KdfParams; + +// ============================================================================ +// Document Key Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentKeyResponse { + pub document_id: Uuid, + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, // base64 encoded + #[schema(value_type = String, format = "byte")] + pub nonce: String, // base64 encoded + pub key_version: i32, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl From for DocumentKeyResponse { + fn from(dto: DocumentEncryptedKeyDto) -> Self { + use base64::Engine; + Self { + document_id: dto.document_id, + encrypted_dek: base64::engine::general_purpose::STANDARD.encode(&dto.encrypted_dek), + nonce: base64::engine::general_purpose::STANDARD.encode(&dto.nonce), + key_version: dto.key_version, + created_at: dto.created_at, + updated_at: dto.updated_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StoreDocumentKeyRequest { + /// Base64 encoded encrypted DEK + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, + /// Base64 encoded nonce + #[schema(value_type = String, format = "byte")] + pub nonce: String, + /// Key version + pub key_version: i32, +} + +impl StoreDocumentKeyRequest { + pub fn decode(&self) -> Result<(Vec, Vec), &'static str> { + use base64::Engine; + let encrypted_dek = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_dek) + .map_err(|_| "invalid_encrypted_dek_base64")?; + let nonce = base64::engine::general_purpose::STANDARD + .decode(&self.nonce) + .map_err(|_| "invalid_nonce_base64")?; + Ok((encrypted_dek, nonce)) + } +} + +// ============================================================================ +// Share Key Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShareKeyResponse { + pub share_id: Uuid, + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, // base64 encoded + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub salt: Option, // base64 encoded, for password-protected shares + #[serde(skip_serializing_if = "Option::is_none")] + pub kdf_params: Option, + pub is_password_protected: bool, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct KdfParamsResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iterations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallelism: Option, +} + +impl From<&KdfParams> for KdfParamsResponse { + fn from(params: &KdfParams) -> Self { + Self { + memory: params.memory, + iterations: params.iterations, + parallelism: params.parallelism, + } + } +} + +impl From for KdfParams { + fn from(resp: KdfParamsResponse) -> Self { + Self { + memory: resp.memory, + iterations: resp.iterations, + parallelism: resp.parallelism, + } + } +} + +impl From for ShareKeyResponse { + fn from(dto: ShareEncryptedKeyDto) -> Self { + use base64::Engine; + let is_password_protected = dto.is_password_protected(); + Self { + share_id: dto.share_id, + encrypted_dek: base64::engine::general_purpose::STANDARD.encode(&dto.encrypted_dek), + salt: dto.salt.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + kdf_params: dto.kdf_params.as_ref().map(KdfParamsResponse::from), + is_password_protected, + created_at: dto.created_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StoreShareKeyRequest { + /// Base64 encoded encrypted DEK + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, +} + +impl StoreShareKeyRequest { + pub fn decode(&self) -> Result, &'static str> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_dek) + .map_err(|_| "invalid_encrypted_dek_base64") + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StorePasswordProtectedShareKeyRequest { + /// Base64 encoded encrypted DEK + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, + /// Base64 encoded salt + #[schema(value_type = String, format = "byte")] + pub salt: String, + /// KDF parameters + pub kdf_params: KdfParamsResponse, +} + +impl StorePasswordProtectedShareKeyRequest { + pub fn decode(&self) -> Result<(Vec, Vec, KdfParams), &'static str> { + use base64::Engine; + let encrypted_dek = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_dek) + .map_err(|_| "invalid_encrypted_dek_base64")?; + let salt = base64::engine::general_purpose::STANDARD + .decode(&self.salt) + .map_err(|_| "invalid_salt_base64")?; + let kdf_params = KdfParams::from(self.kdf_params.clone()); + Ok((encrypted_dek, salt, kdf_params)) + } +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShareSaltResponse { + pub share_id: Uuid, + #[schema(value_type = Option, format = "byte")] + pub salt: Option, // base64 encoded +} + +// ============================================================================ +// Document Key Rotation Types +// ============================================================================ + +/// Request body for document DEK rotation +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RotateDocumentKeyRequest { + /// Base64 encoded new encrypted DEK + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, + /// Base64 encoded nonce + #[schema(value_type = String, format = "byte")] + pub nonce: String, +} + +impl RotateDocumentKeyRequest { + pub fn decode(&self) -> Result<(Vec, Vec), &'static str> { + use base64::Engine; + let encrypted_dek = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_dek) + .map_err(|_| "invalid_encrypted_dek_base64")?; + let nonce = base64::engine::general_purpose::STANDARD + .decode(&self.nonce) + .map_err(|_| "invalid_nonce_base64")?; + Ok((encrypted_dek, nonce)) + } +} + +/// Response for document DEK rotation +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RotateDocumentKeyResponse { + pub document_id: Uuid, + pub new_key_version: i32, +} diff --git a/api/crates/presentation/src/http/documents/mod.rs b/api/crates/presentation/src/http/documents/mod.rs index e465b03c..3ba42f91 100644 --- a/api/crates/presentation/src/http/documents/mod.rs +++ b/api/crates/presentation/src/http/documents/mod.rs @@ -1,5 +1,6 @@ pub mod files; mod handlers; +pub mod keys; pub mod publishing; pub mod sharing; pub mod tagging; diff --git a/api/crates/presentation/src/http/identity/keys/handlers/mod.rs b/api/crates/presentation/src/http/identity/keys/handlers/mod.rs new file mode 100644 index 00000000..639b216a --- /dev/null +++ b/api/crates/presentation/src/http/identity/keys/handlers/mod.rs @@ -0,0 +1,245 @@ +use axum::{extract::State, http::StatusCode, Json}; +use uuid::Uuid; + +use crate::context::IdentityContext; +use crate::http::error::ApiError; +use crate::http::extractors::AuthedUser; +use application::core::services::errors::ServiceError; + +use super::types::{ + EncryptedPrivateKeyResponse, MasterKeyBackupResponse, RegisterPublicKeyRequest, + StoreEncryptedPrivateKeyRequest, StoreMasterKeyBackupRequest, UserPublicKeyResponse, +}; + +fn map_keys_error(err: ServiceError) -> ApiError { + crate::http::error::map_service_error(err, "user_keys_service_error") +} + +// ============================================================================ +// Public Key Endpoints +// ============================================================================ + +#[utoipa::path( + post, + path = "/api/me/keys", + tag = "E2EE", + request_body = RegisterPublicKeyRequest, + responses((status = 200, body = UserPublicKeyResponse)) +)] +pub async fn register_public_key( + State(ctx): State, + auth: AuthedUser, + Json(payload): Json, +) -> Result, ApiError> { + let (public_key, key_type) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.user_keys_service(); + let dto = service + .register_public_key(auth.user_id, public_key, key_type) + .await + .map_err(map_keys_error)?; + + Ok(Json(UserPublicKeyResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/me/keys", + tag = "E2EE", + responses( + (status = 200, body = UserPublicKeyResponse), + (status = 404, description = "Public key not found") + ) +)] +pub async fn get_my_public_key( + State(ctx): State, + auth: AuthedUser, +) -> Result, ApiError> { + let service = ctx.user_keys_service(); + let dto = service + .get_public_key(auth.user_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("public_key_not_found"))?; + + Ok(Json(UserPublicKeyResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/users/{user_id}/keys", + tag = "E2EE", + params(("user_id" = Uuid, Path, description = "User ID")), + responses( + (status = 200, body = UserPublicKeyResponse), + (status = 404, description = "Public key not found") + ) +)] +pub async fn get_user_public_key( + State(ctx): State, + _auth: AuthedUser, + axum::extract::Path(user_id): axum::extract::Path, +) -> Result, ApiError> { + let service = ctx.user_keys_service(); + let dto = service + .get_public_key(user_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("public_key_not_found"))?; + + Ok(Json(UserPublicKeyResponse::from(dto))) +} + +// ============================================================================ +// Master Key Backup Endpoints +// ============================================================================ + +#[utoipa::path( + post, + path = "/api/me/master-key/backup", + tag = "E2EE", + request_body = StoreMasterKeyBackupRequest, + responses((status = 200, body = MasterKeyBackupResponse)) +)] +pub async fn store_master_key_backup( + State(ctx): State, + auth: AuthedUser, + Json(payload): Json, +) -> Result, ApiError> { + let (encrypted_key, salt, kdf_type, kdf_params) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.user_keys_service(); + let dto = service + .store_master_key_backup(auth.user_id, encrypted_key, salt, kdf_type, kdf_params) + .await + .map_err(map_keys_error)?; + + Ok(Json(MasterKeyBackupResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/me/master-key/backup", + tag = "E2EE", + responses( + (status = 200, body = MasterKeyBackupResponse), + (status = 404, description = "Master key backup not found") + ) +)] +pub async fn get_master_key_backup( + State(ctx): State, + auth: AuthedUser, +) -> Result, ApiError> { + let service = ctx.user_keys_service(); + let dto = service + .get_master_key_backup(auth.user_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("master_key_backup_not_found"))?; + + Ok(Json(MasterKeyBackupResponse::from(dto))) +} + +// ============================================================================ +// Encrypted Private Key Endpoints +// ============================================================================ + +#[utoipa::path( + post, + path = "/api/me/private-key/encrypted", + tag = "E2EE", + request_body = StoreEncryptedPrivateKeyRequest, + responses((status = 200, body = EncryptedPrivateKeyResponse)) +)] +pub async fn store_encrypted_private_key( + State(ctx): State, + auth: AuthedUser, + Json(payload): Json, +) -> Result, ApiError> { + let (encrypted_private_key, nonce) = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.user_keys_service(); + let dto = service + .store_encrypted_private_key(auth.user_id, encrypted_private_key, nonce) + .await + .map_err(map_keys_error)?; + + Ok(Json(EncryptedPrivateKeyResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/me/private-key/encrypted", + tag = "E2EE", + responses( + (status = 200, body = EncryptedPrivateKeyResponse), + (status = 404, description = "Encrypted private key not found") + ) +)] +pub async fn get_encrypted_private_key( + State(ctx): State, + auth: AuthedUser, +) -> Result, ApiError> { + let service = ctx.user_keys_service(); + let dto = service + .get_encrypted_private_key(auth.user_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("encrypted_private_key_not_found"))?; + + Ok(Json(EncryptedPrivateKeyResponse::from(dto))) +} + +// ============================================================================ +// E2EE Setup Status +// ============================================================================ + +#[utoipa::path( + post, + path = "/api/me/e2ee/setup-complete", + tag = "E2EE", + responses((status = 204)) +)] +pub async fn mark_e2ee_setup_complete( + State(ctx): State, + auth: AuthedUser, +) -> Result { + let service = ctx.user_keys_service(); + service + .mark_e2ee_setup_completed(auth.user_id) + .await + .map_err(map_keys_error)?; + + Ok(StatusCode::NO_CONTENT) +} + +#[utoipa::path( + get, + path = "/api/me/e2ee/status", + tag = "E2EE", + responses((status = 200, body = E2eeStatusResponse)) +)] +pub async fn get_e2ee_status( + State(ctx): State, + auth: AuthedUser, +) -> Result, ApiError> { + let service = ctx.user_keys_service(); + let is_setup = service + .is_e2ee_setup_completed(auth.user_id) + .await + .map_err(map_keys_error)?; + + Ok(Json(E2eeStatusResponse { is_setup_completed: is_setup })) +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct E2eeStatusResponse { + pub is_setup_completed: bool, +} diff --git a/api/crates/presentation/src/http/identity/keys/mod.rs b/api/crates/presentation/src/http/identity/keys/mod.rs new file mode 100644 index 00000000..e1324913 --- /dev/null +++ b/api/crates/presentation/src/http/identity/keys/mod.rs @@ -0,0 +1,39 @@ +mod handlers; +pub mod types; + +use axum::routing::{get, post}; +use axum::Router; + +use crate::context::AppContext; + +pub use handlers::{ + get_e2ee_status, get_encrypted_private_key, get_master_key_backup, get_my_public_key, + get_user_public_key, mark_e2ee_setup_complete, register_public_key, + store_encrypted_private_key, store_master_key_backup, E2eeStatusResponse, +}; +pub use types::*; + +pub mod openapi { + pub use super::handlers::*; +} + +pub fn routes(ctx: AppContext) -> Router { + Router::new() + // Public key endpoints + .route("/me/keys", get(get_my_public_key).post(register_public_key)) + .route("/users/:user_id/keys", get(get_user_public_key)) + // Master key backup endpoints + .route( + "/me/master-key/backup", + get(get_master_key_backup).post(store_master_key_backup), + ) + // Encrypted private key endpoints + .route( + "/me/private-key/encrypted", + get(get_encrypted_private_key).post(store_encrypted_private_key), + ) + // E2EE setup status + .route("/me/e2ee/setup-complete", post(mark_e2ee_setup_complete)) + .route("/me/e2ee/status", get(get_e2ee_status)) + .with_state(ctx) +} diff --git a/api/crates/presentation/src/http/identity/keys/types.rs b/api/crates/presentation/src/http/identity/keys/types.rs new file mode 100644 index 00000000..002b20c5 --- /dev/null +++ b/api/crates/presentation/src/http/identity/keys/types.rs @@ -0,0 +1,200 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use application::identity::dtos::{ + UserEncryptedMasterKeyDto, UserEncryptedPrivateKeyDto, UserPublicKeyDto, +}; +use domain::identity::keys::{KdfParams, KdfType, KeyType}; + +// ============================================================================ +// Public Key Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserPublicKeyResponse { + #[schema(value_type = String, format = "byte")] + pub public_key: String, // base64 encoded + pub key_type: String, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +impl From for UserPublicKeyResponse { + fn from(dto: UserPublicKeyDto) -> Self { + use base64::Engine; + Self { + public_key: base64::engine::general_purpose::STANDARD.encode(&dto.public_key), + key_type: dto.key_type.as_str().to_string(), + created_at: dto.created_at, + updated_at: dto.updated_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RegisterPublicKeyRequest { + /// Base64 encoded public key + #[schema(value_type = String, format = "byte")] + pub public_key: String, + /// Key type (e.g., "ecdh-p256") + #[schema(example = "ecdh-p256")] + pub key_type: String, +} + +impl RegisterPublicKeyRequest { + pub fn decode(&self) -> Result<(Vec, KeyType), &'static str> { + use base64::Engine; + let public_key = base64::engine::general_purpose::STANDARD + .decode(&self.public_key) + .map_err(|_| "invalid_base64")?; + let key_type = KeyType::parse(&self.key_type).ok_or("invalid_key_type")?; + Ok((public_key, key_type)) + } +} + +// ============================================================================ +// Master Key Backup Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MasterKeyBackupResponse { + #[schema(value_type = String, format = "byte")] + pub encrypted_key: String, // base64 encoded + #[schema(value_type = String, format = "byte")] + pub salt: String, // base64 encoded + pub kdf_type: String, + pub kdf_params: KdfParamsResponse, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct KdfParamsResponse { + #[serde(skip_serializing_if = "Option::is_none")] + pub memory: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub iterations: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub parallelism: Option, +} + +impl From<&KdfParams> for KdfParamsResponse { + fn from(params: &KdfParams) -> Self { + Self { + memory: params.memory, + iterations: params.iterations, + parallelism: params.parallelism, + } + } +} + +impl From for KdfParams { + fn from(resp: KdfParamsResponse) -> Self { + Self { + memory: resp.memory, + iterations: resp.iterations, + parallelism: resp.parallelism, + } + } +} + +impl From for MasterKeyBackupResponse { + fn from(dto: UserEncryptedMasterKeyDto) -> Self { + use base64::Engine; + Self { + encrypted_key: base64::engine::general_purpose::STANDARD.encode(&dto.encrypted_key), + salt: base64::engine::general_purpose::STANDARD.encode(&dto.salt), + kdf_type: dto.kdf_type.as_str().to_string(), + kdf_params: KdfParamsResponse::from(&dto.kdf_params), + created_at: dto.created_at, + updated_at: dto.updated_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StoreMasterKeyBackupRequest { + /// Base64 encoded encrypted master key + #[schema(value_type = String, format = "byte")] + pub encrypted_key: String, + /// Base64 encoded salt + #[schema(value_type = String, format = "byte")] + pub salt: String, + /// KDF type (e.g., "argon2id", "pbkdf2") + #[schema(example = "argon2id")] + pub kdf_type: String, + /// KDF parameters + pub kdf_params: KdfParamsResponse, +} + +impl StoreMasterKeyBackupRequest { + pub fn decode(&self) -> Result<(Vec, Vec, KdfType, KdfParams), &'static str> { + use base64::Engine; + let encrypted_key = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_key) + .map_err(|_| "invalid_encrypted_key_base64")?; + let salt = base64::engine::general_purpose::STANDARD + .decode(&self.salt) + .map_err(|_| "invalid_salt_base64")?; + let kdf_type = KdfType::parse(&self.kdf_type).ok_or("invalid_kdf_type")?; + let kdf_params = KdfParams::from(self.kdf_params.clone()); + Ok((encrypted_key, salt, kdf_type, kdf_params)) + } +} + +// ============================================================================ +// Encrypted Private Key Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedPrivateKeyResponse { + #[schema(value_type = String, format = "byte")] + pub encrypted_private_key: String, // base64 encoded + #[schema(value_type = String, format = "byte")] + pub nonce: String, // base64 encoded + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +impl From for EncryptedPrivateKeyResponse { + fn from(dto: UserEncryptedPrivateKeyDto) -> Self { + use base64::Engine; + Self { + encrypted_private_key: base64::engine::general_purpose::STANDARD + .encode(&dto.encrypted_private_key), + nonce: base64::engine::general_purpose::STANDARD.encode(&dto.nonce), + created_at: dto.created_at, + updated_at: dto.updated_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StoreEncryptedPrivateKeyRequest { + /// Base64 encoded encrypted private key + #[schema(value_type = String, format = "byte")] + pub encrypted_private_key: String, + /// Base64 encoded nonce + #[schema(value_type = String, format = "byte")] + pub nonce: String, +} + +impl StoreEncryptedPrivateKeyRequest { + pub fn decode(&self) -> Result<(Vec, Vec), &'static str> { + use base64::Engine; + let encrypted_private_key = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_private_key) + .map_err(|_| "invalid_encrypted_private_key_base64")?; + let nonce = base64::engine::general_purpose::STANDARD + .decode(&self.nonce) + .map_err(|_| "invalid_nonce_base64")?; + Ok((encrypted_private_key, nonce)) + } +} diff --git a/api/crates/presentation/src/http/identity/mod.rs b/api/crates/presentation/src/http/identity/mod.rs index f221d7e6..cde29156 100644 --- a/api/crates/presentation/src/http/identity/mod.rs +++ b/api/crates/presentation/src/http/identity/mod.rs @@ -1,3 +1,4 @@ pub mod api_tokens; pub mod auth; +pub mod keys; pub mod shortcuts; diff --git a/api/crates/presentation/src/http/workspaces/keys.rs b/api/crates/presentation/src/http/workspaces/keys.rs new file mode 100644 index 00000000..67155e5f --- /dev/null +++ b/api/crates/presentation/src/http/workspaces/keys.rs @@ -0,0 +1,213 @@ +use axum::{extract::State, Json}; +use uuid::Uuid; + +use crate::context::WorkspacesContext; +use crate::http::error::ApiError; +use crate::http::extractors::WorkspaceAuth; +use application::core::services::errors::ServiceError; + +use super::types::{ + RotateWorkspaceKeyRequest, RotateWorkspaceKeyResponse, StoreWorkspaceKeyRequest, + WorkspaceKeyResponse, WorkspaceKeyVersionResponse, +}; + +fn map_keys_error(err: ServiceError) -> ApiError { + crate::http::error::map_service_error(err, "workspace_keys_service_error") +} + +#[utoipa::path( + get, + path = "/api/workspaces/{id}/keys/me", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Workspace ID")), + responses( + (status = 200, body = WorkspaceKeyResponse), + (status = 404, description = "Key not found") + ) +)] +pub async fn get_my_workspace_key( + State(ctx): State, + auth: WorkspaceAuth, +) -> Result, ApiError> { + let service = ctx.workspace_keys_service(); + let dto = service + .get_encrypted_kek(auth.workspace_id, auth.user_id) + .await + .map_err(map_keys_error)? + .ok_or_else(|| ApiError::not_found("workspace_key_not_found"))?; + + Ok(Json(WorkspaceKeyResponse::from(dto))) +} + +#[utoipa::path( + post, + path = "/api/workspaces/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Workspace ID")), + request_body = StoreWorkspaceKeyRequest, + responses((status = 200, body = WorkspaceKeyResponse)) +)] +pub async fn store_workspace_key( + State(ctx): State, + auth: WorkspaceAuth, + axum::extract::Path(workspace_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + // Verify the workspace_id matches the auth context + if workspace_id != auth.workspace_id { + return Err(ApiError::forbidden("workspace_mismatch")); + } + + let encrypted_kek = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.workspace_keys_service(); + let dto = service + .store_encrypted_kek( + auth.workspace_id, + auth.user_id, + encrypted_kek, + payload.key_version, + ) + .await + .map_err(map_keys_error)?; + + Ok(Json(WorkspaceKeyResponse::from(dto))) +} + +#[utoipa::path( + get, + path = "/api/workspaces/{id}/keys", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Workspace ID")), + responses((status = 200, body = Vec)) +)] +pub async fn list_workspace_keys( + State(ctx): State, + auth: WorkspaceAuth, +) -> Result>, ApiError> { + // Check permission for listing all keys (admin operation) + auth.ensure_permission("workspace:manage")?; + + let service = ctx.workspace_keys_service(); + let dtos = service + .list_encrypted_keks(auth.workspace_id) + .await + .map_err(map_keys_error)?; + + Ok(Json(dtos.into_iter().map(WorkspaceKeyResponse::from).collect())) +} + +#[utoipa::path( + get, + path = "/api/workspaces/{id}/keys/version", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Workspace ID")), + responses((status = 200, body = WorkspaceKeyVersionResponse)) +)] +pub async fn get_workspace_key_version( + State(ctx): State, + auth: WorkspaceAuth, +) -> Result, ApiError> { + let service = ctx.workspace_keys_service(); + let version = service + .get_current_key_version(auth.workspace_id) + .await + .map_err(map_keys_error)?; + + Ok(Json(WorkspaceKeyVersionResponse { + workspace_id: auth.workspace_id, + key_version: version, + })) +} + +#[derive(serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DeleteKeyVersionResponse { + pub workspace_id: Uuid, + pub key_version: i32, + pub deleted_count: u64, +} + +#[utoipa::path( + delete, + path = "/api/workspaces/{id}/keys/{version}", + tag = "E2EE", + params( + ("id" = Uuid, Path, description = "Workspace ID"), + ("version" = i32, Path, description = "Key version to delete") + ), + responses( + (status = 200, body = DeleteKeyVersionResponse), + (status = 403, description = "Permission denied") + ) +)] +pub async fn delete_key_version( + State(ctx): State, + auth: WorkspaceAuth, + axum::extract::Path((workspace_id, key_version)): axum::extract::Path<(Uuid, i32)>, +) -> Result, ApiError> { + // Verify the workspace_id matches the auth context + if workspace_id != auth.workspace_id { + return Err(ApiError::forbidden("workspace_mismatch")); + } + + // Check permission for deleting keys (admin operation) + auth.ensure_permission("workspace:manage")?; + + let service = ctx.workspace_keys_service(); + let deleted_count = service + .delete_key_version(auth.workspace_id, key_version) + .await + .map_err(map_keys_error)?; + + Ok(Json(DeleteKeyVersionResponse { + workspace_id: auth.workspace_id, + key_version, + deleted_count, + })) +} + +#[utoipa::path( + post, + path = "/api/workspaces/{id}/keys/rotate", + tag = "E2EE", + params(("id" = Uuid, Path, description = "Workspace ID")), + request_body = RotateWorkspaceKeyRequest, + responses( + (status = 200, body = RotateWorkspaceKeyResponse), + (status = 400, description = "Invalid request"), + (status = 403, description = "Permission denied") + ) +)] +pub async fn rotate_workspace_key( + State(ctx): State, + auth: WorkspaceAuth, + axum::extract::Path(workspace_id): axum::extract::Path, + Json(payload): Json, +) -> Result, ApiError> { + // Verify the workspace_id matches the auth context + if workspace_id != auth.workspace_id { + return Err(ApiError::forbidden("workspace_mismatch")); + } + + // Check permission for key rotation (admin operation) + auth.ensure_permission("workspace:manage")?; + + let member_keys = payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.workspace_keys_service(); + let (new_version, keys_updated) = service + .rotate_keys(auth.workspace_id, member_keys) + .await + .map_err(map_keys_error)?; + + Ok(Json(RotateWorkspaceKeyResponse { + workspace_id: auth.workspace_id, + new_key_version: new_version, + keys_updated, + })) +} diff --git a/api/crates/presentation/src/http/workspaces/mod.rs b/api/crates/presentation/src/http/workspaces/mod.rs index 994209fe..797bc91c 100644 --- a/api/crates/presentation/src/http/workspaces/mod.rs +++ b/api/crates/presentation/src/http/workspaces/mod.rs @@ -1,4 +1,5 @@ mod invitations; +mod keys; mod members; mod permissions; mod roles; @@ -12,6 +13,10 @@ use axum::routing::{delete, get, patch, post}; use crate::context::AppContext; pub use invitations::{accept_invitation, create_invitation, list_invitations, revoke_invitation}; +pub use keys::{ + delete_key_version, get_my_workspace_key, get_workspace_key_version, list_workspace_keys, + rotate_workspace_key, store_workspace_key, DeleteKeyVersionResponse, +}; pub use members::{list_members, remove_member, update_member_role}; pub use permissions::get_workspace_permissions; pub use roles::{create_role, delete_role, list_roles, update_role}; @@ -23,6 +28,7 @@ pub use workspace::{ pub mod openapi { pub use super::invitations::*; + pub use super::keys::*; pub use super::members::*; pub use super::permissions::*; pub use super::roles::*; @@ -67,5 +73,17 @@ pub fn routes(ctx: AppContext) -> Router { "/workspace-invitations/:token/accept", post(accept_invitation), ) + // E2EE workspace keys + .route( + "/workspaces/:id/keys", + get(list_workspace_keys).post(store_workspace_key), + ) + .route("/workspaces/:id/keys/me", get(get_my_workspace_key)) + .route("/workspaces/:id/keys/version", get(get_workspace_key_version)) + .route("/workspaces/:id/keys/rotate", post(rotate_workspace_key)) + .route( + "/workspaces/:id/keys/:version", + delete(delete_key_version), + ) .with_state(ctx) } diff --git a/api/crates/presentation/src/http/workspaces/types.rs b/api/crates/presentation/src/http/workspaces/types.rs index 12d4f6d0..45745176 100644 --- a/api/crates/presentation/src/http/workspaces/types.rs +++ b/api/crates/presentation/src/http/workspaces/types.rs @@ -310,3 +310,106 @@ pub fn normalize_overrides( } Ok(out) } + +// ============================================================================ +// E2EE Workspace Key Types +// ============================================================================ + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceKeyResponse { + pub id: Uuid, + pub workspace_id: Uuid, + pub user_id: Uuid, + #[schema(value_type = String, format = "byte")] + pub encrypted_kek: String, // base64 encoded + pub key_version: i32, + pub created_at: DateTime, +} + +impl From for WorkspaceKeyResponse { + fn from(dto: application::workspaces::dtos::WorkspaceEncryptedKeyDto) -> Self { + use base64::Engine; + Self { + id: dto.id, + workspace_id: dto.workspace_id, + user_id: dto.user_id, + encrypted_kek: base64::engine::general_purpose::STANDARD.encode(&dto.encrypted_kek), + key_version: dto.key_version, + created_at: dto.created_at, + } + } +} + +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct StoreWorkspaceKeyRequest { + /// Base64 encoded encrypted KEK + #[schema(value_type = String, format = "byte")] + pub encrypted_kek: String, + /// Key version (for key rotation tracking) + pub key_version: i32, +} + +impl StoreWorkspaceKeyRequest { + pub fn decode(&self) -> Result, &'static str> { + use base64::Engine; + base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_kek) + .map_err(|_| "invalid_base64") + } +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct WorkspaceKeyVersionResponse { + pub workspace_id: Uuid, + pub key_version: Option, +} + +// ============================================================================ +// E2EE Key Rotation Types +// ============================================================================ + +/// A single member's encrypted KEK for key rotation +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RotationMemberKey { + /// User ID of the member + pub user_id: Uuid, + /// Base64 encoded encrypted KEK for this member + #[schema(value_type = String, format = "byte")] + pub encrypted_kek: String, +} + +/// Request body for KEK rotation +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RotateWorkspaceKeyRequest { + /// Encrypted KEKs for all workspace members + pub member_keys: Vec, +} + +impl RotateWorkspaceKeyRequest { + pub fn decode(&self) -> Result)>, &'static str> { + use base64::Engine; + self.member_keys + .iter() + .map(|mk| { + base64::engine::general_purpose::STANDARD + .decode(&mk.encrypted_kek) + .map(|bytes| (mk.user_id, bytes)) + .map_err(|_| "invalid_base64") + }) + .collect() + } +} + +/// Response for KEK rotation +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct RotateWorkspaceKeyResponse { + pub workspace_id: Uuid, + pub new_key_version: i32, + pub keys_updated: usize, +} diff --git a/api/crates/presentation/src/openapi.rs b/api/crates/presentation/src/openapi.rs index a1cb4c80..40953595 100644 --- a/api/crates/presentation/src/openapi.rs +++ b/api/crates/presentation/src/openapi.rs @@ -2,8 +2,9 @@ use utoipa::OpenApi; use crate::http::core::{health, markdown, storage_ingest}; use crate::http::documents::files; +use crate::http::documents::keys as document_keys; use crate::http::documents::{publishing as public, sharing as shares, tagging as tags}; -use crate::http::identity::{api_tokens, auth, shortcuts}; +use crate::http::identity::{api_tokens, auth, keys, shortcuts}; use crate::http::{documents, git, plugins, workspaces}; use crate::ws; @@ -25,6 +26,22 @@ use crate::ws; api_tokens::openapi::revoke_api_token, shortcuts::openapi::get_user_shortcuts, shortcuts::openapi::update_user_shortcuts, + keys::openapi::register_public_key, + keys::openapi::get_my_public_key, + keys::openapi::get_user_public_key, + keys::openapi::store_master_key_backup, + keys::openapi::get_master_key_backup, + keys::openapi::store_encrypted_private_key, + keys::openapi::get_encrypted_private_key, + keys::openapi::mark_e2ee_setup_complete, + keys::openapi::get_e2ee_status, + document_keys::openapi::get_document_key, + document_keys::openapi::store_document_key, + document_keys::openapi::rotate_document_key, + document_keys::openapi::get_share_key, + document_keys::openapi::get_share_salt, + document_keys::openapi::store_share_key, + document_keys::openapi::store_password_protected_share_key, auth::openapi::delete_account, ws::documents::yjs::openapi::axum_ws_entry, tags::openapi::list_tags, @@ -124,6 +141,12 @@ use crate::ws; workspaces::openapi::revoke_invitation, workspaces::openapi::accept_invitation, workspaces::openapi::download_workspace_archive, + workspaces::openapi::get_my_workspace_key, + workspaces::openapi::store_workspace_key, + workspaces::openapi::list_workspace_keys, + workspaces::openapi::get_workspace_key_version, + workspaces::openapi::delete_key_version, + workspaces::openapi::rotate_workspace_key, health::openapi::health, ), components(schemas( @@ -143,6 +166,23 @@ use crate::ws; api_tokens::ApiTokenCreateResponse, shortcuts::UserShortcutResponse, shortcuts::UpdateUserShortcutRequest, + keys::UserPublicKeyResponse, + keys::RegisterPublicKeyRequest, + keys::MasterKeyBackupResponse, + keys::StoreMasterKeyBackupRequest, + keys::KdfParamsResponse, + keys::EncryptedPrivateKeyResponse, + keys::StoreEncryptedPrivateKeyRequest, + keys::E2eeStatusResponse, + document_keys::DocumentKeyResponse, + document_keys::StoreDocumentKeyRequest, + document_keys::RotateDocumentKeyRequest, + document_keys::RotateDocumentKeyResponse, + document_keys::ShareKeyResponse, + document_keys::ShareSaltResponse, + document_keys::StoreShareKeyRequest, + document_keys::StorePasswordProtectedShareKeyRequest, + document_keys::KdfParamsResponse, tags::TagItem, documents::Document, documents::DocumentListResponse, @@ -236,6 +276,13 @@ use crate::ws; workspaces::WorkspaceInvitationResponse, workspaces::CreateWorkspaceInvitationRequest, workspaces::DownloadWorkspaceQuery, + workspaces::WorkspaceKeyResponse, + workspaces::StoreWorkspaceKeyRequest, + workspaces::WorkspaceKeyVersionResponse, + workspaces::DeleteKeyVersionResponse, + workspaces::RotationMemberKey, + workspaces::RotateWorkspaceKeyRequest, + workspaces::RotateWorkspaceKeyResponse, storage_ingest::IngestBatchRequest, storage_ingest::IngestEventRequest, storage_ingest::IngestKindParam, @@ -243,6 +290,7 @@ use crate::ws; )), tags( (name = "Auth", description = "Authentication"), + (name = "E2EE", description = "End-to-end encryption key management"), (name = "Documents", description = "Documents management"), (name = "Files", description = "File management"), (name = "Sharing", description = "Document sharing"), diff --git a/api/openapi.json b/api/openapi.json index a296188a..2d84fd10 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1 +1,3 @@ -{"openapi":"3.0.3","info":{"title":"presentation","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugin-assets":{"get":{"tags":["Plugins"],"operationId":"pluginsGetAsset","parameters":[{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Plugin asset"}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/storage/ingest":{"post":{"tags":["Storage"],"operationId":"enqueue_ingest_events","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestBatchRequest"}}},"required":true},"responses":{"202":{"description":"Events enqueued"},"400":{"description":"Invalid request"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/leave":{"post":{"tags":["Workspaces"],"operationId":"leave_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid","description":"Legacy alias for `workspace_id` kept for backward compatibility with older clients."},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"IngestBatchRequest":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/IngestEventRequest"}}}},"IngestEventRequest":{"type":"object","required":["repo_path","kind"],"properties":{"backend":{"type":"string","nullable":true},"content_hash":{"type":"string","nullable":true},"kind":{"$ref":"#/components/schemas/IngestKindParam"},"payload":{"nullable":true},"repo_path":{"type":"string"}}},"IngestKindParam":{"type":"string","enum":["upsert","delete"]},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"file":{"type":"string","format":"binary"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Storage","description":"Storage ingest APIs"},{"name":"Health","description":"System health checks"}]} + Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s + Running `target/debug/refmd openapi export` +{"openapi":"3.0.3","info":{"title":"presentation","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}},"404":{"description":"Document key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}}}}},"/api/documents/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/e2ee/setup-complete":{"post":{"tags":["E2EE"],"operationId":"mark_e2ee_setup_complete","responses":{"204":{"description":""}}}},"/api/me/e2ee/status":{"get":{"tags":["E2EE"],"operationId":"get_e2ee_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/E2eeStatusResponse"}}}}}}},"/api/me/keys":{"get":{"tags":["E2EE"],"operationId":"get_my_public_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}},"post":{"tags":["E2EE"],"operationId":"register_public_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPublicKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}}}}},"/api/me/master-key/backup":{"get":{"tags":["E2EE"],"operationId":"get_master_key_backup","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}},"404":{"description":"Master key backup not found"}}},"post":{"tags":["E2EE"],"operationId":"store_master_key_backup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreMasterKeyBackupRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/private-key/encrypted":{"get":{"tags":["E2EE"],"operationId":"get_encrypted_private_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}},"404":{"description":"Encrypted private key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_encrypted_private_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreEncryptedPrivateKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugin-assets":{"get":{"tags":["Plugins"],"operationId":"pluginsGetAsset","parameters":[{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Plugin asset"}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}},"404":{"description":"Share key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/keys/password-protected":{"post":{"tags":["E2EE"],"operationId":"store_password_protected_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StorePasswordProtectedShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/salt":{"get":{"tags":["E2EE"],"operationId":"get_share_salt","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/storage/ingest":{"post":{"tags":["Storage"],"operationId":"enqueue_ingest_events","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestBatchRequest"}}},"required":true},"responses":{"202":{"description":"Events enqueued"},"400":{"description":"Invalid request"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/users/{user_id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_user_public_key","parameters":[{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"list_workspace_keys","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"post":{"tags":["E2EE"],"operationId":"store_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"/api/workspaces/{id}/keys/me":{"get":{"tags":["E2EE"],"operationId":"get_my_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}},"404":{"description":"Key not found"}}}},"/api/workspaces/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/keys/version":{"get":{"tags":["E2EE"],"operationId":"get_workspace_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyVersionResponse"}}}}}}},"/api/workspaces/{id}/keys/{version}":{"delete":{"tags":["E2EE"],"operationId":"delete_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","description":"Key version to delete","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteKeyVersionResponse"}}}},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/leave":{"post":{"tags":["Workspaces"],"operationId":"leave_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"DeleteKeyVersionResponse":{"type":"object","required":["workspaceId","keyVersion","deletedCount"],"properties":{"deletedCount":{"type":"integer","format":"int64","minimum":0},"keyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid","description":"Legacy alias for `workspace_id` kept for backward compatibility with older clients."},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentKeyResponse":{"type":"object","required":["documentId","encryptedDek","nonce","keyVersion","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte"},"keyVersion":{"type":"integer","format":"int32"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"E2eeStatusResponse":{"type":"object","required":["isSetupCompleted"],"properties":{"isSetupCompleted":{"type":"boolean"}}},"EncryptedPrivateKeyResponse":{"type":"object","required":["encryptedPrivateKey","nonce","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedPrivateKey":{"type":"string","format":"byte"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"IngestBatchRequest":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/IngestEventRequest"}}}},"IngestEventRequest":{"type":"object","required":["repo_path","kind"],"properties":{"backend":{"type":"string","nullable":true},"content_hash":{"type":"string","nullable":true},"kind":{"$ref":"#/components/schemas/IngestKindParam"},"payload":{"nullable":true},"repo_path":{"type":"string"}}},"IngestKindParam":{"type":"string","enum":["upsert","delete"]},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KdfParamsResponse":{"type":"object","properties":{"iterations":{"type":"integer","format":"int32","nullable":true,"minimum":0},"memory":{"type":"integer","format":"int32","nullable":true,"minimum":0},"parallelism":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MasterKeyBackupResponse":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKey":{"type":"string","format":"byte"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string"},"salt":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterPublicKeyRequest":{"type":"object","required":["publicKey","keyType"],"properties":{"keyType":{"type":"string","description":"Key type (e.g., \"ecdh-p256\")","example":"ecdh-p256"},"publicKey":{"type":"string","format":"byte","description":"Base64 encoded public key"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"RotateDocumentKeyRequest":{"type":"object","description":"Request body for document DEK rotation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded new encrypted DEK"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"RotateDocumentKeyResponse":{"type":"object","description":"Response for document DEK rotation","required":["documentId","newKeyVersion"],"properties":{"documentId":{"type":"string","format":"uuid"},"newKeyVersion":{"type":"integer","format":"int32"}}},"RotateWorkspaceKeyRequest":{"type":"object","description":"Request body for KEK rotation","required":["memberKeys"],"properties":{"memberKeys":{"type":"array","items":{"$ref":"#/components/schemas/RotationMemberKey"},"description":"Encrypted KEKs for all workspace members"}}},"RotateWorkspaceKeyResponse":{"type":"object","description":"Response for KEK rotation","required":["workspaceId","newKeyVersion","keysUpdated"],"properties":{"keysUpdated":{"type":"integer","minimum":0},"newKeyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"RotationMemberKey":{"type":"object","description":"A single member's encrypted KEK for key rotation","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK for this member"},"userId":{"type":"string","format":"uuid","description":"User ID of the member"}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareKeyResponse":{"type":"object","required":["shareId","encryptedDek","isPasswordProtected","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedDek":{"type":"string","format":"byte"},"isPasswordProtected":{"type":"boolean"},"kdfParams":{"allOf":[{"$ref":"#/components/schemas/KdfParamsResponse"}],"nullable":true},"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"ShareSaltResponse":{"type":"object","required":["shareId"],"properties":{"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"StoreDocumentKeyRequest":{"type":"object","required":["encryptedDek","nonce","keyVersion"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreEncryptedPrivateKeyRequest":{"type":"object","required":["encryptedPrivateKey","nonce"],"properties":{"encryptedPrivateKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted private key"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreMasterKeyBackupRequest":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams"],"properties":{"encryptedKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted master key"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string","description":"KDF type (e.g., \"argon2id\", \"pbkdf2\")","example":"argon2id"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StorePasswordProtectedShareKeyRequest":{"type":"object","required":["encryptedDek","salt","kdfParams"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StoreShareKeyRequest":{"type":"object","required":["encryptedDek"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"}}},"StoreWorkspaceKeyRequest":{"type":"object","required":["encryptedKek","keyVersion"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version (for key rotation tracking)"}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"file":{"type":"string","format":"binary"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserPublicKeyResponse":{"type":"object","required":["publicKey","keyType","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"keyType":{"type":"string"},"publicKey":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceKeyResponse":{"type":"object","required":["id","workspaceId","userId","encryptedKek","keyVersion","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKek":{"type":"string","format":"byte"},"id":{"type":"string","format":"uuid"},"keyVersion":{"type":"integer","format":"int32"},"userId":{"type":"string","format":"uuid"},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceKeyVersionResponse":{"type":"object","required":["workspaceId"],"properties":{"keyVersion":{"type":"integer","format":"int32","nullable":true},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"E2EE","description":"End-to-end encryption key management"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Storage","description":"Storage ingest APIs"},{"name":"Health","description":"System health checks"}]} From 1ee1dd42b1c3c4fe115fa4cb03b2f88b13f42cf4 Mon Sep 17 00:00:00 2001 From: munenick Date: Wed, 7 Jan 2026 19:24:13 +0900 Subject: [PATCH 03/45] add: support e2ee --- .../src/documents/dtos/documents.rs | 29 +++ .../application/src/documents/dtos/shares.rs | 1 + .../application/src/documents/dtos/tags.rs | 18 ++ .../documents/ports/document_repository.rs | 8 + .../document_snapshot_archive_repository.rs | 3 + .../documents/ports/files/files_repository.rs | 40 +++- .../ports/publishing/public_repository.rs | 25 +++ .../realtime/realtime_persistence_port.rs | 27 +++ .../documents/ports/realtime/realtime_port.rs | 43 ++++ .../ports/tagging/encrypted_tag_repository.rs | 62 ++++++ .../src/documents/ports/tagging/mod.rs | 1 + .../src/documents/services/attachments.rs | 19 +- .../src/documents/services/content.rs | 140 ++++++++++-- .../src/documents/services/crud.rs | 15 +- .../src/documents/services/files/mod.rs | 51 +++-- .../application/src/documents/services/mod.rs | 78 ++++++- .../src/documents/services/publishing/mod.rs | 55 ++++- .../documents/services/realtime/snapshot.rs | 18 +- .../src/documents/services/sharing/crud.rs | 1 + .../src/documents/services/snapshots.rs | 43 +++- .../src/documents/services/tagging/mod.rs | 204 +++++++++++++++++- .../documents/use_cases/download_document.rs | 2 + .../documents/use_cases/files/upload_file.rs | 50 +++-- .../use_cases/sharing/create_share.rs | 2 + api/crates/domain/src/documents/document.rs | 15 ++ .../document_repository_sqlx/helpers.rs | 2 + .../document_repository_sqlx/repository.rs | 20 ++ .../mod.rs | 28 ++- .../encrypted_tag_repository_sqlx/mod.rs | 189 ++++++++++++++++ .../repositories/files_repository_sqlx/mod.rs | 49 +++-- .../src/documents/db/repositories/mod.rs | 1 + .../public_repository_sqlx/mod.rs | 68 +++++- .../src/documents/realtime/doc_persistence.rs | 42 +++- .../src/documents/realtime/hub.rs | 92 ++++++++ .../src/documents/realtime/local_engine.rs | 24 ++- .../src/documents/realtime/redis/engine.rs | 73 ++++++- .../src/http/documents/files/download.rs | 1 + .../src/http/documents/files/mod.rs | 2 +- .../src/http/documents/files/types.rs | 58 ++++- .../src/http/documents/files/upload.rs | 73 ++++--- .../src/http/documents/handlers/content.rs | 156 ++++++++++++-- .../src/http/documents/handlers/crud.rs | 47 ++++ .../src/http/documents/handlers/snapshots.rs | 70 +++++- .../presentation/src/http/documents/mod.rs | 10 +- .../http/documents/publishing/handlers/mod.rs | 21 +- .../src/http/documents/publishing/types.rs | 15 +- .../src/http/documents/sharing/mod.rs | 3 +- .../src/http/documents/sharing/shares.rs | 38 ++++ .../src/http/documents/sharing/types.rs | 44 ++++ .../src/http/documents/sharing/validation.rs | 79 ++++++- .../http/documents/tagging/handlers/mod.rs | 118 ++++++++-- .../src/http/documents/tagging/mod.rs | 10 +- .../src/http/documents/tagging/types.rs | 87 +++++++- .../presentation/src/http/documents/types.rs | 194 +++++++++++++++-- api/crates/presentation/src/openapi.rs | 16 +- api/openapi.json | 3 - 56 files changed, 2344 insertions(+), 239 deletions(-) create mode 100644 api/crates/application/src/documents/ports/tagging/encrypted_tag_repository.rs create mode 100644 api/crates/infrastructure/src/documents/db/repositories/encrypted_tag_repository_sqlx/mod.rs diff --git a/api/crates/application/src/documents/dtos/documents.rs b/api/crates/application/src/documents/dtos/documents.rs index cf32c788..c661ad1a 100644 --- a/api/crates/application/src/documents/dtos/documents.rs +++ b/api/crates/application/src/documents/dtos/documents.rs @@ -31,6 +31,9 @@ pub struct SnapshotSummaryDto { pub created_by: Option, pub byte_size: i64, pub content_hash: String, + // E2EE fields + pub nonce: Option>, + pub signature: Option>, } #[derive(Debug, Clone)] @@ -63,6 +66,32 @@ impl From for SnapshotSummaryDto { created_by: record.created_by, byte_size: record.byte_size, content_hash: record.content_hash, + nonce: record.nonce, + signature: record.signature, } } } + +/// Snapshot detail DTO +/// - For E2EE documents: content is encrypted, nonce is Some +/// - For non-E2EE documents: content is plaintext Yjs state, nonce is None +#[derive(Debug, Clone)] +pub struct SnapshotDetailDto { + pub id: Uuid, + /// Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE) + pub content: Vec, + /// Nonce for decryption (present for E2EE documents) + pub nonce: Option>, + pub created_at: DateTime, +} + +/// Document content DTO (unified for both plaintext and E2EE) +/// - For E2EE: content is encrypted bytes, nonce is present +/// - For plaintext: content is Yjs state bytes, nonce is None +#[derive(Debug, Clone)] +pub struct ContentDto { + /// Document content as Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE) + pub content: Vec, + /// Nonce for decryption (present for E2EE documents) + pub nonce: Option>, +} diff --git a/api/crates/application/src/documents/dtos/shares.rs b/api/crates/application/src/documents/dtos/shares.rs index 9de659cc..8a4c1997 100644 --- a/api/crates/application/src/documents/dtos/shares.rs +++ b/api/crates/application/src/documents/dtos/shares.rs @@ -70,6 +70,7 @@ pub struct ShareBrowseResponseDto { #[derive(Debug, Clone)] pub struct CreatedShareDto { + pub share_id: Uuid, pub token: String, pub document_id: Uuid, pub document_type: String, diff --git a/api/crates/application/src/documents/dtos/tags.rs b/api/crates/application/src/documents/dtos/tags.rs index f76f86fa..21440bf3 100644 --- a/api/crates/application/src/documents/dtos/tags.rs +++ b/api/crates/application/src/documents/dtos/tags.rs @@ -1,5 +1,23 @@ +use chrono::{DateTime, Utc}; +use uuid::Uuid; + #[derive(Debug, Clone)] pub struct TagItemDto { pub name: String, pub count: i64, } + +/// Encrypted tag item with Base64-encoded tag +#[derive(Debug, Clone)] +pub struct EncryptedTagItemDto { + pub encrypted_tag: Vec, + pub count: i64, +} + +/// Encrypted tag entry for a document +#[derive(Debug, Clone)] +pub struct EncryptedTagEntryDto { + pub id: Uuid, + pub encrypted_tag: Vec, + pub created_at: DateTime, +} diff --git a/api/crates/application/src/documents/ports/document_repository.rs b/api/crates/application/src/documents/ports/document_repository.rs index 403bb5a4..85d509aa 100644 --- a/api/crates/application/src/documents/ports/document_repository.rs +++ b/api/crates/application/src/documents/ports/document_repository.rs @@ -119,6 +119,14 @@ pub trait DocumentRepository: Send + Sync { workspace_id: Uuid, root_id: Uuid, ) -> DocumentRepoResult>; + + /// Update encrypted title fields for E2EE documents + async fn update_encrypted_title( + &self, + doc_id: Uuid, + encrypted_title: Vec, + encrypted_title_nonce: Vec, + ) -> DocumentRepoResult<()>; } #[async_trait] diff --git a/api/crates/application/src/documents/ports/document_snapshot_archive_repository.rs b/api/crates/application/src/documents/ports/document_snapshot_archive_repository.rs index 3640c30f..88524006 100644 --- a/api/crates/application/src/documents/ports/document_snapshot_archive_repository.rs +++ b/api/crates/application/src/documents/ports/document_snapshot_archive_repository.rs @@ -29,6 +29,9 @@ pub struct SnapshotArchiveRecord { pub created_by: Option, pub byte_size: i64, pub content_hash: String, + // E2EE fields + pub nonce: Option>, + pub signature: Option>, } #[derive(Debug, Clone)] diff --git a/api/crates/application/src/documents/ports/files/files_repository.rs b/api/crates/application/src/documents/ports/files/files_repository.rs index ddede632..e814e2d5 100644 --- a/api/crates/application/src/documents/ports/files/files_repository.rs +++ b/api/crates/application/src/documents/ports/files/files_repository.rs @@ -9,6 +9,10 @@ pub struct FileMeta { pub content_type: Option, pub document_id: Uuid, pub workspace_id: Uuid, + // E2EE fields + pub encrypted_metadata: Option>, + pub encrypted_metadata_nonce: Option>, + pub encrypted_hash: Option, } #[derive(Debug, Clone)] @@ -27,15 +31,12 @@ pub struct StoredFileScope { #[async_trait] pub trait FilesRepository: Send + Sync { async fn is_workspace_document(&self, doc_id: Uuid, workspace_id: Uuid) -> PortResult; - async fn insert_file( - &self, - doc_id: Uuid, - filename: &str, - content_type: Option<&str>, - size: i64, - storage_path: &str, - content_hash: &str, - ) -> PortResult; + + /// Insert a file with optional E2EE metadata. + /// For plaintext files: pass encrypted_* fields as None + /// For E2EE files: pass encrypted_* fields with values + async fn insert_file(&self, input: FileInsert<'_>) -> PortResult; + async fn get_file_meta(&self, file_id: Uuid) -> PortResult>; async fn get_file_path_by_doc_and_name( &self, @@ -78,4 +79,25 @@ pub struct FileRecord { pub size: i64, pub storage_path: String, pub content_hash: String, + // E2EE fields + pub encrypted_metadata: Option>, + pub encrypted_metadata_nonce: Option>, + pub encrypted_hash: Option, +} + +/// Input for file insert (unified for both plaintext and E2EE) +#[derive(Debug, Clone)] +pub struct FileInsert<'a> { + pub doc_id: Uuid, + pub filename: &'a str, + pub content_type: Option<&'a str>, + pub size: i64, + pub storage_path: &'a str, + pub content_hash: &'a str, + /// E2EE: encrypted file metadata + pub encrypted_metadata: Option<&'a [u8]>, + /// E2EE: nonce for encrypted metadata + pub encrypted_metadata_nonce: Option<&'a [u8]>, + /// E2EE: encrypted hash of the file content + pub encrypted_hash: Option<&'a str>, } diff --git a/api/crates/application/src/documents/ports/publishing/public_repository.rs b/api/crates/application/src/documents/ports/publishing/public_repository.rs index 5c8d8cfc..99f077cc 100644 --- a/api/crates/application/src/documents/ports/publishing/public_repository.rs +++ b/api/crates/application/src/documents/ports/publishing/public_repository.rs @@ -54,4 +54,29 @@ pub trait PublicRepository: Send + Sync { workspace_slug: &str, doc_id: Uuid, ) -> PortResult; + + /// Store or update plaintext content for a published document (for E2EE mode) + async fn store_public_content( + &self, + doc_id: Uuid, + title: &str, + content: &str, + content_hash: &str, + ) -> PortResult<()>; + + /// Get stored plaintext content for a published document + async fn get_public_content(&self, doc_id: Uuid) -> PortResult>; + + /// Delete stored public content when unpublishing + async fn delete_public_content(&self, doc_id: Uuid) -> PortResult<()>; +} + +/// Stored plaintext content for published document +#[derive(Debug, Clone)] +pub struct PublicContentRow { + pub document_id: Uuid, + pub title: String, + pub content: String, + pub content_hash: String, + pub updated_at: chrono::DateTime, } diff --git a/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs b/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs index 86bb797b..54df5a25 100644 --- a/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs +++ b/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs @@ -21,6 +21,24 @@ pub struct PersistenceTask { pub struct SnapshotEntry { pub version: i64, pub bytes: Vec, + pub nonce: Option>, + pub signature: Option>, +} + +/// Encryption metadata for E2EE content +#[derive(Debug, Clone, Default)] +pub struct ContentEncryptionMeta { + pub nonce: Option>, + pub signature: Option>, +} + +/// Encrypted update data for E2EE documents +#[derive(Debug, Clone)] +pub struct EncryptedUpdateData { + pub data: Vec, + pub nonce: Option>, + pub signature: Option>, + pub public_key: Option>, } #[async_trait] @@ -32,6 +50,14 @@ pub trait DocPersistencePort: Send + Sync { update: &[u8], ) -> PortResult<()>; + /// Append encrypted update with E2EE metadata + async fn append_encrypted_update_with_seq( + &self, + doc_id: &Uuid, + seq: i64, + update: &EncryptedUpdateData, + ) -> PortResult<()>; + async fn latest_update_seq(&self, doc_id: &Uuid) -> PortResult>; async fn persist_snapshot( @@ -39,6 +65,7 @@ pub trait DocPersistencePort: Send + Sync { doc_id: &Uuid, version: i64, snapshot: &[u8], + encryption_meta: Option<&ContentEncryptionMeta>, ) -> PortResult<()>; async fn latest_snapshot_entry(&self, doc_id: &Uuid) -> PortResult>; diff --git a/api/crates/application/src/documents/ports/realtime/realtime_port.rs b/api/crates/application/src/documents/ports/realtime/realtime_port.rs index d41a0feb..06f46c9e 100644 --- a/api/crates/application/src/documents/ports/realtime/realtime_port.rs +++ b/api/crates/application/src/documents/ports/realtime/realtime_port.rs @@ -37,6 +37,10 @@ pub trait RealtimeEngine: Send + Sync { async fn get_content(&self, doc_id: &str) -> PortResult>; + /// Get Yjs snapshot with E2EE metadata (nonce, signature) + /// Returns snapshot data including nonce for decryption + async fn get_snapshot(&self, doc_id: &str) -> PortResult>; + async fn force_persist(&self, doc_id: &str) -> PortResult<()>; async fn force_save_to_fs(&self, doc_id: &str) -> PortResult<()> { @@ -45,7 +49,46 @@ pub trait RealtimeEngine: Send + Sync { async fn apply_snapshot(&self, doc_id: &str, snapshot: &[u8]) -> PortResult<()>; + /// Apply encrypted snapshot with E2EE metadata + async fn apply_encrypted_snapshot( + &self, + doc_id: &str, + snapshot: &[u8], + _nonce: Option<&[u8]>, + _signature: Option<&[u8]>, + ) -> PortResult<()> { + // Default implementation ignores encryption metadata + self.apply_snapshot(doc_id, snapshot).await + } + + /// Apply encrypted updates (delta) for E2EE documents + /// This appends encrypted Yjs updates without processing them + async fn apply_encrypted_updates( + &self, + doc_id: &str, + updates: &[EncryptedUpdate], + ) -> PortResult<()>; + async fn set_document_editable(&self, _doc_id: &str, _editable: bool) -> PortResult<()> { Ok(()) } } + +/// Encrypted Yjs update for E2EE documents +#[derive(Debug, Clone)] +pub struct EncryptedUpdate { + pub data: Vec, + pub nonce: Option>, + pub signature: Option>, +} + +/// Snapshot data with E2EE metadata +#[derive(Debug, Clone)] +pub struct SnapshotData { + /// Yjs snapshot bytes + pub data: Vec, + /// Nonce for decryption + pub nonce: Option>, + /// Signature for verification + pub signature: Option>, +} diff --git a/api/crates/application/src/documents/ports/tagging/encrypted_tag_repository.rs b/api/crates/application/src/documents/ports/tagging/encrypted_tag_repository.rs new file mode 100644 index 00000000..2a7dee66 --- /dev/null +++ b/api/crates/application/src/documents/ports/tagging/encrypted_tag_repository.rs @@ -0,0 +1,62 @@ +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; + +/// Encrypted tag entry for a document +#[derive(Debug, Clone)] +pub struct EncryptedTagEntry { + pub id: Uuid, + pub workspace_id: Uuid, + pub document_id: Uuid, + pub encrypted_tag: Vec, + pub created_at: DateTime, +} + +/// Summary of encrypted tags with occurrence count +#[derive(Debug, Clone)] +pub struct EncryptedTagSummary { + pub encrypted_tag: Vec, + pub count: i64, +} + +#[async_trait] +pub trait EncryptedTagRepository: Send + Sync { + /// List all unique encrypted tags in a workspace with their counts + async fn list_encrypted_tags( + &self, + workspace_id: Uuid, + ) -> PortResult>; + + /// List encrypted tags for a specific document + async fn list_document_encrypted_tags( + &self, + document_id: Uuid, + ) -> PortResult>; + + /// Replace all encrypted tags for a document + async fn replace_document_encrypted_tags( + &self, + workspace_id: Uuid, + document_id: Uuid, + encrypted_tags: &[Vec], + ) -> PortResult>; + + /// Find documents by encrypted tag (deterministic encryption allows exact match) + async fn find_documents_by_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: &[u8], + ) -> PortResult>; + + /// Find a specific encrypted tag with its count (for filtering) + async fn find_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: &[u8], + ) -> PortResult>; + + /// Delete all encrypted tags for a document + async fn delete_document_encrypted_tags(&self, document_id: Uuid) -> PortResult<()>; +} diff --git a/api/crates/application/src/documents/ports/tagging/mod.rs b/api/crates/application/src/documents/ports/tagging/mod.rs index 5a5457fe..fc3cd469 100644 --- a/api/crates/application/src/documents/ports/tagging/mod.rs +++ b/api/crates/application/src/documents/ports/tagging/mod.rs @@ -1,2 +1,3 @@ +pub mod encrypted_tag_repository; pub mod tag_repository; pub mod tagging_repository; diff --git a/api/crates/application/src/documents/services/attachments.rs b/api/crates/application/src/documents/services/attachments.rs index 2b97c0c6..c39e3ae7 100644 --- a/api/crates/application/src/documents/services/attachments.rs +++ b/api/crates/application/src/documents/services/attachments.rs @@ -91,14 +91,17 @@ impl DocumentService { .relative_from_uploads(&target_path) .replace('\\', "/"); self.files_repo - .insert_file( - target_doc.id(), - &filename, - attachment.content_type.as_deref(), - attachment.bytes.len() as i64, - &storage_path, - &attachment.content_hash, - ) + .insert_file(crate::documents::ports::files::files_repository::FileInsert { + doc_id: target_doc.id(), + filename: &filename, + content_type: attachment.content_type.as_deref(), + size: attachment.bytes.len() as i64, + storage_path: &storage_path, + content_hash: &attachment.content_hash, + encrypted_metadata: None, + encrypted_metadata_nonce: None, + encrypted_hash: None, + }) .await .map_err(ServiceError::from)?; if let Some(repo_path) = diff --git a/api/crates/application/src/documents/services/content.rs b/api/crates/application/src/documents/services/content.rs index a9aebf3a..68d3eeda 100644 --- a/api/crates/application/src/documents/services/content.rs +++ b/api/crates/application/src/documents/services/content.rs @@ -6,6 +6,8 @@ use domain::documents::document::Document as DomainDocument; use crate::core::services::access::{self, Actor}; use crate::core::services::errors::ServiceError; +use crate::documents::dtos::ContentDto; +use crate::documents::ports::realtime::realtime_port::EncryptedUpdate; use crate::documents::ports::tx_runner::run_in_tx; use crate::documents::services::realtime::snapshot::snapshot_from_markdown; @@ -14,7 +16,9 @@ use super::patch::{DocumentPatchOperation, apply_patch_operations}; use super::util::map_tx_error; impl DocumentService { - pub async fn get_content(&self, actor: &Actor, doc_id: Uuid) -> Result { + /// Get document content as Yjs snapshot bytes. + /// Returns ContentDto with content bytes and optional nonce (for E2EE documents). + pub async fn get_content(&self, actor: &Actor, doc_id: Uuid) -> Result { access::require_view( self.access_repo.as_ref(), self.share_access.as_ref(), @@ -27,20 +31,34 @@ impl DocumentService { other => other, })?; - let content = self + let snapshot = self .realtime - .get_content(&doc_id.to_string()) + .get_snapshot(&doc_id.to_string()) .await - .map_err(ServiceError::from)? - .unwrap_or_default(); - Ok(content) + .map_err(ServiceError::from)?; + + match snapshot { + Some(data) => Ok(ContentDto { + content: data.data, + nonce: data.nonce, + }), + None => Ok(ContentDto { + content: Vec::new(), + nonce: None, + }), + } } + /// Update document content. + /// - For plaintext mode: pass content bytes (Yjs state), nonce and signature as None + /// - For E2EE mode: pass encrypted content bytes with nonce and optional signature pub async fn update_content( &self, actor: &Actor, doc_id: Uuid, - content: &str, + content: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, ) -> Result { access::require_edit( self.access_repo.as_ref(), @@ -54,9 +72,9 @@ impl DocumentService { other => other, })?; - let snapshot_bytes = snapshot_from_markdown(content); + // Apply snapshot with optional E2EE metadata self.realtime - .apply_snapshot(&doc_id.to_string(), snapshot_bytes.as_slice()) + .apply_encrypted_snapshot(&doc_id.to_string(), content, nonce, signature) .await .map_err(ServiceError::from)?; @@ -86,6 +104,8 @@ impl DocumentService { }) .await .map_err(map_tx_error)?; + + let is_encrypted = nonce.is_some(); let repo_path = doc.desired_path().as_str().to_string(); let event_payload = json!({ "repo_path": repo_path, @@ -93,6 +113,7 @@ impl DocumentService { "slug": doc.slug().as_str(), "doc_type": doc.doc_type().as_str(), "owner_id": doc.workspace_id(), + "encrypted": is_encrypted, }); self.record_event( doc.workspace_id(), @@ -104,16 +125,27 @@ impl DocumentService { Ok(doc) } - pub async fn patch_content( + /// Update document content from markdown string (convenience method for plaintext mode). + pub async fn update_content_from_markdown( &self, actor: &Actor, doc_id: Uuid, - operations: &[DocumentPatchOperation], + content: &str, ) -> Result { - if operations.is_empty() { - return Err(ServiceError::BadRequest("patch_operations_required")); - } + let snapshot_bytes = snapshot_from_markdown(content); + self.update_content(actor, doc_id, &snapshot_bytes, None, None).await + } + /// Patch document content. + /// - For plaintext mode: pass DocumentPatchOperation with text + /// - For E2EE mode: pass EncryptedUpdate with encrypted data and nonce + pub async fn patch_content( + &self, + actor: &Actor, + doc_id: Uuid, + plaintext_operations: Option<&[DocumentPatchOperation]>, + encrypted_updates: Option<&[EncryptedUpdate]>, + ) -> Result { access::require_edit( self.access_repo.as_ref(), self.share_access.as_ref(), @@ -126,14 +158,82 @@ impl DocumentService { other => other, })?; - let current = self - .realtime - .get_content(&doc_id.to_string()) + let is_encrypted = encrypted_updates.is_some() && !encrypted_updates.unwrap().is_empty(); + + if let Some(updates) = encrypted_updates { + if !updates.is_empty() { + // E2EE mode: apply encrypted updates + self.realtime + .apply_encrypted_updates(&doc_id.to_string(), updates) + .await + .map_err(ServiceError::from)?; + } + } else if let Some(operations) = plaintext_operations { + if operations.is_empty() { + return Err(ServiceError::BadRequest("patch_operations_required")); + } + // Plaintext mode: get current content, apply operations, update + let current = self + .realtime + .get_content(&doc_id.to_string()) + .await + .map_err(ServiceError::from)? + .unwrap_or_default(); + let updated = apply_patch_operations(¤t, operations)?; + let snapshot_bytes = snapshot_from_markdown(&updated); + self.realtime + .apply_snapshot(&doc_id.to_string(), &snapshot_bytes) + .await + .map_err(ServiceError::from)?; + } else { + return Err(ServiceError::BadRequest("patch_operations_required")); + } + + if let Err(err) = self.realtime.force_persist(&doc_id.to_string()).await { + warn!(document_id = %doc_id, error = ?err, "document_force_persist_after_patch_failed"); + } + + let doc = self + .document_repo + .get_by_id(doc_id) .await .map_err(ServiceError::from)? - .unwrap_or_default(); - let updated = apply_patch_operations(¤t, operations)?; + .ok_or(ServiceError::NotFound)?; + + let workspace_id = doc.workspace_id(); + let doc_id = doc.id(); + run_in_tx(self.tx_runner.as_ref(), move |tx| { + Box::pin(async move { + Self::enqueue_doc_sync_tx( + tx.storage_jobs(), + workspace_id, + doc_id, + "patch_content", + ) + .await?; + Ok(()) + }) + }) + .await + .map_err(map_tx_error)?; - self.update_content(actor, doc_id, &updated).await + let repo_path = doc.desired_path().as_str().to_string(); + let event_payload = json!({ + "repo_path": repo_path, + "desired_path": doc.desired_path().as_str(), + "slug": doc.slug().as_str(), + "doc_type": doc.doc_type().as_str(), + "owner_id": doc.workspace_id(), + "encrypted": is_encrypted, + }); + self.record_event( + doc.workspace_id(), + doc.id(), + "document.content_patched", + Some(event_payload), + ) + .await; + + Ok(doc) } } diff --git a/api/crates/application/src/documents/services/crud.rs b/api/crates/application/src/documents/services/crud.rs index 6f0f7725..083b8304 100644 --- a/api/crates/application/src/documents/services/crud.rs +++ b/api/crates/application/src/documents/services/crud.rs @@ -181,7 +181,7 @@ impl DocumentService { let result = async { let updated_doc = self - .update_content(&actor, new_doc.id(), &source_content) + .update_content_from_markdown(&actor, new_doc.id(), &source_content) .await?; self.copy_attachments(&updated_doc, &attachments, actor_id) @@ -427,4 +427,17 @@ impl DocumentService { .await .map_err(ServiceError::from) } + + /// Update encrypted title fields for E2EE documents + pub async fn update_encrypted_title( + &self, + doc_id: Uuid, + encrypted_title: Vec, + encrypted_title_nonce: Vec, + ) -> Result<(), ServiceError> { + self.document_repo + .update_encrypted_title(doc_id, encrypted_title, encrypted_title_nonce) + .await + .map_err(ServiceError::from) + } } diff --git a/api/crates/application/src/documents/services/files/mod.rs b/api/crates/application/src/documents/services/files/mod.rs index dc65039e..b3f9bfcf 100644 --- a/api/crates/application/src/documents/services/files/mod.rs +++ b/api/crates/application/src/documents/services/files/mod.rs @@ -12,29 +12,39 @@ use crate::documents::ports::access_repository::AccessRepository; use crate::documents::ports::doc_event_log::DocEventLog; use crate::documents::ports::files::files_repository::FilesRepository; use crate::documents::ports::sharing::share_access_port::ShareAccessPort; -use crate::documents::use_cases::files::upload_file::{UploadFile, UploadedFile}; +use crate::documents::use_cases::files::upload_file::{FileUploadInput, UploadFile, UploadedFile}; use async_trait::async_trait; use domain::documents::path as doc_path; +/// File payload with optional E2EE metadata (unified for both plaintext and E2EE) pub struct FilePayload { pub bytes: Vec, pub content_type: Option, + /// E2EE: encrypted file metadata + pub encrypted_metadata: Option>, + /// E2EE: nonce for encrypted metadata + pub encrypted_metadata_nonce: Option>, + /// E2EE: encrypted hash of the file content + pub encrypted_hash: Option, } #[async_trait] pub trait FileServiceFacade: Send + Sync { + /// Upload a file with optional E2EE metadata. + /// For plaintext files: pass encrypted_* fields as None in FileUploadInput + /// For E2EE files: pass encrypted_* fields with values #[allow(clippy::too_many_arguments)] async fn upload_file( &self, workspace_id: Uuid, actor_id: Uuid, doc_id: Uuid, - bytes: Vec, - orig_filename: Option, - content_type: Option, + input: FileUploadInput, public_base_url: Option, ) -> Result; + /// Download file with optional E2EE metadata. + /// Returns FilePayload with bytes and optional E2EE fields. async fn download_owned_file( &self, actor: &Actor, @@ -65,21 +75,11 @@ impl FileServiceFacade for FileService { workspace_id: Uuid, actor_id: Uuid, doc_id: Uuid, - bytes: Vec, - orig_filename: Option, - content_type: Option, + input: FileUploadInput, public_base_url: Option, ) -> Result { - self.upload_file( - workspace_id, - actor_id, - doc_id, - bytes, - orig_filename, - content_type, - public_base_url, - ) - .await + self.upload_file(workspace_id, actor_id, doc_id, input, public_base_url) + .await } async fn download_owned_file( @@ -135,15 +135,14 @@ impl FileService { } } + /// Upload a file with optional E2EE metadata. #[allow(clippy::too_many_arguments)] pub async fn upload_file( &self, workspace_id: Uuid, actor_id: Uuid, doc_id: Uuid, - bytes: Vec, - orig_filename: Option, - content_type: Option, + input: FileUploadInput, public_base_url: Option, ) -> Result { let uc = UploadFile { @@ -152,7 +151,7 @@ impl FileService { public_base_url, }; let uploaded = uc - .execute(workspace_id, doc_id, bytes, orig_filename, content_type) + .execute(workspace_id, doc_id, input) .await .map_err(ServiceError::from)? .ok_or(ServiceError::Forbidden)?; @@ -161,6 +160,7 @@ impl FileService { Ok(uploaded) } + /// Download file with optional E2EE metadata. pub async fn download_owned_file( &self, actor: &Actor, @@ -192,6 +192,9 @@ impl FileService { Ok(FilePayload { bytes, content_type: meta.content_type, + encrypted_metadata: meta.encrypted_metadata, + encrypted_metadata_nonce: meta.encrypted_metadata_nonce, + encrypted_hash: meta.encrypted_hash, }) } @@ -224,6 +227,9 @@ impl FileService { Ok(FilePayload { bytes, content_type: meta.content_type, + encrypted_metadata: None, + encrypted_metadata_nonce: None, + encrypted_hash: None, }) } @@ -256,6 +262,9 @@ impl FileService { Ok(FilePayload { bytes, content_type, + encrypted_metadata: None, + encrypted_metadata_nonce: None, + encrypted_hash: None, }) } diff --git a/api/crates/application/src/documents/services/mod.rs b/api/crates/application/src/documents/services/mod.rs index 5126efae..f58369a1 100644 --- a/api/crates/application/src/documents/services/mod.rs +++ b/api/crates/application/src/documents/services/mod.rs @@ -125,24 +125,43 @@ pub trait DocumentServiceFacade: Send + Sync { permissions: &PermissionSet, ) -> Result; + /// Get document content as Yjs snapshot bytes. + /// Returns ContentDto with content bytes and optional nonce (for E2EE documents). async fn get_content( &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, - ) -> Result; + ) -> Result; + /// Update document content. + /// - For plaintext mode: pass content bytes (Yjs state), nonce and signature as None + /// - For E2EE mode: pass encrypted content bytes with nonce and optional signature async fn update_content( + &self, + actor: &crate::core::services::access::Actor, + doc_id: Uuid, + content: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, + ) -> Result; + + /// Update document content from markdown string (convenience method for plaintext mode). + async fn update_content_from_markdown( &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, content: &str, ) -> Result; + /// Patch document content. + /// - For plaintext mode: pass DocumentPatchOperation with text + /// - For E2EE mode: pass EncryptedUpdate with encrypted data and nonce async fn patch_content( &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, - operations: &[DocumentPatchOperation], + plaintext_operations: Option<&[DocumentPatchOperation]>, + encrypted_updates: Option<&[crate::documents::ports::realtime::realtime_port::EncryptedUpdate]>, ) -> Result; async fn download_document( @@ -191,6 +210,14 @@ pub trait DocumentServiceFacade: Send + Sync { snapshot_id: Uuid, ) -> Result; + /// Get a single snapshot with its encrypted content (E2EE format) + async fn get_snapshot( + &self, + actor: &crate::core::services::access::Actor, + doc_id: Uuid, + snapshot_id: Uuid, + ) -> Result; + async fn backlinks( &self, actor: &crate::core::services::access::Actor, @@ -204,6 +231,14 @@ pub trait DocumentServiceFacade: Send + Sync { workspace_id: Uuid, doc_id: Uuid, ) -> Result, ServiceError>; + + /// Update encrypted title fields for E2EE documents + async fn update_encrypted_title( + &self, + doc_id: Uuid, + encrypted_title: Vec, + encrypted_title_nonce: Vec, + ) -> Result<(), ServiceError>; } #[async_trait] @@ -334,26 +369,38 @@ impl DocumentServiceFacade for DocumentService { &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, - ) -> Result { + ) -> Result { self.get_content(actor, doc_id).await } async fn update_content( + &self, + actor: &crate::core::services::access::Actor, + doc_id: Uuid, + content: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, + ) -> Result { + self.update_content(actor, doc_id, content, nonce, signature).await + } + + async fn update_content_from_markdown( &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, content: &str, ) -> Result { - self.update_content(actor, doc_id, content).await + self.update_content_from_markdown(actor, doc_id, content).await } async fn patch_content( &self, actor: &crate::core::services::access::Actor, doc_id: Uuid, - operations: &[DocumentPatchOperation], + plaintext_operations: Option<&[DocumentPatchOperation]>, + encrypted_updates: Option<&[crate::documents::ports::realtime::realtime_port::EncryptedUpdate]>, ) -> Result { - self.patch_content(actor, doc_id, operations).await + self.patch_content(actor, doc_id, plaintext_operations, encrypted_updates).await } async fn download_document( @@ -417,6 +464,15 @@ impl DocumentServiceFacade for DocumentService { self.download_snapshot(actor, doc_id, snapshot_id).await } + async fn get_snapshot( + &self, + actor: &crate::core::services::access::Actor, + doc_id: Uuid, + snapshot_id: Uuid, + ) -> Result { + self.get_snapshot(actor, doc_id, snapshot_id).await + } + async fn backlinks( &self, actor: &crate::core::services::access::Actor, @@ -434,6 +490,16 @@ impl DocumentServiceFacade for DocumentService { ) -> Result, ServiceError> { self.outgoing_links(actor, workspace_id, doc_id).await } + + async fn update_encrypted_title( + &self, + doc_id: Uuid, + encrypted_title: Vec, + encrypted_title_nonce: Vec, + ) -> Result<(), ServiceError> { + self.update_encrypted_title(doc_id, encrypted_title, encrypted_title_nonce) + .await + } } pub struct DocumentService { diff --git a/api/crates/application/src/documents/services/publishing/mod.rs b/api/crates/application/src/documents/services/publishing/mod.rs index 6aeae1f2..cc72691a 100644 --- a/api/crates/application/src/documents/services/publishing/mod.rs +++ b/api/crates/application/src/documents/services/publishing/mod.rs @@ -23,11 +23,16 @@ pub struct PublicService { #[async_trait] pub trait PublicServiceFacade: Send + Sync { + /// Publish document. + /// For E2EE mode: pass plaintext_title and plaintext_content + /// For non-E2EE mode: pass None for both async fn publish_document( &self, workspace_id: Uuid, permissions: &PermissionSet, doc_id: Uuid, + plaintext_title: Option<&str>, + plaintext_content: Option<&str>, ) -> Result; async fn unpublish_document( @@ -69,8 +74,10 @@ impl PublicServiceFacade for PublicService { workspace_id: Uuid, permissions: &PermissionSet, doc_id: Uuid, + plaintext_title: Option<&str>, + plaintext_content: Option<&str>, ) -> Result { - self.publish_document(workspace_id, permissions, doc_id) + self.publish_document(workspace_id, permissions, doc_id, plaintext_title, plaintext_content) .await } @@ -125,21 +132,44 @@ impl PublicService { Self { repo, realtime } } + /// Publish document. + /// For E2EE mode: pass plaintext_title and plaintext_content + /// For non-E2EE mode: pass None for both pub async fn publish_document( &self, workspace_id: Uuid, permissions: &PermissionSet, doc_id: Uuid, + plaintext_title: Option<&str>, + plaintext_content: Option<&str>, ) -> Result { public_policy::ensure_public_publish_allowed(permissions) .map_err(|_| ServiceError::Forbidden)?; + let uc = PublishDocument { repo: self.repo.as_ref(), }; - uc.execute(workspace_id, doc_id) + let publish_result = uc + .execute(workspace_id, doc_id) .await .map_err(ServiceError::from)? - .ok_or(ServiceError::NotFound) + .ok_or(ServiceError::NotFound)?; + + // For E2EE mode: store plaintext content for public access + if let (Some(title), Some(content)) = (plaintext_title, plaintext_content) { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(title); + hasher.update(content); + let content_hash = hex::encode(hasher.finalize()); + + self.repo + .store_public_content(doc_id, title, content, &content_hash) + .await + .map_err(ServiceError::from)?; + } + + Ok(publish_result) } pub async fn unpublish_document( @@ -150,6 +180,13 @@ impl PublicService { ) -> Result { public_policy::ensure_public_unpublish_allowed(permissions) .map_err(|_| ServiceError::Forbidden)?; + + // Delete stored public content (E2EE mode) + self.repo + .delete_public_content(doc_id) + .await + .map_err(ServiceError::from)?; + let uc = UnpublishDocument { repo: self.repo.as_ref(), }; @@ -217,6 +254,18 @@ impl PublicService { if !exists { return Err(ServiceError::NotFound); } + + // Prefer stored plaintext content (E2EE mode) over realtime + if let Some(stored) = self + .repo + .get_public_content(doc_id) + .await + .map_err(ServiceError::from)? + { + return Ok(stored.content); + } + + // Fall back to realtime content for non-E2EE documents let content = self .realtime .get_content(&doc_id.to_string()) diff --git a/api/crates/application/src/documents/services/realtime/snapshot.rs b/api/crates/application/src/documents/services/realtime/snapshot.rs index b0dd7b18..486381f9 100644 --- a/api/crates/application/src/documents/services/realtime/snapshot.rs +++ b/api/crates/application/src/documents/services/realtime/snapshot.rs @@ -12,7 +12,8 @@ use crate::core::ports::storage::storage_projection_queue::{ use crate::core::services::tagging; use crate::core::services::utils::hash::sha256_hex; use crate::documents::ports::document_snapshot_archive_repository::{ - DocumentSnapshotArchiveRepository, SnapshotArchiveInsert, SnapshotArchiveRecord, + DocumentSnapshotArchiveRepository, SnapshotArchiveEntry, SnapshotArchiveInsert, + SnapshotArchiveRecord, }; use crate::documents::ports::linkgraph_repository::LinkGraphRepository; use crate::documents::ports::realtime::realtime_hydration_port::DocStateReader; @@ -129,7 +130,7 @@ impl SnapshotService { let snapshot_bin = encode_doc_snapshot(doc); let (current_version, previous_snapshot) = if options.skip_if_unchanged { match self.persistence.latest_snapshot_entry(doc_id).await? { - Some(SnapshotEntry { version, bytes }) => (version, Some(bytes)), + Some(SnapshotEntry { version, bytes, .. }) => (version, Some(bytes)), None => (0, None), } } else { @@ -165,7 +166,7 @@ impl SnapshotService { } let next_version = current_version + 1; self.persistence - .persist_snapshot(doc_id, next_version, &snapshot_bin) + .persist_snapshot(doc_id, next_version, &snapshot_bin, None) .await?; if options.clear_updates { self.persistence.clear_updates(doc_id).await?; @@ -327,6 +328,17 @@ impl SnapshotService { } Ok(None) } + + /// Get a snapshot entry (record + bytes) by ID + pub async fn get_snapshot_entry( + &self, + snapshot_id: Uuid, + ) -> anyhow::Result> { + self.archive_repo + .get_by_id(snapshot_id) + .await + .map_err(Into::into) + } } fn extract_markdown(doc: &Doc) -> String { diff --git a/api/crates/application/src/documents/services/sharing/crud.rs b/api/crates/application/src/documents/services/sharing/crud.rs index 0c9ae731..fbd62f55 100644 --- a/api/crates/application/src/documents/services/sharing/crud.rs +++ b/api/crates/application/src/documents/services/sharing/crud.rs @@ -36,6 +36,7 @@ impl ShareService { uc.execute(workspace_id, actor_id, document_id, permission, expires_at) .await .map(|res| CreatedShareDto { + share_id: res.share_id, token: res.token, document_id: res.document_id, document_type: res.document_type.as_str().to_string(), diff --git a/api/crates/application/src/documents/services/snapshots.rs b/api/crates/application/src/documents/services/snapshots.rs index 72364519..54754c20 100644 --- a/api/crates/application/src/documents/services/snapshots.rs +++ b/api/crates/application/src/documents/services/snapshots.rs @@ -2,7 +2,9 @@ use uuid::Uuid; use crate::core::services::access::{self, Actor}; use crate::core::services::errors::ServiceError; -use crate::documents::dtos::{SnapshotDiffBaseMode, SnapshotDiffDto, SnapshotSummaryDto}; +use crate::documents::dtos::{ + SnapshotDetailDto, SnapshotDiffBaseMode, SnapshotDiffDto, SnapshotSummaryDto, +}; use crate::documents::use_cases::list_snapshots::ListSnapshots; use crate::documents::use_cases::restore_snapshot::RestoreSnapshot; use crate::documents::use_cases::snapshot_diff::SnapshotDiff; @@ -138,4 +140,43 @@ impl DocumentService { .map_err(ServiceError::from)? .ok_or(ServiceError::NotFound) } + + /// Get a single snapshot with its encrypted content (E2EE format) + pub async fn get_snapshot( + &self, + actor: &Actor, + doc_id: Uuid, + snapshot_id: Uuid, + ) -> Result { + access::require_view( + self.access_repo.as_ref(), + self.share_access.as_ref(), + actor, + doc_id, + ) + .await + .map_err(|err| match err { + ServiceError::Forbidden => ServiceError::Unauthorized, + other => other, + })?; + + let entry = self + .snapshot_service + .get_snapshot_entry(snapshot_id) + .await + .map_err(ServiceError::from)? + .ok_or(ServiceError::NotFound)?; + + // Verify the snapshot belongs to the requested document + if entry.record.document_id != doc_id { + return Err(ServiceError::NotFound); + } + + Ok(SnapshotDetailDto { + id: entry.record.id, + content: entry.bytes, + nonce: entry.record.nonce, + created_at: entry.record.created_at, + }) + } } diff --git a/api/crates/application/src/documents/services/tagging/mod.rs b/api/crates/application/src/documents/services/tagging/mod.rs index 9ac28a88..ecc3c1a8 100644 --- a/api/crates/application/src/documents/services/tagging/mod.rs +++ b/api/crates/application/src/documents/services/tagging/mod.rs @@ -3,13 +3,15 @@ use std::sync::Arc; use uuid::Uuid; use crate::core::services::errors::ServiceError; -use crate::documents::dtos::TagItemDto; +use crate::documents::dtos::{EncryptedTagEntryDto, EncryptedTagItemDto, TagItemDto}; +use crate::documents::ports::tagging::encrypted_tag_repository::EncryptedTagRepository; use crate::documents::ports::tagging::tag_repository::TagRepository; use crate::documents::use_cases::tagging::list_tags::ListTags; use async_trait::async_trait; pub struct TagService { repo: Arc, + encrypted_tag_repo: Option>, } #[async_trait] @@ -19,6 +21,40 @@ pub trait TagServiceFacade: Send + Sync { workspace_id: Uuid, filter: Option, ) -> Result, ServiceError>; + + /// List all encrypted tags in a workspace + async fn list_encrypted_tags( + &self, + workspace_id: Uuid, + ) -> Result, ServiceError>; + + /// List encrypted tags for a specific document + async fn list_document_encrypted_tags( + &self, + document_id: Uuid, + ) -> Result, ServiceError>; + + /// Replace all encrypted tags for a document + async fn replace_document_encrypted_tags( + &self, + workspace_id: Uuid, + document_id: Uuid, + encrypted_tags: Vec>, + ) -> Result, ServiceError>; + + /// Find documents by encrypted tag + async fn find_documents_by_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError>; + + /// Find a specific encrypted tag (for filtering) + async fn find_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError>; } #[async_trait] @@ -30,11 +66,63 @@ impl TagServiceFacade for TagService { ) -> Result, ServiceError> { self.list(workspace_id, filter).await } + + async fn list_encrypted_tags( + &self, + workspace_id: Uuid, + ) -> Result, ServiceError> { + self.list_encrypted_tags(workspace_id).await + } + + async fn list_document_encrypted_tags( + &self, + document_id: Uuid, + ) -> Result, ServiceError> { + self.list_document_encrypted_tags(document_id).await + } + + async fn replace_document_encrypted_tags( + &self, + workspace_id: Uuid, + document_id: Uuid, + encrypted_tags: Vec>, + ) -> Result, ServiceError> { + self.replace_document_encrypted_tags(workspace_id, document_id, encrypted_tags) + .await + } + + async fn find_documents_by_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError> { + self.find_documents_by_encrypted_tag(workspace_id, encrypted_tag) + .await + } + + async fn find_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError> { + self.find_encrypted_tag(workspace_id, encrypted_tag).await + } } impl TagService { pub fn new(repo: Arc) -> Self { - Self { repo } + Self { + repo, + encrypted_tag_repo: None, + } + } + + pub fn with_encrypted_tag_repo( + mut self, + encrypted_tag_repo: Arc, + ) -> Self { + self.encrypted_tag_repo = Some(encrypted_tag_repo); + self } pub async fn list( @@ -49,4 +137,116 @@ impl TagService { .await .map_err(ServiceError::from) } + + pub async fn list_encrypted_tags( + &self, + workspace_id: Uuid, + ) -> Result, ServiceError> { + let repo = self + .encrypted_tag_repo + .as_ref() + .ok_or(ServiceError::BadRequest("encrypted_tags_not_enabled"))?; + + let summaries = repo + .list_encrypted_tags(workspace_id) + .await + .map_err(ServiceError::from)?; + + Ok(summaries + .into_iter() + .map(|s| EncryptedTagItemDto { + encrypted_tag: s.encrypted_tag, + count: s.count, + }) + .collect()) + } + + pub async fn list_document_encrypted_tags( + &self, + document_id: Uuid, + ) -> Result, ServiceError> { + let repo = self + .encrypted_tag_repo + .as_ref() + .ok_or(ServiceError::BadRequest("encrypted_tags_not_enabled"))?; + + let entries = repo + .list_document_encrypted_tags(document_id) + .await + .map_err(ServiceError::from)?; + + Ok(entries + .into_iter() + .map(|e| EncryptedTagEntryDto { + id: e.id, + encrypted_tag: e.encrypted_tag, + created_at: e.created_at, + }) + .collect()) + } + + pub async fn replace_document_encrypted_tags( + &self, + workspace_id: Uuid, + document_id: Uuid, + encrypted_tags: Vec>, + ) -> Result, ServiceError> { + let repo = self + .encrypted_tag_repo + .as_ref() + .ok_or(ServiceError::BadRequest("encrypted_tags_not_enabled"))?; + + let entries = repo + .replace_document_encrypted_tags(workspace_id, document_id, &encrypted_tags) + .await + .map_err(ServiceError::from)?; + + Ok(entries + .into_iter() + .map(|e| EncryptedTagEntryDto { + id: e.id, + encrypted_tag: e.encrypted_tag, + created_at: e.created_at, + }) + .collect()) + } + + pub async fn find_documents_by_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError> { + let repo = self + .encrypted_tag_repo + .as_ref() + .ok_or(ServiceError::BadRequest("encrypted_tags_not_enabled"))?; + + repo.find_documents_by_encrypted_tag(workspace_id, &encrypted_tag) + .await + .map_err(ServiceError::from) + } + + pub async fn find_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: Vec, + ) -> Result, ServiceError> { + let repo = self + .encrypted_tag_repo + .as_ref() + .ok_or(ServiceError::BadRequest("encrypted_tags_not_enabled"))?; + + let result = repo + .find_encrypted_tag(workspace_id, &encrypted_tag) + .await + .map_err(ServiceError::from)?; + + Ok(result + .into_iter() + .map(|s| EncryptedTagItemDto { + encrypted_tag: s.encrypted_tag, + count: s.count, + }) + .collect()) + } } diff --git a/api/crates/application/src/documents/use_cases/download_document.rs b/api/crates/application/src/documents/use_cases/download_document.rs index 3f8d75e7..8f4b15a0 100644 --- a/api/crates/application/src/documents/use_cases/download_document.rs +++ b/api/crates/application/src/documents/use_cases/download_document.rs @@ -221,6 +221,8 @@ where None, None, None, + None, // encrypted_title + None, // encrypted_title_nonce ); nodes.insert(root.id(), root); diff --git a/api/crates/application/src/documents/use_cases/files/upload_file.rs b/api/crates/application/src/documents/use_cases/files/upload_file.rs index f589bcfc..c960e6f5 100644 --- a/api/crates/application/src/documents/use_cases/files/upload_file.rs +++ b/api/crates/application/src/documents/use_cases/files/upload_file.rs @@ -1,7 +1,7 @@ use uuid::Uuid; use crate::core::ports::storage::storage_port::StorageResolverPort; -use crate::documents::ports::files::files_repository::FilesRepository; +use crate::documents::ports::files::files_repository::{FileInsert, FilesRepository}; pub struct UploadFile<'a, R, S> where @@ -21,6 +21,23 @@ pub struct UploadedFile { pub size: i64, pub storage_path: String, pub content_hash: String, + // E2EE fields + pub encrypted_metadata: Option>, + pub encrypted_metadata_nonce: Option>, + pub encrypted_hash: Option, +} + +/// Input for file upload (unified for both plaintext and E2EE) +pub struct FileUploadInput { + pub bytes: Vec, + pub orig_filename: Option, + pub content_type: Option, + /// E2EE: encrypted file metadata + pub encrypted_metadata: Option>, + /// E2EE: nonce for encrypted metadata + pub encrypted_metadata_nonce: Option>, + /// E2EE: encrypted hash of the file content + pub encrypted_hash: Option, } impl<'a, R, S> UploadFile<'a, R, S> @@ -28,13 +45,14 @@ where R: FilesRepository + ?Sized, S: StorageResolverPort + ?Sized, { + /// Upload a file with optional E2EE metadata. + /// For plaintext files: pass encrypted_* fields as None in FileUploadInput + /// For E2EE files: pass encrypted_* fields with values pub async fn execute( &self, workspace_id: Uuid, doc_id: Uuid, - bytes: Vec, - orig_filename: Option, - content_type: Option, + input: FileUploadInput, ) -> anyhow::Result> { if !self .repo @@ -45,7 +63,7 @@ where } let stored = self .storage - .store_doc_attachment(doc_id, orig_filename.as_deref(), &bytes) + .store_doc_attachment(doc_id, input.orig_filename.as_deref(), &input.bytes) .await .map_err(|err| { tracing::error!(error = ?err, doc_id = %doc_id, "store_doc_attachment_failed"); @@ -53,14 +71,17 @@ where })?; let id = self .repo - .insert_file( + .insert_file(FileInsert { doc_id, - &stored.filename, - content_type.as_deref(), - stored.size, - &stored.relative_path, - &stored.content_hash, - ) + filename: &stored.filename, + content_type: input.content_type.as_deref(), + size: stored.size, + storage_path: &stored.relative_path, + content_hash: &stored.content_hash, + encrypted_metadata: input.encrypted_metadata.as_deref(), + encrypted_metadata_nonce: input.encrypted_metadata_nonce.as_deref(), + encrypted_hash: input.encrypted_hash.as_deref(), + }) .await .map_err(|err| { tracing::error!(error = ?err, doc_id = %doc_id, "insert_file_failed"); @@ -78,10 +99,13 @@ where id, url, filename: stored.filename, - content_type, + content_type: input.content_type, size: stored.size, storage_path, content_hash: stored.content_hash, + encrypted_metadata: input.encrypted_metadata, + encrypted_metadata_nonce: input.encrypted_metadata_nonce, + encrypted_hash: input.encrypted_hash, })) } } diff --git a/api/crates/application/src/documents/use_cases/sharing/create_share.rs b/api/crates/application/src/documents/use_cases/sharing/create_share.rs index 968ef29e..c7a48c41 100644 --- a/api/crates/application/src/documents/use_cases/sharing/create_share.rs +++ b/api/crates/application/src/documents/use_cases/sharing/create_share.rs @@ -9,6 +9,7 @@ pub struct CreateShare<'a, R: SharesRepository + ?Sized> { } pub struct CreateShareResult { + pub share_id: Uuid, pub token: String, pub document_id: Uuid, pub document_type: DocumentType, @@ -28,6 +29,7 @@ impl<'a, R: SharesRepository + ?Sized> CreateShare<'a, R> { .create_share(workspace_id, actor_id, document_id, permission, expires_at) .await?; Ok(CreateShareResult { + share_id: created.share_id, token: created.token, document_id, document_type: created.document_type, diff --git a/api/crates/domain/src/documents/document.rs b/api/crates/domain/src/documents/document.rs index 6faea782..edfd6790 100644 --- a/api/crates/domain/src/documents/document.rs +++ b/api/crates/domain/src/documents/document.rs @@ -22,6 +22,9 @@ pub struct Document { archived_at: Option>, archived_by: Option, archived_parent_id: Option, + // E2EE fields + encrypted_title: Option>, + encrypted_title_nonce: Option>, } impl Document { @@ -43,6 +46,8 @@ impl Document { archived_at: Option>, archived_by: Option, archived_parent_id: Option, + encrypted_title: Option>, + encrypted_title_nonce: Option>, ) -> Self { Self { id, @@ -61,6 +66,8 @@ impl Document { archived_at, archived_by, archived_parent_id, + encrypted_title, + encrypted_title_nonce, } } @@ -127,6 +134,14 @@ impl Document { pub fn archived_parent_id(&self) -> Option { self.archived_parent_id } + + pub fn encrypted_title(&self) -> Option<&[u8]> { + self.encrypted_title.as_deref() + } + + pub fn encrypted_title_nonce(&self) -> Option<&[u8]> { + self.encrypted_title_nonce.as_deref() + } } #[derive(Debug, Clone)] diff --git a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/helpers.rs b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/helpers.rs index 240ab015..fd052782 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/helpers.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/helpers.rs @@ -64,6 +64,8 @@ impl SqlxDocumentRepository { row.try_get("archived_at").ok(), row.try_get("archived_by").ok(), row.try_get("archived_parent_id").ok(), + row.try_get("encrypted_title").ok(), + row.try_get("encrypted_title_nonce").ok(), )) } diff --git a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs index 3ea889b1..eb9ea775 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/document_repository_sqlx/repository.rs @@ -326,6 +326,26 @@ impl DocumentRepository for SqlxDocumentRepository { .collect::>>() .map_err(DocumentRepositoryError::from) } + + async fn update_encrypted_title( + &self, + doc_id: Uuid, + encrypted_title: Vec, + encrypted_title_nonce: Vec, + ) -> DocumentRepoResult<()> { + sqlx::query( + r#"UPDATE documents + SET encrypted_title = $2, encrypted_title_nonce = $3, updated_at = now() + WHERE id = $1"#, + ) + .bind(doc_id) + .bind(&encrypted_title) + .bind(&encrypted_title_nonce) + .execute(&self.pool) + .await + .map_err(unexpected_sqlx)?; + Ok(()) + } } #[async_trait] diff --git a/api/crates/infrastructure/src/documents/db/repositories/document_snapshot_archive_repository_sqlx/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/document_snapshot_archive_repository_sqlx/mod.rs index 577e6fd6..26fbc350 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/document_snapshot_archive_repository_sqlx/mod.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/document_snapshot_archive_repository_sqlx/mod.rs @@ -47,7 +47,9 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_at, created_by, byte_size, - content_hash"#, + content_hash, + nonce, + signature"#, ) .bind(input.document_id) .bind(input.version as i32) @@ -75,7 +77,9 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_at, created_by, byte_size, - content_hash + content_hash, + nonce, + signature FROM document_snapshot_archives WHERE document_id = $1 AND version = $2"#, ) @@ -97,6 +101,8 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_by: row.try_get("created_by").ok(), byte_size: row.get("byte_size"), content_hash: row.get("content_hash"), + nonce: row.try_get("nonce").ok(), + signature: row.try_get("signature").ok(), }) } .await; @@ -117,7 +123,9 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_at, created_by, byte_size, - content_hash + content_hash, + nonce, + signature FROM document_snapshot_archives WHERE id = $1"#, ) @@ -137,6 +145,8 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_by: row.try_get("created_by").ok(), byte_size: row.get("byte_size"), content_hash: row.get("content_hash"), + nonce: row.try_get("nonce").ok(), + signature: row.try_get("signature").ok(), }, bytes: row.get("snapshot"), })) @@ -163,7 +173,9 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_at, created_by, byte_size, - content_hash + content_hash, + nonce, + signature FROM document_snapshot_archives WHERE document_id = $1 ORDER BY created_at DESC @@ -188,6 +200,8 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_by: row.try_get("created_by").ok(), byte_size: row.get("byte_size"), content_hash: row.get("content_hash"), + nonce: row.try_get("nonce").ok(), + signature: row.try_get("signature").ok(), }) .collect()) } @@ -213,7 +227,9 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_at, created_by, byte_size, - content_hash + content_hash, + nonce, + signature FROM document_snapshot_archives WHERE document_id = $1 AND version < $2 ORDER BY version DESC @@ -236,6 +252,8 @@ impl DocumentSnapshotArchiveRepository for SqlxDocumentSnapshotArchiveRepository created_by: row.try_get("created_by").ok(), byte_size: row.get("byte_size"), content_hash: row.get("content_hash"), + nonce: row.try_get("nonce").ok(), + signature: row.try_get("signature").ok(), }, bytes: row.get("snapshot"), })) diff --git a/api/crates/infrastructure/src/documents/db/repositories/encrypted_tag_repository_sqlx/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/encrypted_tag_repository_sqlx/mod.rs new file mode 100644 index 00000000..bf3d3d3c --- /dev/null +++ b/api/crates/infrastructure/src/documents/db/repositories/encrypted_tag_repository_sqlx/mod.rs @@ -0,0 +1,189 @@ +use async_trait::async_trait; +use sqlx::Row; +use uuid::Uuid; + +use crate::core::db::PgPool; +use application::core::ports::errors::PortResult; +use application::documents::ports::tagging::encrypted_tag_repository::{ + EncryptedTagEntry, EncryptedTagRepository, EncryptedTagSummary, +}; + +pub struct SqlxEncryptedTagRepository { + pool: PgPool, +} + +impl SqlxEncryptedTagRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl EncryptedTagRepository for SqlxEncryptedTagRepository { + async fn list_encrypted_tags( + &self, + workspace_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT encrypted_tag, COUNT(*) as count + FROM encrypted_tag_index + WHERE workspace_id = $1 + GROUP BY encrypted_tag + ORDER BY count DESC"#, + ) + .bind(workspace_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| EncryptedTagSummary { + encrypted_tag: row.get("encrypted_tag"), + count: row.get("count"), + }) + .collect()) + } + .await; + out.map_err(Into::into) + } + + async fn list_document_encrypted_tags( + &self, + document_id: Uuid, + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT id, workspace_id, document_id, encrypted_tag, created_at + FROM encrypted_tag_index + WHERE document_id = $1 + ORDER BY created_at"#, + ) + .bind(document_id) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| EncryptedTagEntry { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + document_id: row.get("document_id"), + encrypted_tag: row.get("encrypted_tag"), + created_at: row.get("created_at"), + }) + .collect()) + } + .await; + out.map_err(Into::into) + } + + async fn replace_document_encrypted_tags( + &self, + workspace_id: Uuid, + document_id: Uuid, + encrypted_tags: &[Vec], + ) -> PortResult> { + let out: anyhow::Result> = async { + // Use a transaction for atomicity + let mut tx = self.pool.begin().await?; + + // Delete existing tags for this document + sqlx::query(r#"DELETE FROM encrypted_tag_index WHERE document_id = $1"#) + .bind(document_id) + .execute(&mut *tx) + .await?; + + // Insert new tags + let mut entries = Vec::with_capacity(encrypted_tags.len()); + for encrypted_tag in encrypted_tags { + let row = sqlx::query( + r#"INSERT INTO encrypted_tag_index (workspace_id, document_id, encrypted_tag) + VALUES ($1, $2, $3) + RETURNING id, workspace_id, document_id, encrypted_tag, created_at"#, + ) + .bind(workspace_id) + .bind(document_id) + .bind(encrypted_tag) + .fetch_one(&mut *tx) + .await?; + + entries.push(EncryptedTagEntry { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + document_id: row.get("document_id"), + encrypted_tag: row.get("encrypted_tag"), + created_at: row.get("created_at"), + }); + } + + tx.commit().await?; + Ok(entries) + } + .await; + out.map_err(Into::into) + } + + async fn find_documents_by_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: &[u8], + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT DISTINCT document_id + FROM encrypted_tag_index + WHERE workspace_id = $1 AND encrypted_tag = $2"#, + ) + .bind(workspace_id) + .bind(encrypted_tag) + .fetch_all(&self.pool) + .await?; + + Ok(rows.into_iter().map(|row| row.get("document_id")).collect()) + } + .await; + out.map_err(Into::into) + } + + async fn find_encrypted_tag( + &self, + workspace_id: Uuid, + encrypted_tag: &[u8], + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT encrypted_tag, COUNT(*) as count + FROM encrypted_tag_index + WHERE workspace_id = $1 AND encrypted_tag = $2 + GROUP BY encrypted_tag"#, + ) + .bind(workspace_id) + .bind(encrypted_tag) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| EncryptedTagSummary { + encrypted_tag: row.get("encrypted_tag"), + count: row.get("count"), + }) + .collect()) + } + .await; + out.map_err(Into::into) + } + + async fn delete_document_encrypted_tags(&self, document_id: Uuid) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query(r#"DELETE FROM encrypted_tag_index WHERE document_id = $1"#) + .bind(document_id) + .execute(&self.pool) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/infrastructure/src/documents/db/repositories/files_repository_sqlx/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/files_repository_sqlx/mod.rs index ece2503c..aa7e89c9 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/files_repository_sqlx/mod.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/files_repository_sqlx/mod.rs @@ -5,7 +5,7 @@ use uuid::Uuid; use crate::core::db::PgPool; use application::core::ports::errors::PortResult; use application::documents::ports::files::files_repository::{ - FileMeta, FilePathMeta, FileRecord, FilesRepository, StoredFileScope, + FileInsert, FileMeta, FilePathMeta, FileRecord, FilesRepository, StoredFileScope, }; pub struct SqlxFilesRepository { @@ -50,27 +50,25 @@ impl FilesRepository for SqlxFilesRepository { out.map_err(Into::into) } - async fn insert_file( - &self, - doc_id: Uuid, - filename: &str, - content_type: Option<&str>, - size: i64, - storage_path: &str, - content_hash: &str, - ) -> PortResult { + async fn insert_file(&self, input: FileInsert<'_>) -> PortResult { let out: anyhow::Result = async { let row = sqlx::query( - r#"INSERT INTO files (document_id, filename, content_type, size, storage_path, content_hash) - VALUES ($1, $2, $3, $4, $5, $6) - RETURNING id"#, + r#"INSERT INTO files ( + document_id, filename, content_type, size, storage_path, content_hash, + encrypted_metadata, encrypted_metadata_nonce, encrypted_hash + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id"#, ) - .bind(doc_id) - .bind(filename) - .bind(content_type) - .bind(size) - .bind(storage_path) - .bind(content_hash) + .bind(input.doc_id) + .bind(input.filename) + .bind(input.content_type) + .bind(input.size) + .bind(input.storage_path) + .bind(input.content_hash) + .bind(input.encrypted_metadata) + .bind(input.encrypted_metadata_nonce) + .bind(input.encrypted_hash) .fetch_one(&self.pool) .await?; Ok(row.get("id")) @@ -82,7 +80,8 @@ impl FilesRepository for SqlxFilesRepository { async fn get_file_meta(&self, file_id: Uuid) -> PortResult> { let out: anyhow::Result> = async { let row = sqlx::query( - r#"SELECT f.storage_path, f.content_type, f.document_id, d.workspace_id + r#"SELECT f.storage_path, f.content_type, f.document_id, d.workspace_id, + f.encrypted_metadata, f.encrypted_metadata_nonce, f.encrypted_hash FROM files f JOIN documents d ON f.document_id = d.id WHERE f.id = $1"#, ) @@ -94,6 +93,9 @@ impl FilesRepository for SqlxFilesRepository { content_type: r.try_get("content_type").ok(), document_id: r.get("document_id"), workspace_id: r.get("workspace_id"), + encrypted_metadata: r.try_get("encrypted_metadata").ok(), + encrypted_metadata_nonce: r.try_get("encrypted_metadata_nonce").ok(), + encrypted_hash: r.try_get("encrypted_hash").ok(), })) } .await; @@ -140,7 +142,8 @@ impl FilesRepository for SqlxFilesRepository { async fn list_files_for_document(&self, doc_id: Uuid) -> PortResult> { let out: anyhow::Result> = async { let rows = sqlx::query( - r#"SELECT id, filename, content_type, size, storage_path, content_hash + r#"SELECT id, filename, content_type, size, storage_path, content_hash, + encrypted_metadata, encrypted_metadata_nonce, encrypted_hash FROM files WHERE document_id = $1"#, ) @@ -156,6 +159,9 @@ impl FilesRepository for SqlxFilesRepository { size: r.get("size"), storage_path: r.get("storage_path"), content_hash: r.get("content_hash"), + encrypted_metadata: r.try_get("encrypted_metadata").ok(), + encrypted_metadata_nonce: r.try_get("encrypted_metadata_nonce").ok(), + encrypted_hash: r.try_get("encrypted_hash").ok(), }) .collect()) } @@ -262,4 +268,5 @@ impl FilesRepository for SqlxFilesRepository { .await; out.map_err(Into::into) } + } diff --git a/api/crates/infrastructure/src/documents/db/repositories/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/mod.rs index 0779757b..65d47257 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/mod.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/mod.rs @@ -2,6 +2,7 @@ pub mod access_repository_sqlx; pub mod document_keys_repository_sqlx; pub mod document_repository_sqlx; pub mod document_snapshot_archive_repository_sqlx; +pub mod encrypted_tag_repository_sqlx; pub mod files_repository_sqlx; pub mod linkgraph_repository_sqlx; pub mod public_repository_sqlx; diff --git a/api/crates/infrastructure/src/documents/db/repositories/public_repository_sqlx/mod.rs b/api/crates/infrastructure/src/documents/db/repositories/public_repository_sqlx/mod.rs index 002ce43c..88ec1f55 100644 --- a/api/crates/infrastructure/src/documents/db/repositories/public_repository_sqlx/mod.rs +++ b/api/crates/infrastructure/src/documents/db/repositories/public_repository_sqlx/mod.rs @@ -6,7 +6,8 @@ use uuid::Uuid; use crate::core::db::PgPool; use application::core::ports::errors::PortResult; use application::documents::ports::publishing::public_repository::{ - PublicDocumentSummaryRow, PublicRepository, PublishStatusRow, WorkspaceTitleAndSlug, + PublicContentRow, PublicDocumentSummaryRow, PublicRepository, PublishStatusRow, + WorkspaceTitleAndSlug, }; use domain::documents::doc_type::DocumentType; use domain::documents::document::Document; @@ -214,6 +215,8 @@ impl PublicRepository for SqlxPublicRepository { r.try_get("archived_at").ok(), r.try_get("archived_by").ok(), r.try_get("archived_parent_id").ok(), + r.try_get("encrypted_title").ok(), + r.try_get("encrypted_title_nonce").ok(), )) }) .transpose() @@ -250,4 +253,67 @@ impl PublicRepository for SqlxPublicRepository { .await; out.map_err(Into::into) } + + async fn store_public_content( + &self, + doc_id: Uuid, + title: &str, + content: &str, + content_hash: &str, + ) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query( + r#"INSERT INTO public_document_contents (document_id, title, content, content_hash, updated_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (document_id) DO UPDATE SET + title = EXCLUDED.title, + content = EXCLUDED.content, + content_hash = EXCLUDED.content_hash, + updated_at = now()"#, + ) + .bind(doc_id) + .bind(title) + .bind(content) + .bind(content_hash) + .execute(&self.pool) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } + + async fn get_public_content(&self, doc_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + let row = sqlx::query( + r#"SELECT document_id, title, content, content_hash, updated_at + FROM public_document_contents + WHERE document_id = $1"#, + ) + .bind(doc_id) + .fetch_optional(&self.pool) + .await?; + Ok(row.map(|r| PublicContentRow { + document_id: r.get("document_id"), + title: r.get("title"), + content: r.get("content"), + content_hash: r.get("content_hash"), + updated_at: r.get("updated_at"), + })) + } + .await; + out.map_err(Into::into) + } + + async fn delete_public_content(&self, doc_id: Uuid) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query(r#"DELETE FROM public_document_contents WHERE document_id = $1"#) + .bind(doc_id) + .execute(&self.pool) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } } diff --git a/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs b/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs index 1e3826ce..13850053 100644 --- a/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs +++ b/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs @@ -5,7 +5,8 @@ use uuid::Uuid; use crate::core::db::PgPool; use application::core::ports::errors::PortResult; use application::documents::ports::realtime::realtime_persistence_port::{ - DocPersistencePort, DocumentMissingError, SnapshotEntry, + ContentEncryptionMeta, DocPersistencePort, DocumentMissingError, EncryptedUpdateData, + SnapshotEntry, }; #[derive(Clone)] @@ -42,6 +43,31 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { out.map_err(Into::into) } + async fn append_encrypted_update_with_seq( + &self, + doc_id: &Uuid, + seq: i64, + update: &EncryptedUpdateData, + ) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query( + r#"INSERT INTO document_updates (document_id, seq, update, nonce, signature, public_key) + VALUES ($1, $2, $3, $4, $5, $6)"#, + ) + .bind(doc_id) + .bind(seq) + .bind(&update.data) + .bind(update.nonce.as_deref()) + .bind(update.signature.as_deref()) + .bind(update.public_key.as_deref()) + .execute(&self.pool) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } + async fn latest_update_seq(&self, doc_id: &Uuid) -> PortResult> { let out: anyhow::Result> = async { let row = sqlx::query( @@ -61,15 +87,21 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { doc_id: &Uuid, version: i64, snapshot: &[u8], + encryption_meta: Option<&ContentEncryptionMeta>, ) -> PortResult<()> { let out: anyhow::Result<()> = async { + let (nonce, signature) = encryption_meta + .map(|m| (m.nonce.as_deref(), m.signature.as_deref())) + .unwrap_or((None, None)); let result = sqlx::query( - "INSERT INTO document_snapshots (document_id, version, snapshot) VALUES ($1, $2, $3) - ON CONFLICT (document_id, version) DO UPDATE SET snapshot = EXCLUDED.snapshot", + "INSERT INTO document_snapshots (document_id, version, snapshot, nonce, signature) VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (document_id, version) DO UPDATE SET snapshot = EXCLUDED.snapshot, nonce = EXCLUDED.nonce, signature = EXCLUDED.signature", ) .bind(doc_id) .bind(version as i32) .bind(snapshot) + .bind(nonce) + .bind(signature) .execute(&self.pool) .await; @@ -96,7 +128,7 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { async fn latest_snapshot_entry(&self, doc_id: &Uuid) -> PortResult> { let out: anyhow::Result> = async { let row = sqlx::query( - "SELECT version, snapshot FROM document_snapshots WHERE document_id = $1 + "SELECT version, snapshot, nonce, signature FROM document_snapshots WHERE document_id = $1 ORDER BY version DESC LIMIT 1", ) .bind(doc_id) @@ -105,6 +137,8 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { Ok(row.map(|row| SnapshotEntry { version: row.get::("version") as i64, bytes: row.get("snapshot"), + nonce: row.try_get("nonce").ok(), + signature: row.try_get("signature").ok(), })) } .await; diff --git a/api/crates/infrastructure/src/documents/realtime/hub.rs b/api/crates/infrastructure/src/documents/realtime/hub.rs index 68723993..a968cc09 100644 --- a/api/crates/infrastructure/src/documents/realtime/hub.rs +++ b/api/crates/infrastructure/src/documents/realtime/hub.rs @@ -394,6 +394,98 @@ impl Hub { let txn = hydrated.doc.transact(); Ok(Some(txt.get_string(&txn))) } + + /// Get Yjs snapshot with E2EE metadata (nonce, signature) + pub async fn get_snapshot( + &self, + doc_id: &str, + ) -> anyhow::Result> + { + use application::documents::ports::realtime::realtime_port::SnapshotData; + + let uuid = match Uuid::parse_str(doc_id) { + Ok(id) => id, + Err(_) => return Ok(None), + }; + + // First try to get from in-memory room (for current state) + if let Some(room) = self.inner.read().await.get(doc_id).cloned() { + let txn = room.doc.transact(); + let data = txn.encode_state_as_update_v1(&yrs::StateVector::default()); + + // Get nonce/signature from DB if available + if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { + return Ok(Some(SnapshotData { + data, + nonce: entry.nonce, + signature: entry.signature, + })); + } + + return Ok(Some(SnapshotData { + data, + nonce: None, + signature: None, + })); + } + + // No in-memory room, get from persistence directly + if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { + return Ok(Some(SnapshotData { + data: entry.bytes, + nonce: entry.nonce, + signature: entry.signature, + })); + } + + // Fallback: hydrate and encode + let hydrated = self + .hydration_service + .hydrate(&uuid, HydrationOptions::default()) + .await?; + let txn = hydrated.doc.transact(); + Ok(Some(SnapshotData { + data: txn.encode_state_as_update_v1(&yrs::StateVector::default()), + nonce: None, + signature: None, + })) + } + + /// Apply encrypted update for E2EE documents + /// This stores the encrypted update data directly without processing it in Yjs + pub async fn apply_encrypted_update( + &self, + doc_id: &str, + data: &[u8], + nonce: Option<&[u8]>, + ) -> anyhow::Result<()> { + use application::documents::ports::realtime::realtime_persistence_port::EncryptedUpdateData; + + let doc_uuid = Uuid::parse_str(doc_id)?; + + // Get the current seq number (create room if needed to track seq) + let room = self.get_or_create(doc_id).await?; + let seq = { + let mut guard = room.seq.lock().await; + *guard += 1; + *guard + }; + + // Store the encrypted update with metadata + let update_data = EncryptedUpdateData { + data: data.to_vec(), + nonce: nonce.map(|n| n.to_vec()), + signature: None, + public_key: None, + }; + + self.persistence + .append_encrypted_update_with_seq(&doc_uuid, seq, &update_data) + .await + .map_err(|e| anyhow::anyhow!("failed to persist encrypted update: {:?}", e))?; + + Ok(()) + } } impl Hub { diff --git a/api/crates/infrastructure/src/documents/realtime/local_engine.rs b/api/crates/infrastructure/src/documents/realtime/local_engine.rs index 79304df2..62234a88 100644 --- a/api/crates/infrastructure/src/documents/realtime/local_engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/local_engine.rs @@ -1,5 +1,7 @@ use application::core::ports::errors::PortResult; -use application::documents::ports::realtime::realtime_port::RealtimeEngine; +use application::documents::ports::realtime::realtime_port::{ + EncryptedUpdate, RealtimeEngine, SnapshotData, +}; use application::documents::ports::realtime::realtime_types::{DynRealtimeSink, DynRealtimeStream}; use application::documents::services::realtime::snapshot::doc_from_snapshot_bytes; @@ -26,6 +28,10 @@ impl RealtimeEngine for LocalRealtimeEngine { self.hub.get_content(doc_id).await.map_err(Into::into) } + async fn get_snapshot(&self, doc_id: &str) -> PortResult> { + self.hub.get_snapshot(doc_id).await.map_err(Into::into) + } + async fn force_persist(&self, doc_id: &str) -> PortResult<()> { self.hub.force_save_to_fs(doc_id).await.map_err(Into::into) } @@ -45,4 +51,20 @@ impl RealtimeEngine for LocalRealtimeEngine { .await .map_err(Into::into) } + + async fn apply_encrypted_updates( + &self, + doc_id: &str, + updates: &[EncryptedUpdate], + ) -> PortResult<()> { + // For E2EE documents, we apply updates as encrypted snapshots + // The hub will store the data without decrypting + for update in updates { + self.hub + .apply_encrypted_update(doc_id, &update.data, update.nonce.as_deref()) + .await + .map_err(|e| application::core::ports::errors::PortError::from(e))?; + } + Ok(()) + } } diff --git a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs index 022552aa..bbf52a5e 100644 --- a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs @@ -37,7 +37,9 @@ use application::documents::ports::realtime::realtime_hydration_port::{ use application::documents::ports::realtime::realtime_persistence_port::{ DocPersistencePort, DocumentMissingError, }; -use application::documents::ports::realtime::realtime_port::RealtimeEngine as RealtimeEngineTrait; +use application::documents::ports::realtime::realtime_port::{ + EncryptedUpdate, RealtimeEngine as RealtimeEngineTrait, SnapshotData, +}; use application::documents::ports::realtime::realtime_types::{DynRealtimeSink, DynRealtimeStream}; use application::documents::ports::tagging::tagging_repository::TaggingRepository; use application::documents::services::realtime::doc_hydration::{ @@ -56,6 +58,7 @@ pub struct RedisRealtimeEngine { bus: Arc, hydration_service: Arc, snapshot_service: Arc, + persistence: Arc, task_debounce: Duration, awareness_ttl: Duration, _worker: Option>, @@ -107,7 +110,7 @@ impl RedisRealtimeEngine { Arc::new(SqlxDocumentSnapshotArchiveRepository::new(pool.clone())); let snapshot_service = Arc::new(SnapshotService::new( doc_state_reader, - doc_persistence, + doc_persistence.clone(), linkgraph_repo, tagging_repo, archive_repo, @@ -137,6 +140,7 @@ impl RedisRealtimeEngine { bus, hydration_service, snapshot_service, + persistence: doc_persistence, task_debounce: Duration::from_millis(cfg.task_debounce_ms), awareness_ttl: Duration::from_millis(cfg.awareness_ttl_ms), _worker: worker, @@ -398,6 +402,31 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { Ok(Some(txt.get_string(&txn))) } + async fn get_snapshot(&self, doc_id: &str) -> PortResult> { + let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; + + // Try to get from persistence first (for E2EE documents with stored nonce/signature) + if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { + return Ok(Some(SnapshotData { + data: entry.bytes, + nonce: entry.nonce, + signature: entry.signature, + })); + } + + // Fallback: hydrate and encode (no nonce/signature) + let hydrated = self + .hydration_service + .hydrate(&uuid, HydrationOptions::default()) + .await?; + let txn = hydrated.doc.transact(); + Ok(Some(SnapshotData { + data: txn.encode_state_as_update_v1(&yrs::StateVector::default()), + nonce: None, + signature: None, + })) + } + async fn force_persist(&self, doc_id: &str) -> PortResult<()> { let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; let hydrated = self @@ -461,6 +490,46 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { flag.store(editable, Ordering::SeqCst); Ok(()) } + + async fn apply_encrypted_updates( + &self, + doc_id: &str, + updates: &[EncryptedUpdate], + ) -> PortResult<()> { + use application::documents::ports::realtime::realtime_persistence_port::EncryptedUpdateData; + + let doc_uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; + + // Get current seq from persistence + let mut seq = self + .persistence + .latest_update_seq(&doc_uuid) + .await? + .unwrap_or(0); + + // Store each encrypted update + for update in updates { + seq += 1; + let update_data = EncryptedUpdateData { + data: update.data.clone(), + nonce: update.nonce.clone(), + signature: update.signature.clone(), + public_key: None, + }; + + self.persistence + .append_encrypted_update_with_seq(&doc_uuid, seq, &update_data) + .await + .map_err(|e| { + application::core::ports::errors::PortError::from(anyhow::anyhow!( + "failed to persist encrypted update: {:?}", + e + )) + })?; + } + + Ok(()) + } } fn spawn_persistence_worker( diff --git a/api/crates/presentation/src/http/documents/files/download.rs b/api/crates/presentation/src/http/documents/files/download.rs index c1aea111..017543de 100644 --- a/api/crates/presentation/src/http/documents/files/download.rs +++ b/api/crates/presentation/src/http/documents/files/download.rs @@ -31,6 +31,7 @@ pub async fn get_file( .download_owned_file(&actor, auth.workspace_id, id) .await .map_err(map_file_error)?; + Ok(file_payload_response(payload)) } diff --git a/api/crates/presentation/src/http/documents/files/mod.rs b/api/crates/presentation/src/http/documents/files/mod.rs index 7fc23e50..6ba08979 100644 --- a/api/crates/presentation/src/http/documents/files/mod.rs +++ b/api/crates/presentation/src/http/documents/files/mod.rs @@ -22,7 +22,7 @@ pub mod openapi { pub fn routes(ctx: AppContext) -> Router { Router::new() - .route("/files", post(upload_file)) + .route("/documents/:doc_id/files", post(upload_file)) .route("/files/:id", get(get_file)) .route("/files/documents/:filename", get(get_file_by_name)) .with_state(ctx) diff --git a/api/crates/presentation/src/http/documents/files/types.rs b/api/crates/presentation/src/http/documents/files/types.rs index 0742e1e3..882be522 100644 --- a/api/crates/presentation/src/http/documents/files/types.rs +++ b/api/crates/presentation/src/http/documents/files/types.rs @@ -9,12 +9,13 @@ use uuid::Uuid; use application::core::services::errors::ServiceError; use application::documents::services::files::FilePayload; +/// Response for file upload (E2EE format per design) #[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct UploadFileResponse { pub id: Uuid, - pub url: String, - pub filename: String, - pub content_type: Option, + /// SHA256 hash of encrypted file content + pub encrypted_hash: String, pub size: i64, } @@ -22,7 +23,10 @@ pub fn map_file_error(err: ServiceError) -> crate::http::error::ApiError { crate::http::error::map_service_error(err, "file_service_error") } +/// File payload response with optional E2EE metadata in headers pub fn file_payload_response(payload: FilePayload) -> axum::response::Response { + use base64::Engine; + let mut headers = HeaderMap::new(); if let Some(ct) = payload.content_type { headers.insert( @@ -35,16 +39,60 @@ pub fn file_payload_response(payload: FilePayload) -> axum::response::Response { axum::http::header::HeaderName::from_static("x-content-type-options"), HeaderValue::from_static("nosniff"), ); + + // Add E2EE metadata headers if present + if let Some(encrypted_metadata) = payload.encrypted_metadata { + let encoded = base64::engine::general_purpose::STANDARD.encode(&encrypted_metadata); + if let Ok(val) = HeaderValue::from_str(&encoded) { + headers.insert( + axum::http::header::HeaderName::from_static("x-encrypted-metadata"), + val, + ); + } + } + if let Some(nonce) = payload.encrypted_metadata_nonce { + let encoded = base64::engine::general_purpose::STANDARD.encode(&nonce); + if let Ok(val) = HeaderValue::from_str(&encoded) { + headers.insert( + axum::http::header::HeaderName::from_static("x-encrypted-metadata-nonce"), + val, + ); + } + } + if let Some(hash) = payload.encrypted_hash { + if let Ok(val) = HeaderValue::from_str(&hash) { + headers.insert( + axum::http::header::HeaderName::from_static("x-encrypted-hash"), + val, + ); + } + } + (headers, payload.bytes).into_response() } +/// Multipart upload schema for OpenAPI #[derive(ToSchema)] #[allow(dead_code)] pub struct UploadFileMultipart { + /// Encrypted file binary (.rme format) #[schema(value_type = String, format = Binary)] pub file: String, - #[schema(value_type = String, format = Uuid)] - pub document_id: String, + /// JSON metadata containing encrypted file metadata + #[schema(value_type = Option)] + pub metadata: Option, +} + +/// Metadata JSON structure for file upload +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FileUploadMetadata { + /// Base64 encoded encrypted metadata + pub encrypted_metadata: Option, + /// Base64 encoded nonce for encrypted metadata + pub encrypted_metadata_nonce: Option, + /// Client-computed hash of encrypted file content (SHA256) + pub encrypted_hash: Option, } #[derive(Debug, Deserialize)] diff --git a/api/crates/presentation/src/http/documents/files/upload.rs b/api/crates/presentation/src/http/documents/files/upload.rs index 4d609a32..a77f7ece 100644 --- a/api/crates/presentation/src/http/documents/files/upload.rs +++ b/api/crates/presentation/src/http/documents/files/upload.rs @@ -1,21 +1,24 @@ use axum::{ Json, - extract::{Multipart, State}, + extract::{Multipart, Path, State}, http::StatusCode, }; +use base64::Engine; use uuid::Uuid; use crate::context::DocumentsContext; use crate::http::error::ApiError; use crate::http::extractors::WorkspaceAuth; +use application::documents::use_cases::files::upload_file::FileUploadInput; use domain::access::permissions::PERM_FILE_UPLOAD; -use super::types::{UploadFileResponse, map_file_error}; +use super::types::{FileUploadMetadata, UploadFileResponse, map_file_error}; #[utoipa::path( post, - path = "/api/files", + path = "/api/documents/{docId}/files", tag = "Files", + params(("docId" = Uuid, Path, description = "Document ID")), request_body( content = UploadFileMultipart, content_type = "multipart/form-data", @@ -27,14 +30,15 @@ use super::types::{UploadFileResponse, map_file_error}; pub async fn upload_file( State(ctx): State, auth: WorkspaceAuth, + Path(doc_id): Path, mut multipart: Multipart, ) -> Result<(StatusCode, Json), ApiError> { auth.ensure_permission(PERM_FILE_UPLOAD)?; - let mut document_id: Option = None; let mut file_bytes: Option> = None; let mut orig_filename: Option = None; let mut content_type: Option = None; + let mut metadata: Option = None; while let Some(field) = multipart .next_field() @@ -45,16 +49,6 @@ pub async fn upload_file( let file_name = field.file_name().map(|s| s.to_string()); let ct = field.content_type().map(|s| s.to_string()); match name.as_deref() { - Some("document_id") => { - let t = field - .text() - .await - .map_err(|_| ApiError::bad_request("invalid_document_id"))?; - document_id = Some( - Uuid::parse_str(t.trim()) - .map_err(|_| ApiError::bad_request("invalid_document_id"))?, - ); - } Some("file") => { orig_filename = file_name.clone(); content_type = ct.clone(); @@ -70,34 +64,59 @@ pub async fn upload_file( } file_bytes = Some(data.to_vec()); } + Some("metadata") => { + let text = field + .text() + .await + .map_err(|_| ApiError::bad_request("invalid_metadata"))?; + metadata = serde_json::from_str(&text) + .map_err(|_| ApiError::bad_request("invalid_metadata_json"))?; + } _ => {} } } - let doc_id = document_id.ok_or(ApiError::bad_request("missing_document_id"))?; let bytes = file_bytes.ok_or(ApiError::bad_request("missing_file"))?; + // Extract E2EE fields from metadata + let (encrypted_metadata, encrypted_metadata_nonce, encrypted_hash) = if let Some(m) = metadata { + let em = m.encrypted_metadata.and_then(|s| { + base64::engine::general_purpose::STANDARD + .decode(&s) + .ok() + }); + let emn = m.encrypted_metadata_nonce.and_then(|s| { + base64::engine::general_purpose::STANDARD + .decode(&s) + .ok() + }); + (em, emn, m.encrypted_hash) + } else { + (None, None, None) + }; + let public_base_url = ctx.cfg.public_base_url.clone(); let file_service = ctx.file_service(); + + // Upload file with optional E2EE metadata + let input = FileUploadInput { + bytes, + orig_filename, + content_type, + encrypted_metadata, + encrypted_metadata_nonce, + encrypted_hash: encrypted_hash.clone(), + }; let f = file_service - .upload_file( - auth.workspace_id, - auth.user_id, - doc_id, - bytes, - orig_filename, - content_type.clone(), - public_base_url, - ) + .upload_file(auth.workspace_id, auth.user_id, doc_id, input, public_base_url) .await .map_err(map_file_error)?; + Ok(( StatusCode::CREATED, Json(UploadFileResponse { id: f.id, - url: f.url, - filename: f.filename, - content_type: f.content_type, + encrypted_hash: f.encrypted_hash.unwrap_or_else(|| f.content_hash), size: f.size, }), )) diff --git a/api/crates/presentation/src/http/documents/handlers/content.rs b/api/crates/presentation/src/http/documents/handlers/content.rs index 9a0b15c0..13122352 100644 --- a/api/crates/presentation/src/http/documents/handlers/content.rs +++ b/api/crates/presentation/src/http/documents/handlers/content.rs @@ -4,7 +4,6 @@ use axum::{ http::{HeaderMap, HeaderValue, StatusCode}, response::{IntoResponse, Response}, }; -use serde_json::{Value, json}; use uuid::Uuid; use crate::context::DocumentsContext; @@ -17,24 +16,40 @@ use application::documents::services::DocumentPatchOperation; #[allow(unused_imports)] use crate::http::documents::types::{ - Document, DocumentArchiveBinary, DocumentDownloadBinary, DownloadDocumentQuery, DownloadFormat, - PatchDocumentContentRequest, SnapshotTokenQuery, UpdateDocumentContentRequest, - map_service_error, to_http_document, + Document, DocumentArchiveBinary, DocumentDownloadBinary, DocumentPatchOperationRequest, + DownloadDocumentQuery, DownloadFormat, GetContentResponse, PatchDocumentContentRequest, + SnapshotTokenQuery, UpdateDocumentContentRequest, map_service_error, to_http_document, }; -#[utoipa::path(get, path = "/api/documents/{id}/content", tag = "Documents", params(("id" = Uuid, Path, description = "Document ID"),), responses((status = 200)))] +#[utoipa::path( + get, + path = "/api/documents/{id}/content", + tag = "Documents", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, body = GetContentResponse)) +)] pub async fn get_document_content( State(ctx): State, auth: AuthedUser, Path(id): Path, -) -> Result, ApiError> { +) -> Result, ApiError> { + use base64::Engine; + let actor = access::Actor::User(auth.user_id); let service = ctx.document_service(); + + // Return Yjs snapshot bytes as Base64 let content = service .get_content(&actor, id) .await .map_err(map_service_error)?; - Ok(Json(json!({"content": content}))) + + Ok(Json(GetContentResponse { + content: base64::engine::general_purpose::STANDARD.encode(&content.content), + nonce: content + .nonce + .map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + })) } #[utoipa::path( @@ -55,6 +70,8 @@ pub async fn update_document_content( q: Option>, Json(body): Json, ) -> Result, ApiError> { + use base64::Engine; + let params = q.map(|Query(v)| v).unwrap_or_default(); let token = params.token.as_deref(); let actor = token::resolve_actor_from_parts(&ctx, bearer, token) @@ -62,10 +79,44 @@ pub async fn update_document_content( .map_err(token::map_actor_error)? .ok_or(ApiError::unauthorized("unauthorized"))?; let service = ctx.document_service(); - let updated = service - .update_content(&actor, id, &body.content) - .await - .map_err(map_service_error)?; + + // Check if this is an E2EE update (nonce provided) + let updated = if body.nonce.is_some() { + // E2EE mode: content is Base64 encoded encrypted Yjs state + let content_bytes = base64::engine::general_purpose::STANDARD + .decode(&body.content) + .map_err(|_| ApiError::bad_request("invalid_content_base64"))?; + let nonce_bytes = body + .nonce + .as_ref() + .map(|s| { + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| ApiError::bad_request("invalid_nonce_base64")) + }) + .transpose()?; + let signature_bytes = body + .signature + .as_ref() + .map(|s| { + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| ApiError::bad_request("invalid_signature_base64")) + }) + .transpose()?; + + service + .update_content(&actor, id, &content_bytes, nonce_bytes.as_deref(), signature_bytes.as_deref()) + .await + .map_err(map_service_error)? + } else { + // Plaintext mode: content is markdown + service + .update_content_from_markdown(&actor, id, &body.content) + .await + .map_err(map_service_error)? + }; + Ok(Json(to_http_document(updated))) } @@ -87,9 +138,9 @@ pub async fn patch_document_content( q: Option>, Json(body): Json, ) -> Result, ApiError> { - if body.operations.is_empty() { - return Err(ApiError::bad_request("missing_operations")); - } + use application::documents::ports::realtime::realtime_port::EncryptedUpdate; + use base64::Engine; + let params = q.map(|Query(v)| v).unwrap_or_default(); let token = params.token.as_deref(); let actor = token::resolve_actor_from_parts(&ctx, bearer, token) @@ -97,15 +148,74 @@ pub async fn patch_document_content( .map_err(token::map_actor_error)? .ok_or(ApiError::unauthorized("unauthorized"))?; let service = ctx.document_service(); - let operations: Vec = body - .operations - .into_iter() - .map(DocumentPatchOperation::from) - .collect(); - let updated = service - .patch_content(&actor, id, &operations) - .await - .map_err(map_service_error)?; + + if body.operations.is_empty() { + return Err(ApiError::bad_request("missing_operations")); + } + + // Check if any operation has encrypted_data (E2EE mode) + let has_encrypted = body.operations.iter().any(|op| op.is_encrypted()); + + let updated = if has_encrypted { + // E2EE mode: convert operations with encrypted_data to EncryptedUpdate + let encrypted_updates: Vec = body + .operations + .iter() + .filter_map(|op| { + match op { + DocumentPatchOperationRequest::Insert { + encrypted_data: Some(encrypted_data), + nonce, + .. + } + | DocumentPatchOperationRequest::Replace { + encrypted_data: Some(encrypted_data), + nonce, + .. + } => { + let data = base64::engine::general_purpose::STANDARD + .decode(encrypted_data) + .ok()?; + let nonce_bytes = nonce.as_ref().and_then(|n| { + base64::engine::general_purpose::STANDARD.decode(n).ok() + }); + Some(EncryptedUpdate { + data, + nonce: nonce_bytes, + signature: None, + }) + } + _ => None, + } + }) + .collect(); + + if encrypted_updates.is_empty() { + return Err(ApiError::bad_request("no_encrypted_data_in_operations")); + } + + service + .patch_content(&actor, id, None, Some(&encrypted_updates)) + .await + .map_err(map_service_error)? + } else { + // Plaintext mode: convert operations with text to DocumentPatchOperation + let plaintext_operations: Vec = body + .operations + .iter() + .filter_map(|op| op.to_plaintext_operation()) + .collect(); + + if plaintext_operations.is_empty() { + return Err(ApiError::bad_request("no_text_in_operations")); + } + + service + .patch_content(&actor, id, Some(&plaintext_operations), None) + .await + .map_err(map_service_error)? + }; + Ok(Json(to_http_document(updated))) } diff --git a/api/crates/presentation/src/http/documents/handlers/crud.rs b/api/crates/presentation/src/http/documents/handlers/crud.rs index b89ef78f..ef5a97b3 100644 --- a/api/crates/presentation/src/http/documents/handlers/crud.rs +++ b/api/crates/presentation/src/http/documents/handlers/crud.rs @@ -54,12 +54,35 @@ pub async fn create_document( auth: WorkspaceAuth, Json(req): Json, ) -> Result, ApiError> { + use base64::Engine; + let title = req.title.unwrap_or_else(|| "Untitled".into()); let dtype = req .r#type .unwrap_or_else(|| DocumentType::Document.as_str().to_string()); let doc_type = DocumentType::try_from(dtype.as_str()) .map_err(|_| ApiError::bad_request("invalid_document_type"))?; + + // Decode E2EE fields if provided + let encrypted_title = req + .encrypted_title + .as_ref() + .map(|s| { + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| ApiError::bad_request("invalid_encrypted_title_base64")) + }) + .transpose()?; + let encrypted_title_nonce = req + .encrypted_title_nonce + .as_ref() + .map(|s| { + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(|_| ApiError::bad_request("invalid_encrypted_title_nonce_base64")) + }) + .transpose()?; + let service = ctx.document_service(); let doc = service .create_for_user( @@ -74,6 +97,30 @@ pub async fn create_document( .await .map_err(map_service_error)?; + // Store DEK if provided (E2EE mode) + if let Some(dek_payload) = req.dek { + let (encrypted_dek, nonce, key_version) = dek_payload + .decode() + .map_err(|e| ApiError::bad_request(e))?; + let keys_service = ctx.document_keys_service(); + keys_service + .store_document_key(doc.id(), encrypted_dek, nonce, key_version) + .await + .map_err(|e| { + tracing::error!(error = ?e, "failed_to_store_document_key"); + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "failed_to_store_document_key") + })?; + } + + // Store encrypted title if provided (E2EE mode) + // TODO: In the future, combine this with document creation in a single transaction + if let (Some(enc_title), Some(enc_nonce)) = (encrypted_title, encrypted_title_nonce) { + service + .update_encrypted_title(doc.id(), enc_title, enc_nonce) + .await + .map_err(map_service_error)?; + } + Ok(Json(to_http_document(doc))) } diff --git a/api/crates/presentation/src/http/documents/handlers/snapshots.rs b/api/crates/presentation/src/http/documents/handlers/snapshots.rs index 4966c011..3ef37865 100644 --- a/api/crates/presentation/src/http/documents/handlers/snapshots.rs +++ b/api/crates/presentation/src/http/documents/handlers/snapshots.rs @@ -12,9 +12,9 @@ use crate::security::token::{self, Bearer}; #[allow(unused_imports)] use crate::http::documents::types::{ - DocumentArchiveBinary, SnapshotDiffBaseParam, SnapshotDiffQuery, SnapshotDiffResponse, - SnapshotListResponse, SnapshotRestoreResponse, SnapshotTokenQuery, map_service_error, - snapshot_diff_side_response_from, snapshot_summary_from, + DocumentArchiveBinary, SnapshotDetailResponse, SnapshotDiffBaseParam, SnapshotDiffQuery, + SnapshotDiffResponse, SnapshotListResponse, SnapshotRestoreResponse, SnapshotTokenQuery, + map_service_error, snapshot_diff_side_response_from, snapshot_summary_from, }; #[utoipa::path( @@ -55,6 +55,48 @@ pub async fn list_document_snapshots( Ok(Json(SnapshotListResponse { items })) } +#[utoipa::path( + get, + path = "/api/documents/{id}/snapshots/{snapshot_id}", + tag = "Documents", + params( + ("id" = Uuid, Path, description = "Document ID"), + ("snapshot_id" = Uuid, Path, description = "Snapshot ID"), + ("token" = Option, Query, description = "Share token (optional)") + ), + responses((status = 200, body = SnapshotDetailResponse)) +)] +pub async fn get_document_snapshot( + State(ctx): State, + bearer: Option, + Path((id, snapshot_id)): Path<(Uuid, Uuid)>, + q: Option>, +) -> Result, ApiError> { + use base64::Engine; + + let params = q.map(|Query(v)| v).unwrap_or_default(); + let token = params.token.as_deref(); + let actor = token::resolve_actor_from_parts(&ctx, bearer, token) + .await + .map_err(token::map_actor_error)? + .ok_or(ApiError::unauthorized("unauthorized"))?; + + let service = ctx.document_service(); + let detail = service + .get_snapshot(&actor, id, snapshot_id) + .await + .map_err(map_service_error)?; + + Ok(Json(SnapshotDetailResponse { + id: detail.id, + content: base64::engine::general_purpose::STANDARD.encode(&detail.content), + nonce: detail + .nonce + .map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + created_at: detail.created_at, + })) +} + #[utoipa::path( get, path = "/api/documents/{id}/snapshots/{snapshot_id}/diff", @@ -155,6 +197,8 @@ pub async fn download_document_snapshot( Path((id, snapshot_id)): Path<(Uuid, Uuid)>, q: Option>, ) -> Result { + use base64::Engine; + let params = q.map(|Query(v)| v).unwrap_or_default(); let token = params.token.as_deref(); let actor = token::resolve_actor_from_parts(&ctx, bearer, token) @@ -178,5 +222,25 @@ pub async fn download_document_snapshot( .map_err(|_| ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "internal_error"))?; headers.insert(axum::http::header::CONTENT_DISPOSITION, content_disposition); + // Add E2EE headers if present + if let Some(nonce) = &download.snapshot.nonce { + let encoded = base64::engine::general_purpose::STANDARD.encode(nonce); + if let Ok(val) = HeaderValue::from_str(&encoded) { + headers.insert( + axum::http::header::HeaderName::from_static("x-snapshot-nonce"), + val, + ); + } + } + if let Some(signature) = &download.snapshot.signature { + let encoded = base64::engine::general_purpose::STANDARD.encode(signature); + if let Ok(val) = HeaderValue::from_str(&encoded) { + headers.insert( + axum::http::header::HeaderName::from_static("x-snapshot-signature"), + val, + ); + } + } + Ok((headers, download.bytes).into_response()) } diff --git a/api/crates/presentation/src/http/documents/mod.rs b/api/crates/presentation/src/http/documents/mod.rs index 3ba42f91..59cfd2d3 100644 --- a/api/crates/presentation/src/http/documents/mod.rs +++ b/api/crates/presentation/src/http/documents/mod.rs @@ -16,9 +16,9 @@ use crate::context::AppContext; pub use handlers::{ archive_document, create_document, delete_document, download_document, download_document_snapshot, duplicate_document, get_backlinks, get_document, - get_document_content, get_document_snapshot_diff, get_outgoing_links, list_document_snapshots, - list_documents, patch_document_content, restore_document_snapshot, search_documents, - unarchive_document, update_document, update_document_content, + get_document_content, get_document_snapshot, get_document_snapshot_diff, get_outgoing_links, + list_document_snapshots, list_documents, patch_document_content, restore_document_snapshot, + search_documents, unarchive_document, update_document, update_document_content, }; pub use types::*; @@ -45,6 +45,10 @@ pub fn routes(ctx: AppContext) -> Router { .route("/documents/:id/archive", post(archive_document)) .route("/documents/:id/unarchive", post(unarchive_document)) .route("/documents/:id/snapshots", get(list_document_snapshots)) + .route( + "/documents/:id/snapshots/:snapshot_id", + get(get_document_snapshot), + ) .route( "/documents/:id/snapshots/:snapshot_id/diff", get(get_document_snapshot_diff), diff --git a/api/crates/presentation/src/http/documents/publishing/handlers/mod.rs b/api/crates/presentation/src/http/documents/publishing/handlers/mod.rs index 1624d4ca..ae72f469 100644 --- a/api/crates/presentation/src/http/documents/publishing/handlers/mod.rs +++ b/api/crates/presentation/src/http/documents/publishing/handlers/mod.rs @@ -11,7 +11,7 @@ use crate::http::error::ApiError; use crate::http::extractors::WorkspaceAuth; use application::core::services::errors::ServiceError; -use super::types::{PublicDocumentSummary, PublishResponse}; +use super::types::{PublicDocumentSummary, PublishRequest, PublishResponse}; fn map_public_error(err: ServiceError) -> crate::http::error::ApiError { crate::http::error::map_service_error(err, "public_service_error") @@ -22,18 +22,31 @@ fn map_public_error(err: ServiceError) -> crate::http::error::ApiError { path = "/api/public/documents/{id}", tag = "Public Documents", params(("id" = Uuid, Path, description = "Document ID")), + request_body(content = Option, description = "Optional plaintext content for E2EE workspaces"), responses((status = 200, description = "Published", body = PublishResponse)) )] pub async fn publish_document( State(ctx): State, auth: WorkspaceAuth, Path(id): Path, + body: Option>, ) -> Result, ApiError> { - let service = ctx.public_service(); - let out = service - .publish_document(auth.workspace_id, &auth.permissions, id) + let (plaintext_title, plaintext_content) = body + .map(|Json(req)| (req.plaintext_title, req.plaintext_content)) + .unwrap_or((None, None)); + + let out = ctx + .public_service() + .publish_document( + auth.workspace_id, + &auth.permissions, + id, + plaintext_title.as_deref(), + plaintext_content.as_deref(), + ) .await .map_err(map_public_error)?; + Ok(Json(PublishResponse { slug: out.slug, public_url: out.public_url, diff --git a/api/crates/presentation/src/http/documents/publishing/types.rs b/api/crates/presentation/src/http/documents/publishing/types.rs index 690fac73..ef565998 100644 --- a/api/crates/presentation/src/http/documents/publishing/types.rs +++ b/api/crates/presentation/src/http/documents/publishing/types.rs @@ -1,9 +1,22 @@ -use serde::Serialize; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; use application::documents::dtos::PublicDocumentSummaryDto; +/// Request to publish a document. For E2EE workspaces, plaintext title and content +/// must be provided so public pages can be rendered without decryption. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct PublishRequest { + /// Plaintext title (required for E2EE mode) + #[serde(default)] + pub plaintext_title: Option, + /// Plaintext content (required for E2EE mode) + #[serde(default)] + pub plaintext_content: Option, +} + #[derive(Debug, Serialize, ToSchema)] pub struct PublishResponse { pub slug: String, diff --git a/api/crates/presentation/src/http/documents/sharing/mod.rs b/api/crates/presentation/src/http/documents/sharing/mod.rs index 85543b7e..aa1b40fc 100644 --- a/api/crates/presentation/src/http/documents/sharing/mod.rs +++ b/api/crates/presentation/src/http/documents/sharing/mod.rs @@ -19,7 +19,7 @@ pub use mounts::{ }; pub use shares::{create_share, delete_share, list_document_shares}; pub use types::*; -pub use validation::{browse_share, validate_share_token}; +pub use validation::{browse_share, get_share_salt, validate_share_token}; pub mod openapi { pub use super::active::*; @@ -38,6 +38,7 @@ pub fn routes(ctx: AppContext) -> Router { ) .route("/shares/browse", get(browse_share)) .route("/shares/validate", get(validate_share_token)) + .route("/shares/salt", get(get_share_salt)) .route("/shares/documents/:id", get(list_document_shares)) .route("/shares/applicable", get(list_applicable_shares)) .route( diff --git a/api/crates/presentation/src/http/documents/sharing/shares.rs b/api/crates/presentation/src/http/documents/sharing/shares.rs index 476bba3e..a5e3ce9b 100644 --- a/api/crates/presentation/src/http/documents/sharing/shares.rs +++ b/api/crates/presentation/src/http/documents/sharing/shares.rs @@ -3,6 +3,7 @@ use axum::{ extract::{Path, State}, http::StatusCode, }; +use base64::Engine; use uuid::Uuid; use crate::context::DocumentsContext; @@ -10,6 +11,7 @@ use crate::http::error::ApiError; use crate::http::extractors::WorkspaceAuth; use application::core::services::access; use domain::documents::share::SHARE_PERMISSION_VIEW; +use domain::identity::keys::KdfParams; use application::documents::dtos::ShareItemDto; @@ -48,6 +50,42 @@ pub async fn create_share( ) .await .map_err(map_share_error)?; + + // Store encrypted DEK if provided (E2EE mode) + if let Some(encrypted_dek_b64) = req.encrypted_dek { + let encrypted_dek = base64::engine::general_purpose::STANDARD + .decode(&encrypted_dek_b64) + .map_err(|_| ApiError::bad_request("invalid_encrypted_dek_base64"))?; + + let keys_service = ctx.document_keys_service(); + + if let (Some(salt_b64), Some(kdf_params_json)) = (req.salt, req.kdf_params) { + // Password-protected share + let salt = base64::engine::general_purpose::STANDARD + .decode(&salt_b64) + .map_err(|_| ApiError::bad_request("invalid_salt_base64"))?; + let kdf_params: KdfParams = serde_json::from_value(kdf_params_json) + .map_err(|_| ApiError::bad_request("invalid_kdf_params"))?; + + keys_service + .store_password_protected_share_key(res.share_id, encrypted_dek, salt, kdf_params) + .await + .map_err(|e| { + tracing::error!(error = ?e, "failed_to_store_share_key"); + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "failed_to_store_share_key") + })?; + } else { + // URL fragment based share (no password) + keys_service + .store_share_key(res.share_id, encrypted_dek) + .await + .map_err(|e| { + tracing::error!(error = ?e, "failed_to_store_share_key"); + ApiError::new(StatusCode::INTERNAL_SERVER_ERROR, "failed_to_store_share_key") + })?; + } + } + let base = frontend_base(&ctx.cfg); let url = build_share_url(&base, &res.document_type, res.document_id, &res.token); Ok(Json(CreateShareResponse { diff --git a/api/crates/presentation/src/http/documents/sharing/types.rs b/api/crates/presentation/src/http/documents/sharing/types.rs index caf43507..ded03dff 100644 --- a/api/crates/presentation/src/http/documents/sharing/types.rs +++ b/api/crates/presentation/src/http/documents/sharing/types.rs @@ -38,10 +38,23 @@ pub fn map_share_error(err: ServiceError) -> crate::http::error::ApiError { } #[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateShareRequest { pub document_id: Uuid, pub permission: Option, pub expires_at: Option>, + // E2EE fields - encrypted DEK for share access + /// Base64 encoded encrypted DEK (encrypted with share key derived from password) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub encrypted_dek: Option, + /// Base64 encoded salt for key derivation + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub salt: Option, + /// KDF parameters (e.g., Argon2id settings) + #[serde(default)] + pub kdf_params: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -86,11 +99,24 @@ impl From for ApplicableShareItem { } #[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct ShareDocumentResponse { pub id: Uuid, pub title: String, pub permission: String, pub content: Option, + // E2EE fields + /// Base64 encoded encrypted DEK (encrypted with share key) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub encrypted_dek: Option, + /// Base64 encoded salt for password-protected shares + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub salt: Option, + /// KDF parameters for password-protected shares + #[serde(skip_serializing_if = "Option::is_none")] + pub kdf_params: Option, } impl From for ShareDocumentResponse { @@ -100,6 +126,9 @@ impl From for ShareDocumentResponse { title: d.title, permission: d.permission, content: d.content, + encrypted_dek: None, + salt: None, + kdf_params: None, } } } @@ -109,6 +138,21 @@ pub struct ShareTokenQuery { pub token: String, } +/// Response for share salt challenge (for password-protected shares) +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ShareSaltResponse { + /// Whether this share is password-protected + pub password_protected: bool, + /// Base64 encoded salt for key derivation (only present if password-protected) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub salt: Option, + /// KDF parameters for key derivation (only present if password-protected) + #[serde(skip_serializing_if = "Option::is_none")] + pub kdf_params: Option, +} + #[derive(Debug, Serialize, ToSchema)] pub struct ActiveShareItem { pub id: Uuid, diff --git a/api/crates/presentation/src/http/documents/sharing/validation.rs b/api/crates/presentation/src/http/documents/sharing/validation.rs index dcd98ef8..b8bf9efa 100644 --- a/api/crates/presentation/src/http/documents/sharing/validation.rs +++ b/api/crates/presentation/src/http/documents/sharing/validation.rs @@ -2,11 +2,12 @@ use axum::{ Json, extract::{Query, State}, }; +use base64::Engine; use crate::context::DocumentsContext; use crate::http::error::ApiError; -use super::types::{ShareBrowseResponse, ShareDocumentResponse, ShareTokenQuery, map_share_error}; +use super::types::{ShareBrowseResponse, ShareDocumentResponse, ShareSaltResponse, ShareTokenQuery, map_share_error}; #[utoipa::path( get, @@ -19,17 +20,87 @@ pub async fn validate_share_token( State(ctx): State, Query(query): Query, ) -> Result, ApiError> { - let service = ctx.share_service(); - let res = service + let share_service = ctx.share_service(); + + // Get basic share document info + let res = share_service .validate_token(&query.token) .await .map_err(map_share_error)?; - let out: ShareDocumentResponse = res + let mut out: ShareDocumentResponse = res .map(Into::into) .ok_or(ApiError::not_found("not_found"))?; + + // Get share context to obtain share_id for E2EE key lookup + if let Ok(Some(share_ctx)) = share_service.resolve_share_context(&query.token).await { + let keys_service = ctx.document_keys_service(); + if let Ok(Some(share_key)) = keys_service.get_share_key(share_ctx.share_id).await { + out.encrypted_dek = Some( + base64::engine::general_purpose::STANDARD.encode(&share_key.encrypted_dek), + ); + if let Some(salt) = share_key.salt { + out.salt = Some(base64::engine::general_purpose::STANDARD.encode(&salt)); + } + if let Some(kdf_params) = share_key.kdf_params { + out.kdf_params = serde_json::to_value(&kdf_params).ok(); + } + } + } + Ok(Json(out)) } +/// Get salt for password-protected share (for password challenge) +#[utoipa::path( + get, + path = "/api/shares/salt", + tag = "Sharing", + params(("token" = String, Query, description = "Share token")), + responses((status = 200, description = "Salt info for password-protected share", body = ShareSaltResponse)) +)] +pub async fn get_share_salt( + State(ctx): State, + Query(query): Query, +) -> Result, ApiError> { + let share_service = ctx.share_service(); + + // First validate that the share token exists + let share_ctx = share_service + .resolve_share_context(&query.token) + .await + .map_err(map_share_error)? + .ok_or(ApiError::not_found("not_found"))?; + + // Get share key info + let keys_service = ctx.document_keys_service(); + let share_key = keys_service + .get_share_key(share_ctx.share_id) + .await + .map_err(|e| { + tracing::error!(error = ?e, "failed_to_get_share_key"); + ApiError::not_found("not_found") + })?; + + match share_key { + Some(key) => { + let password_protected = key.salt.is_some(); + Ok(Json(ShareSaltResponse { + password_protected, + salt: key.salt.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + kdf_params: key.kdf_params.and_then(|p| serde_json::to_value(&p).ok()), + })) + } + None => { + // No E2EE key stored, share is not encrypted + Ok(Json(ShareSaltResponse { + password_protected: false, + salt: None, + kdf_params: None, + })) + } + } +} + #[utoipa::path(get, path = "/api/shares/browse", tag = "Sharing", params(("token" = String, Query, description = "Share token")), responses((status = 200, description = "Share tree", body = ShareBrowseResponse)))] diff --git a/api/crates/presentation/src/http/documents/tagging/handlers/mod.rs b/api/crates/presentation/src/http/documents/tagging/handlers/mod.rs index 0ffbf918..d1d4dae8 100644 --- a/api/crates/presentation/src/http/documents/tagging/handlers/mod.rs +++ b/api/crates/presentation/src/http/documents/tagging/handlers/mod.rs @@ -1,35 +1,129 @@ use axum::{ Json, - extract::{Query, State}, + extract::{Path, Query, State}, }; +use base64::Engine; +use uuid::Uuid; use crate::context::DocumentsContext; use crate::http::error::ApiError; use crate::http::extractors::WorkspaceAuth; +use application::core::services::access; use application::core::services::errors::ServiceError; -use domain::access::permissions::PERM_DOC_VIEW; +use domain::access::permissions::{PERM_DOC_EDIT, PERM_DOC_VIEW}; -use super::types::TagItem; +use super::types::{ + DocumentTagEntry, DocumentTagsResponse, ListTagsResponse, TagEntry, TagSearchQuery, + UpdateDocumentTagsRequest, +}; fn map_tag_error(err: ServiceError) -> crate::http::error::ApiError { crate::http::error::map_service_error(err, "tag_service_error") } -#[utoipa::path(get, path = "/api/tags", tag = "Tags", - params(("q" = Option, Query, description = "Filter contains")), - responses((status = 200, body = [TagItem])))] +/// List all tags in the workspace (E2EE format) +#[utoipa::path( + get, + path = "/api/tags", + tag = "Tags", + params(("q" = Option, Query, description = "Base64 encoded encrypted tag for exact match filter")), + responses((status = 200, body = ListTagsResponse)) +)] pub async fn list_tags( State(ctx): State, auth: WorkspaceAuth, - q: Option>>, -) -> Result>, ApiError> { + Query(query): Query, +) -> Result, ApiError> { auth.ensure_permission(PERM_DOC_VIEW)?; - let filter = q.and_then(|Query(m)| m.get("q").cloned()); + + let service = ctx.tag_service(); + + // If filter is provided, decode and use it for exact match + let items = if let Some(q) = query.q { + let encrypted_tag = base64::engine::general_purpose::STANDARD + .decode(&q) + .map_err(|_| ApiError::bad_request("invalid_encrypted_tag_base64"))?; + service + .find_encrypted_tag(auth.workspace_id, encrypted_tag) + .await + .map_err(map_tag_error)? + } else { + service + .list_encrypted_tags(auth.workspace_id) + .await + .map_err(map_tag_error)? + }; + + let tags: Vec = items.into_iter().map(Into::into).collect(); + Ok(Json(ListTagsResponse { tags })) +} + +/// Get tags for a specific document (E2EE format) +#[utoipa::path( + get, + path = "/api/documents/{id}/tags", + tag = "Tags", + params(("id" = Uuid, Path, description = "Document ID")), + responses((status = 200, body = DocumentTagsResponse)) +)] +pub async fn get_document_tags( + State(ctx): State, + auth: WorkspaceAuth, + Path(id): Path, +) -> Result, ApiError> { + let actor = access::Actor::User(auth.user_id); + ctx.authorization() + .require_view(&actor, id) + .await + .map_err(|err| crate::http::error::map_service_error(err, "authorization_error"))?; + + let service = ctx.tag_service(); + let items = service + .list_document_encrypted_tags(id) + .await + .map_err(map_tag_error)?; + let tags: Vec = items.into_iter().map(Into::into).collect(); + Ok(Json(DocumentTagsResponse { tags })) +} + +/// Replace tags for a document (E2EE format) +#[utoipa::path( + put, + path = "/api/documents/{id}/tags", + tag = "Tags", + params(("id" = Uuid, Path, description = "Document ID")), + request_body = UpdateDocumentTagsRequest, + responses((status = 200, body = DocumentTagsResponse)) +)] +pub async fn update_document_tags( + State(ctx): State, + auth: WorkspaceAuth, + Path(id): Path, + Json(req): Json, +) -> Result, ApiError> { + auth.ensure_permission(PERM_DOC_EDIT)?; + let actor = access::Actor::User(auth.user_id); + ctx.authorization() + .require_edit(&actor, id) + .await + .map_err(|err| crate::http::error::map_service_error(err, "authorization_error"))?; + + // Decode Base64 encoded tags + let encrypted_tags: Vec> = req + .encrypted_tags + .iter() + .map(|t| { + base64::engine::general_purpose::STANDARD + .decode(&t.encrypted_name) + .map_err(|_| ApiError::bad_request("invalid_encrypted_tag_base64")) + }) + .collect::, _>>()?; + let service = ctx.tag_service(); let items = service - .list(auth.workspace_id, filter) + .replace_document_encrypted_tags(auth.workspace_id, id, encrypted_tags) .await .map_err(map_tag_error)?; - let out: Vec = items.into_iter().map(Into::into).collect(); - Ok(Json(out)) + let tags: Vec = items.into_iter().map(Into::into).collect(); + Ok(Json(DocumentTagsResponse { tags })) } diff --git a/api/crates/presentation/src/http/documents/tagging/mod.rs b/api/crates/presentation/src/http/documents/tagging/mod.rs index 575f2868..0913847e 100644 --- a/api/crates/presentation/src/http/documents/tagging/mod.rs +++ b/api/crates/presentation/src/http/documents/tagging/mod.rs @@ -5,7 +5,7 @@ use axum::{Router, routing::get}; use crate::context::AppContext; -pub use handlers::list_tags; +pub use handlers::{get_document_tags, list_tags, update_document_tags}; pub use types::*; pub mod openapi { @@ -13,5 +13,11 @@ pub mod openapi { } pub fn routes(ctx: AppContext) -> Router { - Router::new().route("/tags", get(list_tags)).with_state(ctx) + Router::new() + .route("/tags", get(list_tags)) + .route( + "/documents/:id/tags", + get(get_document_tags).put(update_document_tags), + ) + .with_state(ctx) } diff --git a/api/crates/presentation/src/http/documents/tagging/types.rs b/api/crates/presentation/src/http/documents/tagging/types.rs index 69bf44a6..4d549998 100644 --- a/api/crates/presentation/src/http/documents/tagging/types.rs +++ b/api/crates/presentation/src/http/documents/tagging/types.rs @@ -1,19 +1,84 @@ -use serde::Serialize; +use base64::Engine; +use serde::{Deserialize, Serialize}; use utoipa::ToSchema; +use uuid::Uuid; -use application::documents::dtos::TagItemDto; +use application::documents::dtos::{EncryptedTagEntryDto, EncryptedTagItemDto}; -#[derive(Serialize, ToSchema)] -pub struct TagItem { - pub name: String, - pub count: i64, +/// Tag entry in list response (E2EE format) +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct TagEntry { + /// Base64 encoded deterministically encrypted tag name + #[schema(value_type = String, format = "byte")] + pub encrypted_name: String, + pub document_count: i64, } -impl From for TagItem { - fn from(d: TagItemDto) -> Self { - TagItem { - name: d.name, - count: d.count, +impl From for TagEntry { + fn from(d: EncryptedTagItemDto) -> Self { + TagEntry { + encrypted_name: base64::engine::general_purpose::STANDARD.encode(&d.encrypted_tag), + document_count: d.count, } } } + +/// Response for GET /api/tags +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct ListTagsResponse { + pub tags: Vec, +} + +/// Tag entry in document tags response +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentTagEntry { + pub id: Uuid, + /// Base64 encoded deterministically encrypted tag name + #[schema(value_type = String, format = "byte")] + pub encrypted_name: String, + pub created_at: chrono::DateTime, +} + +impl From for DocumentTagEntry { + fn from(d: EncryptedTagEntryDto) -> Self { + DocumentTagEntry { + id: d.id, + encrypted_name: base64::engine::general_purpose::STANDARD.encode(&d.encrypted_tag), + created_at: d.created_at, + } + } +} + +/// Response for GET /api/documents/{id}/tags +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct DocumentTagsResponse { + pub tags: Vec, +} + +/// Single encrypted tag in request +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedTagInput { + /// Base64 encoded deterministically encrypted tag name + #[schema(value_type = String, format = "byte")] + pub encrypted_name: String, +} + +/// Request for PUT /api/documents/{id}/tags +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpdateDocumentTagsRequest { + pub encrypted_tags: Vec, +} + +/// Query for tag search +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TagSearchQuery { + /// Optional filter query (Base64 encoded encrypted tag for exact match) + pub q: Option, +} diff --git a/api/crates/presentation/src/http/documents/types.rs b/api/crates/presentation/src/http/documents/types.rs index 9c1e5436..8f7d388a 100644 --- a/api/crates/presentation/src/http/documents/types.rs +++ b/api/crates/presentation/src/http/documents/types.rs @@ -32,9 +32,17 @@ pub struct Document { pub archived_at: Option>, pub archived_by: Option, pub archived_parent_id: Option, + // E2EE fields + #[serde(skip_serializing_if = "Option::is_none", rename = "encryptedTitle")] + #[schema(value_type = Option, format = "byte")] + pub encrypted_title: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "encryptedTitleNonce")] + #[schema(value_type = Option, format = "byte")] + pub encrypted_title_nonce: Option, } pub fn to_http_document(doc: domain::Document) -> Document { + use base64::Engine; Document { id: doc.id(), // NOTE: Older clients used `owner_id` to identify the workspace. @@ -53,6 +61,12 @@ pub fn to_http_document(doc: domain::Document) -> Document { archived_at: doc.archived_at(), archived_by: doc.archived_by(), archived_parent_id: doc.archived_parent_id(), + encrypted_title: doc + .encrypted_title() + .map(|b| base64::engine::general_purpose::STANDARD.encode(b)), + encrypted_title_nonce: doc + .encrypted_title_nonce() + .map(|b| base64::engine::general_purpose::STANDARD.encode(b)), } } @@ -76,6 +90,13 @@ pub struct SnapshotSummary { pub created_by: Option, pub byte_size: i64, pub content_hash: String, + // E2EE fields + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub nonce: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub signature: Option, } #[derive(Debug, Serialize, ToSchema)] @@ -128,7 +149,25 @@ pub struct SnapshotRestoreResponse { pub snapshot: SnapshotSummary, } +/// Response for GET /api/documents/{id}/snapshots/{snapshotId} +/// - For E2EE documents: content is encrypted, nonce is present +/// - For non-E2EE documents: content is plaintext Yjs state, nonce is None +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotDetailResponse { + pub id: Uuid, + /// Base64 encoded Yjs snapshot (encrypted for E2EE, plaintext for non-E2EE) + #[schema(value_type = String, format = "byte")] + pub content: String, + /// Base64 encoded nonce (present for E2EE documents) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub nonce: Option, + pub created_at: chrono::DateTime, +} + pub fn snapshot_summary_from(record: SnapshotSummaryDto) -> SnapshotSummary { + use base64::Engine; SnapshotSummary { id: record.id, document_id: record.document_id, @@ -139,6 +178,12 @@ pub fn snapshot_summary_from(record: SnapshotSummaryDto) -> SnapshotSummary { created_by: record.created_by, byte_size: record.byte_size, content_hash: record.content_hash, + nonce: record + .nonce + .map(|b| base64::engine::general_purpose::STANDARD.encode(&b)), + signature: record + .signature + .map(|b| base64::engine::general_purpose::STANDARD.encode(&b)), } } @@ -158,10 +203,55 @@ pub fn snapshot_diff_side_response_from(side: SnapshotDiffSideDto) -> SnapshotDi } #[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct CreateDocumentRequest { pub title: Option, pub parent_id: Option, pub r#type: Option, + // E2EE fields + /// Base64 encoded encrypted title (for E2EE clients) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub encrypted_title: Option, + /// Base64 encoded nonce for encrypted title + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub encrypted_title_nonce: Option, + /// Encrypted DEK for this document (optional, for E2EE clients) + #[serde(default)] + pub dek: Option, +} + +/// DEK payload for document creation +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreateDocumentDekPayload { + /// Base64 encoded encrypted DEK + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, + /// Base64 encoded nonce + #[schema(value_type = String, format = "byte")] + pub nonce: String, + /// Key version + #[serde(default = "default_key_version")] + pub key_version: i32, +} + +fn default_key_version() -> i32 { + 1 +} + +impl CreateDocumentDekPayload { + pub fn decode(&self) -> Result<(Vec, Vec, i32), &'static str> { + use base64::Engine; + let encrypted_dek = base64::engine::general_purpose::STANDARD + .decode(&self.encrypted_dek) + .map_err(|_| "invalid_encrypted_dek_base64")?; + let nonce = base64::engine::general_purpose::STANDARD + .decode(&self.nonce) + .map_err(|_| "invalid_nonce_base64")?; + Ok((encrypted_dek, nonce, self.key_version)) + } } #[derive(Debug, Deserialize, ToSchema)] @@ -244,16 +334,39 @@ impl From for DocumentListFilter { } #[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct UpdateDocumentContentRequest { + /// Document content (plaintext or Base64-encoded encrypted Yjs state for E2EE) pub content: String, + /// Base64 encoded nonce (required for E2EE content) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub nonce: Option, + /// Base64 encoded signature for integrity verification (optional for E2EE) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub signature: Option, } +/// Patch operation for document content. +/// For plaintext mode: use `text` field. +/// For E2EE mode: use `encrypted_data` and `nonce` fields instead of `text`. #[derive(Debug, Deserialize, ToSchema)] #[serde(tag = "op", rename_all = "snake_case")] pub enum DocumentPatchOperationRequest { Insert { offset: usize, - text: String, + /// Plaintext to insert (for non-E2EE documents) + #[serde(default)] + text: Option, + /// Base64 encoded encrypted data (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + encrypted_data: Option, + /// Base64 encoded nonce (required when encrypted_data is provided) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + nonce: Option, }, Delete { offset: usize, @@ -262,35 +375,67 @@ pub enum DocumentPatchOperationRequest { Replace { offset: usize, length: usize, - text: String, + /// Plaintext replacement (for non-E2EE documents) + #[serde(default)] + text: Option, + /// Base64 encoded encrypted data (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + encrypted_data: Option, + /// Base64 encoded nonce (required when encrypted_data is provided) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + nonce: Option, }, } -impl From for DocumentPatchOperation { - fn from(value: DocumentPatchOperationRequest) -> Self { - match value { - DocumentPatchOperationRequest::Insert { offset, text } => { - DocumentPatchOperation::Insert { offset, text } +impl DocumentPatchOperationRequest { + /// Check if this operation is for E2EE (has encrypted_data) + pub fn is_encrypted(&self) -> bool { + match self { + DocumentPatchOperationRequest::Insert { encrypted_data, .. } => encrypted_data.is_some(), + DocumentPatchOperationRequest::Delete { .. } => false, + DocumentPatchOperationRequest::Replace { encrypted_data, .. } => encrypted_data.is_some(), + } + } + + /// Convert to plaintext DocumentPatchOperation (for non-E2EE mode) + pub fn to_plaintext_operation(&self) -> Option { + match self { + DocumentPatchOperationRequest::Insert { offset, text, .. } => { + text.as_ref().map(|t| DocumentPatchOperation::Insert { + offset: *offset, + text: t.clone(), + }) } DocumentPatchOperationRequest::Delete { offset, length } => { - DocumentPatchOperation::Delete { offset, length } + Some(DocumentPatchOperation::Delete { + offset: *offset, + length: *length, + }) + } + DocumentPatchOperationRequest::Replace { offset, length, text, .. } => { + text.as_ref().map(|t| DocumentPatchOperation::Replace { + offset: *offset, + length: *length, + text: t.clone(), + }) } - DocumentPatchOperationRequest::Replace { - offset, - length, - text, - } => DocumentPatchOperation::Replace { - offset, - length, - text, - }, } } } #[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] pub struct PatchDocumentContentRequest { + /// Patch operations. Each operation can be either plaintext (using `text` field) + /// or encrypted (using `encryptedData` and `nonce` fields). + #[serde(default)] pub operations: Vec, + /// Base64 encoded signature for integrity verification (optional for E2EE) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + pub signature: Option, } #[allow(dead_code)] @@ -470,3 +615,18 @@ pub struct OutgoingLinksResponse { pub links: Vec, pub total_count: usize, } + +/// Response for GET /api/documents/{id}/content +/// - For E2EE documents: content is encrypted, nonce is present +/// - For non-E2EE documents: content is plaintext Yjs state, nonce is None +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetContentResponse { + /// Base64 encoded Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE) + #[schema(value_type = String, format = "byte")] + pub content: String, + /// Base64 encoded nonce for decryption (present for E2EE documents) + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Option, format = "byte")] + pub nonce: Option, +} diff --git a/api/crates/presentation/src/openapi.rs b/api/crates/presentation/src/openapi.rs index 40953595..04d18b10 100644 --- a/api/crates/presentation/src/openapi.rs +++ b/api/crates/presentation/src/openapi.rs @@ -45,6 +45,8 @@ use crate::ws; auth::openapi::delete_account, ws::documents::yjs::openapi::axum_ws_entry, tags::openapi::list_tags, + tags::openapi::get_document_tags, + tags::openapi::update_document_tags, documents::openapi::list_documents, documents::openapi::create_document, documents::openapi::get_document, @@ -58,6 +60,7 @@ use crate::ws; documents::openapi::unarchive_document, documents::openapi::download_document, documents::openapi::list_document_snapshots, + documents::openapi::get_document_snapshot, documents::openapi::get_document_snapshot_diff, documents::openapi::restore_document_snapshot, documents::openapi::download_document_snapshot, @@ -71,6 +74,7 @@ use crate::ws; shares::openapi::delete_share, shares::openapi::list_document_shares, shares::openapi::validate_share_token, + shares::openapi::get_share_salt, shares::openapi::browse_share, shares::openapi::list_active_shares, shares::openapi::create_share_mount, @@ -183,12 +187,19 @@ use crate::ws; document_keys::StoreShareKeyRequest, document_keys::StorePasswordProtectedShareKeyRequest, document_keys::KdfParamsResponse, - tags::TagItem, + tags::TagEntry, + tags::ListTagsResponse, + tags::DocumentTagEntry, + tags::DocumentTagsResponse, + tags::EncryptedTagInput, + tags::UpdateDocumentTagsRequest, documents::Document, documents::DocumentListResponse, documents::CreateDocumentRequest, + documents::CreateDocumentDekPayload, documents::UpdateDocumentRequest, documents::DuplicateDocumentRequest, + documents::GetContentResponse, documents::UpdateDocumentContentRequest, documents::DocumentPatchOperationRequest, documents::PatchDocumentContentRequest, @@ -208,6 +219,7 @@ use crate::ws; documents::SnapshotDiffResponse, documents::SnapshotDiffBaseParam, documents::SnapshotRestoreResponse, + documents::SnapshotDetailResponse, files::UploadFileResponse, files::UploadFileMultipart, shares::CreateShareRequest, @@ -215,12 +227,14 @@ use crate::ws; shares::CreateShareMountRequest, shares::ShareItem, shares::ShareDocumentResponse, + shares::ShareSaltResponse, shares::ShareBrowseTreeItem, shares::ShareBrowseResponse, shares::ApplicableShareItem, shares::ActiveShareItem, shares::ShareMountItem, shares::MaterializeResponse, + public::PublishRequest, public::PublishResponse, public::PublicDocumentSummary, git::GitConfigResponse, diff --git a/api/openapi.json b/api/openapi.json index 2d84fd10..e69de29b 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1,3 +0,0 @@ - Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.28s - Running `target/debug/refmd openapi export` -{"openapi":"3.0.3","info":{"title":"presentation","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}},"404":{"description":"Document key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}}}}},"/api/documents/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/e2ee/setup-complete":{"post":{"tags":["E2EE"],"operationId":"mark_e2ee_setup_complete","responses":{"204":{"description":""}}}},"/api/me/e2ee/status":{"get":{"tags":["E2EE"],"operationId":"get_e2ee_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/E2eeStatusResponse"}}}}}}},"/api/me/keys":{"get":{"tags":["E2EE"],"operationId":"get_my_public_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}},"post":{"tags":["E2EE"],"operationId":"register_public_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPublicKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}}}}},"/api/me/master-key/backup":{"get":{"tags":["E2EE"],"operationId":"get_master_key_backup","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}},"404":{"description":"Master key backup not found"}}},"post":{"tags":["E2EE"],"operationId":"store_master_key_backup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreMasterKeyBackupRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/private-key/encrypted":{"get":{"tags":["E2EE"],"operationId":"get_encrypted_private_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}},"404":{"description":"Encrypted private key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_encrypted_private_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreEncryptedPrivateKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugin-assets":{"get":{"tags":["Plugins"],"operationId":"pluginsGetAsset","parameters":[{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Plugin asset"}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}},"404":{"description":"Share key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/keys/password-protected":{"post":{"tags":["E2EE"],"operationId":"store_password_protected_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StorePasswordProtectedShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/salt":{"get":{"tags":["E2EE"],"operationId":"get_share_salt","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/storage/ingest":{"post":{"tags":["Storage"],"operationId":"enqueue_ingest_events","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestBatchRequest"}}},"required":true},"responses":{"202":{"description":"Events enqueued"},"400":{"description":"Invalid request"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/users/{user_id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_user_public_key","parameters":[{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"list_workspace_keys","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"post":{"tags":["E2EE"],"operationId":"store_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"/api/workspaces/{id}/keys/me":{"get":{"tags":["E2EE"],"operationId":"get_my_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}},"404":{"description":"Key not found"}}}},"/api/workspaces/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/keys/version":{"get":{"tags":["E2EE"],"operationId":"get_workspace_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyVersionResponse"}}}}}}},"/api/workspaces/{id}/keys/{version}":{"delete":{"tags":["E2EE"],"operationId":"delete_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","description":"Key version to delete","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteKeyVersionResponse"}}}},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/leave":{"post":{"tags":["Workspaces"],"operationId":"leave_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"DeleteKeyVersionResponse":{"type":"object","required":["workspaceId","keyVersion","deletedCount"],"properties":{"deletedCount":{"type":"integer","format":"int64","minimum":0},"keyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid","description":"Legacy alias for `workspace_id` kept for backward compatibility with older clients."},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentKeyResponse":{"type":"object","required":["documentId","encryptedDek","nonce","keyVersion","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte"},"keyVersion":{"type":"integer","format":"int32"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"E2eeStatusResponse":{"type":"object","required":["isSetupCompleted"],"properties":{"isSetupCompleted":{"type":"boolean"}}},"EncryptedPrivateKeyResponse":{"type":"object","required":["encryptedPrivateKey","nonce","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedPrivateKey":{"type":"string","format":"byte"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"IngestBatchRequest":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/IngestEventRequest"}}}},"IngestEventRequest":{"type":"object","required":["repo_path","kind"],"properties":{"backend":{"type":"string","nullable":true},"content_hash":{"type":"string","nullable":true},"kind":{"$ref":"#/components/schemas/IngestKindParam"},"payload":{"nullable":true},"repo_path":{"type":"string"}}},"IngestKindParam":{"type":"string","enum":["upsert","delete"]},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KdfParamsResponse":{"type":"object","properties":{"iterations":{"type":"integer","format":"int32","nullable":true,"minimum":0},"memory":{"type":"integer","format":"int32","nullable":true,"minimum":0},"parallelism":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MasterKeyBackupResponse":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKey":{"type":"string","format":"byte"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string"},"salt":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterPublicKeyRequest":{"type":"object","required":["publicKey","keyType"],"properties":{"keyType":{"type":"string","description":"Key type (e.g., \"ecdh-p256\")","example":"ecdh-p256"},"publicKey":{"type":"string","format":"byte","description":"Base64 encoded public key"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"RotateDocumentKeyRequest":{"type":"object","description":"Request body for document DEK rotation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded new encrypted DEK"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"RotateDocumentKeyResponse":{"type":"object","description":"Response for document DEK rotation","required":["documentId","newKeyVersion"],"properties":{"documentId":{"type":"string","format":"uuid"},"newKeyVersion":{"type":"integer","format":"int32"}}},"RotateWorkspaceKeyRequest":{"type":"object","description":"Request body for KEK rotation","required":["memberKeys"],"properties":{"memberKeys":{"type":"array","items":{"$ref":"#/components/schemas/RotationMemberKey"},"description":"Encrypted KEKs for all workspace members"}}},"RotateWorkspaceKeyResponse":{"type":"object","description":"Response for KEK rotation","required":["workspaceId","newKeyVersion","keysUpdated"],"properties":{"keysUpdated":{"type":"integer","minimum":0},"newKeyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"RotationMemberKey":{"type":"object","description":"A single member's encrypted KEK for key rotation","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK for this member"},"userId":{"type":"string","format":"uuid","description":"User ID of the member"}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareKeyResponse":{"type":"object","required":["shareId","encryptedDek","isPasswordProtected","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedDek":{"type":"string","format":"byte"},"isPasswordProtected":{"type":"boolean"},"kdfParams":{"allOf":[{"$ref":"#/components/schemas/KdfParamsResponse"}],"nullable":true},"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"ShareSaltResponse":{"type":"object","required":["shareId"],"properties":{"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"StoreDocumentKeyRequest":{"type":"object","required":["encryptedDek","nonce","keyVersion"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreEncryptedPrivateKeyRequest":{"type":"object","required":["encryptedPrivateKey","nonce"],"properties":{"encryptedPrivateKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted private key"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreMasterKeyBackupRequest":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams"],"properties":{"encryptedKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted master key"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string","description":"KDF type (e.g., \"argon2id\", \"pbkdf2\")","example":"argon2id"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StorePasswordProtectedShareKeyRequest":{"type":"object","required":["encryptedDek","salt","kdfParams"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StoreShareKeyRequest":{"type":"object","required":["encryptedDek"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"}}},"StoreWorkspaceKeyRequest":{"type":"object","required":["encryptedKek","keyVersion"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version (for key rotation tracking)"}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"file":{"type":"string","format":"binary"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserPublicKeyResponse":{"type":"object","required":["publicKey","keyType","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"keyType":{"type":"string"},"publicKey":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceKeyResponse":{"type":"object","required":["id","workspaceId","userId","encryptedKek","keyVersion","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKek":{"type":"string","format":"byte"},"id":{"type":"string","format":"uuid"},"keyVersion":{"type":"integer","format":"int32"},"userId":{"type":"string","format":"uuid"},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceKeyVersionResponse":{"type":"object","required":["workspaceId"],"properties":{"keyVersion":{"type":"integer","format":"int32","nullable":true},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"E2EE","description":"End-to-end encryption key management"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Storage","description":"Storage ingest APIs"},{"name":"Health","description":"System health checks"}]} From e6da74e7c40ca0564eb7bb19d5406526178a5a4a Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 8 Jan 2026 01:09:34 +0900 Subject: [PATCH 04/45] add: support yjs --- api/Cargo.lock | 58 + .../realtime/realtime_persistence_port.rs | 21 + .../documents/ports/realtime/realtime_port.rs | 3 + .../ports/realtime/realtime_types.rs | 123 ++ api/crates/infrastructure/Cargo.toml | 1 + .../infrastructure/src/core/crypto/ed25519.rs | 130 +++ .../infrastructure/src/core/crypto/mod.rs | 4 + .../src/documents/realtime/doc_persistence.rs | 48 +- .../src/documents/realtime/hub.rs | 1009 +++++++---------- .../src/documents/realtime/local_engine.rs | 10 +- .../src/documents/realtime/redis/engine.rs | 659 ++++++----- .../src/http/documents/handlers/content.rs | 13 +- .../presentation/src/http/documents/types.rs | 16 + .../202701020001_add_e2ee_tables.sql | 5 +- 14 files changed, 1214 insertions(+), 886 deletions(-) create mode 100644 api/crates/infrastructure/src/core/crypto/ed25519.rs diff --git a/api/Cargo.lock b/api/Cargo.lock index b4b2d9c0..83e2c7f8 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -1585,6 +1585,33 @@ dependencies = [ "cipher", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures", + "curve25519-dalek-derive", + "digest", + "fiat-crypto", + "rustc_version", + "subtle", + "zeroize", +] + +[[package]] +name = "curve25519-dalek-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] + [[package]] name = "darling" version = "0.14.4" @@ -1824,6 +1851,30 @@ dependencies = [ "signature 1.6.4", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek", + "ed25519", + "serde", + "sha2", + "subtle", + "zeroize", +] + [[package]] name = "either" version = "1.15.0" @@ -2040,6 +2091,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "filetime" version = "0.2.26" @@ -2888,6 +2945,7 @@ dependencies = [ "comrak", "domain", "dotenvy", + "ed25519-dalek", "extism", "futures-core", "futures-util", diff --git a/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs b/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs index 54df5a25..63d2c83a 100644 --- a/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs +++ b/api/crates/application/src/documents/ports/realtime/realtime_persistence_port.rs @@ -23,6 +23,8 @@ pub struct SnapshotEntry { pub bytes: Vec, pub nonce: Option>, pub signature: Option>, + /// The seq number at the time this snapshot was created (for E2EE sync) + pub seq_at_snapshot: Option, } /// Encryption metadata for E2EE content @@ -30,6 +32,8 @@ pub struct SnapshotEntry { pub struct ContentEncryptionMeta { pub nonce: Option>, pub signature: Option>, + /// The seq number at the time this snapshot was created (for E2EE sync) + pub seq_at_snapshot: Option, } /// Encrypted update data for E2EE documents @@ -41,6 +45,16 @@ pub struct EncryptedUpdateData { pub public_key: Option>, } +/// Encrypted update entry with sequence number (for retrieval) +#[derive(Debug, Clone)] +pub struct EncryptedUpdateEntry { + pub seq: i64, + pub data: Vec, + pub nonce: Option>, + pub signature: Option>, + pub public_key: Option>, +} + #[async_trait] pub trait DocPersistencePort: Send + Sync { async fn append_update_with_seq( @@ -77,6 +91,13 @@ pub trait DocPersistencePort: Send + Sync { async fn prune_updates_before(&self, doc_id: &Uuid, seq_inclusive: i64) -> PortResult<()>; async fn clear_updates(&self, doc_id: &Uuid) -> PortResult<()>; + + /// Get encrypted updates since a given sequence number (for E2EE sync) + async fn get_updates_since( + &self, + doc_id: &Uuid, + since_seq: i64, + ) -> PortResult>; } #[async_trait] diff --git a/api/crates/application/src/documents/ports/realtime/realtime_port.rs b/api/crates/application/src/documents/ports/realtime/realtime_port.rs index 06f46c9e..6e23c8c8 100644 --- a/api/crates/application/src/documents/ports/realtime/realtime_port.rs +++ b/api/crates/application/src/documents/ports/realtime/realtime_port.rs @@ -80,6 +80,7 @@ pub struct EncryptedUpdate { pub data: Vec, pub nonce: Option>, pub signature: Option>, + pub public_key: Option>, } /// Snapshot data with E2EE metadata @@ -91,4 +92,6 @@ pub struct SnapshotData { pub nonce: Option>, /// Signature for verification pub signature: Option>, + /// The seq number at the time this snapshot was created (for E2EE sync) + pub seq_at_snapshot: Option, } diff --git a/api/crates/application/src/documents/ports/realtime/realtime_types.rs b/api/crates/application/src/documents/ports/realtime/realtime_types.rs index d974de89..5329d3ab 100644 --- a/api/crates/application/src/documents/ports/realtime/realtime_types.rs +++ b/api/crates/application/src/documents/ports/realtime/realtime_types.rs @@ -1,6 +1,7 @@ use std::pin::Pin; use futures_util::{Sink, Stream}; +use serde::{Deserialize, Serialize}; use super::realtime_port::RealtimeError; @@ -8,3 +9,125 @@ pub type DynRealtimeSink = Pin, Error = RealtimeError> + Send + Sync + 'static>>; pub type DynRealtimeStream = Pin, RealtimeError>> + Send + Sync + 'static>>; + +// ============================================================================ +// E2EE Message Types (secsync-compatible) +// ============================================================================ + +/// Signature domains for E2EE messages (domain separation) +pub mod signature_domains { + pub const SNAPSHOT: &str = "refmd_snapshot"; + pub const UPDATE: &str = "refmd_update"; + pub const EPHEMERAL: &str = "refmd_ephemeral"; +} + +/// E2EE realtime message types +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum MessageType { + Update, + Snapshot, + Awareness, +} + +/// E2EE realtime message (JSON format over WebSocket) +/// Field names follow secsync specification (camelCase) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RealtimeMessage { + /// Message type + #[serde(rename = "type")] + pub msg_type: MessageType, + /// Base64-encoded ciphertext (XChaCha20-Poly1305) + pub ciphertext: String, + /// Base64-encoded nonce (24 bytes for XChaCha20-Poly1305) + pub nonce: String, + /// Base64-encoded Ed25519 signature + pub signature: String, + /// Public metadata (not encrypted, but authenticated via signature) + /// This is a Base64-encoded canonicalized JSON string + pub public_data: String, +} + +/// Update public data structure (for parsing publicData field) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct UpdatePublicData { + /// Document ID + pub doc_id: String, + /// Ed25519 public key (Base64-encoded, 32 bytes) + pub pub_key: String, + /// Reference snapshot ID + pub ref_snapshot_id: String, + /// Logical clock for ordering (per client) + pub clock: u64, +} + +/// Snapshot public data structure (for parsing publicData field) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SnapshotPublicData { + /// Document ID + pub doc_id: String, + /// Ed25519 public key (Base64-encoded, 32 bytes) + pub pub_key: String, + /// Snapshot ID + pub snapshot_id: String, + /// Parent snapshot ID + pub parent_snapshot_id: String, + /// Parent snapshot proof + pub parent_snapshot_proof: String, + /// Update clocks at the time of snapshot (pubKey -> clock) + pub parent_snapshot_update_clocks: std::collections::HashMap, +} + +/// Ephemeral message public data structure (for Awareness) +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EphemeralPublicData { + /// Document ID + pub doc_id: String, + /// Ed25519 public key (Base64-encoded, 32 bytes) + pub pub_key: String, +} + +impl RealtimeMessage { + /// Get the signature domain for this message type + pub fn signature_domain(&self) -> &'static str { + match self.msg_type { + MessageType::Update => signature_domains::UPDATE, + MessageType::Snapshot => signature_domains::SNAPSHOT, + MessageType::Awareness => signature_domains::EPHEMERAL, + } + } + + /// Parse the publicData field as UpdatePublicData + pub fn parse_update_public_data(&self) -> anyhow::Result { + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &self.public_data, + )?; + let json_str = String::from_utf8(decoded)?; + Ok(serde_json::from_str(&json_str)?) + } + + /// Parse the publicData field as SnapshotPublicData + pub fn parse_snapshot_public_data(&self) -> anyhow::Result { + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &self.public_data, + )?; + let json_str = String::from_utf8(decoded)?; + Ok(serde_json::from_str(&json_str)?) + } + + /// Parse the publicData field as EphemeralPublicData + pub fn parse_ephemeral_public_data(&self) -> anyhow::Result { + let decoded = base64::Engine::decode( + &base64::engine::general_purpose::STANDARD, + &self.public_data, + )?; + let json_str = String::from_utf8(decoded)?; + Ok(serde_json::from_str(&json_str)?) + } +} diff --git a/api/crates/infrastructure/Cargo.toml b/api/crates/infrastructure/Cargo.toml index 25f964fe..93fff06b 100644 --- a/api/crates/infrastructure/Cargo.toml +++ b/api/crates/infrastructure/Cargo.toml @@ -61,3 +61,4 @@ extism = { version = "1" } pandoc = "0.8" base64 = "0.21" +ed25519-dalek = { version = "2", features = ["std"] } diff --git a/api/crates/infrastructure/src/core/crypto/ed25519.rs b/api/crates/infrastructure/src/core/crypto/ed25519.rs new file mode 100644 index 00000000..4219a03a --- /dev/null +++ b/api/crates/infrastructure/src/core/crypto/ed25519.rs @@ -0,0 +1,130 @@ +//! Ed25519 signature verification for E2EE messages +//! +//! This module provides signature verification for E2EE realtime messages. +//! The server verifies signatures to ensure message integrity and authenticity, +//! but does not decrypt the message content. +//! +//! Signature format follows secsync specification: +//! `domain + canonicalize({nonce, ciphertext, publicData})` + +use anyhow::Result; +use ed25519_dalek::{Signature, Verifier, VerifyingKey}; + +/// Ed25519 signature verifier for E2EE messages +pub struct Ed25519Verifier; + +impl Ed25519Verifier { + /// Verify an Ed25519 signature + /// + /// # Arguments + /// * `public_key` - 32-byte Ed25519 public key + /// * `message` - Message bytes to verify + /// * `signature` - 64-byte Ed25519 signature + /// + /// # Returns + /// * `Ok(true)` if signature is valid + /// * `Ok(false)` if signature is invalid + /// * `Err` if key/signature format is invalid + pub fn verify(public_key: &[u8], message: &[u8], signature: &[u8]) -> Result { + // Validate key length (32 bytes) + if public_key.len() != 32 { + anyhow::bail!( + "invalid public key length: expected 32, got {}", + public_key.len() + ); + } + + // Validate signature length (64 bytes) + if signature.len() != 64 { + anyhow::bail!( + "invalid signature length: expected 64, got {}", + signature.len() + ); + } + + let verifying_key = VerifyingKey::from_bytes( + public_key + .try_into() + .map_err(|_| anyhow::anyhow!("invalid public key"))?, + ) + .map_err(|e| anyhow::anyhow!("failed to parse public key: {}", e))?; + + let sig = Signature::from_bytes( + signature + .try_into() + .map_err(|_| anyhow::anyhow!("invalid signature"))?, + ); + + Ok(verifying_key.verify(message, &sig).is_ok()) + } + + /// Build message bytes for signature verification (secsync format) + /// + /// Format: `domain + canonicalize({nonce, ciphertext, publicData})` + /// + /// Where canonicalize produces deterministic JSON (RFC 8785 JSON Canonicalization Scheme). + /// The publicData is passed as a Base64-encoded string (already canonicalized by client). + /// + /// # Arguments + /// * `domain` - Signature domain (e.g., "refmd_update", "refmd_snapshot", "refmd_ephemeral") + /// * `nonce` - Base64-encoded nonce string + /// * `ciphertext` - Base64-encoded ciphertext string + /// * `public_data` - Base64-encoded canonicalized publicData string + pub fn build_signing_message( + domain: &str, + nonce: &str, + ciphertext: &str, + public_data: &str, + ) -> Vec { + // Canonicalize {nonce, ciphertext, publicData} as JSON + // Keys must be sorted alphabetically per RFC 8785 + let canonical_json = format!( + r#"{{"ciphertext":"{}","nonce":"{}","publicData":"{}"}}"#, + ciphertext, nonce, public_data + ); + + // domain + canonicalized JSON + let mut message = Vec::with_capacity(domain.len() + canonical_json.len()); + message.extend_from_slice(domain.as_bytes()); + message.extend_from_slice(canonical_json.as_bytes()); + message + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_build_signing_message_secsync_format() { + let msg = Ed25519Verifier::build_signing_message( + "refmd_update", + "bm9uY2U=", // "nonce" in base64 + "Y2lwaGVy", // "cipher" in base64 + "cHVibGljRGF0YQ==", // "publicData" in base64 + ); + + let expected = r#"refmd_update{"ciphertext":"Y2lwaGVy","nonce":"bm9uY2U=","publicData":"cHVibGljRGF0YQ=="}"#; + assert_eq!(String::from_utf8(msg).unwrap(), expected); + } + + #[test] + fn test_verify_invalid_key_length() { + let result = Ed25519Verifier::verify(&[0u8; 16], &[0u8; 32], &[0u8; 64]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid public key length")); + } + + #[test] + fn test_verify_invalid_signature_length() { + let result = Ed25519Verifier::verify(&[0u8; 32], &[0u8; 32], &[0u8; 32]); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("invalid signature length")); + } +} diff --git a/api/crates/infrastructure/src/core/crypto/mod.rs b/api/crates/infrastructure/src/core/crypto/mod.rs index 989b68a4..076a951c 100644 --- a/api/crates/infrastructure/src/core/crypto/mod.rs +++ b/api/crates/infrastructure/src/core/crypto/mod.rs @@ -1,3 +1,7 @@ +pub mod ed25519; + +pub use ed25519::Ed25519Verifier; + use aes_gcm::aead::{Aead, KeyInit}; use aes_gcm::{Aes256Gcm, Key, Nonce}; use base64::Engine as _; diff --git a/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs b/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs index 13850053..d3ecb9ef 100644 --- a/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs +++ b/api/crates/infrastructure/src/documents/realtime/doc_persistence.rs @@ -6,7 +6,7 @@ use crate::core::db::PgPool; use application::core::ports::errors::PortResult; use application::documents::ports::realtime::realtime_persistence_port::{ ContentEncryptionMeta, DocPersistencePort, DocumentMissingError, EncryptedUpdateData, - SnapshotEntry, + EncryptedUpdateEntry, SnapshotEntry, }; #[derive(Clone)] @@ -90,18 +90,19 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { encryption_meta: Option<&ContentEncryptionMeta>, ) -> PortResult<()> { let out: anyhow::Result<()> = async { - let (nonce, signature) = encryption_meta - .map(|m| (m.nonce.as_deref(), m.signature.as_deref())) - .unwrap_or((None, None)); + let (nonce, signature, seq_at_snapshot) = encryption_meta + .map(|m| (m.nonce.as_deref(), m.signature.as_deref(), m.seq_at_snapshot)) + .unwrap_or((None, None, None)); let result = sqlx::query( - "INSERT INTO document_snapshots (document_id, version, snapshot, nonce, signature) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (document_id, version) DO UPDATE SET snapshot = EXCLUDED.snapshot, nonce = EXCLUDED.nonce, signature = EXCLUDED.signature", + "INSERT INTO document_snapshots (document_id, version, snapshot, nonce, signature, seq_at_snapshot) VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (document_id, version) DO UPDATE SET snapshot = EXCLUDED.snapshot, nonce = EXCLUDED.nonce, signature = EXCLUDED.signature, seq_at_snapshot = EXCLUDED.seq_at_snapshot", ) .bind(doc_id) .bind(version as i32) .bind(snapshot) .bind(nonce) .bind(signature) + .bind(seq_at_snapshot) .execute(&self.pool) .await; @@ -128,7 +129,7 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { async fn latest_snapshot_entry(&self, doc_id: &Uuid) -> PortResult> { let out: anyhow::Result> = async { let row = sqlx::query( - "SELECT version, snapshot, nonce, signature FROM document_snapshots WHERE document_id = $1 + "SELECT version, snapshot, nonce, signature, seq_at_snapshot FROM document_snapshots WHERE document_id = $1 ORDER BY version DESC LIMIT 1", ) .bind(doc_id) @@ -139,6 +140,7 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { bytes: row.get("snapshot"), nonce: row.try_get("nonce").ok(), signature: row.try_get("signature").ok(), + seq_at_snapshot: row.try_get("seq_at_snapshot").ok().flatten(), })) } .await; @@ -197,4 +199,36 @@ impl DocPersistencePort for SqlxDocPersistenceAdapter { .await; out.map_err(Into::into) } + + async fn get_updates_since( + &self, + doc_id: &Uuid, + since_seq: i64, + ) -> PortResult> { + let out: anyhow::Result> = async { + let rows = sqlx::query( + r#"SELECT seq, update, nonce, signature, public_key + FROM document_updates + WHERE document_id = $1 AND seq > $2 + ORDER BY seq ASC"#, + ) + .bind(doc_id) + .bind(since_seq) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|row| EncryptedUpdateEntry { + seq: row.get("seq"), + data: row.get("update"), + nonce: row.try_get("nonce").ok().flatten(), + signature: row.try_get("signature").ok().flatten(), + public_key: row.try_get("public_key").ok().flatten(), + }) + .collect()) + } + .await; + out.map_err(Into::into) + } } diff --git a/api/crates/infrastructure/src/documents/realtime/hub.rs b/api/crates/infrastructure/src/documents/realtime/hub.rs index a968cc09..b75cee0b 100644 --- a/api/crates/infrastructure/src/documents/realtime/hub.rs +++ b/api/crates/infrastructure/src/documents/realtime/hub.rs @@ -1,333 +1,148 @@ -use std::collections::{HashMap, HashSet}; +use std::collections::HashMap; use std::sync::Arc; -use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicBool, Ordering}; -use anyhow::Context; -use chrono::Utc; -use futures_util::SinkExt; -use tokio::sync::mpsc; +use base64::Engine; +use futures_util::{SinkExt, StreamExt}; use tokio::sync::{Mutex, RwLock}; -use tokio::time::{Duration, Instant, sleep}; +use tokio::time::Duration; use uuid::Uuid; -use yrs::GetString; -use yrs::block::ClientID; -use yrs::encoding::write::Write as YWrite; -use yrs::sync::awareness::AwarenessUpdate; -use yrs::sync::protocol::{MSG_SYNC, MSG_SYNC_UPDATE}; -use yrs::sync::{DefaultProtocol, Error as SyncError, Protocol}; -use yrs::updates::decoder::Decode; -use yrs::updates::encoder::{Encoder, EncoderV1}; -use yrs::{Doc, ReadTxn, StateVector, Text, Transact, Update}; -use yrs_warp::AwarenessRef; -use yrs_warp::broadcast::BroadcastGroup; - -use crate::documents::realtime::utils::wrap_stream_with_edit_guard; + +use crate::core::crypto::Ed25519Verifier; use crate::documents::realtime::{DynRealtimeSink, DynRealtimeStream}; use application::documents::ports::realtime::realtime_persistence_port::{ - DocPersistencePort, DocumentMissingError, -}; -use application::documents::services::realtime::doc_hydration::{ - DocHydrationService, HydrationOptions, + ContentEncryptionMeta, DocPersistencePort, EncryptedUpdateData, }; -use application::documents::services::realtime::snapshot::{ - SnapshotArchiveKind, SnapshotArchiveOptions, SnapshotPersistOptions, SnapshotService, +use application::documents::ports::realtime::realtime_types::{ + MessageType, RealtimeMessage, }; +use application::documents::services::realtime::snapshot::SnapshotService; type SharedRealtimeSink = Arc>; +/// E2EE Document Room - simple relay without CRDT merge +/// Server only relays encrypted messages and verifies signatures #[derive(Clone)] -pub struct DocumentRoom { - pub doc: Doc, - pub awareness: AwarenessRef, - pub broadcast: Arc, - #[allow(dead_code)] - persist_sub: yrs::Subscription, - pub seq: Arc>, // latest persisted seq +pub struct E2EEDocumentRoom { + /// Connected clients for broadcasting + clients: Arc>>, + /// Latest persisted sequence number + pub seq: Arc>, + /// Flag to skip filesystem persistence (e.g., after ingest) pub skip_fs_persist: Arc, } +impl E2EEDocumentRoom { + pub fn new(start_seq: i64) -> Self { + Self { + clients: Arc::new(RwLock::new(Vec::new())), + seq: Arc::new(Mutex::new(start_seq)), + skip_fs_persist: Arc::new(AtomicBool::new(false)), + } + } + + /// Add a client to the room + pub async fn add_client(&self, sink: SharedRealtimeSink) { + self.clients.write().await.push(sink); + } + + /// Remove a client from the room + pub async fn remove_client(&self, sink: &SharedRealtimeSink) { + let mut clients = self.clients.write().await; + clients.retain(|c| !Arc::ptr_eq(c, sink)); + } + + /// Broadcast message to all clients except the sender + pub async fn broadcast_except(&self, message: &[u8], sender: &SharedRealtimeSink) { + let clients = self.clients.read().await; + for client in clients.iter() { + if Arc::ptr_eq(client, sender) { + continue; + } + let mut guard = client.lock().await; + if let Err(e) = guard.send(message.to_vec()).await { + tracing::debug!(error = %e, "e2ee_broadcast_send_failed"); + } + } + } + + /// Get current client count + pub async fn client_count(&self) -> usize { + self.clients.read().await.len() + } +} + +/// E2EE Hub - manages document rooms with encrypted relay #[derive(Clone)] pub struct Hub { - inner: Arc>>>, - hydration_service: Arc, + /// Document rooms by document ID + inner: Arc>>>, + /// Snapshot service for persistence snapshot_service: Arc, + /// Document persistence port persistence: Arc, - save_flags: Arc>>, - auto_archive_interval: Duration, - last_auto_archive: Arc>>, + /// Edit flags per document edit_flags: Arc>>>, + /// Auto archive interval (0 = disabled) + #[allow(dead_code)] + auto_archive_interval: Duration, } impl Hub { + /// Create a new Hub + /// + /// Note: hydration_service parameter is kept for API compatibility but not used + /// (clients handle hydration with their own keys) pub fn new( - hydration_service: Arc, + _hydration_service: Arc, snapshot_service: Arc, persistence: Arc, auto_archive_interval: Duration, ) -> Self { Self { inner: Arc::new(RwLock::new(HashMap::new())), - hydration_service, snapshot_service, persistence, - save_flags: Arc::new(Mutex::new(HashMap::new())), - auto_archive_interval, - last_auto_archive: Arc::new(Mutex::new(HashMap::new())), edit_flags: Arc::new(RwLock::new(HashMap::new())), + auto_archive_interval, } } - pub async fn get_or_create(&self, doc_id: &str) -> anyhow::Result> { + + /// Get or create a document room + /// + /// The room is a simple relay structure without Yjs Doc. + /// The server doesn't process document content, only relays encrypted messages. + pub async fn get_or_create(&self, doc_id: &str) -> anyhow::Result> { + // Return existing room if available if let Some(r) = self.inner.read().await.get(doc_id).cloned() { return Ok(r); } - // Create Doc; hydration will run asynchronously after room is registered to avoid blocking WS - let doc = Doc::new(); let doc_uuid = Uuid::parse_str(doc_id)?; - let awareness: AwarenessRef = Arc::new(yrs::sync::Awareness::new(doc.clone())); - let bcast = Arc::new(BroadcastGroup::new(awareness.clone(), 64).await); - - let save_flags = self.save_flags.clone(); - let skip_fs_persist_flag = Arc::new(AtomicBool::new(false)); + // Get the latest sequence number from persistence let start_seq = self .persistence .latest_update_seq(&doc_uuid) .await? .unwrap_or(0); - let seq = Arc::new(Mutex::new(start_seq)); - // Persist updates through a channel. We'll await send in a spawned task to avoid dropping updates. - let (tx, mut rx) = mpsc::channel::>(512); - let persistence = self.persistence.clone(); - let snapshot_service = self.snapshot_service.clone(); - let last_auto_archive = self.last_auto_archive.clone(); - let auto_archive_interval = self.auto_archive_interval; - let persist_doc = doc_uuid; - let persist_seq = seq.clone(); - let doc_for_snap = doc.clone(); - tokio::spawn(async move { - while let Some(bytes) = rx.recv().await { - let mut guard = persist_seq.lock().await; - *guard += 1; - let s = *guard; - if let Err(e) = persistence - .append_update_with_seq(&persist_doc, s, &bytes) - .await - { - tracing::error!( - document_id = %persist_doc, - seq = s, - error = ?e, - "persist_document_update_failed" - ); - } - if s % 100 == 0 && !auto_archive_interval.is_zero() { - let should_archive = { - let mut guard = last_auto_archive.lock().await; - let now = Instant::now(); - match guard.get(&persist_doc.to_string()) { - Some(last) if now.duration_since(*last) < auto_archive_interval => { - false - } - _ => { - guard.insert(persist_doc.to_string(), now); - true - } - } - }; - - if should_archive { - match snapshot_service - .persist_snapshot( - &persist_doc, - &doc_for_snap, - SnapshotPersistOptions { - clear_updates: false, - skip_if_unchanged: true, - ..Default::default() - }, - ) - .await - { - Ok(result) => { - if result.persisted { - let label = format!( - "Snapshot {}", - Utc::now().format("%Y-%m-%d %H:%M:%S UTC") - ); - if let Err(e) = snapshot_service - .archive_snapshot( - &persist_doc, - &result.snapshot_bytes, - result.version, - SnapshotArchiveOptions { - label: label.as_str(), - notes: None, - kind: SnapshotArchiveKind::Automatic, - created_by: None, - }, - ) - .await - { - tracing::debug!( - document_id = %persist_doc, - version = result.version, - error = ?e, - "persist_document_snapshot_archive_failed" - ); - } - } else { - tracing::debug!( - document_id = %persist_doc, - version = result.version, - "persist_document_snapshot_skipped_no_changes" - ); - } - } - Err(err) if err.downcast_ref::().is_some() => { - tracing::debug!( - document_id = %persist_doc, - "persist_document_snapshot_missing_document" - ); - } - Err(e) => { - tracing::error!( - document_id = %persist_doc, - version = s, - error = ?e, - "persist_document_snapshot_failed" - ); - } - } - } - } - } - }); - let tx_obs = tx.clone(); - let hub_for_save = self.clone(); - let doc_id_str = doc_uuid.to_string(); - let doc_for_markdown = doc.clone(); - let skip_flag_for_updates = skip_fs_persist_flag.clone(); - let persist_sub = doc - .observe_update_v1(move |_txn, u| { - // Send to the channel asynchronously to avoid blocking and prevent drops under load - let tx_clone = tx_obs.clone(); - let bytes = u.update.clone(); - tokio::spawn(async move { - let _ = tx_clone.send(bytes).await; - }); - // schedule fs save (debounced) - let save_flags = save_flags.clone(); - let doc_id_s = doc_id_str.clone(); - let hub_clone = hub_for_save.clone(); - let doc_for_markdown = doc_for_markdown.clone(); - let skip_flag = skip_flag_for_updates.clone(); - tokio::spawn(async move { - // simple debounce: set flag and sleep; if still set after sleep, run - { - let mut m = save_flags.lock().await; - m.insert(doc_id_s.clone(), true); - } - sleep(Duration::from_millis(600)).await; - let should_run = { - let mut m = save_flags.lock().await; - m.remove(&doc_id_s).is_some() - }; - if should_run { - if let Ok(doc_uuid) = Uuid::parse_str(&doc_id_s) { - if skip_flag.swap(false, Ordering::SeqCst) { - tracing::debug!( - document_id = %doc_id_s, - "debounced_save_skipped_after_ingest" - ); - return; - } - if let Err(e) = hub_clone - .snapshot_service - .write_markdown(&doc_uuid, &doc_for_markdown) - .await - { - tracing::error!( - document_id = %doc_id_s, - error = ?e, - "debounced_save_failed" - ); - } - } - } - }); - }) - .unwrap(); - - let room = Arc::new(DocumentRoom { - doc: doc.clone(), - awareness: awareness.clone(), - broadcast: bcast.clone(), - persist_sub, - seq: seq.clone(), - skip_fs_persist: skip_fs_persist_flag.clone(), - }); + // Create a simple E2EE room (no Yjs Doc needed) + let room = Arc::new(E2EEDocumentRoom::new(start_seq)); + + // Register the room self.inner .write() .await .insert(doc_id.to_string(), room.clone()); let _ = self.ensure_edit_flag(doc_id).await; - // Hydrate in background (snapshot + updates). Non-blocking for WS subscription - let bcast_h = bcast.clone(); - let hydration = self.hydration_service.clone(); - let seq_for_hydrate = seq.clone(); - let skip_flag_for_hydrate = skip_fs_persist_flag.clone(); - tokio::spawn(async move { - tracing::debug!(%doc_uuid, "hydrate:start"); - match hydration - .hydrate(&doc_uuid, HydrationOptions::default()) - .await - { - Ok(hydrated_state) => { - let update_bin = { - let txn = hydrated_state.doc.transact(); - txn.encode_state_as_update_v1(&StateVector::default()) - }; - if let Ok(update) = Update::decode_v1(&update_bin) { - let mut txn = doc.transact_mut(); - if let Err(e) = txn.apply_update(update) { - tracing::debug!(document_id = %doc_uuid, error = ?e, "hydrate_apply_failed"); - } else { - skip_flag_for_hydrate.store(true, Ordering::SeqCst); - } - } - { - let mut guard = seq_for_hydrate.lock().await; - if hydrated_state.last_seq > *guard { - *guard = hydrated_state.last_seq; - } - } + tracing::debug!( + document_id = %doc_id, + start_seq = start_seq, + "e2ee_room_created" + ); - let txn = doc.transact(); - let bin = txn.encode_state_as_update_v1(&StateVector::default()); - drop(txn); - let mut enc = EncoderV1::new(); - enc.write_var(MSG_SYNC); - enc.write_var(MSG_SYNC_UPDATE); - enc.write_buf(&bin); - let msg = enc.to_vec(); - if let Err(e) = bcast_h.broadcast(msg) { - tracing::debug!( - document_id = %doc_uuid, - error = %e, - "hydrate:broadcast_failed" - ); - } - tracing::debug!(document_id = %doc_uuid, "hydrate:complete"); - } - Err(e) => { - tracing::error!(document_id = %doc_uuid, error = ?e, "hydrate_failed"); - } - } - }); Ok(room) } @@ -335,67 +150,10 @@ impl Hub { self.snapshot_service.clone() } - pub async fn apply_snapshot(&self, doc_id: &str, snapshot: &Doc) -> anyhow::Result<()> { - let room = self.get_or_create(doc_id).await?; - let new_markdown = { - let txt_new = snapshot.get_or_insert_text("content"); - let txn = snapshot.transact(); - txt_new.get_string(&txn) - }; - - let update_bytes = { - let txt = room.doc.get_or_insert_text("content"); - let mut txn = room.doc.transact_mut(); - let len = txt.len(&txn); - if len > 0 { - txt.remove_range(&mut txn, 0, len); - } - if !new_markdown.is_empty() { - txt.insert(&mut txn, 0, &new_markdown); - } - txn.encode_update_v1() - }; - - if update_bytes.is_empty() { - return Ok(()); - } - - room.skip_fs_persist.store(true, Ordering::SeqCst); - - let mut encoder = EncoderV1::new(); - encoder.write_var(MSG_SYNC); - encoder.write_var(MSG_SYNC_UPDATE); - encoder.write_buf(&update_bytes); - let frame = encoder.to_vec(); - room.broadcast - .broadcast(frame) - .map_err(|err| anyhow::anyhow!(err)) - .context("broadcast_snapshot_update")?; - - Ok(()) - } - - pub async fn get_content(&self, doc_id: &str) -> anyhow::Result> { - if let Some(room) = self.inner.read().await.get(doc_id).cloned() { - let txt = room.doc.get_or_insert_text("content"); - let txn = room.doc.transact(); - return Ok(Some(txt.get_string(&txn))); - } - - let uuid = match Uuid::parse_str(doc_id) { - Ok(id) => id, - Err(_) => return Ok(None), - }; - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - let txt = hydrated.doc.get_or_insert_text("content"); - let txn = hydrated.doc.transact(); - Ok(Some(txt.get_string(&txn))) - } - - /// Get Yjs snapshot with E2EE metadata (nonce, signature) + /// Get encrypted snapshot with metadata (nonce, signature, seq_at_snapshot) + /// + /// Returns the encrypted snapshot directly from persistence. + /// The server cannot decode the content. pub async fn get_snapshot( &self, doc_id: &str, @@ -408,59 +166,94 @@ impl Hub { Err(_) => return Ok(None), }; - // First try to get from in-memory room (for current state) - if let Some(room) = self.inner.read().await.get(doc_id).cloned() { - let txn = room.doc.transact(); - let data = txn.encode_state_as_update_v1(&yrs::StateVector::default()); - - // Get nonce/signature from DB if available - if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { - return Ok(Some(SnapshotData { - data, - nonce: entry.nonce, - signature: entry.signature, - })); - } - - return Ok(Some(SnapshotData { - data, - nonce: None, - signature: None, - })); - } - - // No in-memory room, get from persistence directly + // Get encrypted snapshot from persistence if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { return Ok(Some(SnapshotData { data: entry.bytes, nonce: entry.nonce, signature: entry.signature, + seq_at_snapshot: entry.seq_at_snapshot, })); } - // Fallback: hydrate and encode - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - let txn = hydrated.doc.transact(); - Ok(Some(SnapshotData { - data: txn.encode_state_as_update_v1(&yrs::StateVector::default()), - nonce: None, - signature: None, - })) + Ok(None) } - /// Apply encrypted update for E2EE documents - /// This stores the encrypted update data directly without processing it in Yjs - pub async fn apply_encrypted_update( + /// Get plaintext content is not available + /// + /// Returns None as the server cannot decrypt content. + pub async fn get_content(&self, _doc_id: &str) -> anyhow::Result> { + // Server cannot access plaintext content + Ok(None) + } + + /// Apply plaintext snapshot is not available + /// + /// Use apply_encrypted_snapshot instead. + pub async fn apply_snapshot( + &self, + _doc_id: &str, + _snapshot: &yrs::Doc, + ) -> anyhow::Result<()> { + anyhow::bail!("apply_snapshot not available, use apply_encrypted_snapshot") + } + + /// Apply encrypted snapshot + pub async fn apply_encrypted_snapshot( &self, doc_id: &str, data: &[u8], nonce: Option<&[u8]>, + signature: Option<&[u8]>, ) -> anyhow::Result<()> { - use application::documents::ports::realtime::realtime_persistence_port::EncryptedUpdateData; + let doc_uuid = Uuid::parse_str(doc_id)?; + + // Get the next version number + let version = self + .persistence + .latest_snapshot_version(&doc_uuid) + .await? + .unwrap_or(0) + + 1; + + // Get current seq to record in snapshot (for E2EE sync) + let room = self.get_or_create(doc_id).await?; + let current_seq = { + let guard = room.seq.lock().await; + *guard + }; + + // Store the encrypted snapshot with metadata including seq_at_snapshot + let encryption_meta = Some(ContentEncryptionMeta { + nonce: nonce.map(|n| n.to_vec()), + signature: signature.map(|s| s.to_vec()), + seq_at_snapshot: Some(current_seq), + }); + + self.persistence + .persist_snapshot(&doc_uuid, version, data, encryption_meta.as_ref()) + .await + .map_err(|e| anyhow::anyhow!("failed to persist encrypted snapshot: {:?}", e))?; + + tracing::debug!( + document_id = %doc_id, + version = version, + seq_at_snapshot = current_seq, + "e2ee_snapshot_persisted" + ); + + Ok(()) + } + /// Apply encrypted update + pub async fn apply_encrypted_update( + &self, + doc_id: &str, + data: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, + public_key: Option<&[u8]>, + ) -> anyhow::Result<()> { let doc_uuid = Uuid::parse_str(doc_id)?; // Get the current seq number (create room if needed to track seq) @@ -475,8 +268,8 @@ impl Hub { let update_data = EncryptedUpdateData { data: data.to_vec(), nonce: nonce.map(|n| n.to_vec()), - signature: None, - public_key: None, + signature: signature.map(|s| s.to_vec()), + public_key: public_key.map(|p| p.to_vec()), }; self.persistence @@ -484,17 +277,27 @@ impl Hub { .await .map_err(|e| anyhow::anyhow!("failed to persist encrypted update: {:?}", e))?; + tracing::debug!( + document_id = %doc_id, + seq = seq, + "e2ee_update_persisted" + ); + Ok(()) } } impl Hub { + /// Prune old updates for all documents + /// + /// Snapshot creation is client-driven. This method only + /// prunes old encrypted updates after the window. pub async fn snapshot_all( &self, - keep_versions: i64, + _keep_versions: i64, updates_keep_window: i64, ) -> anyhow::Result<()> { - let rooms: Vec<(String, Arc)> = { + let rooms: Vec<(String, Arc)> = { let map = self.inner.read().await; map.iter().map(|(k, v)| (k.clone(), v.clone())).collect() }; @@ -508,40 +311,40 @@ impl Hub { *guard }; let cutoff = (current_seq - updates_keep_window).max(0); - self.snapshot_service - .persist_snapshot( - &doc_uuid, - &room.doc, - SnapshotPersistOptions { - clear_updates: false, - skip_if_unchanged: true, - prune_snapshots: Some(keep_versions), - prune_updates_before: Some(cutoff), - }, - ) - .await?; + + // Prune old updates (encrypted updates before cutoff) + if let Err(e) = self.persistence.prune_updates_before(&doc_uuid, cutoff).await { + tracing::warn!( + document_id = %doc_id, + error = %e, + "e2ee_prune_updates_failed" + ); + } } Ok(()) } + /// Force save to filesystem is not available + /// + /// The server cannot decrypt content to write markdown. pub async fn force_save_to_fs(&self, doc_id: &str) -> anyhow::Result<()> { - let uuid = Uuid::parse_str(doc_id)?; - if let Some(room) = self.inner.read().await.get(doc_id).cloned() { - self.snapshot_service - .write_markdown(&uuid, &room.doc) - .await?; - } else { - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - self.snapshot_service - .write_markdown(&uuid, &hydrated.doc) - .await?; - } + tracing::warn!( + document_id = %doc_id, + "force_save_to_fs called - server cannot decrypt content" + ); + // We cannot write plaintext markdown + // This is a no-op but we don't fail to maintain API compatibility Ok(()) } + /// Subscribe to a document room for realtime collaboration + /// + /// This method: + /// 1. Sends initial encrypted snapshot to the client + /// 2. Processes incoming E2EE messages (JSON format) + /// 3. Verifies Ed25519 signatures + /// 4. Relays valid messages to other clients + /// 5. Persists encrypted updates to the database pub async fn subscribe( &self, doc_id: &str, @@ -553,62 +356,235 @@ impl Hub { let sink: SharedRealtimeSink = Arc::new(Mutex::new(sink)); let edit_flag = self.ensure_edit_flag(doc_id).await; let effective_can_edit = can_edit && edit_flag.load(Ordering::Relaxed); - let guarded_stream = - wrap_stream_with_edit_guard(stream, doc_id.to_string(), edit_flag.clone()); - let tracked_clients: Arc>> = - Arc::new(StdMutex::new(HashMap::new())); - let awareness = room.awareness.clone(); - let result = if effective_can_edit { - let subscription = room.broadcast.subscribe_with( - sink.clone(), - guarded_stream, - TrackingProtocol::new(DefaultProtocol, tracked_clients.clone()), - ); - Self::send_protocol_start(sink.clone(), awareness.clone(), DefaultProtocol).await?; - subscription.completed().await + + // Add client to room for broadcast + room.add_client(sink.clone()).await; + + // Send initial encrypted snapshot if available + let snapshot_seq = if let Ok(Some(snapshot)) = self.get_snapshot(doc_id).await { + let init_msg = serde_json::json!({ + "type": "init", + "snapshot": { + "data": base64::engine::general_purpose::STANDARD.encode(&snapshot.data), + "nonce": snapshot.nonce.map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + "signature": snapshot.signature.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + "seq_at_snapshot": snapshot.seq_at_snapshot, + } + }); + let msg_bytes = serde_json::to_vec(&init_msg)?; + let mut guard = sink.lock().await; + if let Err(e) = guard.send(msg_bytes).await { + tracing::debug!(error = %e, "e2ee_init_send_failed"); + } + drop(guard); + // Use seq_at_snapshot to determine which updates to send + snapshot.seq_at_snapshot.unwrap_or(0) } else { - let subscription = room.broadcast.subscribe_with( - sink.clone(), - guarded_stream, - TrackingProtocol::new(ReadOnlyProtocol, tracked_clients.clone()), - ); - Self::send_protocol_start(sink.clone(), awareness.clone(), ReadOnlyProtocol).await?; - subscription.completed().await + 0 }; - Self::cleanup_tracked_clients(awareness, tracked_clients); - result.map_err(|e| anyhow::anyhow!(e)) - } - - fn cleanup_tracked_clients( - awareness: AwarenessRef, - tracked: Arc>>, - ) { - // Remove awareness states owned by a connection once it disconnects to avoid ghost cursors. - let tracked_clients: Vec<(ClientID, u32)> = { - let mut guard = tracked.lock().expect("tracked clients mutex poisoned"); - guard.drain().collect() - }; - if tracked_clients.is_empty() { - return; + // Send pending encrypted updates since last snapshot (only updates after snapshot) + let doc_uuid = Uuid::parse_str(doc_id)?; + if let Ok(updates) = self.persistence.get_updates_since(&doc_uuid, snapshot_seq).await { + for update in updates { + let update_msg = serde_json::json!({ + "type": "sync_update", + "update": { + "data": base64::engine::general_purpose::STANDARD.encode(&update.data), + "nonce": update.nonce.map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + "signature": update.signature.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + "public_key": update.public_key.map(|p| base64::engine::general_purpose::STANDARD.encode(&p)), + "seq": update.seq, + } + }); + let msg_bytes = serde_json::to_vec(&update_msg)?; + let mut guard = sink.lock().await; + if let Err(e) = guard.send(msg_bytes).await { + tracing::debug!(error = %e, "e2ee_sync_update_send_failed"); + break; + } + drop(guard); + } } - let active_clients: HashSet = awareness - .iter() - .filter(|(_, state)| state.data.is_some()) - .map(|(id, _)| id) - .collect(); + // Process incoming messages + let mut stream = stream; + while let Some(result) = stream.next().await { + let data = match result { + Ok(d) => d, + Err(e) => { + tracing::debug!(error = %e, "e2ee_stream_error"); + break; + } + }; + + // Parse E2EE message (secsync-compatible format) + let msg: RealtimeMessage = match serde_json::from_slice(&data) { + Ok(m) => m, + Err(e) => { + tracing::debug!(error = %e, "e2ee_parse_error"); + continue; + } + }; + + // Extract public key from publicData based on message type + let (pub_key_b64, msg_doc_id) = match msg.msg_type { + MessageType::Update => { + match msg.parse_update_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), + Err(e) => { + tracing::debug!(error = %e, "e2ee_parse_update_public_data_error"); + continue; + } + } + } + MessageType::Snapshot => { + match msg.parse_snapshot_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), + Err(e) => { + tracing::debug!(error = %e, "e2ee_parse_snapshot_public_data_error"); + continue; + } + } + } + MessageType::Awareness => { + match msg.parse_ephemeral_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), + Err(e) => { + tracing::debug!(error = %e, "e2ee_parse_ephemeral_public_data_error"); + continue; + } + } + } + }; + + // Verify document ID matches + if msg_doc_id != doc_id { + tracing::warn!( + expected = %doc_id, + actual = %msg_doc_id, + "e2ee_doc_id_mismatch" + ); + continue; + } - for (client_id, recorded_clock) in tracked_clients { - if !active_clients.contains(&client_id) { + // Check edit permission for updates/snapshots + if !effective_can_edit + && matches!(msg.msg_type, MessageType::Update | MessageType::Snapshot) + { + tracing::debug!("e2ee_write_rejected_readonly"); continue; } - if let Some((current_clock, _)) = awareness.meta(client_id) { - if current_clock == recorded_clock { - awareness.remove_state(client_id); + + // Decode signature components + let public_key = match base64::engine::general_purpose::STANDARD.decode(&pub_key_b64) { + Ok(k) => k, + Err(e) => { + tracing::debug!(error = %e, "e2ee_public_key_decode_error"); + continue; + } + }; + let signature = + match base64::engine::general_purpose::STANDARD.decode(&msg.signature) { + Ok(s) => s, + Err(e) => { + tracing::debug!(error = %e, "e2ee_signature_decode_error"); + continue; + } + }; + + // Verify Ed25519 signature (secsync format: domain + canonicalize({nonce, ciphertext, publicData})) + let signing_message = Ed25519Verifier::build_signing_message( + msg.signature_domain(), + &msg.nonce, + &msg.ciphertext, + &msg.public_data, + ); + + match Ed25519Verifier::verify(&public_key, &signing_message, &signature) { + Ok(true) => { + // Signature valid + } + Ok(false) => { + tracing::warn!( + document_id = %doc_id, + "e2ee_signature_invalid" + ); + continue; + } + Err(e) => { + tracing::warn!( + document_id = %doc_id, + error = %e, + "e2ee_signature_verify_error" + ); + continue; + } + } + + // Decode ciphertext and nonce for persistence + let ciphertext = + match base64::engine::general_purpose::STANDARD.decode(&msg.ciphertext) { + Ok(c) => c, + Err(e) => { + tracing::debug!(error = %e, "e2ee_ciphertext_decode_error"); + continue; + } + }; + let nonce = match base64::engine::general_purpose::STANDARD.decode(&msg.nonce) { + Ok(n) => n, + Err(e) => { + tracing::debug!(error = %e, "e2ee_nonce_decode_error"); + continue; + } + }; + + // Process message by type + match msg.msg_type { + MessageType::Update => { + // Persist encrypted update + if let Err(e) = self + .apply_encrypted_update( + doc_id, + &ciphertext, + Some(&nonce), + Some(&signature), + Some(&public_key), + ) + .await + { + tracing::warn!(error = %e, "e2ee_persist_update_failed"); + } + } + MessageType::Snapshot => { + // Persist encrypted snapshot + if let Err(e) = self + .apply_encrypted_snapshot(doc_id, &ciphertext, Some(&nonce), Some(&signature)) + .await + { + tracing::warn!(error = %e, "e2ee_persist_snapshot_failed"); + } + } + MessageType::Awareness => { + // Awareness messages are ephemeral, no persistence } } + + // Relay to other clients + room.broadcast_except(&data, &sink).await; } + + // Remove client from room + room.remove_client(&sink).await; + + let remaining = room.client_count().await; + tracing::debug!( + document_id = %doc_id, + remaining_clients = remaining, + "e2ee_client_disconnected" + ); + + Ok(()) } async fn ensure_edit_flag(&self, doc_id: &str) -> Arc { @@ -625,140 +601,3 @@ impl Hub { Ok(()) } } - -#[derive(Debug, Clone, Copy)] -struct ReadOnlyProtocol; - -impl yrs::sync::Protocol for ReadOnlyProtocol { - fn handle_sync_step2( - &self, - _awareness: &yrs::sync::Awareness, - _update: yrs::Update, - ) -> Result, yrs::sync::Error> { - Ok(None) - } - - fn handle_update( - &self, - _awareness: &yrs::sync::Awareness, - _update: yrs::Update, - ) -> Result, yrs::sync::Error> { - Ok(None) - } -} - -struct TrackingProtocol

{ - inner: P, - tracked: Arc>>, -} - -impl

TrackingProtocol

{ - fn new(inner: P, tracked: Arc>>) -> Self { - Self { inner, tracked } - } -} - -impl

Protocol for TrackingProtocol

-where - P: Protocol + Send + Sync, -{ - fn start(&self, awareness: &yrs::sync::Awareness, encoder: &mut E) -> Result<(), SyncError> - where - E: Encoder, - { - Protocol::start(&self.inner, awareness, encoder) - } - - fn handle_sync_step1( - &self, - awareness: &yrs::sync::Awareness, - sv: StateVector, - ) -> Result, SyncError> { - Protocol::handle_sync_step1(&self.inner, awareness, sv) - } - - fn handle_sync_step2( - &self, - awareness: &yrs::sync::Awareness, - update: Update, - ) -> Result, SyncError> { - Protocol::handle_sync_step2(&self.inner, awareness, update) - } - - fn handle_update( - &self, - awareness: &yrs::sync::Awareness, - update: Update, - ) -> Result, SyncError> { - Protocol::handle_update(&self.inner, awareness, update) - } - - fn handle_auth( - &self, - awareness: &yrs::sync::Awareness, - deny_reason: Option, - ) -> Result, SyncError> { - Protocol::handle_auth(&self.inner, awareness, deny_reason) - } - - fn handle_awareness_query( - &self, - awareness: &yrs::sync::Awareness, - ) -> Result, SyncError> { - Protocol::handle_awareness_query(&self.inner, awareness) - } - - fn handle_awareness_update( - &self, - awareness: &yrs::sync::Awareness, - update: AwarenessUpdate, - ) -> Result, SyncError> { - { - let mut guard = self.tracked.lock().expect("tracked clients mutex poisoned"); - for (&client_id, entry) in update.clients.iter() { - if entry.json.as_ref() == "null" { - guard.remove(&client_id); - } else { - guard.insert(client_id, entry.clock); - } - } - } - awareness.apply_update(update)?; - Ok(None) - } - - fn missing_handle( - &self, - awareness: &yrs::sync::Awareness, - tag: u8, - data: Vec, - ) -> Result, SyncError> { - Protocol::missing_handle(&self.inner, awareness, tag, data) - } -} - -impl Hub { - async fn send_protocol_start

( - sink: SharedRealtimeSink, - awareness: AwarenessRef, - protocol: P, - ) -> anyhow::Result<()> - where - P: Protocol, - { - let mut encoder = EncoderV1::new(); - protocol - .start::(awareness.as_ref(), &mut encoder) - .map_err(|err| anyhow::anyhow!(err))?; - let frame = encoder.to_vec(); - if frame.is_empty() { - return Ok(()); - } - let mut guard = sink.lock().await; - guard - .send(frame) - .await - .map_err(|err| anyhow::anyhow!(err))?; - Ok(()) - } -} diff --git a/api/crates/infrastructure/src/documents/realtime/local_engine.rs b/api/crates/infrastructure/src/documents/realtime/local_engine.rs index 62234a88..89335b22 100644 --- a/api/crates/infrastructure/src/documents/realtime/local_engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/local_engine.rs @@ -57,11 +57,17 @@ impl RealtimeEngine for LocalRealtimeEngine { doc_id: &str, updates: &[EncryptedUpdate], ) -> PortResult<()> { - // For E2EE documents, we apply updates as encrypted snapshots + // For E2EE documents, we apply updates as encrypted data // The hub will store the data without decrypting for update in updates { self.hub - .apply_encrypted_update(doc_id, &update.data, update.nonce.as_deref()) + .apply_encrypted_update( + doc_id, + &update.data, + update.nonce.as_deref(), + update.signature.as_deref(), + update.public_key.as_deref(), + ) .await .map_err(|e| application::core::ports::errors::PortError::from(e))?; } diff --git a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs index bbf52a5e..87d3eae6 100644 --- a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs @@ -3,7 +3,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -use anyhow::{Context, anyhow}; +use anyhow::anyhow; +use base64::Engine; use chrono::Utc; use futures_util::{SinkExt, StreamExt}; use tokio::sync::{Mutex, RwLock}; @@ -11,43 +12,36 @@ use tokio::task::JoinHandle; use tokio::time::{Instant, sleep}; use tokio_stream::wrappers::UnboundedReceiverStream; use uuid::Uuid; -use yrs::encoding::write::Write as YWrite; -use yrs::sync::awareness::Awareness; -use yrs::sync::protocol::{MSG_SYNC, MSG_SYNC_UPDATE}; -use yrs::sync::{DefaultProtocol, Protocol}; -use yrs::updates::encoder::{Encoder, EncoderV1}; -use yrs::{Doc, GetString, ReadTxn, StateVector, Text, Transact}; +use crate::core::crypto::Ed25519Verifier; use crate::core::db::PgPool; use crate::documents::db::repositories::document_snapshot_archive_repository_sqlx::SqlxDocumentSnapshotArchiveRepository; use crate::documents::db::repositories::linkgraph_repository_sqlx::SqlxLinkGraphRepository; use crate::documents::db::repositories::tagging_repository_sqlx::SqlxTaggingRepository; -use crate::documents::realtime::awareness::{AwarenessService, encode_awareness_state}; -use crate::documents::realtime::utils::{analyse_frame, wrap_stream_with_edit_guard}; use crate::documents::realtime::{SqlxDocPersistenceAdapter, SqlxDocStateReader}; use application::core::ports::errors::PortResult; use application::core::ports::storage::storage_port::StorageResolverPort; use application::core::ports::storage::storage_projection_queue::StorageProjectionQueue; use application::documents::ports::document_snapshot_archive_repository::DocumentSnapshotArchiveRepository; use application::documents::ports::linkgraph_repository::LinkGraphRepository; -use application::documents::ports::realtime::awareness_port::AwarenessPublisher; use application::documents::ports::realtime::realtime_hydration_port::{ DocStateReader, RealtimeBacklogReader, }; use application::documents::ports::realtime::realtime_persistence_port::{ - DocPersistencePort, DocumentMissingError, + ContentEncryptionMeta, DocPersistencePort, DocumentMissingError, EncryptedUpdateData, }; use application::documents::ports::realtime::realtime_port::{ EncryptedUpdate, RealtimeEngine as RealtimeEngineTrait, SnapshotData, }; -use application::documents::ports::realtime::realtime_types::{DynRealtimeSink, DynRealtimeStream}; +use application::documents::ports::realtime::realtime_types::{ + DynRealtimeSink, DynRealtimeStream, MessageType, RealtimeMessage, +}; use application::documents::ports::tagging::tagging_repository::TaggingRepository; use application::documents::services::realtime::doc_hydration::{ DocHydrationService, HydrationOptions, }; use application::documents::services::realtime::snapshot::{ SnapshotArchiveKind, SnapshotArchiveOptions, SnapshotPersistOptions, SnapshotService, - doc_from_snapshot_bytes, }; use super::cluster_bus::{RedisClusterBus, StreamItem}; @@ -152,76 +146,35 @@ impl RedisRealtimeEngine { self.snapshot_service.clone() } - async fn send_initial_sync(&self, doc: &Doc, sink: &SharedRealtimeSink) -> anyhow::Result<()> { - let bin = { - let txn = doc.transact(); - txn.encode_state_as_update_v1(&StateVector::default()) - }; - let mut enc = EncoderV1::new(); - enc.write_var(MSG_SYNC); - enc.write_var(MSG_SYNC_UPDATE); - enc.write_buf(&bin); - let frame = enc.to_vec(); - - let mut guard = sink.lock().await; - guard - .send(frame) - .await - .map_err(|e| anyhow!("initial_sync_send_failed: {e}"))?; - Ok(()) - } - - async fn flush_awareness_backlog( - &self, - sink: &SharedRealtimeSink, - frames: &[Vec], - doc_id: &str, - awareness_manager: &AwarenessService, - ) -> anyhow::Result<()> { - for payload in frames { - awareness_manager.apply_remote_frame(payload).await?; - let mut guard = sink.lock().await; - if let Err(e) = guard.send(payload.clone()).await { - return Err(anyhow!("initial_awareness_send_failed: {e}")); - } - } - tracing::debug!( - document_id = doc_id, - count = frames.len(), - "redis_cluster_awareness_prefill" - ); - Ok(()) - } - - fn spawn_forward_task( + /// Spawn a task to forward E2EE messages from Redis to the client + fn spawn_e2ee_forward_task( mut stream: UnboundedReceiverStream>, sink: SharedRealtimeSink, doc_id: String, channel: &'static str, - awareness_manager: Option, ) -> JoinHandle<()> { tokio::spawn(async move { while let Some(item) = stream.next().await { match item { Ok((_id, frame)) => { - if let Some(manager) = &awareness_manager { - if let Err(e) = manager.apply_remote_frame(&frame).await { - tracing::debug!( - document_id = %doc_id, - channel, - error = ?e, - "redis_cluster_awareness_apply_failed" - ); - } - } let mut guard = sink.lock().await; if let Err(e) = guard.send(frame).await { - tracing::debug!(document_id = %doc_id, channel, error = %e, "redis_cluster_forward_sink_closed"); + tracing::debug!( + document_id = %doc_id, + channel, + error = %e, + "redis_e2ee_forward_sink_closed" + ); break; } } Err(e) => { - tracing::warn!(document_id = %doc_id, channel, error = ?e, "redis_cluster_forward_stream_error"); + tracing::warn!( + document_id = %doc_id, + channel, + error = ?e, + "redis_e2ee_forward_stream_error" + ); } } } @@ -235,10 +188,98 @@ impl RedisRealtimeEngine { .or_insert_with(|| Arc::new(AtomicBool::new(true))) .clone() } + + /// Get the current seq for a document + async fn get_current_seq(&self, doc_id: &Uuid) -> i64 { + self.persistence + .latest_update_seq(doc_id) + .await + .ok() + .flatten() + .unwrap_or(0) + } + + /// Apply encrypted snapshot with seq tracking + async fn apply_encrypted_snapshot( + &self, + doc_id: &Uuid, + data: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, + ) -> anyhow::Result<()> { + let version = self + .persistence + .latest_snapshot_version(doc_id) + .await? + .unwrap_or(0) + + 1; + + let current_seq = self.get_current_seq(doc_id).await; + + let encryption_meta = Some(ContentEncryptionMeta { + nonce: nonce.map(|n| n.to_vec()), + signature: signature.map(|s| s.to_vec()), + seq_at_snapshot: Some(current_seq), + }); + + self.persistence + .persist_snapshot(doc_id, version, data, encryption_meta.as_ref()) + .await + .map_err(|e| anyhow!("failed to persist encrypted snapshot: {:?}", e))?; + + tracing::debug!( + document_id = %doc_id, + version = version, + seq_at_snapshot = current_seq, + "redis_e2ee_snapshot_persisted" + ); + + Ok(()) + } + + /// Apply encrypted update with seq tracking + async fn apply_encrypted_update( + &self, + doc_id: &Uuid, + data: &[u8], + nonce: Option<&[u8]>, + signature: Option<&[u8]>, + public_key: Option<&[u8]>, + ) -> anyhow::Result<()> { + let seq = self.get_current_seq(doc_id).await + 1; + + let update_data = EncryptedUpdateData { + data: data.to_vec(), + nonce: nonce.map(|n| n.to_vec()), + signature: signature.map(|s| s.to_vec()), + public_key: public_key.map(|p| p.to_vec()), + }; + + self.persistence + .append_encrypted_update_with_seq(doc_id, seq, &update_data) + .await + .map_err(|e| anyhow!("failed to persist encrypted update: {:?}", e))?; + + tracing::debug!( + document_id = %doc_id, + seq = seq, + "redis_e2ee_update_persisted" + ); + + Ok(()) + } } #[async_trait::async_trait] impl RealtimeEngineTrait for RedisRealtimeEngine { + /// Subscribe to a document for E2EE realtime collaboration via Redis + /// + /// This method: + /// 1. Sends initial encrypted snapshot to the client + /// 2. Processes incoming E2EE messages (JSON format) + /// 3. Verifies Ed25519 signatures + /// 4. Relays valid messages to other clients via Redis + /// 5. Persists encrypted updates to the database async fn subscribe( &self, doc_id: &str, @@ -248,127 +289,264 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { ) -> PortResult<()> { let sink: SharedRealtimeSink = Arc::new(Mutex::new(sink)); let doc_uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; - let hydrated = self - .hydration_service - .hydrate(&doc_uuid, HydrationOptions::default()) - .await?; - let awareness_publisher: Arc = self.bus.clone(); - let awareness_service = AwarenessService::new( - hydrated.doc.clone(), - self.awareness_ttl, - awareness_publisher, - doc_id.to_string(), - ); - let ttl_handle = awareness_service.spawn_ttl_task(); + let edit_flag = self.ensure_edit_flag(doc_id).await; + let effective_can_edit = can_edit && edit_flag.load(Ordering::Relaxed); + let mut updates_handle: Option> = None; let mut awareness_handle: Option> = None; let result: anyhow::Result<()> = async { - let edit_flag = self.ensure_edit_flag(doc_id).await; - let session_can_edit = can_edit && edit_flag.load(Ordering::Relaxed); - let mut guarded_stream = - wrap_stream_with_edit_guard(stream, doc_id.to_string(), edit_flag.clone()); - - self.send_initial_sync(&hydrated.doc, &sink).await?; - self.flush_awareness_backlog( - &sink, - &hydrated.awareness_frames, - doc_id, - &awareness_service, - ) - .await?; - if let Ok(Some(frame)) = encode_awareness_state(&awareness_service.awareness()) { + // Send initial encrypted snapshot if available + let snapshot_seq = if let Ok(Some(entry)) = + self.persistence.latest_snapshot_entry(&doc_uuid).await + { + let init_msg = serde_json::json!({ + "type": "init", + "snapshot": { + "data": base64::engine::general_purpose::STANDARD.encode(&entry.bytes), + "nonce": entry.nonce.map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + "signature": entry.signature.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + "seq_at_snapshot": entry.seq_at_snapshot, + } + }); + let msg_bytes = serde_json::to_vec(&init_msg)?; let mut guard = sink.lock().await; - let _ = guard.send(frame).await; + if let Err(e) = guard.send(msg_bytes).await { + tracing::debug!(error = %e, "redis_e2ee_init_send_failed"); + } + drop(guard); + entry.seq_at_snapshot.unwrap_or(0) + } else { + 0 + }; + + // Send pending encrypted updates since last snapshot + if let Ok(updates) = self + .persistence + .get_updates_since(&doc_uuid, snapshot_seq) + .await + { + for update in updates { + let update_msg = serde_json::json!({ + "type": "sync_update", + "update": { + "data": base64::engine::general_purpose::STANDARD.encode(&update.data), + "nonce": update.nonce.map(|n| base64::engine::general_purpose::STANDARD.encode(&n)), + "signature": update.signature.map(|s| base64::engine::general_purpose::STANDARD.encode(&s)), + "public_key": update.public_key.map(|p| base64::engine::general_purpose::STANDARD.encode(&p)), + "seq": update.seq, + } + }); + let msg_bytes = serde_json::to_vec(&update_msg)?; + let mut guard = sink.lock().await; + if let Err(e) = guard.send(msg_bytes).await { + tracing::debug!(error = %e, "redis_e2ee_sync_update_send_failed"); + break; + } + drop(guard); + } } - Self::send_protocol_start( - sink.clone(), - awareness_service.awareness(), - session_can_edit, - ) - .await - .context("redis_cluster_send_protocol_start")?; - - let updates_stream = self - .bus - .subscribe_updates(doc_id, hydrated.last_update_stream_id.clone()) - .await?; - let awareness_stream = self - .bus - .subscribe_awareness(doc_id, hydrated.last_awareness_stream_id.clone()) - .await?; - - updates_handle = Some(Self::spawn_forward_task( + + // Subscribe to Redis streams for updates from other clients + let updates_stream = self.bus.subscribe_updates(doc_id, None).await?; + let awareness_stream = self.bus.subscribe_awareness(doc_id, None).await?; + + updates_handle = Some(Self::spawn_e2ee_forward_task( updates_stream, sink.clone(), doc_id.to_string(), "updates", - None, )); - awareness_handle = Some(Self::spawn_forward_task( + awareness_handle = Some(Self::spawn_e2ee_forward_task( awareness_stream, sink.clone(), doc_id.to_string(), "awareness", - Some(awareness_service.clone()), )); - while let Some(frame) = guarded_stream.next().await { - match frame { - Ok(bytes) => match analyse_frame(&bytes) { - Ok(summary) => { - if summary.has_update { - let allow_edit = can_edit && edit_flag.load(Ordering::Relaxed); - if !allow_edit { - tracing::warn!( - document_id = %doc_id, - "ignored_update_from_readonly_client" - ); - } else if let Err(e) = - self.bus.publish_update(doc_id, bytes.clone()).await - { - tracing::warn!( - document_id = %doc_id, - error = ?e, - "redis_cluster_publish_update_failed" - ); - sleep(self.task_debounce).await; - } - } - if summary.has_awareness { - awareness_service.record_local_frame(&bytes).await.ok(); - if let Err(e) = - self.bus.publish_awareness(doc_id, bytes.clone()).await - { - tracing::debug!( - document_id = %doc_id, - error = ?e, - "redis_cluster_publish_awareness_failed" - ); - } - } - if !summary.has_update && !summary.has_awareness { - tracing::debug!( - document_id = %doc_id, - "redis_cluster_dropped_unknown_frame" - ); - } + // Process incoming E2EE messages + let mut stream = stream; + while let Some(result) = stream.next().await { + let data = match result { + Ok(d) => d, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_stream_error"); + break; + } + }; + + // Parse E2EE message (secsync-compatible format) + let msg: RealtimeMessage = match serde_json::from_slice(&data) { + Ok(m) => m, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_parse_error"); + continue; + } + }; + + // Extract public key from publicData based on message type + let (pub_key_b64, msg_doc_id) = match msg.msg_type { + MessageType::Update => match msg.parse_update_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_parse_update_public_data_error"); + continue; } + }, + MessageType::Snapshot => match msg.parse_snapshot_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), Err(e) => { - tracing::warn!( - document_id = %doc_id, - error = ?e, - "redis_cluster_frame_decode_failed" - ); + tracing::debug!(error = %e, "redis_e2ee_parse_snapshot_public_data_error"); + continue; } }, + MessageType::Awareness => match msg.parse_ephemeral_public_data() { + Ok(pd) => (pd.pub_key, pd.doc_id), + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_parse_ephemeral_public_data_error"); + continue; + } + }, + }; + + // Verify document ID matches + if msg_doc_id != doc_id { + tracing::warn!( + expected = %doc_id, + actual = %msg_doc_id, + "redis_e2ee_doc_id_mismatch" + ); + continue; + } + + // Check edit permission for updates/snapshots + if !effective_can_edit + && matches!(msg.msg_type, MessageType::Update | MessageType::Snapshot) + { + tracing::debug!("redis_e2ee_write_rejected_readonly"); + continue; + } + + // Decode signature components + let public_key = + match base64::engine::general_purpose::STANDARD.decode(&pub_key_b64) { + Ok(k) => k, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_public_key_decode_error"); + continue; + } + }; + let signature = + match base64::engine::general_purpose::STANDARD.decode(&msg.signature) { + Ok(s) => s, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_signature_decode_error"); + continue; + } + }; + + // Verify Ed25519 signature + let signing_message = Ed25519Verifier::build_signing_message( + msg.signature_domain(), + &msg.nonce, + &msg.ciphertext, + &msg.public_data, + ); + + match Ed25519Verifier::verify(&public_key, &signing_message, &signature) { + Ok(true) => { + // Signature valid + } + Ok(false) => { + tracing::warn!( + document_id = %doc_id, + "redis_e2ee_signature_invalid" + ); + continue; + } Err(e) => { - tracing::debug!( + tracing::warn!( document_id = %doc_id, error = %e, - "redis_cluster_inbound_closed" + "redis_e2ee_signature_verify_error" ); - break; + continue; + } + } + + // Decode ciphertext and nonce for persistence + let ciphertext = + match base64::engine::general_purpose::STANDARD.decode(&msg.ciphertext) { + Ok(c) => c, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_ciphertext_decode_error"); + continue; + } + }; + let nonce = match base64::engine::general_purpose::STANDARD.decode(&msg.nonce) { + Ok(n) => n, + Err(e) => { + tracing::debug!(error = %e, "redis_e2ee_nonce_decode_error"); + continue; + } + }; + + // Process message by type + match msg.msg_type { + MessageType::Update => { + // Persist encrypted update + if let Err(e) = self + .apply_encrypted_update( + &doc_uuid, + &ciphertext, + Some(&nonce), + Some(&signature), + Some(&public_key), + ) + .await + { + tracing::warn!(error = %e, "redis_e2ee_persist_update_failed"); + } + } + MessageType::Snapshot => { + // Persist encrypted snapshot + if let Err(e) = self + .apply_encrypted_snapshot( + &doc_uuid, + &ciphertext, + Some(&nonce), + Some(&signature), + ) + .await + { + tracing::warn!(error = %e, "redis_e2ee_persist_snapshot_failed"); + } + } + MessageType::Awareness => { + // Awareness messages are ephemeral, no persistence + } + } + + // Relay to other clients via Redis + match msg.msg_type { + MessageType::Update | MessageType::Snapshot => { + if let Err(e) = self.bus.publish_update(doc_id, data.clone()).await { + tracing::warn!( + document_id = %doc_id, + error = ?e, + "redis_e2ee_publish_update_failed" + ); + sleep(self.task_debounce).await; + } + } + MessageType::Awareness => { + if let Err(e) = self.bus.publish_awareness(doc_id, data.clone()).await { + tracing::debug!( + document_id = %doc_id, + error = ?e, + "redis_e2ee_publish_awareness_failed" + ); + } } } } @@ -383,106 +561,56 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { if let Some(handle) = awareness_handle { handle.abort(); } - if let Err(err) = awareness_service.clear_local_clients().await { - tracing::debug!(document_id = %doc_id, error = ?err, "redis_cluster_awareness_clear_failed"); - } - ttl_handle.abort(); + + tracing::debug!( + document_id = %doc_id, + "redis_e2ee_client_disconnected" + ); result.map_err(Into::into) } - async fn get_content(&self, doc_id: &str) -> PortResult> { - let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - let txt = hydrated.doc.get_or_insert_text("content"); - let txn = hydrated.doc.transact(); - Ok(Some(txt.get_string(&txn))) + async fn get_content(&self, _doc_id: &str) -> PortResult> { + // In E2EE mode, server cannot decrypt content + Ok(None) } async fn get_snapshot(&self, doc_id: &str) -> PortResult> { let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; - // Try to get from persistence first (for E2EE documents with stored nonce/signature) + // Get encrypted snapshot from persistence (E2EE mode) if let Ok(Some(entry)) = self.persistence.latest_snapshot_entry(&uuid).await { return Ok(Some(SnapshotData { data: entry.bytes, nonce: entry.nonce, signature: entry.signature, + seq_at_snapshot: entry.seq_at_snapshot, })); } - // Fallback: hydrate and encode (no nonce/signature) - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - let txn = hydrated.doc.transact(); - Ok(Some(SnapshotData { - data: txn.encode_state_as_update_v1(&yrs::StateVector::default()), - nonce: None, - signature: None, - })) + Ok(None) } async fn force_persist(&self, doc_id: &str) -> PortResult<()> { - let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - self.snapshot_service - .write_markdown(&uuid, &hydrated.doc) - .await?; - self.snapshot_service - .persist_snapshot( - &uuid, - &hydrated.doc, - SnapshotPersistOptions { - clear_updates: true, - ..Default::default() - }, - ) - .await?; + // In E2EE mode, server cannot write plaintext markdown + // Snapshot persistence is handled by clients via WebSocket + tracing::warn!( + document_id = %doc_id, + "force_persist called in E2EE mode - server cannot decrypt content" + ); Ok(()) } - async fn apply_snapshot(&self, doc_id: &str, snapshot: &[u8]) -> PortResult<()> { - let doc = doc_from_snapshot_bytes(snapshot)?; - let uuid = Uuid::parse_str(doc_id).map_err(anyhow::Error::from)?; - let hydrated = self - .hydration_service - .hydrate(&uuid, HydrationOptions::default()) - .await?; - let update_bytes = { - let txt_new = doc.get_or_insert_text("content"); - let txn_new = doc.transact(); - let new_markdown = txt_new.get_string(&txn_new); - drop(txn_new); - - let txt = hydrated.doc.get_or_insert_text("content"); - let mut txn = hydrated.doc.transact_mut(); - let len = txt.len(&txn); - if len > 0 { - txt.remove_range(&mut txn, 0, len); - } - if !new_markdown.is_empty() { - txt.insert(&mut txn, 0, &new_markdown); - } - txn.encode_update_v1() - }; - if update_bytes.is_empty() { - return Ok(()); - } - let mut encoder = EncoderV1::new(); - encoder.write_var(MSG_SYNC); - encoder.write_var(MSG_SYNC_UPDATE); - encoder.write_buf(&update_bytes); - let frame = encoder.to_vec(); - self.bus.publish_update(doc_id, frame).await?; - Ok(()) + async fn apply_snapshot(&self, doc_id: &str, _snapshot: &[u8]) -> PortResult<()> { + // In E2EE mode, plaintext snapshot application is not supported + // Use apply_encrypted_updates or WebSocket snapshot messages instead + tracing::warn!( + document_id = %doc_id, + "apply_snapshot called in E2EE mode - not supported" + ); + Err(application::core::ports::errors::PortError::from( + anyhow!("apply_snapshot not available in E2EE mode"), + )) } async fn set_document_editable(&self, doc_id: &str, editable: bool) -> PortResult<()> { @@ -514,7 +642,7 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { data: update.data.clone(), nonce: update.nonce.clone(), signature: update.signature.clone(), - public_key: None, + public_key: update.public_key.clone(), }; self.persistence @@ -719,50 +847,3 @@ fn spawn_persistence_worker( tracing::info!("redis_persistence_worker_stopped"); })) } - -impl RedisRealtimeEngine { - async fn send_protocol_start( - sink: SharedRealtimeSink, - awareness: Arc, - writable: bool, - ) -> anyhow::Result<()> { - let mut encoder = EncoderV1::new(); - if writable { - DefaultProtocol - .start::(awareness.as_ref(), &mut encoder) - .map_err(|err| anyhow!(err))?; - } else { - ReadOnlyProtocol - .start::(awareness.as_ref(), &mut encoder) - .map_err(|err| anyhow!(err))?; - } - let frame = encoder.to_vec(); - if frame.is_empty() { - return Ok(()); - } - let mut guard = sink.lock().await; - guard.send(frame).await.map_err(|err| anyhow!(err))?; - Ok(()) - } -} - -#[derive(Debug, Clone, Copy)] -struct ReadOnlyProtocol; - -impl yrs::sync::Protocol for ReadOnlyProtocol { - fn handle_sync_step2( - &self, - _awareness: &yrs::sync::Awareness, - _update: yrs::Update, - ) -> Result, yrs::sync::Error> { - Ok(None) - } - - fn handle_update( - &self, - _awareness: &yrs::sync::Awareness, - _update: yrs::Update, - ) -> Result, yrs::sync::Error> { - Ok(None) - } -} diff --git a/api/crates/presentation/src/http/documents/handlers/content.rs b/api/crates/presentation/src/http/documents/handlers/content.rs index 13122352..e4ade63b 100644 --- a/api/crates/presentation/src/http/documents/handlers/content.rs +++ b/api/crates/presentation/src/http/documents/handlers/content.rs @@ -166,11 +166,15 @@ pub async fn patch_document_content( DocumentPatchOperationRequest::Insert { encrypted_data: Some(encrypted_data), nonce, + signature, + public_key, .. } | DocumentPatchOperationRequest::Replace { encrypted_data: Some(encrypted_data), nonce, + signature, + public_key, .. } => { let data = base64::engine::general_purpose::STANDARD @@ -179,10 +183,17 @@ pub async fn patch_document_content( let nonce_bytes = nonce.as_ref().and_then(|n| { base64::engine::general_purpose::STANDARD.decode(n).ok() }); + let signature_bytes = signature.as_ref().and_then(|s| { + base64::engine::general_purpose::STANDARD.decode(s).ok() + }); + let public_key_bytes = public_key.as_ref().and_then(|p| { + base64::engine::general_purpose::STANDARD.decode(p).ok() + }); Some(EncryptedUpdate { data, nonce: nonce_bytes, - signature: None, + signature: signature_bytes, + public_key: public_key_bytes, }) } _ => None, diff --git a/api/crates/presentation/src/http/documents/types.rs b/api/crates/presentation/src/http/documents/types.rs index 8f7d388a..37812a03 100644 --- a/api/crates/presentation/src/http/documents/types.rs +++ b/api/crates/presentation/src/http/documents/types.rs @@ -367,6 +367,14 @@ pub enum DocumentPatchOperationRequest { #[serde(default)] #[schema(value_type = Option, format = "byte")] nonce: Option, + /// Base64 encoded Ed25519 signature (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + signature: Option, + /// Base64 encoded Ed25519 public key (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + public_key: Option, }, Delete { offset: usize, @@ -386,6 +394,14 @@ pub enum DocumentPatchOperationRequest { #[serde(default)] #[schema(value_type = Option, format = "byte")] nonce: Option, + /// Base64 encoded Ed25519 signature (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + signature: Option, + /// Base64 encoded Ed25519 public key (for E2EE documents) + #[serde(default)] + #[schema(value_type = Option, format = "byte")] + public_key: Option, }, } diff --git a/api/migrations/202701020001_add_e2ee_tables.sql b/api/migrations/202701020001_add_e2ee_tables.sql index 96cbef72..e4bdfdfc 100644 --- a/api/migrations/202701020001_add_e2ee_tables.sql +++ b/api/migrations/202701020001_add_e2ee_tables.sql @@ -101,10 +101,11 @@ ALTER TABLE document_updates ADD COLUMN IF NOT EXISTS signature BYTEA, ADD COLUMN IF NOT EXISTS public_key BYTEA; --- Document snapshots: encryption metadata and signature +-- Document snapshots: encryption metadata, signature, and seq tracking ALTER TABLE document_snapshots ADD COLUMN IF NOT EXISTS nonce BYTEA, - ADD COLUMN IF NOT EXISTS signature BYTEA; + ADD COLUMN IF NOT EXISTS signature BYTEA, + ADD COLUMN IF NOT EXISTS seq_at_snapshot BIGINT; -- Files: encrypted metadata ALTER TABLE files From b31813b153a33c7a9e274b3762f5a5e40f051808 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 8 Jan 2026 03:53:38 +0900 Subject: [PATCH 05/45] add: support storage --- .../services/storage/ingest/attachments.rs | 18 ++- .../core/services/storage/ingest/documents.rs | 48 ++------ .../core/services/storage/ingest/handler.rs | 61 ++-------- .../core/services/storage/ingest/markdown.rs | 110 ++++++------------ .../src/core/services/storage/ingest/mod.rs | 9 +- .../core/services/storage/reconcile/mod.rs | 6 + .../src/core/storage/fs_ingest_watcher.rs | 21 +++- 7 files changed, 94 insertions(+), 179 deletions(-) diff --git a/api/crates/application/src/core/services/storage/ingest/attachments.rs b/api/crates/application/src/core/services/storage/ingest/attachments.rs index d05ec35b..315b85e6 100644 --- a/api/crates/application/src/core/services/storage/ingest/attachments.rs +++ b/api/crates/application/src/core/services/storage/ingest/attachments.rs @@ -27,17 +27,29 @@ impl StorageIngestService { } Err(err) => return Err(err.into()), }; + + // E2EE: Validate RME1 format + if bytes.len() < 4 || &bytes[0..4] != RME1_MAGIC { + warn!( + file_id = %file_id, + doc_id = %doc_id, + repo_path = repo_path, + "storage_ingest_attachment_invalid_rme1_format" + ); + return Ok(()); + } + let size = bytes.len() as i64; - let hash = sha256_hex(&bytes); + let encrypted_hash = sha256_hex(&bytes); self.files_repo - .update_hash_and_size(file_id, size, &hash) + .update_hash_and_size(file_id, size, &encrypted_hash) .await?; let mut payload_obj = serde_json::Map::new(); payload_obj.insert("repo_path".into(), json!(repo_path)); payload_obj.insert("storage_path".into(), json!(rel_path)); payload_obj.insert("backend".into(), json!(event.backend.as_str())); payload_obj.insert("size".into(), json!(size)); - payload_obj.insert("content_hash".into(), json!(hash)); + payload_obj.insert("encrypted_hash".into(), json!(encrypted_hash)); if let Some(prev) = previous_repo_path { payload_obj.insert("previous_path".into(), json!(prev)); } diff --git a/api/crates/application/src/core/services/storage/ingest/documents.rs b/api/crates/application/src/core/services/storage/ingest/documents.rs index 31c59f2e..12424180 100644 --- a/api/crates/application/src/core/services/storage/ingest/documents.rs +++ b/api/crates/application/src/core/services/storage/ingest/documents.rs @@ -9,12 +9,13 @@ impl StorageIngestService { payload: MarkdownIngestPayload, previous_repo_path: Option<&str>, ) -> anyhow::Result<()> { + // E2EE: Skip recent export check using encrypted_hash if event.backend.is_fs_watcher() && event.actor_id.is_none() && self.recent_exports.is_recent_match( event.workspace_id, repo_path, - &payload.content_hash, + &payload.encrypted_hash, ) { debug!( @@ -24,25 +25,15 @@ impl StorageIngestService { ); return Ok(()); } - let snapshot = snapshot_from_markdown(&payload.body); - self.realtime - .apply_snapshot(&doc.id.to_string(), snapshot.as_slice()) - .await?; - // Persist back to storage only for API/actor initiated ingests; fs_watcher/reconcile events - // originate from the filesystem itself and writing would re-trigger the watcher endlessly. - if event.actor_id.is_some() - && let Err(err) = self.realtime.force_persist(&doc.id.to_string()).await - { - warn!( - error = ?err, - doc_id = %doc.id, - "storage_ingest_force_persist_failed" - ); - } + + // E2EE: No Yjs snapshot conversion - encrypted data is handled by client via WebSocket + // Server only stores encrypted bytes as-is + let mut payload_obj = serde_json::Map::new(); payload_obj.insert("repo_path".into(), json!(repo_path)); payload_obj.insert("backend".into(), json!(event.backend.as_str())); - payload_obj.insert("content_hash".into(), json!(payload.content_hash)); + payload_obj.insert("encrypted_hash".into(), json!(payload.encrypted_hash)); + payload_obj.insert("size".into(), json!(payload.size)); payload_obj.insert("doc_type".into(), json!(doc.doc_type.as_str())); if let Some(prev) = previous_repo_path { payload_obj.insert("previous_path".into(), json!(prev)); @@ -73,28 +64,7 @@ impl StorageIngestService { parse_markdown_payload(bytes) } - pub(super) async fn resolve_doc_from_front_matter( - &self, - user_id: Uuid, - payload: &MarkdownIngestPayload, - ) -> anyhow::Result> { - let Some(doc_id) = payload.doc_id_hint else { - return Ok(None); - }; - let Some(meta) = self - .document_repo - .get_meta_for_owner(doc_id, user_id) - .await? - else { - return Ok(None); - }; - Ok(Some(ResolvedDocument::new( - doc_id, - meta.doc_type, - meta.path, - meta.archived_at.is_some(), - ))) - } + // E2EE: resolve_doc_from_front_matter removed - document ID resolved from storage path pub(super) async fn handle_doc_delete( &self, diff --git a/api/crates/application/src/core/services/storage/ingest/handler.rs b/api/crates/application/src/core/services/storage/ingest/handler.rs index 3fda8bd3..eed91532 100644 --- a/api/crates/application/src/core/services/storage/ingest/handler.rs +++ b/api/crates/application/src/core/services/storage/ingest/handler.rs @@ -153,61 +153,14 @@ impl StorageIngestHandler for StorageIngestService { return Ok(()); } + // E2EE: No front-matter resolution - document ID must be resolved from storage path + // New documents are created via API, not from storage ingest if event.kind == StorageIngestKind::Upsert && rel_path.ends_with(".md") { - let payload = match self.load_markdown_payload(&rel_path).await { - Ok(payload) => payload, - Err(err) if is_not_found_error(&err) => { - info!( - user_id = %event.workspace_id, - repo_path = repo_path, - "storage_ingest_missing_source_skipped" - ); - self.storage_projection - .delete_relative_path(&rel_path) - .await?; - return Ok(()); - } - Err(err) => return Err(err), - }; - if let Some(doc) = self - .resolve_doc_from_front_matter(event.workspace_id, &payload) - .await? - { - if doc.is_folder() { - warn!( - doc_id = %doc.id, - repo_path = repo_path, - "storage_ingest_folder_event_skipped" - ); - } else if doc.is_archived() { - warn!( - doc_id = %doc.id, - repo_path = repo_path, - "storage_ingest_archived_doc_skipped" - ); - } else { - if !self - .reconcile_repo_path(&doc, event.workspace_id, &rel_path) - .await? - { - warn!( - doc_id = %doc.id, - repo_path = repo_path, - "storage_ingest_repo_path_rejected" - ); - return Ok(()); - } - self.handle_doc_upsert( - &doc, - &repo_path, - event, - payload, - payload_previous_repo_path.as_deref(), - ) - .await?; - } - return Ok(()); - } + info!( + user_id = %event.workspace_id, + repo_path = repo_path, + "storage_ingest_orphan_encrypted_file" + ); } if event.kind == StorageIngestKind::Delete { diff --git a/api/crates/application/src/core/services/storage/ingest/markdown.rs b/api/crates/application/src/core/services/storage/ingest/markdown.rs index e344fcad..9920be6a 100644 --- a/api/crates/application/src/core/services/storage/ingest/markdown.rs +++ b/api/crates/application/src/core/services/storage/ingest/markdown.rs @@ -2,78 +2,24 @@ use super::*; #[derive(Debug, Clone)] pub(super) struct MarkdownIngestPayload { - pub(super) doc_id_hint: Option, - pub(super) body: String, - pub(super) content_hash: String, -} - -#[derive(Debug, Deserialize)] -struct MarkdownFrontMatter { - id: Option, + pub(super) encrypted_hash: String, + pub(super) size: i64, } +/// Parse encrypted file payload (RME1 format) pub(super) fn parse_markdown_payload(bytes: Vec) -> anyhow::Result { - let content_hash = sha256_hex(&bytes); - // Accept lossy UTF-8 to avoid retry storms on malformed files; non-UTF8 bytes become U+FFFD. - let text = String::from_utf8_lossy(&bytes).to_string(); - let trimmed = text.trim_start_matches('\u{feff}'); - if let Some((front, body)) = split_front_matter(trimmed) - && let Ok(front_matter) = serde_yaml::from_str::(front) - && let Some(doc_id) = front_matter.id - { - return Ok(MarkdownIngestPayload { - doc_id_hint: Some(doc_id), - body: body.to_string(), - content_hash, - }); + // Validate RME1 magic number + if bytes.len() < 4 || &bytes[0..4] != RME1_MAGIC { + anyhow::bail!("Invalid RME1 format: missing or invalid magic number"); } - Ok(MarkdownIngestPayload { - doc_id_hint: None, - body: trimmed.to_string(), - content_hash, - }) -} -fn split_front_matter(input: &str) -> Option<(&str, &str)> { - let after_open = input - .strip_prefix("---\r\n") - .or_else(|| input.strip_prefix("---\n"))?; - if let Some((front_len, body_start)) = find_front_matter_end(after_open) { - let front = &after_open[..front_len]; - let body = &after_open[body_start..]; - return Some((front, body)); - } - None -} + let encrypted_hash = sha256_hex(&bytes); + let size = bytes.len() as i64; -fn find_front_matter_end(s: &str) -> Option<(usize, usize)> { - let bytes = s.as_bytes(); - let mut idx = 0; - while idx < bytes.len() { - if bytes[idx] == b'\n' { - let after_newline = &s[idx + 1..]; - if after_newline.starts_with("---") { - let mut body_start = idx + 1 + 3; - let mut remainder = &s[body_start..]; - // Skip any trailing newlines so we don't feed extra blank lines - // back into the realtime layer when the projection re-imports. - while remainder.starts_with("\r\n") || remainder.starts_with('\n') { - if remainder.starts_with("\r\n") { - body_start += 2; - let (_, rest) = remainder.split_at(2); - remainder = rest; - } else { - body_start += 1; - let (_, rest) = remainder.split_at(1); - remainder = rest; - } - } - return Some((idx, body_start)); - } - } - idx += 1; - } - None + Ok(MarkdownIngestPayload { + encrypted_hash, + size, + }) } #[cfg(test)] @@ -81,19 +27,29 @@ mod tests { use super::*; #[test] - fn preserves_body_when_front_matter_has_no_id() { - let markdown = "---\ntitle: Foo\n---\n\nBody".to_string(); - let payload = parse_markdown_payload(markdown.clone().into_bytes()).unwrap(); - assert!(payload.doc_id_hint.is_none()); - assert_eq!(payload.body, markdown); + fn parses_valid_rme1_format() { + let mut bytes = b"RME1".to_vec(); + bytes.extend_from_slice(&[0x01, 0x00, 0x00, 0x00, 0x10]); // version + header length + bytes.extend_from_slice(&[0u8; 16]); // dummy header + bytes.extend_from_slice(&[0u8; 24]); // dummy content nonce + bytes.extend_from_slice(b"encrypted content"); + + let payload = parse_markdown_payload(bytes.clone()).unwrap(); + assert_eq!(payload.size, bytes.len() as i64); + assert!(!payload.encrypted_hash.is_empty()); + } + + #[test] + fn rejects_invalid_magic() { + let bytes = b"XXXX invalid data".to_vec(); + let result = parse_markdown_payload(bytes); + assert!(result.is_err()); } #[test] - fn extracts_id_when_front_matter_is_valid() { - let doc_id = Uuid::new_v4(); - let markdown = format!("---\nid: {}\n---\n\nHello", doc_id); - let payload = parse_markdown_payload(markdown.into_bytes()).unwrap(); - assert_eq!(payload.doc_id_hint, Some(doc_id)); - assert_eq!(payload.body.trim_start_matches('\n'), "Hello"); + fn rejects_too_short_data() { + let bytes = b"RM".to_vec(); + let result = parse_markdown_payload(bytes); + assert!(result.is_err()); } } diff --git a/api/crates/application/src/core/services/storage/ingest/mod.rs b/api/crates/application/src/core/services/storage/ingest/mod.rs index b432b0af..ae5fc4ff 100644 --- a/api/crates/application/src/core/services/storage/ingest/mod.rs +++ b/api/crates/application/src/core/services/storage/ingest/mod.rs @@ -3,7 +3,6 @@ use std::path::PathBuf; use std::sync::Arc; use async_trait::async_trait; -use serde::Deserialize; use serde_json::{Value, json}; use tracing::{debug, info, warn}; use uuid::Uuid; @@ -19,7 +18,6 @@ use crate::documents::ports::document_repository::DocumentRepository; use crate::documents::ports::files::files_repository::FilesRepository; use crate::documents::ports::realtime::realtime_port::RealtimeEngine; use crate::documents::services::DocumentService; -use crate::documents::services::realtime::snapshot::snapshot_from_markdown; use crate::workspaces::services::{ WorkspacePermissionResolver, permission_snapshot::permission_set_from_snapshot, }; @@ -34,6 +32,9 @@ mod permissions; mod resolved_document; mod utils; +/// RME1 (RefMD Encrypted v1) magic number for E2EE file format +pub const RME1_MAGIC: &[u8; 4] = b"RME1"; + pub use domain::documents::path::normalize_repo_path; use markdown::{MarkdownIngestPayload, parse_markdown_payload}; @@ -46,9 +47,13 @@ pub trait StorageIngestHandler: Send + Sync { } pub struct StorageIngestService { + // TODO(e2ee): Remove after E2EE migration complete - was used for resolve_doc_from_front_matter + #[allow(dead_code)] document_repo: Arc, document_paths: Arc, files_repo: Arc, + // TODO(e2ee): Remove after E2EE migration complete - was used for Yjs snapshot conversion + #[allow(dead_code)] realtime: Arc, storage: Arc, storage_projection: Arc, diff --git a/api/crates/application/src/core/services/storage/reconcile/mod.rs b/api/crates/application/src/core/services/storage/reconcile/mod.rs index 8fb1eaeb..a1c71632 100644 --- a/api/crates/application/src/core/services/storage/reconcile/mod.rs +++ b/api/crates/application/src/core/services/storage/reconcile/mod.rs @@ -1,3 +1,9 @@ +//! Storage reconciliation service +//! +//! E2EE: This service operates on encrypted files (RME1 format). +//! It only performs path-level consistency checks without reading file contents. +//! Actual content verification (RME1 magic, encrypted_hash) is done by ingest handlers. + use std::collections::HashSet; use std::sync::Arc; diff --git a/api/crates/infrastructure/src/core/storage/fs_ingest_watcher.rs b/api/crates/infrastructure/src/core/storage/fs_ingest_watcher.rs index c2e5583c..11bccd46 100644 --- a/api/crates/infrastructure/src/core/storage/fs_ingest_watcher.rs +++ b/api/crates/infrastructure/src/core/storage/fs_ingest_watcher.rs @@ -12,7 +12,7 @@ use uuid::Uuid; use application::core::ports::storage::storage_ingest_queue::{ StorageIngestKind, StorageIngestQueue, }; -use application::core::services::storage::ingest::normalize_repo_path; +use application::core::services::storage::ingest::{normalize_repo_path, RME1_MAGIC}; use application::core::services::utils::hash::sha256_hex; use domain::access::permissions::PermissionSet; use domain::storage::ingest_backend::StorageIngestBackend; @@ -177,6 +177,8 @@ impl FsIngestWatcher { Ok(()) } + /// E2EE: Capture metadata for encrypted files (RME1 format) + /// Hash is computed on encrypted bytes - content is not interpreted async fn capture_file_metadata( &self, path: &Path, @@ -184,13 +186,24 @@ impl FsIngestWatcher { ) -> (Option, Option) { match tokio::fs::read(path).await { Ok(bytes) => { - let hash = sha256_hex(&bytes); + // E2EE: Validate RME1 magic number + let is_valid_rme1 = bytes.len() >= 4 && &bytes[0..4] == RME1_MAGIC; + if !is_valid_rme1 { + warn!( + repo_path = repo_path, + size = bytes.len(), + "fs_ingest_invalid_rme1_format" + ); + } + + // E2EE: Hash computed on encrypted bytes (used as encrypted_hash) + let encrypted_hash = sha256_hex(&bytes); let payload = serde_json::json!({ "file_kind": file_kind(repo_path), - "is_text": repo_path.ends_with(".md"), + "is_encrypted": is_valid_rme1, "size": bytes.len(), }); - (Some(hash), Some(payload)) + (Some(encrypted_hash), Some(payload)) } Err(err) => { warn!(error = ?err, repo_path = repo_path, "fs_ingest_metadata_failed"); From faed1d1c239823b066dc8302a8ddb88aaebb9b02 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 8 Jan 2026 14:08:29 +0900 Subject: [PATCH 06/45] add: migrate api --- api/Cargo.lock | 53 +++ api/crates/application/Cargo.toml | 1 + .../identity/ports/migration_repository.rs | 59 +++ .../src/identity/ports/migration_tx_runner.rs | 183 ++++++++++ .../application/src/identity/ports/mod.rs | 2 + .../src/identity/services/migration/mod.rs | 344 ++++++++++++++++++ .../src/identity/services/migration/types.rs | 89 +++++ .../application/src/identity/services/mod.rs | 1 + api/crates/bootstrap/src/app/build_runtime.rs | 17 + api/crates/bootstrap/src/http.rs | 4 + api/crates/infrastructure/Cargo.toml | 2 + .../infrastructure/src/core/crypto/mod.rs | 2 + .../src/core/crypto/xchacha20.rs | 279 ++++++++++++++ .../migration_repository_sqlx/mod.rs | 314 ++++++++++++++++ .../repositories/migration_tx_runner_sqlx.rs | 274 ++++++++++++++ .../src/identity/db/repositories/mod.rs | 2 + api/crates/presentation/src/context.rs | 8 + .../presentation/src/context/subcontexts.rs | 8 + .../http/identity/migration/handlers/mod.rs | 80 ++++ .../src/http/identity/migration/mod.rs | 22 ++ .../src/http/identity/migration/types.rs | 165 +++++++++ .../presentation/src/http/identity/mod.rs | 1 + api/crates/presentation/src/openapi.rs | 9 +- api/openapi.json | 49 +++ 24 files changed, 1967 insertions(+), 1 deletion(-) create mode 100644 api/crates/application/src/identity/ports/migration_repository.rs create mode 100644 api/crates/application/src/identity/ports/migration_tx_runner.rs create mode 100644 api/crates/application/src/identity/services/migration/mod.rs create mode 100644 api/crates/application/src/identity/services/migration/types.rs create mode 100644 api/crates/infrastructure/src/core/crypto/xchacha20.rs create mode 100644 api/crates/infrastructure/src/identity/db/repositories/migration_repository_sqlx/mod.rs create mode 100644 api/crates/infrastructure/src/identity/db/repositories/migration_tx_runner_sqlx.rs create mode 100644 api/crates/presentation/src/http/identity/migration/handlers/mod.rs create mode 100644 api/crates/presentation/src/http/identity/migration/mod.rs create mode 100644 api/crates/presentation/src/http/identity/migration/types.rs diff --git a/api/Cargo.lock b/api/Cargo.lock index 83e2c7f8..4c54ca23 100644 --- a/api/Cargo.lock +++ b/api/Cargo.lock @@ -166,6 +166,7 @@ dependencies = [ "anyhow", "async-trait", "base64 0.21.7", + "chacha20poly1305", "chrono", "contracts", "domain", @@ -1113,6 +1114,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.42" @@ -1135,6 +1160,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common", "inout", + "zeroize", ] [[package]] @@ -2941,6 +2967,7 @@ dependencies = [ "aws-config", "aws-sdk-s3", "base64 0.21.7", + "chacha20poly1305", "chrono", "comrak", "domain", @@ -2981,6 +3008,7 @@ dependencies = [ "walkdir", "yrs", "yrs-warp", + "zeroize", "zip 0.6.6", ] @@ -4023,6 +4051,17 @@ dependencies = [ "time", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "polyval" version = "0.6.2" @@ -7494,6 +7533,20 @@ name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.106", +] [[package]] name = "zerotrie" diff --git a/api/crates/application/Cargo.toml b/api/crates/application/Cargo.toml index 1768a623..3dd41088 100644 --- a/api/crates/application/Cargo.toml +++ b/api/crates/application/Cargo.toml @@ -31,6 +31,7 @@ tracing = "0.1" urlencoding = "2" uuid = { version = "1", features = ["v4", "serde"] } yrs = { version = "0.24", features = ["sync"] } +chacha20poly1305 = "0.10" zip = { version = "0.6" } [dev-dependencies] diff --git a/api/crates/application/src/identity/ports/migration_repository.rs b/api/crates/application/src/identity/ports/migration_repository.rs new file mode 100644 index 00000000..87bc6afd --- /dev/null +++ b/api/crates/application/src/identity/ports/migration_repository.rs @@ -0,0 +1,59 @@ +//! Migration repository port for E2EE migration processing. +//! +//! This repository provides read-only access to existing plaintext data +//! during the E2EE migration process. Write operations are handled through +//! the transactional interface in `migration_tx_runner`. + +use async_trait::async_trait; +use chrono::{DateTime, Utc}; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; + +/// Document information for migration. +#[derive(Debug, Clone)] +pub struct MigrationDocument { + pub id: Uuid, + pub workspace_id: Uuid, + pub title: String, + pub created_at: DateTime, +} + +/// File information for migration. +#[derive(Debug, Clone)] +pub struct MigrationFile { + pub id: Uuid, + pub document_id: Uuid, + pub workspace_id: Uuid, + pub filename: String, + pub content_type: Option, + pub storage_path: String, +} + +/// Latest snapshot information. +#[derive(Debug, Clone)] +pub struct MigrationSnapshot { + pub document_id: Uuid, + pub version: i64, + pub data: Vec, + pub seq_at_snapshot: Option, +} + +/// Repository trait for E2EE migration read operations. +/// +/// This trait provides read-only access to plaintext data that needs +/// to be encrypted during migration. Write operations are performed +/// through `MigrationRepositoryTx` within a transaction. +#[async_trait] +pub trait MigrationRepository: Send + Sync { + /// List all documents owned by or accessible to a user. + /// + /// Returns documents from all workspaces where the user is a member, + /// filtered to only include documents that haven't been encrypted yet. + async fn list_user_documents(&self, user_id: Uuid) -> PortResult>; + + /// List all files associated with a user's documents. + /// + /// Returns files that haven't been encrypted yet. + async fn list_user_files(&self, user_id: Uuid) -> PortResult>; +} diff --git a/api/crates/application/src/identity/ports/migration_tx_runner.rs b/api/crates/application/src/identity/ports/migration_tx_runner.rs new file mode 100644 index 00000000..c16f7ce8 --- /dev/null +++ b/api/crates/application/src/identity/ports/migration_tx_runner.rs @@ -0,0 +1,183 @@ +//! Transaction runner for E2EE migration operations. +//! +//! This module provides transactional support for the migration process, +//! ensuring that all database operations are atomic. + +use std::any::Any; +use std::future::Future; +use std::pin::Pin; + +use anyhow::anyhow; +use async_trait::async_trait; +use uuid::Uuid; + +use crate::core::ports::errors::PortResult; + +use super::migration_repository::MigrationSnapshot; + +// ============================================================================ +// Type aliases for boxed futures +// ============================================================================ + +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; +pub type BoxedTxResult = Box; +pub type MigrationTxFuture<'tx> = BoxFuture<'tx, anyhow::Result>; +pub type MigrationTxFn = + Box FnOnce(&'tx mut dyn MigrationTx) -> MigrationTxFuture<'tx> + Send>; + +// ============================================================================ +// Transaction context trait +// ============================================================================ + +/// Transaction context for migration operations. +/// +/// Provides access to all repositories needed during migration, +/// all operating within the same database transaction. +pub trait MigrationTx: Send { + /// Access to migration-specific repository operations. + fn migration(&mut self) -> &mut dyn MigrationRepositoryTx; + + /// Access to document keys repository. + fn document_keys(&mut self) -> &mut dyn DocumentKeysRepositoryTx; + + /// Access to workspace keys repository. + fn workspace_keys(&mut self) -> &mut dyn WorkspaceKeysRepositoryTx; + + /// Access to user keys repository. + fn user_keys(&mut self) -> &mut dyn UserKeysRepositoryTx; +} + +// ============================================================================ +// Transactional repository traits +// ============================================================================ + +/// Migration repository operations that run within a transaction. +#[async_trait] +pub trait MigrationRepositoryTx: Send { + /// Update a document with encrypted title. + async fn update_encrypted_title( + &mut self, + document_id: Uuid, + encrypted_title: &[u8], + nonce: &[u8], + ) -> PortResult<()>; + + /// Create or update an encrypted snapshot for a document. + async fn upsert_encrypted_snapshot( + &mut self, + document_id: Uuid, + encrypted_snapshot: &[u8], + nonce: &[u8], + seq_at_snapshot: i64, + ) -> PortResult<()>; + + /// Delete all plaintext updates for a document. + async fn clear_plaintext_updates(&mut self, document_id: Uuid) -> PortResult; + + /// Update a file's metadata with encrypted values. + async fn update_encrypted_file_metadata( + &mut self, + file_id: Uuid, + encrypted_metadata: &[u8], + nonce: &[u8], + encrypted_hash: &str, + ) -> PortResult<()>; + + /// Clear plaintext title from a document after encryption. + async fn clear_plaintext_title(&mut self, document_id: Uuid) -> PortResult<()>; + + /// Clear plaintext metadata from a file after encryption. + async fn clear_plaintext_file_metadata(&mut self, file_id: Uuid) -> PortResult<()>; + + /// Get the latest Yjs snapshot for a document. + async fn get_document_snapshot( + &mut self, + document_id: Uuid, + ) -> PortResult>; + + /// Get the maximum sequence number for a document's updates. + async fn get_document_max_seq(&mut self, document_id: Uuid) -> PortResult>; +} + +/// Document keys repository operations that run within a transaction. +#[async_trait] +pub trait DocumentKeysRepositoryTx: Send { + /// Store or update an encrypted DEK for a document. + async fn upsert_encrypted_dek( + &mut self, + document_id: Uuid, + encrypted_dek: &[u8], + nonce: &[u8], + key_version: i32, + ) -> PortResult<()>; +} + +/// Workspace keys repository operations that run within a transaction. +#[async_trait] +pub trait WorkspaceKeysRepositoryTx: Send { + /// Store or update an encrypted KEK for a workspace member. + async fn upsert_encrypted_kek( + &mut self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: &[u8], + key_version: i32, + ) -> PortResult<()>; +} + +/// User keys repository operations that run within a transaction. +#[async_trait] +pub trait UserKeysRepositoryTx: Send { + /// Mark E2EE setup as completed for a user. + async fn mark_e2ee_setup_completed(&mut self, user_id: Uuid) -> PortResult<()>; + + /// Check if E2EE setup is completed for a user. + async fn is_e2ee_setup_completed(&mut self, user_id: Uuid) -> PortResult; +} + +// ============================================================================ +// Transaction runner trait +// ============================================================================ + +/// Runner for executing migration operations within a transaction. +#[async_trait] +pub trait MigrationTxRunner: Send + Sync { + /// Execute a function within a database transaction. + /// + /// The transaction is committed if the function returns Ok, + /// and rolled back if it returns Err. + async fn run_boxed(&self, f: MigrationTxFn) -> anyhow::Result; +} + +// ============================================================================ +// Helper function +// ============================================================================ + +/// Execute a migration operation within a transaction. +/// +/// This is a type-safe wrapper around `MigrationTxRunner::run_boxed`. +pub async fn run_migration_tx(runner: &dyn MigrationTxRunner, f: F) -> anyhow::Result +where + T: Send + 'static, + F: for<'tx> FnOnce(&'tx mut dyn MigrationTx) -> BoxFuture<'tx, anyhow::Result> + + Send + + 'static, +{ + let mut f = Some(f); + let result = runner + .run_boxed(Box::new(move |tx| { + let f = f + .take() + .expect("MigrationTx closure must be called exactly once"); + Box::pin(async move { + let out = f(tx).await?; + Ok(Box::new(out) as BoxedTxResult) + }) + })) + .await?; + + result + .downcast::() + .map(|v| *v) + .map_err(|_| anyhow!("migration tx runner output type mismatch")) +} diff --git a/api/crates/application/src/identity/ports/mod.rs b/api/crates/application/src/identity/ports/mod.rs index 6150d863..6b994f8b 100644 --- a/api/crates/application/src/identity/ports/mod.rs +++ b/api/crates/application/src/identity/ports/mod.rs @@ -1,5 +1,7 @@ pub mod api_token_repository; pub mod jwt_codec; +pub mod migration_repository; +pub mod migration_tx_runner; pub mod secret_hasher; pub mod user_keys_repository; pub mod user_repository; diff --git a/api/crates/application/src/identity/services/migration/mod.rs b/api/crates/application/src/identity/services/migration/mod.rs new file mode 100644 index 00000000..8644cb4e --- /dev/null +++ b/api/crates/application/src/identity/services/migration/mod.rs @@ -0,0 +1,344 @@ +//! E2EE migration service. +//! +//! This service handles the server-side encryption of existing plaintext data +//! during the E2EE migration process. + +pub mod types; + +use std::sync::Arc; + +use async_trait::async_trait; +use serde_json::json; +use tracing::{info, warn}; +use uuid::Uuid; + +use crate::core::services::errors::ServiceError; +use crate::identity::ports::migration_repository::MigrationRepository; +use crate::identity::ports::migration_tx_runner::{run_migration_tx, MigrationTx, MigrationTxRunner}; + +pub use types::*; + +/// Encryption function signature. +/// Takes (key, plaintext) and returns (ciphertext, nonce). +pub type EncryptFn = fn(&[u8], &[u8]) -> Result<(Vec, [u8; 24]), anyhow::Error>; + +/// Migration service for E2EE. +pub struct MigrationService { + migration_repo: Arc, + tx_runner: Arc, + encrypt_fn: EncryptFn, +} + +/// Service facade trait for E2EE migration. +#[async_trait] +pub trait MigrationServiceFacade: Send + Sync { + /// Execute the full migration for a user. + /// + /// This encrypts all of the user's documents and files using the provided keys. + /// The operation is atomic - either all data is encrypted or none is. + async fn migrate_user_data( + &self, + user_id: Uuid, + request: MigrationRequest, + ) -> Result; + + /// Check if migration is needed for a user. + async fn needs_migration(&self, user_id: Uuid) -> Result; +} + +impl MigrationService { + pub fn new( + migration_repo: Arc, + tx_runner: Arc, + ) -> Self { + Self { + migration_repo, + tx_runner, + encrypt_fn: default_encrypt_fn, + } + } + + /// Create with a custom encryption function (for testing). + #[cfg(test)] + pub fn with_encrypt_fn(mut self, encrypt_fn: EncryptFn) -> Self { + self.encrypt_fn = encrypt_fn; + self + } +} + +#[async_trait] +impl MigrationServiceFacade for MigrationService { + async fn migrate_user_data( + &self, + user_id: Uuid, + request: MigrationRequest, + ) -> Result { + // Get all documents and files for the user (outside transaction for read-only ops) + let documents = self + .migration_repo + .list_user_documents(user_id) + .await + .map_err(ServiceError::from)?; + + let files = self + .migration_repo + .list_user_files(user_id) + .await + .map_err(ServiceError::from)?; + + let encrypt_fn = self.encrypt_fn; + + // Execute all write operations within a transaction + let result = run_migration_tx(self.tx_runner.as_ref(), move |tx| { + Box::pin(async move { + migrate_user_data_in_tx(tx, user_id, request, documents, files, encrypt_fn).await + }) + }) + .await + .map_err(|e| ServiceError::Unexpected(e))?; + + Ok(result) + } + + async fn needs_migration(&self, user_id: Uuid) -> Result { + // Check using the transaction runner (reads from users table) + let result = run_migration_tx(self.tx_runner.as_ref(), move |tx| { + Box::pin(async move { + let completed = tx.user_keys().is_e2ee_setup_completed(user_id).await?; + Ok(!completed) + }) + }) + .await + .map_err(|e| ServiceError::Unexpected(e))?; + + Ok(result) + } +} + +/// Execute migration within a transaction. +async fn migrate_user_data_in_tx( + tx: &mut dyn MigrationTx, + user_id: Uuid, + request: MigrationRequest, + documents: Vec, + files: Vec, + encrypt_fn: EncryptFn, +) -> anyhow::Result { + // Check if already migrated + let already_completed = tx.user_keys().is_e2ee_setup_completed(user_id).await?; + + if already_completed { + info!(user_id = %user_id, "User already completed E2EE migration"); + return Ok(MigrationResult { + documents_encrypted: 0, + files_encrypted: 0, + updates_cleared: 0, + status: MigrationStatus::AlreadyCompleted, + }); + } + + info!(user_id = %user_id, "Starting E2EE migration"); + + let mut documents_encrypted = 0; + let mut files_encrypted = 0; + let mut updates_cleared: u64 = 0; + + // Encrypt each document + for doc in &documents { + let dek = request.document_deks.get(&doc.id).ok_or_else(|| { + warn!(document_id = %doc.id, "Missing DEK for document"); + anyhow::anyhow!("missing DEK for document") + })?; + + // Validate DEK length + if dek.len() != 32 { + anyhow::bail!("invalid DEK length"); + } + + // Encrypt title + let (encrypted_title, title_nonce) = encrypt_title(encrypt_fn, dek, &doc.title)?; + + tx.migration() + .update_encrypted_title(doc.id, &encrypted_title, &title_nonce) + .await?; + + // Get and encrypt snapshot if exists + if let Some(snapshot) = tx.migration().get_document_snapshot(doc.id).await? { + let (encrypted_snapshot, snapshot_nonce) = + encrypt_snapshot(encrypt_fn, dek, &snapshot.data)?; + + // Get current max seq for seq_at_snapshot + let max_seq = tx + .migration() + .get_document_max_seq(doc.id) + .await? + .unwrap_or(0); + + tx.migration() + .upsert_encrypted_snapshot(doc.id, &encrypted_snapshot, &snapshot_nonce, max_seq) + .await?; + + // Clear plaintext updates + let cleared = tx.migration().clear_plaintext_updates(doc.id).await?; + updates_cleared += cleared; + } + + // Clear plaintext title + tx.migration().clear_plaintext_title(doc.id).await?; + + // Store encrypted DEK + if let Some(encrypted_dek) = request.encrypted_document_deks.get(&doc.id) { + tx.document_keys() + .upsert_encrypted_dek( + doc.id, + &encrypted_dek.encrypted_dek, + &encrypted_dek.nonce, + 1, // Initial key version + ) + .await?; + } + + documents_encrypted += 1; + } + + // Encrypt each file's metadata + for file in &files { + let dek = request.document_deks.get(&file.document_id).ok_or_else(|| { + warn!(file_id = %file.id, document_id = %file.document_id, "Missing DEK for file's document"); + anyhow::anyhow!("missing DEK for file's document") + })?; + + let (encrypted_metadata, metadata_nonce, encrypted_hash) = + encrypt_file_metadata(encrypt_fn, dek, &file.filename, file.content_type.as_deref())?; + + tx.migration() + .update_encrypted_file_metadata(file.id, &encrypted_metadata, &metadata_nonce, &encrypted_hash) + .await?; + + // Clear plaintext metadata + tx.migration().clear_plaintext_file_metadata(file.id).await?; + + files_encrypted += 1; + } + + // Store workspace KEKs for all members + for (workspace_id, member_keks) in &request.encrypted_workspace_keks { + for member_kek in member_keks { + tx.workspace_keys() + .upsert_encrypted_kek( + *workspace_id, + member_kek.user_id, + &member_kek.encrypted_kek, + 1, // Initial key version + ) + .await?; + } + } + + // Mark migration as completed + tx.user_keys().mark_e2ee_setup_completed(user_id).await?; + + info!( + user_id = %user_id, + documents_encrypted, + files_encrypted, + updates_cleared, + "E2EE migration completed" + ); + + Ok(MigrationResult { + documents_encrypted, + files_encrypted, + updates_cleared, + status: MigrationStatus::Completed, + }) +} + +/// Encrypt a document's title. +fn encrypt_title( + encrypt_fn: EncryptFn, + dek: &[u8], + title: &str, +) -> anyhow::Result<(Vec, Vec)> { + let (ciphertext, nonce) = encrypt_fn(dek, title.as_bytes())?; + Ok((ciphertext, nonce.to_vec())) +} + +/// Encrypt a document's Yjs snapshot. +fn encrypt_snapshot( + encrypt_fn: EncryptFn, + dek: &[u8], + snapshot: &[u8], +) -> anyhow::Result<(Vec, Vec)> { + let (ciphertext, nonce) = encrypt_fn(dek, snapshot)?; + Ok((ciphertext, nonce.to_vec())) +} + +/// Encrypt file metadata (filename, content_type). +fn encrypt_file_metadata( + encrypt_fn: EncryptFn, + dek: &[u8], + filename: &str, + content_type: Option<&str>, +) -> anyhow::Result<(Vec, Vec, String)> { + let metadata = json!({ + "filename": filename, + "content_type": content_type + }); + let metadata_bytes = serde_json::to_vec(&metadata)?; + + let (ciphertext, nonce) = encrypt_fn(dek, &metadata_bytes)?; + + // Create encrypted hash (hash of the encrypted metadata) + let encrypted_hash = format!("enc:{}", hex::encode(&ciphertext[..16.min(ciphertext.len())])); + + Ok((ciphertext, nonce.to_vec(), encrypted_hash)) +} + +/// Default encryption function using XChaCha20-Poly1305. +fn default_encrypt_fn(key: &[u8], plaintext: &[u8]) -> Result<(Vec, [u8; 24]), anyhow::Error> { + use chacha20poly1305::{ + aead::{Aead, KeyInit}, + XChaCha20Poly1305, XNonce, + }; + use rand::RngCore; + + if key.len() != 32 { + anyhow::bail!("Invalid key length: expected 32, got {}", key.len()); + } + + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|e| anyhow::anyhow!("Invalid key: {}", e))?; + + let mut nonce_bytes = [0u8; 24]; + rand::thread_rng().fill_bytes(&mut nonce_bytes); + let nonce = XNonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| anyhow::anyhow!("Encryption failed: {}", e))?; + + Ok((ciphertext, nonce_bytes)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_migration_progress_percent() { + let progress = MigrationProgress { + total_documents: 10, + processed_documents: 5, + total_files: 10, + processed_files: 5, + }; + assert!((progress.percent() - 50.0).abs() < 0.01); + } + + #[test] + fn test_migration_progress_empty() { + let progress = MigrationProgress::default(); + assert!((progress.percent() - 100.0).abs() < 0.01); + } +} diff --git a/api/crates/application/src/identity/services/migration/types.rs b/api/crates/application/src/identity/services/migration/types.rs new file mode 100644 index 00000000..ea214ddd --- /dev/null +++ b/api/crates/application/src/identity/services/migration/types.rs @@ -0,0 +1,89 @@ +//! Types for E2EE migration service. + +use std::collections::HashMap; +use uuid::Uuid; + +/// Request to migrate user data to E2EE. +/// +/// Contains all the keys needed to encrypt the user's existing data. +#[derive(Debug, Clone)] +pub struct MigrationRequest { + /// Workspace KEKs (Key Encryption Keys). + /// Maps workspace_id -> raw KEK (32 bytes). + pub workspace_keks: HashMap>, + + /// Document DEKs (Data Encryption Keys). + /// Maps document_id -> raw DEK (32 bytes). + pub document_deks: HashMap>, + + /// Encrypted workspace KEKs to store for each member. + /// Maps workspace_id -> Vec<(user_id, encrypted_kek, nonce)>. + pub encrypted_workspace_keks: HashMap>, + + /// Encrypted DEKs to store for each document. + /// Maps document_id -> (encrypted_dek, nonce). + pub encrypted_document_deks: HashMap, +} + +/// Encrypted KEK for a workspace member. +/// +/// The KEK is encrypted with the member's ECDH public key. +/// The encryption format (including nonce) is handled by the client. +#[derive(Debug, Clone)] +pub struct MemberEncryptedKek { + pub user_id: Uuid, + /// KEK encrypted with user's ECDH public key. + pub encrypted_kek: Vec, +} + +/// Encrypted DEK for a document. +#[derive(Debug, Clone)] +pub struct EncryptedDek { + pub encrypted_dek: Vec, + pub nonce: Vec, +} + +/// Result of the migration process. +#[derive(Debug, Clone)] +pub struct MigrationResult { + /// Number of documents encrypted. + pub documents_encrypted: usize, + + /// Number of files with encrypted metadata. + pub files_encrypted: usize, + + /// Total number of Yjs updates cleared. + pub updates_cleared: u64, + + /// Migration status. + pub status: MigrationStatus, +} + +/// Status of the migration. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MigrationStatus { + /// Migration completed successfully. + Completed, + /// Migration was already completed before. + AlreadyCompleted, +} + +/// Progress tracking during migration. +#[derive(Debug, Clone, Default)] +pub struct MigrationProgress { + pub total_documents: usize, + pub processed_documents: usize, + pub total_files: usize, + pub processed_files: usize, +} + +impl MigrationProgress { + pub fn percent(&self) -> f32 { + let total = self.total_documents + self.total_files; + if total == 0 { + return 100.0; + } + let done = self.processed_documents + self.processed_files; + (done as f32 / total as f32) * 100.0 + } +} diff --git a/api/crates/application/src/identity/services/mod.rs b/api/crates/application/src/identity/services/mod.rs index 5971e04d..59aa7bd8 100644 --- a/api/crates/application/src/identity/services/mod.rs +++ b/api/crates/application/src/identity/services/mod.rs @@ -1,4 +1,5 @@ pub mod api_tokens; pub mod auth; +pub mod migration; pub mod user_keys; pub mod user_shortcuts; diff --git a/api/crates/bootstrap/src/app/build_runtime.rs b/api/crates/bootstrap/src/app/build_runtime.rs index ef45021a..ca43b676 100644 --- a/api/crates/bootstrap/src/app/build_runtime.rs +++ b/api/crates/bootstrap/src/app/build_runtime.rs @@ -29,6 +29,7 @@ use application::identity::ports::secret_hasher::SecretHasher; use application::identity::services::api_tokens::ApiTokenService; use application::identity::services::auth::account::AccountService; use application::identity::services::auth::token_validation::TokenValidationService; +use application::identity::services::migration::MigrationService; use application::identity::services::user_keys::UserKeysService; use application::identity::services::user_shortcuts::UserShortcutService; use application::plugins::ports::plugin_event_publisher::PluginEventPublisher; @@ -288,6 +289,21 @@ pub async fn build_runtime( ), ); let user_keys_service = Arc::new(UserKeysService::new(user_keys_repo.clone())); + let migration_repo = Arc::new( + infrastructure::identity::db::repositories::migration_repository_sqlx::SqlxMigrationRepository::new( + pool.clone(), + ), + ); + let migration_tx_runner: Arc = Arc::new( + infrastructure::identity::db::repositories::migration_tx_runner_sqlx::SqlxMigrationTxRunner::new( + pool.clone(), + migration_repo.clone(), + ), + ); + let migration_service = Arc::new(MigrationService::new( + migration_repo.clone(), + migration_tx_runner, + )); let realtime_stack = realtime::build_realtime_stack( &cfg, &pool, @@ -485,6 +501,7 @@ pub async fn build_runtime( api_token_service: api_token_service.clone(), user_shortcut_service: user_shortcut_service.clone(), user_keys_service: user_keys_service.clone(), + migration_service: migration_service.clone(), account_service: account_service.clone(), auth_service: auth_stack.auth_service.clone(), session_service: auth_stack.session_service.clone(), diff --git a/api/crates/bootstrap/src/http.rs b/api/crates/bootstrap/src/http.rs index 65e54c7a..dbd1a2a4 100644 --- a/api/crates/bootstrap/src/http.rs +++ b/api/crates/bootstrap/src/http.rs @@ -78,6 +78,10 @@ pub async fn build_api_router(cfg: &Config, ctx: AppContext) -> anyhow::Result [u8; NONCE_SIZE] { + let mut nonce = [0u8; NONCE_SIZE]; + rand::thread_rng().fill_bytes(&mut nonce); + nonce +} + +/// Encrypt plaintext using XChaCha20-Poly1305. +/// +/// # Arguments +/// * `key` - 32-byte encryption key (DEK) +/// * `plaintext` - Data to encrypt +/// +/// # Returns +/// A tuple of (ciphertext, nonce) on success. +/// The ciphertext includes the 16-byte Poly1305 authentication tag. +pub fn encrypt(key: &[u8], plaintext: &[u8]) -> Result<(Vec, [u8; NONCE_SIZE]), CryptoError> { + if key.len() != KEY_SIZE { + return Err(CryptoError::InvalidKeyLength(key.len())); + } + + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| CryptoError::InvalidKeyLength(key.len()))?; + + let nonce = generate_nonce(); + let xnonce = XNonce::from_slice(&nonce); + + let ciphertext = cipher + .encrypt(xnonce, plaintext) + .map_err(|_| CryptoError::EncryptionFailed)?; + + Ok((ciphertext, nonce)) +} + +/// Decrypt ciphertext using XChaCha20-Poly1305. +/// +/// # Arguments +/// * `key` - 32-byte encryption key (DEK) +/// * `ciphertext` - Encrypted data (including auth tag) +/// * `nonce` - 24-byte nonce used during encryption +/// +/// # Returns +/// The decrypted plaintext on success. +pub fn decrypt( + key: &[u8], + ciphertext: &[u8], + nonce: &[u8], +) -> Result, CryptoError> { + if key.len() != KEY_SIZE { + return Err(CryptoError::InvalidKeyLength(key.len())); + } + if nonce.len() != NONCE_SIZE { + return Err(CryptoError::InvalidNonceLength(nonce.len())); + } + + let cipher = XChaCha20Poly1305::new_from_slice(key) + .map_err(|_| CryptoError::InvalidKeyLength(key.len()))?; + + let xnonce = XNonce::from_slice(nonce); + + cipher + .decrypt(xnonce, ciphertext) + .map_err(|_| CryptoError::DecryptionFailed) +} + +/// Encrypt a DEK with a KEK. +/// +/// Used for storing document encryption keys encrypted with workspace keys. +/// +/// # Arguments +/// * `kek` - 32-byte Key Encryption Key +/// * `dek` - 32-byte Data Encryption Key to encrypt +/// +/// # Returns +/// A tuple of (encrypted_dek, nonce) on success. +pub fn encrypt_dek(kek: &[u8], dek: &[u8]) -> Result<(Vec, [u8; NONCE_SIZE]), CryptoError> { + if dek.len() != KEY_SIZE { + return Err(CryptoError::InvalidKeyLength(dek.len())); + } + encrypt(kek, dek) +} + +/// Decrypt a DEK with a KEK. +/// +/// # Arguments +/// * `kek` - 32-byte Key Encryption Key +/// * `encrypted_dek` - Encrypted DEK (including auth tag) +/// * `nonce` - 24-byte nonce used during encryption +/// +/// # Returns +/// The decrypted 32-byte DEK on success. +pub fn decrypt_dek( + kek: &[u8], + encrypted_dek: &[u8], + nonce: &[u8], +) -> Result<[u8; KEY_SIZE], CryptoError> { + let decrypted = decrypt(kek, encrypted_dek, nonce)?; + if decrypted.len() != KEY_SIZE { + return Err(CryptoError::InvalidKeyLength(decrypted.len())); + } + + let mut dek = [0u8; KEY_SIZE]; + dek.copy_from_slice(&decrypted); + Ok(dek) +} + +/// A wrapper for sensitive key material that zeroizes on drop. +#[derive(Zeroize)] +#[zeroize(drop)] +pub struct SecretKey { + key: [u8; KEY_SIZE], +} + +impl SecretKey { + /// Create a new SecretKey from bytes. + pub fn new(key: [u8; KEY_SIZE]) -> Self { + Self { key } + } + + /// Create from a slice, returning error if length is invalid. + pub fn from_slice(slice: &[u8]) -> Result { + if slice.len() != KEY_SIZE { + return Err(CryptoError::InvalidKeyLength(slice.len())); + } + let mut key = [0u8; KEY_SIZE]; + key.copy_from_slice(slice); + Ok(Self { key }) + } + + /// Get the key bytes. + pub fn as_bytes(&self) -> &[u8; KEY_SIZE] { + &self.key + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let key = [0x42u8; KEY_SIZE]; + let plaintext = b"Hello, E2EE World!"; + + let (ciphertext, nonce) = encrypt(&key, plaintext).unwrap(); + let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap(); + + assert_eq!(plaintext.as_slice(), decrypted.as_slice()); + } + + #[test] + fn test_encrypt_decrypt_empty() { + let key = [0x42u8; KEY_SIZE]; + let plaintext = b""; + + let (ciphertext, nonce) = encrypt(&key, plaintext).unwrap(); + let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap(); + + assert_eq!(plaintext.as_slice(), decrypted.as_slice()); + } + + #[test] + fn test_encrypt_decrypt_large_data() { + let key = [0x42u8; KEY_SIZE]; + let plaintext = vec![0xABu8; 1024 * 1024]; // 1MB + + let (ciphertext, nonce) = encrypt(&key, &plaintext).unwrap(); + let decrypted = decrypt(&key, &ciphertext, &nonce).unwrap(); + + assert_eq!(plaintext, decrypted); + } + + #[test] + fn test_nonce_uniqueness() { + let nonce1 = generate_nonce(); + let nonce2 = generate_nonce(); + assert_ne!(nonce1, nonce2); + } + + #[test] + fn test_invalid_key_length() { + let short_key = [0x42u8; 16]; + let plaintext = b"test"; + + let result = encrypt(&short_key, plaintext); + assert!(matches!(result, Err(CryptoError::InvalidKeyLength(16)))); + } + + #[test] + fn test_invalid_nonce_length() { + let key = [0x42u8; KEY_SIZE]; + let ciphertext = vec![0u8; 32]; + let short_nonce = [0u8; 12]; + + let result = decrypt(&key, &ciphertext, &short_nonce); + assert!(matches!(result, Err(CryptoError::InvalidNonceLength(12)))); + } + + #[test] + fn test_corrupted_ciphertext() { + let key = [0x42u8; KEY_SIZE]; + let plaintext = b"Hello, E2EE World!"; + + let (mut ciphertext, nonce) = encrypt(&key, plaintext).unwrap(); + // Corrupt the ciphertext + ciphertext[0] ^= 0xFF; + + let result = decrypt(&key, &ciphertext, &nonce); + assert!(matches!(result, Err(CryptoError::DecryptionFailed))); + } + + #[test] + fn test_wrong_key() { + let key1 = [0x42u8; KEY_SIZE]; + let key2 = [0x43u8; KEY_SIZE]; + let plaintext = b"Secret message"; + + let (ciphertext, nonce) = encrypt(&key1, plaintext).unwrap(); + let result = decrypt(&key2, &ciphertext, &nonce); + + assert!(matches!(result, Err(CryptoError::DecryptionFailed))); + } + + #[test] + fn test_dek_encrypt_decrypt() { + let kek = [0x42u8; KEY_SIZE]; + let dek = [0x55u8; KEY_SIZE]; + + let (encrypted_dek, nonce) = encrypt_dek(&kek, &dek).unwrap(); + let decrypted_dek = decrypt_dek(&kek, &encrypted_dek, &nonce).unwrap(); + + assert_eq!(dek, decrypted_dek); + } + + #[test] + fn test_secret_key_zeroize() { + let key_bytes = [0x42u8; KEY_SIZE]; + let secret_key = SecretKey::new(key_bytes); + assert_eq!(secret_key.as_bytes(), &key_bytes); + // SecretKey will zeroize on drop + } +} diff --git a/api/crates/infrastructure/src/identity/db/repositories/migration_repository_sqlx/mod.rs b/api/crates/infrastructure/src/identity/db/repositories/migration_repository_sqlx/mod.rs new file mode 100644 index 00000000..2bd31acf --- /dev/null +++ b/api/crates/infrastructure/src/identity/db/repositories/migration_repository_sqlx/mod.rs @@ -0,0 +1,314 @@ +//! SQLx implementation of the migration repository. + +use async_trait::async_trait; +use sqlx::{Postgres, Row, Transaction}; +use uuid::Uuid; + +use crate::core::db::PgPool; +use application::core::ports::errors::PortResult; +use application::identity::ports::migration_repository::{ + MigrationDocument, MigrationFile, MigrationRepository, MigrationSnapshot, +}; + +/// SQLx implementation of the migration repository. +/// +/// This provides read-only access to plaintext data for migration. +pub struct SqlxMigrationRepository { + pool: PgPool, +} + +impl SqlxMigrationRepository { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } + + /// Get a reference to the pool for transaction creation. + pub fn pool(&self) -> &PgPool { + &self.pool + } +} + +#[async_trait] +impl MigrationRepository for SqlxMigrationRepository { + async fn list_user_documents(&self, user_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + // Get all documents from workspaces where the user is a member + let rows = sqlx::query( + r#" + SELECT d.id, d.workspace_id, d.title, d.created_at + FROM documents d + INNER JOIN workspace_members wm ON d.workspace_id = wm.workspace_id + WHERE wm.user_id = $1 + AND d.encrypted_title IS NULL + ORDER BY d.created_at + "#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + let documents = rows + .into_iter() + .map(|row| MigrationDocument { + id: row.get("id"), + workspace_id: row.get("workspace_id"), + title: row.get("title"), + created_at: row.get("created_at"), + }) + .collect(); + + Ok(documents) + } + .await; + out.map_err(Into::into) + } + + async fn list_user_files(&self, user_id: Uuid) -> PortResult> { + let out: anyhow::Result> = async { + // Get all files from documents in workspaces where the user is a member + let rows = sqlx::query( + r#" + SELECT f.id, f.document_id, d.workspace_id, f.filename, f.content_type, f.storage_path + FROM files f + INNER JOIN documents d ON f.document_id = d.id + INNER JOIN workspace_members wm ON d.workspace_id = wm.workspace_id + WHERE wm.user_id = $1 + AND f.encrypted_metadata IS NULL + ORDER BY f.created_at + "#, + ) + .bind(user_id) + .fetch_all(&self.pool) + .await?; + + let files = rows + .into_iter() + .map(|row| MigrationFile { + id: row.get("id"), + document_id: row.get("document_id"), + workspace_id: row.get("workspace_id"), + filename: row.get("filename"), + content_type: row.get("content_type"), + storage_path: row.get("storage_path"), + }) + .collect(); + + Ok(files) + } + .await; + out.map_err(Into::into) + } +} + +// ============================================================================ +// Transactional operations +// ============================================================================ + +impl SqlxMigrationRepository { + /// Update a document with encrypted title (within transaction). + pub async fn update_encrypted_title_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + encrypted_title: &[u8], + nonce: &[u8], + ) -> anyhow::Result<()> { + sqlx::query( + r#" + UPDATE documents + SET encrypted_title = $2, + encrypted_title_nonce = $3, + updated_at = now() + WHERE id = $1 + "#, + ) + .bind(document_id) + .bind(encrypted_title) + .bind(nonce) + .execute(tx.as_mut()) + .await?; + + Ok(()) + } + + /// Create or update an encrypted snapshot (within transaction). + pub async fn upsert_encrypted_snapshot_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + encrypted_snapshot: &[u8], + nonce: &[u8], + seq_at_snapshot: i64, + ) -> anyhow::Result<()> { + // Get the next version number + let next_version: i64 = sqlx::query_scalar( + r#" + SELECT COALESCE(MAX(version), 0) + 1 + FROM document_snapshots + WHERE document_id = $1 + "#, + ) + .bind(document_id) + .fetch_one(tx.as_mut()) + .await?; + + // Insert new encrypted snapshot + sqlx::query( + r#" + INSERT INTO document_snapshots (document_id, version, snapshot, nonce, seq_at_snapshot, created_at) + VALUES ($1, $2, $3, $4, $5, now()) + "#, + ) + .bind(document_id) + .bind(next_version) + .bind(encrypted_snapshot) + .bind(nonce) + .bind(seq_at_snapshot) + .execute(tx.as_mut()) + .await?; + + Ok(()) + } + + /// Delete all plaintext updates for a document (within transaction). + pub async fn clear_plaintext_updates_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + ) -> anyhow::Result { + // Delete all plaintext updates (those without nonce) + let result = sqlx::query( + r#" + DELETE FROM document_updates + WHERE document_id = $1 + AND nonce IS NULL + "#, + ) + .bind(document_id) + .execute(tx.as_mut()) + .await?; + + Ok(result.rows_affected()) + } + + /// Update a file's metadata with encrypted values (within transaction). + pub async fn update_encrypted_file_metadata_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + file_id: Uuid, + encrypted_metadata: &[u8], + nonce: &[u8], + encrypted_hash: &str, + ) -> anyhow::Result<()> { + sqlx::query( + r#" + UPDATE files + SET encrypted_metadata = $2, + encrypted_metadata_nonce = $3, + encrypted_hash = $4, + updated_at = now() + WHERE id = $1 + "#, + ) + .bind(file_id) + .bind(encrypted_metadata) + .bind(nonce) + .bind(encrypted_hash) + .execute(tx.as_mut()) + .await?; + + Ok(()) + } + + /// Clear plaintext title from a document (within transaction). + pub async fn clear_plaintext_title_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + ) -> anyhow::Result<()> { + sqlx::query( + r#" + UPDATE documents + SET title = '[encrypted]', + updated_at = now() + WHERE id = $1 + AND encrypted_title IS NOT NULL + "#, + ) + .bind(document_id) + .execute(tx.as_mut()) + .await?; + + Ok(()) + } + + /// Clear plaintext metadata from a file (within transaction). + pub async fn clear_plaintext_file_metadata_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + file_id: Uuid, + ) -> anyhow::Result<()> { + sqlx::query( + r#" + UPDATE files + SET filename = '[encrypted]', + content_type = NULL, + updated_at = now() + WHERE id = $1 + AND encrypted_metadata IS NOT NULL + "#, + ) + .bind(file_id) + .execute(tx.as_mut()) + .await?; + + Ok(()) + } + + /// Get the latest Yjs snapshot for a document (within transaction). + pub async fn get_document_snapshot_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + ) -> anyhow::Result> { + let row = sqlx::query( + r#" + SELECT document_id, version, snapshot, seq_at_snapshot + FROM document_snapshots + WHERE document_id = $1 + ORDER BY version DESC + LIMIT 1 + "#, + ) + .bind(document_id) + .fetch_optional(tx.as_mut()) + .await?; + + Ok(row.map(|row| MigrationSnapshot { + document_id: row.get("document_id"), + version: row.get("version"), + data: row.get("snapshot"), + seq_at_snapshot: row.get("seq_at_snapshot"), + })) + } + + /// Get the maximum sequence number for a document's updates (within transaction). + pub async fn get_document_max_seq_tx( + &self, + tx: &mut Transaction<'_, Postgres>, + document_id: Uuid, + ) -> anyhow::Result> { + let row = sqlx::query( + r#" + SELECT MAX(seq) as max_seq + FROM document_updates + WHERE document_id = $1 + "#, + ) + .bind(document_id) + .fetch_one(tx.as_mut()) + .await?; + + Ok(row.get("max_seq")) + } +} diff --git a/api/crates/infrastructure/src/identity/db/repositories/migration_tx_runner_sqlx.rs b/api/crates/infrastructure/src/identity/db/repositories/migration_tx_runner_sqlx.rs new file mode 100644 index 00000000..9209ed79 --- /dev/null +++ b/api/crates/infrastructure/src/identity/db/repositories/migration_tx_runner_sqlx.rs @@ -0,0 +1,274 @@ +//! SQLx implementation of the migration transaction runner. + +use std::sync::Arc; + +use async_trait::async_trait; +use sqlx::{Postgres, Transaction}; +use uuid::Uuid; + +use application::core::ports::errors::PortResult; +use application::identity::ports::migration_repository::MigrationSnapshot; +use application::identity::ports::migration_tx_runner::{ + BoxedTxResult, DocumentKeysRepositoryTx, MigrationRepositoryTx, MigrationTx, + MigrationTxFn, MigrationTxRunner, UserKeysRepositoryTx, WorkspaceKeysRepositoryTx, +}; + +use crate::core::db::PgPool; + +use super::migration_repository_sqlx::SqlxMigrationRepository; + +/// SQLx implementation of the migration transaction runner. +pub struct SqlxMigrationTxRunner { + pool: PgPool, + migration_repo: Arc, +} + +impl SqlxMigrationTxRunner { + pub fn new(pool: PgPool, migration_repo: Arc) -> Self { + Self { + pool, + migration_repo, + } + } +} + +#[async_trait] +impl MigrationTxRunner for SqlxMigrationTxRunner { + async fn run_boxed(&self, f: MigrationTxFn) -> anyhow::Result { + let mut tx = self.pool.begin().await?; + + let mut uow = SqlxMigrationTx { + migration_repo: self.migration_repo.as_ref(), + tx: &mut tx, + }; + + let result = f(&mut uow).await; + match result { + Ok(out) => { + tx.commit().await?; + Ok(out) + } + Err(err) => { + tx.rollback().await.ok(); + Err(err) + } + } + } +} + +/// SQLx transaction context for migration. +struct SqlxMigrationTx<'repo, 'tx, 'c> { + migration_repo: &'repo SqlxMigrationRepository, + tx: &'tx mut Transaction<'c, Postgres>, +} + +impl<'repo, 'tx, 'c> MigrationTx for SqlxMigrationTx<'repo, 'tx, 'c> { + fn migration(&mut self) -> &mut dyn MigrationRepositoryTx { + self + } + + fn document_keys(&mut self) -> &mut dyn DocumentKeysRepositoryTx { + self + } + + fn workspace_keys(&mut self) -> &mut dyn WorkspaceKeysRepositoryTx { + self + } + + fn user_keys(&mut self) -> &mut dyn UserKeysRepositoryTx { + self + } +} + +// ============================================================================ +// MigrationRepositoryTx implementation +// ============================================================================ + +#[async_trait] +impl<'repo, 'tx, 'c> MigrationRepositoryTx for SqlxMigrationTx<'repo, 'tx, 'c> { + async fn update_encrypted_title( + &mut self, + document_id: Uuid, + encrypted_title: &[u8], + nonce: &[u8], + ) -> PortResult<()> { + self.migration_repo + .update_encrypted_title_tx(self.tx, document_id, encrypted_title, nonce) + .await + .map_err(Into::into) + } + + async fn upsert_encrypted_snapshot( + &mut self, + document_id: Uuid, + encrypted_snapshot: &[u8], + nonce: &[u8], + seq_at_snapshot: i64, + ) -> PortResult<()> { + self.migration_repo + .upsert_encrypted_snapshot_tx(self.tx, document_id, encrypted_snapshot, nonce, seq_at_snapshot) + .await + .map_err(Into::into) + } + + async fn clear_plaintext_updates(&mut self, document_id: Uuid) -> PortResult { + self.migration_repo + .clear_plaintext_updates_tx(self.tx, document_id) + .await + .map_err(Into::into) + } + + async fn update_encrypted_file_metadata( + &mut self, + file_id: Uuid, + encrypted_metadata: &[u8], + nonce: &[u8], + encrypted_hash: &str, + ) -> PortResult<()> { + self.migration_repo + .update_encrypted_file_metadata_tx(self.tx, file_id, encrypted_metadata, nonce, encrypted_hash) + .await + .map_err(Into::into) + } + + async fn clear_plaintext_title(&mut self, document_id: Uuid) -> PortResult<()> { + self.migration_repo + .clear_plaintext_title_tx(self.tx, document_id) + .await + .map_err(Into::into) + } + + async fn clear_plaintext_file_metadata(&mut self, file_id: Uuid) -> PortResult<()> { + self.migration_repo + .clear_plaintext_file_metadata_tx(self.tx, file_id) + .await + .map_err(Into::into) + } + + async fn get_document_snapshot( + &mut self, + document_id: Uuid, + ) -> PortResult> { + self.migration_repo + .get_document_snapshot_tx(self.tx, document_id) + .await + .map_err(Into::into) + } + + async fn get_document_max_seq(&mut self, document_id: Uuid) -> PortResult> { + self.migration_repo + .get_document_max_seq_tx(self.tx, document_id) + .await + .map_err(Into::into) + } +} + +// ============================================================================ +// DocumentKeysRepositoryTx implementation +// ============================================================================ + +#[async_trait] +impl<'repo, 'tx, 'c> DocumentKeysRepositoryTx for SqlxMigrationTx<'repo, 'tx, 'c> { + async fn upsert_encrypted_dek( + &mut self, + document_id: Uuid, + encrypted_dek: &[u8], + nonce: &[u8], + key_version: i32, + ) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query( + r#"INSERT INTO document_encrypted_keys (document_id, encrypted_dek, nonce, key_version, created_at, updated_at) + VALUES ($1, $2, $3, $4, now(), now()) + ON CONFLICT (document_id) + DO UPDATE SET + encrypted_dek = EXCLUDED.encrypted_dek, + nonce = EXCLUDED.nonce, + key_version = EXCLUDED.key_version, + updated_at = now()"#, + ) + .bind(document_id) + .bind(encrypted_dek) + .bind(nonce) + .bind(key_version) + .execute(self.tx.as_mut()) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } +} + +// ============================================================================ +// WorkspaceKeysRepositoryTx implementation +// ============================================================================ + +#[async_trait] +impl<'repo, 'tx, 'c> WorkspaceKeysRepositoryTx for SqlxMigrationTx<'repo, 'tx, 'c> { + async fn upsert_encrypted_kek( + &mut self, + workspace_id: Uuid, + user_id: Uuid, + encrypted_kek: &[u8], + key_version: i32, + ) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query( + r#"INSERT INTO workspace_encrypted_keys (workspace_id, user_id, encrypted_kek, key_version, created_at) + VALUES ($1, $2, $3, $4, now()) + ON CONFLICT (workspace_id, user_id, key_version) + DO UPDATE SET + encrypted_kek = EXCLUDED.encrypted_kek"#, + ) + .bind(workspace_id) + .bind(user_id) + .bind(encrypted_kek) + .bind(key_version) + .execute(self.tx.as_mut()) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } +} + +// ============================================================================ +// UserKeysRepositoryTx implementation +// ============================================================================ + +#[async_trait] +impl<'repo, 'tx, 'c> UserKeysRepositoryTx for SqlxMigrationTx<'repo, 'tx, 'c> { + async fn mark_e2ee_setup_completed(&mut self, user_id: Uuid) -> PortResult<()> { + let out: anyhow::Result<()> = async { + sqlx::query(r#"UPDATE users SET e2ee_setup_completed_at = now() WHERE id = $1"#) + .bind(user_id) + .execute(self.tx.as_mut()) + .await?; + Ok(()) + } + .await; + out.map_err(Into::into) + } + + async fn is_e2ee_setup_completed(&mut self, user_id: Uuid) -> PortResult { + let out: anyhow::Result = async { + let row = sqlx::query(r#"SELECT e2ee_setup_completed_at FROM users WHERE id = $1"#) + .bind(user_id) + .fetch_optional(self.tx.as_mut()) + .await?; + + Ok(row + .and_then(|r| { + use sqlx::Row; + r.try_get::>, _>("e2ee_setup_completed_at") + .ok() + }) + .flatten() + .is_some()) + } + .await; + out.map_err(Into::into) + } +} diff --git a/api/crates/infrastructure/src/identity/db/repositories/mod.rs b/api/crates/infrastructure/src/identity/db/repositories/mod.rs index 3c1a9aaf..a4e9e3ae 100644 --- a/api/crates/infrastructure/src/identity/db/repositories/mod.rs +++ b/api/crates/infrastructure/src/identity/db/repositories/mod.rs @@ -1,4 +1,6 @@ pub mod api_token_repository_sqlx; +pub mod migration_repository_sqlx; +pub mod migration_tx_runner_sqlx; pub mod user_keys_repository_sqlx; pub mod user_repository_sqlx; pub mod user_session_repository_sqlx; diff --git a/api/crates/presentation/src/context.rs b/api/crates/presentation/src/context.rs index 61e56521..ccebdc90 100644 --- a/api/crates/presentation/src/context.rs +++ b/api/crates/presentation/src/context.rs @@ -25,6 +25,7 @@ use application::identity::services::auth::account::AccountServiceFacade; use application::identity::services::auth::auth_service::AuthServiceFacade; use application::identity::services::auth::external::ExternalAuthRegistryFacade; use application::identity::services::auth::user_sessions::UserSessionServiceFacade; +use application::identity::services::migration::MigrationServiceFacade; use application::identity::services::user_keys::UserKeysServiceFacade; use application::identity::services::user_shortcuts::UserShortcutServiceFacade; use application::plugins::ports::plugin_event_publisher::PluginScopedEvent; @@ -104,6 +105,7 @@ pub struct IdentityServicesDeps { pub api_token_service: Arc, pub user_shortcut_service: Arc, pub user_keys_service: Arc, + pub migration_service: Arc, pub account_service: Arc, pub auth_service: Arc, pub session_service: Arc, @@ -155,6 +157,7 @@ struct IdentityServices { api_token_service: Arc, user_shortcut_service: Arc, user_keys_service: Arc, + migration_service: Arc, account_service: Arc, auth_service: Arc, session_service: Arc, @@ -208,6 +211,7 @@ impl AppServices { api_token_service: deps.identity.api_token_service, user_shortcut_service: deps.identity.user_shortcut_service, user_keys_service: deps.identity.user_keys_service, + migration_service: deps.identity.migration_service, account_service: deps.identity.account_service, auth_service: deps.identity.auth_service, session_service: deps.identity.session_service, @@ -360,6 +364,10 @@ impl AppContext { self.services.identity.user_keys_service.clone() } + pub fn migration_service(&self) -> Arc { + self.services.identity.migration_service.clone() + } + pub fn realtime_engine(&self) -> Arc { self.services.documents.realtime_engine.clone() } diff --git a/api/crates/presentation/src/context/subcontexts.rs b/api/crates/presentation/src/context/subcontexts.rs index 79d836ae..925efd3f 100644 --- a/api/crates/presentation/src/context/subcontexts.rs +++ b/api/crates/presentation/src/context/subcontexts.rs @@ -244,12 +244,15 @@ impl FromRef for GitContext { } } +use application::identity::services::migration::MigrationServiceFacade; + #[derive(Clone)] pub struct IdentityContext { pub cfg: PresentationConfig, api_token_service: Arc, user_shortcut_service: Arc, user_keys_service: Arc, + migration_service: Arc, account_service: Arc, auth_service: Arc, session_service: Arc, @@ -270,6 +273,10 @@ impl IdentityContext { self.user_keys_service.clone() } + pub fn migration_service(&self) -> Arc { + self.migration_service.clone() + } + pub fn account_service(&self) -> Arc { self.account_service.clone() } @@ -314,6 +321,7 @@ impl FromRef for IdentityContext { api_token_service: ctx.api_token_service(), user_shortcut_service: ctx.user_shortcut_service(), user_keys_service: ctx.user_keys_service(), + migration_service: ctx.migration_service(), account_service: ctx.account_service(), auth_service: ctx.auth_service(), session_service: ctx.session_service(), diff --git a/api/crates/presentation/src/http/identity/migration/handlers/mod.rs b/api/crates/presentation/src/http/identity/migration/handlers/mod.rs new file mode 100644 index 00000000..9112cd35 --- /dev/null +++ b/api/crates/presentation/src/http/identity/migration/handlers/mod.rs @@ -0,0 +1,80 @@ +//! HTTP handlers for E2EE migration. + +use axum::{extract::State, Json}; + +use crate::context::IdentityContext; +use crate::http::error::ApiError; +use crate::http::extractors::AuthedUser; +use application::core::services::errors::ServiceError; + +use super::types::{MigrateRequest, MigrationResponse}; + +fn map_migration_error(err: ServiceError) -> ApiError { + match &err { + ServiceError::Conflict => ApiError::conflict("migration_already_completed"), + ServiceError::BadRequest(msg) => ApiError::bad_request(*msg), + _ => crate::http::error::map_service_error(err, "migration_service_error"), + } +} + +/// Migrate user data to E2EE. +/// +/// This endpoint receives encryption keys from the client and encrypts +/// all of the user's existing plaintext data on the server. +/// +/// The operation is atomic - either all data is encrypted or none is. +#[utoipa::path( + post, + path = "/api/me/e2ee/migrate", + tag = "E2EE", + request_body = MigrateRequest, + responses( + (status = 200, body = MigrationResponse, description = "Migration completed successfully"), + (status = 400, description = "Invalid request (e.g., missing DEK for document)"), + (status = 409, description = "Migration already completed"), + (status = 500, description = "Migration failed") + ) +)] +pub async fn migrate_to_e2ee( + State(ctx): State, + auth: AuthedUser, + Json(payload): Json, +) -> Result, ApiError> { + let request = payload.decode().map_err(|e| ApiError::bad_request(e))?; + + let service = ctx.migration_service(); + let result = service + .migrate_user_data(auth.user_id, request) + .await + .map_err(map_migration_error)?; + + Ok(Json(MigrationResponse::from(result))) +} + +/// Check if migration is needed for the current user. +#[utoipa::path( + get, + path = "/api/me/e2ee/needs-migration", + tag = "E2EE", + responses( + (status = 200, body = NeedsMigrationResponse) + ) +)] +pub async fn needs_migration( + State(ctx): State, + auth: AuthedUser, +) -> Result, ApiError> { + let service = ctx.migration_service(); + let needs = service + .needs_migration(auth.user_id) + .await + .map_err(map_migration_error)?; + + Ok(Json(NeedsMigrationResponse { needs_migration: needs })) +} + +#[derive(Debug, serde::Serialize, utoipa::ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct NeedsMigrationResponse { + pub needs_migration: bool, +} diff --git a/api/crates/presentation/src/http/identity/migration/mod.rs b/api/crates/presentation/src/http/identity/migration/mod.rs new file mode 100644 index 00000000..1f58621c --- /dev/null +++ b/api/crates/presentation/src/http/identity/migration/mod.rs @@ -0,0 +1,22 @@ +//! E2EE migration HTTP module. + +pub mod handlers; +pub mod types; + +use axum::{routing::{get, post}, Router}; + +use crate::context::AppContext; + +pub use handlers::{migrate_to_e2ee, needs_migration, NeedsMigrationResponse}; +pub use types::*; + +pub mod openapi { + pub use super::handlers::*; +} + +pub fn routes(ctx: AppContext) -> Router { + Router::new() + .route("/me/e2ee/migrate", post(handlers::migrate_to_e2ee)) + .route("/me/e2ee/needs-migration", get(handlers::needs_migration)) + .with_state(ctx) +} diff --git a/api/crates/presentation/src/http/identity/migration/types.rs b/api/crates/presentation/src/http/identity/migration/types.rs new file mode 100644 index 00000000..548b6a82 --- /dev/null +++ b/api/crates/presentation/src/http/identity/migration/types.rs @@ -0,0 +1,165 @@ +//! HTTP request/response types for E2EE migration. + +use std::collections::HashMap; + +use base64::Engine; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use uuid::Uuid; + +use application::identity::services::migration::{ + EncryptedDek, MemberEncryptedKek, MigrationRequest, MigrationResult, MigrationStatus, +}; + +// ============================================================================ +// Request Types +// ============================================================================ + +/// Request to migrate user data to E2EE. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MigrateRequest { + /// Workspace KEKs (Key Encryption Keys). + /// Maps workspace_id (string) -> base64-encoded raw KEK. + pub workspace_keks: HashMap, + + /// Document DEKs (Data Encryption Keys). + /// Maps document_id (string) -> base64-encoded raw DEK. + pub document_deks: HashMap, + + /// Encrypted workspace KEKs to store for each member. + /// Maps workspace_id (string) -> array of member encrypted KEKs. + pub encrypted_workspace_keks: HashMap>, + + /// Encrypted DEKs to store for each document. + /// Maps document_id (string) -> encrypted DEK with nonce. + pub encrypted_document_deks: HashMap, +} + +/// Encrypted KEK for a workspace member (request). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MemberEncryptedKekRequest { + /// User ID. + pub user_id: String, + /// Base64-encoded encrypted KEK. + #[schema(value_type = String, format = "byte")] + pub encrypted_kek: String, +} + +/// Encrypted DEK for a document (request). +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct EncryptedDekRequest { + /// Base64-encoded encrypted DEK. + #[schema(value_type = String, format = "byte")] + pub encrypted_dek: String, + /// Base64-encoded nonce. + #[schema(value_type = String, format = "byte")] + pub nonce: String, +} + +impl MigrateRequest { + /// Decode the request into domain types. + pub fn decode(&self) -> Result { + let b64 = base64::engine::general_purpose::STANDARD; + + // Decode workspace KEKs + let mut workspace_keks = HashMap::new(); + for (ws_id, kek_b64) in &self.workspace_keks { + let ws_uuid = Uuid::parse_str(ws_id).map_err(|_| "invalid_workspace_id")?; + let kek = b64.decode(kek_b64).map_err(|_| "invalid_base64_kek")?; + if kek.len() != 32 { + return Err("invalid_kek_length"); + } + workspace_keks.insert(ws_uuid, kek); + } + + // Decode document DEKs + let mut document_deks = HashMap::new(); + for (doc_id, dek_b64) in &self.document_deks { + let doc_uuid = Uuid::parse_str(doc_id).map_err(|_| "invalid_document_id")?; + let dek = b64.decode(dek_b64).map_err(|_| "invalid_base64_dek")?; + if dek.len() != 32 { + return Err("invalid_dek_length"); + } + document_deks.insert(doc_uuid, dek); + } + + // Decode encrypted workspace KEKs + let mut encrypted_workspace_keks = HashMap::new(); + for (ws_id, members) in &self.encrypted_workspace_keks { + let ws_uuid = Uuid::parse_str(ws_id).map_err(|_| "invalid_workspace_id")?; + let mut member_keks = Vec::new(); + for m in members { + let user_uuid = Uuid::parse_str(&m.user_id).map_err(|_| "invalid_user_id")?; + let encrypted_kek = b64 + .decode(&m.encrypted_kek) + .map_err(|_| "invalid_base64_encrypted_kek")?; + member_keks.push(MemberEncryptedKek { + user_id: user_uuid, + encrypted_kek, + }); + } + encrypted_workspace_keks.insert(ws_uuid, member_keks); + } + + // Decode encrypted document DEKs + let mut encrypted_document_deks = HashMap::new(); + for (doc_id, enc_dek) in &self.encrypted_document_deks { + let doc_uuid = Uuid::parse_str(doc_id).map_err(|_| "invalid_document_id")?; + let encrypted_dek = b64 + .decode(&enc_dek.encrypted_dek) + .map_err(|_| "invalid_base64_encrypted_dek")?; + let nonce = b64 + .decode(&enc_dek.nonce) + .map_err(|_| "invalid_base64_nonce")?; + encrypted_document_deks.insert( + doc_uuid, + EncryptedDek { + encrypted_dek, + nonce, + }, + ); + } + + Ok(MigrationRequest { + workspace_keks, + document_deks, + encrypted_workspace_keks, + encrypted_document_deks, + }) + } +} + +// ============================================================================ +// Response Types +// ============================================================================ + +/// Response for migration result. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct MigrationResponse { + /// Number of documents encrypted. + pub documents_encrypted: usize, + /// Number of files with encrypted metadata. + pub files_encrypted: usize, + /// Total number of Yjs updates cleared. + pub updates_cleared: u64, + /// Migration status. + pub status: String, +} + +impl From for MigrationResponse { + fn from(result: MigrationResult) -> Self { + Self { + documents_encrypted: result.documents_encrypted, + files_encrypted: result.files_encrypted, + updates_cleared: result.updates_cleared, + status: match result.status { + MigrationStatus::Completed => "completed".to_string(), + MigrationStatus::AlreadyCompleted => "already_completed".to_string(), + }, + } + } +} diff --git a/api/crates/presentation/src/http/identity/mod.rs b/api/crates/presentation/src/http/identity/mod.rs index cde29156..fa091d53 100644 --- a/api/crates/presentation/src/http/identity/mod.rs +++ b/api/crates/presentation/src/http/identity/mod.rs @@ -1,4 +1,5 @@ pub mod api_tokens; pub mod auth; pub mod keys; +pub mod migration; pub mod shortcuts; diff --git a/api/crates/presentation/src/openapi.rs b/api/crates/presentation/src/openapi.rs index 04d18b10..f2bec71c 100644 --- a/api/crates/presentation/src/openapi.rs +++ b/api/crates/presentation/src/openapi.rs @@ -4,7 +4,7 @@ use crate::http::core::{health, markdown, storage_ingest}; use crate::http::documents::files; use crate::http::documents::keys as document_keys; use crate::http::documents::{publishing as public, sharing as shares, tagging as tags}; -use crate::http::identity::{api_tokens, auth, keys, shortcuts}; +use crate::http::identity::{api_tokens, auth, keys, migration, shortcuts}; use crate::http::{documents, git, plugins, workspaces}; use crate::ws; @@ -35,6 +35,8 @@ use crate::ws; keys::openapi::get_encrypted_private_key, keys::openapi::mark_e2ee_setup_complete, keys::openapi::get_e2ee_status, + migration::openapi::migrate_to_e2ee, + migration::openapi::needs_migration, document_keys::openapi::get_document_key, document_keys::openapi::store_document_key, document_keys::openapi::rotate_document_key, @@ -178,6 +180,11 @@ use crate::ws; keys::EncryptedPrivateKeyResponse, keys::StoreEncryptedPrivateKeyRequest, keys::E2eeStatusResponse, + migration::MigrateRequest, + migration::MemberEncryptedKekRequest, + migration::EncryptedDekRequest, + migration::MigrationResponse, + migration::NeedsMigrationResponse, document_keys::DocumentKeyResponse, document_keys::StoreDocumentKeyRequest, document_keys::RotateDocumentKeyRequest, diff --git a/api/openapi.json b/api/openapi.json index e69de29b..d81e4b24 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -0,0 +1,49 @@ + Compiling application v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/application) + Compiling presentation v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/presentation) + Compiling infrastructure v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/infrastructure) +warning: fields `hydration_service` and `awareness_ttl` are never read + --> crates/infrastructure/src/documents/realtime/redis/engine.rs:53:5 + | +51 | pub struct RedisRealtimeEngine { + | ------------------- fields in this struct +52 | bus: Arc, +53 | hydration_service: Arc, + | ^^^^^^^^^^^^^^^^^ +... +57 | awareness_ttl: Duration, + | ^^^^^^^^^^^^^ + | + = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default + +warning: function `analyse_frame` is never used + --> crates/infrastructure/src/documents/realtime/utils.rs:17:8 + | +17 | pub fn analyse_frame(frame: &[u8]) -> Result { + | ^^^^^^^^^^^^^ + +warning: struct `FrameSummary` is never constructed + --> crates/infrastructure/src/documents/realtime/utils.rs:36:12 + | +36 | pub struct FrameSummary { + | ^^^^^^^^^^^^ + +warning: function `wrap_stream_with_edit_guard` is never used + --> crates/infrastructure/src/documents/realtime/utils.rs:41:8 + | +41 | pub fn wrap_stream_with_edit_guard( + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +warning: struct `GuardedStream` is never constructed + --> crates/infrastructure/src/documents/realtime/utils.rs:53:8 + | +53 | struct GuardedStream { + | ^^^^^^^^^^^^^ + + Compiling bootstrap v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/bootstrap) + Compiling api v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/api) + Compiling cli v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/cli) +warning: `infrastructure` (lib) generated 5 warnings + Compiling refmd-bins v0.1.0 (/home/munenick/workspace/refmdio/refmd/api) + Finished `dev` profile [unoptimized + debuginfo] target(s) in 16.51s + Running `target/debug/refmd openapi export` +{"openapi":"3.0.3","info":{"title":"presentation","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{docId}/files":{"post":{"tags":["Files"],"operationId":"upload_file","parameters":[{"name":"docId","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetContentResponse"}}}}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}},"404":{"description":"Document key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}}}}},"/api/documents/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDetailResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/tags":{"get":{"tags":["Tags"],"summary":"Get tags for a specific document (E2EE format)","operationId":"get_document_tags","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentTagsResponse"}}}}}},"put":{"tags":["Tags"],"summary":"Replace tags for a document (E2EE format)","operationId":"update_document_tags","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentTagsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentTagsResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/e2ee/migrate":{"post":{"tags":["E2EE"],"summary":"Migrate user data to E2EE.","description":"This endpoint receives encryption keys from the client and encrypts\nall of the user's existing plaintext data on the server.\n\nThe operation is atomic - either all data is encrypted or none is.","operationId":"migrate_to_e2ee","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateRequest"}}},"required":true},"responses":{"200":{"description":"Migration completed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationResponse"}}}},"400":{"description":"Invalid request (e.g., missing DEK for document)"},"409":{"description":"Migration already completed"},"500":{"description":"Migration failed"}}}},"/api/me/e2ee/needs-migration":{"get":{"tags":["E2EE"],"summary":"Check if migration is needed for the current user.","operationId":"needs_migration","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NeedsMigrationResponse"}}}}}}},"/api/me/e2ee/setup-complete":{"post":{"tags":["E2EE"],"operationId":"mark_e2ee_setup_complete","responses":{"204":{"description":""}}}},"/api/me/e2ee/status":{"get":{"tags":["E2EE"],"operationId":"get_e2ee_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/E2eeStatusResponse"}}}}}}},"/api/me/keys":{"get":{"tags":["E2EE"],"operationId":"get_my_public_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}},"post":{"tags":["E2EE"],"operationId":"register_public_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPublicKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}}}}},"/api/me/master-key/backup":{"get":{"tags":["E2EE"],"operationId":"get_master_key_backup","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}},"404":{"description":"Master key backup not found"}}},"post":{"tags":["E2EE"],"operationId":"store_master_key_backup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreMasterKeyBackupRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/private-key/encrypted":{"get":{"tags":["E2EE"],"operationId":"get_encrypted_private_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}},"404":{"description":"Encrypted private key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_encrypted_private_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreEncryptedPrivateKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugin-assets":{"get":{"tags":["Plugins"],"operationId":"pluginsGetAsset","parameters":[{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Plugin asset"}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"description":"Optional plaintext content for E2EE workspaces","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PublishRequest"}],"nullable":true}}},"required":false},"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/salt":{"get":{"tags":["Sharing"],"summary":"Get salt for password-protected share (for password challenge)","operationId":"get_share_salt","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Salt info for password-protected share","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}},"404":{"description":"Share key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/keys/password-protected":{"post":{"tags":["E2EE"],"operationId":"store_password_protected_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StorePasswordProtectedShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/salt":{"get":{"tags":["E2EE"],"operationId":"get_share_salt","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/storage/ingest":{"post":{"tags":["Storage"],"operationId":"enqueue_ingest_events","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestBatchRequest"}}},"required":true},"responses":{"202":{"description":"Events enqueued"},"400":{"description":"Invalid request"}}}},"/api/tags":{"get":{"tags":["Tags"],"summary":"List all tags in the workspace (E2EE format)","operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Base64 encoded encrypted tag for exact match filter","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListTagsResponse"}}}}}}},"/api/users/{user_id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_user_public_key","parameters":[{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"list_workspace_keys","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"post":{"tags":["E2EE"],"operationId":"store_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"/api/workspaces/{id}/keys/me":{"get":{"tags":["E2EE"],"operationId":"get_my_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}},"404":{"description":"Key not found"}}}},"/api/workspaces/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/keys/version":{"get":{"tags":["E2EE"],"operationId":"get_workspace_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyVersionResponse"}}}}}}},"/api/workspaces/{id}/keys/{version}":{"delete":{"tags":["E2EE"],"operationId":"delete_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","description":"Key version to delete","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteKeyVersionResponse"}}}},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/leave":{"post":{"tags":["Workspaces"],"operationId":"leave_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentDekPayload":{"type":"object","description":"DEK payload for document creation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"CreateDocumentRequest":{"type":"object","properties":{"dek":{"allOf":[{"$ref":"#/components/schemas/CreateDocumentDekPayload"}],"nullable":true},"encryptedTitle":{"type":"string","format":"byte","description":"Base64 encoded encrypted title (for E2EE clients)","nullable":true},"encryptedTitleNonce":{"type":"string","format":"byte","description":"Base64 encoded nonce for encrypted title","nullable":true},"parentId":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK (encrypted with share key derived from password)","nullable":true},"expiresAt":{"type":"string","format":"date-time","nullable":true},"kdfParams":{"description":"KDF parameters (e.g., Argon2id settings)","nullable":true},"permission":{"type":"string","nullable":true},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for key derivation","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"DeleteKeyVersionResponse":{"type":"object","required":["workspaceId","keyVersion","deletedCount"],"properties":{"deletedCount":{"type":"integer","format":"int64","minimum":0},"keyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"encryptedTitle":{"type":"string","format":"byte","nullable":true},"encryptedTitleNonce":{"type":"string","format":"byte","nullable":true},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid","description":"Legacy alias for `workspace_id` kept for backward compatibility with older clients."},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentKeyResponse":{"type":"object","required":["documentId","encryptedDek","nonce","keyVersion","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte"},"keyVersion":{"type":"integer","format":"int32"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","op"],"properties":{"encrypted_data":{"type":"string","format":"byte","description":"Base64 encoded encrypted data (for E2EE documents)","nullable":true},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required when encrypted_data is provided)","nullable":true},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"public_key":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 public key (for E2EE documents)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 signature (for E2EE documents)","nullable":true},"text":{"type":"string","description":"Plaintext to insert (for non-E2EE documents)","nullable":true}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","op"],"properties":{"encrypted_data":{"type":"string","format":"byte","description":"Base64 encoded encrypted data (for E2EE documents)","nullable":true},"length":{"type":"integer","minimum":0},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required when encrypted_data is provided)","nullable":true},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"public_key":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 public key (for E2EE documents)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 signature (for E2EE documents)","nullable":true},"text":{"type":"string","description":"Plaintext replacement (for non-E2EE documents)","nullable":true}}}],"description":"Patch operation for document content.\nFor plaintext mode: use `text` field.\nFor E2EE mode: use `encrypted_data` and `nonce` fields instead of `text`.","discriminator":{"propertyName":"op"}},"DocumentTagEntry":{"type":"object","description":"Tag entry in document tags response","required":["id","encryptedName","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"},"id":{"type":"string","format":"uuid"}}},"DocumentTagsResponse":{"type":"object","description":"Response for GET /api/documents/{id}/tags","required":["tags"],"properties":{"tags":{"type":"array","items":{"$ref":"#/components/schemas/DocumentTagEntry"}}}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"E2eeStatusResponse":{"type":"object","required":["isSetupCompleted"],"properties":{"isSetupCompleted":{"type":"boolean"}}},"EncryptedDekRequest":{"type":"object","description":"Encrypted DEK for a document (request).","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64-encoded encrypted DEK."},"nonce":{"type":"string","format":"byte","description":"Base64-encoded nonce."}}},"EncryptedPrivateKeyResponse":{"type":"object","required":["encryptedPrivateKey","nonce","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedPrivateKey":{"type":"string","format":"byte"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"EncryptedTagInput":{"type":"object","description":"Single encrypted tag in request","required":["encryptedName"],"properties":{"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GetContentResponse":{"type":"object","description":"Response for GET /api/documents/{id}/content\n- For E2EE documents: content is encrypted, nonce is present\n- For non-E2EE documents: content is plaintext Yjs state, nonce is None","required":["content"],"properties":{"content":{"type":"string","format":"byte","description":"Base64 encoded Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE)"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce for decryption (present for E2EE documents)","nullable":true}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"IngestBatchRequest":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/IngestEventRequest"}}}},"IngestEventRequest":{"type":"object","required":["repo_path","kind"],"properties":{"backend":{"type":"string","nullable":true},"content_hash":{"type":"string","nullable":true},"kind":{"$ref":"#/components/schemas/IngestKindParam"},"payload":{"nullable":true},"repo_path":{"type":"string"}}},"IngestKindParam":{"type":"string","enum":["upsert","delete"]},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KdfParamsResponse":{"type":"object","properties":{"iterations":{"type":"integer","format":"int32","nullable":true,"minimum":0},"memory":{"type":"integer","format":"int32","nullable":true,"minimum":0},"parallelism":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"ListTagsResponse":{"type":"object","description":"Response for GET /api/tags","required":["tags"],"properties":{"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagEntry"}}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MasterKeyBackupResponse":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKey":{"type":"string","format":"byte"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string"},"salt":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"MemberEncryptedKekRequest":{"type":"object","description":"Encrypted KEK for a workspace member (request).","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64-encoded encrypted KEK."},"userId":{"type":"string","description":"User ID."}}},"MigrateRequest":{"type":"object","description":"Request to migrate user data to E2EE.","required":["workspaceKeks","documentDeks","encryptedWorkspaceKeks","encryptedDocumentDeks"],"properties":{"documentDeks":{"type":"object","description":"Document DEKs (Data Encryption Keys).\nMaps document_id (string) -> base64-encoded raw DEK.","additionalProperties":{"type":"string"}},"encryptedDocumentDeks":{"type":"object","description":"Encrypted DEKs to store for each document.\nMaps document_id (string) -> encrypted DEK with nonce.","additionalProperties":{"$ref":"#/components/schemas/EncryptedDekRequest"}},"encryptedWorkspaceKeks":{"type":"object","description":"Encrypted workspace KEKs to store for each member.\nMaps workspace_id (string) -> array of member encrypted KEKs.","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MemberEncryptedKekRequest"}}},"workspaceKeks":{"type":"object","description":"Workspace KEKs (Key Encryption Keys).\nMaps workspace_id (string) -> base64-encoded raw KEK.","additionalProperties":{"type":"string"}}}},"MigrationResponse":{"type":"object","description":"Response for migration result.","required":["documentsEncrypted","filesEncrypted","updatesCleared","status"],"properties":{"documentsEncrypted":{"type":"integer","description":"Number of documents encrypted.","minimum":0},"filesEncrypted":{"type":"integer","description":"Number of files with encrypted metadata.","minimum":0},"status":{"type":"string","description":"Migration status."},"updatesCleared":{"type":"integer","format":"int64","description":"Total number of Yjs updates cleared.","minimum":0}}},"NeedsMigrationResponse":{"type":"object","required":["needsMigration"],"properties":{"needsMigration":{"type":"boolean"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"},"description":"Patch operations. Each operation can be either plaintext (using `text` field)\nor encrypted (using `encryptedData` and `nonce` fields)."},"signature":{"type":"string","format":"byte","description":"Base64 encoded signature for integrity verification (optional for E2EE)","nullable":true}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishRequest":{"type":"object","description":"Request to publish a document. For E2EE workspaces, plaintext title and content\nmust be provided so public pages can be rendered without decryption.","properties":{"plaintextContent":{"type":"string","description":"Plaintext content (required for E2EE mode)","nullable":true},"plaintextTitle":{"type":"string","description":"Plaintext title (required for E2EE mode)","nullable":true}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterPublicKeyRequest":{"type":"object","required":["publicKey","keyType"],"properties":{"keyType":{"type":"string","description":"Key type (e.g., \"ecdh-p256\")","example":"ecdh-p256"},"publicKey":{"type":"string","format":"byte","description":"Base64 encoded public key"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"RotateDocumentKeyRequest":{"type":"object","description":"Request body for document DEK rotation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded new encrypted DEK"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"RotateDocumentKeyResponse":{"type":"object","description":"Response for document DEK rotation","required":["documentId","newKeyVersion"],"properties":{"documentId":{"type":"string","format":"uuid"},"newKeyVersion":{"type":"integer","format":"int32"}}},"RotateWorkspaceKeyRequest":{"type":"object","description":"Request body for KEK rotation","required":["memberKeys"],"properties":{"memberKeys":{"type":"array","items":{"$ref":"#/components/schemas/RotationMemberKey"},"description":"Encrypted KEKs for all workspace members"}}},"RotateWorkspaceKeyResponse":{"type":"object","description":"Response for KEK rotation","required":["workspaceId","newKeyVersion","keysUpdated"],"properties":{"keysUpdated":{"type":"integer","minimum":0},"newKeyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"RotationMemberKey":{"type":"object","description":"A single member's encrypted KEK for key rotation","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK for this member"},"userId":{"type":"string","format":"uuid","description":"User ID of the member"}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK (encrypted with share key)","nullable":true},"id":{"type":"string","format":"uuid"},"kdfParams":{"description":"KDF parameters for password-protected shares","nullable":true},"permission":{"type":"string"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for password-protected shares","nullable":true},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareKeyResponse":{"type":"object","required":["shareId","encryptedDek","isPasswordProtected","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedDek":{"type":"string","format":"byte"},"isPasswordProtected":{"type":"boolean"},"kdfParams":{"allOf":[{"$ref":"#/components/schemas/KdfParamsResponse"}],"nullable":true},"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"ShareSaltResponse":{"type":"object","description":"Response for share salt challenge (for password-protected shares)","required":["passwordProtected"],"properties":{"kdfParams":{"description":"KDF parameters for key derivation (only present if password-protected)","nullable":true},"passwordProtected":{"type":"boolean","description":"Whether this share is password-protected"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for key derivation (only present if password-protected)","nullable":true}}},"SnapshotDetailResponse":{"type":"object","description":"Response for GET /api/documents/{id}/snapshots/{snapshotId}\n- For E2EE documents: content is encrypted, nonce is present\n- For non-E2EE documents: content is plaintext Yjs state, nonce is None","required":["id","content","createdAt"],"properties":{"content":{"type":"string","format":"byte","description":"Base64 encoded Yjs snapshot (encrypted for E2EE, plaintext for non-E2EE)"},"createdAt":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (present for E2EE documents)","nullable":true}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"nonce":{"type":"string","format":"byte","nullable":true},"notes":{"type":"string","nullable":true},"signature":{"type":"string","format":"byte","nullable":true}}},"StoreDocumentKeyRequest":{"type":"object","required":["encryptedDek","nonce","keyVersion"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreEncryptedPrivateKeyRequest":{"type":"object","required":["encryptedPrivateKey","nonce"],"properties":{"encryptedPrivateKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted private key"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreMasterKeyBackupRequest":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams"],"properties":{"encryptedKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted master key"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string","description":"KDF type (e.g., \"argon2id\", \"pbkdf2\")","example":"argon2id"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StorePasswordProtectedShareKeyRequest":{"type":"object","required":["encryptedDek","salt","kdfParams"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StoreShareKeyRequest":{"type":"object","required":["encryptedDek"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"}}},"StoreWorkspaceKeyRequest":{"type":"object","required":["encryptedKek","keyVersion"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version (for key rotation tracking)"}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagEntry":{"type":"object","description":"Tag entry in list response (E2EE format)","required":["encryptedName","documentCount"],"properties":{"documentCount":{"type":"integer","format":"int64"},"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string","description":"Document content (plaintext or Base64-encoded encrypted Yjs state for E2EE)"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required for E2EE content)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded signature for integrity verification (optional for E2EE)","nullable":true}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateDocumentTagsRequest":{"type":"object","description":"Request for PUT /api/documents/{id}/tags","required":["encryptedTags"],"properties":{"encryptedTags":{"type":"array","items":{"$ref":"#/components/schemas/EncryptedTagInput"}}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","description":"Multipart upload schema for OpenAPI","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"Encrypted file binary (.rme format)"},"metadata":{"type":"string","description":"JSON metadata containing encrypted file metadata","nullable":true}}},"UploadFileResponse":{"type":"object","description":"Response for file upload (E2EE format per design)","required":["id","encryptedHash","size"],"properties":{"encryptedHash":{"type":"string","description":"SHA256 hash of encrypted file content"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"}}},"UserPublicKeyResponse":{"type":"object","required":["publicKey","keyType","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"keyType":{"type":"string"},"publicKey":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceKeyResponse":{"type":"object","required":["id","workspaceId","userId","encryptedKek","keyVersion","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKek":{"type":"string","format":"byte"},"id":{"type":"string","format":"uuid"},"keyVersion":{"type":"integer","format":"int32"},"userId":{"type":"string","format":"uuid"},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceKeyVersionResponse":{"type":"object","required":["workspaceId"],"properties":{"keyVersion":{"type":"integer","format":"int32","nullable":true},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"E2EE","description":"End-to-end encryption key management"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Storage","description":"Storage ingest APIs"},{"name":"Health","description":"System health checks"}]} From 6fc755eab805b733c2f21303a1a212c62ed3ace5 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 8 Jan 2026 18:21:18 +0900 Subject: [PATCH 07/45] refactor: monaco to codemirror --- app/package-lock.json | 736 ++++++++++++++++-- app/package.json | 17 +- .../edit-document/hooks/useAwarenessStyles.ts | 87 --- .../edit-document/hooks/useEditorBinding.ts | 122 +++ .../edit-document/hooks/useEditorUploads.ts | 38 +- .../hooks/useMarkdownCommands.ts | 122 ++- .../edit-document/hooks/useMonacoBinding.ts | 106 --- .../edit-document/hooks/useScrollSync.ts | 153 ++-- .../edit-document/lib/editor/awareness.ts | 175 +++++ .../edit-document/lib/editor/index.ts | 83 ++ .../edit-document/lib/editor/keymaps.ts | 175 +++++ .../edit-document/lib/editor/theme.ts | 206 +++++ .../features/edit-document/lib/editor/vim.ts | 41 + .../edit-document/lib/editor/wiki-link.ts | 156 ++++ .../edit-document/lib/monaco/theme.ts | 142 ---- .../edit-document/lib/monaco/vim-loader.ts | 227 ------ .../lib/monaco/wiki-link-provider.ts | 76 -- .../edit-document/model/editor-context.tsx | 14 +- app/src/features/edit-document/ui/Editor.tsx | 547 ++++--------- .../edit-document/ui/EditorLayout.tsx | 326 ++------ .../features/edit-document/ui/EditorPane.tsx | 155 +++- .../model/shortcut-registry-provider.tsx | 4 +- app/src/styles.css | 16 +- app/src/types/monaco-vim.d.ts | 43 - .../document/DocumentMosaicWorkspace.tsx | 13 +- app/src/widgets/header/Header.tsx | 4 +- 26 files changed, 2158 insertions(+), 1626 deletions(-) delete mode 100644 app/src/features/edit-document/hooks/useAwarenessStyles.ts create mode 100644 app/src/features/edit-document/hooks/useEditorBinding.ts delete mode 100644 app/src/features/edit-document/hooks/useMonacoBinding.ts create mode 100644 app/src/features/edit-document/lib/editor/awareness.ts create mode 100644 app/src/features/edit-document/lib/editor/index.ts create mode 100644 app/src/features/edit-document/lib/editor/keymaps.ts create mode 100644 app/src/features/edit-document/lib/editor/theme.ts create mode 100644 app/src/features/edit-document/lib/editor/vim.ts create mode 100644 app/src/features/edit-document/lib/editor/wiki-link.ts delete mode 100644 app/src/features/edit-document/lib/monaco/theme.ts delete mode 100644 app/src/features/edit-document/lib/monaco/vim-loader.ts delete mode 100644 app/src/features/edit-document/lib/monaco/wiki-link-provider.ts delete mode 100644 app/src/types/monaco-vim.d.ts diff --git a/app/package-lock.json b/app/package-lock.json index 008da57f..26d72bb8 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -6,7 +6,16 @@ "": { "name": "app", "dependencies": { - "@monaco-editor/react": "^4.7.0", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.1", + "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.11.2", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.3", + "@codemirror/view": "^6.39.9", + "@lezer/markdown": "^1.6.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12", @@ -20,6 +29,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@replit/codemirror-vim": "^6.3.0", "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", @@ -35,9 +45,6 @@ "cmdk": "^1.1.1", "is-hotkey": "^0.2.0", "lucide-react": "^0.544.0", - "monaco-editor": "^0.52.2", - "monaco-markdown": "^0.0.12", - "monaco-vim": "^0.4.2", "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -50,8 +57,8 @@ "tailwindcss": "^4.0.6", "tw-animate-css": "^1.3.6", "vite-tsconfig-paths": "^5.1.4", + "y-codemirror.next": "^0.3.5", "y-indexeddb": "^9.0.12", - "y-monaco": "^0.1.6", "y-websocket": "^1.5.4", "yjs": "^13.6.27" }, @@ -1778,6 +1785,417 @@ "node": ">=10.0.0" } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.0", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz", + "integrity": "sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.1.tgz", + "integrity": "sha512-uWDWFypNdQmz2y1LaNJzK7fL7TYKLeUAU0npEC685OKTF3KcQ2Vu3klIM78D7I6wGhktme0lh3CuQLv0ZCrD9Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.4.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-angular": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-angular/-/lang-angular-0.1.4.tgz", + "integrity": "sha512-oap+gsltb/fzdlTQWD6BFF4bSLKcDnlxDsLdePiJpCVNKWXSTAbiiQeYI3UmES+BLAdkmIC1WjyztC1pi/bX4g==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.3" + } + }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.3.tgz", + "integrity": "sha512-URM26M3vunFFn9/sm6rzqrBzDgfWuDixp85uTY49wKudToc2jTHUrKIGGKs+QWND+YLofNNZpxcNGRynFJfvgA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz", + "integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.11", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz", + "integrity": "sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.12" + } + }, + "node_modules/@codemirror/lang-java": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.2.tgz", + "integrity": "sha512-m5Nt1mQ/cznJY7tMfQTJchmrjdjQ71IDs+55d1GAa8DGaB8JXWsVCkVT284C3RTASaY43YknrK2X3hPO/J3MOQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/java": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz", + "integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-jinja": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-jinja/-/lang-jinja-6.0.0.tgz", + "integrity": "sha512-47MFmRcR8UAxd8DReVgj7WJN1WSAMT7OJnewwugZM4XiHWkOjgJQqvEM1NpMj9ALMPyxmlziEI1opH9IaEvmaw==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-less": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-less/-/lang-less-6.0.2.tgz", + "integrity": "sha512-EYdQTG22V+KUUk8Qq582g7FMnCZeEHsyuOJisHRft/mQ+ZSZ2w51NupvDUHiqtsOy7It5cHLPGfHQLpMh9bqpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-liquid": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-liquid/-/lang-liquid-6.3.1.tgz", + "integrity": "sha512-S/jE/D7iij2Pu70AC65ME6AYWxOOcX20cSJvaPgY5w7m2sfxsArAcUAuUgm/CZCVmqoi9KiOlS7gj/gyLipABw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-markdown": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.5.0.tgz", + "integrity": "sha512-0K40bZ35jpHya6FriukbgaleaqzBLZfOh7HuzqbMxBXkbYMJDxfF39c23xOgxFezR+3G+tR2/Mup+Xk865OMvw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.7.1", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.3.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/markdown": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.2.tgz", + "integrity": "sha512-ZKy2v1n8Fc8oEXj0Th0PUMXzQJ0AIR6TaZU+PbDHExFwdu+guzOA4jmCHS1Nz4vbFezwD7LyBdDnddSJeScMCA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.2.1.tgz", + "integrity": "sha512-IRjC8RUBhn9mGR9ywecNhB51yePWCGgvHfY1lWN/Mrp3cKuHr0isDKia+9HnvhiWNnMpbGhWrkhuWOc09exRyw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.2.tgz", + "integrity": "sha512-EZaGjCUegtiU7kSMvOfEZpaCReowEf3yNidYu7+vfuGTm9ow4mthAparY5hisJqOHmJowVH3Upu+eJlUji6qqA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sass": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sass/-/lang-sass-6.0.2.tgz", + "integrity": "sha512-l/bdzIABvnTo1nzdY6U+kPAC51czYQcOErfzQ9zSm9D8GmNPD0WTW8st/CJwBTPLO8jlrbyvlSEcN20dc4iL0Q==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-css": "^6.2.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/sass": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.10.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.10.0.tgz", + "integrity": "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-vue": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-vue/-/lang-vue-0.1.3.tgz", + "integrity": "sha512-QSKdtYTDRhEHCfo5zOShzxCmqKJvgGrZwDQSdbvCRJ5pRLWBS7pD/8e/tH44aVQT6FKm0t6RVNoSUWHOI5vNug==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-javascript": "^6.1.2", + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.1" + } + }, + "node_modules/@codemirror/lang-wast": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-wast/-/lang-wast-6.0.2.tgz", + "integrity": "sha512-Imi2KTpVGm7TKuUkqyJ5NRmeFWF7aMpNiwHnLQe0x9kmrxElndyH0K6H/gXtWwY6UshMRAhpENsgfpSwsgmC6Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-xml": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-xml/-/lang-xml-6.1.0.tgz", + "integrity": "sha512-3z0blhicHLfwi2UgkZYRPioSgVTo9PV5GP5ducFH6FaHy0IAJRg+ixj5gTR1gnT/glAIC8xv4w2VL1LoZfs+Jg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/xml": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.2.tgz", + "integrity": "sha512-dxrfG8w5Ce/QbT7YID7mWZFKhdhsaTNOYjOkSIMt1qmC4VQnXSDSYVHHHn8k6kJUfIhtLo8t1JJgltlxWdsITw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.1", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz", + "integrity": "sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/language-data": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/language-data/-/language-data-6.5.2.tgz", + "integrity": "sha512-CPkWBKrNS8stYbEU5kwBwTf3JB1kghlbh4FSAwzGW2TEscdeHHH4FGysREW86Mqnj3Qn09s0/6Ea/TutmoTobg==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-angular": "^0.1.0", + "@codemirror/lang-cpp": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-go": "^6.0.0", + "@codemirror/lang-html": "^6.0.0", + "@codemirror/lang-java": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/lang-jinja": "^6.0.0", + "@codemirror/lang-json": "^6.0.0", + "@codemirror/lang-less": "^6.0.0", + "@codemirror/lang-liquid": "^6.0.0", + "@codemirror/lang-markdown": "^6.0.0", + "@codemirror/lang-php": "^6.0.0", + "@codemirror/lang-python": "^6.0.0", + "@codemirror/lang-rust": "^6.0.0", + "@codemirror/lang-sass": "^6.0.0", + "@codemirror/lang-sql": "^6.0.0", + "@codemirror/lang-vue": "^0.1.1", + "@codemirror/lang-wast": "^6.0.0", + "@codemirror/lang-xml": "^6.0.0", + "@codemirror/lang-yaml": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/legacy-modes": "^6.4.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.2", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.2.tgz", + "integrity": "sha512-sv3DylBiIyi+xKwRCJAAsBZZZWo82shJ/RTMymLabAdtbkV5cSKwWDeCgtUq3v8flTaXS2y1kKkICuRYtUswyQ==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.35.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/merge": { + "version": "6.11.2", + "resolved": "https://registry.npmjs.org/@codemirror/merge/-/merge-6.11.2.tgz", + "integrity": "sha512-NO5EJd2rLRbwVWLgMdhIntDIhfDtMOKYEZgqV5WnkNUS2oXOCVWLPjG/kgl/Jth2fGiOuG947bteqxP9nBXmMg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/highlight": "^1.0.0", + "style-mod": "^4.1.0" + } + }, + "node_modules/@codemirror/search": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz", + "integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.3.tgz", + "integrity": "sha512-MerMzJzlXogk2fxWFU1nKp36bY5orBG59HnPiz0G9nLRebWa0zXuv2siH6PLIHBvv5TH8CkQRqjBs0MlxCZu+A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.39.9", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.9.tgz", + "integrity": "sha512-miGSIfBOKC1s2oHoa80dp+BjtsL8sXsrgGlQnQuOcfvaedcQUtqddTmKbJSDkLl4mkgPvZyXuKic2HDNYcJLYA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@csstools/color-helpers": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", @@ -2817,6 +3235,183 @@ "dev": true, "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.0.tgz", + "integrity": "sha512-PNGcolp9hr4PJdXR4ix7XtixDrClScvtSCYW3rQG106oVMOOI+jFb+0+J3mbeL/53g1Zd6s0kJzaw6Ri68GmAA==", + "license": "MIT" + }, + "node_modules/@lezer/cpp": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.5.tgz", + "integrity": "sha512-DIhSXmYtJKLehrjzDFN+2cPt547ySQ41nA8yqcDf/GxMc+YM736xqltFkvADL2M0VebU5I+3+4ks2Vv+Kyq3Aw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz", + "integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.1.tgz", + "integrity": "sha512-xToRsYxwsgJNHTgNdStpcvmbVuKxTapV0dM0wey1geMMRc9aggoVyKgzYp41D2/vVOx+Ii4hmE206kvxIXBVXQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/html": { + "version": "1.3.13", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz", + "integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/java": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", + "integrity": "sha512-yHquUfujwg6Yu4Fd1GNHCvidIvJwi/1Xu2DaKl/pfWIA2c1oXkVvawH3NyXhCaFx4OdlYBVX5wvz2f7Aoa/4Xw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/javascript": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.4.tgz", + "integrity": "sha512-vvYx3MhWqeZtGPwDStM2dwgljd5smolYD2lR2UyFcHfxbBQebqx8yjmFmxtJ/E6nN6u1D9srOiVWm3Rb4tmcUA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.7", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.7.tgz", + "integrity": "sha512-wNIFWdSUfX9Jc6ePMzxSPVgTVB4EOfDIwLQLWASyiUdHKaMsiilj9bYiGkGQCKVodd0x6bgQCV207PILGFCF9Q==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/markdown": { + "version": "1.6.3", + "resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.6.3.tgz", + "integrity": "sha512-jpGm5Ps+XErS+xA4urw7ogEGkeZOahVQF21Z6oECF0sj+2liwZopd2+I8uH5I/vZsRuuze3OxBREIANLf6KKUw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@lezer/php": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.5.tgz", + "integrity": "sha512-W7asp9DhM6q0W6DYNwIkLSKOvxlXRrif+UXBMxzsJUuqmhE7oVU+gS3THO4S/Puh7Xzgm858UNaFi6dxTP8dJA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.18.tgz", + "integrity": "sha512-31FiUrU7z9+d/ElGQLJFXl+dKOdx0jALlP3KEOsGTex8mvj+SoE1FgItcHWK/axkxCHGUSpqIHt6JAWfWu9Rhg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/sass": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@lezer/sass/-/sass-1.1.0.tgz", + "integrity": "sha512-3mMGdCTUZ/84ArHOuXWQr37pnf7f+Nw9ycPUeKX+wu19b7pSMcZGLbaXwvD2APMBDOGxPmpK/O6S1v1EvLoqgQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/xml": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@lezer/xml/-/xml-1.0.6.tgz", + "integrity": "sha512-CdDwirL0OEaStFue/66ZmFSeppuL6Dwjlk8qk153mSQwiSH/Dlri4GNymrNWnUmPl2Um7QfV1FO9KFUyX3Twww==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.3.tgz", + "integrity": "sha512-GuBLekbw9jDBDhGur82nuwkxKQ+a3W5H0GfaAthDXcAu+XdpS43VlnxA9E9hllkpSP5ellRDKjLLj7Lu9Wr6xA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, "node_modules/@mapbox/node-pre-gyp": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-2.0.0.tgz", @@ -2852,28 +3447,11 @@ "node": ">=10" } }, - "node_modules/@monaco-editor/loader": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/loader/-/loader-1.5.0.tgz", - "integrity": "sha512-hKoGSM+7aAc7eRTRjpqAZucPmoNOC4UUbknb/VNoTkEIkCPhqV8LfbsgM1webRM7S/z21eHEx9Fkwx8Z/C/+Xw==", - "license": "MIT", - "dependencies": { - "state-local": "^1.0.6" - } - }, - "node_modules/@monaco-editor/react": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@monaco-editor/react/-/react-4.7.0.tgz", - "integrity": "sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==", - "license": "MIT", - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "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" - } + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", @@ -4333,6 +4911,19 @@ "integrity": "sha512-/RVXdLvJxLg4QKvMoM5WlwNR9ViO9z8B/qPcc+C0Sa/teJY7QG7kJ441DwzOjMYEY7GmU4dj5EcGHIkKZiQZCA==", "license": "MIT" }, + "node_modules/@replit/codemirror-vim": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/@replit/codemirror-vim/-/codemirror-vim-6.3.0.tgz", + "integrity": "sha512-aTx931ULAMuJx6xLf7KQDOL7CxD+Sa05FktTDrtLaSy53uj01ll3Zf17JdKsriER248oS55GBzg0CfCTjEneAQ==", + "license": "MIT", + "peerDependencies": { + "@codemirror/commands": "6.x.x", + "@codemirror/language": "6.x.x", + "@codemirror/search": "6.x.x", + "@codemirror/state": "6.x.x", + "@codemirror/view": "6.x.x" + } + }, "node_modules/@resvg/resvg-js": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/@resvg/resvg-js/-/resvg-js-2.6.2.tgz", @@ -8562,6 +9153,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/croner": { "version": "9.1.0", "resolved": "https://registry.npmjs.org/croner/-/croner-9.1.0.tgz", @@ -12810,37 +13407,6 @@ "pathe": "^2.0.1" } }, - "node_modules/monaco-editor": { - "version": "0.52.2", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.52.2.tgz", - "integrity": "sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==", - "license": "MIT" - }, - "node_modules/monaco-markdown": { - "version": "0.0.12", - "resolved": "https://registry.npmjs.org/monaco-markdown/-/monaco-markdown-0.0.12.tgz", - "integrity": "sha512-KkGheL2pUazZJY2DfBqpHuMQOjILkbYGNtF5MzpT3ZWupKjunkpL7ByKTllBQGKKcVNt5nuRHl7YsKFskWlc6Q==", - "license": "MIT", - "dependencies": { - "monaco-editor": "0.30.1", - "string-similarity": "^3.0.0" - } - }, - "node_modules/monaco-markdown/node_modules/monaco-editor": { - "version": "0.30.1", - "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.30.1.tgz", - "integrity": "sha512-B/y4+b2O5G2gjuxIFtCE2EkM17R2NM7/3F8x0qcPsqy4V83bitJTIO4TIeZpYlzu/xy6INiY/+84BEm6+7Cmzg==", - "license": "MIT" - }, - "node_modules/monaco-vim": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/monaco-vim/-/monaco-vim-0.4.2.tgz", - "integrity": "sha512-rdbQC3O2rmpwX2Orzig/6gZjZfH7q7TIeB+uEl49sa+QyNm3jCKJOw5mwxBdFzTqbrPD+URfg6A2lEkuL5kymw==", - "license": "MIT", - "peerDependencies": { - "monaco-editor": "*" - } - }, "node_modules/morphdom": { "version": "2.7.7", "resolved": "https://registry.npmjs.org/morphdom/-/morphdom-2.7.7.tgz", @@ -15231,13 +15797,6 @@ "safe-buffer": "~5.2.0" } }, - "node_modules/string-similarity": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/string-similarity/-/string-similarity-3.0.0.tgz", - "integrity": "sha512-7kS7LyTp56OqOI2BDWQNVnLX/rCxIQn+/5M0op1WV6P8Xx6TZNdajpuqQdiJ7Xx+p1C5CsWMvdiBp9ApMhxzEQ==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "license": "ISC" - }, "node_modules/string-width": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", @@ -15509,6 +16068,12 @@ "dev": true, "license": "MIT" }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -17375,6 +17940,12 @@ } } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", @@ -18235,6 +18806,24 @@ "node": ">=0.4" } }, + "node_modules/y-codemirror.next": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/y-codemirror.next/-/y-codemirror.next-0.3.5.tgz", + "integrity": "sha512-VluNu3e5HfEXybnypnsGwKAj+fKLd4iAnR7JuX1Sfyydmn1jCBS5wwEL/uS04Ch2ib0DnMAOF6ZRR/8kK3wyGw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.42" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "yjs": "^13.5.6" + } + }, "node_modules/y-indexeddb": { "version": "9.0.12", "resolved": "https://registry.npmjs.org/y-indexeddb/-/y-indexeddb-9.0.12.tgz", @@ -18273,23 +18862,6 @@ "yjs": "^13.0.0" } }, - "node_modules/y-monaco": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/y-monaco/-/y-monaco-0.1.6.tgz", - "integrity": "sha512-sYRywMmcylt+Nupl+11AvizD2am06ST8lkVbUXuaEmrtV6Tf+TD4rsEm6u9YGGowYue+Vfg1IJ97SUP2J+PVXg==", - "license": "MIT", - "dependencies": { - "lib0": "^0.2.43" - }, - "engines": { - "node": ">=12.0.0", - "npm": ">=6.0.0" - }, - "peerDependencies": { - "monaco-editor": ">=0.20.0", - "yjs": "^13.3.1" - } - }, "node_modules/y-protocols": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.6.tgz", diff --git a/app/package.json b/app/package.json index 755cbc19..37d932be 100644 --- a/app/package.json +++ b/app/package.json @@ -15,7 +15,16 @@ "gen:api": "npm run gen:openapi && npm run gen:client" }, "dependencies": { - "@monaco-editor/react": "^4.7.0", + "@codemirror/autocomplete": "^6.20.0", + "@codemirror/commands": "^6.10.1", + "@codemirror/lang-markdown": "^6.5.0", + "@codemirror/language": "^6.12.1", + "@codemirror/language-data": "^6.5.2", + "@codemirror/merge": "^6.11.2", + "@codemirror/search": "^6.5.11", + "@codemirror/state": "^6.5.3", + "@codemirror/view": "^6.39.9", + "@lezer/markdown": "^1.6.3", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12", @@ -29,6 +38,7 @@ "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tooltip": "^1.2.8", + "@replit/codemirror-vim": "^6.3.0", "@resvg/resvg-js": "^2.6.2", "@tailwindcss/vite": "^4.0.6", "@tanstack/react-devtools": "^0.7.0", @@ -44,9 +54,6 @@ "cmdk": "^1.1.1", "is-hotkey": "^0.2.0", "lucide-react": "^0.544.0", - "monaco-editor": "^0.52.2", - "monaco-markdown": "^0.0.12", - "monaco-vim": "^0.4.2", "morphdom": "^2.7.7", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -59,8 +66,8 @@ "tailwindcss": "^4.0.6", "tw-animate-css": "^1.3.6", "vite-tsconfig-paths": "^5.1.4", + "y-codemirror.next": "^0.3.5", "y-indexeddb": "^9.0.12", - "y-monaco": "^0.1.6", "y-websocket": "^1.5.4", "yjs": "^13.6.27" }, diff --git a/app/src/features/edit-document/hooks/useAwarenessStyles.ts b/app/src/features/edit-document/hooks/useAwarenessStyles.ts deleted file mode 100644 index 2269a6b7..00000000 --- a/app/src/features/edit-document/hooks/useAwarenessStyles.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { useEffect } from 'react' -import type { Awareness } from 'y-protocols/awareness' - -type Options = { - userId?: string - userName?: string -} - -/** - * Syncs local user metadata to Yjs awareness and injects remote cursor styles. - */ -export function useAwarenessStyles(awareness: Awareness | null | undefined, { userId, userName }: Options) { - useEffect(() => { - if (!awareness || (awareness as any)?._destroyed) return - - const info = { - name: userName || `User-${awareness.clientID}`, - color: generateUserColor(userId), - colorLight: generateUserColor(userId, true), - id: userId || String(awareness.clientID), - } - awareness.setLocalStateField('user', info) - - const style = document.createElement('style') - style.id = 'y-remote-cursor-styles' - document.head.appendChild(style) - - const update = () => { - const states = awareness.getStates() - let css = '' - states.forEach((state: any, clientId: number) => { - if (state?.user && clientId !== awareness.clientID) { - const c = state.user.color || '#000' - const cl = state.user.colorLight || c - css += ` - .yRemoteSelection-${clientId} { background-color: ${cl}; opacity: .5; } - .yRemoteSelectionHead-${clientId} { border-color: ${c}; border-width: 2px; } - .yRemoteSelectionHead-${clientId}::after { - content: ''; - position: absolute; - left: -1px; - top: 0; - bottom: 0; - border-left: 2px solid ${c}; - } - .yRemoteCursorLabel-${clientId} { - background-color: ${c}; - color: #fff; - opacity: 1; - padding: 2px 4px; - border-radius: 2px; - font-size: 11px; - position: absolute; - z-index: 100; - } - ` - } - }) - style.textContent = css - } - - update() - const handler = () => update() - awareness.on('update', handler) - - return () => { - try { awareness.off('update', handler) } catch {} - style.remove() - } - }, [awareness, userId, userName]) -} - -function generateUserColor(userId?: string, light = false): string { - let hash = 0 - const str = userId || Math.random().toString() - for (let i = 0; i < str.length; i++) { - const char = str.charCodeAt(i) - hash = ((hash << 5) - hash) + char - hash = hash & hash - } - const hue = Math.abs(hash) % 360 - const saturation = light ? 30 : 70 - const lightness = light ? 80 : 50 - return `hsl(${hue}, ${saturation}%, ${lightness}%)` -} - -export default useAwarenessStyles diff --git a/app/src/features/edit-document/hooks/useEditorBinding.ts b/app/src/features/edit-document/hooks/useEditorBinding.ts new file mode 100644 index 00000000..6b006d2e --- /dev/null +++ b/app/src/features/edit-document/hooks/useEditorBinding.ts @@ -0,0 +1,122 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Extension } from '@codemirror/state' +import { EditorView, ViewUpdate } from '@codemirror/view' +import { yCollab } from 'y-codemirror.next' +import type { Awareness } from 'y-protocols/awareness' +import type * as Y from 'yjs' + +export type UseEditorBindingParams = { + doc: Y.Doc + awareness: Awareness + onTextChange?: (text: string) => void + onCaretAtEnd?: (isAtEnd: boolean) => void +} + +export function useEditorBinding(params: UseEditorBindingParams) { + const { doc, awareness, onTextChange, onCaretAtEnd } = params + + const editorRef = useRef(null) + const [text, setText] = useState('') + const onTextChangeRef = useRef(onTextChange) + const onCaretAtEndRef = useRef(onCaretAtEnd) + + // Keep refs up to date + useEffect(() => { + onTextChangeRef.current = onTextChange + onCaretAtEndRef.current = onCaretAtEnd + }, [onTextChange, onCaretAtEnd]) + + // EOL normalization + useEffect(() => { + const metaMap = doc.getMap('__refmd_internal') + if (metaMap.get('eol') === 'lf') return + const ytext = doc.getText('content') + const current = ytext.toString() + const hasCR = current.includes('\r') + doc.transact(() => { + if (hasCR) { + const normalized = current.replace(/\r\n?/g, '\n') + ytext.delete(0, ytext.length) + ytext.insert(0, normalized) + } + metaMap.set('eol', 'lf') + }) + }, [doc]) + + // Observe Y.Text changes for external updates + useEffect(() => { + const ytext = doc.getText('content') + const update = () => { + const value = ytext.toString() + setText(value) + try { + onTextChangeRef.current?.(value) + } catch {} + } + update() + const observer = () => update() + ytext.observe(observer) + return () => { + try { + ytext.unobserve(observer) + } catch {} + } + }, [doc]) + + // Create update listener extension for tracking changes + const updateListenerExtension = useMemo((): Extension => { + return EditorView.updateListener.of((update: ViewUpdate) => { + if (update.docChanged) { + const value = update.state.doc.toString() + setText(value) + try { + onTextChangeRef.current?.(value) + } catch {} + + // Check if caret is at end of document + try { + const { head } = update.state.selection.main + const docLength = update.state.doc.length + const isAtEnd = head >= docLength - 1 + onCaretAtEndRef.current?.(isAtEnd) + } catch {} + } + }) + }, []) + + // Create yCollab extension + const collabExtension = useMemo((): Extension => { + const ytext = doc.getText('content') + return yCollab(ytext, awareness, { undoManager: false }) + }, [doc, awareness]) + + // Combined extensions for binding + const bindingExtensions = useMemo((): Extension[] => { + return [collabExtension, updateListenerExtension] + }, [collabExtension, updateListenerExtension]) + + // Set editor ref callback + const setEditorView = useCallback((view: EditorView | null) => { + editorRef.current = view + if (view) { + const value = view.state.doc.toString() + setText(value) + } + }, []) + + // Cleanup + useEffect(() => { + return () => { + editorRef.current = null + } + }, []) + + return { + text, + editorRef, + bindingExtensions, + setEditorView, + } +} + +export type UseEditorBindingReturn = ReturnType diff --git a/app/src/features/edit-document/hooks/useEditorUploads.ts b/app/src/features/edit-document/hooks/useEditorUploads.ts index 6b695986..9c9a5288 100644 --- a/app/src/features/edit-document/hooks/useEditorUploads.ts +++ b/app/src/features/edit-document/hooks/useEditorUploads.ts @@ -1,4 +1,4 @@ -import type * as monacoNs from 'monaco-editor' +import { EditorView } from '@codemirror/view' import { useCallback, useEffect, useRef, useState } from 'react' import { toast } from 'sonner' @@ -52,30 +52,28 @@ export function useEditorUploads(documentId: string, readOnly?: boolean, onReadO try { const resp = await uploadAttachment(documentId, f) const name: string = (resp as any).filename || f.name - const ed = editor - if (ed) { - const selection = ed.getSelection() as monacoNs.Selection | null - let targetRange: monacoNs.IRange | null = selection || null - if (!targetRange) { + const view = editor as EditorView | null + if (view) { + const { from, to } = view.state.selection.main + let targetFrom = from + let targetTo = to + + // If no selection, insert at end of document + if (from === to && from === 0) { try { - const model = ed.getModel() - if (model) { - const lastLine = model.getLineCount() - const lastCol = model.getLineMaxColumn(lastLine) - targetRange = { - startLineNumber: lastLine, - startColumn: lastCol, - endLineNumber: lastLine, - endColumn: lastCol, - } - } + const docLength = view.state.doc.length + targetFrom = docLength + targetTo = docLength } catch {} } - if (!targetRange) continue + const rel = `./attachments/${(resp as any).filename || f.name}` const text = f.type.startsWith('image/') ? `![${name}](${rel})` : `[${name}](${rel})` - ed.executeEdits('insertUpload', [{ range: targetRange, text, forceMoveMarkers: true }]) - ed.focus() + view.dispatch({ + changes: { from: targetFrom, to: targetTo, insert: text }, + selection: { anchor: targetFrom + text.length }, + }) + view.focus() } completed += 1 } catch { diff --git a/app/src/features/edit-document/hooks/useMarkdownCommands.ts b/app/src/features/edit-document/hooks/useMarkdownCommands.ts index 932ccf33..7e3b1183 100644 --- a/app/src/features/edit-document/hooks/useMarkdownCommands.ts +++ b/app/src/features/edit-document/hooks/useMarkdownCommands.ts @@ -1,4 +1,4 @@ -import type * as monacoNs from 'monaco-editor' +import { EditorView } from '@codemirror/view' import { useCallback } from 'react' export type MarkdownCommand = @@ -16,49 +16,46 @@ export type MarkdownCommand = | 'link' export function useMarkdownCommands( - editorRef: React.MutableRefObject, + editorRef: React.MutableRefObject, ) { const applyEdit = useCallback( - (fn: (editor: monacoNs.editor.IStandaloneCodeEditor) => void) => { - const editor = editorRef.current - if (!editor) return - fn(editor) + (fn: (view: EditorView) => void) => { + const view = editorRef.current + if (!view) return + fn(view) }, [editorRef], ) const insertAround = useCallback( (start: string, end: string = start) => - applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return - const model = editor.getModel() - if (!model) return - const selected = model.getValueInRange(selection) - editor.executeEdits('insertAround', [ - { range: selection, text: `${start}${selected}${end}`, forceMoveMarkers: true }, - ]) - editor.focus() + applyEdit((view) => { + const { from, to } = view.state.selection.main + const selected = view.state.sliceDoc(from, to) + view.dispatch({ + changes: { from, to, insert: `${start}${selected}${end}` }, + selection: { anchor: from + start.length, head: to + start.length }, + }) + view.focus() }), [applyEdit], ) const prefixLines = useCallback( (prefix: string) => - applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return - const model = editor.getModel() - if (!model) return - const startLine = selection.startLineNumber - const endLine = selection.endLineNumber - const edits: monacoNs.editor.IIdentifiedSingleEditOperation[] = [] - for (let line = startLine; line <= endLine; line += 1) { - const range = new (window as any).monaco.Range(line, 1, line, 1) - edits.push({ range, text: prefix }) + applyEdit((view) => { + const { from, to } = view.state.selection.main + const startLine = view.state.doc.lineAt(from) + const endLine = view.state.doc.lineAt(to) + const changes: { from: number; insert: string }[] = [] + + for (let lineNum = startLine.number; lineNum <= endLine.number; lineNum++) { + const line = view.state.doc.line(lineNum) + changes.push({ from: line.from, insert: prefix }) } - editor.executeEdits('prefixLines', edits) - editor.focus() + + view.dispatch({ changes }) + view.focus() }), [applyEdit], ) @@ -83,54 +80,47 @@ export function useMarkdownCommands( case 'quote': return prefixLines('> ') case 'code': - return applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return - const model = editor.getModel() - if (!model) return - const text = model.getValueInRange(selection) + return applyEdit((view) => { + const { from, to } = view.state.selection.main + const text = view.state.sliceDoc(from, to) if (!text.includes('\n')) { - editor.executeEdits('codeInline', [ - { range: selection, text: `\`${text}\``, forceMoveMarkers: true }, - ]) + view.dispatch({ + changes: { from, to, insert: `\`${text}\`` }, + selection: { anchor: from + 1, head: to + 1 }, + }) } else { - editor.executeEdits('codeBlock', [ - { - range: selection, - text: `\n\n\`\`\`\n${text}\n\`\`\`\n\n`, - forceMoveMarkers: true, - }, - ]) + view.dispatch({ + changes: { from, to, insert: `\n\n\`\`\`\n${text}\n\`\`\`\n\n` }, + }) } + view.focus() }) case 'table': - return applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return + return applyEdit((view) => { + const { from, to } = view.state.selection.main const snippet = '\n\n| Header 1 | Header 2 |\n| --- | --- |\n| Cell 1 | Cell 2 |\n\n' - editor.executeEdits('table', [ - { range: selection, text: snippet, forceMoveMarkers: true }, - ]) + view.dispatch({ + changes: { from, to, insert: snippet }, + }) + view.focus() }) case 'horizontal-rule': - return applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return - editor.executeEdits('hr', [ - { range: selection, text: '\n\n---\n\n', forceMoveMarkers: true }, - ]) + return applyEdit((view) => { + const { from, to } = view.state.selection.main + view.dispatch({ + changes: { from, to, insert: '\n\n---\n\n' }, + }) + view.focus() }) case 'link': - return applyEdit((editor) => { - const selection = editor.getSelection() - if (!selection) return - const model = editor.getModel() - if (!model) return - const text = model.getValueInRange(selection) || 'text' + return applyEdit((view) => { + const { from, to } = view.state.selection.main + const text = view.state.sliceDoc(from, to) || 'text' const url = prompt('URL?') || 'https://' - editor.executeEdits('link', [ - { range: selection, text: `[${text}](${url})`, forceMoveMarkers: true }, - ]) + view.dispatch({ + changes: { from, to, insert: `[${text}](${url})` }, + }) + view.focus() }) default: return undefined diff --git a/app/src/features/edit-document/hooks/useMonacoBinding.ts b/app/src/features/edit-document/hooks/useMonacoBinding.ts deleted file mode 100644 index 71973245..00000000 --- a/app/src/features/edit-document/hooks/useMonacoBinding.ts +++ /dev/null @@ -1,106 +0,0 @@ -import type { OnMount } from '@monaco-editor/react' -import type * as monacoNs from 'monaco-editor' -import { useCallback, useEffect, useRef, useState } from 'react' -import { MonacoBinding } from 'y-monaco' -import type { Awareness } from 'y-protocols/awareness' -import type * as Y from 'yjs' - -export type UseMonacoBindingParams = { - doc: Y.Doc - awareness: Awareness - language?: string - onTextChange?: (text: string) => void -} - -export function useMonacoBinding(params: UseMonacoBindingParams) { - const { doc, awareness, language = 'markdown', onTextChange } = params - - const editorRef = useRef(null) - const modelRef = useRef(null) - const bindingRef = useRef(null) - const [text, setText] = useState('') - useEffect(() => { - const metaMap = doc.getMap('__refmd_internal') - if (metaMap.get('eol') === 'lf') return - const ytext = doc.getText('content') - const current = ytext.toString() - const hasCR = current.includes('\r') - doc.transact(() => { - if (hasCR) { - const normalized = current.replace(/\r\n?/g, '\n') - ytext.delete(0, ytext.length) - ytext.insert(0, normalized) - } - metaMap.set('eol', 'lf') - }) - }, [doc]) - - const onMount: OnMount = useCallback((editor, monaco) => { - editorRef.current = editor - const model = monaco.editor.createModel('', language) - model.setEOL(monaco.editor.EndOfLineSequence.LF) - editor.setModel(model) - modelRef.current = model - - const ytext = doc.getText('content') - const editors = new Set([editor]) - bindingRef.current = new MonacoBinding(ytext, model, editors, awareness) - - const sub = model.onDidChangeContent(() => { - const v = model.getValue() - setText(v) - try { - if (typeof onTextChange === 'function') onTextChange(v) - // Support wiring after hook init (Editor side) - const anyMount = onMount as any - if (typeof anyMount._onTextChange === 'function') anyMount._onTextChange(v) - // Notify caret-at-end status for scroll lock logic - try { - const ed = editorRef.current - const pos = ed?.getPosition?.() - const lineCount = model.getLineCount() - const isAtEnd = !!pos && pos.lineNumber >= lineCount - if (typeof anyMount._onCaretAtEnd === 'function') anyMount._onCaretAtEnd(isAtEnd) - } catch {} - } catch {} - }) - setText(model.getValue()) - - ;(editor as any).__disposeChange = () => { - try { sub.dispose() } catch {} - } - }, [doc, awareness, language, onTextChange]) - - useEffect(() => { - const ytext = doc.getText('content') - const update = () => { - const value = ytext.toString() - setText(value) - try { onTextChange?.(value) } catch {} - } - update() - const observer = () => update() - ytext.observe(observer) - return () => { try { ytext.unobserve(observer) } catch {} } - }, [doc, onTextChange]) - - useEffect(() => { - return () => { - try { bindingRef.current?.destroy?.() } catch {} - try { modelRef.current?.dispose?.() } catch {} - bindingRef.current = null - modelRef.current = null - editorRef.current = null - } - }, []) - - return { - onMount, - text, - editorRef, - modelRef, - bindingRef, - } -} - -export type UseMonacoBindingReturn = ReturnType diff --git a/app/src/features/edit-document/hooks/useScrollSync.ts b/app/src/features/edit-document/hooks/useScrollSync.ts index bc604775..764498b9 100644 --- a/app/src/features/edit-document/hooks/useScrollSync.ts +++ b/app/src/features/edit-document/hooks/useScrollSync.ts @@ -1,11 +1,7 @@ -import type * as monacoNs from 'monaco-editor' +import { EditorView } from '@codemirror/view' import { useCallback, useRef, useState } from 'react' -function isMonacoDisposedError(error: unknown) { - return error instanceof Error && /InstantiationService has been disposed/i.test(error.message) -} - -export function useScrollSync(editorRef: React.MutableRefObject) { +export function useScrollSync(editorRef: React.MutableRefObject) { const isSyncingRef = useRef(false) const [previewScrollPct, setPreviewScrollPct] = useState(undefined) const [previewAnchorLine, setPreviewAnchorLine] = useState(undefined) @@ -17,85 +13,88 @@ export function useScrollSync(editorRef: React.MutableRefObject(null) - const handleEditorScroll = useCallback((e: any) => { - const ed = editorRef.current - if (!ed) return + const handleEditorScroll = useCallback(() => { + const view = editorRef.current + if (!view) return if (isSyncingRef.current) return if (rafRef.current != null) cancelAnimationFrame(rafRef.current) + rafRef.current = requestAnimationFrame(() => { try { + const scrollDOM = view.scrollDOM + const scrollTop = scrollDOM.scrollTop + const scrollHeight = scrollDOM.scrollHeight + const clientHeight = scrollDOM.clientHeight + const denom = Math.max(1, scrollHeight - clientHeight) + const top = scrollTop + + const prevDenom = prevDenomRef.current || denom + const prevTop = prevTopRef.current || 0 + + // Heuristic: if content height grew but scrollTop barely changed, + // treat this as content insertion (not user scroll) and anchor + // preview percentage to previous denominator to avoid upward drift. + const denomIncreased = denom > prevDenom + 0.5 + const topUnchanged = Math.abs(top - prevTop) <= 2 + const baselineDenom = denomIncreased && topUnchanged ? prevDenom : denom + + // Determine if editor was pinned to bottom as of previous metrics. + const distFromBottomPrev = Math.max(0, prevDenom - prevTop) + const pinnedPrev = distFromBottomPrev <= 4 + pinnedEditorBottomRef.current = pinnedPrev + + const now = Date.now() + const locked = lockUntilRef.current > now + + // Visible top line for source-anchored sync + let topLine: number | undefined try { - const height = ed.getScrollHeight?.() ?? 0 - const viewHeight = ed.getLayoutInfo?.().height ?? 0 - const denom = Math.max(1, height - viewHeight) - const top = e?.scrollTop ?? ed.getScrollTop() - const prevDenom = prevDenomRef.current || denom - const prevTop = prevTopRef.current || 0 - - // Heuristic: if content height grew but scrollTop barely changed, - // treat this as content insertion (not user scroll) and anchor - // preview percentage to previous denominator to avoid upward drift. - const denomIncreased = denom > prevDenom + 0.5 - const topUnchanged = Math.abs(top - prevTop) <= 2 - const baselineDenom = (denomIncreased && topUnchanged) ? prevDenom : denom - - // Determine if editor was pinned to bottom as of previous metrics. - const distFromBottomPrev = Math.max(0, prevDenom - prevTop) - const pinnedPrev = distFromBottomPrev <= 4 - pinnedEditorBottomRef.current = pinnedPrev - const now = Date.now() - const locked = lockUntilRef.current > now - // Visible top line for source-anchored sync - let topLine: number | undefined - try { - const vrs = (ed as any).getVisibleRanges?.() || [] - if (vrs && vrs.length > 0) topLine = vrs[0].startLineNumber - else topLine = (ed as any).getPosition?.()?.lineNumber - } catch {} - - const pct = (pinnedPrev || locked) - ? 1 - : Math.min(1, Math.max(0, top / baselineDenom)) - - // Prefer anchor-line when not pinned/locked; else rely on bottom lock - if (pinnedPrev || locked) setPreviewAnchorLine(undefined) - else if (typeof topLine === 'number' && Number.isFinite(topLine)) setPreviewAnchorLine(topLine) - else setPreviewAnchorLine(undefined) - prevDenomRef.current = denom - prevTopRef.current = top - isSyncingRef.current = true - setPreviewScrollPct(pct) - } catch (error) { - if (isMonacoDisposedError(error)) return - throw error - } + const block = view.lineBlockAtHeight(scrollTop) + topLine = view.state.doc.lineAt(block.from).number + } catch {} + + const pct = + pinnedPrev || locked ? 1 : Math.min(1, Math.max(0, top / baselineDenom)) + + // Prefer anchor-line when not pinned/locked; else rely on bottom lock + if (pinnedPrev || locked) setPreviewAnchorLine(undefined) + else if (typeof topLine === 'number' && Number.isFinite(topLine)) + setPreviewAnchorLine(topLine) + else setPreviewAnchorLine(undefined) + + prevDenomRef.current = denom + prevTopRef.current = top + isSyncingRef.current = true + setPreviewScrollPct(pct) } finally { - setTimeout(() => { isSyncingRef.current = false }, 0) + setTimeout(() => { + isSyncingRef.current = false + }, 0) rafRef.current = null } }) }, [editorRef]) - const handlePreviewScroll = useCallback((pct: number) => { - const ed = editorRef.current - if (!ed) return - if (isSyncingRef.current) return - try { - isSyncingRef.current = true - const height = ed.getScrollHeight?.() ?? 0 - const viewHeight = ed.getLayoutInfo?.().height ?? 0 - const denom = Math.max(1, height - viewHeight) - const target = pct >= 0.999 ? denom : Math.round(denom * pct) + const handlePreviewScroll = useCallback( + (pct: number) => { + const view = editorRef.current + if (!view) return + if (isSyncingRef.current) return + try { - ed.setScrollTop(target) - } catch (error) { - if (isMonacoDisposedError(error)) return - throw error + isSyncingRef.current = true + const scrollDOM = view.scrollDOM + const scrollHeight = scrollDOM.scrollHeight + const clientHeight = scrollDOM.clientHeight + const denom = Math.max(1, scrollHeight - clientHeight) + const target = pct >= 0.999 ? denom : Math.round(denom * pct) + scrollDOM.scrollTop = target + } finally { + isSyncingRef.current = false } - } finally { - isSyncingRef.current = false - } - }, [editorRef]) + }, + [editorRef], + ) const onEditorContentChange = useCallback(() => { if (pinnedEditorBottomRef.current) { @@ -119,5 +118,13 @@ export function useScrollSync(editorRef: React.MutableRefObject() + const decorations: Array<{ from: number; to: number; decoration: Decoration }> = [] + + awareness.getStates().forEach((state: AwarenessState, clientId: number) => { + if (clientId === localClientId) return + if (!state.cursor) return + + const { anchor, head } = state.cursor + const { light } = getColorForClient(clientId) + const userLight = state.user?.colorLight || light + + // Selection decoration + const from = Math.min(anchor, head) + const to = Math.max(anchor, head) + + if (from !== to) { + decorations.push({ + from, + to, + decoration: Decoration.mark({ + class: 'cm-yjs-selection', + attributes: { + style: `background-color: ${userLight};`, + }, + }), + }) + } + + // Cursor widget at head position + decorations.push({ + from: head, + to: head, + decoration: Decoration.widget({ + widget: new CursorWidget(state.user || {}, clientId), + side: 1, + }), + }) + }) + + // Sort by position for RangeSetBuilder + decorations.sort((a, b) => a.from - b.from || a.to - b.to) + + for (const { from, to, decoration } of decorations) { + builder.add(from, to, decoration) + } + + return builder.finish() +} + +export function awarenessExtension(awareness: Awareness): Extension { + const localClientId = awareness.clientID + + return ViewPlugin.fromClass( + class { + decorations: DecorationSet + + constructor(_view: EditorView) { + this.decorations = createCursorDecorations(awareness, localClientId) + } + + update(_update: ViewUpdate) { + this.decorations = createCursorDecorations(awareness, localClientId) + } + }, + { + decorations: (v) => v.decorations, + } + ) +} + +export function awarenessStyles(): Extension { + return EditorView.baseTheme({ + '.cm-yjs-cursor': { + position: 'relative', + }, + '.cm-yjs-cursor-label': { + fontFamily: 'system-ui, -apple-system, sans-serif', + }, + '.cm-yjs-selection': { + mixBlendMode: 'multiply', + }, + '.dark .cm-yjs-selection': { + mixBlendMode: 'screen', + }, + }) +} diff --git a/app/src/features/edit-document/lib/editor/index.ts b/app/src/features/edit-document/lib/editor/index.ts new file mode 100644 index 00000000..4fbcad0c --- /dev/null +++ b/app/src/features/edit-document/lib/editor/index.ts @@ -0,0 +1,83 @@ +import { Extension, EditorState, Compartment } from '@codemirror/state' +import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor, rectangularSelection, crosshairCursor, highlightSpecialChars } from '@codemirror/view' +import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands' +import { markdown, markdownLanguage } from '@codemirror/lang-markdown' +import { languages } from '@codemirror/language-data' +import { bracketMatching, indentOnInput, foldGutter, foldKeymap } from '@codemirror/language' +import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirror/autocomplete' +import { searchKeymap, highlightSelectionMatches } from '@codemirror/search' + +import { refmdLightTheme, refmdDarkTheme } from './theme' +import { getVimPlaceholder } from './vim' +import { wikiLinkExtension } from './wiki-link' + +export interface EditorConfig { + isDarkMode: boolean + readOnly: boolean + vimMode: boolean + isMobile: boolean + lineWrapping?: boolean +} + +export const themeCompartment = new Compartment() +export const readOnlyCompartment = new Compartment() + +export function createBaseExtensions(config: EditorConfig): Extension[] { + const theme = config.isDarkMode ? refmdDarkTheme : refmdLightTheme + + const extensions: Extension[] = [ + highlightSpecialChars(), + history(), + drawSelection(), + dropCursor(), + EditorState.allowMultipleSelections.of(true), + indentOnInput(), + bracketMatching(), + closeBrackets(), + rectangularSelection(), + crosshairCursor(), + highlightActiveLine(), + highlightActiveLineGutter(), + highlightSelectionMatches(), + keymap.of([ + ...closeBracketsKeymap, + ...defaultKeymap, + ...searchKeymap, + ...historyKeymap, + ...foldKeymap, + ...completionKeymap, + indentWithTab, + ]), + markdown({ + base: markdownLanguage, + codeLanguages: languages, + }), + wikiLinkExtension(), + themeCompartment.of(theme), + readOnlyCompartment.of(EditorState.readOnly.of(config.readOnly)), + getVimPlaceholder(), + ] + + if (!config.isMobile) { + extensions.push(lineNumbers()) + extensions.push(foldGutter()) + } + + if (config.lineWrapping !== false) { + extensions.push(EditorView.lineWrapping) + } + + return extensions +} + +export function createEditorExtensions(config: EditorConfig): Extension[] { + return createBaseExtensions(config) +} + +export function getThemeExtension(isDarkMode: boolean): Extension { + return isDarkMode ? refmdDarkTheme : refmdLightTheme +} + +export { refmdLightTheme, refmdDarkTheme } from './theme' +export { vimCompartment, enableVimMode, disableVimMode, toggleVimMode } from './vim' +export { wikiLinkExtension } from './wiki-link' diff --git a/app/src/features/edit-document/lib/editor/keymaps.ts b/app/src/features/edit-document/lib/editor/keymaps.ts new file mode 100644 index 00000000..3790e53c --- /dev/null +++ b/app/src/features/edit-document/lib/editor/keymaps.ts @@ -0,0 +1,175 @@ +import { keymap, KeyBinding } from '@codemirror/view' +import { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' + +export type MarkdownAction = + | 'bold' + | 'italic' + | 'strikethrough' + | 'code' + | 'link' + | 'heading1' + | 'heading2' + | 'heading3' + | 'bulletList' + | 'numberedList' + | 'taskList' + | 'blockquote' + | 'codeBlock' + | 'horizontalRule' + +export interface MarkdownKeymapConfig { + onAction?: (action: MarkdownAction) => void +} + +function wrapSelection(view: EditorView, before: string, after: string): boolean { + const { from, to } = view.state.selection.main + const selectedText = view.state.sliceDoc(from, to) + + view.dispatch({ + changes: { + from, + to, + insert: `${before}${selectedText}${after}`, + }, + selection: { + anchor: from + before.length, + head: to + before.length, + }, + }) + view.focus() + return true +} + +function insertAtLineStart(view: EditorView, prefix: string): boolean { + const line = view.state.doc.lineAt(view.state.selection.main.head) + const content = view.state.sliceDoc(line.from, line.to) + + view.dispatch({ + changes: { + from: line.from, + to: line.to, + insert: `${prefix}${content}`, + }, + selection: { anchor: line.from + prefix.length + content.length }, + }) + view.focus() + return true +} + +export function createMarkdownKeymap(config?: MarkdownKeymapConfig): Extension { + const bindings: KeyBinding[] = [ + { + key: 'Mod-b', + run: (view) => { + config?.onAction?.('bold') + return wrapSelection(view, '**', '**') + }, + }, + { + key: 'Mod-i', + run: (view) => { + config?.onAction?.('italic') + return wrapSelection(view, '_', '_') + }, + }, + { + key: 'Mod-Shift-s', + run: (view) => { + config?.onAction?.('strikethrough') + return wrapSelection(view, '~~', '~~') + }, + }, + { + key: 'Mod-e', + run: (view) => { + config?.onAction?.('code') + return wrapSelection(view, '`', '`') + }, + }, + { + key: 'Mod-k', + run: (view) => { + config?.onAction?.('link') + const { from, to } = view.state.selection.main + const selectedText = view.state.sliceDoc(from, to) + + if (selectedText) { + view.dispatch({ + changes: { + from, + to, + insert: `[${selectedText}](url)`, + }, + selection: { anchor: from + selectedText.length + 3, head: from + selectedText.length + 6 }, + }) + } else { + view.dispatch({ + changes: { + from, + insert: '[](url)', + }, + selection: { anchor: from + 1 }, + }) + } + view.focus() + return true + }, + }, + { + key: 'Mod-1', + run: (view) => { + config?.onAction?.('heading1') + return insertAtLineStart(view, '# ') + }, + }, + { + key: 'Mod-2', + run: (view) => { + config?.onAction?.('heading2') + return insertAtLineStart(view, '## ') + }, + }, + { + key: 'Mod-3', + run: (view) => { + config?.onAction?.('heading3') + return insertAtLineStart(view, '### ') + }, + }, + { + key: 'Mod-Shift-8', + run: (view) => { + config?.onAction?.('bulletList') + return insertAtLineStart(view, '- ') + }, + }, + { + key: 'Mod-Shift-7', + run: (view) => { + config?.onAction?.('numberedList') + return insertAtLineStart(view, '1. ') + }, + }, + { + key: 'Mod-Shift-9', + run: (view) => { + config?.onAction?.('taskList') + return insertAtLineStart(view, '- [ ] ') + }, + }, + { + key: 'Mod-Shift-.', + run: (view) => { + config?.onAction?.('blockquote') + return insertAtLineStart(view, '> ') + }, + }, + ] + + return keymap.of(bindings) +} + +export function createCustomKeymap(bindings: KeyBinding[]): Extension { + return keymap.of(bindings) +} diff --git a/app/src/features/edit-document/lib/editor/theme.ts b/app/src/features/edit-document/lib/editor/theme.ts new file mode 100644 index 00000000..8a5b5ca7 --- /dev/null +++ b/app/src/features/edit-document/lib/editor/theme.ts @@ -0,0 +1,206 @@ +import { EditorView } from '@codemirror/view' +import { Extension } from '@codemirror/state' +import { HighlightStyle, syntaxHighlighting } from '@codemirror/language' +import { tags } from '@lezer/highlight' + +type Palette = { + primary: string + background: string + foreground: string + mutedForeground: string + codeBlockBg: string + codeBlockFg: string +} + +const LIGHT_PALETTE: Palette = { + primary: '#6e63d6', + background: '#ffffff', + foreground: '#252a33', + mutedForeground: '#596272', + codeBlockBg: '#fafafa', + codeBlockFg: '#24292e', +} + +const DARK_PALETTE: Palette = { + primary: '#8f86e8', + background: '#1e1e1e', + foreground: '#e4e7eb', + mutedForeground: '#9aa1b0', + codeBlockBg: '#242424', + codeBlockFg: '#f3f4f6', +} + +const hexToRgb = (hex: string) => { + const h = hex.replace('#', '') + const bigint = parseInt(h, 16) + const r = (bigint >> 16) & 255 + const g = (bigint >> 8) & 255 + const b = bigint & 255 + return { r, g, b } +} + +const hexWithAlpha = (hex: string, alpha: number) => { + const { r, g, b } = hexToRgb(hex) + return `rgba(${r}, ${g}, ${b}, ${alpha})` +} + +const mixHexWithWhite = (hex: string, weight: number) => { + const { r, g, b } = hexToRgb(hex) + const w = Math.min(Math.max(weight, 0), 1) + const mix = (c: number) => Math.round(c + (255 - c) * w) + const pad = (n: number) => n.toString(16).padStart(2, '0') + return `#${pad(mix(r))}${pad(mix(g))}${pad(mix(b))}` +} + +function buildTheme(palette: Palette, isDark: boolean): Extension { + const softPrimary = mixHexWithWhite(palette.primary, isDark ? 0.3 : 0.55) + const linkColor = mixHexWithWhite(palette.primary, isDark ? 0.2 : 0.35) + + const theme = EditorView.theme( + { + '&': { + color: palette.foreground, + backgroundColor: palette.background, + height: '100%', + }, + '.cm-content': { + caretColor: palette.primary, + fontFamily: 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontSize: '14px', + lineHeight: '1.6', + }, + '.cm-cursor, .cm-dropCursor': { + borderLeftColor: palette.primary, + borderLeftWidth: '2px', + }, + '&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.22 : 0.16), + }, + '.cm-panels': { + backgroundColor: palette.background, + color: palette.foreground, + }, + '.cm-panels.cm-panels-top': { + borderBottom: `1px solid ${hexWithAlpha(palette.foreground, 0.1)}`, + }, + '.cm-panels.cm-panels-bottom': { + borderTop: `1px solid ${hexWithAlpha(palette.foreground, 0.1)}`, + }, + '.cm-searchMatch': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.2 : 0.14), + outline: `1px solid ${hexWithAlpha(palette.primary, 0.3)}`, + }, + '.cm-searchMatch.cm-searchMatch-selected': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.35 : 0.25), + }, + '.cm-activeLine': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.08 : 0.06), + }, + '.cm-selectionMatch': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.16 : 0.12), + }, + '&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.16 : 0.1), + outline: `1px solid ${hexWithAlpha(palette.primary, isDark ? 0.55 : 0.42)}`, + }, + '.cm-gutters': { + backgroundColor: palette.background, + color: hexWithAlpha(palette.foreground, 0.45), + border: 'none', + }, + '.cm-activeLineGutter': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.08 : 0.06), + color: palette.primary, + }, + '.cm-foldPlaceholder': { + backgroundColor: 'transparent', + border: 'none', + color: palette.mutedForeground, + }, + '.cm-tooltip': { + border: `1px solid ${hexWithAlpha(palette.foreground, 0.15)}`, + backgroundColor: palette.background, + color: palette.foreground, + borderRadius: '6px', + boxShadow: isDark + ? '0 4px 12px rgba(0, 0, 0, 0.4)' + : '0 4px 12px rgba(0, 0, 0, 0.1)', + }, + '.cm-tooltip .cm-tooltip-arrow:before': { + borderTopColor: 'transparent', + borderBottomColor: 'transparent', + }, + '.cm-tooltip .cm-tooltip-arrow:after': { + borderTopColor: palette.background, + borderBottomColor: palette.background, + }, + '.cm-tooltip-autocomplete': { + '& > ul > li[aria-selected]': { + backgroundColor: hexWithAlpha(palette.primary, isDark ? 0.2 : 0.12), + color: palette.foreground, + }, + }, + '.cm-completionLabel': { + color: palette.foreground, + }, + '.cm-completionDetail': { + color: palette.mutedForeground, + }, + '.cm-completionMatchedText': { + color: palette.primary, + fontWeight: '600', + textDecoration: 'none', + }, + '.cm-line': { + padding: '0 4px', + }, + '.cm-scroller': { + overflow: 'auto', + }, + }, + { dark: isDark } + ) + + const highlightStyle = HighlightStyle.define([ + { tag: tags.heading, fontWeight: 'bold', color: palette.primary }, + { tag: tags.heading1, fontSize: '1.5em' }, + { tag: tags.heading2, fontSize: '1.3em' }, + { tag: tags.heading3, fontSize: '1.15em' }, + { tag: tags.strong, fontWeight: 'bold', color: palette.primary }, + { tag: tags.emphasis, fontStyle: 'italic', color: palette.primary }, + { tag: tags.strikethrough, textDecoration: 'line-through' }, + { tag: tags.link, color: linkColor, textDecoration: 'underline' }, + { tag: tags.url, color: linkColor }, + { tag: tags.monospace, fontFamily: 'monospace', color: palette.codeBlockFg, backgroundColor: palette.codeBlockBg }, + { tag: tags.quote, color: palette.mutedForeground, fontStyle: 'italic' }, + { tag: tags.list, color: palette.foreground }, + { tag: tags.meta, color: palette.mutedForeground }, + { tag: tags.processingInstruction, color: softPrimary }, + { tag: tags.comment, color: palette.mutedForeground }, + { tag: tags.keyword, color: palette.primary, fontWeight: 'bold' }, + { tag: tags.string, color: softPrimary }, + { tag: tags.number, color: softPrimary }, + { tag: tags.operator, color: palette.foreground }, + { tag: tags.punctuation, color: palette.mutedForeground }, + { tag: tags.bracket, color: palette.foreground }, + { tag: tags.variableName, color: palette.foreground }, + { tag: tags.propertyName, color: palette.primary }, + { tag: tags.function(tags.variableName), color: palette.primary }, + { tag: tags.typeName, color: softPrimary }, + { tag: tags.className, color: softPrimary }, + { tag: tags.labelName, color: palette.primary }, + { tag: tags.attributeName, color: palette.primary }, + { tag: tags.attributeValue, color: softPrimary }, + { tag: tags.tagName, color: palette.primary }, + { tag: tags.angleBracket, color: palette.mutedForeground }, + { tag: tags.contentSeparator, color: palette.mutedForeground }, + ]) + + return [theme, syntaxHighlighting(highlightStyle)] +} + +export const refmdLightTheme: Extension = buildTheme(LIGHT_PALETTE, false) +export const refmdDarkTheme: Extension = buildTheme(DARK_PALETTE, true) + +export { LIGHT_PALETTE, DARK_PALETTE } +export type { Palette } diff --git a/app/src/features/edit-document/lib/editor/vim.ts b/app/src/features/edit-document/lib/editor/vim.ts new file mode 100644 index 00000000..092b9533 --- /dev/null +++ b/app/src/features/edit-document/lib/editor/vim.ts @@ -0,0 +1,41 @@ +import { Extension, Compartment } from '@codemirror/state' +import { vim } from '@replit/codemirror-vim' +import { EditorView } from '@codemirror/view' + +export const vimCompartment = new Compartment() + +export interface VimModeState { + enabled: boolean + statusBarElement?: HTMLElement | null +} + +export function createVimExtension(): Extension { + return vim() +} + +export function enableVimMode(view: EditorView): void { + const vimExt = createVimExtension() + view.dispatch({ + effects: vimCompartment.reconfigure(vimExt), + }) +} + +export function disableVimMode(view: EditorView): void { + view.dispatch({ + effects: vimCompartment.reconfigure([]), + }) +} + +export function toggleVimMode(view: EditorView, enabled: boolean): void { + if (enabled) { + enableVimMode(view) + } else { + disableVimMode(view) + } +} + +export function getVimPlaceholder(): Extension { + return vimCompartment.of([]) +} + +export { vim } diff --git a/app/src/features/edit-document/lib/editor/wiki-link.ts b/app/src/features/edit-document/lib/editor/wiki-link.ts new file mode 100644 index 00000000..e7b6ba30 --- /dev/null +++ b/app/src/features/edit-document/lib/editor/wiki-link.ts @@ -0,0 +1,156 @@ +import { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { + autocompletion, + CompletionContext, + CompletionResult, + Completion, +} from '@codemirror/autocomplete' + +import { listDocuments } from '@/entities/document' + +type SearchResult = { + id: string + title: string + document_type: string + path?: string | null + updated_at?: string +} + +async function wikiLinkCompletionSource( + context: CompletionContext +): Promise { + // Match [[, ![[, or @[[ patterns + const wiki = context.matchBefore(/\[\[[^\]]*$/) + const embed = context.matchBefore(/!\[\[[^\]]*$/) + const mention = context.matchBefore(/@\[\[[^\]]*$/) + const match = wiki || embed || mention + if (!match) return null + + // Extract the prefix and query + const text = match.text + let prefix: string + let query: string + + if (text.startsWith('![[')) { + prefix = '![[' + query = text.slice(3) + } else if (text.startsWith('@[[')) { + prefix = '@[[' + query = text.slice(3) + } else { + prefix = '[[' + query = text.slice(2) + } + + // Check if ]] already exists after cursor + const line = context.state.doc.lineAt(context.pos) + const after = context.state.sliceDoc(context.pos, line.to) + const hasClosing = after.startsWith(']]') + + // Fetch documents + let items: SearchResult[] = [] + try { + const resp = await listDocuments({ query: query || null }) + const docs = Array.isArray((resp as any)?.items) + ? ((resp as any).items as Array<{ + id: string + title: string + type: string + path?: string + updated_at?: string + }>) + : [] + items = docs.map((d) => ({ + id: d.id, + title: d.title, + document_type: d.type, + path: (d as any).path, + updated_at: (d as any).updated_at, + })) + } catch {} + + // Deduplicate + const seen = new Set() + const uniq: SearchResult[] = [] + for (const it of items) { + if (it && it.id && !seen.has(it.id)) { + seen.add(it.id) + uniq.push(it) + } + } + + // Track duplicates by title + const titleCounts = new Map() + for (const it of uniq) { + const t = (it.title || '').toLowerCase() + if (!t) continue + titleCounts.set(t, (titleCounts.get(t) || 0) + 1) + } + const duplicates = new Set() + titleCounts.forEach((c, t) => { + if (c > 1) duplicates.add(t) + }) + + // Build completions + const options: Completion[] = [] + + // Add "Create new" option if query is not empty + if (query && query.length > 0) { + options.push({ + label: `Create "${query}"`, + detail: 'Create a new document', + info: 'Create a new document with this title (link will use document ID)', + apply: (view: EditorView, _completion: Completion, _from: number, _to: number) => { + const insertText = hasClosing ? query : `${query}]]` + view.dispatch({ + changes: { from: match.from + prefix.length, to: context.pos, insert: insertText }, + selection: { anchor: match.from + prefix.length + insertText.length }, + }) + }, + boost: 99, + }) + } + + // Add document completions + for (const doc of uniq) { + const isDup = duplicates.has((doc.title || '').toLowerCase()) + const typeLower = (doc.document_type || '').toLowerCase() + const typeDisplay = + typeLower === 'folder' ? 'Folder' : typeLower === 'scrap' ? 'Scrap' : 'Document' + + options.push({ + label: doc.title || 'Untitled', + detail: isDup ? doc.path || '' : typeDisplay, + info: () => { + const el = document.createElement('div') + el.innerHTML = `${doc.title || 'Untitled'}
${ + isDup ? `Path: ${doc.path || ''}
` : '' + }Type: ${typeDisplay}
ID: ${doc.id}${doc.updated_at ? `
Updated: ${doc.updated_at}` : ''}` + el.style.cssText = 'font-size: 12px; line-height: 1.5;' + return el + }, + apply: (view: EditorView, _completion: Completion, _from: number, _to: number) => { + const insertText = hasClosing ? doc.id : `${doc.id}]]` + view.dispatch({ + changes: { from: match.from + prefix.length, to: context.pos, insert: insertText }, + selection: { anchor: match.from + prefix.length + insertText.length + (hasClosing ? 2 : 0) }, + }) + }, + }) + } + + return { + from: match.from + prefix.length, + options, + validFor: /^[^\]]*$/, + } +} + +export function wikiLinkExtension(): Extension { + return autocompletion({ + override: [wikiLinkCompletionSource], + activateOnTyping: true, + closeOnBlur: true, + }) +} diff --git a/app/src/features/edit-document/lib/monaco/theme.ts b/app/src/features/edit-document/lib/monaco/theme.ts deleted file mode 100644 index 3ef832d2..00000000 --- a/app/src/features/edit-document/lib/monaco/theme.ts +++ /dev/null @@ -1,142 +0,0 @@ -import type * as monacoNs from 'monaco-editor' - -export const REFMD_LIGHT_THEME = 'refmd-light' -export const REFMD_DARK_THEME = 'refmd-dark' - -const stripHash = (hex: string) => hex.replace('#', '') - -const pad = (n: number) => n.toString(16).padStart(2, '0') - -const hexToRgb = (hex: string) => { - const h = stripHash(hex) - const bigint = parseInt(h, 16) - const r = (bigint >> 16) & 255 - const g = (bigint >> 8) & 255 - const b = bigint & 255 - return { r, g, b } -} - -const hexWithAlpha = (hex: string, alpha: number) => { - const a = Math.min(Math.max(alpha, 0), 1) - const { r, g, b } = hexToRgb(hex) - const alphaHex = pad(Math.round(a * 255)) - return `#${pad(r)}${pad(g)}${pad(b)}${alphaHex}` -} - -const mixHexWithWhite = (hex: string, weight: number) => { - const { r, g, b } = hexToRgb(hex) - const w = Math.min(Math.max(weight, 0), 1) - const mix = (c: number) => Math.round(c + (255 - c) * w) - return `#${pad(mix(r))}${pad(mix(g))}${pad(mix(b))}` -} - -type Palette = { - primary: string - background: string - foreground: string - mutedForeground: string - codeBlockBg: string - codeBlockFg: string -} - -const LIGHT_PALETTE: Palette = { - primary: '#6e63d6', - background: '#ffffff', - foreground: '#252a33', - mutedForeground: '#596272', - codeBlockBg: '#fafafa', - codeBlockFg: '#24292e', -} - -const DARK_PALETTE: Palette = { - primary: '#8f86e8', - background: '#1e1e1e', - foreground: '#e4e7eb', - mutedForeground: '#9aa1b0', - codeBlockBg: '#242424', - codeBlockFg: '#f3f4f6', -} - -type ThemeDefinition = { - name: string - data: monacoNs.editor.IStandaloneThemeData -} - -const buildTheme = (name: string, palette: Palette, isDark: boolean): ThemeDefinition => { - const softPrimary = mixHexWithWhite(palette.primary, isDark ? 0.3 : 0.55) - const linkColor = mixHexWithWhite(palette.primary, isDark ? 0.2 : 0.35) - const keywordColor = stripHash(palette.primary) - const defaultFg = stripHash(palette.foreground) - - return { - name, - data: { - base: isDark ? 'vs-dark' : 'vs', - inherit: false, - rules: [ - { token: '', foreground: defaultFg }, - { token: 'keyword', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'keyword.table.header', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'keyword.table.left', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'keyword.table.middle', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'keyword.table.right', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'strong', foreground: keywordColor, fontStyle: 'bold' }, - { token: 'emphasis', foreground: keywordColor, fontStyle: 'italic' }, - { token: 'string', foreground: stripHash(softPrimary) }, - { token: 'string.link', foreground: stripHash(linkColor), fontStyle: 'underline' }, - { token: 'string.target', foreground: stripHash(linkColor) }, - { token: 'variable', foreground: stripHash(mixHexWithWhite(palette.foreground, isDark ? 0.08 : 0.12)) }, - { token: 'delimiter', foreground: defaultFg }, - { token: 'delimiter.parenthesis', foreground: defaultFg }, - { token: 'delimiter.bracket', foreground: defaultFg }, - { token: 'delimiter.curly', foreground: defaultFg }, - { token: 'operator', foreground: defaultFg }, - { - token: 'variable.source', - foreground: stripHash(palette.codeBlockFg), - background: stripHash(palette.codeBlockBg), - }, - ], - colors: { - 'editor.background': palette.background, - 'editor.foreground': palette.foreground, - 'editorLineNumber.foreground': hexWithAlpha(palette.foreground, 0.45), - 'editorLineNumber.activeForeground': palette.primary, - 'editor.selectionBackground': hexWithAlpha(palette.primary, isDark ? 0.22 : 0.16), - 'editor.selectionHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.16 : 0.12), - 'editor.wordHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.16 : 0.12), - 'editor.findMatchHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.2 : 0.14), - 'editorBracketMatch.background': hexWithAlpha(palette.primary, isDark ? 0.16 : 0.1), - 'editorBracketMatch.border': hexWithAlpha(palette.primary, isDark ? 0.55 : 0.42), - 'editorBracketHighlight.foreground1': palette.foreground, - 'editorBracketHighlight.foreground2': palette.foreground, - 'editorBracketHighlight.foreground3': palette.foreground, - 'editorBracketHighlight.foreground4': palette.foreground, - 'editorBracketHighlight.foreground5': palette.foreground, - 'editorBracketHighlight.foreground6': palette.foreground, - 'editorBracketHighlight.unexpectedBracket.foreground': palette.foreground, - 'editorCursor.foreground': palette.primary, - 'editorIndentGuide.background': hexWithAlpha(palette.foreground, 0.12), - 'editorIndentGuide.activeBackground': hexWithAlpha(palette.primary, isDark ? 0.3 : 0.24), - 'editor.lineHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.08 : 0.06), - 'editor.selectionHighlightBorder': hexWithAlpha(palette.foreground, 0.16), - // Diff: align with Git History/Changes/Snapshot viewer colors - 'diffEditor.insertedTextBackground': isDark ? hexWithAlpha('#052e16', 0.4) : '#f0fdf4', - 'diffEditor.removedTextBackground': isDark ? hexWithAlpha('#450a0a', 0.4) : '#fef2f2', - }, - }, - } -} - -const themeDefinitions: ThemeDefinition[] = [ - buildTheme(REFMD_LIGHT_THEME, LIGHT_PALETTE, false), - buildTheme(REFMD_DARK_THEME, DARK_PALETTE, true), -] - -type MonacoNamespace = typeof import('monaco-editor') - -export function ensureRefmdThemes(monaco: MonacoNamespace) { - themeDefinitions.forEach(({ name, data }) => { - monaco.editor.defineTheme(name, data) - }) -} diff --git a/app/src/features/edit-document/lib/monaco/vim-loader.ts b/app/src/features/edit-document/lib/monaco/vim-loader.ts deleted file mode 100644 index fb5251a8..00000000 --- a/app/src/features/edit-document/lib/monaco/vim-loader.ts +++ /dev/null @@ -1,227 +0,0 @@ -import type * as monacoNs from 'monaco-editor' -import type { CodeMirrorShim, RegisterController } from 'monaco-vim' - -type MonacoVimModule = typeof import('monaco-vim') - -let cachedModulePromise: Promise | null = null -let vimPatched = false - -const clamp = (value: number, min: number, max: number) => Math.max(min, Math.min(max, value)) -type ScrollPosition = 'top' | 'center' | 'bottom' - -type ScrollToCursorArgs = { - position?: ScrollPosition -} - -type CmAdapter = { - editor?: monacoNs.editor.IStandaloneCodeEditor - clipPos: (pos: { line: number; ch: number }) => { line: number; ch: number } - moveCurrentLineTo?: (viewPosition: ScrollPosition) => void -} - -type VimState = { - lastMotion?: unknown - lastHPos?: number - lastHSPos?: number -} - -type MotionArgs = { - forward: boolean - repeat: number -} - -type MotionHandler = ( - cm: CmAdapter, - head: { line: number; ch: number }, - motionArgs: MotionArgs, - vim: VimState, -) => { line: number; ch: number } - -type MotionsShape = Record - -const getViewModel = (editor: monacoNs.editor.IStandaloneCodeEditor) => (editor as any)?._getViewModel?.() - -const toModelPosition = (pos: { line: number; ch: number }) => ({ - lineNumber: pos.line + 1, - column: pos.ch + 1, -}) - -type PatchedRegisterController = RegisterController & { __clipboardHooked?: boolean } -type ResetFn = ((...args: unknown[]) => void) & { __clipboardWrapped?: boolean } - -const maybeCopyToSystemClipboard = (operator?: string, text?: string) => { - if (operator !== 'yank') return - if (!text) return - if (typeof navigator === 'undefined') return - const clipboard = navigator.clipboard - if (!clipboard?.writeText) return - void clipboard.writeText(text).catch(() => {}) -} - -const patchRegisterClipboardSync = (vimApi: CodeMirrorShim['Vim']) => { - if (!vimApi?.getRegisterController) return - const controller = vimApi.getRegisterController() as PatchedRegisterController | undefined - if (!controller || controller.__clipboardHooked) return - const originalPushText = controller.pushText.bind(controller) - controller.pushText = function patchedPushText(registerName, operator, text, linewise, blockwise) { - maybeCopyToSystemClipboard(operator, text) - return originalPushText(registerName, operator, text, linewise, blockwise) - } - controller.__clipboardHooked = true -} - -const patchResetForClipboard = (vimApi: CodeMirrorShim['Vim']) => { - if (!vimApi) return - const api = vimApi as NonNullable - const reset = api.resetVimGlobalState_ as ResetFn | undefined - if (!reset || reset.__clipboardWrapped) return - const wrapped: ResetFn = function wrappedReset(this: unknown, ...args: unknown[]) { - reset.apply(this, args) - patchRegisterClipboardSync(api) - } - wrapped.__clipboardWrapped = true - api.resetVimGlobalState_ = wrapped as NonNullable['resetVimGlobalState_'] -} - -const setupClipboardSync = (vimApi: CodeMirrorShim['Vim']) => { - if (!vimApi) return - patchRegisterClipboardSync(vimApi) - patchResetForClipboard(vimApi) -} - -const resolveScrollPosition = (position?: ScrollPosition): ScrollPosition => position ?? 'center' - -const getViewHeight = (editor: monacoNs.editor.IStandaloneCodeEditor) => { - const layoutHeight = editor.getLayoutInfo?.()?.height - if (typeof layoutHeight === 'number' && layoutHeight > 0) { - return layoutHeight - } - const domHeight = editor.getDomNode?.()?.clientHeight - return typeof domHeight === 'number' ? domHeight : 0 -} - -const scrollLineToPosition = ( - editor: monacoNs.editor.IStandaloneCodeEditor, - lineNumber: number, - position: ScrollPosition, -) => { - const viewHeight = getViewHeight(editor) - const lineTop = editor.getTopForLineNumber?.(lineNumber) ?? 0 - const lineBottom = editor.getBottomForLineNumber?.(lineNumber) ?? lineTop - const lineHeight = Math.max(1, lineBottom - lineTop) - - let targetTop = lineTop - switch (position) { - case 'center': - targetTop = lineTop - Math.max(0, viewHeight - lineHeight) / 2 - break - case 'bottom': - targetTop = lineBottom - viewHeight - break - case 'top': - default: - targetTop = lineTop - } - - const maxScrollTop = typeof editor.getScrollHeight === 'function' && viewHeight - ? editor.getScrollHeight() - viewHeight - : undefined - const clampedTop = typeof maxScrollTop === 'number' ? clamp(targetTop, 0, Math.max(0, maxScrollTop)) : Math.max(0, targetTop) - editor.setScrollTop(Math.max(0, clampedTop)) -} - -const patchScrollToCursorAction = (vimApi: CodeMirrorShim['Vim']) => { - if (!vimApi?.defineAction) return - - const defineAction = vimApi.defineAction.bind(vimApi) - defineAction('scrollToCursor', (cm: CmAdapter, actionArgs?: ScrollToCursorArgs) => { - const editor = cm?.editor - if (!editor) { - return - } - - const position = resolveScrollPosition(actionArgs?.position) - const lineNumber = editor.getPosition?.()?.lineNumber - if (!lineNumber) { - return - } - - scrollLineToPosition(editor, lineNumber, position) - }) -} - -const patchDisplayLineMotion = (module: MonacoVimModule) => { - if (vimPatched) return - - const codeMirror = module.VimMode - const vimApi = codeMirror?.Vim - if (!vimApi?.defineMotion) return - - const defineMotion = vimApi.defineMotion.bind(vimApi) - - defineMotion('moveByDisplayLines', function moveByDisplayLines(this: MotionsShape, cm, head, motionArgs, vim) { - const editor = cm.editor - const viewModel = editor ? getViewModel(editor) : null - const fallback = () => (typeof this.moveByLines === 'function' ? this.moveByLines(cm, head, motionArgs, vim) : head) - - if (!editor || !viewModel) { - return fallback() - } - - const converter = viewModel.coordinatesConverter - const modelStart = toModelPosition(head) - const viewStart = converter.convertModelPositionToViewPosition(modelStart) - const viewLineCount = typeof viewModel.getLineCount === 'function' ? viewModel.getLineCount() : 0 - if (!viewLineCount) { - return fallback() - } - - const repeat = Math.max(1, motionArgs.repeat || 1) - const direction = motionArgs.forward ? 1 : -1 - - const startZeroColumn = Math.max(0, viewStart.column - 1) - let goalColumn = typeof vim.lastHSPos === 'number' ? vim.lastHSPos : startZeroColumn - switch (vim.lastMotion) { - case this.moveByDisplayLines: - case this.moveByScroll: - case this.moveByLines: - case this.moveToColumn: - case this.moveToEol: - break - default: - goalColumn = startZeroColumn - vim.lastHSPos = goalColumn - } - - const rawTargetLine = viewStart.lineNumber + direction * repeat - const targetLine = clamp(rawTargetLine, 1, viewLineCount) - const maxColumn = Math.max(1, viewModel.getLineMaxColumn(targetLine) ?? 1) - const maxZeroColumn = Math.max(0, maxColumn - 1) - const resolvedColumn = clamp(goalColumn, 0, maxZeroColumn) - - const targetViewPos = { lineNumber: targetLine, column: resolvedColumn + 1 } - const targetModelPos = converter.convertViewPositionToModelPosition(targetViewPos) - const candidate = cm.clipPos({ - line: targetModelPos.lineNumber - 1, - ch: targetModelPos.column - 1, - }) - - vim.lastHSPos = goalColumn - vim.lastHPos = candidate.ch - return candidate - }) - - patchScrollToCursorAction(vimApi) - setupClipboardSync(vimApi) - vimPatched = true -} - -export async function loadMonacoVim() { - if (!cachedModulePromise) { - cachedModulePromise = import('monaco-vim').then((module) => { - patchDisplayLineMotion(module) - return module - }) - } - return cachedModulePromise -} diff --git a/app/src/features/edit-document/lib/monaco/wiki-link-provider.ts b/app/src/features/edit-document/lib/monaco/wiki-link-provider.ts deleted file mode 100644 index 46091f4c..00000000 --- a/app/src/features/edit-document/lib/monaco/wiki-link-provider.ts +++ /dev/null @@ -1,76 +0,0 @@ -import type * as monacoNs from 'monaco-editor' - -import { listDocuments } from '@/entities/document' - -type SearchResult = { - id: string - title: string - document_type: string - path?: string | null - updated_at?: string -} - -export function registerWikiLinkCompletion(monaco: typeof monacoNs) { - const provider: monacoNs.languages.CompletionItemProvider = { - triggerCharacters: ['[', '!', '@', '|', ' ', '-'], - async provideCompletionItems(model, position) { - const before = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: 1, endLineNumber: position.lineNumber, endColumn: position.column }) - const after = model.getValueInRange({ startLineNumber: position.lineNumber, startColumn: position.column, endLineNumber: position.lineNumber, endColumn: model.getLineMaxColumn(position.lineNumber) }) - const wiki = before.match(/(\[\[)([^\]]*?)$/) - const embed = before.match(/(!\[\[)([^\]]*?)$/) - const mention = before.match(/(@\[\[)([^\]]*?)$/) - const match = wiki || embed || mention - if (!match) return { suggestions: [] } - const q = match[2] || '' - const hasClosing = after.startsWith(']]') - let items: SearchResult[] = [] - try { - const resp = await listDocuments({ query: q || null }) - const docs = Array.isArray((resp as any)?.items) ? (resp as any).items as Array<{ id: string; title: string; type: string; path?: string; updated_at?: string }> : [] - items = docs.map(d => ({ id: d.id, title: d.title, document_type: d.type, path: (d as any).path, updated_at: (d as any).updated_at })) - } catch {} - const seen = new Set() - const uniq: SearchResult[] = [] - for (const it of items) { if (it && it.id && !seen.has(it.id)) { seen.add(it.id); uniq.push(it) } } - const titleCounts = new Map() - for (const it of uniq) { const t = (it.title || '').toLowerCase(); if (!t) continue; titleCounts.set(t, (titleCounts.get(t) || 0) + 1) } - const duplicates = new Set() - titleCounts.forEach((c, t) => { if (c > 1) duplicates.add(t) }) - - const range: monacoNs.IRange = { startLineNumber: position.lineNumber, startColumn: position.column - q.length, endLineNumber: position.lineNumber, endColumn: position.column } - const suggestions: monacoNs.languages.CompletionItem[] = uniq.map((doc) => { - const isDup = duplicates.has((doc.title || '').toLowerCase()) - const insertText = hasClosing ? (doc.id || '') : `${doc.id}]]` - const typeLower = (doc.document_type || '').toLowerCase() - const typeDisplay = typeLower === 'folder' ? 'Folder' : typeLower === 'scrap' ? 'Scrap' : 'Document' - const updated = doc.updated_at || '' - const path = doc.path || '' - const documentation = `**${doc.title || 'Untitled'}**\n\n${isDup ? `Path: ${path}\n\n` : ''}Type: ${typeDisplay}\nID: ${doc.id}\n${updated ? `Updated: ${updated}` : ''}` - return { - label: doc.title || 'Untitled', - kind: monaco.languages.CompletionItemKind.File, - detail: isDup ? (path || '') : typeDisplay, - documentation: { value: documentation }, - insertText, - range, - command: hasClosing - ? { id: 'cursorMove', title: 'Move cursor', arguments: [{ to: 'right', by: 'character', value: 2 }] } - : { id: 'editor.action.triggerSuggest', title: 'Re-trigger suggestions' }, - } - }) - if (q && q.length > 0) { - suggestions.unshift({ - label: `Create "${q}"`, - kind: monaco.languages.CompletionItemKind.Constant, - detail: 'Create a new document', - documentation: 'Create a new document with this title (link will use document ID)', - insertText: hasClosing ? q : `${q}]]`, - range, - }) - } - return { suggestions } - }, - } - const disp = monaco.languages.registerCompletionItemProvider('markdown', provider) - return disp -} diff --git a/app/src/features/edit-document/model/editor-context.tsx b/app/src/features/edit-document/model/editor-context.tsx index db7ae6d3..a6a8c9cc 100644 --- a/app/src/features/edit-document/model/editor-context.tsx +++ b/app/src/features/edit-document/model/editor-context.tsx @@ -1,19 +1,19 @@ -import type * as monaco from 'monaco-editor' +import { EditorView } from '@codemirror/view' import React, { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react' type Ctx = { - editor: monaco.editor.IStandaloneCodeEditor | null - setEditor: (ed: monaco.editor.IStandaloneCodeEditor | null) => void - registerEditor: (ed: monaco.editor.IStandaloneCodeEditor) => () => void + editor: EditorView | null + setEditor: (ed: EditorView | null) => void + registerEditor: (ed: EditorView) => () => void } const EditorCtx = createContext(null) export function EditorProvider({ children }: { children: React.ReactNode }) { - const [editor, setEditor] = useState(null) - const editorsRef = useRef>(new Set()) + const [editor, setEditor] = useState(null) + const editorsRef = useRef>(new Set()) - const registerEditor = useCallback((ed: monaco.editor.IStandaloneCodeEditor) => { + const registerEditor = useCallback((ed: EditorView) => { editorsRef.current.add(ed) setEditor((current) => current ?? ed) let released = false diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 864fc976..e6c3420f 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -1,6 +1,5 @@ -import type { OnMount } from '@monaco-editor/react' +import { EditorView } from '@codemirror/view' import { useNavigate, useRouterState } from '@tanstack/react-router' -import type * as monacoNs from 'monaco-editor' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { toast } from 'sonner' import type { Awareness } from 'y-protocols/awareness' @@ -15,27 +14,21 @@ import type { ViewMode } from '@/shared/types/view-mode' import { listDocuments } from '@/entities/document' -import { useAwarenessStyles } from '@/features/edit-document/hooks/useAwarenessStyles' +import { useEditorBinding } from '@/features/edit-document/hooks/useEditorBinding' import { useEditorUploads } from '@/features/edit-document/hooks/useEditorUploads' import { useMarkdownCommands, type MarkdownCommand } from '@/features/edit-document/hooks/useMarkdownCommands' -import { useMonacoBinding } from '@/features/edit-document/hooks/useMonacoBinding' import { useScrollSync } from '@/features/edit-document/hooks/useScrollSync' -import { ensureRefmdThemes, REFMD_DARK_THEME, REFMD_LIGHT_THEME } from '@/features/edit-document/lib/monaco/theme' -import { registerWikiLinkCompletion } from '@/features/edit-document/lib/monaco/wiki-link-provider' +import { awarenessExtension, awarenessStyles } from '@/features/edit-document/lib/editor/awareness' +import { enableVimMode, disableVimMode } from '@/features/edit-document/lib/editor/vim' import { useEditorContext } from '@/features/edit-document/model/editor-context' import { useViewContext } from '@/features/edit-document/model/view-context' -import { loadMonacoVim } from '../lib/monaco/vim-loader' - import CursorDisplay from './CursorDisplay' import EditorLayout from './EditorLayout' import type { PreviewPaneProps } from './PreviewPane' import EditorToolbar from './Toolbar' const logEditorError = (scope: string, error: unknown) => { - if (error instanceof Error && /InstantiationService has been disposed/i.test(error.message)) { - return - } if (error instanceof Error) { console.error(`[editor] ${scope}:`, error) } else { @@ -89,7 +82,6 @@ export type MarkdownEditorProps = { renderPreview?: (props: PreviewPaneProps) => React.ReactNode } - export function MarkdownEditor(props: MarkdownEditorProps) { const { doc, @@ -110,12 +102,14 @@ export function MarkdownEditor(props: MarkdownEditorProps) { previewOverride, renderPreview, } = props + const { isDarkMode } = useTheme() const isMobile = useIsMobile() const { editor: activeEditor, setEditor, registerEditor } = useEditorContext() const { viewMode, setViewMode, viewModeHydrated, hasPersistentViewMode } = useViewContext() const navigate = useNavigate() const shareToken = useShareToken() + const shareScope = useRouterState({ select: (state) => { const raw = (state.location?.search as any)?.shareScope @@ -123,6 +117,7 @@ export function MarkdownEditor(props: MarkdownEditorProps) { return scope === 'folder' || scope === 'document' ? scope : null }, }) + const isShareMount = useRouterState({ select: (state) => { const search = (state.location?.search ?? {}) as Record @@ -136,14 +131,14 @@ export function MarkdownEditor(props: MarkdownEditorProps) { return Boolean(raw) }, }) + const isShareLink = Boolean(shareToken && !isShareMount) - const brandedMonacoTheme = isDarkMode ? REFMD_DARK_THEME : REFMD_LIGHT_THEME - const monacoTheme = brandedMonacoTheme const view = forcedView ?? viewMode + const [isVimMode, setIsVimMode] = useState(() => typeof window !== 'undefined' && localStorage.getItem('editorVimMode') === 'true') const [syncScroll, setSyncScroll] = useState(true) const [toolbarOpen, setToolbarOpen] = useState(false) - const [editorMountNonce, setEditorMountNonce] = useState(0) + const readOnlyWarningRef = useRef(0) const emitReadOnlyWarning = useCallback(() => { if (!readOnly) return @@ -152,77 +147,84 @@ export function MarkdownEditor(props: MarkdownEditorProps) { readOnlyWarningRef.current = now toast.info('Document is read-only') }, [readOnly]) + const syncScrollRef = useRef(true) - useEffect(() => { syncScrollRef.current = syncScroll }, [syncScroll]) - const vimModeRef = useRef<{ dispose: () => void } | null>(null) + useEffect(() => { + syncScrollRef.current = syncScroll + }, [syncScroll]) + const vimStatusBarRef = useRef(null) const fileInputRef = useRef(null) const viewRef = useRef(forcedView ?? initialViewProp) + const unregisterEditorRef = useRef void)>(null) + useEffect(() => { viewRef.current = view as ViewMode }, [view]) - const { onMount: onMonacoMount, text: boundText, editorRef } = useMonacoBinding({ - doc, - awareness, - language: 'markdown', - onTextChange: () => {}, - }) + const mosaicGroupIdRef = useRef(scrollSyncGroupId) useEffect(() => { mosaicGroupIdRef.current = scrollSyncGroupId }, [scrollSyncGroupId]) + const mosaicScrollRafRef = useRef(null) const suppressMosaicEmitRef = useRef(false) const suppressMosaicTimeoutRef = useRef(null) - const unregisterEditorRef = useRef void)>(null) - const focusDisposableRef = useRef void }>(null) - const blurDisposableRef = useRef void }>(null) - const isThisEditorActive = useCallback(() => { - const ed = editorRef.current - if (!ed) return false - return activeEditor === ed - }, [activeEditor, editorRef]) - - const ensureThisEditorActive = useCallback(() => { - const ed = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null - if (!ed) return - if (activeEditor !== ed) setEditor(ed as any) - }, [activeEditor, editorRef, setEditor]) - const disableVimMode = useCallback(() => { - if (vimModeRef.current) { - safeExecute('disable vim mode', () => vimModeRef.current?.dispose()) - vimModeRef.current = null - } - if (vimStatusBarRef.current) { - vimStatusBarRef.current.textContent = '' + // Set up awareness user info + useEffect(() => { + if (!awareness || (awareness as any)?._destroyed) return + + const generateUserColor = (id?: string, light = false): string => { + let hash = 0 + const str = id || Math.random().toString() + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i) + hash = ((hash << 5) - hash) + char + hash = hash & hash + } + const hue = Math.abs(hash) % 360 + const saturation = light ? 30 : 70 + const lightness = light ? 80 : 50 + return `hsl(${hue}, ${saturation}%, ${lightness}%)` } - }, []) - const enableVimMode = useCallback(async (targetEditor?: monacoNs.editor.IStandaloneCodeEditor) => { - const editorInstance = targetEditor ?? (editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null) - const statusBar = vimStatusBarRef.current - if (!editorInstance || !statusBar) return - disableVimMode() - try { - const { initVimMode } = await loadMonacoVim() - statusBar.textContent = '' - vimModeRef.current = initVimMode(editorInstance, statusBar) - editorInstance.focus() - } catch (error) { - logEditorError('enable vim mode', error) + + const info = { + name: userName || `User-${awareness.clientID}`, + color: generateUserColor(userId), + colorLight: generateUserColor(userId, true), + id: userId || String(awareness.clientID), } - }, [disableVimMode, editorRef]) - const { previewScrollPct, previewAnchorLine, handleEditorScroll, handlePreviewScroll, onEditorContentChange, onCaretAtEndChange, lockActive } = useScrollSync(editorRef) + awareness.setLocalStateField('user', info) + }, [awareness, userId, userName]) + + // Editor binding hook + const { text: boundText, editorRef, bindingExtensions, setEditorView } = useEditorBinding({ + doc, + awareness, + onTextChange: () => {}, + }) + + // Create awareness extensions + const awarenessExts = useMemo(() => { + return [awarenessExtension(awareness), awarenessStyles()] + }, [awareness]) + + // Combined extensions + const editorExtensions = useMemo(() => { + return [...bindingExtensions, ...awarenessExts] + }, [bindingExtensions, awarenessExts]) + + const { previewScrollPct, previewAnchorLine, handleEditorScroll, handlePreviewScroll, lockActive } = useScrollSync(editorRef) const { runCommand } = useMarkdownCommands(editorRef) + const handleToolbarCommand = useCallback( (cmd: string, value?: number) => { runCommand(cmd as MarkdownCommand, value) }, [runCommand], ) - // Wire the actual callback now that hook is ready - ;(onMonacoMount as any)._onTextChange = onEditorContentChange - ;(onMonacoMount as any)._onCaretAtEnd = onCaretAtEndChange + useEffect(() => { if (!viewModeHydrated) return if (forcedView) return @@ -232,45 +234,12 @@ export function MarkdownEditor(props: MarkdownEditorProps) { safeExecute('set initial view mode', () => setViewMode(initialViewProp)) }, [forcedView, hasPersistentViewMode, initialViewProp, setViewMode, viewMode, viewModeHydrated]) - useAwarenessStyles(awareness, { userId, userName }) - const { uploadFiles, uploadStatus } = useEditorUploads(documentId, readOnly, emitReadOnlyWarning) const uploadFilesRef = useRef(uploadFiles) useEffect(() => { uploadFilesRef.current = uploadFiles }, [uploadFiles]) - const setReadOnlyOverlay = useCallback( - ( - editor: (monacoNs.editor.IStandaloneCodeEditor & { __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget; domNode: HTMLElement }; __monaco?: typeof monacoNs }) | undefined, - monacoInstance: typeof monacoNs | undefined, - enabled: boolean, - ) => { - if (!editor || !monacoInstance) return - const existing = editor.__readOnlyOverlay - if (enabled) { - if (existing) return - const domNode = document.createElement('div') - domNode.className = 'pointer-events-none select-none text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground bg-background/85 border border-border/60 rounded-full px-3 py-1 shadow-sm' - domNode.textContent = 'Read-only' - const widget: monacoNs.editor.IOverlayWidget = { - getId: () => 'read-only-overlay', - getDomNode: () => domNode, - getPosition: () => ({ - preference: monacoInstance.editor.OverlayWidgetPositionPreference.TOP_RIGHT_CORNER, - }), - } - editor.addOverlayWidget(widget) - editor.__readOnlyOverlay = { widget, domNode } - } else if (existing) { - try { editor.removeOverlayWidget(existing.widget) } catch {} - try { existing.domNode.remove() } catch {} - delete editor.__readOnlyOverlay - } - }, - [], - ) - const handleTaskToggle = useCallback((lineNumber: number, checked: boolean) => { if (readOnly) { emitReadOnlyWarning() @@ -283,16 +252,13 @@ export function MarkdownEditor(props: MarkdownEditorProps) { let currentLine = 1 while (currentLine < lineNumber) { const nextNewline = text.indexOf('\n', offset) - if (nextNewline === -1) { - return - } + if (nextNewline === -1) return offset = nextNewline + 1 currentLine += 1 } const nextNewline = text.indexOf('\n', offset) const lineEnd = nextNewline === -1 ? text.length : nextNewline const lineText = text.slice(offset, lineEnd) - // Allow optional blockquote and ordered list prefixes before the task checkbox const taskMatch = lineText.match(/^(\s*(?:>\s*)*(?:[-*+]|\d+[.)])\s*\[)([ xX])(\]\s*)(.*)$/) if (!taskMatch) return const [, prefix, currentChar, closing, rest] = taskMatch @@ -306,127 +272,58 @@ export function MarkdownEditor(props: MarkdownEditorProps) { }) }, [doc, readOnly, emitReadOnlyWarning]) - const handleBeforeMount = useCallback((monaco: Parameters[1]) => { - ensureRefmdThemes(monaco as any) - monaco.editor.setTheme(brandedMonacoTheme) - }, [brandedMonacoTheme]) - - const handleMount: OnMount = useCallback((editor, monaco) => { - // First, bind Monaco to Yjs via hook - onMonacoMount(editor, monaco) - ;(editor as any).__monaco = monaco - setReadOnlyOverlay(editor as any, monaco as any, readOnly) - // Register wiki-link completion provider - try { - const disp = registerWikiLinkCompletion(monaco as any) - ;(editor as any).__disposeWiki = () => safeExecute('dispose wiki completion', () => disp?.dispose?.()) - } catch (error) { - logEditorError('register wiki completion', error) - } + const isThisEditorActive = useCallback(() => { + const ed = editorRef.current + if (!ed) return false + return activeEditor === ed + }, [activeEditor, editorRef]) - // Activate monaco-markdown extension for lists/enter/tab/completions (lazy load) - ;(async () => { - try { - const mod = await import('monaco-markdown') - const ext = new mod.MonacoMarkdownExtension() - ext.activate(editor as any) - ;(editor as any).__disposeMonacoMd = () => {} - } catch (error) { - logEditorError('load monaco-markdown', error) - } - })() - - const cursorDispose = editor.onDidChangeCursorSelection((_e) => {}) - ;(editor as any).__disposeCursor = () => safeExecute('dispose cursor listener', () => cursorDispose.dispose()) - - const shouldWarnForKey = (ev: any) => { - if (!readOnly) return false - const native = ev?.browserEvent ?? ev - if (!native) return false - const { ctrlKey, metaKey, altKey } = native - if (ctrlKey || metaKey || altKey) return false - const key = native.key ?? native.code ?? '' - if (key === ' ' || key === 'Spacebar') return true - const editingKeys = ['Backspace', 'Delete', 'Enter', 'Tab'] - if (editingKeys.includes(key)) return true - if (typeof key === 'string' && key.length === 1) return true - return false + const ensureThisEditorActive = useCallback(() => { + const ed = editorRef.current + if (!ed) return + if (activeEditor !== ed) setEditor(ed) + }, [activeEditor, editorRef, setEditor]) + + // Handle editor view creation + const handleEditorViewCreated = useCallback((view: EditorView) => { + setEditorView(view) + + // Register editor + unregisterEditorRef.current?.() + unregisterEditorRef.current = registerEditor(view) + + // Set up scroll listener for split view sync + const scrollHandler = () => { + if (!syncScrollRef.current || viewRef.current !== 'split') return + handleEditorScroll() } + view.scrollDOM.addEventListener('scroll', scrollHandler) + + // Set up mosaic scroll sync + const mosaicScrollHandler = () => { + const groupId = mosaicGroupIdRef.current + if (!groupId) return + if (!syncScrollRef.current) return + if (suppressMosaicEmitRef.current) return + if (mosaicScrollRafRef.current != null) return - // Pre-lock preview to bottom when user hits Enter at file end - try { - const keydownDispose = editor.onKeyDown((e: any) => { + mosaicScrollRafRef.current = window.requestAnimationFrame(() => { + mosaicScrollRafRef.current = null try { - if (shouldWarnForKey(e)) { - emitReadOnlyWarning() - return - } - const KeyCode = (monaco as any)?.KeyCode - const isEnter = KeyCode ? e.keyCode === KeyCode.Enter : e.code === 'Enter' || e.keyCode === 13 - if (!isEnter) return - const model = editor.getModel() - const pos = editor.getPosition() - if (!model || !pos) return - const lastLine = model.getLineCount() - const atLastLine = pos.lineNumber >= lastLine - if (!atLastLine) return - const maxCol = model.getLineMaxColumn(lastLine) - const atEndOfDoc = pos.column >= maxCol - if (atEndOfDoc) { - safeExecute('handle enter at end of doc', () => onEditorContentChange()) - } + const block = view.lineBlockAtHeight(view.scrollDOM.scrollTop) + const line = view.state.doc.lineAt(block.from).number + if (!Number.isFinite(line) || line < 1) return + dispatchMosaicScrollSync({ groupId, source: 'editor', line }) } catch (error) { - logEditorError('keydown handler', error) + logEditorError('mosaic scroll sync emit', error) } }) - ;(editor as any).__disposeKeydown = () => safeExecute('dispose keydown listener', () => keydownDispose.dispose()) - } catch (error) { - logEditorError('register keydown handler', error) } + view.scrollDOM.addEventListener('scroll', mosaicScrollHandler) - // Hook editor scroll for sync - const scrollDispose = editor.onDidScrollChange?.((e) => { - if (!syncScrollRef.current || viewRef.current !== 'split') return - handleEditorScroll(e) - }) - ;(editor as any).__disposeScroll = () => safeExecute('dispose scroll listener', () => scrollDispose?.dispose?.()) - - // Mosaic scroll sync: emit current top line to paired preview tile (by group) - try { - const mosaicScrollDispose = editor.onDidScrollChange?.(() => { - const groupId = mosaicGroupIdRef.current - if (!groupId) return - if (!syncScrollRef.current) return - if (suppressMosaicEmitRef.current) return - if (mosaicScrollRafRef.current != null) return - mosaicScrollRafRef.current = window.requestAnimationFrame(() => { - mosaicScrollRafRef.current = null - try { - if ((editor as any)?._isDisposed === true) return - const domNode = editor.getDomNode?.() - if (!domNode) return - const range = editor.getVisibleRanges?.()?.[0] - const line = range?.startLineNumber ?? editor.getPosition?.()?.lineNumber ?? 1 - if (!Number.isFinite(line) || line < 1) return - dispatchMosaicScrollSync({ groupId, source: 'editor', line }) - } catch (error) { - logEditorError('mosaic scroll sync emit', error) - } - }) - }) - ;(editor as any).__disposeMosaicScroll = () => safeExecute('dispose mosaic scroll listener', () => mosaicScrollDispose?.dispose?.()) - } catch (error) { - logEditorError('register mosaic scroll listener', error) - } - - // Handle paste (Ctrl+V) with files from clipboard - const dom = editor.getDomNode() as HTMLElement | null + // Set up paste handler const pasteHandler = async (event: ClipboardEvent) => { try { - const editorDomNode = dom - const target = event.target as HTMLElement | null - if (!editorDomNode || !target || !editorDomNode.contains(target)) return - const clipboardData = event.clipboardData const fileList = clipboardData?.files if (!fileList || fileList.length === 0) return @@ -444,85 +341,51 @@ export function MarkdownEditor(props: MarkdownEditorProps) { logEditorError('paste handler', error) } } + view.contentDOM.addEventListener('paste', pasteHandler) - if (typeof document !== 'undefined') { - document.addEventListener('paste', pasteHandler as any, true) - } - - ;(editor as any).__disposePaste = () => { - safeExecute('remove document paste listener', () => { - if (typeof document !== 'undefined') { - document.removeEventListener('paste', pasteHandler as any, true) - } - }) - } - - // Apply vim if enabled + // Apply vim mode if enabled if (isVimMode) { - void enableVimMode(editor) + enableVimMode(view) } - }, [onMonacoMount, isVimMode, syncScroll, handleEditorScroll, emitReadOnlyWarning, readOnly, setReadOnlyOverlay, enableVimMode, brandedMonacoTheme]) - useEffect(() => { - const editorInstance = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget; domNode: HTMLElement }; __monaco?: typeof monacoNs }) | null - if (!editorInstance) return - const monacoInstance = editorInstance.__monaco - setReadOnlyOverlay(editorInstance, monacoInstance, readOnly) - }, [readOnly, setReadOnlyOverlay]) + // Store cleanup function on view + ;(view as any).__cleanup = () => { + view.scrollDOM.removeEventListener('scroll', scrollHandler) + view.scrollDOM.removeEventListener('scroll', mosaicScrollHandler) + view.contentDOM.removeEventListener('paste', pasteHandler) + } + }, [setEditorView, registerEditor, handleEditorScroll, isVimMode]) + // Cleanup on unmount useEffect(() => { - const editorInstance = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __monaco?: typeof monacoNs }) | null - const monacoInstance = editorInstance?.__monaco - if (!monacoInstance) return - ensureRefmdThemes(monacoInstance) - monacoInstance.editor.setTheme(brandedMonacoTheme) - }, [brandedMonacoTheme, editorRef]) - - useEffect(() => () => { - const anyEditor = editorRef.current as (monacoNs.editor.IStandaloneCodeEditor & { __readOnlyOverlay?: { widget: monacoNs.editor.IOverlayWidget; domNode: HTMLElement }; __monaco?: typeof monacoNs }) | undefined - safeExecute('dispose change listener', () => (anyEditor as any)?.__disposeChange?.()) - safeExecute('dispose scroll listener', () => (anyEditor as any)?.__disposeScroll?.()) - safeExecute('dispose mosaic scroll listener', () => (anyEditor as any)?.__disposeMosaicScroll?.()) - safeExecute('dispose paste handler', () => (anyEditor as any)?.__disposePaste?.()) - safeExecute('dispose wiki handler', () => (anyEditor as any)?.__disposeWiki?.()) - safeExecute('dispose cursor handler', () => (anyEditor as any)?.__disposeCursor?.()) - safeExecute('dispose monaco markdown handler', () => (anyEditor as any)?.__disposeMonacoMd?.()) - safeExecute('dispose keydown handler', () => (anyEditor as any)?.__disposeKeydown?.()) - safeExecute('dispose read-only overlay', () => { - if (anyEditor?.__readOnlyOverlay) { - try { anyEditor.removeOverlayWidget(anyEditor.__readOnlyOverlay.widget) } catch {} - try { anyEditor.__readOnlyOverlay.domNode.remove() } catch {} - delete anyEditor.__readOnlyOverlay - } - if (anyEditor && '__monaco' in anyEditor) { - delete (anyEditor as any).__monaco - } - }) - safeExecute('dispose editor focus listener', () => focusDisposableRef.current?.dispose()) - focusDisposableRef.current = null - safeExecute('dispose editor blur listener', () => blurDisposableRef.current?.dispose()) - blurDisposableRef.current = null - safeExecute('unregister editor instance', () => unregisterEditorRef.current?.()) - unregisterEditorRef.current = null - safeExecute('cancel mosaic scroll raf', () => { - if (mosaicScrollRafRef.current != null) { - window.cancelAnimationFrame(mosaicScrollRafRef.current) - mosaicScrollRafRef.current = null - } - }) - safeExecute('cancel mosaic suppress timeout', () => { - if (suppressMosaicTimeoutRef.current != null) { - window.clearTimeout(suppressMosaicTimeoutRef.current) - suppressMosaicTimeoutRef.current = null + return () => { + const view = editorRef.current + if (view) { + safeExecute('cleanup editor', () => (view as any).__cleanup?.()) } - suppressMosaicEmitRef.current = false - }) - disableVimMode() - }, [editorRef, setEditor, disableVimMode]) + safeExecute('unregister editor', () => unregisterEditorRef.current?.()) + unregisterEditorRef.current = null + safeExecute('cancel mosaic scroll raf', () => { + if (mosaicScrollRafRef.current != null) { + window.cancelAnimationFrame(mosaicScrollRafRef.current) + mosaicScrollRafRef.current = null + } + }) + safeExecute('cancel mosaic suppress timeout', () => { + if (suppressMosaicTimeoutRef.current != null) { + window.clearTimeout(suppressMosaicTimeoutRef.current) + suppressMosaicTimeoutRef.current = null + } + suppressMosaicEmitRef.current = false + }) + } + }, [editorRef]) + // Listen for mosaic scroll sync from preview useEffect(() => { if (typeof window === 'undefined') return if (!scrollSyncGroupId) return + const handler = (event: Event) => { try { if (!syncScrollRef.current) return @@ -532,30 +395,23 @@ export function MarkdownEditor(props: MarkdownEditorProps) { const line = detail.line if (!Number.isFinite(line) || (line as number) < 1) return - const editorInstance = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null - if (!editorInstance) return - if ((editorInstance as any)?._isDisposed === true) return - const domNode = editorInstance.getDomNode?.() - if (!domNode) return + const view = editorRef.current + if (!view) return - const model = editorInstance.getModel?.() - if (!model) return - const maxLine = model.getLineCount?.() ?? null - const clamped = maxLine - ? Math.min(maxLine, Math.max(1, Math.floor(line as number))) - : Math.max(1, Math.floor(line as number)) + const maxLine = view.state.doc.lines + const clamped = maxLine ? Math.min(maxLine, Math.max(1, Math.floor(line as number))) : Math.max(1, Math.floor(line as number)) if (suppressMosaicTimeoutRef.current != null) { window.clearTimeout(suppressMosaicTimeoutRef.current) suppressMosaicTimeoutRef.current = null } suppressMosaicEmitRef.current = true + try { - ;(editorInstance as any).revealLineNearTop?.(clamped) - } catch (error) { - // Avoid noisy errors when editor is being disposed during tile close/layout changes. - if (error instanceof Error && /InstantiationService has been disposed/i.test(error.message)) return - throw error + const lineInfo = view.state.doc.line(clamped) + view.dispatch({ + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'start' }), + }) } finally { suppressMosaicTimeoutRef.current = window.setTimeout(() => { suppressMosaicTimeoutRef.current = null @@ -566,22 +422,27 @@ export function MarkdownEditor(props: MarkdownEditorProps) { logEditorError('mosaic scroll sync receive', error) } } + window.addEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) return () => { window.removeEventListener(MOSAIC_SCROLL_SYNC_EVENT, handler as EventListener) } }, [editorRef, scrollSyncGroupId]) - const toggleVim = useCallback(async () => { + const toggleVim = useCallback(() => { const next = !isVimMode setIsVimMode(next) if (typeof window !== 'undefined') localStorage.setItem('editorVimMode', String(next)) + + const view = editorRef.current + if (!view) return + if (next) { - await enableVimMode() + enableVimMode(view) } else { - disableVimMode() + disableVimMode(view) } - }, [isVimMode, enableVimMode, disableVimMode]) + }, [isVimMode, editorRef]) const handleFileUpload = useCallback(() => { if (readOnly) { @@ -592,10 +453,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { if (fileInputRef.current) fileInputRef.current.click() }, [emitReadOnlyWarning, readOnly, ensureThisEditorActive]) - // uploadFiles provided by hook - - // View mode is now controlled via ViewContext - const Toolbar = useMemo(() => ( !value) }, [isThisEditorActive]) + const shortcutToggleVim = useCallback(() => { if (!isThisEditorActive()) return - void toggleVim() + toggleVim() }, [isThisEditorActive, toggleVim]) + const shortcutUpload = useCallback(() => { if (!isThisEditorActive()) return handleFileUpload() @@ -666,73 +525,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } }, [isShareLink, isShareMount, navigate, shareScope, shareToken]) - // Ensure Monaco relayouts when view/layout changes or container resizes - useEffect(() => { - const ed = editorRef.current as monacoNs.editor.IStandaloneCodeEditor | null - if (!ed) return - const relayoutToContainer = () => { - safeExecute('editor relayout', () => { - const container = (ed as any).getContainerDomNode?.() as HTMLElement | null - const node = ed.getDomNode?.() as HTMLElement | null - const target = container || node?.parentElement || node - if (!target) { - ed.layout() - return - } - const rect = target.getBoundingClientRect() - if (!rect.width || !rect.height) { - ed.layout() - return - } - ed.layout({ width: rect.width, height: rect.height }) - }) - } - // immediate relayout on view change - relayoutToContainer() - // also schedule once after transition - const t = setTimeout(relayoutToContainer, 120) - // observe parent size changes - let ro: ResizeObserver | null = null - try { - const container = (ed as any).getContainerDomNode?.() as HTMLElement | null - const node = ed.getDomNode() as HTMLElement | null - const target = container || node?.parentElement || node - if (target && 'ResizeObserver' in window) { - ro = new ResizeObserver(() => relayoutToContainer()) - ro.observe(target) - } - } catch (error) { - logEditorError('init resize observer', error) - } - // window resize - window.addEventListener('resize', relayoutToContainer) - return () => { - clearTimeout(t) - safeExecute('disconnect resize observer', () => { - if (ro) ro.disconnect() - }) - window.removeEventListener('resize', relayoutToContainer) - } - }, [editorMountNonce, view, editorRef]) - - const handleEditorMount = useCallback( - (editor: monacoNs.editor.IStandaloneCodeEditor, monaco: Parameters[1]) => { - unregisterEditorRef.current?.() - unregisterEditorRef.current = registerEditor(editor as any) - safeExecute('dispose editor focus listener', () => focusDisposableRef.current?.dispose()) - safeExecute('dispose editor blur listener', () => blurDisposableRef.current?.dispose()) - focusDisposableRef.current = editor.onDidFocusEditorWidget(() => { - try { setEditor(editor as any) } catch {} - }) - blurDisposableRef.current = editor.onDidBlurEditorWidget(() => { - // Keep last active editor; do not clear on blur to avoid losing target when clicking chrome. - }) - handleMount(editor, monaco) - setEditorMountNonce((n) => n + 1) - }, - [handleMount, registerEditor, setEditor], - ) - const handleEditorDropFiles = useCallback( async (files: File[]) => { ensureThisEditorActive() @@ -741,8 +533,6 @@ export function MarkdownEditor(props: MarkdownEditorProps) { [ensureThisEditorActive, uploadFiles], ) - - return (

diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index 59bf0f5b..bfa1201d 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -1,7 +1,8 @@ -import { DiffEditor } from '@monaco-editor/react' +import { Extension } from '@codemirror/state' +import { EditorView } from '@codemirror/view' +import { MergeView } from '@codemirror/merge' import { AlertTriangle, Check, Loader2, SlidersHorizontal, X } from 'lucide-react' -import * as monacoNs from 'monaco-editor' -import { useCallback, useMemo, useEffect, useRef, useState, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' +import { useCallback, useMemo, useEffect, useRef, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -9,7 +10,7 @@ import type { ViewMode } from '@/shared/types/view-mode' import { Button } from '@/shared/ui/button' import type { UploadStatus } from '@/features/edit-document/hooks/useEditorUploads' -import { ensureRefmdThemes } from '@/features/edit-document/lib/monaco/theme' +import { createBaseExtensions } from '@/features/edit-document/lib/editor' import EditorPane from './EditorPane' import PreviewPane, { type PreviewPaneProps } from './PreviewPane' @@ -22,12 +23,12 @@ export type EditorLayoutProps = { toolbar: ReactNode toolbarOpen: boolean onToolbarOpenChange: (open: boolean) => void - monacoTheme: string - onEditorBeforeMount?: (monaco: typeof import('monaco-editor')) => void + isDarkMode: boolean readOnly: boolean onEditorDropFiles: (files: File[]) => Promise - onEditorMount: (editor: monacoNs.editor.IStandaloneCodeEditor, monaco: typeof import('monaco-editor')) => void - editorRef: MutableRefObject + onEditorViewCreated: (view: EditorView) => void + editorExtensions?: Extension[] + editorRef: MutableRefObject syncScroll: boolean onPreviewScroll: (percentage: number) => void previewScrollPct?: number @@ -58,7 +59,6 @@ export type EditorLayoutProps = { modified?: string onChange?: (val: string) => void readOnly?: boolean - theme?: string actions?: { onKeepMine?: () => void onTakeTheirs?: () => void @@ -75,11 +75,11 @@ export function EditorLayout({ toolbar, toolbarOpen, onToolbarOpenChange, - monacoTheme, - onEditorBeforeMount, + isDarkMode, readOnly, onEditorDropFiles, - onEditorMount, + onEditorViewCreated, + editorExtensions, editorRef, syncScroll, onPreviewScroll, @@ -102,164 +102,59 @@ export function EditorLayout({ conflictHunkWidgets, conflictView, }: EditorLayoutProps) { - const diffEditorRef = useRef(null) - const monacoRef = useRef(null) - const [diffReady, setDiffReady] = useState(false) - const overlayNodesRef = useRef>({}) - const overlayWidgetsRef = useRef>({}) - const overlayDisposablesRef = useRef([]) + const mergeViewContainerRef = useRef(null) + const mergeViewRef = useRef(null) + // Create and manage MergeView for conflict resolution useEffect(() => { - // cleanup helper - const cleanup = () => { - if (diffEditorRef.current && monacoRef.current) { - const modified = diffEditorRef.current.getModifiedEditor() - Object.values(overlayWidgetsRef.current).forEach((widget) => { - try { - modified.removeContentWidget(widget) - } catch { - /* ignore */ - } - }) + if (!conflictView || conflictView.kind !== 'text' || !mergeViewContainerRef.current) { + if (mergeViewRef.current) { + mergeViewRef.current.destroy() + mergeViewRef.current = null } - Object.values(overlayNodesRef.current).forEach((node) => node.remove()) - overlayNodesRef.current = {} - overlayWidgetsRef.current = {} - overlayDisposablesRef.current.forEach((d) => d.dispose()) - overlayDisposablesRef.current = [] - } - - const diff = diffEditorRef.current - const monacoInstance = monacoRef.current - if (!diff || !monacoInstance || !diffReady) { - cleanup() - return - } - const modified = diff.getModifiedEditor() - const model = modified?.getModel() - if (!modified || !model || !conflictHunkWidgets || conflictHunkWidgets.length === 0) { - cleanup() return } - const host = - modified.getDomNode()?.querySelector('.overflow-guard') ?? - modified.getDomNode() ?? - document.createElement('div') - if (!host) { - cleanup() - return - } - if (host instanceof HTMLElement) { - const style = host.style - if (!style.position || style.position === 'static') { - style.position = 'relative' - } - } + const container = mergeViewContainerRef.current + const baseExtensions = createBaseExtensions({ + isDarkMode, + readOnly: conflictView.readOnly ?? false, + vimMode: false, + isMobile, + lineWrapping: true, + }) - const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') - const palette = { - ours: { - bg: isDark ? 'rgba(127,29,29,0.30)' : '#fef2f2', - bgActive: isDark ? 'rgba(185,28,28,0.55)' : '#fee2e2', - color: isDark ? '#fecdd3' : '#b91c1c', + const mergeView = new MergeView({ + a: { + doc: conflictView.original ?? '', + extensions: [ + ...baseExtensions, + EditorView.editable.of(false), + ], }, - theirs: { - bg: isDark ? 'rgba(5,46,22,0.30)' : '#f0fdf4', - bgActive: isDark ? 'rgba(34,197,94,0.50)' : '#dcfce7', - color: isDark ? '#bbf7d0' : '#166534', + b: { + doc: conflictView.modified ?? '', + extensions: [ + ...baseExtensions, + EditorView.editable.of(!conflictView.readOnly), + EditorView.updateListener.of((update) => { + if (update.docChanged && conflictView.onChange) { + conflictView.onChange(update.state.doc.toString()) + } + }), + ], }, - } - - const createNode = (hunk: typeof conflictHunkWidgets[number]) => { - const node = document.createElement('div') - node.style.position = 'absolute' - node.style.display = 'inline-flex' - node.style.flexDirection = 'row' - node.style.alignItems = 'center' - node.style.gap = '8px' - node.style.padding = '2px 6px' - node.style.borderRadius = '10px' - node.style.background = 'transparent' - node.style.pointerEvents = 'auto' - node.style.whiteSpace = 'nowrap' - node.style.zIndex = '50' - node.style.marginLeft = '8px' - - const makeBtn = (label: string, side: 'ours' | 'theirs') => { - const btn = document.createElement('button') - btn.textContent = label - btn.style.fontSize = '11px' - btn.style.padding = '4px 10px' - btn.style.borderRadius = '8px' - btn.style.border = 'none' - btn.style.cursor = 'pointer' - btn.style.lineHeight = '1' - btn.style.fontWeight = hunk.choice === side ? '700' : '500' - btn.style.display = 'inline-flex' - btn.style.alignItems = 'center' - btn.style.justifyContent = 'center' - const colors = side === 'ours' ? palette.ours : palette.theirs - btn.style.background = hunk.choice === side ? colors.bgActive : colors.bg - btn.style.color = colors.color - btn.onmousedown = (e) => { - e.preventDefault() - e.stopPropagation() - } - btn.onclick = (e) => { - e.preventDefault() - e.stopPropagation() - hunk.onChoose(side) - } - return btn - } - - node.appendChild(makeBtn('Keep Mine', 'ours')) - node.appendChild(makeBtn('Take Remote', 'theirs')) - return node - } - - conflictHunkWidgets.forEach((hunk) => { - const node = createNode(hunk) - overlayNodesRef.current[hunk.id] = node - const widget: monacoNs.editor.IContentWidget = { - getId: () => `conflict-hunk-${hunk.id}`, - getDomNode: () => node, - getPosition: () => ({ - position: { - lineNumber: Math.max(hunk.line, 1), - // Place at line end so it follows text instead of gutter. - column: - (modified.getModel()?.getLineMaxColumn(Math.max(hunk.line, 1)) ?? 1) + - 1, - }, - preference: [monacoNs.editor.ContentWidgetPositionPreference.EXACT], - }), - } - overlayWidgetsRef.current[hunk.id] = widget - modified.addContentWidget(widget) + parent: container, + collapseUnchanged: {}, }) - const relayout = () => { - Object.values(overlayWidgetsRef.current).forEach((widget) => { - try { - modified.layoutContentWidget(widget) - } catch { - /* ignore */ - } - }) - } - - relayout() - - overlayDisposablesRef.current.push( - modified.onDidScrollChange(() => relayout()), - modified.onDidLayoutChange(() => relayout()), - modified.onDidChangeConfiguration(() => relayout()), - ) + mergeViewRef.current = mergeView - return cleanup - }, [conflictHunkWidgets, diffReady]) + return () => { + mergeView.destroy() + mergeViewRef.current = null + } + }, [conflictView, isDarkMode, isMobile]) const uploadStatusNode = (() => { if (uploadStatus.state === 'idle') return null @@ -347,10 +242,13 @@ export function EditorLayout({ const revealEditorLine = useCallback( (line: number) => { - const editor = editorRef.current - if (!editor) return + const editorView = editorRef.current + if (!editorView) return try { - ;(editor as any).revealLineNearTop?.(line) + const lineInfo = editorView.state.doc.line(line) + editorView.dispatch({ + effects: EditorView.scrollIntoView(lineInfo.from, { y: 'start' }), + }) } catch {} }, [editorRef], @@ -368,35 +266,30 @@ export function EditorLayout({
{layoutState.wEditor !== '0%' && (
- {editorBanner ?
{editorBanner}
: null} + 'flex flex-1 min-h-0 min-w-0 flex-col', + !isMobile && 'px-4 pb-6 pt-6 sm:px-6 sm:pb-8 sm:pt-8', + )} + > + {editorBanner ?
{editorBanner}
: null}
{editorOverlay ? ( -
- {editorOverlay} -
+
{editorOverlay}
) : null}
{uploadStatusNode} @@ -431,57 +324,10 @@ export function EditorLayout({ {conflictView && conflictView.kind === 'text' ? (
{conflictControls ?
{conflictControls}
: null} -
- { - monacoRef.current = monacoInstance - ensureRefmdThemes(monacoInstance) - }} - onMount={(editor, monacoInstance) => { - diffEditorRef.current = editor - monacoRef.current = monacoInstance - setDiffReady(true) - monacoInstance.editor.setTheme(conflictView.theme ?? monacoTheme) - const modified = editor.getModifiedEditor() - const original = editor.getOriginalEditor() - // Align gutters; show line numbers only on original - original.updateOptions({ - glyphMargin: false, - lineDecorationsWidth: 24, - lineNumbersMinChars: 1, // Monaco enforces >=1 - lineNumbers: 'on' as const, - }) - modified.updateOptions({ - glyphMargin: false, - lineDecorationsWidth: 24, - lineNumbersMinChars: 1, // Monaco enforces >=1 - lineNumbers: 'off' as const, - }) - if (conflictView.onChange) { - modified.onDidChangeModelContent(() => { - conflictView.onChange?.(modified.getValue()) - }) - } - }} - language="markdown" - theme={conflictView.theme ?? monacoTheme} - options={{ - readOnly: conflictView.readOnly, - renderSideBySide: false, - renderMarginRevertIcon: false, - renderOverviewRuler: false, - renderIndicators: false, - minimap: { enabled: false }, - automaticLayout: true, - wordWrap: 'on', - scrollBeyondLastLine: true, - fontSize: isMobile ? 17 : 14, - lineHeight: isMobile ? 26 : 22, - }} - /> -
+
{conflictHunkWidgets && conflictHunkWidgets.length ? (
@@ -497,14 +343,14 @@ export function EditorLayout({
) : ( { if (!readOnly) await onEditorDropFiles(files) }} - isMobile={isMobile} - onMount={onEditorMount} vimStatusBarRef={vimStatusBarRef} showVimStatusBar={showVimStatusBar} /> @@ -519,15 +365,12 @@ export function EditorLayout({
void + isDarkMode: boolean readOnly?: boolean - onMount: OnMount - onDropFiles?: (files: File[]) => Promise | void isMobile?: boolean + extensions?: Extension[] + onViewCreated?: (view: EditorView) => void + onDropFiles?: (files: File[]) => Promise | void vimStatusBarRef: MutableRefObject showVimStatusBar?: boolean } -export default function EditorPane({ theme, onBeforeMount, readOnly, onMount, onDropFiles, isMobile = false, vimStatusBarRef, showVimStatusBar = false }: Props) { +export default function EditorPane({ + isDarkMode, + readOnly = false, + isMobile = false, + extensions = [], + onViewCreated, + onDropFiles, + vimStatusBarRef, + showVimStatusBar = false, +}: Props) { + const containerRef = useRef(null) + const viewRef = useRef(null) const [isDragging, setIsDragging] = useState(false) const dragCounterRef = useRef(0) + // Create and mount the editor + useEffect(() => { + if (!containerRef.current) return + + const baseExtensions = createEditorExtensions({ + isDarkMode, + readOnly, + vimMode: false, + isMobile, + lineWrapping: true, + }) + + const state = EditorState.create({ + doc: '', + extensions: [...baseExtensions, ...extensions], + }) + + const view = new EditorView({ + state, + parent: containerRef.current, + }) + + viewRef.current = view + onViewCreated?.(view) + + return () => { + view.destroy() + viewRef.current = null + } + }, []) // Only run once on mount + + // Update theme when isDarkMode changes + useEffect(() => { + const view = viewRef.current + if (!view) return + + view.dispatch({ + effects: themeCompartment.reconfigure(getThemeExtension(isDarkMode)), + }) + }, [isDarkMode]) + + // Update readOnly when it changes + useEffect(() => { + const view = viewRef.current + if (!view) return + + view.dispatch({ + effects: readOnlyCompartment.reconfigure(EditorState.readOnly.of(readOnly)), + }) + }, [readOnly]) + + const handleDragEnter = useCallback((e: React.DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + dragCounterRef.current++ + setIsDragging(true) + } + }, []) + + const handleDragLeave = useCallback(() => { + dragCounterRef.current = Math.max(0, dragCounterRef.current - 1) + if (dragCounterRef.current === 0) setIsDragging(false) + }, []) + + const handleDragOver = useCallback((e: React.DragEvent) => { + if (e.dataTransfer?.types?.includes('Files')) { + e.preventDefault() + setIsDragging(true) + } + }, []) + + const handleDrop = useCallback( + async (e: React.DragEvent) => { + e.preventDefault() + const files = Array.from(e.dataTransfer?.files || []) + setIsDragging(false) + dragCounterRef.current = 0 + if (files.length > 0) { + try { + await onDropFiles?.(files) + } catch {} + } + }, + [onDropFiles], + ) + return (
{ if (e.dataTransfer?.types?.includes('Files')) { dragCounterRef.current++; setIsDragging(true) } }} - onDragLeave={() => { dragCounterRef.current = Math.max(0, dragCounterRef.current - 1); if (dragCounterRef.current === 0) setIsDragging(false) }} - onDragOver={(e) => { if (e.dataTransfer?.types?.includes('Files')) { e.preventDefault(); setIsDragging(true) } }} - onDrop={async (e) => { - e.preventDefault() - const files = Array.from(e.dataTransfer?.files || []) - setIsDragging(false) - dragCounterRef.current = 0 - if (files.length > 0) { try { await onDropFiles?.(files as File[]) } catch {} } - }} + onDragEnter={handleDragEnter} + onDragLeave={handleDragLeave} + onDragOver={handleDragOver} + onDrop={handleDrop} > -
void): void - attach(): void - setStatusBar(statusBar: VimStatusBar): void - } - - export interface VimMode { - dispose(): void - } - - export interface RegisterController { - pushText( - registerName: string | undefined, - operator: string | undefined, - text: string, - linewise?: boolean, - blockwise?: boolean, - ): void - } - - export interface CodeMirrorShim { - Vim?: { - defineMotion: (name: string, fn: (...args: any[]) => any) => void - defineAction?: (name: string, fn: (...args: any[]) => any) => void - getRegisterController?: () => RegisterController | undefined - resetVimGlobalState_?: () => void - } - } - - export const VimMode: CodeMirrorShim - - export function initVimMode(editor: any, statusbar: HTMLElement): VimMode -} diff --git a/app/src/widgets/document/DocumentMosaicWorkspace.tsx b/app/src/widgets/document/DocumentMosaicWorkspace.tsx index 3ece2adb..d32418f2 100644 --- a/app/src/widgets/document/DocumentMosaicWorkspace.tsx +++ b/app/src/widgets/document/DocumentMosaicWorkspace.tsx @@ -806,16 +806,13 @@ export default function DocumentMosaicWorkspace(props: Props) { const leaves = getLeavesSafe(safe.layout) if (!leaves.includes(tileKey)) return false - const monacoInput = - el.querySelector('.monaco-editor textarea.inputarea') ?? - el.querySelector('.monaco-editor textarea') ?? - el.querySelector('textarea.inputarea') - if (monacoInput) { + const cmContent = el.querySelector('.cm-editor .cm-content') + if (cmContent) { try { - monacoInput.focus({ preventScroll: true } as any) + cmContent.focus({ preventScroll: true } as any) } catch { try { - monacoInput.focus() + cmContent.focus() } catch {} } return true @@ -837,7 +834,7 @@ export default function DocumentMosaicWorkspace(props: Props) { } if (focusInside()) return - // Monaco might mount a tick later; try a few times. + // Editor might mount a tick later; try a few times. let tries = 0 const retry = () => { if (focusRequestIdRef.current !== requestId) return diff --git a/app/src/widgets/header/Header.tsx b/app/src/widgets/header/Header.tsx index 2981e86b..52902bd8 100644 --- a/app/src/widgets/header/Header.tsx +++ b/app/src/widgets/header/Header.tsx @@ -214,8 +214,8 @@ export function Header({ className, realtime, variant = 'overlay' }: HeaderProps if (target) { const tagName = target.tagName const isInputElement = tagName === 'INPUT' || tagName === 'TEXTAREA' || target.isContentEditable - const insideMonacoEditor = Boolean(target.closest('.monaco-editor')) - if (isInputElement && !insideMonacoEditor) { + const insideEditor = Boolean(target.closest('.cm-editor')) + if (isInputElement && !insideEditor) { return } } From 75cf99c0be1373bce51d44e008c726bd1bbc31b9 Mon Sep 17 00:00:00 2001 From: munenick Date: Fri, 9 Jan 2026 00:29:02 +0900 Subject: [PATCH 08/45] add: generate openapi client --- api/openapi.json | 48 -- app/src/shared/api/client/sdk.gen.ts | 537 +++++++++++++++- app/src/shared/api/client/types.gen.ts | 810 ++++++++++++++++++++++++- 3 files changed, 1306 insertions(+), 89 deletions(-) diff --git a/api/openapi.json b/api/openapi.json index d81e4b24..b79f83bb 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -1,49 +1 @@ - Compiling application v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/application) - Compiling presentation v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/presentation) - Compiling infrastructure v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/infrastructure) -warning: fields `hydration_service` and `awareness_ttl` are never read - --> crates/infrastructure/src/documents/realtime/redis/engine.rs:53:5 - | -51 | pub struct RedisRealtimeEngine { - | ------------------- fields in this struct -52 | bus: Arc, -53 | hydration_service: Arc, - | ^^^^^^^^^^^^^^^^^ -... -57 | awareness_ttl: Duration, - | ^^^^^^^^^^^^^ - | - = note: `#[warn(dead_code)]` (part of `#[warn(unused)]`) on by default - -warning: function `analyse_frame` is never used - --> crates/infrastructure/src/documents/realtime/utils.rs:17:8 - | -17 | pub fn analyse_frame(frame: &[u8]) -> Result { - | ^^^^^^^^^^^^^ - -warning: struct `FrameSummary` is never constructed - --> crates/infrastructure/src/documents/realtime/utils.rs:36:12 - | -36 | pub struct FrameSummary { - | ^^^^^^^^^^^^ - -warning: function `wrap_stream_with_edit_guard` is never used - --> crates/infrastructure/src/documents/realtime/utils.rs:41:8 - | -41 | pub fn wrap_stream_with_edit_guard( - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^ - -warning: struct `GuardedStream` is never constructed - --> crates/infrastructure/src/documents/realtime/utils.rs:53:8 - | -53 | struct GuardedStream { - | ^^^^^^^^^^^^^ - - Compiling bootstrap v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/bootstrap) - Compiling api v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/api) - Compiling cli v0.1.0 (/home/munenick/workspace/refmdio/refmd/api/crates/cli) -warning: `infrastructure` (lib) generated 5 warnings - Compiling refmd-bins v0.1.0 (/home/munenick/workspace/refmdio/refmd/api) - Finished `dev` profile [unoptimized + debuginfo] target(s) in 16.51s - Running `target/debug/refmd openapi export` {"openapi":"3.0.3","info":{"title":"presentation","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{docId}/files":{"post":{"tags":["Files"],"operationId":"upload_file","parameters":[{"name":"docId","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GetContentResponse"}}}}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}},"404":{"description":"Document key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentKeyResponse"}}}}}}},"/api/documents/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_document_key","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateDocumentKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDetailResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/tags":{"get":{"tags":["Tags"],"summary":"Get tags for a specific document (E2EE format)","operationId":"get_document_tags","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentTagsResponse"}}}}}},"put":{"tags":["Tags"],"summary":"Replace tags for a document (E2EE format)","operationId":"update_document_tags","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentTagsRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentTagsResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"400":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/e2ee/migrate":{"post":{"tags":["E2EE"],"summary":"Migrate user data to E2EE.","description":"This endpoint receives encryption keys from the client and encrypts\nall of the user's existing plaintext data on the server.\n\nThe operation is atomic - either all data is encrypted or none is.","operationId":"migrate_to_e2ee","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrateRequest"}}},"required":true},"responses":{"200":{"description":"Migration completed successfully","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MigrationResponse"}}}},"400":{"description":"Invalid request (e.g., missing DEK for document)"},"409":{"description":"Migration already completed"},"500":{"description":"Migration failed"}}}},"/api/me/e2ee/needs-migration":{"get":{"tags":["E2EE"],"summary":"Check if migration is needed for the current user.","operationId":"needs_migration","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/NeedsMigrationResponse"}}}}}}},"/api/me/e2ee/setup-complete":{"post":{"tags":["E2EE"],"operationId":"mark_e2ee_setup_complete","responses":{"204":{"description":""}}}},"/api/me/e2ee/status":{"get":{"tags":["E2EE"],"operationId":"get_e2ee_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/E2eeStatusResponse"}}}}}}},"/api/me/keys":{"get":{"tags":["E2EE"],"operationId":"get_my_public_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}},"post":{"tags":["E2EE"],"operationId":"register_public_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterPublicKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}}}}},"/api/me/master-key/backup":{"get":{"tags":["E2EE"],"operationId":"get_master_key_backup","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}},"404":{"description":"Master key backup not found"}}},"post":{"tags":["E2EE"],"operationId":"store_master_key_backup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreMasterKeyBackupRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MasterKeyBackupResponse"}}}}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/private-key/encrypted":{"get":{"tags":["E2EE"],"operationId":"get_encrypted_private_key","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}},"404":{"description":"Encrypted private key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_encrypted_private_key","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreEncryptedPrivateKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/EncryptedPrivateKeyResponse"}}}}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugin-assets":{"get":{"tags":["Plugins"],"operationId":"pluginsGetAsset","parameters":[{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Plugin asset"}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"description":"Optional plaintext content for E2EE workspaces","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/PublishRequest"}],"nullable":true}}},"required":false},"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/salt":{"get":{"tags":["Sharing"],"summary":"Get salt for password-protected share (for password challenge)","operationId":"get_share_salt","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Salt info for password-protected share","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}},"404":{"description":"Share key not found"}}},"post":{"tags":["E2EE"],"operationId":"store_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/keys/password-protected":{"post":{"tags":["E2EE"],"operationId":"store_password_protected_share_key","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StorePasswordProtectedShareKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareKeyResponse"}}}}}}},"/api/shares/{id}/salt":{"get":{"tags":["E2EE"],"operationId":"get_share_salt","parameters":[{"name":"id","in":"path","description":"Share ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareSaltResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/storage/ingest":{"post":{"tags":["Storage"],"operationId":"enqueue_ingest_events","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/IngestBatchRequest"}}},"required":true},"responses":{"202":{"description":"Events enqueued"},"400":{"description":"Invalid request"}}}},"/api/tags":{"get":{"tags":["Tags"],"summary":"List all tags in the workspace (E2EE format)","operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Base64 encoded encrypted tag for exact match filter","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ListTagsResponse"}}}}}}},"/api/users/{user_id}/keys":{"get":{"tags":["E2EE"],"operationId":"get_user_public_key","parameters":[{"name":"user_id","in":"path","description":"User ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserPublicKeyResponse"}}}},"404":{"description":"Public key not found"}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/keys":{"get":{"tags":["E2EE"],"operationId":"list_workspace_keys","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"post":{"tags":["E2EE"],"operationId":"store_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/StoreWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}}}}},"/api/workspaces/{id}/keys/me":{"get":{"tags":["E2EE"],"operationId":"get_my_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyResponse"}}}},"404":{"description":"Key not found"}}}},"/api/workspaces/{id}/keys/rotate":{"post":{"tags":["E2EE"],"operationId":"rotate_workspace_key","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RotateWorkspaceKeyResponse"}}}},"400":{"description":"Invalid request"},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/keys/version":{"get":{"tags":["E2EE"],"operationId":"get_workspace_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceKeyVersionResponse"}}}}}}},"/api/workspaces/{id}/keys/{version}":{"delete":{"tags":["E2EE"],"operationId":"delete_key_version","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","description":"Key version to delete","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DeleteKeyVersionResponse"}}}},"403":{"description":"Permission denied"}}}},"/api/workspaces/{id}/leave":{"post":{"tags":["Workspaces"],"operationId":"leave_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentDekPayload":{"type":"object","description":"DEK payload for document creation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"CreateDocumentRequest":{"type":"object","properties":{"dek":{"allOf":[{"$ref":"#/components/schemas/CreateDocumentDekPayload"}],"nullable":true},"encryptedTitle":{"type":"string","format":"byte","description":"Base64 encoded encrypted title (for E2EE clients)","nullable":true},"encryptedTitleNonce":{"type":"string","format":"byte","description":"Base64 encoded nonce for encrypted title","nullable":true},"parentId":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["documentId"],"properties":{"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK (encrypted with share key derived from password)","nullable":true},"expiresAt":{"type":"string","format":"date-time","nullable":true},"kdfParams":{"description":"KDF parameters (e.g., Argon2id settings)","nullable":true},"permission":{"type":"string","nullable":true},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for key derivation","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"DeleteKeyVersionResponse":{"type":"object","required":["workspaceId","keyVersion","deletedCount"],"properties":{"deletedCount":{"type":"integer","format":"int64","minimum":0},"keyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"encryptedTitle":{"type":"string","format":"byte","nullable":true},"encryptedTitleNonce":{"type":"string","format":"byte","nullable":true},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid","description":"Legacy alias for `workspace_id` kept for backward compatibility with older clients."},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentKeyResponse":{"type":"object","required":["documentId","encryptedDek","nonce","keyVersion","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"documentId":{"type":"string","format":"uuid"},"encryptedDek":{"type":"string","format":"byte"},"keyVersion":{"type":"integer","format":"int32"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","op"],"properties":{"encrypted_data":{"type":"string","format":"byte","description":"Base64 encoded encrypted data (for E2EE documents)","nullable":true},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required when encrypted_data is provided)","nullable":true},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"public_key":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 public key (for E2EE documents)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 signature (for E2EE documents)","nullable":true},"text":{"type":"string","description":"Plaintext to insert (for non-E2EE documents)","nullable":true}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","op"],"properties":{"encrypted_data":{"type":"string","format":"byte","description":"Base64 encoded encrypted data (for E2EE documents)","nullable":true},"length":{"type":"integer","minimum":0},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required when encrypted_data is provided)","nullable":true},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"public_key":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 public key (for E2EE documents)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded Ed25519 signature (for E2EE documents)","nullable":true},"text":{"type":"string","description":"Plaintext replacement (for non-E2EE documents)","nullable":true}}}],"description":"Patch operation for document content.\nFor plaintext mode: use `text` field.\nFor E2EE mode: use `encrypted_data` and `nonce` fields instead of `text`.","discriminator":{"propertyName":"op"}},"DocumentTagEntry":{"type":"object","description":"Tag entry in document tags response","required":["id","encryptedName","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"},"id":{"type":"string","format":"uuid"}}},"DocumentTagsResponse":{"type":"object","description":"Response for GET /api/documents/{id}/tags","required":["tags"],"properties":{"tags":{"type":"array","items":{"$ref":"#/components/schemas/DocumentTagEntry"}}}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"E2eeStatusResponse":{"type":"object","required":["isSetupCompleted"],"properties":{"isSetupCompleted":{"type":"boolean"}}},"EncryptedDekRequest":{"type":"object","description":"Encrypted DEK for a document (request).","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64-encoded encrypted DEK."},"nonce":{"type":"string","format":"byte","description":"Base64-encoded nonce."}}},"EncryptedPrivateKeyResponse":{"type":"object","required":["encryptedPrivateKey","nonce","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedPrivateKey":{"type":"string","format":"byte"},"nonce":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"EncryptedTagInput":{"type":"object","description":"Single encrypted tag in request","required":["encryptedName"],"properties":{"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GetContentResponse":{"type":"object","description":"Response for GET /api/documents/{id}/content\n- For E2EE documents: content is encrypted, nonce is present\n- For non-E2EE documents: content is plaintext Yjs state, nonce is None","required":["content"],"properties":{"content":{"type":"string","format":"byte","description":"Base64 encoded Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE)"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce for decryption (present for E2EE documents)","nullable":true}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"IngestBatchRequest":{"type":"object","required":["events"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/IngestEventRequest"}}}},"IngestEventRequest":{"type":"object","required":["repo_path","kind"],"properties":{"backend":{"type":"string","nullable":true},"content_hash":{"type":"string","nullable":true},"kind":{"$ref":"#/components/schemas/IngestKindParam"},"payload":{"nullable":true},"repo_path":{"type":"string"}}},"IngestKindParam":{"type":"string","enum":["upsert","delete"]},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KdfParamsResponse":{"type":"object","properties":{"iterations":{"type":"integer","format":"int32","nullable":true,"minimum":0},"memory":{"type":"integer","format":"int32","nullable":true,"minimum":0},"parallelism":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"ListTagsResponse":{"type":"object","description":"Response for GET /api/tags","required":["tags"],"properties":{"tags":{"type":"array","items":{"$ref":"#/components/schemas/TagEntry"}}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MasterKeyBackupResponse":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKey":{"type":"string","format":"byte"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string"},"salt":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"MemberEncryptedKekRequest":{"type":"object","description":"Encrypted KEK for a workspace member (request).","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64-encoded encrypted KEK."},"userId":{"type":"string","description":"User ID."}}},"MigrateRequest":{"type":"object","description":"Request to migrate user data to E2EE.","required":["workspaceKeks","documentDeks","encryptedWorkspaceKeks","encryptedDocumentDeks"],"properties":{"documentDeks":{"type":"object","description":"Document DEKs (Data Encryption Keys).\nMaps document_id (string) -> base64-encoded raw DEK.","additionalProperties":{"type":"string"}},"encryptedDocumentDeks":{"type":"object","description":"Encrypted DEKs to store for each document.\nMaps document_id (string) -> encrypted DEK with nonce.","additionalProperties":{"$ref":"#/components/schemas/EncryptedDekRequest"}},"encryptedWorkspaceKeks":{"type":"object","description":"Encrypted workspace KEKs to store for each member.\nMaps workspace_id (string) -> array of member encrypted KEKs.","additionalProperties":{"type":"array","items":{"$ref":"#/components/schemas/MemberEncryptedKekRequest"}}},"workspaceKeks":{"type":"object","description":"Workspace KEKs (Key Encryption Keys).\nMaps workspace_id (string) -> base64-encoded raw KEK.","additionalProperties":{"type":"string"}}}},"MigrationResponse":{"type":"object","description":"Response for migration result.","required":["documentsEncrypted","filesEncrypted","updatesCleared","status"],"properties":{"documentsEncrypted":{"type":"integer","description":"Number of documents encrypted.","minimum":0},"filesEncrypted":{"type":"integer","description":"Number of files with encrypted metadata.","minimum":0},"status":{"type":"string","description":"Migration status."},"updatesCleared":{"type":"integer","format":"int64","description":"Total number of Yjs updates cleared.","minimum":0}}},"NeedsMigrationResponse":{"type":"object","required":["needsMigration"],"properties":{"needsMigration":{"type":"boolean"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"},"description":"Patch operations. Each operation can be either plaintext (using `text` field)\nor encrypted (using `encryptedData` and `nonce` fields)."},"signature":{"type":"string","format":"byte","description":"Base64 encoded signature for integrity verification (optional for E2EE)","nullable":true}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishRequest":{"type":"object","description":"Request to publish a document. For E2EE workspaces, plaintext title and content\nmust be provided so public pages can be rendered without decryption.","properties":{"plaintextContent":{"type":"string","description":"Plaintext content (required for E2EE mode)","nullable":true},"plaintextTitle":{"type":"string","description":"Plaintext title (required for E2EE mode)","nullable":true}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterPublicKeyRequest":{"type":"object","required":["publicKey","keyType"],"properties":{"keyType":{"type":"string","description":"Key type (e.g., \"ecdh-p256\")","example":"ecdh-p256"},"publicKey":{"type":"string","format":"byte","description":"Base64 encoded public key"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"RotateDocumentKeyRequest":{"type":"object","description":"Request body for document DEK rotation","required":["encryptedDek","nonce"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded new encrypted DEK"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"RotateDocumentKeyResponse":{"type":"object","description":"Response for document DEK rotation","required":["documentId","newKeyVersion"],"properties":{"documentId":{"type":"string","format":"uuid"},"newKeyVersion":{"type":"integer","format":"int32"}}},"RotateWorkspaceKeyRequest":{"type":"object","description":"Request body for KEK rotation","required":["memberKeys"],"properties":{"memberKeys":{"type":"array","items":{"$ref":"#/components/schemas/RotationMemberKey"},"description":"Encrypted KEKs for all workspace members"}}},"RotateWorkspaceKeyResponse":{"type":"object","description":"Response for KEK rotation","required":["workspaceId","newKeyVersion","keysUpdated"],"properties":{"keysUpdated":{"type":"integer","minimum":0},"newKeyVersion":{"type":"integer","format":"int32"},"workspaceId":{"type":"string","format":"uuid"}}},"RotationMemberKey":{"type":"object","description":"A single member's encrypted KEK for key rotation","required":["userId","encryptedKek"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK for this member"},"userId":{"type":"string","format":"uuid","description":"User ID of the member"}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK (encrypted with share key)","nullable":true},"id":{"type":"string","format":"uuid"},"kdfParams":{"description":"KDF parameters for password-protected shares","nullable":true},"permission":{"type":"string"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for password-protected shares","nullable":true},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"scope":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareKeyResponse":{"type":"object","required":["shareId","encryptedDek","isPasswordProtected","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedDek":{"type":"string","format":"byte"},"isPasswordProtected":{"type":"boolean"},"kdfParams":{"allOf":[{"$ref":"#/components/schemas/KdfParamsResponse"}],"nullable":true},"salt":{"type":"string","format":"byte","nullable":true},"shareId":{"type":"string","format":"uuid"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"ShareSaltResponse":{"type":"object","description":"Response for share salt challenge (for password-protected shares)","required":["passwordProtected"],"properties":{"kdfParams":{"description":"KDF parameters for key derivation (only present if password-protected)","nullable":true},"passwordProtected":{"type":"boolean","description":"Whether this share is password-protected"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt for key derivation (only present if password-protected)","nullable":true}}},"SnapshotDetailResponse":{"type":"object","description":"Response for GET /api/documents/{id}/snapshots/{snapshotId}\n- For E2EE documents: content is encrypted, nonce is present\n- For non-E2EE documents: content is plaintext Yjs state, nonce is None","required":["id","content","createdAt"],"properties":{"content":{"type":"string","format":"byte","description":"Base64 encoded Yjs snapshot (encrypted for E2EE, plaintext for non-E2EE)"},"createdAt":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (present for E2EE documents)","nullable":true}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"nonce":{"type":"string","format":"byte","nullable":true},"notes":{"type":"string","nullable":true},"signature":{"type":"string","format":"byte","nullable":true}}},"StoreDocumentKeyRequest":{"type":"object","required":["encryptedDek","nonce","keyVersion"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreEncryptedPrivateKeyRequest":{"type":"object","required":["encryptedPrivateKey","nonce"],"properties":{"encryptedPrivateKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted private key"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce"}}},"StoreMasterKeyBackupRequest":{"type":"object","required":["encryptedKey","salt","kdfType","kdfParams"],"properties":{"encryptedKey":{"type":"string","format":"byte","description":"Base64 encoded encrypted master key"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"kdfType":{"type":"string","description":"KDF type (e.g., \"argon2id\", \"pbkdf2\")","example":"argon2id"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StorePasswordProtectedShareKeyRequest":{"type":"object","required":["encryptedDek","salt","kdfParams"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"},"kdfParams":{"$ref":"#/components/schemas/KdfParamsResponse"},"salt":{"type":"string","format":"byte","description":"Base64 encoded salt"}}},"StoreShareKeyRequest":{"type":"object","required":["encryptedDek"],"properties":{"encryptedDek":{"type":"string","format":"byte","description":"Base64 encoded encrypted DEK"}}},"StoreWorkspaceKeyRequest":{"type":"object","required":["encryptedKek","keyVersion"],"properties":{"encryptedKek":{"type":"string","format":"byte","description":"Base64 encoded encrypted KEK"},"keyVersion":{"type":"integer","format":"int32","description":"Key version (for key rotation tracking)"}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagEntry":{"type":"object","description":"Tag entry in list response (E2EE format)","required":["encryptedName","documentCount"],"properties":{"documentCount":{"type":"integer","format":"int64"},"encryptedName":{"type":"string","format":"byte","description":"Base64 encoded deterministically encrypted tag name"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string","description":"Document content (plaintext or Base64-encoded encrypted Yjs state for E2EE)"},"nonce":{"type":"string","format":"byte","description":"Base64 encoded nonce (required for E2EE content)","nullable":true},"signature":{"type":"string","format":"byte","description":"Base64 encoded signature for integrity verification (optional for E2EE)","nullable":true}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateDocumentTagsRequest":{"type":"object","description":"Request for PUT /api/documents/{id}/tags","required":["encryptedTags"],"properties":{"encryptedTags":{"type":"array","items":{"$ref":"#/components/schemas/EncryptedTagInput"}}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","description":"Multipart upload schema for OpenAPI","required":["file"],"properties":{"file":{"type":"string","format":"binary","description":"Encrypted file binary (.rme format)"},"metadata":{"type":"string","description":"JSON metadata containing encrypted file metadata","nullable":true}}},"UploadFileResponse":{"type":"object","description":"Response for file upload (E2EE format per design)","required":["id","encryptedHash","size"],"properties":{"encryptedHash":{"type":"string","description":"SHA256 hash of encrypted file content"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"}}},"UserPublicKeyResponse":{"type":"object","required":["publicKey","keyType","createdAt","updatedAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"keyType":{"type":"string"},"publicKey":{"type":"string","format":"byte"},"updatedAt":{"type":"string","format":"date-time"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceKeyResponse":{"type":"object","required":["id","workspaceId","userId","encryptedKek","keyVersion","createdAt"],"properties":{"createdAt":{"type":"string","format":"date-time"},"encryptedKek":{"type":"string","format":"byte"},"id":{"type":"string","format":"uuid"},"keyVersion":{"type":"integer","format":"int32"},"userId":{"type":"string","format":"uuid"},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceKeyVersionResponse":{"type":"object","required":["workspaceId"],"properties":{"keyVersion":{"type":"integer","format":"int32","nullable":true},"workspaceId":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"E2EE","description":"End-to-end encryption key management"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Storage","description":"Storage ingest APIs"},{"name":"Health","description":"System health checks"}]} diff --git a/app/src/shared/api/client/sdk.gen.ts b/app/src/shared/api/client/sdk.gen.ts index c69b54b9..981e1303 100644 --- a/app/src/shared/api/client/sdk.gen.ts +++ b/app/src/shared/api/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, ImportRepositoryData, ImportRepositoryResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetAssetData, PluginsGetAssetResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, EnqueueIngestEventsData, EnqueueIngestEventsResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, LeaveWorkspaceData, LeaveWorkspaceResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; +import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, UploadFileData, UploadFileResponse2, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetDocumentKeyData, GetDocumentKeyResponse, StoreDocumentKeyData, StoreDocumentKeyResponse, RotateDocumentKeyData, RotateDocumentKeyResponse2, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotData, GetDocumentSnapshotResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, GetDocumentTagsData, GetDocumentTagsResponse, UpdateDocumentTagsData, UpdateDocumentTagsResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, ImportRepositoryData, ImportRepositoryResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, MigrateToE2EeData, MigrateToE2EeResponse, NeedsMigrationResponse2, MarkE2EeSetupCompleteResponse, GetE2EeStatusResponse, GetMyPublicKeyResponse, RegisterPublicKeyData, RegisterPublicKeyResponse, GetMasterKeyBackupResponse, StoreMasterKeyBackupData, StoreMasterKeyBackupResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetEncryptedPrivateKeyResponse, StoreEncryptedPrivateKeyData, StoreEncryptedPrivateKeyResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetAssetData, PluginsGetAssetResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, GetShareSaltData, GetShareSaltResponse, ValidateShareTokenData, ValidateShareTokenResponse, GetShareKeyData, GetShareKeyResponse, StoreShareKeyData, StoreShareKeyResponse, StorePasswordProtectedShareKeyData, StorePasswordProtectedShareKeyResponse, GetShareSalt1Data, GetShareSalt1Response, DeleteShareData, DeleteShareResponse, EnqueueIngestEventsData, EnqueueIngestEventsResponse, ListTagsData, ListTagsResponse2, GetUserPublicKeyData, GetUserPublicKeyResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListWorkspaceKeysData, ListWorkspaceKeysResponse, StoreWorkspaceKeyData, StoreWorkspaceKeyResponse, GetMyWorkspaceKeyData, GetMyWorkspaceKeyResponse, RotateWorkspaceKeyData, RotateWorkspaceKeyResponse2, GetWorkspaceKeyVersionData, GetWorkspaceKeyVersionResponse, DeleteKeyVersionData, DeleteKeyVersionResponse2, LeaveWorkspaceData, LeaveWorkspaceResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; /** * @param data The data for the request. @@ -203,6 +203,25 @@ export const searchDocuments = (data: SearchDocumentsData = {}): CancelablePromi }); }; +/** + * @param data The data for the request. + * @param data.docId Document ID + * @param data.formData + * @returns UploadFileResponse File uploaded + * @throws ApiError + */ +export const uploadFile = (data: UploadFileData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/documents/{docId}/files', + path: { + docId: data.docId + }, + formData: data.formData, + mediaType: 'multipart/form-data' + }); +}; + /** * @param data The data for the request. * @param data.id Document ID @@ -297,7 +316,7 @@ export const getBacklinks = (data: GetBacklinksData): CancelablePromise => { @@ -401,6 +420,67 @@ export const duplicateDocument = (data: DuplicateDocumentData): CancelablePromis }); }; +/** + * @param data The data for the request. + * @param data.id Document ID + * @returns DocumentKeyResponse + * @throws ApiError + */ +export const getDocumentKey = (data: GetDocumentKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/documents/{id}/keys', + path: { + id: data.id + }, + errors: { + 404: 'Document key not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Document ID + * @param data.requestBody + * @returns DocumentKeyResponse + * @throws ApiError + */ +export const storeDocumentKey = (data: StoreDocumentKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/documents/{id}/keys', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @param data The data for the request. + * @param data.id Document ID + * @param data.requestBody + * @returns RotateDocumentKeyResponse + * @throws ApiError + */ +export const rotateDocumentKey = (data: RotateDocumentKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/documents/{id}/keys/rotate', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 400: 'Invalid request', + 403: 'Permission denied' + } + }); +}; + /** * @param data The data for the request. * @param data.id Document ID @@ -441,6 +521,28 @@ export const listDocumentSnapshots = (data: ListDocumentSnapshotsData): Cancelab }); }; +/** + * @param data The data for the request. + * @param data.id Document ID + * @param data.snapshotId Snapshot ID + * @param data.token Share token (optional) + * @returns SnapshotDetailResponse + * @throws ApiError + */ +export const getDocumentSnapshot = (data: GetDocumentSnapshotData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/documents/{id}/snapshots/{snapshot_id}', + path: { + id: data.id, + snapshot_id: data.snapshotId + }, + query: { + token: data.token + } + }); +}; + /** * @param data The data for the request. * @param data.id Document ID @@ -516,37 +618,59 @@ export const restoreDocumentSnapshot = (data: RestoreDocumentSnapshotData): Canc }; /** + * Get tags for a specific document (E2EE format) * @param data The data for the request. * @param data.id Document ID - * @returns Document + * @returns DocumentTagsResponse * @throws ApiError */ -export const unarchiveDocument = (data: UnarchiveDocumentData): CancelablePromise => { +export const getDocumentTags = (data: GetDocumentTagsData): CancelablePromise => { return __request(OpenAPI, { - method: 'POST', - url: '/api/documents/{id}/unarchive', + method: 'GET', + url: '/api/documents/{id}/tags', path: { id: data.id - }, - errors: { - 404: 'Document not found', - 409: 'Document is not archived' } }); }; /** + * Replace tags for a document (E2EE format) * @param data The data for the request. - * @param data.formData - * @returns UploadFileResponse File uploaded + * @param data.id Document ID + * @param data.requestBody + * @returns DocumentTagsResponse * @throws ApiError */ -export const uploadFile = (data: UploadFileData): CancelablePromise => { +export const updateDocumentTags = (data: UpdateDocumentTagsData): CancelablePromise => { + return __request(OpenAPI, { + method: 'PUT', + url: '/api/documents/{id}/tags', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @param data The data for the request. + * @param data.id Document ID + * @returns Document + * @throws ApiError + */ +export const unarchiveDocument = (data: UnarchiveDocumentData): CancelablePromise => { return __request(OpenAPI, { method: 'POST', - url: '/api/files', - formData: data.formData, - mediaType: 'multipart/form-data' + url: '/api/documents/{id}/unarchive', + path: { + id: data.id + }, + errors: { + 404: 'Document not found', + 409: 'Document is not archived' + } }); }; @@ -988,6 +1112,123 @@ export const revokeApiToken = (data: RevokeApiTokenData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/me/e2ee/migrate', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 400: 'Invalid request (e.g., missing DEK for document)', + 409: 'Migration already completed', + 500: 'Migration failed' + } + }); +}; + +/** + * Check if migration is needed for the current user. + * @returns NeedsMigrationResponse + * @throws ApiError + */ +export const needsMigration = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/me/e2ee/needs-migration' + }); +}; + +/** + * @returns void + * @throws ApiError + */ +export const markE2EeSetupComplete = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/me/e2ee/setup-complete' + }); +}; + +/** + * @returns E2eeStatusResponse + * @throws ApiError + */ +export const getE2EeStatus = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/me/e2ee/status' + }); +}; + +/** + * @returns UserPublicKeyResponse + * @throws ApiError + */ +export const getMyPublicKey = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/me/keys', + errors: { + 404: 'Public key not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.requestBody + * @returns UserPublicKeyResponse + * @throws ApiError + */ +export const registerPublicKey = (data: RegisterPublicKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/me/keys', + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @returns MasterKeyBackupResponse + * @throws ApiError + */ +export const getMasterKeyBackup = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/me/master-key/backup', + errors: { + 404: 'Master key backup not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.requestBody + * @returns MasterKeyBackupResponse + * @throws ApiError + */ +export const storeMasterKeyBackup = (data: StoreMasterKeyBackupData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/me/master-key/backup', + body: data.requestBody, + mediaType: 'application/json' + }); +}; + /** * @param data The data for the request. * @param data.requestBody @@ -1040,6 +1281,35 @@ export const sseUpdates = (): CancelablePromise => { }); }; +/** + * @returns EncryptedPrivateKeyResponse + * @throws ApiError + */ +export const getEncryptedPrivateKey = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/me/private-key/encrypted', + errors: { + 404: 'Encrypted private key not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.requestBody + * @returns EncryptedPrivateKeyResponse + * @throws ApiError + */ +export const storeEncryptedPrivateKey = (data: StoreEncryptedPrivateKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/me/private-key/encrypted', + body: data.requestBody, + mediaType: 'application/json' + }); +}; + /** * @returns UserShortcutResponse * @throws ApiError @@ -1253,6 +1523,7 @@ export const getPublishStatus = (data: GetPublishStatusData): CancelablePromise< /** * @param data The data for the request. * @param data.id Document ID + * @param data.requestBody Optional plaintext content for E2EE workspaces * @returns PublishResponse Published * @throws ApiError */ @@ -1262,7 +1533,9 @@ export const publishDocument = (data: PublishDocumentData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/shares/salt', + query: { + token: data.token + } + }); +}; + /** * @param data The data for the request. * @param data.token Share token @@ -1482,6 +1772,79 @@ export const validateShareToken = (data: ValidateShareTokenData): CancelableProm }); }; +/** + * @param data The data for the request. + * @param data.id Share ID + * @returns ShareKeyResponse + * @throws ApiError + */ +export const getShareKey = (data: GetShareKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/shares/{id}/keys', + path: { + id: data.id + }, + errors: { + 404: 'Share key not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Share ID + * @param data.requestBody + * @returns ShareKeyResponse + * @throws ApiError + */ +export const storeShareKey = (data: StoreShareKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/shares/{id}/keys', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @param data The data for the request. + * @param data.id Share ID + * @param data.requestBody + * @returns ShareKeyResponse + * @throws ApiError + */ +export const storePasswordProtectedShareKey = (data: StorePasswordProtectedShareKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/shares/{id}/keys/password-protected', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @param data The data for the request. + * @param data.id Share ID + * @returns ShareSaltResponse + * @throws ApiError + */ +export const getShareSalt1 = (data: GetShareSalt1Data): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/shares/{id}/salt', + path: { + id: data.id + } + }); +}; + /** * @param data The data for the request. * @param data.token Share token @@ -1517,12 +1880,13 @@ export const enqueueIngestEvents = (data: EnqueueIngestEventsData): CancelablePr }; /** + * List all tags in the workspace (E2EE format) * @param data The data for the request. - * @param data.q Filter contains - * @returns TagItem + * @param data.q Base64 encoded encrypted tag for exact match filter + * @returns ListTagsResponse * @throws ApiError */ -export const listTags = (data: ListTagsData = {}): CancelablePromise => { +export const listTags = (data: ListTagsData = {}): CancelablePromise => { return __request(OpenAPI, { method: 'GET', url: '/api/tags', @@ -1532,6 +1896,25 @@ export const listTags = (data: ListTagsData = {}): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/users/{user_id}/keys', + path: { + user_id: data.userId + }, + errors: { + 404: 'Public key not found' + } + }); +}; + /** * @param data The data for the request. * @param data.token Invitation token @@ -1702,6 +2085,120 @@ export const revokeInvitation = (data: RevokeInvitationData): CancelablePromise< }); }; +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @returns WorkspaceKeyResponse + * @throws ApiError + */ +export const listWorkspaceKeys = (data: ListWorkspaceKeysData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/workspaces/{id}/keys', + path: { + id: data.id + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @param data.requestBody + * @returns WorkspaceKeyResponse + * @throws ApiError + */ +export const storeWorkspaceKey = (data: StoreWorkspaceKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/workspaces/{id}/keys', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json' + }); +}; + +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @returns WorkspaceKeyResponse + * @throws ApiError + */ +export const getMyWorkspaceKey = (data: GetMyWorkspaceKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/workspaces/{id}/keys/me', + path: { + id: data.id + }, + errors: { + 404: 'Key not found' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @param data.requestBody + * @returns RotateWorkspaceKeyResponse + * @throws ApiError + */ +export const rotateWorkspaceKey = (data: RotateWorkspaceKeyData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/workspaces/{id}/keys/rotate', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 400: 'Invalid request', + 403: 'Permission denied' + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @returns WorkspaceKeyVersionResponse + * @throws ApiError + */ +export const getWorkspaceKeyVersion = (data: GetWorkspaceKeyVersionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/workspaces/{id}/keys/version', + path: { + id: data.id + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id Workspace ID + * @param data.version Key version to delete + * @returns DeleteKeyVersionResponse + * @throws ApiError + */ +export const deleteKeyVersion = (data: DeleteKeyVersionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'DELETE', + url: '/api/workspaces/{id}/keys/{version}', + path: { + id: data.id, + version: data.version + }, + errors: { + 403: 'Permission denied' + } + }); +}; + /** * @param data The data for the request. * @param data.id Workspace ID diff --git a/app/src/shared/api/client/types.gen.ts b/app/src/shared/api/client/types.gen.ts index e8da0e9f..94e49b0d 100644 --- a/app/src/shared/api/client/types.gen.ts +++ b/app/src/shared/api/client/types.gen.ts @@ -76,8 +76,35 @@ export type CheckIgnoredRequest = { path: string; }; +/** + * DEK payload for document creation + */ +export type CreateDocumentDekPayload = { + /** + * Base64 encoded encrypted DEK + */ + encryptedDek: string; + /** + * Key version + */ + keyVersion?: number; + /** + * Base64 encoded nonce + */ + nonce: string; +}; + export type CreateDocumentRequest = { - parent_id?: (string) | null; + dek?: ((CreateDocumentDekPayload) | null); + /** + * Base64 encoded encrypted title (for E2EE clients) + */ + encryptedTitle?: (string) | null; + /** + * Base64 encoded nonce for encrypted title + */ + encryptedTitleNonce?: (string) | null; + parentId?: (string) | null; title?: (string) | null; type?: (string) | null; }; @@ -100,9 +127,21 @@ export type CreateShareMountRequest = { }; export type CreateShareRequest = { - document_id: string; - expires_at?: (string) | null; + documentId: string; + /** + * Base64 encoded encrypted DEK (encrypted with share key derived from password) + */ + encryptedDek?: (string) | null; + expiresAt?: (string) | null; + /** + * KDF parameters (e.g., Argon2id settings) + */ + kdfParams?: unknown; permission?: (string) | null; + /** + * Base64 encoded salt for key derivation + */ + salt?: (string) | null; }; export type CreateShareResponse = { @@ -132,6 +171,12 @@ export type CreateWorkspaceRoleRequest = { priority?: (number) | null; }; +export type DeleteKeyVersionResponse = { + deletedCount: number; + keyVersion: number; + workspaceId: string; +}; + export type Document = { archived_at?: (string) | null; archived_by?: (string) | null; @@ -140,6 +185,8 @@ export type Document = { created_by?: (string) | null; created_by_plugin?: (string) | null; desired_path: string; + encryptedTitle?: (string) | null; + encryptedTitleNonce?: (string) | null; id: string; /** * Legacy alias for `workspace_id` kept for backward compatibility with older clients. @@ -158,23 +205,75 @@ export type DocumentArchiveBinary = Blob | File; export type DocumentDownloadBinary = Blob | File; +export type DocumentKeyResponse = { + createdAt: string; + documentId: string; + encryptedDek: string; + keyVersion: number; + nonce: string; + updatedAt: string; +}; + export type DocumentListResponse = { items: Array; }; +/** + * Patch operation for document content. + * For plaintext mode: use `text` field. + * For E2EE mode: use `encrypted_data` and `nonce` fields instead of `text`. + */ export type DocumentPatchOperationRequest = { + /** + * Base64 encoded encrypted data (for E2EE documents) + */ + encrypted_data?: (string) | null; + /** + * Base64 encoded nonce (required when encrypted_data is provided) + */ + nonce?: (string) | null; offset: number; op: 'insert'; - text: string; + /** + * Base64 encoded Ed25519 public key (for E2EE documents) + */ + public_key?: (string) | null; + /** + * Base64 encoded Ed25519 signature (for E2EE documents) + */ + signature?: (string) | null; + /** + * Plaintext to insert (for non-E2EE documents) + */ + text?: (string) | null; } | { length: number; offset: number; op: 'delete'; } | { + /** + * Base64 encoded encrypted data (for E2EE documents) + */ + encrypted_data?: (string) | null; length: number; + /** + * Base64 encoded nonce (required when encrypted_data is provided) + */ + nonce?: (string) | null; offset: number; op: 'replace'; - text: string; + /** + * Base64 encoded Ed25519 public key (for E2EE documents) + */ + public_key?: (string) | null; + /** + * Base64 encoded Ed25519 signature (for E2EE documents) + */ + signature?: (string) | null; + /** + * Plaintext replacement (for non-E2EE documents) + */ + text?: (string) | null; }; export namespace DocumentPatchOperationRequest { @@ -183,6 +282,25 @@ export namespace DocumentPatchOperationRequest { } } +/** + * Tag entry in document tags response + */ +export type DocumentTagEntry = { + createdAt: string; + /** + * Base64 encoded deterministically encrypted tag name + */ + encryptedName: string; + id: string; +}; + +/** + * Response for GET /api/documents/{id}/tags + */ +export type DocumentTagsResponse = { + tags: Array; +}; + export type DownloadDocumentQuery = { format?: DownloadFormat; token?: (string) | null; @@ -199,6 +317,41 @@ export type DuplicateDocumentRequest = { title?: (string) | null; }; +export type E2eeStatusResponse = { + isSetupCompleted: boolean; +}; + +/** + * Encrypted DEK for a document (request). + */ +export type EncryptedDekRequest = { + /** + * Base64-encoded encrypted DEK. + */ + encryptedDek: string; + /** + * Base64-encoded nonce. + */ + nonce: string; +}; + +export type EncryptedPrivateKeyResponse = { + createdAt: string; + encryptedPrivateKey: string; + nonce: string; + updatedAt: string; +}; + +/** + * Single encrypted tag in request + */ +export type EncryptedTagInput = { + /** + * Base64 encoded deterministically encrypted tag name + */ + encryptedName: string; +}; + export type ExecBody = { payload?: unknown; }; @@ -210,6 +363,22 @@ export type ExecResultResponse = { ok: boolean; }; +/** + * Response for GET /api/documents/{id}/content + * - For E2EE documents: content is encrypted, nonce is present + * - For non-E2EE documents: content is plaintext Yjs state, nonce is None + */ +export type GetContentResponse = { + /** + * Base64 encoded Yjs snapshot bytes (encrypted for E2EE, plaintext for non-E2EE) + */ + content: string; + /** + * Base64 encoded nonce for decryption (present for E2EE documents) + */ + nonce?: (string) | null; +}; + export type GitChangeItem = { path: string; status: string; @@ -348,6 +517,12 @@ export type InstallResponse = { version: string; }; +export type KdfParamsResponse = { + iterations?: (number) | null; + memory?: (number) | null; + parallelism?: (number) | null; +}; + export type KvValueBody = { value: unknown; }; @@ -356,6 +531,13 @@ export type KvValueResponse = { value: unknown; }; +/** + * Response for GET /api/tags + */ +export type ListTagsResponse = { + tags: Array; +}; + export type LoginRequest = { email: string; password: string; @@ -381,10 +563,93 @@ export type ManifestItem = { version: string; }; +export type MasterKeyBackupResponse = { + createdAt: string; + encryptedKey: string; + kdfParams: KdfParamsResponse; + kdfType: string; + salt: string; + updatedAt: string; +}; + export type MaterializeResponse = { created: number; }; +/** + * Encrypted KEK for a workspace member (request). + */ +export type MemberEncryptedKekRequest = { + /** + * Base64-encoded encrypted KEK. + */ + encryptedKek: string; + /** + * User ID. + */ + userId: string; +}; + +/** + * Request to migrate user data to E2EE. + */ +export type MigrateRequest = { + /** + * Document DEKs (Data Encryption Keys). + * Maps document_id (string) -> base64-encoded raw DEK. + */ + documentDeks: { + [key: string]: string; + }; + /** + * Encrypted DEKs to store for each document. + * Maps document_id (string) -> encrypted DEK with nonce. + */ + encryptedDocumentDeks: { + [key: string]: EncryptedDekRequest; + }; + /** + * Encrypted workspace KEKs to store for each member. + * Maps workspace_id (string) -> array of member encrypted KEKs. + */ + encryptedWorkspaceKeks: { + [key: string]: Array; + }; + /** + * Workspace KEKs (Key Encryption Keys). + * Maps workspace_id (string) -> base64-encoded raw KEK. + */ + workspaceKeks: { + [key: string]: string; + }; +}; + +/** + * Response for migration result. + */ +export type MigrationResponse = { + /** + * Number of documents encrypted. + */ + documentsEncrypted: number; + /** + * Number of files with encrypted metadata. + */ + filesEncrypted: number; + /** + * Migration status. + */ + status: string; + /** + * Total number of Yjs updates cleared. + */ + updatesCleared: number; +}; + +export type NeedsMigrationResponse = { + needsMigration: boolean; +}; + export type OAuthLoginRequest = { code?: (string) | null; credential?: (string) | null; @@ -414,7 +679,15 @@ export type OutgoingLinksResponse = { }; export type PatchDocumentContentRequest = { - operations: Array; + /** + * Patch operations. Each operation can be either plaintext (using `text` field) + * or encrypted (using `encryptedData` and `nonce` fields). + */ + operations?: Array; + /** + * Base64 encoded signature for integrity verification (optional for E2EE) + */ + signature?: (string) | null; }; export type PermissionOverridePayload = { @@ -435,6 +708,21 @@ export type PublicDocumentSummary = { updated_at: string; }; +/** + * Request to publish a document. For E2EE workspaces, plaintext title and content + * must be provided so public pages can be rendered without decryption. + */ +export type PublishRequest = { + /** + * Plaintext content (required for E2EE mode) + */ + plaintextContent?: (string) | null; + /** + * Plaintext title (required for E2EE mode) + */ + plaintextTitle?: (string) | null; +}; + export type PublishResponse = { public_url: string; slug: string; @@ -448,6 +736,17 @@ export type RefreshResponse = { access_token: string; }; +export type RegisterPublicKeyRequest = { + /** + * Key type (e.g., "ecdh-p256") + */ + keyType: string; + /** + * Base64 encoded public key + */ + publicKey: string; +}; + export type RegisterRequest = { email: string; name: string; @@ -485,6 +784,61 @@ export type RenderResponseBody = { placeholders?: Array; }; +/** + * Request body for document DEK rotation + */ +export type RotateDocumentKeyRequest = { + /** + * Base64 encoded new encrypted DEK + */ + encryptedDek: string; + /** + * Base64 encoded nonce + */ + nonce: string; +}; + +/** + * Response for document DEK rotation + */ +export type RotateDocumentKeyResponse = { + documentId: string; + newKeyVersion: number; +}; + +/** + * Request body for KEK rotation + */ +export type RotateWorkspaceKeyRequest = { + /** + * Encrypted KEKs for all workspace members + */ + memberKeys: Array; +}; + +/** + * Response for KEK rotation + */ +export type RotateWorkspaceKeyResponse = { + keysUpdated: number; + newKeyVersion: number; + workspaceId: string; +}; + +/** + * A single member's encrypted KEK for key rotation + */ +export type RotationMemberKey = { + /** + * Base64 encoded encrypted KEK for this member + */ + encryptedKek: string; + /** + * User ID of the member + */ + userId: string; +}; + export type SearchResult = { document_type: string; id: string; @@ -520,8 +874,20 @@ export type ShareBrowseTreeItem = { export type ShareDocumentResponse = { content?: (string) | null; + /** + * Base64 encoded encrypted DEK (encrypted with share key) + */ + encryptedDek?: (string) | null; id: string; + /** + * KDF parameters for password-protected shares + */ + kdfParams?: unknown; permission: string; + /** + * Base64 encoded salt for password-protected shares + */ + salt?: (string) | null; title: string; }; @@ -535,6 +901,15 @@ export type ShareItem = { url: string; }; +export type ShareKeyResponse = { + createdAt: string; + encryptedDek: string; + isPasswordProtected: boolean; + kdfParams?: ((KdfParamsResponse) | null); + salt?: (string) | null; + shareId: string; +}; + export type ShareMountItem = { created_at: string; id: string; @@ -546,6 +921,42 @@ export type ShareMountItem = { token: string; }; +/** + * Response for share salt challenge (for password-protected shares) + */ +export type ShareSaltResponse = { + /** + * KDF parameters for key derivation (only present if password-protected) + */ + kdfParams?: unknown; + /** + * Whether this share is password-protected + */ + passwordProtected: boolean; + /** + * Base64 encoded salt for key derivation (only present if password-protected) + */ + salt?: (string) | null; +}; + +/** + * Response for GET /api/documents/{id}/snapshots/{snapshotId} + * - For E2EE documents: content is encrypted, nonce is present + * - For non-E2EE documents: content is plaintext Yjs state, nonce is None + */ +export type SnapshotDetailResponse = { + /** + * Base64 encoded Yjs snapshot (encrypted for E2EE, plaintext for non-E2EE) + */ + content: string; + createdAt: string; + id: string; + /** + * Base64 encoded nonce (present for E2EE documents) + */ + nonce?: (string) | null; +}; + export type SnapshotDiffBaseParam = 'auto' | 'current' | 'previous'; export type SnapshotDiffKind = 'current' | 'snapshot'; @@ -579,16 +990,96 @@ export type SnapshotSummary = { id: string; kind: string; label: string; + nonce?: (string) | null; notes?: (string) | null; + signature?: (string) | null; +}; + +export type StoreDocumentKeyRequest = { + /** + * Base64 encoded encrypted DEK + */ + encryptedDek: string; + /** + * Key version + */ + keyVersion: number; + /** + * Base64 encoded nonce + */ + nonce: string; +}; + +export type StoreEncryptedPrivateKeyRequest = { + /** + * Base64 encoded encrypted private key + */ + encryptedPrivateKey: string; + /** + * Base64 encoded nonce + */ + nonce: string; +}; + +export type StoreMasterKeyBackupRequest = { + /** + * Base64 encoded encrypted master key + */ + encryptedKey: string; + kdfParams: KdfParamsResponse; + /** + * KDF type (e.g., "argon2id", "pbkdf2") + */ + kdfType: string; + /** + * Base64 encoded salt + */ + salt: string; +}; + +export type StorePasswordProtectedShareKeyRequest = { + /** + * Base64 encoded encrypted DEK + */ + encryptedDek: string; + kdfParams: KdfParamsResponse; + /** + * Base64 encoded salt + */ + salt: string; +}; + +export type StoreShareKeyRequest = { + /** + * Base64 encoded encrypted DEK + */ + encryptedDek: string; +}; + +export type StoreWorkspaceKeyRequest = { + /** + * Base64 encoded encrypted KEK + */ + encryptedKek: string; + /** + * Key version (for key rotation tracking) + */ + keyVersion: number; }; export type SwitchWorkspaceResponse = { access_token: string; }; -export type TagItem = { - count: number; - name: string; +/** + * Tag entry in list response (E2EE format) + */ +export type TagEntry = { + documentCount: number; + /** + * Base64 encoded deterministically encrypted tag name + */ + encryptedName: string; }; export type TextDiffLine = { @@ -612,7 +1103,18 @@ export type UninstallBody = { }; export type UpdateDocumentContentRequest = { + /** + * Document content (plaintext or Base64-encoded encrypted Yjs state for E2EE) + */ content: string; + /** + * Base64 encoded nonce (required for E2EE content) + */ + nonce?: (string) | null; + /** + * Base64 encoded signature for integrity verification (optional for E2EE) + */ + signature?: (string) | null; }; export type UpdateDocumentRequest = { @@ -620,6 +1122,13 @@ export type UpdateDocumentRequest = { title?: (string) | null; }; +/** + * Request for PUT /api/documents/{id}/tags + */ +export type UpdateDocumentTagsRequest = { + encryptedTags: Array; +}; + export type UpdateGitConfigRequest = { auth_data?: unknown; auth_type?: (string) | null; @@ -659,17 +1168,37 @@ export type UpdateWorkspaceRoleRequest = { priority?: (number) | null; }; +/** + * Multipart upload schema for OpenAPI + */ export type UploadFileMultipart = { - document_id: string; + /** + * Encrypted file binary (.rme format) + */ file: Blob | File; + /** + * JSON metadata containing encrypted file metadata + */ + metadata?: (string) | null; }; +/** + * Response for file upload (E2EE format per design) + */ export type UploadFileResponse = { - content_type?: (string) | null; - filename: string; + /** + * SHA256 hash of encrypted file content + */ + encryptedHash: string; id: string; size: number; - url: string; +}; + +export type UserPublicKeyResponse = { + createdAt: string; + keyType: string; + publicKey: string; + updatedAt: string; }; export type UserResponse = { @@ -706,6 +1235,20 @@ export type WorkspaceInvitationResponse = { workspace_id: string; }; +export type WorkspaceKeyResponse = { + createdAt: string; + encryptedKek: string; + id: string; + keyVersion: number; + userId: string; + workspaceId: string; +}; + +export type WorkspaceKeyVersionResponse = { + keyVersion?: (number) | null; + workspaceId: string; +}; + export type WorkspaceMemberResponse = { custom_role_id?: (string) | null; email: string; @@ -842,6 +1385,16 @@ export type SearchDocumentsData = { export type SearchDocumentsResponse = (Array); +export type UploadFileData = { + /** + * Document ID + */ + docId: string; + formData: UploadFileMultipart; +}; + +export type UploadFileResponse2 = (UploadFileResponse); + export type GetDocumentData = { /** * Document ID @@ -899,7 +1452,7 @@ export type GetDocumentContentData = { id: string; }; -export type GetDocumentContentResponse = (unknown); +export type GetDocumentContentResponse = (GetContentResponse); export type UpdateDocumentContentData = { /** @@ -956,6 +1509,35 @@ export type DuplicateDocumentData = { export type DuplicateDocumentResponse = (Document); +export type GetDocumentKeyData = { + /** + * Document ID + */ + id: string; +}; + +export type GetDocumentKeyResponse = (DocumentKeyResponse); + +export type StoreDocumentKeyData = { + /** + * Document ID + */ + id: string; + requestBody: StoreDocumentKeyRequest; +}; + +export type StoreDocumentKeyResponse = (DocumentKeyResponse); + +export type RotateDocumentKeyData = { + /** + * Document ID + */ + id: string; + requestBody: RotateDocumentKeyRequest; +}; + +export type RotateDocumentKeyResponse2 = (RotateDocumentKeyResponse); + export type GetOutgoingLinksData = { /** * Document ID @@ -986,6 +1568,23 @@ export type ListDocumentSnapshotsData = { export type ListDocumentSnapshotsResponse = (SnapshotListResponse); +export type GetDocumentSnapshotData = { + /** + * Document ID + */ + id: string; + /** + * Snapshot ID + */ + snapshotId: string; + /** + * Share token (optional) + */ + token?: (string) | null; +}; + +export type GetDocumentSnapshotResponse = (SnapshotDetailResponse); + export type GetDocumentSnapshotDiffData = { /** * Base comparison to use when compare is not provided (auto|current|previous) @@ -1045,20 +1644,33 @@ export type RestoreDocumentSnapshotData = { export type RestoreDocumentSnapshotResponse = (SnapshotRestoreResponse); -export type UnarchiveDocumentData = { +export type GetDocumentTagsData = { /** * Document ID */ id: string; }; -export type UnarchiveDocumentResponse = (Document); +export type GetDocumentTagsResponse = (DocumentTagsResponse); -export type UploadFileData = { - formData: UploadFileMultipart; +export type UpdateDocumentTagsData = { + /** + * Document ID + */ + id: string; + requestBody: UpdateDocumentTagsRequest; }; -export type UploadFileResponse2 = (UploadFileResponse); +export type UpdateDocumentTagsResponse = (DocumentTagsResponse); + +export type UnarchiveDocumentData = { + /** + * Document ID + */ + id: string; +}; + +export type UnarchiveDocumentResponse = (Document); export type GetFileByNameData = { /** @@ -1219,6 +1831,34 @@ export type RevokeApiTokenData = { export type RevokeApiTokenResponse = (void); +export type MigrateToE2EeData = { + requestBody: MigrateRequest; +}; + +export type MigrateToE2EeResponse = (MigrationResponse); + +export type NeedsMigrationResponse2 = (NeedsMigrationResponse); + +export type MarkE2EeSetupCompleteResponse = (void); + +export type GetE2EeStatusResponse = (E2eeStatusResponse); + +export type GetMyPublicKeyResponse = (UserPublicKeyResponse); + +export type RegisterPublicKeyData = { + requestBody: RegisterPublicKeyRequest; +}; + +export type RegisterPublicKeyResponse = (UserPublicKeyResponse); + +export type GetMasterKeyBackupResponse = (MasterKeyBackupResponse); + +export type StoreMasterKeyBackupData = { + requestBody: StoreMasterKeyBackupRequest; +}; + +export type StoreMasterKeyBackupResponse = (MasterKeyBackupResponse); + export type PluginsInstallFromUrlData = { requestBody: InstallFromUrlBody; }; @@ -1235,6 +1875,14 @@ export type PluginsUninstallResponse = (void); export type SseUpdatesResponse = (unknown); +export type GetEncryptedPrivateKeyResponse = (EncryptedPrivateKeyResponse); + +export type StoreEncryptedPrivateKeyData = { + requestBody: StoreEncryptedPrivateKeyRequest; +}; + +export type StoreEncryptedPrivateKeyResponse = (EncryptedPrivateKeyResponse); + export type GetUserShortcutsResponse = (UserShortcutResponse); export type UpdateUserShortcutsData = { @@ -1385,6 +2033,10 @@ export type PublishDocumentData = { * Document ID */ id: string; + /** + * Optional plaintext content for E2EE workspaces + */ + requestBody?: ((PublishRequest) | null); }; export type PublishDocumentResponse = (PublishResponse); @@ -1494,6 +2146,15 @@ export type DeleteShareMountData = { export type DeleteShareMountResponse = (void); +export type GetShareSaltData = { + /** + * Share token + */ + token: string; +}; + +export type GetShareSaltResponse = (ShareSaltResponse); + export type ValidateShareTokenData = { /** * Share token @@ -1503,6 +2164,44 @@ export type ValidateShareTokenData = { export type ValidateShareTokenResponse = (ShareDocumentResponse); +export type GetShareKeyData = { + /** + * Share ID + */ + id: string; +}; + +export type GetShareKeyResponse = (ShareKeyResponse); + +export type StoreShareKeyData = { + /** + * Share ID + */ + id: string; + requestBody: StoreShareKeyRequest; +}; + +export type StoreShareKeyResponse = (ShareKeyResponse); + +export type StorePasswordProtectedShareKeyData = { + /** + * Share ID + */ + id: string; + requestBody: StorePasswordProtectedShareKeyRequest; +}; + +export type StorePasswordProtectedShareKeyResponse = (ShareKeyResponse); + +export type GetShareSalt1Data = { + /** + * Share ID + */ + id: string; +}; + +export type GetShareSalt1Response = (ShareSaltResponse); + export type DeleteShareData = { /** * Share token @@ -1520,12 +2219,21 @@ export type EnqueueIngestEventsResponse = (unknown); export type ListTagsData = { /** - * Filter contains + * Base64 encoded encrypted tag for exact match filter */ q?: (string) | null; }; -export type ListTagsResponse = (Array); +export type ListTagsResponse2 = (ListTagsResponse); + +export type GetUserPublicKeyData = { + /** + * User ID + */ + userId: string; +}; + +export type GetUserPublicKeyResponse = (UserPublicKeyResponse); export type AcceptInvitationData = { /** @@ -1617,6 +2325,66 @@ export type RevokeInvitationData = { export type RevokeInvitationResponse = (WorkspaceInvitationResponse); +export type ListWorkspaceKeysData = { + /** + * Workspace ID + */ + id: string; +}; + +export type ListWorkspaceKeysResponse = (Array); + +export type StoreWorkspaceKeyData = { + /** + * Workspace ID + */ + id: string; + requestBody: StoreWorkspaceKeyRequest; +}; + +export type StoreWorkspaceKeyResponse = (WorkspaceKeyResponse); + +export type GetMyWorkspaceKeyData = { + /** + * Workspace ID + */ + id: string; +}; + +export type GetMyWorkspaceKeyResponse = (WorkspaceKeyResponse); + +export type RotateWorkspaceKeyData = { + /** + * Workspace ID + */ + id: string; + requestBody: RotateWorkspaceKeyRequest; +}; + +export type RotateWorkspaceKeyResponse2 = (RotateWorkspaceKeyResponse); + +export type GetWorkspaceKeyVersionData = { + /** + * Workspace ID + */ + id: string; +}; + +export type GetWorkspaceKeyVersionResponse = (WorkspaceKeyVersionResponse); + +export type DeleteKeyVersionData = { + /** + * Workspace ID + */ + id: string; + /** + * Key version to delete + */ + version: number; +}; + +export type DeleteKeyVersionResponse2 = (DeleteKeyVersionResponse); + export type LeaveWorkspaceData = { /** * Workspace ID From 882daf3bfe509ef237714d49c7fc10624345cf71 Mon Sep 17 00:00:00 2001 From: munenick Date: Fri, 9 Jan 2026 09:09:23 +0900 Subject: [PATCH 09/45] add: key management --- app/package-lock.json | 101 ++++++++++ app/package.json | 6 + app/src/features/e2ee/index.ts | 11 ++ .../e2ee/lib/crypto/__tests__/bip39.test.ts | 186 +++++++++++++++++ .../e2ee/lib/crypto/__tests__/ed25519.test.ts | 187 ++++++++++++++++++ .../lib/crypto/__tests__/xchacha20.test.ts | 143 ++++++++++++++ app/src/features/e2ee/lib/crypto/argon2.ts | 117 +++++++++++ app/src/features/e2ee/lib/crypto/bip39.ts | 150 ++++++++++++++ .../features/e2ee/lib/crypto/canonicalize.ts | 67 +++++++ app/src/features/e2ee/lib/crypto/ecdh.ts | 168 ++++++++++++++++ app/src/features/e2ee/lib/crypto/ed25519.ts | 180 +++++++++++++++++ app/src/features/e2ee/lib/crypto/hkdf.ts | 92 +++++++++ app/src/features/e2ee/lib/crypto/index.ts | 111 +++++++++++ app/src/features/e2ee/lib/crypto/pbkdf2.ts | 91 +++++++++ app/src/features/e2ee/lib/crypto/sodium.ts | 30 +++ app/src/features/e2ee/lib/crypto/xchacha20.ts | 179 +++++++++++++++++ app/src/features/e2ee/lib/types/errors.ts | 140 +++++++++++++ .../features/e2ee/lib/types/file-format.ts | 126 ++++++++++++ app/src/features/e2ee/lib/types/index.ts | 8 + app/src/features/e2ee/lib/types/keys.ts | 154 +++++++++++++++ app/src/features/e2ee/lib/types/messages.ts | 124 ++++++++++++ 21 files changed, 2371 insertions(+) create mode 100644 app/src/features/e2ee/index.ts create mode 100644 app/src/features/e2ee/lib/crypto/__tests__/bip39.test.ts create mode 100644 app/src/features/e2ee/lib/crypto/__tests__/ed25519.test.ts create mode 100644 app/src/features/e2ee/lib/crypto/__tests__/xchacha20.test.ts create mode 100644 app/src/features/e2ee/lib/crypto/argon2.ts create mode 100644 app/src/features/e2ee/lib/crypto/bip39.ts create mode 100644 app/src/features/e2ee/lib/crypto/canonicalize.ts create mode 100644 app/src/features/e2ee/lib/crypto/ecdh.ts create mode 100644 app/src/features/e2ee/lib/crypto/ed25519.ts create mode 100644 app/src/features/e2ee/lib/crypto/hkdf.ts create mode 100644 app/src/features/e2ee/lib/crypto/index.ts create mode 100644 app/src/features/e2ee/lib/crypto/pbkdf2.ts create mode 100644 app/src/features/e2ee/lib/crypto/sodium.ts create mode 100644 app/src/features/e2ee/lib/crypto/xchacha20.ts create mode 100644 app/src/features/e2ee/lib/types/errors.ts create mode 100644 app/src/features/e2ee/lib/types/file-format.ts create mode 100644 app/src/features/e2ee/lib/types/index.ts create mode 100644 app/src/features/e2ee/lib/types/keys.ts create mode 100644 app/src/features/e2ee/lib/types/messages.ts diff --git a/app/package-lock.json b/app/package-lock.json index 26d72bb8..619d6ead 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -16,6 +16,7 @@ "@codemirror/state": "^6.5.3", "@codemirror/view": "^6.39.9", "@lezer/markdown": "^1.6.3", + "@noble/curves": "^2.0.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12", @@ -40,10 +41,14 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", + "argon2-browser": "^1.18.0", + "bip39": "^3.1.0", + "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "is-hotkey": "^0.2.0", + "libsodium-wrappers-sumo": "^0.8.0", "lucide-react": "^0.544.0", "morphdom": "^2.7.7", "react": "^19.0.0", @@ -69,6 +74,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/is-hotkey": "^0.1.10", + "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/node": "^22.18.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", @@ -3465,6 +3471,33 @@ "@tybys/wasm-util": "^0.10.0" } }, + "node_modules/@noble/curves": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.0.1.tgz", + "integrity": "sha512-vs1Az2OOTBiP4q0pwjW5aF0xp9n4MxVrmkFBxc6EKZc6ddYx5gaZiAsZoq0uRRXWbi3AT/sBqn05eRPtn1JCPw==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.0.1" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.0.1.tgz", + "integrity": "sha512-XlOlEbQcE9fmuXxrVTXCTlG2nlRXa9Rj3rr5Ue/+tX+nmkgbX720YHh0VR3hBF9xDvwnb8D2shVGOwNx+ulArw==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -6902,6 +6935,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/libsodium-wrappers": { + "version": "0.7.14", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers/-/libsodium-wrappers-0.7.14.tgz", + "integrity": "sha512-5Kv68fXuXK0iDuUir1WPGw2R9fOZUlYlSAa0ztMcL0s0BfIDTqg9GXz8K30VJpPP3sxWhbolnQma2x+/TfkzDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/libsodium-wrappers-sumo": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/@types/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.7.8.tgz", + "integrity": "sha512-N2+df4MB/A+W0RAcTw7A5oxKgzD+Vh6Ye7lfjWIi5SdTzVLfHPzxUjhwPqHLO5Ev9fv/+VHl+sUaUuTg4fUPqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/libsodium-wrappers": "*" + } + }, "node_modules/@types/node": { "version": "22.18.8", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.8.tgz", @@ -7975,6 +8025,12 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/argon2-browser": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", + "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==", + "license": "MIT" + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -8349,6 +8405,27 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bip39": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/bip39/-/bip39-3.1.0.tgz", + "integrity": "sha512-c9kiwdk45Do5GL0vJMe7tS95VjCii65mYAH7DfWl3uW8AVzXKQVUm64i3hzVybBDMp9r7j9iNxR85+ul8MdN/A==", + "license": "ISC", + "dependencies": { + "@noble/hashes": "^1.2.0" + } + }, + "node_modules/bip39/node_modules/@noble/hashes": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz", + "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==", + "license": "MIT", + "engines": { + "node": "^14.21.3 || >=16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/boolbase": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", @@ -8627,6 +8704,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canonicalize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/canonicalize/-/canonicalize-2.1.0.tgz", + "integrity": "sha512-F705O3xrsUtgt98j7leetNhTWPe+5S72rlL5O4jA1pKqBVQ/dT1O1D6PFxmSXvc0SUOinWS57DKx0I3CHrXJHQ==", + "license": "Apache-2.0", + "bin": { + "canonicalize": "bin/canonicalize.js" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -12774,6 +12860,21 @@ "url": "https://github.com/sponsors/dmonad" } }, + "node_modules/libsodium-sumo": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/libsodium-sumo/-/libsodium-sumo-0.8.0.tgz", + "integrity": "sha512-Afy7Ya+jUT+JeBx93Vk83tFhmhOjLN521dVPYKi/KiLdHoSsa2j7qf9gxZmvo0HpVxlqhdrLX6nHhVPrfMqRhA==", + "license": "ISC" + }, + "node_modules/libsodium-wrappers-sumo": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/libsodium-wrappers-sumo/-/libsodium-wrappers-sumo-0.8.0.tgz", + "integrity": "sha512-IZAtgHPFnggh9tZNc1F3KgT3fkEcKeiUFh4Af1peH8zlK8A7EB7BcHianoS6IKIzpV28XbhmB/Rh9u7xHwy75g==", + "license": "ISC", + "dependencies": { + "libsodium-sumo": "^0.8.0" + } + }, "node_modules/lightningcss": { "version": "1.30.1", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", diff --git a/app/package.json b/app/package.json index 37d932be..84289e6a 100644 --- a/app/package.json +++ b/app/package.json @@ -25,6 +25,7 @@ "@codemirror/state": "^6.5.3", "@codemirror/view": "^6.39.9", "@lezer/markdown": "^1.6.3", + "@noble/curves": "^2.0.1", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-collapsible": "^1.1.12", @@ -49,10 +50,14 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", + "argon2-browser": "^1.18.0", + "bip39": "^3.1.0", + "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", "is-hotkey": "^0.2.0", + "libsodium-wrappers-sumo": "^0.8.0", "lucide-react": "^0.544.0", "morphdom": "^2.7.7", "react": "^19.0.0", @@ -83,6 +88,7 @@ "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.2.0", "@types/is-hotkey": "^0.1.10", + "@types/libsodium-wrappers-sumo": "^0.7.8", "@types/node": "^22.18.1", "@types/react": "^19.0.8", "@types/react-dom": "^19.0.3", diff --git a/app/src/features/e2ee/index.ts b/app/src/features/e2ee/index.ts new file mode 100644 index 00000000..960fa0cb --- /dev/null +++ b/app/src/features/e2ee/index.ts @@ -0,0 +1,11 @@ +/** + * E2EE Feature Module + * + * End-to-End Encryption for RefMD. + */ + +// Crypto primitives +export * from './lib/crypto' + +// Type definitions +export * from './lib/types' diff --git a/app/src/features/e2ee/lib/crypto/__tests__/bip39.test.ts b/app/src/features/e2ee/lib/crypto/__tests__/bip39.test.ts new file mode 100644 index 00000000..d94f2b83 --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/__tests__/bip39.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest' +import { + generateRecoveryKey, + validateRecoveryKey, + recoveryKeyToUmk, + umkToRecoveryKey, + getWordsAtIndices, + verifyWords, + generateVerificationIndices, + getWordList, + WORD_COUNT, + ENTROPY_BYTES, +} from '../bip39' + +describe('BIP39 Recovery Key', () => { + describe('generateRecoveryKey', () => { + it('should generate 24 words', () => { + const mnemonic = generateRecoveryKey() + const words = mnemonic.split(' ') + expect(words.length).toBe(WORD_COUNT) + }) + + it('should generate valid mnemonic', () => { + const mnemonic = generateRecoveryKey() + expect(validateRecoveryKey(mnemonic)).toBe(true) + }) + + it('should generate unique mnemonics', () => { + const mnemonic1 = generateRecoveryKey() + const mnemonic2 = generateRecoveryKey() + expect(mnemonic1).not.toBe(mnemonic2) + }) + + it('should only use words from BIP39 wordlist', () => { + const wordlist = getWordList() + const mnemonic = generateRecoveryKey() + const words = mnemonic.split(' ') + + for (const word of words) { + expect(wordlist).toContain(word) + } + }) + }) + + describe('validateRecoveryKey', () => { + it('should validate correct mnemonic', () => { + const mnemonic = generateRecoveryKey() + expect(validateRecoveryKey(mnemonic)).toBe(true) + }) + + it('should reject invalid mnemonic', () => { + expect(validateRecoveryKey('not a valid mnemonic phrase')).toBe(false) + }) + + it('should reject mnemonic with wrong word count', () => { + const mnemonic = generateRecoveryKey() + const words = mnemonic.split(' ').slice(0, 12).join(' ') + expect(validateRecoveryKey(words)).toBe(false) + }) + + it('should reject mnemonic with invalid checksum', () => { + const mnemonic = generateRecoveryKey() + const words = mnemonic.split(' ') + words[0] = 'abandon' // Replace first word to break checksum + expect(validateRecoveryKey(words.join(' '))).toBe(false) + }) + }) + + describe('recoveryKeyToUmk / umkToRecoveryKey', () => { + it('should convert mnemonic to UMK and back', () => { + const mnemonic = generateRecoveryKey() + const umk = recoveryKeyToUmk(mnemonic) + + expect(umk).toBeInstanceOf(Uint8Array) + expect(umk.length).toBe(ENTROPY_BYTES) + + const recoveredMnemonic = umkToRecoveryKey(umk) + expect(recoveredMnemonic).toBe(mnemonic) + }) + + it('should produce consistent UMK for same mnemonic', () => { + const mnemonic = generateRecoveryKey() + const umk1 = recoveryKeyToUmk(mnemonic) + const umk2 = recoveryKeyToUmk(mnemonic) + + expect(umk1).toEqual(umk2) + }) + + it('should throw on invalid mnemonic', () => { + expect(() => recoveryKeyToUmk('invalid mnemonic')).toThrow('Invalid recovery key') + }) + + it('should throw on invalid UMK length', () => { + const shortUmk = new Uint8Array(16) + expect(() => umkToRecoveryKey(shortUmk)).toThrow('Invalid UMK length') + }) + }) + + describe('getWordsAtIndices', () => { + it('should get correct words at specified indices', () => { + const mnemonic = generateRecoveryKey() + const words = mnemonic.split(' ') + + const result = getWordsAtIndices(mnemonic, [0, 5, 23]) + + expect(result[0]).toBe(words[0]) + expect(result[1]).toBe(words[5]) + expect(result[2]).toBe(words[23]) + }) + + it('should throw on out of bounds index', () => { + const mnemonic = generateRecoveryKey() + expect(() => getWordsAtIndices(mnemonic, [24])).toThrow('Invalid word index') + expect(() => getWordsAtIndices(mnemonic, [-1])).toThrow('Invalid word index') + }) + }) + + describe('verifyWords', () => { + it('should verify correct words', () => { + const mnemonic = generateRecoveryKey() + const indices = [2, 7] + const words = getWordsAtIndices(mnemonic, indices) + + expect(verifyWords(mnemonic, indices, words)).toBe(true) + }) + + it('should reject incorrect words', () => { + const mnemonic = generateRecoveryKey() + const indices = [2, 7] + + expect(verifyWords(mnemonic, indices, ['wrong', 'words'])).toBe(false) + }) + + it('should be case insensitive', () => { + const mnemonic = generateRecoveryKey() + const indices = [0] + const words = getWordsAtIndices(mnemonic, indices) + + expect(verifyWords(mnemonic, indices, [words[0].toUpperCase()])).toBe(true) + }) + + it('should trim whitespace', () => { + const mnemonic = generateRecoveryKey() + const indices = [0] + const words = getWordsAtIndices(mnemonic, indices) + + expect(verifyWords(mnemonic, indices, [` ${words[0]} `])).toBe(true) + }) + }) + + describe('generateVerificationIndices', () => { + it('should generate specified number of indices', () => { + const indices = generateVerificationIndices(3) + expect(indices.length).toBe(3) + }) + + it('should generate unique indices', () => { + const indices = generateVerificationIndices(5) + const uniqueIndices = new Set(indices) + expect(uniqueIndices.size).toBe(5) + }) + + it('should generate indices within valid range', () => { + const indices = generateVerificationIndices(5) + for (const i of indices) { + expect(i).toBeGreaterThanOrEqual(0) + expect(i).toBeLessThan(WORD_COUNT) + } + }) + + it('should return sorted indices', () => { + const indices = generateVerificationIndices(5) + const sorted = [...indices].sort((a, b) => a - b) + expect(indices).toEqual(sorted) + }) + }) + + describe('getWordList', () => { + it('should return BIP39 English wordlist', () => { + const wordlist = getWordList() + expect(wordlist.length).toBe(2048) + expect(wordlist).toContain('abandon') + expect(wordlist).toContain('zoo') + }) + }) +}) diff --git a/app/src/features/e2ee/lib/crypto/__tests__/ed25519.test.ts b/app/src/features/e2ee/lib/crypto/__tests__/ed25519.test.ts new file mode 100644 index 00000000..a45d8b3f --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/__tests__/ed25519.test.ts @@ -0,0 +1,187 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { + sign, + verify, + signToBase64, + verifyFromBase64, + generateKeyPair, + buildSigningMessage, + SIGNATURE_DOMAINS, + PUBLIC_KEY_SIZE, + PRIVATE_KEY_SIZE, + SIGNATURE_SIZE, +} from '../ed25519' +import { getSodium } from '../sodium' + +describe('Ed25519', () => { + beforeAll(async () => { + await getSodium() + }) + + describe('generateKeyPair', () => { + it('should generate valid key pair sizes', async () => { + const { publicKey, privateKey } = await generateKeyPair() + + expect(publicKey).toBeInstanceOf(Uint8Array) + expect(publicKey.length).toBe(PUBLIC_KEY_SIZE) + + expect(privateKey).toBeInstanceOf(Uint8Array) + expect(privateKey.length).toBe(PRIVATE_KEY_SIZE) + }) + + it('should generate unique key pairs', async () => { + const kp1 = await generateKeyPair() + const kp2 = await generateKeyPair() + + expect(kp1.publicKey).not.toEqual(kp2.publicKey) + expect(kp1.privateKey).not.toEqual(kp2.privateKey) + }) + }) + + describe('buildSigningMessage', () => { + it('should build message with correct format', () => { + const message = buildSigningMessage(SIGNATURE_DOMAINS.UPDATE, { + ciphertext: 'Y2lwaGVy', + nonce: 'bm9uY2U=', + publicData: 'cHVibGljRGF0YQ==', + }) + + const expected = 'refmd_update{"ciphertext":"Y2lwaGVy","nonce":"bm9uY2U=","publicData":"cHVibGljRGF0YQ=="}' + expect(new TextDecoder().decode(message)).toBe(expected) + }) + + it('should produce deterministic output', () => { + const msg1 = buildSigningMessage(SIGNATURE_DOMAINS.SNAPSHOT, { + ciphertext: 'abc', + nonce: 'def', + publicData: 'ghi', + }) + + const msg2 = buildSigningMessage(SIGNATURE_DOMAINS.SNAPSHOT, { + nonce: 'def', // different order + ciphertext: 'abc', + publicData: 'ghi', + }) + + expect(msg1).toEqual(msg2) + }) + }) + + describe('sign/verify', () => { + it('should sign and verify a message', async () => { + const { publicKey, privateKey } = await generateKeyPair() + const message = { + ciphertext: 'encrypted_content', + nonce: 'random_nonce', + publicData: 'public_metadata', + } + + const signature = await sign(privateKey, SIGNATURE_DOMAINS.UPDATE, message) + + expect(signature).toBeInstanceOf(Uint8Array) + expect(signature.length).toBe(SIGNATURE_SIZE) + + const isValid = await verify(publicKey, signature, SIGNATURE_DOMAINS.UPDATE, message) + expect(isValid).toBe(true) + }) + + it('should fail verification with wrong public key', async () => { + const kp1 = await generateKeyPair() + const kp2 = await generateKeyPair() + const message = { + ciphertext: 'encrypted_content', + nonce: 'random_nonce', + publicData: 'public_metadata', + } + + const signature = await sign(kp1.privateKey, SIGNATURE_DOMAINS.UPDATE, message) + const isValid = await verify(kp2.publicKey, signature, SIGNATURE_DOMAINS.UPDATE, message) + + expect(isValid).toBe(false) + }) + + it('should fail verification with modified message', async () => { + const { publicKey, privateKey } = await generateKeyPair() + const message = { + ciphertext: 'encrypted_content', + nonce: 'random_nonce', + publicData: 'public_metadata', + } + + const signature = await sign(privateKey, SIGNATURE_DOMAINS.UPDATE, message) + + const modifiedMessage = { ...message, ciphertext: 'tampered_content' } + const isValid = await verify(publicKey, signature, SIGNATURE_DOMAINS.UPDATE, modifiedMessage) + + expect(isValid).toBe(false) + }) + + it('should fail verification with wrong domain', async () => { + const { publicKey, privateKey } = await generateKeyPair() + const message = { + ciphertext: 'encrypted_content', + nonce: 'random_nonce', + publicData: 'public_metadata', + } + + const signature = await sign(privateKey, SIGNATURE_DOMAINS.UPDATE, message) + const isValid = await verify(publicKey, signature, SIGNATURE_DOMAINS.SNAPSHOT, message) + + expect(isValid).toBe(false) + }) + }) + + describe('signToBase64/verifyFromBase64', () => { + it('should work with Base64 encoding', async () => { + const { publicKey, privateKey } = await generateKeyPair() + const message = { + ciphertext: 'encrypted_content', + nonce: 'random_nonce', + publicData: 'public_metadata', + } + + const signatureBase64 = await signToBase64(privateKey, SIGNATURE_DOMAINS.UPDATE, message) + expect(typeof signatureBase64).toBe('string') + + const isValid = await verifyFromBase64(publicKey, signatureBase64, SIGNATURE_DOMAINS.UPDATE, message) + expect(isValid).toBe(true) + }) + }) + + describe('input validation', () => { + it('should throw on invalid private key length', async () => { + const shortKey = new Uint8Array(32) + const message = { + ciphertext: 'test', + nonce: 'test', + publicData: 'test', + } + + await expect(sign(shortKey, SIGNATURE_DOMAINS.UPDATE, message)).rejects.toThrow('Invalid private key length') + }) + + it('should throw on invalid public key length', async () => { + const shortKey = new Uint8Array(16) + const signature = new Uint8Array(SIGNATURE_SIZE) + const message = { + ciphertext: 'test', + nonce: 'test', + publicData: 'test', + } + + await expect(verify(shortKey, signature, SIGNATURE_DOMAINS.UPDATE, message)).rejects.toThrow('Invalid public key length') + }) + + it('should throw on invalid signature length', async () => { + const { publicKey } = await generateKeyPair() + const shortSignature = new Uint8Array(32) + const message = { + ciphertext: 'test', + nonce: 'test', + publicData: 'test', + } + + await expect(verify(publicKey, shortSignature, SIGNATURE_DOMAINS.UPDATE, message)).rejects.toThrow('Invalid signature length') + }) + }) +}) diff --git a/app/src/features/e2ee/lib/crypto/__tests__/xchacha20.test.ts b/app/src/features/e2ee/lib/crypto/__tests__/xchacha20.test.ts new file mode 100644 index 00000000..49032ead --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/__tests__/xchacha20.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { + encrypt, + decrypt, + encryptDek, + decryptDek, + encryptString, + decryptString, + generateKey, + generateNonce, + KEY_SIZE, + NONCE_SIZE, +} from '../xchacha20' +import { getSodium } from '../sodium' + +describe('XChaCha20-Poly1305', () => { + beforeAll(async () => { + // Ensure sodium is initialized + await getSodium() + }) + + describe('generateKey', () => { + it('should generate a 32-byte key', async () => { + const key = await generateKey() + expect(key).toBeInstanceOf(Uint8Array) + expect(key.length).toBe(KEY_SIZE) + }) + + it('should generate unique keys', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + expect(key1).not.toEqual(key2) + }) + }) + + describe('generateNonce', () => { + it('should generate a 24-byte nonce', async () => { + const nonce = await generateNonce() + expect(nonce).toBeInstanceOf(Uint8Array) + expect(nonce.length).toBe(NONCE_SIZE) + }) + + it('should generate unique nonces', async () => { + const nonce1 = await generateNonce() + const nonce2 = await generateNonce() + expect(nonce1).not.toEqual(nonce2) + }) + }) + + describe('encrypt/decrypt', () => { + it('should encrypt and decrypt data correctly', async () => { + const key = await generateKey() + const plaintext = new TextEncoder().encode('Hello, E2EE World!') + + const { ciphertext, nonce } = await encrypt(key, plaintext) + const decrypted = await decrypt(key, ciphertext, nonce) + + expect(decrypted).toEqual(plaintext) + }) + + it('should encrypt empty data', async () => { + const key = await generateKey() + const plaintext = new Uint8Array(0) + + const { ciphertext, nonce } = await encrypt(key, plaintext) + const decrypted = await decrypt(key, ciphertext, nonce) + + expect(decrypted).toEqual(plaintext) + }) + + it('should encrypt large data', async () => { + const key = await generateKey() + const plaintext = new Uint8Array(1024 * 1024) // 1MB + plaintext.fill(0xAB) + + const { ciphertext, nonce } = await encrypt(key, plaintext) + const decrypted = await decrypt(key, ciphertext, nonce) + + expect(decrypted).toEqual(plaintext) + }) + + it('should fail with wrong key', async () => { + const key1 = await generateKey() + const key2 = await generateKey() + const plaintext = new TextEncoder().encode('Secret message') + + const { ciphertext, nonce } = await encrypt(key1, plaintext) + + await expect(decrypt(key2, ciphertext, nonce)).rejects.toThrow('Decryption failed') + }) + + it('should fail with corrupted ciphertext', async () => { + const key = await generateKey() + const plaintext = new TextEncoder().encode('Secret message') + + const { ciphertext, nonce } = await encrypt(key, plaintext) + + // Corrupt the ciphertext + ciphertext[0] ^= 0xFF + + await expect(decrypt(key, ciphertext, nonce)).rejects.toThrow('Decryption failed') + }) + + it('should throw on invalid key length', async () => { + const shortKey = new Uint8Array(16) + const plaintext = new Uint8Array([1, 2, 3]) + + await expect(encrypt(shortKey, plaintext)).rejects.toThrow('Invalid key length') + }) + + it('should throw on invalid nonce length', async () => { + const key = await generateKey() + const ciphertext = new Uint8Array(32) + const shortNonce = new Uint8Array(12) + + await expect(decrypt(key, ciphertext, shortNonce)).rejects.toThrow('Invalid nonce length') + }) + }) + + describe('encryptDek/decryptDek', () => { + it('should encrypt and decrypt a DEK', async () => { + const kek = await generateKey() + const dek = await generateKey() + + const { ciphertext, nonce } = await encryptDek(kek, dek) + const decryptedDek = await decryptDek(kek, ciphertext, nonce) + + expect(decryptedDek).toEqual(dek) + }) + }) + + describe('encryptString/decryptString', () => { + it('should encrypt and decrypt strings', async () => { + const key = await generateKey() + const plaintext = 'Hello, World! 日本語テスト 🎉' + + const { ciphertext, nonce } = await encryptString(key, plaintext) + const decrypted = await decryptString(key, ciphertext, nonce) + + expect(decrypted).toBe(plaintext) + }) + }) +}) diff --git a/app/src/features/e2ee/lib/crypto/argon2.ts b/app/src/features/e2ee/lib/crypto/argon2.ts new file mode 100644 index 00000000..a560f95c --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/argon2.ts @@ -0,0 +1,117 @@ +/** + * Argon2id Key Derivation Function + * + * Used for deriving encryption keys from user passphrases. + * Argon2id is the recommended KDF for password hashing. + * + * Parameters follow OWASP recommendations: + * - memory: 64 MB (65536 KB) + * - iterations: 3 + * - parallelism: 4 + */ + +import argon2 from 'argon2-browser' +import { getSodium } from './sodium' + +/** Salt size for Argon2id (16 bytes) */ +export const SALT_SIZE = 16 + +/** Output key size (32 bytes for XChaCha20-Poly1305) */ +export const KEY_SIZE = 32 + +/** Argon2id parameters */ +export interface Argon2Params { + /** Memory cost in KB (default: 65536 = 64 MB) */ + memory: number + /** Time cost / iterations (default: 3) */ + iterations: number + /** Parallelism / lanes (default: 4) */ + parallelism: number +} + +/** Default Argon2id parameters (OWASP recommended) */ +export const DEFAULT_ARGON2_PARAMS: Argon2Params = { + memory: 65536, // 64 MB + iterations: 3, + parallelism: 4, +} + +/** + * Generate a random salt for Argon2id. + * + * @returns 16-byte random salt + */ +export async function generateSalt(): Promise { + const sodium = await getSodium() + return sodium.randombytes_buf(SALT_SIZE) +} + +/** + * Derive a key from a passphrase using Argon2id. + * + * @param passphrase - User's passphrase + * @param salt - 16-byte random salt + * @param params - Argon2id parameters (optional, defaults to OWASP recommended) + * @returns 32-byte derived key + */ +export async function deriveKey( + passphrase: string, + salt: Uint8Array, + params: Argon2Params = DEFAULT_ARGON2_PARAMS +): Promise { + if (salt.length !== SALT_SIZE) { + throw new Error(`Invalid salt length: expected ${SALT_SIZE}, got ${salt.length}`) + } + + const result = await argon2.hash({ + pass: passphrase, + salt, + time: params.iterations, + mem: params.memory, + parallelism: params.parallelism, + hashLen: KEY_SIZE, + type: argon2.ArgonType.Argon2id, + }) + + return result.hash +} + +/** + * Derive a key from a passphrase with a new random salt. + * + * @param passphrase - User's passphrase + * @param params - Argon2id parameters (optional) + * @returns Derived key and the salt used + */ +export async function deriveKeyWithNewSalt( + passphrase: string, + params: Argon2Params = DEFAULT_ARGON2_PARAMS +): Promise<{ key: Uint8Array; salt: Uint8Array }> { + const salt = await generateSalt() + const key = await deriveKey(passphrase, salt, params) + return { key, salt } +} + +/** + * Check if Argon2id is supported in the current environment. + * Falls back to PBKDF2 if not supported. + * + * @returns true if Argon2id is supported + */ +export async function isArgon2Supported(): Promise { + try { + // Try a minimal hash to check if Argon2 WASM is working + await argon2.hash({ + pass: 'test', + salt: new Uint8Array(16), + time: 1, + mem: 1024, + parallelism: 1, + hashLen: 32, + type: argon2.ArgonType.Argon2id, + }) + return true + } catch { + return false + } +} diff --git a/app/src/features/e2ee/lib/crypto/bip39.ts b/app/src/features/e2ee/lib/crypto/bip39.ts new file mode 100644 index 00000000..78ea837d --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/bip39.ts @@ -0,0 +1,150 @@ +/** + * BIP39 Recovery Key module + * + * Generates and validates 24-word mnemonic phrases for key recovery. + * The mnemonic encodes 256 bits of entropy (the UMK). + */ + +import * as bip39Lib from 'bip39' + +/** Number of words in the recovery key */ +export const WORD_COUNT = 24 + +/** Entropy size in bits (256 bits = 32 bytes = 24 words) */ +export const ENTROPY_BITS = 256 + +/** Entropy size in bytes */ +export const ENTROPY_BYTES = ENTROPY_BITS / 8 + +/** + * Generate a new recovery key (24-word mnemonic). + * + * @returns 24-word mnemonic phrase + */ +export function generateRecoveryKey(): string { + return bip39Lib.generateMnemonic(ENTROPY_BITS) +} + +/** + * Validate a recovery key (mnemonic phrase). + * + * @param mnemonic - The mnemonic phrase to validate + * @returns true if valid, false otherwise + */ +export function validateRecoveryKey(mnemonic: string): boolean { + return bip39Lib.validateMnemonic(mnemonic) +} + +/** + * Convert a recovery key (mnemonic) to User Master Key (UMK). + * + * @param mnemonic - 24-word mnemonic phrase + * @returns 32-byte UMK + * @throws Error if mnemonic is invalid + */ +export function recoveryKeyToUmk(mnemonic: string): Uint8Array { + if (!validateRecoveryKey(mnemonic)) { + throw new Error('Invalid recovery key (mnemonic)') + } + + const entropy = bip39Lib.mnemonicToEntropy(mnemonic) + // mnemonicToEntropy returns a hex string + return hexToBytes(entropy) +} + +/** + * Convert a User Master Key (UMK) to recovery key (mnemonic). + * + * @param umk - 32-byte User Master Key + * @returns 24-word mnemonic phrase + * @throws Error if UMK length is invalid + */ +export function umkToRecoveryKey(umk: Uint8Array): string { + if (umk.length !== ENTROPY_BYTES) { + throw new Error(`Invalid UMK length: expected ${ENTROPY_BYTES}, got ${umk.length}`) + } + + const entropy = bytesToHex(umk) + return bip39Lib.entropyToMnemonic(entropy) +} + +/** + * Get specific words from a mnemonic for verification. + * Used in the recovery key verification challenge. + * + * @param mnemonic - The mnemonic phrase + * @param indices - 0-based indices of words to get + * @returns Array of words at the specified indices + */ +export function getWordsAtIndices(mnemonic: string, indices: number[]): string[] { + const words = mnemonic.split(' ') + return indices.map(i => { + if (i < 0 || i >= words.length) { + throw new Error(`Invalid word index: ${i}`) + } + return words[i] + }) +} + +/** + * Verify specific words in a mnemonic. + * Used for recovery key verification challenge. + * + * @param mnemonic - The full mnemonic phrase + * @param indices - 0-based indices to verify + * @param userWords - User-provided words to verify + * @returns true if all words match, false otherwise + */ +export function verifyWords( + mnemonic: string, + indices: number[], + userWords: string[] +): boolean { + if (indices.length !== userWords.length) { + return false + } + + const correctWords = getWordsAtIndices(mnemonic, indices) + return correctWords.every((word, i) => + word.toLowerCase() === userWords[i].toLowerCase().trim() + ) +} + +/** + * Generate random indices for verification challenge. + * Selects n unique random indices from 0 to WORD_COUNT-1. + * + * @param count - Number of indices to generate (default: 2) + * @returns Array of unique random indices (sorted) + */ +export function generateVerificationIndices(count = 2): number[] { + const indices = new Set() + while (indices.size < count) { + indices.add(Math.floor(Math.random() * WORD_COUNT)) + } + return Array.from(indices).sort((a, b) => a - b) +} + +/** + * Get the word list used by BIP39. + * + * @returns Array of all BIP39 English words + */ +export function getWordList(): string[] { + return bip39Lib.wordlists.english +} + +// Helper functions +function hexToBytes(hex: string): Uint8Array { + const bytes = new Uint8Array(hex.length / 2) + for (let i = 0; i < bytes.length; i++) { + bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16) + } + return bytes +} + +function bytesToHex(bytes: Uint8Array): string { + return Array.from(bytes) + .map(b => b.toString(16).padStart(2, '0')) + .join('') +} diff --git a/app/src/features/e2ee/lib/crypto/canonicalize.ts b/app/src/features/e2ee/lib/crypto/canonicalize.ts new file mode 100644 index 00000000..c0bccee4 --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/canonicalize.ts @@ -0,0 +1,67 @@ +/** + * RFC 8785 JSON Canonicalization Scheme (JCS) + * + * Used for creating deterministic JSON for signature verification. + * Compatible with backend Ed25519 signature verification. + */ + +import canonicalizeLib from 'canonicalize' +import { getSodium } from './sodium' + +/** + * Canonicalize a JavaScript object to deterministic JSON string. + * Uses RFC 8785 JSON Canonicalization Scheme. + * + * @param input - Object to canonicalize + * @returns Canonicalized JSON string + * @throws Error if canonicalization fails + */ +export function canonicalize(input: unknown): string { + const result = canonicalizeLib(input) + if (result === undefined) { + throw new Error('Failed to canonicalize input') + } + return result +} + +/** + * Canonicalize an object and encode to Base64. + * Used for publicData in E2EE messages. + * + * @param input - Object to canonicalize and encode + * @returns Base64-encoded canonicalized JSON + */ +export async function canonicalizeAndToBase64(input: unknown): Promise { + const sodium = await getSodium() + const canonicalized = canonicalize(input) + return sodium.to_base64(canonicalized, sodium.base64_variants.ORIGINAL) +} + +/** + * Decode Base64 and parse as JSON. + * + * @param base64 - Base64-encoded JSON string + * @returns Parsed object + */ +export async function fromBase64Json(base64: string): Promise { + const sodium = await getSodium() + const decoded = sodium.from_base64(base64, sodium.base64_variants.ORIGINAL) + const jsonStr = new TextDecoder().decode(decoded) + return JSON.parse(jsonStr) as T +} + +/** + * Encode Uint8Array to Base64. + */ +export async function toBase64(data: Uint8Array): Promise { + const sodium = await getSodium() + return sodium.to_base64(data, sodium.base64_variants.ORIGINAL) +} + +/** + * Decode Base64 to Uint8Array. + */ +export async function fromBase64(base64: string): Promise { + const sodium = await getSodium() + return sodium.from_base64(base64, sodium.base64_variants.ORIGINAL) +} diff --git a/app/src/features/e2ee/lib/crypto/ecdh.ts b/app/src/features/e2ee/lib/crypto/ecdh.ts new file mode 100644 index 00000000..08165c7c --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/ecdh.ts @@ -0,0 +1,168 @@ +/** + * ECDH P-256 Key Exchange module + * + * Used for key sharing between users. + * Uses @noble/curves for elliptic curve operations. + */ + +import { p256 } from '@noble/curves/p256' +import { hkdf } from '@noble/hashes/hkdf' +import { sha256 } from '@noble/hashes/sha256' + +/** P-256 private key size (32 bytes) */ +export const PRIVATE_KEY_SIZE = 32 + +/** P-256 public key size (uncompressed: 65 bytes, compressed: 33 bytes) */ +export const PUBLIC_KEY_SIZE_UNCOMPRESSED = 65 +export const PUBLIC_KEY_SIZE_COMPRESSED = 33 + +/** Derived shared secret size (32 bytes after HKDF) */ +export const SHARED_SECRET_SIZE = 32 + +/** ECDH P-256 key pair */ +export interface EcdhKeyPair { + /** 32-byte private key */ + privateKey: Uint8Array + /** 65-byte uncompressed public key (04 || x || y) */ + publicKey: Uint8Array +} + +/** + * Generate an ECDH P-256 key pair. + * + * @returns New ECDH key pair + */ +export function generateKeyPair(): EcdhKeyPair { + const privateKey = p256.utils.randomPrivateKey() + const publicKey = p256.getPublicKey(privateKey, false) // uncompressed + + return { privateKey, publicKey } +} + +/** + * Get the public key from a private key. + * + * @param privateKey - 32-byte private key + * @param compressed - Whether to return compressed format (default: false) + * @returns Public key + */ +export function getPublicKey(privateKey: Uint8Array, compressed = false): Uint8Array { + return p256.getPublicKey(privateKey, compressed) +} + +/** + * Compute shared secret using ECDH. + * + * @param privateKey - Our 32-byte private key + * @param publicKey - Their public key (33 or 65 bytes) + * @returns Raw shared point x-coordinate (32 bytes) + */ +export function computeSharedSecret( + privateKey: Uint8Array, + publicKey: Uint8Array +): Uint8Array { + const sharedPoint = p256.getSharedSecret(privateKey, publicKey) + // getSharedSecret returns the full point, we only need the x-coordinate + // The first byte is 0x04 for uncompressed, then 32 bytes x, 32 bytes y + // For compressed, first byte is 0x02 or 0x03, then 32 bytes x + // We return the x-coordinate (bytes 1-33 for uncompressed) + return sharedPoint.slice(1, 33) +} + +/** + * Derive a symmetric key from ECDH shared secret using HKDF-SHA256. + * + * @param privateKey - Our 32-byte private key + * @param publicKey - Their public key + * @param info - Context info for HKDF (e.g., "refmd_kek") + * @param length - Desired output key length (default: 32) + * @returns Derived symmetric key + */ +export function deriveSharedKey( + privateKey: Uint8Array, + publicKey: Uint8Array, + info: string, + length = SHARED_SECRET_SIZE +): Uint8Array { + const sharedSecret = computeSharedSecret(privateKey, publicKey) + + // Use HKDF to derive a key from the shared secret + const encoder = new TextEncoder() + return hkdf(sha256, sharedSecret, undefined, encoder.encode(info), length) +} + +/** + * Encrypt a key for a recipient using ECDH. + * This creates an ephemeral key pair, derives a shared key, and encrypts. + * + * @param recipientPublicKey - Recipient's public key + * @param keyToEncrypt - The key to encrypt (e.g., KEK) + * @param info - Context info for HKDF + * @returns Ephemeral public key and encrypted key + */ +export async function encryptKeyForRecipient( + recipientPublicKey: Uint8Array, + keyToEncrypt: Uint8Array, + info: string +): Promise<{ ephemeralPublicKey: Uint8Array; encryptedKey: Uint8Array; nonce: Uint8Array }> { + // Import xchacha20 dynamically to avoid circular dependency + const { encrypt } = await import('./xchacha20') + + // Generate ephemeral key pair + const ephemeral = generateKeyPair() + + // Derive shared key + const sharedKey = deriveSharedKey(ephemeral.privateKey, recipientPublicKey, info) + + // Encrypt the key + const { ciphertext, nonce } = await encrypt(sharedKey, keyToEncrypt) + + return { + ephemeralPublicKey: ephemeral.publicKey, + encryptedKey: ciphertext, + nonce, + } +} + +/** + * Decrypt a key that was encrypted for us using ECDH. + * + * @param ourPrivateKey - Our private key + * @param ephemeralPublicKey - Sender's ephemeral public key + * @param encryptedKey - The encrypted key + * @param nonce - The nonce used for encryption + * @param info - Context info for HKDF + * @returns Decrypted key + */ +export async function decryptKeyFromSender( + ourPrivateKey: Uint8Array, + ephemeralPublicKey: Uint8Array, + encryptedKey: Uint8Array, + nonce: Uint8Array, + info: string +): Promise { + // Import xchacha20 dynamically to avoid circular dependency + const { decrypt } = await import('./xchacha20') + + // Derive shared key + const sharedKey = deriveSharedKey(ourPrivateKey, ephemeralPublicKey, info) + + // Decrypt the key + return decrypt(sharedKey, encryptedKey, nonce) +} + +/** + * Validate a P-256 public key. + * + * @param publicKey - Public key to validate + * @returns true if valid, false otherwise + */ +export function isValidPublicKey(publicKey: Uint8Array): boolean { + try { + // Try to create a point from the public key + p256.ProjectivePoint.fromHex(publicKey) + return true + } catch { + return false + } +} diff --git a/app/src/features/e2ee/lib/crypto/ed25519.ts b/app/src/features/e2ee/lib/crypto/ed25519.ts new file mode 100644 index 00000000..e23f378f --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/ed25519.ts @@ -0,0 +1,180 @@ +/** + * Ed25519 signature module for E2EE messages + * + * Compatible with backend implementation (api/crates/infrastructure/src/core/crypto/ed25519.rs) + * Signature format: domain + canonicalize({ciphertext, nonce, publicData}) + */ + +import { getSodium } from './sodium' +import { canonicalize } from './canonicalize' + +/** Ed25519 public key size (32 bytes) */ +export const PUBLIC_KEY_SIZE = 32 + +/** Ed25519 private key size (64 bytes for libsodium's keypair format) */ +export const PRIVATE_KEY_SIZE = 64 + +/** Ed25519 signature size (64 bytes) */ +export const SIGNATURE_SIZE = 64 + +/** Signature domains for E2EE messages (domain separation) */ +export const SIGNATURE_DOMAINS = { + SNAPSHOT: 'refmd_snapshot', + UPDATE: 'refmd_update', + EPHEMERAL: 'refmd_ephemeral', +} as const + +export type SignatureDomain = typeof SIGNATURE_DOMAINS[keyof typeof SIGNATURE_DOMAINS] + +/** Ed25519 key pair */ +export interface Ed25519KeyPair { + publicKey: Uint8Array + privateKey: Uint8Array +} + +/** Signing message content (matches backend format) */ +export interface SigningMessage { + /** Base64-encoded ciphertext */ + ciphertext: string + /** Base64-encoded nonce */ + nonce: string + /** Base64-encoded canonicalized publicData */ + publicData: string +} + +/** + * Generate an Ed25519 key pair for signing. + * + * @returns New Ed25519 key pair + */ +export async function generateKeyPair(): Promise { + const sodium = await getSodium() + const keyPair = sodium.crypto_sign_keypair() + return { + publicKey: keyPair.publicKey, + privateKey: keyPair.privateKey, + } +} + +/** + * Build the message bytes for signing/verification. + * Format: domain + canonicalize({ciphertext, nonce, publicData}) + * + * Keys are sorted alphabetically per RFC 8785: + * {"ciphertext":"...","nonce":"...","publicData":"..."} + * + * @param domain - Signature domain (e.g., "refmd_update") + * @param message - Signing message content + * @returns Message bytes to sign + */ +export function buildSigningMessage( + domain: SignatureDomain, + message: SigningMessage +): Uint8Array { + // Canonicalize with sorted keys (RFC 8785) + const canonicalJson = canonicalize({ + ciphertext: message.ciphertext, + nonce: message.nonce, + publicData: message.publicData, + }) + + // domain + canonicalized JSON + const encoder = new TextEncoder() + const domainBytes = encoder.encode(domain) + const jsonBytes = encoder.encode(canonicalJson) + + const result = new Uint8Array(domainBytes.length + jsonBytes.length) + result.set(domainBytes, 0) + result.set(jsonBytes, domainBytes.length) + + return result +} + +/** + * Sign a message using Ed25519. + * + * @param privateKey - 64-byte Ed25519 private key + * @param domain - Signature domain + * @param message - Message content to sign + * @returns 64-byte Ed25519 signature + */ +export async function sign( + privateKey: Uint8Array, + domain: SignatureDomain, + message: SigningMessage +): Promise { + if (privateKey.length !== PRIVATE_KEY_SIZE) { + throw new Error(`Invalid private key length: expected ${PRIVATE_KEY_SIZE}, got ${privateKey.length}`) + } + + const sodium = await getSodium() + const messageBytes = buildSigningMessage(domain, message) + + return sodium.crypto_sign_detached(messageBytes, privateKey) +} + +/** + * Verify an Ed25519 signature. + * + * @param publicKey - 32-byte Ed25519 public key + * @param signature - 64-byte Ed25519 signature + * @param domain - Signature domain + * @param message - Message content that was signed + * @returns true if signature is valid, false otherwise + */ +export async function verify( + publicKey: Uint8Array, + signature: Uint8Array, + domain: SignatureDomain, + message: SigningMessage +): Promise { + if (publicKey.length !== PUBLIC_KEY_SIZE) { + throw new Error(`Invalid public key length: expected ${PUBLIC_KEY_SIZE}, got ${publicKey.length}`) + } + if (signature.length !== SIGNATURE_SIZE) { + throw new Error(`Invalid signature length: expected ${SIGNATURE_SIZE}, got ${signature.length}`) + } + + const sodium = await getSodium() + const messageBytes = buildSigningMessage(domain, message) + + return sodium.crypto_sign_verify_detached(signature, messageBytes, publicKey) +} + +/** + * Sign a message and return Base64-encoded signature. + * + * @param privateKey - 64-byte Ed25519 private key + * @param domain - Signature domain + * @param message - Message content to sign + * @returns Base64-encoded signature + */ +export async function signToBase64( + privateKey: Uint8Array, + domain: SignatureDomain, + message: SigningMessage +): Promise { + const sodium = await getSodium() + const signature = await sign(privateKey, domain, message) + return sodium.to_base64(signature, sodium.base64_variants.ORIGINAL) +} + +/** + * Verify a Base64-encoded Ed25519 signature. + * + * @param publicKey - 32-byte Ed25519 public key + * @param signatureBase64 - Base64-encoded signature + * @param domain - Signature domain + * @param message - Message content that was signed + * @returns true if signature is valid, false otherwise + */ +export async function verifyFromBase64( + publicKey: Uint8Array, + signatureBase64: string, + domain: SignatureDomain, + message: SigningMessage +): Promise { + const sodium = await getSodium() + const signature = sodium.from_base64(signatureBase64, sodium.base64_variants.ORIGINAL) + return verify(publicKey, signature, domain, message) +} diff --git a/app/src/features/e2ee/lib/crypto/hkdf.ts b/app/src/features/e2ee/lib/crypto/hkdf.ts new file mode 100644 index 00000000..33de468b --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/hkdf.ts @@ -0,0 +1,92 @@ +/** + * HKDF-SHA256 key derivation module + * + * Used for deriving multiple keys from a single master key. + * Uses libsodium's crypto_kdf functions. + */ + +import { getSodium } from './sodium' + +/** Minimum context length for HKDF */ +export const MIN_CONTEXT_LENGTH = 8 + +/** Maximum context length for HKDF */ +export const MAX_CONTEXT_LENGTH = 8 + +/** Master key size (32 bytes) */ +export const MASTER_KEY_SIZE = 32 + +/** Minimum derived key size */ +export const MIN_KEY_SIZE = 16 + +/** Maximum derived key size */ +export const MAX_KEY_SIZE = 64 + +/** + * Derive a subkey from a master key using HKDF-SHA256. + * + * @param masterKey - 32-byte master key (IKM) + * @param subkeyId - Numeric ID for the subkey (used for domain separation) + * @param context - 8-byte context string (for domain separation) + * @param length - Desired output key length (16-64 bytes) + * @returns Derived key + */ +export async function deriveKey( + masterKey: Uint8Array, + subkeyId: number, + context: string, + length: number = 32 +): Promise { + if (masterKey.length !== MASTER_KEY_SIZE) { + throw new Error(`Invalid master key length: expected ${MASTER_KEY_SIZE}, got ${masterKey.length}`) + } + if (length < MIN_KEY_SIZE || length > MAX_KEY_SIZE) { + throw new Error(`Invalid key length: must be between ${MIN_KEY_SIZE} and ${MAX_KEY_SIZE}`) + } + + // Pad or truncate context to exactly 8 bytes + const contextBytes = new TextEncoder().encode(context.padEnd(8, '\0').slice(0, 8)) + + const sodium = await getSodium() + + return sodium.crypto_kdf_derive_from_key( + length, + subkeyId, + new TextDecoder().decode(contextBytes), + masterKey + ) +} + +/** + * Generate a random master key for HKDF. + * + * @returns 32-byte random master key + */ +export async function generateMasterKey(): Promise { + const sodium = await getSodium() + return sodium.crypto_kdf_keygen() +} + +/** Context strings for different key derivations */ +export const HKDF_CONTEXTS = { + /** Derive encryption key from UMK */ + ENCRYPTION: 'refmd_ek', + /** Derive signing key from UMK */ + SIGNING: 'refmd_sk', + /** Derive workspace KEK */ + WORKSPACE: 'refmd_ws', + /** Derive document DEK */ + DOCUMENT: 'refmd_dc', + /** Derive plugin key */ + PLUGIN: 'refmd_pl', +} as const + +/** Subkey IDs for different purposes */ +export const SUBKEY_IDS = { + /** Main encryption key */ + MAIN: 1, + /** Backup encryption key */ + BACKUP: 2, + /** Authentication key */ + AUTH: 3, +} as const diff --git a/app/src/features/e2ee/lib/crypto/index.ts b/app/src/features/e2ee/lib/crypto/index.ts new file mode 100644 index 00000000..eda4b678 --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/index.ts @@ -0,0 +1,111 @@ +/** + * E2EE Crypto Module + * + * Exports all cryptographic primitives for E2EE implementation. + */ + +// Sodium initialization +export { getSodium, isSodiumReady, type Sodium } from './sodium' + +// XChaCha20-Poly1305 encryption +export { + encrypt, + decrypt, + encryptDek, + decryptDek, + encryptString, + decryptString, + generateKey, + generateNonce, + NONCE_SIZE, + KEY_SIZE, + TAG_SIZE, + type EncryptResult, +} from './xchacha20' + +// Ed25519 signatures +export { + sign, + verify, + signToBase64, + verifyFromBase64, + generateKeyPair as generateSigningKeyPair, + buildSigningMessage, + SIGNATURE_DOMAINS, + PUBLIC_KEY_SIZE, + PRIVATE_KEY_SIZE, + SIGNATURE_SIZE, + type Ed25519KeyPair, + type SigningMessage, + type SignatureDomain, +} from './ed25519' + +// JSON canonicalization +export { + canonicalize, + canonicalizeAndToBase64, + fromBase64Json, + toBase64, + fromBase64, +} from './canonicalize' + +// HKDF key derivation +export { + deriveKey as hkdfDeriveKey, + generateMasterKey as hkdfGenerateMasterKey, + HKDF_CONTEXTS, + SUBKEY_IDS, + MASTER_KEY_SIZE, + MIN_KEY_SIZE, + MAX_KEY_SIZE, +} from './hkdf' + +// Argon2id KDF +export { + deriveKey as argon2DeriveKey, + deriveKeyWithNewSalt as argon2DeriveKeyWithNewSalt, + generateSalt as argon2GenerateSalt, + isArgon2Supported, + DEFAULT_ARGON2_PARAMS, + SALT_SIZE as ARGON2_SALT_SIZE, + type Argon2Params, +} from './argon2' + +// PBKDF2 fallback +export { + deriveKey as pbkdf2DeriveKey, + deriveKeyWithNewSalt as pbkdf2DeriveKeyWithNewSalt, + generateSalt as pbkdf2GenerateSalt, + DEFAULT_ITERATIONS as PBKDF2_DEFAULT_ITERATIONS, + SALT_SIZE as PBKDF2_SALT_SIZE, +} from './pbkdf2' + +// ECDH key exchange +export { + generateKeyPair as generateEcdhKeyPair, + getPublicKey as getEcdhPublicKey, + computeSharedSecret, + deriveSharedKey, + encryptKeyForRecipient, + decryptKeyFromSender, + isValidPublicKey as isValidEcdhPublicKey, + PRIVATE_KEY_SIZE as ECDH_PRIVATE_KEY_SIZE, + PUBLIC_KEY_SIZE_UNCOMPRESSED as ECDH_PUBLIC_KEY_SIZE, + SHARED_SECRET_SIZE, + type EcdhKeyPair, +} from './ecdh' + +// BIP39 recovery keys +export { + generateRecoveryKey, + validateRecoveryKey, + recoveryKeyToUmk, + umkToRecoveryKey, + getWordsAtIndices, + verifyWords, + generateVerificationIndices, + getWordList, + WORD_COUNT, + ENTROPY_BITS, + ENTROPY_BYTES, +} from './bip39' diff --git a/app/src/features/e2ee/lib/crypto/pbkdf2.ts b/app/src/features/e2ee/lib/crypto/pbkdf2.ts new file mode 100644 index 00000000..1ad38894 --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/pbkdf2.ts @@ -0,0 +1,91 @@ +/** + * PBKDF2-SHA256 Key Derivation Function (Fallback) + * + * Used as a fallback when Argon2id is not available. + * Uses Web Crypto API for PBKDF2. + * + * Parameters: + * - iterations: 1,000,000 (high iteration count to compensate for lack of memory-hardness) + * - hash: SHA-256 + */ + +import { getSodium } from './sodium' + +/** Salt size for PBKDF2 (16 bytes) */ +export const SALT_SIZE = 16 + +/** Output key size (32 bytes) */ +export const KEY_SIZE = 32 + +/** Default iteration count (1,000,000) */ +export const DEFAULT_ITERATIONS = 1_000_000 + +/** + * Generate a random salt for PBKDF2. + * + * @returns 16-byte random salt + */ +export async function generateSalt(): Promise { + const sodium = await getSodium() + return sodium.randombytes_buf(SALT_SIZE) +} + +/** + * Derive a key from a passphrase using PBKDF2-SHA256. + * + * @param passphrase - User's passphrase + * @param salt - 16-byte random salt + * @param iterations - Number of iterations (default: 1,000,000) + * @returns 32-byte derived key + */ +export async function deriveKey( + passphrase: string, + salt: Uint8Array, + iterations: number = DEFAULT_ITERATIONS +): Promise { + if (salt.length !== SALT_SIZE) { + throw new Error(`Invalid salt length: expected ${SALT_SIZE}, got ${salt.length}`) + } + + const encoder = new TextEncoder() + const passphraseBytes = encoder.encode(passphrase) + + // Import the passphrase as a key + const baseKey = await crypto.subtle.importKey( + 'raw', + passphraseBytes, + 'PBKDF2', + false, + ['deriveBits'] + ) + + // Derive the key using PBKDF2 + const derivedBits = await crypto.subtle.deriveBits( + { + name: 'PBKDF2', + salt, + iterations, + hash: 'SHA-256', + }, + baseKey, + KEY_SIZE * 8 // bits + ) + + return new Uint8Array(derivedBits) +} + +/** + * Derive a key from a passphrase with a new random salt. + * + * @param passphrase - User's passphrase + * @param iterations - Number of iterations (optional) + * @returns Derived key and the salt used + */ +export async function deriveKeyWithNewSalt( + passphrase: string, + iterations: number = DEFAULT_ITERATIONS +): Promise<{ key: Uint8Array; salt: Uint8Array }> { + const salt = await generateSalt() + const key = await deriveKey(passphrase, salt, iterations) + return { key, salt } +} diff --git a/app/src/features/e2ee/lib/crypto/sodium.ts b/app/src/features/e2ee/lib/crypto/sodium.ts new file mode 100644 index 00000000..8d49b1bd --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/sodium.ts @@ -0,0 +1,30 @@ +/** + * libsodium initialization wrapper + * + * Provides a singleton pattern for libsodium initialization. + * All crypto modules should use getSodium() to ensure libsodium is ready. + */ + +import _sodium from 'libsodium-wrappers-sumo' + +export type Sodium = typeof _sodium + +let sodiumPromise: Promise | null = null + +/** + * Initialize and get the libsodium instance. + * This is safe to call multiple times - it will only initialize once. + */ +export async function getSodium(): Promise { + if (!sodiumPromise) { + sodiumPromise = _sodium.ready.then(() => _sodium) + } + return sodiumPromise +} + +/** + * Check if libsodium is already initialized. + */ +export function isSodiumReady(): boolean { + return sodiumPromise !== null +} diff --git a/app/src/features/e2ee/lib/crypto/xchacha20.ts b/app/src/features/e2ee/lib/crypto/xchacha20.ts new file mode 100644 index 00000000..6167e289 --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/xchacha20.ts @@ -0,0 +1,179 @@ +/** + * XChaCha20-Poly1305 AEAD encryption module + * + * Compatible with backend implementation (api/crates/infrastructure/src/core/crypto/xchacha20.rs) + * Note: This does NOT include robustnessTag (differs from secsync) + */ + +import { getSodium, type Sodium } from './sodium' + +/** XChaCha20-Poly1305 nonce size (24 bytes) */ +export const NONCE_SIZE = 24 + +/** XChaCha20-Poly1305 key size (32 bytes / 256 bits) */ +export const KEY_SIZE = 32 + +/** Poly1305 authentication tag size (16 bytes) */ +export const TAG_SIZE = 16 + +export interface EncryptResult { + /** Encrypted data including authentication tag */ + ciphertext: Uint8Array + /** 24-byte nonce used for encryption */ + nonce: Uint8Array +} + +/** + * Generate a random 24-byte nonce for XChaCha20-Poly1305. + * + * Each encryption operation MUST use a unique nonce. + * Using the same nonce twice with the same key is catastrophic. + */ +export async function generateNonce(): Promise { + const sodium = await getSodium() + return sodium.randombytes_buf(NONCE_SIZE) +} + +/** + * Generate a random 32-byte key for XChaCha20-Poly1305. + */ +export async function generateKey(): Promise { + const sodium = await getSodium() + return sodium.randombytes_buf(KEY_SIZE) +} + +/** + * Encrypt plaintext using XChaCha20-Poly1305. + * + * @param key - 32-byte encryption key + * @param plaintext - Data to encrypt + * @returns Ciphertext (including auth tag) and nonce + * @throws Error if key length is invalid + */ +export async function encrypt( + key: Uint8Array, + plaintext: Uint8Array +): Promise { + if (key.length !== KEY_SIZE) { + throw new Error(`Invalid key length: expected ${KEY_SIZE}, got ${key.length}`) + } + + const sodium = await getSodium() + const nonce = await generateNonce() + + const ciphertext = sodium.crypto_aead_xchacha20poly1305_ietf_encrypt( + plaintext, + null, // no additional data + null, // secret nonce (not used) + nonce, + key + ) + + return { ciphertext, nonce } +} + +/** + * Decrypt ciphertext using XChaCha20-Poly1305. + * + * @param key - 32-byte encryption key + * @param ciphertext - Encrypted data (including auth tag) + * @param nonce - 24-byte nonce used during encryption + * @returns Decrypted plaintext + * @throws Error if decryption fails (wrong key, corrupted data, or tampered) + */ +export async function decrypt( + key: Uint8Array, + ciphertext: Uint8Array, + nonce: Uint8Array +): Promise { + if (key.length !== KEY_SIZE) { + throw new Error(`Invalid key length: expected ${KEY_SIZE}, got ${key.length}`) + } + if (nonce.length !== NONCE_SIZE) { + throw new Error(`Invalid nonce length: expected ${NONCE_SIZE}, got ${nonce.length}`) + } + + const sodium = await getSodium() + + try { + return sodium.crypto_aead_xchacha20poly1305_ietf_decrypt( + null, // secret nonce (not used) + ciphertext, + null, // no additional data + nonce, + key + ) + } catch { + throw new Error('Decryption failed: authentication tag mismatch or corrupted data') + } +} + +/** + * Encrypt a DEK (Data Encryption Key) with a KEK (Key Encryption Key). + * + * @param kek - 32-byte Key Encryption Key + * @param dek - 32-byte Data Encryption Key to encrypt + * @returns Encrypted DEK and nonce + */ +export async function encryptDek( + kek: Uint8Array, + dek: Uint8Array +): Promise { + if (dek.length !== KEY_SIZE) { + throw new Error(`Invalid DEK length: expected ${KEY_SIZE}, got ${dek.length}`) + } + return encrypt(kek, dek) +} + +/** + * Decrypt a DEK (Data Encryption Key) with a KEK (Key Encryption Key). + * + * @param kek - 32-byte Key Encryption Key + * @param encryptedDek - Encrypted DEK (including auth tag) + * @param nonce - 24-byte nonce used during encryption + * @returns Decrypted 32-byte DEK + */ +export async function decryptDek( + kek: Uint8Array, + encryptedDek: Uint8Array, + nonce: Uint8Array +): Promise { + const decrypted = await decrypt(kek, encryptedDek, nonce) + if (decrypted.length !== KEY_SIZE) { + throw new Error(`Invalid decrypted DEK length: expected ${KEY_SIZE}, got ${decrypted.length}`) + } + return decrypted +} + +/** + * Encrypt string data using XChaCha20-Poly1305. + * + * @param key - 32-byte encryption key + * @param plaintext - String to encrypt (UTF-8 encoded) + * @returns Ciphertext and nonce + */ +export async function encryptString( + key: Uint8Array, + plaintext: string +): Promise { + const encoder = new TextEncoder() + return encrypt(key, encoder.encode(plaintext)) +} + +/** + * Decrypt to string using XChaCha20-Poly1305. + * + * @param key - 32-byte encryption key + * @param ciphertext - Encrypted data + * @param nonce - 24-byte nonce + * @returns Decrypted string (UTF-8 decoded) + */ +export async function decryptString( + key: Uint8Array, + ciphertext: Uint8Array, + nonce: Uint8Array +): Promise { + const decrypted = await decrypt(key, ciphertext, nonce) + const decoder = new TextDecoder() + return decoder.decode(decrypted) +} diff --git a/app/src/features/e2ee/lib/types/errors.ts b/app/src/features/e2ee/lib/types/errors.ts new file mode 100644 index 00000000..aa7db4e2 --- /dev/null +++ b/app/src/features/e2ee/lib/types/errors.ts @@ -0,0 +1,140 @@ +/** + * E2EE Error Types + * + * Error codes and types for E2EE operations. + */ + +/** E2EE error codes */ +export const E2EE_ERROR_CODES = { + // Key errors (1xxx) + KEY_NOT_FOUND: 'E2EE_KEY_NOT_FOUND', + KEY_INVALID: 'E2EE_KEY_INVALID', + KEY_EXPIRED: 'E2EE_KEY_EXPIRED', + KEY_GENERATION_FAILED: 'E2EE_KEY_GENERATION_FAILED', + KEY_DERIVATION_FAILED: 'E2EE_KEY_DERIVATION_FAILED', + + // Crypto errors (2xxx) + ENCRYPTION_FAILED: 'E2EE_ENCRYPTION_FAILED', + DECRYPTION_FAILED: 'E2EE_DECRYPTION_FAILED', + SIGNATURE_INVALID: 'E2EE_SIGNATURE_INVALID', + SIGNATURE_FAILED: 'E2EE_SIGNATURE_FAILED', + NONCE_REUSE: 'E2EE_NONCE_REUSE', + + // Session errors (3xxx) + SESSION_LOCKED: 'E2EE_SESSION_LOCKED', + SESSION_EXPIRED: 'E2EE_SESSION_EXPIRED', + PASSPHRASE_INVALID: 'E2EE_PASSPHRASE_INVALID', + RECOVERY_KEY_INVALID: 'E2EE_RECOVERY_KEY_INVALID', + + // Setup errors (4xxx) + SETUP_INCOMPLETE: 'E2EE_SETUP_INCOMPLETE', + SETUP_FAILED: 'E2EE_SETUP_FAILED', + MIGRATION_FAILED: 'E2EE_MIGRATION_FAILED', + + // Sync errors (5xxx) + SYNC_CONFLICT: 'E2EE_SYNC_CONFLICT', + SYNC_FAILED: 'E2EE_SYNC_FAILED', + MESSAGE_INVALID: 'E2EE_MESSAGE_INVALID', + + // Storage errors (6xxx) + STORAGE_FAILED: 'E2EE_STORAGE_FAILED', + STORAGE_NOT_AVAILABLE: 'E2EE_STORAGE_NOT_AVAILABLE', + + // File errors (7xxx) + FILE_FORMAT_INVALID: 'E2EE_FILE_FORMAT_INVALID', + FILE_CORRUPTED: 'E2EE_FILE_CORRUPTED', + + // Network errors (8xxx) + NETWORK_FAILED: 'E2EE_NETWORK_FAILED', + SERVER_ERROR: 'E2EE_SERVER_ERROR', +} as const + +export type E2EEErrorCode = typeof E2EE_ERROR_CODES[keyof typeof E2EE_ERROR_CODES] + +/** + * E2EE Error class + */ +export class E2EEError extends Error { + readonly code: E2EEErrorCode + readonly cause?: Error + + constructor(code: E2EEErrorCode, message: string, cause?: Error) { + super(message) + this.name = 'E2EEError' + this.code = code + this.cause = cause + } + + /** + * Check if this error is recoverable + */ + isRecoverable(): boolean { + switch (this.code) { + case E2EE_ERROR_CODES.SESSION_LOCKED: + case E2EE_ERROR_CODES.SESSION_EXPIRED: + case E2EE_ERROR_CODES.PASSPHRASE_INVALID: + case E2EE_ERROR_CODES.NETWORK_FAILED: + return true + default: + return false + } + } + + /** + * Get user-friendly error message + */ + getUserMessage(): string { + switch (this.code) { + case E2EE_ERROR_CODES.SESSION_LOCKED: + return 'Your session is locked. Please enter your passphrase to continue.' + case E2EE_ERROR_CODES.PASSPHRASE_INVALID: + return 'The passphrase you entered is incorrect.' + case E2EE_ERROR_CODES.RECOVERY_KEY_INVALID: + return 'The recovery key is invalid. Please check and try again.' + case E2EE_ERROR_CODES.DECRYPTION_FAILED: + return 'Failed to decrypt the content. The data may be corrupted.' + case E2EE_ERROR_CODES.SIGNATURE_INVALID: + return 'The message signature is invalid. The content may have been tampered with.' + case E2EE_ERROR_CODES.KEY_NOT_FOUND: + return 'The encryption key was not found. You may not have access to this content.' + case E2EE_ERROR_CODES.SETUP_INCOMPLETE: + return 'E2EE setup is not complete. Please complete the setup process.' + case E2EE_ERROR_CODES.NETWORK_FAILED: + return 'Network error. Please check your connection and try again.' + default: + return this.message + } + } +} + +/** + * Create an E2EE error + */ +export function createE2EEError( + code: E2EEErrorCode, + message: string, + cause?: Error +): E2EEError { + return new E2EEError(code, message, cause) +} + +/** + * Check if an error is an E2EE error + */ +export function isE2EEError(error: unknown): error is E2EEError { + return error instanceof E2EEError +} + +/** + * Wrap an error as an E2EE error + */ +export function wrapError( + code: E2EEErrorCode, + error: unknown, + fallbackMessage: string +): E2EEError { + if (error instanceof Error) { + return new E2EEError(code, error.message, error) + } + return new E2EEError(code, fallbackMessage) +} diff --git a/app/src/features/e2ee/lib/types/file-format.ts b/app/src/features/e2ee/lib/types/file-format.ts new file mode 100644 index 00000000..bdf92694 --- /dev/null +++ b/app/src/features/e2ee/lib/types/file-format.ts @@ -0,0 +1,126 @@ +/** + * Encrypted File Format (.rme) Types + * + * RefMD Encrypted file format for attachments and exports. + */ + +/** Magic bytes for .rme files: "RME1" */ +export const RME_MAGIC = new Uint8Array([0x52, 0x4d, 0x45, 0x31]) // "RME1" + +/** Current .rme format version */ +export const RME_VERSION = 1 + +/** + * .rme file header structure + */ +export interface RmeHeader { + /** Magic bytes (4 bytes): "RME1" */ + magic: Uint8Array + /** Format version (1 byte) */ + version: number + /** Header length in bytes (2 bytes, little-endian) */ + headerLength: number + /** Metadata length in bytes (4 bytes, little-endian) */ + metadataLength: number +} + +/** + * Encrypted file metadata + * This is encrypted and stored after the header + */ +export interface RmeMetadata { + /** Original filename */ + filename: string + /** MIME type */ + mimeType: string + /** Original file size in bytes */ + originalSize: number + /** SHA-256 hash of original file (hex) */ + originalHash: string + /** Timestamp when encrypted */ + encryptedAt: string + /** Additional metadata */ + extra?: Record +} + +/** + * Complete .rme file structure (logical, not byte layout) + */ +export interface RmeFile { + /** File header */ + header: RmeHeader + /** Nonce for metadata encryption (24 bytes) */ + metadataNonce: Uint8Array + /** Encrypted metadata */ + encryptedMetadata: Uint8Array + /** Nonce for content encryption (24 bytes) */ + contentNonce: Uint8Array + /** Encrypted content */ + encryptedContent: Uint8Array +} + +/** + * Parsed .rme file with decrypted content + */ +export interface DecryptedRmeFile { + /** Decrypted metadata */ + metadata: RmeMetadata + /** Decrypted content */ + content: Uint8Array +} + +/** + * File encryption options + */ +export interface EncryptFileOptions { + /** Original filename */ + filename: string + /** MIME type (auto-detected if not provided) */ + mimeType?: string + /** Additional metadata */ + extra?: Record +} + +/** + * Chunked encryption for large files + */ +export interface ChunkInfo { + /** Chunk index (0-based) */ + index: number + /** Total number of chunks */ + total: number + /** Chunk size in bytes */ + size: number + /** Nonce for this chunk */ + nonce: Uint8Array +} + +/** Default chunk size for large file encryption (1 MB) */ +export const DEFAULT_CHUNK_SIZE = 1024 * 1024 + +/** + * Validate RME magic bytes + */ +export function isRmeFile(data: Uint8Array): boolean { + if (data.length < RME_MAGIC.length) { + return false + } + return RME_MAGIC.every((byte, i) => data[i] === byte) +} + +/** + * Get file extension for .rme files + */ +export function getRmeExtension(originalFilename: string): string { + return `${originalFilename}.rme` +} + +/** + * Remove .rme extension to get original filename + */ +export function getOriginalFilename(rmeFilename: string): string { + if (rmeFilename.endsWith('.rme')) { + return rmeFilename.slice(0, -4) + } + return rmeFilename +} diff --git a/app/src/features/e2ee/lib/types/index.ts b/app/src/features/e2ee/lib/types/index.ts new file mode 100644 index 00000000..ce0a5113 --- /dev/null +++ b/app/src/features/e2ee/lib/types/index.ts @@ -0,0 +1,8 @@ +/** + * E2EE Type Definitions + */ + +export * from './keys' +export * from './messages' +export * from './file-format' +export * from './errors' diff --git a/app/src/features/e2ee/lib/types/keys.ts b/app/src/features/e2ee/lib/types/keys.ts new file mode 100644 index 00000000..c351157e --- /dev/null +++ b/app/src/features/e2ee/lib/types/keys.ts @@ -0,0 +1,154 @@ +/** + * E2EE Key Types + * + * Type definitions for the key hierarchy: + * UMK -> User Key Pair -> Workspace KEK -> Document DEK + */ + +/** User Master Key - the root of the key hierarchy */ +export interface UserMasterKey { + /** 32-byte master key (stored encrypted with passphrase) */ + key: Uint8Array + /** Salt used for passphrase derivation */ + salt: Uint8Array + /** KDF used (argon2id or pbkdf2) */ + kdf: 'argon2id' | 'pbkdf2' + /** KDF parameters */ + kdfParams: Argon2Params | Pbkdf2Params +} + +export interface Argon2Params { + type: 'argon2id' + memory: number + iterations: number + parallelism: number +} + +export interface Pbkdf2Params { + type: 'pbkdf2' + iterations: number +} + +/** ECDH Key Pair for key exchange */ +export interface EcdhKeyPair { + /** 32-byte private key */ + privateKey: Uint8Array + /** 65-byte uncompressed public key */ + publicKey: Uint8Array +} + +/** Ed25519 Key Pair for signing */ +export interface SigningKeyPair { + /** 64-byte private key (libsodium format) */ + privateKey: Uint8Array + /** 32-byte public key */ + publicKey: Uint8Array +} + +/** User's complete key set */ +export interface UserKeys { + /** User Master Key */ + umk: Uint8Array + /** ECDH key pair for key exchange */ + ecdhKeyPair: EcdhKeyPair + /** Ed25519 key pair for signing */ + signingKeyPair: SigningKeyPair +} + +/** Workspace Key Encryption Key */ +export interface WorkspaceKek { + /** Workspace ID */ + workspaceId: string + /** 32-byte KEK */ + key: Uint8Array + /** Key version for rotation */ + version: number +} + +/** Encrypted KEK as stored on server */ +export interface EncryptedWorkspaceKek { + /** Workspace ID */ + workspaceId: string + /** User ID this is encrypted for */ + userId: string + /** Encrypted KEK */ + encryptedKey: Uint8Array + /** Nonce used for encryption */ + nonce: Uint8Array + /** Ephemeral public key (for ECDH) */ + ephemeralPublicKey: Uint8Array + /** Key version */ + version: number +} + +/** Document Data Encryption Key */ +export interface DocumentDek { + /** Document ID */ + documentId: string + /** 32-byte DEK */ + key: Uint8Array + /** Key version for rotation */ + version: number +} + +/** Encrypted DEK as stored on server */ +export interface EncryptedDocumentDek { + /** Document ID */ + documentId: string + /** Workspace ID */ + workspaceId: string + /** Encrypted DEK */ + encryptedKey: Uint8Array + /** Nonce used for encryption */ + nonce: Uint8Array + /** Key version */ + version: number + /** KEK version used to encrypt this DEK */ + kekVersion: number +} + +/** Share Key for shared links */ +export interface ShareKey { + /** Share token */ + token: string + /** 32-byte share key */ + key: Uint8Array + /** Whether this is password-derived */ + isPasswordProtected: boolean +} + +/** Encrypted share key as stored on server */ +export interface EncryptedShareKey { + /** Share token */ + token: string + /** Document ID */ + documentId: string + /** Encrypted DEK (encrypted with share key) */ + encryptedDek: Uint8Array + /** Nonce used for encryption */ + nonce: Uint8Array + /** Salt for password-protected shares (if applicable) */ + salt?: Uint8Array +} + +/** Key cache entry with expiration */ +export interface CachedKey { + /** The cached key */ + key: T + /** Timestamp when cached */ + cachedAt: number + /** Time-to-live in milliseconds */ + ttl: number +} + +/** Public key as stored on server */ +export interface UserPublicKeys { + /** User ID */ + userId: string + /** ECDH public key (Base64) */ + ecdhPublicKey: string + /** Ed25519 signing public key (Base64) */ + signingPublicKey: string + /** When the keys were registered */ + createdAt: string +} diff --git a/app/src/features/e2ee/lib/types/messages.ts b/app/src/features/e2ee/lib/types/messages.ts new file mode 100644 index 00000000..e3596240 --- /dev/null +++ b/app/src/features/e2ee/lib/types/messages.ts @@ -0,0 +1,124 @@ +/** + * E2EE Realtime Message Types + * + * Compatible with backend (api/crates/application/src/documents/ports/realtime/realtime_types.rs) + */ + +/** Message types for E2EE realtime */ +export type MessageType = 'update' | 'snapshot' | 'awareness' + +/** + * E2EE realtime message (JSON format over WebSocket) + * Field names match backend (camelCase in JSON) + */ +export interface RealtimeMessage { + /** Message type */ + type: MessageType + /** Base64-encoded ciphertext (XChaCha20-Poly1305) */ + ciphertext: string + /** Base64-encoded nonce (24 bytes) */ + nonce: string + /** Base64-encoded Ed25519 signature */ + signature: string + /** Base64-encoded canonicalized JSON publicData */ + publicData: string +} + +/** + * Update public data structure + * Sent with each Yjs update + */ +export interface UpdatePublicData { + /** Document ID */ + docId: string + /** Ed25519 public key (Base64) */ + pubKey: string + /** Reference snapshot ID */ + refSnapshotId: string + /** Logical clock for ordering */ + clock: number +} + +/** + * Snapshot public data structure + * Sent when creating a snapshot + */ +export interface SnapshotPublicData { + /** Document ID */ + docId: string + /** Ed25519 public key (Base64) */ + pubKey: string + /** Snapshot ID */ + snapshotId: string + /** Parent snapshot ID */ + parentSnapshotId: string + /** Parent snapshot proof (hash chain) */ + parentSnapshotProof: string + /** Update clocks at time of snapshot (pubKey -> clock) */ + parentSnapshotUpdateClocks: Record +} + +/** + * Ephemeral message public data (for Awareness) + */ +export interface EphemeralPublicData { + /** Document ID */ + docId: string + /** Ed25519 public key (Base64) */ + pubKey: string +} + +/** + * Snapshot info with update clocks + * Used for tracking sync state + */ +export interface SnapshotInfoWithUpdateClocks { + /** Snapshot ID */ + snapshotId: string + /** Hash of snapshot ciphertext */ + snapshotCiphertextHash: string + /** Parent snapshot proof */ + parentSnapshotProof: string + /** Update clocks at this snapshot */ + updateClocks: Record + /** Additional public data */ + additionalPublicData?: unknown +} + +/** + * Create a RealtimeMessage + */ +export function createRealtimeMessage( + type: MessageType, + ciphertext: string, + nonce: string, + signature: string, + publicData: string +): RealtimeMessage { + return { + type, + ciphertext, + nonce, + signature, + publicData, + } +} + +/** + * Parse public data from a realtime message + */ +export function parsePublicData(publicDataBase64: string): T { + const decoded = atob(publicDataBase64) + return JSON.parse(decoded) as T +} + +/** + * Signature domains for E2EE messages + */ +export const SIGNATURE_DOMAINS = { + SNAPSHOT: 'refmd_snapshot', + UPDATE: 'refmd_update', + EPHEMERAL: 'refmd_ephemeral', +} as const + +export type SignatureDomain = typeof SIGNATURE_DOMAINS[keyof typeof SIGNATURE_DOMAINS] From cc437777cf47df2aaa80896853cdd13476870b60 Mon Sep 17 00:00:00 2001 From: munenick Date: Fri, 9 Jan 2026 13:10:15 +0900 Subject: [PATCH 10/45] add: key management --- app/src/features/e2ee/index.ts | 169 ++++- .../e2ee/lib/keys/__tests__/key-cache.test.ts | 216 +++++++ .../e2ee/lib/keys/__tests__/share-key.test.ts | 104 +++ .../features/e2ee/lib/keys/document-dek.ts | 217 +++++++ app/src/features/e2ee/lib/keys/index.ts | 114 ++++ app/src/features/e2ee/lib/keys/key-cache.ts | 235 +++++++ app/src/features/e2ee/lib/keys/key-manager.ts | 605 ++++++++++++++++++ app/src/features/e2ee/lib/keys/key-store.ts | 263 ++++++++ app/src/features/e2ee/lib/keys/share-key.ts | 211 ++++++ app/src/features/e2ee/lib/keys/umk.ts | 252 ++++++++ app/src/features/e2ee/lib/keys/user-keys.ts | 184 ++++++ .../features/e2ee/lib/keys/workspace-kek.ts | 210 ++++++ 12 files changed, 2778 insertions(+), 2 deletions(-) create mode 100644 app/src/features/e2ee/lib/keys/__tests__/key-cache.test.ts create mode 100644 app/src/features/e2ee/lib/keys/__tests__/share-key.test.ts create mode 100644 app/src/features/e2ee/lib/keys/document-dek.ts create mode 100644 app/src/features/e2ee/lib/keys/index.ts create mode 100644 app/src/features/e2ee/lib/keys/key-cache.ts create mode 100644 app/src/features/e2ee/lib/keys/key-manager.ts create mode 100644 app/src/features/e2ee/lib/keys/key-store.ts create mode 100644 app/src/features/e2ee/lib/keys/share-key.ts create mode 100644 app/src/features/e2ee/lib/keys/umk.ts create mode 100644 app/src/features/e2ee/lib/keys/user-keys.ts create mode 100644 app/src/features/e2ee/lib/keys/workspace-kek.ts diff --git a/app/src/features/e2ee/index.ts b/app/src/features/e2ee/index.ts index 960fa0cb..8c8894bc 100644 --- a/app/src/features/e2ee/index.ts +++ b/app/src/features/e2ee/index.ts @@ -4,8 +4,173 @@ * End-to-End Encryption for RefMD. */ -// Crypto primitives -export * from './lib/crypto' +// Crypto primitives (explicit exports to avoid conflicts) +export { + // Sodium + getSodium, + isSodiumReady, + type Sodium, + // XChaCha20 + encrypt, + decrypt, + encryptDek, + decryptDek, + encryptString, + decryptString, + generateKey, + generateNonce, + NONCE_SIZE, + KEY_SIZE, + TAG_SIZE, + type EncryptResult, + // Ed25519 + sign, + verify, + signToBase64, + verifyFromBase64, + generateSigningKeyPair, + buildSigningMessage, + SIGNATURE_DOMAINS, + PUBLIC_KEY_SIZE, + PRIVATE_KEY_SIZE, + SIGNATURE_SIZE, + type Ed25519KeyPair, + type SigningMessage, + type SignatureDomain, + // Canonicalize + canonicalize, + canonicalizeAndToBase64, + fromBase64Json, + toBase64, + fromBase64, + // HKDF + hkdfDeriveKey, + hkdfGenerateMasterKey, + HKDF_CONTEXTS, + SUBKEY_IDS, + MASTER_KEY_SIZE, + MIN_KEY_SIZE, + MAX_KEY_SIZE, + // Argon2 + argon2DeriveKey, + argon2DeriveKeyWithNewSalt, + argon2GenerateSalt, + isArgon2Supported, + DEFAULT_ARGON2_PARAMS, + ARGON2_SALT_SIZE, + // Note: Argon2Params is exported from types + // PBKDF2 + pbkdf2DeriveKey, + pbkdf2DeriveKeyWithNewSalt, + pbkdf2GenerateSalt, + PBKDF2_DEFAULT_ITERATIONS, + PBKDF2_SALT_SIZE, + // ECDH + generateEcdhKeyPair, + getEcdhPublicKey, + computeSharedSecret, + deriveSharedKey, + encryptKeyForRecipient, + decryptKeyFromSender, + isValidEcdhPublicKey, + ECDH_PRIVATE_KEY_SIZE, + ECDH_PUBLIC_KEY_SIZE, + SHARED_SECRET_SIZE, + // Note: EcdhKeyPair is exported from types + // BIP39 + generateRecoveryKey, + validateRecoveryKey, + recoveryKeyToUmk, + umkToRecoveryKey, + getWordsAtIndices, + verifyWords, + generateVerificationIndices, + getWordList, + WORD_COUNT, + ENTROPY_BITS, + ENTROPY_BYTES, +} from './lib/crypto' // Type definitions export * from './lib/types' + +// Key management (explicit exports to avoid conflicts) +export { + // KeyManager + KeyManager, + getKeyManager, + resetKeyManager, + SessionLockedError, + KeyNotFoundError, + type E2EESetupResult, + // KeyStore + KeyStore, + getKeyStore, + type StoredKeys, + // KeyCache + KeyCache, + KekCache, + DekCache, + getKekCache, + getDekCache, + clearAllCaches, + DEFAULT_KEK_CACHE_SIZE, + DEFAULT_DEK_CACHE_SIZE, + // UMK + generateUmk, + deriveUmkFromPassphrase, + restoreUmkFromRecoveryKey, + verifyPassphrase, + generateNewRecoveryKey, + zeroUmk, + UMK_SIZE, + type UmkGenerationResult, + // User Keys + generateUserKeys, + encryptUserKeys, + decryptUserKeys, + reEncryptUserKeys, + getPublicKeysBase64, + parsePublicKeysFromBase64, + zeroUserKeys, + type UserKeySet, + type EncryptedUserKeys, + // Workspace KEK + generateWorkspaceKek, + encryptKekForRecipient, + decryptKek, + decryptKekFromApiResponse, + encodeKekForApi, + getOrFetchKek, + invalidateCachedKek, + createKekForMember, + KEK_SIZE, + type EncryptedKekFromApi, + // Document DEK + generateDocumentDek, + encryptDekWithKek, + decryptDekWithKek, + decryptDekFromApiResponse, + encodeDekForApi, + createEncryptedDekForApi, + getOrFetchDek, + invalidateCachedDek, + invalidateWorkspaceDeks, + reEncryptDek, + DEK_SIZE, + type EncryptedDekFromApi, + // Share Keys + generateShareKey, + extractShareKeyFromFragment, + deriveShareKeyFromPassword, + createPasswordProtectedShareKey, + encryptDekWithShareKey, + decryptDekWithShareKey, + buildShareUrl, + parseSaltFromApi, + encodeSaltForApi, + hasShareKeyFragment, + SHARE_KEY_SIZE, + URL_FRAGMENT_PREFIX, + type EncryptedShareKeyForApi, +} from './lib/keys' diff --git a/app/src/features/e2ee/lib/keys/__tests__/key-cache.test.ts b/app/src/features/e2ee/lib/keys/__tests__/key-cache.test.ts new file mode 100644 index 00000000..f3803635 --- /dev/null +++ b/app/src/features/e2ee/lib/keys/__tests__/key-cache.test.ts @@ -0,0 +1,216 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { KeyCache, KekCache, DekCache, clearAllCaches } from '../key-cache' + +describe('KeyCache', () => { + describe('basic operations', () => { + let cache: KeyCache + + beforeEach(() => { + cache = new KeyCache(3) + }) + + it('should set and get values', () => { + cache.set('a', 'value-a') + expect(cache.get('a')).toBe('value-a') + }) + + it('should return undefined for missing keys', () => { + expect(cache.get('missing')).toBeUndefined() + }) + + it('should update existing values', () => { + cache.set('a', 'value-1') + cache.set('a', 'value-2') + expect(cache.get('a')).toBe('value-2') + }) + + it('should delete values', () => { + cache.set('a', 'value-a') + expect(cache.delete('a')).toBe(true) + expect(cache.get('a')).toBeUndefined() + }) + + it('should return false when deleting non-existent key', () => { + expect(cache.delete('missing')).toBe(false) + }) + + it('should check if key exists', () => { + cache.set('a', 'value-a') + expect(cache.has('a')).toBe(true) + expect(cache.has('missing')).toBe(false) + }) + + it('should clear all values', () => { + cache.set('a', 'value-a') + cache.set('b', 'value-b') + cache.clear() + expect(cache.size).toBe(0) + expect(cache.get('a')).toBeUndefined() + }) + + it('should track size', () => { + expect(cache.size).toBe(0) + cache.set('a', 'value-a') + expect(cache.size).toBe(1) + cache.set('b', 'value-b') + expect(cache.size).toBe(2) + cache.delete('a') + expect(cache.size).toBe(1) + }) + + it('should return all keys', () => { + cache.set('a', 'value-a') + cache.set('b', 'value-b') + expect(cache.keys()).toEqual(expect.arrayContaining(['a', 'b'])) + }) + }) + + describe('LRU eviction', () => { + let cache: KeyCache + + beforeEach(() => { + cache = new KeyCache(3) + }) + + it('should evict oldest entry when at capacity', () => { + cache.set('a', 'value-a') + cache.set('b', 'value-b') + cache.set('c', 'value-c') + cache.set('d', 'value-d') // should evict 'a' + + expect(cache.get('a')).toBeUndefined() + expect(cache.get('b')).toBe('value-b') + expect(cache.get('c')).toBe('value-c') + expect(cache.get('d')).toBe('value-d') + expect(cache.size).toBe(3) + }) + + it('should update access time on get', async () => { + // Set 'a' first (oldest by insertion) + cache.set('a', 'value-a') + + // Wait to ensure different timestamps + await new Promise(resolve => setTimeout(resolve, 50)) + + // Set 'b' and 'c' + cache.set('b', 'value-b') + await new Promise(resolve => setTimeout(resolve, 50)) + cache.set('c', 'value-c') + + // Wait and access 'a' to update its timestamp (makes it newest) + await new Promise(resolve => setTimeout(resolve, 50)) + const accessedA = cache.get('a') + expect(accessedA).toBe('value-a') + + // Now timestamps should be: b (oldest), c (middle), a (newest) + // Wait a bit before adding 'd' + await new Promise(resolve => setTimeout(resolve, 50)) + + // Add 'd', should evict 'b' (oldest by access time) + cache.set('d', 'value-d') + + expect(cache.get('a')).toBe('value-a') // still here (was accessed recently) + expect(cache.get('b')).toBeUndefined() // evicted (oldest) + expect(cache.get('c')).toBe('value-c') + expect(cache.get('d')).toBe('value-d') + }) + }) + + describe('Uint8Array handling', () => { + it('should zero out Uint8Array values on clear', () => { + const cache = new KeyCache(3) + const value = new Uint8Array([1, 2, 3, 4]) + cache.set('key', value) + cache.clear() + + // Original array should be zeroed + expect(Array.from(value)).toEqual([0, 0, 0, 0]) + }) + + it('should zero out Uint8Array values on eviction', async () => { + const cache = new KeyCache(2) + const value1 = new Uint8Array([1, 2, 3]) + + cache.set('a', value1) + + await new Promise(resolve => setTimeout(resolve, 10)) + + cache.set('b', new Uint8Array([4, 5, 6])) + + await new Promise(resolve => setTimeout(resolve, 10)) + + // This should evict 'a' + cache.set('c', new Uint8Array([7, 8, 9])) + + // Original value should be zeroed + expect(Array.from(value1)).toEqual([0, 0, 0]) + }) + }) +}) + +describe('KekCache', () => { + let cache: KekCache + + beforeEach(() => { + cache = new KekCache(3) + }) + + it('should set and get KEK by workspace ID', () => { + const kek = new Uint8Array(32).fill(1) + cache.setKek('ws-1', kek) + expect(cache.getKek('ws-1')).toBe(kek) + }) + + it('should delete KEK', () => { + const kek = new Uint8Array(32).fill(1) + cache.setKek('ws-1', kek) + expect(cache.deleteKek('ws-1')).toBe(true) + expect(cache.getKek('ws-1')).toBeUndefined() + }) +}) + +describe('DekCache', () => { + let cache: DekCache + + beforeEach(() => { + cache = new DekCache(3) + }) + + it('should set and get DEK by document ID', () => { + const dek = new Uint8Array(32).fill(2) + cache.setDek('doc-1', dek) + expect(cache.getDek('doc-1')).toBe(dek) + }) + + it('should delete DEK', () => { + const dek = new Uint8Array(32).fill(2) + cache.setDek('doc-1', dek) + expect(cache.deleteDek('doc-1')).toBe(true) + expect(cache.getDek('doc-1')).toBeUndefined() + }) + + it('should delete multiple DEKs by workspace', () => { + cache.setDek('doc-1', new Uint8Array(32).fill(1)) + cache.setDek('doc-2', new Uint8Array(32).fill(2)) + cache.setDek('doc-3', new Uint8Array(32).fill(3)) + + cache.deleteByWorkspace(['doc-1', 'doc-2']) + + expect(cache.getDek('doc-1')).toBeUndefined() + expect(cache.getDek('doc-2')).toBeUndefined() + expect(cache.getDek('doc-3')).toBeDefined() + }) +}) + +describe('clearAllCaches', () => { + it('should clear both KEK and DEK caches', () => { + const kekCache = new KekCache() + const dekCache = new DekCache() + + kekCache.setKek('ws-1', new Uint8Array(32).fill(1)) + dekCache.setDek('doc-1', new Uint8Array(32).fill(2)) + + // Note: clearAllCaches uses singletons, so this test is more of a smoke test + clearAllCaches() + }) +}) diff --git a/app/src/features/e2ee/lib/keys/__tests__/share-key.test.ts b/app/src/features/e2ee/lib/keys/__tests__/share-key.test.ts new file mode 100644 index 00000000..ac6fc927 --- /dev/null +++ b/app/src/features/e2ee/lib/keys/__tests__/share-key.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, beforeAll } from 'vitest' +import { + generateShareKey, + extractShareKeyFromFragment, + hasShareKeyFragment, + buildShareUrl, + URL_FRAGMENT_PREFIX, + SHARE_KEY_SIZE, +} from '../share-key' +import { getSodium } from '../../crypto' + +describe('Share Key', () => { + beforeAll(async () => { + // Initialize sodium + await getSodium() + }) + + describe('generateShareKey', () => { + it('should generate a key and fragment', async () => { + const result = await generateShareKey() + + expect(result.key).toBeInstanceOf(Uint8Array) + expect(result.key.length).toBe(SHARE_KEY_SIZE) + expect(result.fragment).toMatch(new RegExp(`^${URL_FRAGMENT_PREFIX}`)) + }) + + it('should generate unique keys', async () => { + const result1 = await generateShareKey() + const result2 = await generateShareKey() + + expect(result1.key).not.toEqual(result2.key) + expect(result1.fragment).not.toBe(result2.fragment) + }) + }) + + describe('extractShareKeyFromFragment', () => { + it('should extract key from valid fragment', async () => { + const original = await generateShareKey() + const extracted = await extractShareKeyFromFragment(original.fragment) + + expect(extracted).toBeInstanceOf(Uint8Array) + expect(Array.from(extracted!)).toEqual(Array.from(original.key)) + }) + + it('should handle fragment with leading #', async () => { + const original = await generateShareKey() + const extracted = await extractShareKeyFromFragment('#' + original.fragment) + + expect(extracted).toBeInstanceOf(Uint8Array) + expect(Array.from(extracted!)).toEqual(Array.from(original.key)) + }) + + it('should return null for invalid fragment', async () => { + const result = await extractShareKeyFromFragment('invalid') + expect(result).toBeNull() + }) + + it('should return null for fragment without prefix', async () => { + const result = await extractShareKeyFromFragment('other=value') + expect(result).toBeNull() + }) + + it('should return null for empty fragment', async () => { + const result = await extractShareKeyFromFragment('') + expect(result).toBeNull() + }) + }) + + describe('hasShareKeyFragment', () => { + it('should return true for valid fragment', async () => { + const { fragment } = await generateShareKey() + expect(hasShareKeyFragment(fragment)).toBe(true) + }) + + it('should return true for fragment with leading #', async () => { + const { fragment } = await generateShareKey() + expect(hasShareKeyFragment('#' + fragment)).toBe(true) + }) + + it('should return false for invalid fragment', () => { + expect(hasShareKeyFragment('invalid')).toBe(false) + expect(hasShareKeyFragment('')).toBe(false) + expect(hasShareKeyFragment('#other=value')).toBe(false) + }) + }) + + describe('buildShareUrl', () => { + it('should build URL with fragment', () => { + const url = buildShareUrl('https://refmd.io/share/abc123', 'key=xyz') + expect(url).toBe('https://refmd.io/share/abc123#key=xyz') + }) + + it('should replace existing fragment', () => { + const url = buildShareUrl('https://refmd.io/share/abc123#old', 'key=xyz') + expect(url).toBe('https://refmd.io/share/abc123#key=xyz') + }) + + it('should work with generated fragment', async () => { + const { fragment } = await generateShareKey() + const url = buildShareUrl('https://refmd.io/share/abc123', fragment) + expect(url).toBe(`https://refmd.io/share/abc123#${fragment}`) + }) + }) +}) diff --git a/app/src/features/e2ee/lib/keys/document-dek.ts b/app/src/features/e2ee/lib/keys/document-dek.ts new file mode 100644 index 00000000..c76736bc --- /dev/null +++ b/app/src/features/e2ee/lib/keys/document-dek.ts @@ -0,0 +1,217 @@ +/** + * Document DEK (Data Encryption Key) Management + * + * DEKs are used to encrypt document content. + * Each document has its own DEK, encrypted with the workspace KEK. + */ + +import { + generateKey, + encrypt, + decrypt, + getSodium, +} from '../crypto' +import { getDekCache } from './key-cache' + +/** DEK size (32 bytes) */ +export const DEK_SIZE = 32 + +/** Document DEK with metadata */ +export interface DocumentDek { + /** Document ID */ + documentId: string + /** 32-byte DEK */ + key: Uint8Array + /** Key version for rotation */ + version: number +} + +/** Encrypted DEK from API response */ +export interface EncryptedDekFromApi { + /** Encrypted DEK (Base64) */ + encryptedDek: string + /** Nonce (Base64) */ + nonce: string + /** Key version */ + keyVersion: number + /** Document ID */ + documentId: string +} + +/** + * Generate a new Document DEK. + * + * @returns 32-byte random DEK + */ +export async function generateDocumentDek(): Promise { + return generateKey() +} + +/** + * Encrypt a DEK with a workspace KEK. + * + * @param dek - The DEK to encrypt + * @param kek - Workspace KEK + * @returns Encrypted DEK with nonce + */ +export async function encryptDekWithKek( + dek: Uint8Array, + kek: Uint8Array +): Promise<{ + encryptedDek: Uint8Array + nonce: Uint8Array +}> { + const result = await encrypt(kek, dek) + return { + encryptedDek: result.ciphertext, + nonce: result.nonce, + } +} + +/** + * Decrypt a DEK with a workspace KEK. + * + * @param encryptedDek - Encrypted DEK + * @param nonce - Nonce used for encryption + * @param kek - Workspace KEK + * @returns Decrypted DEK + */ +export async function decryptDekWithKek( + encryptedDek: Uint8Array, + nonce: Uint8Array, + kek: Uint8Array +): Promise { + return decrypt(kek, encryptedDek, nonce) +} + +/** + * Decrypt a DEK from API response format. + * + * @param encryptedDekBase64 - Base64-encoded encrypted DEK + * @param nonceBase64 - Base64-encoded nonce + * @param kek - Workspace KEK + * @returns Decrypted DEK + */ +export async function decryptDekFromApiResponse( + encryptedDekBase64: string, + nonceBase64: string, + kek: Uint8Array +): Promise { + const sodium = await getSodium() + + const encryptedDek = sodium.from_base64(encryptedDekBase64, sodium.base64_variants.ORIGINAL) + const nonce = sodium.from_base64(nonceBase64, sodium.base64_variants.ORIGINAL) + + return decryptDekWithKek(encryptedDek, nonce, kek) +} + +/** + * Encode DEK for API request. + * + * @param encryptedDek - Encrypted DEK + * @param nonce - Nonce + * @returns Base64-encoded values + */ +export async function encodeDekForApi( + encryptedDek: Uint8Array, + nonce: Uint8Array +): Promise<{ + encryptedDek: string + nonce: string +}> { + const sodium = await getSodium() + + return { + encryptedDek: sodium.to_base64(encryptedDek, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), + } +} + +/** + * Create encrypted DEK ready for API request. + * + * @param dek - The DEK to encrypt + * @param kek - Workspace KEK + * @returns Base64-encoded encrypted DEK and nonce + */ +export async function createEncryptedDekForApi( + dek: Uint8Array, + kek: Uint8Array +): Promise<{ + encryptedDek: string + nonce: string +}> { + const { encryptedDek, nonce } = await encryptDekWithKek(dek, kek) + return encodeDekForApi(encryptedDek, nonce) +} + +/** + * Get DEK from cache or fetch from API. + * + * @param documentId - Document ID + * @param kek - Workspace KEK for decryption + * @param fetchDekFn - Function to fetch encrypted DEK from API + * @returns Decrypted DEK + */ +export async function getOrFetchDek( + documentId: string, + kek: Uint8Array, + fetchDekFn: () => Promise<{ encryptedDek: string; nonce: string }> +): Promise { + const cache = getDekCache() + + // Check cache first + const cached = cache.getDek(documentId) + if (cached) { + return cached + } + + // Fetch from API + const { encryptedDek, nonce } = await fetchDekFn() + + // Decrypt + const dek = await decryptDekFromApiResponse(encryptedDek, nonce, kek) + + // Cache the result + cache.setDek(documentId, dek) + + return dek +} + +/** + * Invalidate cached DEK (e.g., after rotation). + * + * @param documentId - Document ID + */ +export function invalidateCachedDek(documentId: string): void { + const cache = getDekCache() + cache.deleteDek(documentId) +} + +/** + * Invalidate all DEKs for documents in a workspace. + * Call this when workspace KEK is rotated. + * + * @param documentIds - Document IDs to invalidate + */ +export function invalidateWorkspaceDeks(documentIds: string[]): void { + const cache = getDekCache() + cache.deleteByWorkspace(documentIds) +} + +/** + * Re-encrypt a DEK with a new KEK (for KEK rotation). + * + * @param dek - The DEK to re-encrypt + * @param newKek - New workspace KEK + * @returns Encrypted DEK with new KEK + */ +export async function reEncryptDek( + dek: Uint8Array, + newKek: Uint8Array +): Promise<{ + encryptedDek: string + nonce: string +}> { + return createEncryptedDekForApi(dek, newKek) +} diff --git a/app/src/features/e2ee/lib/keys/index.ts b/app/src/features/e2ee/lib/keys/index.ts new file mode 100644 index 00000000..d899be08 --- /dev/null +++ b/app/src/features/e2ee/lib/keys/index.ts @@ -0,0 +1,114 @@ +/** + * E2EE Key Management Module + * + * This module provides all key management functionality for E2EE: + * - KeyManager: Main entry point for all key operations + * - KeyStore: IndexedDB storage for encrypted keys + * - KeyCache: LRU cache for KEK/DEK + * - Individual key modules for UMK, User Keys, KEK, DEK, Share Keys + */ + +// Main KeyManager class +export { + KeyManager, + getKeyManager, + resetKeyManager, + SessionLockedError, + KeyNotFoundError, + type E2EESetupResult, +} from './key-manager' + +// Key Store (IndexedDB) +export { + KeyStore, + getKeyStore, + type StoredKeys, +} from './key-store' + +// Key Cache (LRU) +export { + KeyCache, + KekCache, + DekCache, + getKekCache, + getDekCache, + clearAllCaches, + DEFAULT_KEK_CACHE_SIZE, + DEFAULT_DEK_CACHE_SIZE, +} from './key-cache' + +// UMK (User Master Key) +export { + generateUmk, + deriveUmkFromPassphrase, + restoreUmkFromRecoveryKey, + verifyPassphrase, + generateNewRecoveryKey, + validateRecoveryKey, + zeroUmk, + UMK_SIZE, + type UmkGenerationResult, +} from './umk' + +// User Keys (ECDH + Ed25519) +export { + generateUserKeys, + encryptUserKeys, + decryptUserKeys, + reEncryptUserKeys, + getPublicKeysBase64, + parsePublicKeysFromBase64, + zeroUserKeys, + type UserKeySet, + type EncryptedUserKeys, +} from './user-keys' + +// Workspace KEK +export { + generateWorkspaceKek, + encryptKekForRecipient, + decryptKek, + decryptKekFromApiResponse, + encodeKekForApi, + getOrFetchKek, + invalidateCachedKek, + createKekForMember, + KEK_SIZE, + type WorkspaceKek, + type EncryptedKekFromApi, +} from './workspace-kek' + +// Document DEK +export { + generateDocumentDek, + encryptDekWithKek, + decryptDekWithKek, + decryptDekFromApiResponse, + encodeDekForApi, + createEncryptedDekForApi, + getOrFetchDek, + invalidateCachedDek, + invalidateWorkspaceDeks, + reEncryptDek, + DEK_SIZE, + type DocumentDek, + type EncryptedDekFromApi, +} from './document-dek' + +// Share Keys +export { + generateShareKey, + extractShareKeyFromFragment, + deriveShareKeyFromPassword, + createPasswordProtectedShareKey, + encryptDekWithShareKey, + decryptDekWithShareKey, + buildShareUrl, + parseSaltFromApi, + encodeSaltForApi, + hasShareKeyFragment, + SHARE_KEY_SIZE, + URL_FRAGMENT_PREFIX, + type ShareKey, + type EncryptedShareKeyForApi, +} from './share-key' diff --git a/app/src/features/e2ee/lib/keys/key-cache.ts b/app/src/features/e2ee/lib/keys/key-cache.ts new file mode 100644 index 00000000..a73f916c --- /dev/null +++ b/app/src/features/e2ee/lib/keys/key-cache.ts @@ -0,0 +1,235 @@ +/** + * E2EE Key Cache + * + * LRU cache for KEK and DEK keys to avoid repeated API calls and decryption. + */ + +/** Default cache sizes */ +export const DEFAULT_KEK_CACHE_SIZE = 50 +export const DEFAULT_DEK_CACHE_SIZE = 200 + +/** Cache entry with metadata */ +interface CacheEntry { + value: T + accessedAt: number +} + +/** + * LRU Cache implementation for encryption keys + * + * Keys are stored in memory only and cleared on page unload. + */ +export class KeyCache { + private cache: Map> + private readonly maxSize: number + + constructor(maxSize: number) { + this.cache = new Map() + this.maxSize = maxSize + } + + /** + * Get a value from the cache + */ + get(key: string): T | undefined { + const entry = this.cache.get(key) + if (!entry) { + return undefined + } + + // Update access time for LRU + entry.accessedAt = Date.now() + return entry.value + } + + /** + * Set a value in the cache + */ + set(key: string, value: T): void { + // If key already exists, update it + if (this.cache.has(key)) { + this.cache.set(key, { + value, + accessedAt: Date.now(), + }) + return + } + + // Evict oldest entries if at capacity + if (this.cache.size >= this.maxSize) { + this.evictOldest() + } + + this.cache.set(key, { + value, + accessedAt: Date.now(), + }) + } + + /** + * Delete a value from the cache + */ + delete(key: string): boolean { + return this.cache.delete(key) + } + + /** + * Check if a key exists in the cache + */ + has(key: string): boolean { + return this.cache.has(key) + } + + /** + * Clear all entries from the cache + */ + clear(): void { + // Zero out key material before clearing + for (const entry of this.cache.values()) { + if (entry.value instanceof Uint8Array) { + entry.value.fill(0) + } + } + this.cache.clear() + } + + /** + * Get the current size of the cache + */ + get size(): number { + return this.cache.size + } + + /** + * Get all keys in the cache + */ + keys(): string[] { + return Array.from(this.cache.keys()) + } + + /** + * Evict the oldest (least recently accessed) entry + */ + private evictOldest(): void { + let oldestKey: string | null = null + let oldestTime = Infinity + + for (const [key, entry] of this.cache.entries()) { + if (entry.accessedAt < oldestTime) { + oldestTime = entry.accessedAt + oldestKey = key + } + } + + if (oldestKey !== null) { + // Zero out key material before eviction + const entry = this.cache.get(oldestKey) + if (entry?.value instanceof Uint8Array) { + entry.value.fill(0) + } + this.cache.delete(oldestKey) + } + } +} + +/** + * Specialized cache for Workspace KEKs + */ +export class KekCache extends KeyCache { + constructor(maxSize: number = DEFAULT_KEK_CACHE_SIZE) { + super(maxSize) + } + + /** + * Get KEK by workspace ID + */ + getKek(workspaceId: string): Uint8Array | undefined { + return this.get(workspaceId) + } + + /** + * Set KEK for workspace + */ + setKek(workspaceId: string, kek: Uint8Array): void { + this.set(workspaceId, kek) + } + + /** + * Delete KEK for workspace + */ + deleteKek(workspaceId: string): boolean { + return this.delete(workspaceId) + } +} + +/** + * Specialized cache for Document DEKs + */ +export class DekCache extends KeyCache { + constructor(maxSize: number = DEFAULT_DEK_CACHE_SIZE) { + super(maxSize) + } + + /** + * Get DEK by document ID + */ + getDek(documentId: string): Uint8Array | undefined { + return this.get(documentId) + } + + /** + * Set DEK for document + */ + setDek(documentId: string, dek: Uint8Array): void { + this.set(documentId, dek) + } + + /** + * Delete DEK for document + */ + deleteDek(documentId: string): boolean { + return this.delete(documentId) + } + + /** + * Delete all DEKs for documents in a workspace + * (useful when workspace KEK is rotated) + */ + deleteByWorkspace(documentIds: string[]): void { + for (const docId of documentIds) { + this.delete(docId) + } + } +} + +// Singleton instances +let kekCacheInstance: KekCache | null = null +let dekCacheInstance: DekCache | null = null + +/** + * Get the singleton KEK cache instance + */ +export function getKekCache(): KekCache { + if (!kekCacheInstance) { + kekCacheInstance = new KekCache() + } + return kekCacheInstance +} + +/** + * Get the singleton DEK cache instance + */ +export function getDekCache(): DekCache { + if (!dekCacheInstance) { + dekCacheInstance = new DekCache() + } + return dekCacheInstance +} + +/** + * Clear all key caches (for logout/lock) + */ +export function clearAllCaches(): void { + kekCacheInstance?.clear() + dekCacheInstance?.clear() +} diff --git a/app/src/features/e2ee/lib/keys/key-manager.ts b/app/src/features/e2ee/lib/keys/key-manager.ts new file mode 100644 index 00000000..5d77b67a --- /dev/null +++ b/app/src/features/e2ee/lib/keys/key-manager.ts @@ -0,0 +1,605 @@ +/** + * KeyManager - Central key management for E2EE + * + * This is the main entry point for all key operations. + * It coordinates between the KeyStore, KeyCache, and individual key modules. + */ + +import { KeyStore, getKeyStore, type StoredKeys } from './key-store' +import { clearAllCaches } from './key-cache' +import { + generateUmk, + deriveUmkFromPassphrase, + restoreUmkFromRecoveryKey, + verifyPassphrase, + zeroUmk, +} from './umk' +import { + generateUserKeys, + encryptUserKeys, + decryptUserKeys, + getPublicKeysBase64, + zeroUserKeys, + type UserKeySet, +} from './user-keys' +import { + generateWorkspaceKek, + getOrFetchKek, + invalidateCachedKek, + createKekForMember, + decryptKekFromApiResponse, +} from './workspace-kek' +import { + generateDocumentDek, + getOrFetchDek, + invalidateCachedDek, + invalidateWorkspaceDeks, + createEncryptedDekForApi, + decryptDekFromApiResponse, +} from './document-dek' +import { + generateShareKey, + extractShareKeyFromFragment, + deriveShareKeyFromPassword, + createPasswordProtectedShareKey, + encryptDekWithShareKey, + decryptDekWithShareKey, +} from './share-key' + +/** E2EE Setup result */ +export interface E2EESetupResult { + /** BIP39 recovery key (24 words) - must be shown to user */ + recoveryKey: string + /** Public keys to register with server */ + publicKeys: { + ecdhPublicKey: string + signingPublicKey: string + } + /** Salt used for passphrase derivation */ + salt: Uint8Array + /** KDF type used */ + kdf: 'argon2id' | 'pbkdf2' + /** KDF parameters */ + kdfParams: { memory?: number; iterations: number; parallelism?: number } +} + +/** Session lock error */ +export class SessionLockedError extends Error { + constructor() { + super('Session is locked. Please unlock with passphrase.') + this.name = 'SessionLockedError' + } +} + +/** Key not found error */ +export class KeyNotFoundError extends Error { + constructor(keyType: string, id: string) { + super(`${keyType} not found for ${id}`) + this.name = 'KeyNotFoundError' + } +} + +/** + * KeyManager - Singleton class for managing E2EE keys + */ +export class KeyManager { + private keyStore: KeyStore + private umk: Uint8Array | null = null + private userKeys: UserKeySet | null = null + private _isInitialized = false + + constructor(keyStore?: KeyStore) { + this.keyStore = keyStore ?? getKeyStore() + } + + /** + * Initialize the KeyManager. + * Must be called before any other operations. + */ + async initialize(): Promise { + if (this._isInitialized) return + await this.keyStore.initialize() + this._isInitialized = true + } + + /** + * Check if KeyManager is initialized. + */ + get isInitialized(): boolean { + return this._isInitialized + } + + /** + * Check if session is unlocked. + */ + get isUnlocked(): boolean { + return this.umk !== null && this.userKeys !== null + } + + /** + * Check if user has E2EE keys set up. + */ + async hasKeys(): Promise { + await this.ensureInitialized() + return this.keyStore.hasKeys() + } + + // ============================================ + // E2EE Setup + // ============================================ + + /** + * Set up E2EE for a new user or existing user migrating. + * + * @param passphrase - User's passphrase (min 8 characters) + * @returns Setup result with recovery key and public keys + */ + async setupE2EE(passphrase: string): Promise { + await this.ensureInitialized() + + // Generate UMK from passphrase + const umkResult = await generateUmk(passphrase) + + // Generate user key pairs + const userKeys = await generateUserKeys() + + // Encrypt user keys with UMK + const encryptedKeys = await encryptUserKeys(userKeys, umkResult.umk) + + // Store in IndexedDB + const kdfParams = umkResult.kdf === 'argon2id' + ? { + type: 'argon2id' as const, + memory: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).memory, + iterations: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).iterations, + parallelism: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).parallelism, + } + : { + type: 'pbkdf2' as const, + iterations: (umkResult.kdfParams as { iterations: number }).iterations, + } + + const storedKeys: StoredKeys = { + ...encryptedKeys, + salt: umkResult.salt, + kdf: umkResult.kdf, + kdfParams, + createdAt: Date.now(), + } + + await this.keyStore.saveKeys(storedKeys) + + // Keep UMK and keys in memory + this.umk = umkResult.umk + this.userKeys = userKeys + + // Get public keys for server registration + const publicKeys = await getPublicKeysBase64(userKeys) + + return { + recoveryKey: umkResult.recoveryKey, + publicKeys, + salt: umkResult.salt, + kdf: umkResult.kdf, + kdfParams: umkResult.kdfParams, + } + } + + // ============================================ + // Unlock / Lock + // ============================================ + + /** + * Unlock the session with a passphrase. + * + * @param passphrase - User's passphrase + * @throws Error if passphrase is incorrect + */ + async unlockWithPassphrase(passphrase: string): Promise { + await this.ensureInitialized() + + const storedKeys = await this.keyStore.loadKeys() + if (!storedKeys) { + throw new Error('No E2EE keys found. Please set up E2EE first.') + } + + // Derive UMK from passphrase + const umk = await deriveUmkFromPassphrase( + passphrase, + storedKeys.salt, + storedKeys.kdf, + storedKeys.kdfParams + ) + + // Try to decrypt user keys to verify passphrase + try { + const userKeys = await decryptUserKeys(storedKeys, umk) + this.umk = umk + this.userKeys = userKeys + } catch { + // Zero out UMK on failure + umk.fill(0) + throw new Error('Incorrect passphrase') + } + } + + /** + * Unlock the session with a recovery key. + * + * @param recoveryKey - BIP39 mnemonic (24 words) + * @throws Error if recovery key is invalid + */ + async unlockWithRecoveryKey(recoveryKey: string): Promise { + await this.ensureInitialized() + + const storedKeys = await this.keyStore.loadKeys() + if (!storedKeys) { + throw new Error('No E2EE keys found. Please set up E2EE first.') + } + + // Restore UMK from recovery key + const umk = restoreUmkFromRecoveryKey(recoveryKey) + + // Try to decrypt user keys to verify recovery key + try { + const userKeys = await decryptUserKeys(storedKeys, umk) + this.umk = umk + this.userKeys = userKeys + } catch { + // Zero out UMK on failure + umk.fill(0) + throw new Error('Incorrect recovery key') + } + } + + /** + * Lock the session - clears all keys from memory. + */ + lock(): void { + if (this.umk) { + zeroUmk(this.umk) + this.umk = null + } + + if (this.userKeys) { + zeroUserKeys(this.userKeys) + this.userKeys = null + } + + // Clear all cached KEKs and DEKs + clearAllCaches() + } + + /** + * Verify if a passphrase is correct without unlocking. + */ + async verifyPassphrase(passphrase: string): Promise { + await this.ensureInitialized() + + const storedKeys = await this.keyStore.loadKeys() + if (!storedKeys) { + return false + } + + return verifyPassphrase(passphrase, storedKeys) + } + + // ============================================ + // User Keys + // ============================================ + + /** + * Get the current user's ECDH key pair. + */ + getEcdhKeyPair(): { publicKey: Uint8Array; privateKey: Uint8Array } { + this.ensureUnlocked() + return { + publicKey: this.userKeys!.ecdh.publicKey, + privateKey: this.userKeys!.ecdh.privateKey, + } + } + + /** + * Get the current user's signing key pair. + */ + getSigningKeyPair(): { publicKey: Uint8Array; privateKey: Uint8Array } { + this.ensureUnlocked() + return { + publicKey: this.userKeys!.signing.publicKey, + privateKey: this.userKeys!.signing.privateKey, + } + } + + /** + * Get public keys as Base64 for API requests. + */ + async getPublicKeysBase64(): Promise<{ ecdhPublicKey: string; signingPublicKey: string }> { + this.ensureUnlocked() + return getPublicKeysBase64(this.userKeys!) + } + + // ============================================ + // Workspace KEK + // ============================================ + + /** + * Generate a new workspace KEK. + */ + async generateWorkspaceKek(): Promise { + return generateWorkspaceKek() + } + + /** + * Encrypt a KEK for a recipient. + */ + async encryptKekForRecipient( + kek: Uint8Array, + recipientPublicKey: Uint8Array + ): Promise { + return createKekForMember(kek, recipientPublicKey) + } + + /** + * Get a workspace KEK (from cache or API). + * + * @param workspaceId - Workspace ID + * @param fetchFn - Function to fetch encrypted KEK from API + */ + async getWorkspaceKek( + workspaceId: string, + fetchFn: () => Promise + ): Promise { + this.ensureUnlocked() + return getOrFetchKek(workspaceId, this.userKeys!.ecdh.privateKey, fetchFn) + } + + /** + * Decrypt a KEK directly from API response. + */ + async decryptKek(encryptedKekBase64: string): Promise { + this.ensureUnlocked() + return decryptKekFromApiResponse(this.userKeys!.ecdh.privateKey, encryptedKekBase64) + } + + /** + * Invalidate cached KEK. + */ + invalidateKekCache(workspaceId: string): void { + invalidateCachedKek(workspaceId) + } + + // ============================================ + // Document DEK + // ============================================ + + /** + * Generate a new document DEK. + */ + async generateDocumentDek(): Promise { + return generateDocumentDek() + } + + /** + * Create an encrypted DEK for API storage. + */ + async createEncryptedDek( + dek: Uint8Array, + kek: Uint8Array + ): Promise<{ encryptedDek: string; nonce: string }> { + return createEncryptedDekForApi(dek, kek) + } + + /** + * Get a document DEK (from cache or API). + * + * @param documentId - Document ID + * @param kek - Workspace KEK + * @param fetchFn - Function to fetch encrypted DEK from API + */ + async getDocumentDek( + documentId: string, + kek: Uint8Array, + fetchFn: () => Promise<{ encryptedDek: string; nonce: string }> + ): Promise { + return getOrFetchDek(documentId, kek, fetchFn) + } + + /** + * Decrypt a DEK directly from API response. + */ + async decryptDek( + encryptedDekBase64: string, + nonceBase64: string, + kek: Uint8Array + ): Promise { + return decryptDekFromApiResponse(encryptedDekBase64, nonceBase64, kek) + } + + /** + * Invalidate cached DEK. + */ + invalidateDekCache(documentId: string): void { + invalidateCachedDek(documentId) + } + + /** + * Invalidate all DEKs for a workspace. + */ + invalidateWorkspaceDeksCache(documentIds: string[]): void { + invalidateWorkspaceDeks(documentIds) + } + + // ============================================ + // Share Keys + // ============================================ + + /** + * Generate a share key for URL fragment mode. + */ + async generateShareKey(): Promise<{ key: Uint8Array; fragment: string }> { + return generateShareKey() + } + + /** + * Extract share key from URL fragment. + */ + async extractShareKeyFromFragment(fragment: string): Promise { + return extractShareKeyFromFragment(fragment) + } + + /** + * Derive share key from password. + */ + async deriveShareKeyFromPassword( + password: string, + salt: Uint8Array + ): Promise { + return deriveShareKeyFromPassword(password, salt) + } + + /** + * Create a password-protected share key. + */ + async createPasswordProtectedShareKey( + password: string + ): Promise<{ key: Uint8Array; salt: Uint8Array }> { + return createPasswordProtectedShareKey(password) + } + + /** + * Encrypt a DEK with a share key. + */ + async encryptDekWithShareKey( + dek: Uint8Array, + shareKey: Uint8Array + ): Promise<{ encryptedDek: string; nonce: string }> { + return encryptDekWithShareKey(dek, shareKey) + } + + /** + * Decrypt a DEK with a share key. + */ + async decryptDekWithShareKey( + encryptedDekBase64: string, + nonceBase64: string, + shareKey: Uint8Array + ): Promise { + return decryptDekWithShareKey(encryptedDekBase64, nonceBase64, shareKey) + } + + // ============================================ + // Password Change + // ============================================ + + /** + * Change the passphrase. + * Session must be unlocked. + * + * @param newPassphrase - New passphrase + * @returns New recovery key + */ + async changePassphrase(newPassphrase: string): Promise { + this.ensureUnlocked() + + // Generate new UMK from new passphrase + const umkResult = await generateUmk(newPassphrase) + + // Re-encrypt user keys with new UMK + const encryptedKeys = await encryptUserKeys(this.userKeys!, umkResult.umk) + + // Build KDF params with type discriminator + const kdfParams = umkResult.kdf === 'argon2id' + ? { + type: 'argon2id' as const, + memory: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).memory, + iterations: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).iterations, + parallelism: (umkResult.kdfParams as { memory: number; iterations: number; parallelism: number }).parallelism, + } + : { + type: 'pbkdf2' as const, + iterations: (umkResult.kdfParams as { iterations: number }).iterations, + } + + // Store updated keys + const storedKeys: StoredKeys = { + ...encryptedKeys, + salt: umkResult.salt, + kdf: umkResult.kdf, + kdfParams, + createdAt: Date.now(), + } + + await this.keyStore.saveKeys(storedKeys) + + // Zero out old UMK + if (this.umk) { + zeroUmk(this.umk) + } + + // Update session with new UMK + this.umk = umkResult.umk + + return umkResult.recoveryKey + } + + // ============================================ + // Utility + // ============================================ + + /** + * Clear all stored keys (for account deletion or reset). + */ + async clearAllKeys(): Promise { + this.lock() + await this.keyStore.clear() + this._isInitialized = false + } + + /** + * Get UMK (for migration or backup operations). + * Use with caution - UMK should not be exposed. + */ + getUmk(): Uint8Array { + this.ensureUnlocked() + return this.umk! + } + + // ============================================ + // Private Helpers + // ============================================ + + private async ensureInitialized(): Promise { + if (!this._isInitialized) { + await this.initialize() + } + } + + private ensureUnlocked(): void { + if (!this.isUnlocked) { + throw new SessionLockedError() + } + } +} + +// Singleton instance +let keyManagerInstance: KeyManager | null = null + +/** + * Get the singleton KeyManager instance. + */ +export function getKeyManager(): KeyManager { + if (!keyManagerInstance) { + keyManagerInstance = new KeyManager() + } + return keyManagerInstance +} + +/** + * Reset the singleton instance (for testing). + */ +export function resetKeyManager(): void { + if (keyManagerInstance) { + keyManagerInstance.lock() + } + keyManagerInstance = null +} diff --git a/app/src/features/e2ee/lib/keys/key-store.ts b/app/src/features/e2ee/lib/keys/key-store.ts new file mode 100644 index 00000000..5490b7a5 --- /dev/null +++ b/app/src/features/e2ee/lib/keys/key-store.ts @@ -0,0 +1,263 @@ +/** + * E2EE Key Store + * + * Stores encrypted keys in IndexedDB. + * UMK is never stored here - only in session memory. + */ + +import type { Argon2Params, Pbkdf2Params } from '../types' + +const DB_NAME = 'refmd-e2ee' +const DB_VERSION = 1 +const STORE_NAME = 'keys' +const KEYS_ID = 'user-keys' + +/** Stored key data structure */ +export interface StoredKeys { + /** ECDH private key encrypted with UMK */ + encryptedEcdhPrivateKey: Uint8Array + /** Nonce for ECDH private key encryption */ + encryptedEcdhPrivateKeyNonce: Uint8Array + /** Ed25519 signing private key encrypted with UMK */ + encryptedSigningPrivateKey: Uint8Array + /** Nonce for signing private key encryption */ + encryptedSigningPrivateKeyNonce: Uint8Array + /** ECDH public key (unencrypted) */ + ecdhPublicKey: Uint8Array + /** Ed25519 signing public key (unencrypted) */ + signingPublicKey: Uint8Array + /** Salt used for passphrase derivation */ + salt: Uint8Array + /** KDF type used */ + kdf: 'argon2id' | 'pbkdf2' + /** KDF parameters */ + kdfParams: Argon2Params | Pbkdf2Params + /** When the keys were created */ + createdAt: number +} + +/** Serializable format for IndexedDB */ +interface SerializedStoredKeys { + encryptedEcdhPrivateKey: number[] + encryptedEcdhPrivateKeyNonce: number[] + encryptedSigningPrivateKey: number[] + encryptedSigningPrivateKeyNonce: number[] + ecdhPublicKey: number[] + signingPublicKey: number[] + salt: number[] + kdf: 'argon2id' | 'pbkdf2' + kdfParams: Argon2Params | Pbkdf2Params + createdAt: number +} + +/** + * Open the IndexedDB database + */ +function openDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(DB_NAME, DB_VERSION) + + request.onerror = () => { + reject(new Error(`Failed to open database: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve(request.result) + } + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result + + // Create the keys store if it doesn't exist + if (!db.objectStoreNames.contains(STORE_NAME)) { + db.createObjectStore(STORE_NAME, { keyPath: 'id' }) + } + } + }) +} + +/** + * Serialize StoredKeys to a format safe for IndexedDB + */ +function serializeKeys(keys: StoredKeys): SerializedStoredKeys { + return { + encryptedEcdhPrivateKey: Array.from(keys.encryptedEcdhPrivateKey), + encryptedEcdhPrivateKeyNonce: Array.from(keys.encryptedEcdhPrivateKeyNonce), + encryptedSigningPrivateKey: Array.from(keys.encryptedSigningPrivateKey), + encryptedSigningPrivateKeyNonce: Array.from(keys.encryptedSigningPrivateKeyNonce), + ecdhPublicKey: Array.from(keys.ecdhPublicKey), + signingPublicKey: Array.from(keys.signingPublicKey), + salt: Array.from(keys.salt), + kdf: keys.kdf, + kdfParams: keys.kdfParams, + createdAt: keys.createdAt, + } +} + +/** + * Deserialize keys from IndexedDB format + */ +function deserializeKeys(data: SerializedStoredKeys): StoredKeys { + return { + encryptedEcdhPrivateKey: new Uint8Array(data.encryptedEcdhPrivateKey), + encryptedEcdhPrivateKeyNonce: new Uint8Array(data.encryptedEcdhPrivateKeyNonce), + encryptedSigningPrivateKey: new Uint8Array(data.encryptedSigningPrivateKey), + encryptedSigningPrivateKeyNonce: new Uint8Array(data.encryptedSigningPrivateKeyNonce), + ecdhPublicKey: new Uint8Array(data.ecdhPublicKey), + signingPublicKey: new Uint8Array(data.signingPublicKey), + salt: new Uint8Array(data.salt), + kdf: data.kdf, + kdfParams: data.kdfParams, + createdAt: data.createdAt, + } +} + +/** + * KeyStore - manages encrypted key storage in IndexedDB + */ +export class KeyStore { + private db: IDBDatabase | null = null + + /** + * Initialize the key store + */ + async initialize(): Promise { + if (this.db) return + this.db = await openDatabase() + } + + /** + * Ensure database is initialized + */ + private async ensureDb(): Promise { + if (!this.db) { + await this.initialize() + } + return this.db! + } + + /** + * Save encrypted keys to IndexedDB + */ + async saveKeys(keys: StoredKeys): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + const data = { + id: KEYS_ID, + ...serializeKeys(keys), + } + + const request = store.put(data) + + request.onerror = () => { + reject(new Error(`Failed to save keys: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve() + } + }) + } + + /** + * Load encrypted keys from IndexedDB + */ + async loadKeys(): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(KEYS_ID) + + request.onerror = () => { + reject(new Error(`Failed to load keys: ${request.error?.message}`)) + } + + request.onsuccess = () => { + if (!request.result) { + resolve(null) + return + } + + // Remove the id field before deserializing + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { id, ...data } = request.result + resolve(deserializeKeys(data as SerializedStoredKeys)) + } + }) + } + + /** + * Check if keys exist in IndexedDB + */ + async hasKeys(): Promise { + const keys = await this.loadKeys() + return keys !== null + } + + /** + * Clear all keys from IndexedDB + */ + async clear(): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.delete(KEYS_ID) + + request.onerror = () => { + reject(new Error(`Failed to clear keys: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve() + } + }) + } + + /** + * Close the database connection + */ + close(): void { + if (this.db) { + this.db.close() + this.db = null + } + } + + /** + * Delete the entire database (for testing/reset) + */ + static async deleteDatabase(): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.deleteDatabase(DB_NAME) + + request.onerror = () => { + reject(new Error(`Failed to delete database: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve() + } + }) + } +} + +// Singleton instance +let keyStoreInstance: KeyStore | null = null + +/** + * Get the singleton KeyStore instance + */ +export function getKeyStore(): KeyStore { + if (!keyStoreInstance) { + keyStoreInstance = new KeyStore() + } + return keyStoreInstance +} diff --git a/app/src/features/e2ee/lib/keys/share-key.ts b/app/src/features/e2ee/lib/keys/share-key.ts new file mode 100644 index 00000000..8ff9b7fd --- /dev/null +++ b/app/src/features/e2ee/lib/keys/share-key.ts @@ -0,0 +1,211 @@ +/** + * Share Key Management + * + * Handles key generation and management for shared document links. + * Supports two modes: + * 1. URL fragment mode: key is in the URL hash (never sent to server) + * 2. Password mode: key is derived from a password using PBKDF2 + */ + +import { + generateKey, + encrypt, + decrypt, + pbkdf2DeriveKey, + pbkdf2GenerateSalt, + getSodium, + PBKDF2_DEFAULT_ITERATIONS, +} from '../crypto' + +/** Share key size (32 bytes) */ +export const SHARE_KEY_SIZE = 32 + +/** URL fragment prefix for share keys */ +export const URL_FRAGMENT_PREFIX = 'key=' + +/** Share key with metadata */ +export interface ShareKey { + /** 32-byte share key */ + key: Uint8Array + /** Whether this is password-derived */ + isPasswordProtected: boolean +} + +/** Encrypted share key for API */ +export interface EncryptedShareKeyForApi { + /** Encrypted DEK (Base64) */ + encryptedDek: string + /** Nonce (Base64) */ + nonce: string + /** Salt for password-protected shares (Base64), if applicable */ + salt?: string +} + +/** + * Generate a new share key for URL fragment mode. + * + * @returns Share key and URL fragment + */ +export async function generateShareKey(): Promise<{ + key: Uint8Array + fragment: string +}> { + const sodium = await getSodium() + const key = await generateKey() + const keyBase64 = sodium.to_base64(key, sodium.base64_variants.URLSAFE_NO_PADDING) + + return { + key, + fragment: `${URL_FRAGMENT_PREFIX}${keyBase64}`, + } +} + +/** + * Extract share key from URL fragment. + * + * @param fragment - URL fragment (with or without leading #) + * @returns Decoded share key, or null if not found/invalid + */ +export async function extractShareKeyFromFragment( + fragment: string +): Promise { + const sodium = await getSodium() + + // Remove leading # if present + const cleanFragment = fragment.startsWith('#') ? fragment.slice(1) : fragment + + // Check for key prefix + if (!cleanFragment.startsWith(URL_FRAGMENT_PREFIX)) { + return null + } + + const keyBase64 = cleanFragment.slice(URL_FRAGMENT_PREFIX.length) + + try { + return sodium.from_base64(keyBase64, sodium.base64_variants.URLSAFE_NO_PADDING) + } catch { + return null + } +} + +/** + * Derive a share key from a password. + * + * @param password - User-provided password + * @param salt - Salt (generate new for creation, use existing for access) + * @returns Derived share key + */ +export async function deriveShareKeyFromPassword( + password: string, + salt: Uint8Array +): Promise { + return pbkdf2DeriveKey(password, salt, PBKDF2_DEFAULT_ITERATIONS) +} + +/** + * Create a password-protected share key. + * + * @param password - User-provided password + * @returns Derived share key and salt + */ +export async function createPasswordProtectedShareKey( + password: string +): Promise<{ + key: Uint8Array + salt: Uint8Array +}> { + const salt = await pbkdf2GenerateSalt() + const key = await deriveShareKeyFromPassword(password, salt) + + return { key, salt } +} + +/** + * Encrypt a DEK with a share key for storage. + * + * @param dek - Document DEK to encrypt + * @param shareKey - Share key + * @returns Encrypted DEK and nonce (Base64) + */ +export async function encryptDekWithShareKey( + dek: Uint8Array, + shareKey: Uint8Array +): Promise<{ + encryptedDek: string + nonce: string +}> { + const sodium = await getSodium() + const { ciphertext, nonce } = await encrypt(shareKey, dek) + + return { + encryptedDek: sodium.to_base64(ciphertext, sodium.base64_variants.ORIGINAL), + nonce: sodium.to_base64(nonce, sodium.base64_variants.ORIGINAL), + } +} + +/** + * Decrypt a DEK with a share key. + * + * @param encryptedDekBase64 - Encrypted DEK (Base64) + * @param nonceBase64 - Nonce (Base64) + * @param shareKey - Share key + * @returns Decrypted DEK + */ +export async function decryptDekWithShareKey( + encryptedDekBase64: string, + nonceBase64: string, + shareKey: Uint8Array +): Promise { + const sodium = await getSodium() + + const encryptedDek = sodium.from_base64(encryptedDekBase64, sodium.base64_variants.ORIGINAL) + const nonce = sodium.from_base64(nonceBase64, sodium.base64_variants.ORIGINAL) + + return decrypt(shareKey, encryptedDek, nonce) +} + +/** + * Build a complete share URL with the key in the fragment. + * + * @param baseUrl - Base share URL (e.g., "https://refmd.io/share/abc123") + * @param fragment - Key fragment (e.g., "key=...") + * @returns Complete URL with fragment + */ +export function buildShareUrl(baseUrl: string, fragment: string): string { + // Remove any existing fragment from baseUrl + const cleanBaseUrl = baseUrl.split('#')[0] + return `${cleanBaseUrl}#${fragment}` +} + +/** + * Parse salt from API response. + * + * @param saltBase64 - Base64-encoded salt + * @returns Decoded salt + */ +export async function parseSaltFromApi(saltBase64: string): Promise { + const sodium = await getSodium() + return sodium.from_base64(saltBase64, sodium.base64_variants.ORIGINAL) +} + +/** + * Encode salt for API request. + * + * @param salt - Salt bytes + * @returns Base64-encoded salt + */ +export async function encodeSaltForApi(salt: Uint8Array): Promise { + const sodium = await getSodium() + return sodium.to_base64(salt, sodium.base64_variants.ORIGINAL) +} + +/** + * Check if a URL fragment contains a share key. + * + * @param fragment - URL fragment + * @returns true if contains key fragment + */ +export function hasShareKeyFragment(fragment: string): boolean { + const cleanFragment = fragment.startsWith('#') ? fragment.slice(1) : fragment + return cleanFragment.startsWith(URL_FRAGMENT_PREFIX) +} diff --git a/app/src/features/e2ee/lib/keys/umk.ts b/app/src/features/e2ee/lib/keys/umk.ts new file mode 100644 index 00000000..4ee6e22c --- /dev/null +++ b/app/src/features/e2ee/lib/keys/umk.ts @@ -0,0 +1,252 @@ +/** + * User Master Key (UMK) Management + * + * The UMK is the root of the key hierarchy: + * - Generated from random entropy or derived from passphrase + * - Never stored in IndexedDB, only in session memory + * - Can be recovered from BIP39 mnemonic (recovery key) + */ + +import { + argon2DeriveKey, + argon2DeriveKeyWithNewSalt, + isArgon2Supported, + pbkdf2DeriveKey, + pbkdf2DeriveKeyWithNewSalt, + DEFAULT_ARGON2_PARAMS, + PBKDF2_DEFAULT_ITERATIONS, + type Argon2Params, + generateRecoveryKey, + validateRecoveryKey, + recoveryKeyToUmk, + umkToRecoveryKey, + getSodium, +} from '../crypto' +import type { StoredKeys } from './key-store' + +/** UMK size in bytes (256 bits) */ +export const UMK_SIZE = 32 + +/** Result of UMK generation */ +export interface UmkGenerationResult { + /** The generated UMK (32 bytes) */ + umk: Uint8Array + /** BIP39 recovery key (24 words) */ + recoveryKey: string + /** Salt used for passphrase derivation */ + salt: Uint8Array + /** KDF type used */ + kdf: 'argon2id' | 'pbkdf2' + /** KDF parameters */ + kdfParams: Argon2Params | { iterations: number } +} + +/** KDF parameters for storage */ +export type KdfParams = Argon2Params | { type: 'pbkdf2'; iterations: number } + +/** + * Generate a new UMK with recovery key. + * + * The UMK is derived from a passphrase using Argon2id (or PBKDF2 fallback). + * A recovery key (BIP39 mnemonic) is also generated for backup. + * + * @param passphrase - User's passphrase (min 12 characters recommended) + * @returns UMK generation result + */ +export async function generateUmk(passphrase: string): Promise { + // Validate passphrase + if (!passphrase || passphrase.length < 8) { + throw new Error('Passphrase must be at least 8 characters') + } + + // Check if Argon2id is supported + const useArgon2 = await isArgon2Supported() + + let umk: Uint8Array + let salt: Uint8Array + let kdf: 'argon2id' | 'pbkdf2' + let kdfParams: Argon2Params | { iterations: number } + + if (useArgon2) { + const result = await argon2DeriveKeyWithNewSalt(passphrase, DEFAULT_ARGON2_PARAMS) + umk = result.key + salt = result.salt + kdf = 'argon2id' + kdfParams = DEFAULT_ARGON2_PARAMS + } else { + const result = await pbkdf2DeriveKeyWithNewSalt(passphrase, PBKDF2_DEFAULT_ITERATIONS) + umk = result.key + salt = result.salt + kdf = 'pbkdf2' + kdfParams = { iterations: PBKDF2_DEFAULT_ITERATIONS } + } + + // Generate recovery key from the UMK + const recoveryKey = umkToRecoveryKey(umk) + + return { + umk, + recoveryKey, + salt, + kdf, + kdfParams, + } +} + +/** + * Derive UMK from passphrase using stored parameters. + * + * @param passphrase - User's passphrase + * @param salt - Salt from key store + * @param kdf - KDF type ('argon2id' or 'pbkdf2') + * @param kdfParams - KDF parameters + * @returns Derived UMK + */ +export async function deriveUmkFromPassphrase( + passphrase: string, + salt: Uint8Array, + kdf: 'argon2id' | 'pbkdf2', + kdfParams: Argon2Params | { iterations: number } +): Promise { + if (kdf === 'argon2id') { + return argon2DeriveKey(passphrase, salt, kdfParams as Argon2Params) + } else { + return pbkdf2DeriveKey( + passphrase, + salt, + (kdfParams as { iterations: number }).iterations + ) + } +} + +/** + * Restore UMK from recovery key (BIP39 mnemonic). + * + * @param recoveryKey - 24-word BIP39 mnemonic + * @returns The restored UMK + * @throws Error if recovery key is invalid + */ +export function restoreUmkFromRecoveryKey(recoveryKey: string): Uint8Array { + if (!validateRecoveryKey(recoveryKey)) { + throw new Error('Invalid recovery key') + } + + return recoveryKeyToUmk(recoveryKey) +} + +/** + * Re-encrypt UMK with a new passphrase. + * Used when user changes their passphrase. + * + * @param umk - Current UMK + * @param newPassphrase - New passphrase + * @returns New salt and KDF parameters + */ +export async function reEncryptUmk( + _umk: Uint8Array, + newPassphrase: string +): Promise<{ + salt: Uint8Array + kdf: 'argon2id' | 'pbkdf2' + kdfParams: Argon2Params | { iterations: number } +}> { + if (!newPassphrase || newPassphrase.length < 8) { + throw new Error('Passphrase must be at least 8 characters') + } + + const useArgon2 = await isArgon2Supported() + + if (useArgon2) { + const { salt } = await argon2DeriveKeyWithNewSalt(newPassphrase, DEFAULT_ARGON2_PARAMS) + return { + salt, + kdf: 'argon2id', + kdfParams: DEFAULT_ARGON2_PARAMS, + } + } else { + const { salt } = await pbkdf2DeriveKeyWithNewSalt(newPassphrase, PBKDF2_DEFAULT_ITERATIONS) + return { + salt, + kdf: 'pbkdf2', + kdfParams: { iterations: PBKDF2_DEFAULT_ITERATIONS }, + } + } +} + +/** + * Verify if a passphrase matches the stored keys. + * Derives UMK and attempts to decrypt the stored private key. + * + * @param passphrase - Passphrase to verify + * @param storedKeys - Stored keys from IndexedDB + * @returns true if passphrase is correct + */ +export async function verifyPassphrase( + passphrase: string, + storedKeys: StoredKeys +): Promise { + try { + const umk = await deriveUmkFromPassphrase( + passphrase, + storedKeys.salt, + storedKeys.kdf, + storedKeys.kdfParams + ) + + // Try to decrypt the stored private key + const sodium = await getSodium() + + // Try ECDH key decryption + const decrypted = sodium.crypto_secretbox_open_easy( + storedKeys.encryptedEcdhPrivateKey, + storedKeys.encryptedEcdhPrivateKeyNonce, + umk + ) + + // If we get here, the passphrase is correct + // Zero out the decrypted key + decrypted.fill(0) + umk.fill(0) + + return true + } catch { + return false + } +} + +/** + * Generate a new recovery key for an existing UMK. + * The UMK must already be unlocked. + * + * @param umk - Current UMK + * @returns New recovery key (24 words) + */ +export function generateNewRecoveryKey(umk: Uint8Array): string { + if (umk.length !== UMK_SIZE) { + throw new Error(`Invalid UMK size: expected ${UMK_SIZE}, got ${umk.length}`) + } + + return umkToRecoveryKey(umk) +} + +/** + * Validate a recovery key without restoring. + * + * @param recoveryKey - Recovery key to validate + * @returns true if valid BIP39 mnemonic + */ +export { validateRecoveryKey } + +/** + * Export recovery key generation for new UMKs. + */ +export { generateRecoveryKey } + +/** + * Zero out UMK from memory (call when locking session). + * + * @param umk - UMK to zero out + */ +export function zeroUmk(umk: Uint8Array): void { + umk.fill(0) +} diff --git a/app/src/features/e2ee/lib/keys/user-keys.ts b/app/src/features/e2ee/lib/keys/user-keys.ts new file mode 100644 index 00000000..86079e3d --- /dev/null +++ b/app/src/features/e2ee/lib/keys/user-keys.ts @@ -0,0 +1,184 @@ +/** + * User Key Pair Management + * + * Manages ECDH key pairs (for key exchange) and Ed25519 key pairs (for signing). + * Private keys are encrypted with UMK before storage. + */ + +import { + generateEcdhKeyPair, + generateSigningKeyPair, + encrypt, + decrypt, + getSodium, + type EcdhKeyPair, + type Ed25519KeyPair, +} from '../crypto' +import type { StoredKeys } from './key-store' + +/** Complete user key set */ +export interface UserKeySet { + /** ECDH key pair for key exchange */ + ecdh: EcdhKeyPair + /** Ed25519 key pair for signing */ + signing: Ed25519KeyPair +} + +/** Encrypted private keys for storage */ +export interface EncryptedUserKeys { + /** ECDH private key encrypted with UMK */ + encryptedEcdhPrivateKey: Uint8Array + /** Nonce for ECDH private key encryption */ + encryptedEcdhPrivateKeyNonce: Uint8Array + /** Ed25519 signing private key encrypted with UMK */ + encryptedSigningPrivateKey: Uint8Array + /** Nonce for signing private key encryption */ + encryptedSigningPrivateKeyNonce: Uint8Array + /** ECDH public key (unencrypted) */ + ecdhPublicKey: Uint8Array + /** Ed25519 signing public key (unencrypted) */ + signingPublicKey: Uint8Array +} + +/** + * Generate a new set of user key pairs. + * + * @returns New ECDH and Ed25519 key pairs + */ +export async function generateUserKeys(): Promise { + // Generate ECDH key pair for key exchange + const ecdh = generateEcdhKeyPair() + + // Generate Ed25519 key pair for signing + const signing = await generateSigningKeyPair() + + return { ecdh, signing } +} + +/** + * Encrypt user private keys with UMK for storage. + * + * @param keys - User key set + * @param umk - User Master Key + * @returns Encrypted keys ready for storage + */ +export async function encryptUserKeys( + keys: UserKeySet, + umk: Uint8Array +): Promise { + // Encrypt ECDH private key + const ecdhResult = await encrypt(umk, keys.ecdh.privateKey) + + // Encrypt Ed25519 signing private key + const signingResult = await encrypt(umk, keys.signing.privateKey) + + return { + encryptedEcdhPrivateKey: ecdhResult.ciphertext, + encryptedEcdhPrivateKeyNonce: ecdhResult.nonce, + encryptedSigningPrivateKey: signingResult.ciphertext, + encryptedSigningPrivateKeyNonce: signingResult.nonce, + ecdhPublicKey: keys.ecdh.publicKey, + signingPublicKey: keys.signing.publicKey, + } +} + +/** + * Decrypt user private keys from storage. + * + * @param storedKeys - Stored encrypted keys + * @param umk - User Master Key + * @returns Decrypted user key set + */ +export async function decryptUserKeys( + storedKeys: StoredKeys, + umk: Uint8Array +): Promise { + // Decrypt ECDH private key + const ecdhPrivateKey = await decrypt( + umk, + storedKeys.encryptedEcdhPrivateKey, + storedKeys.encryptedEcdhPrivateKeyNonce + ) + + // Decrypt Ed25519 signing private key + const signingPrivateKey = await decrypt( + umk, + storedKeys.encryptedSigningPrivateKey, + storedKeys.encryptedSigningPrivateKeyNonce + ) + + return { + ecdh: { + privateKey: ecdhPrivateKey, + publicKey: storedKeys.ecdhPublicKey, + }, + signing: { + privateKey: signingPrivateKey, + publicKey: storedKeys.signingPublicKey, + }, + } +} + +/** + * Re-encrypt user keys with a new UMK (for passphrase change). + * + * @param keys - Current user key set + * @param newUmk - New User Master Key + * @returns Newly encrypted keys + */ +export async function reEncryptUserKeys( + keys: UserKeySet, + newUmk: Uint8Array +): Promise { + return encryptUserKeys(keys, newUmk) +} + +/** + * Zero out user keys from memory (call when locking session). + * + * @param keys - User key set to zero out + */ +export function zeroUserKeys(keys: UserKeySet): void { + keys.ecdh.privateKey.fill(0) + keys.signing.privateKey.fill(0) +} + +/** + * Convert public keys to Base64 for API transmission. + * + * @param keys - User key set + * @returns Base64-encoded public keys + */ +export async function getPublicKeysBase64(keys: UserKeySet): Promise<{ + ecdhPublicKey: string + signingPublicKey: string +}> { + const sodium = await getSodium() + + return { + ecdhPublicKey: sodium.to_base64(keys.ecdh.publicKey, sodium.base64_variants.ORIGINAL), + signingPublicKey: sodium.to_base64(keys.signing.publicKey, sodium.base64_variants.ORIGINAL), + } +} + +/** + * Parse public keys from Base64 (from API). + * + * @param ecdhPublicKey - Base64-encoded ECDH public key + * @param signingPublicKey - Base64-encoded signing public key + * @returns Decoded public keys + */ +export async function parsePublicKeysFromBase64( + ecdhPublicKey: string, + signingPublicKey: string +): Promise<{ + ecdhPublicKey: Uint8Array + signingPublicKey: Uint8Array +}> { + const sodium = await getSodium() + + return { + ecdhPublicKey: sodium.from_base64(ecdhPublicKey, sodium.base64_variants.ORIGINAL), + signingPublicKey: sodium.from_base64(signingPublicKey, sodium.base64_variants.ORIGINAL), + } +} diff --git a/app/src/features/e2ee/lib/keys/workspace-kek.ts b/app/src/features/e2ee/lib/keys/workspace-kek.ts new file mode 100644 index 00000000..e269d5d5 --- /dev/null +++ b/app/src/features/e2ee/lib/keys/workspace-kek.ts @@ -0,0 +1,210 @@ +/** + * Workspace KEK (Key Encryption Key) Management + * + * KEKs are used to encrypt Document DEKs within a workspace. + * Each workspace has its own KEK, encrypted for each member. + */ + +import { + generateKey, + getSodium, + encryptKeyForRecipient, + decryptKeyFromSender, +} from '../crypto' +import { getKekCache } from './key-cache' + +/** HKDF context for KEK derivation */ +const KEK_HKDF_INFO = 'refmd_workspace_kek' + +/** KEK size (32 bytes) */ +export const KEK_SIZE = 32 + +/** Workspace KEK with metadata */ +export interface WorkspaceKek { + /** Workspace ID */ + workspaceId: string + /** 32-byte KEK */ + key: Uint8Array + /** Key version for rotation */ + version: number +} + +/** Encrypted KEK from API response */ +export interface EncryptedKekFromApi { + /** Encrypted KEK (Base64) */ + encryptedKek: string + /** Key version */ + keyVersion: number + /** Workspace ID */ + workspaceId: string +} + +/** + * Generate a new Workspace KEK. + * + * @returns 32-byte random KEK + */ +export async function generateWorkspaceKek(): Promise { + return generateKey() +} + +/** + * Encrypt a KEK for a recipient using their public key. + * + * @param kek - The KEK to encrypt + * @param recipientPublicKey - Recipient's ECDH public key + * @returns Encrypted KEK with ephemeral public key and nonce + */ +export async function encryptKekForRecipient( + kek: Uint8Array, + recipientPublicKey: Uint8Array +): Promise<{ + encryptedKek: Uint8Array + ephemeralPublicKey: Uint8Array + nonce: Uint8Array +}> { + const result = await encryptKeyForRecipient(recipientPublicKey, kek, KEK_HKDF_INFO) + + return { + encryptedKek: result.encryptedKey, + ephemeralPublicKey: result.ephemeralPublicKey, + nonce: result.nonce, + } +} + +/** + * Decrypt a KEK that was encrypted for us. + * + * @param ourPrivateKey - Our ECDH private key + * @param encryptedKek - Encrypted KEK + * @param ephemeralPublicKey - Sender's ephemeral public key + * @param nonce - Nonce used for encryption + * @returns Decrypted KEK + */ +export async function decryptKek( + ourPrivateKey: Uint8Array, + encryptedKek: Uint8Array, + ephemeralPublicKey: Uint8Array, + nonce: Uint8Array +): Promise { + return decryptKeyFromSender(ourPrivateKey, ephemeralPublicKey, encryptedKek, nonce, KEK_HKDF_INFO) +} + +/** + * Decrypt a KEK from API response format. + * The API returns the encrypted KEK as: ephemeralPublicKey || nonce || ciphertext (all Base64 encoded together) + * + * @param ourPrivateKey - Our ECDH private key + * @param encryptedKekBase64 - Base64-encoded encrypted KEK from API + * @returns Decrypted KEK + */ +export async function decryptKekFromApiResponse( + ourPrivateKey: Uint8Array, + encryptedKekBase64: string +): Promise { + const sodium = await getSodium() + const combined = sodium.from_base64(encryptedKekBase64, sodium.base64_variants.ORIGINAL) + + // Format: ephemeralPublicKey (65 bytes) || nonce (24 bytes) || ciphertext (remaining) + const EPHEMERAL_KEY_SIZE = 65 + const NONCE_SIZE = 24 + + if (combined.length < EPHEMERAL_KEY_SIZE + NONCE_SIZE + 1) { + throw new Error('Invalid encrypted KEK format: too short') + } + + const ephemeralPublicKey = combined.slice(0, EPHEMERAL_KEY_SIZE) + const nonce = combined.slice(EPHEMERAL_KEY_SIZE, EPHEMERAL_KEY_SIZE + NONCE_SIZE) + const encryptedKek = combined.slice(EPHEMERAL_KEY_SIZE + NONCE_SIZE) + + return decryptKek(ourPrivateKey, encryptedKek, ephemeralPublicKey, nonce) +} + +/** + * Encode a KEK for API request. + * Format: ephemeralPublicKey || nonce || ciphertext (all Base64 encoded together) + * + * @param encryptedKek - Encrypted KEK + * @param ephemeralPublicKey - Ephemeral public key + * @param nonce - Nonce + * @returns Base64-encoded combined data + */ +export async function encodeKekForApi( + encryptedKek: Uint8Array, + ephemeralPublicKey: Uint8Array, + nonce: Uint8Array +): Promise { + const sodium = await getSodium() + + // Combine: ephemeralPublicKey || nonce || ciphertext + const combined = new Uint8Array( + ephemeralPublicKey.length + nonce.length + encryptedKek.length + ) + combined.set(ephemeralPublicKey, 0) + combined.set(nonce, ephemeralPublicKey.length) + combined.set(encryptedKek, ephemeralPublicKey.length + nonce.length) + + return sodium.to_base64(combined, sodium.base64_variants.ORIGINAL) +} + +/** + * Get KEK from cache or fetch from API. + * + * @param workspaceId - Workspace ID + * @param ecdhPrivateKey - Our ECDH private key + * @param fetchKekFn - Function to fetch encrypted KEK from API + * @returns Decrypted KEK + */ +export async function getOrFetchKek( + workspaceId: string, + ecdhPrivateKey: Uint8Array, + fetchKekFn: () => Promise // Returns Base64-encoded encrypted KEK +): Promise { + const cache = getKekCache() + + // Check cache first + const cached = cache.getKek(workspaceId) + if (cached) { + return cached + } + + // Fetch from API + const encryptedKekBase64 = await fetchKekFn() + + // Decrypt + const kek = await decryptKekFromApiResponse(ecdhPrivateKey, encryptedKekBase64) + + // Cache the result + cache.setKek(workspaceId, kek) + + return kek +} + +/** + * Invalidate cached KEK (e.g., after rotation). + * + * @param workspaceId - Workspace ID + */ +export function invalidateCachedKek(workspaceId: string): void { + const cache = getKekCache() + cache.deleteKek(workspaceId) +} + +/** + * Create a KEK for a new workspace member. + * + * @param kek - The workspace KEK + * @param memberPublicKey - New member's ECDH public key + * @returns Encrypted KEK for the new member (Base64) + */ +export async function createKekForMember( + kek: Uint8Array, + memberPublicKey: Uint8Array +): Promise { + const { encryptedKek, ephemeralPublicKey, nonce } = await encryptKekForRecipient( + kek, + memberPublicKey + ) + + return encodeKekForApi(encryptedKek, ephemeralPublicKey, nonce) +} From 2738fb362160252e9d93eb338515581b23216ed2 Mon Sep 17 00:00:00 2001 From: munenick Date: Fri, 9 Jan 2026 23:03:39 +0900 Subject: [PATCH 11/45] add: update yjs --- app/package-lock.json | 307 +++++- app/package.json | 4 +- app/src/entities/document/api/index.ts | 4 +- app/src/entities/file/api/index.ts | 3 +- app/src/features/e2ee/lib/crypto/argon2.ts | 33 +- app/src/features/e2ee/lib/crypto/ecdh.ts | 13 +- app/src/features/e2ee/lib/crypto/pbkdf2.ts | 2 +- app/src/features/e2ee/lib/crypto/xchacha20.ts | 2 +- .../hooks/useCollaborativeDocument.ts | 99 +- app/src/features/plugins/lib/runtime.ts | 3 +- app/src/features/search/ui/SearchDialog.tsx | 12 +- app/src/shared/lib/realtime/ephemeral.ts | 561 +++++++++++ app/src/shared/lib/realtime/index.ts | 69 ++ app/src/shared/lib/realtime/messages.ts | 363 +++++++ app/src/shared/lib/realtime/sync.ts | 934 ++++++++++++++++++ app/src/shared/lib/yjsConnection.ts | 83 +- app/src/widgets/dashboard/DashboardPage.tsx | 2 +- app/vite.config.ts | 4 + 18 files changed, 2432 insertions(+), 66 deletions(-) create mode 100644 app/src/shared/lib/realtime/ephemeral.ts create mode 100644 app/src/shared/lib/realtime/index.ts create mode 100644 app/src/shared/lib/realtime/messages.ts create mode 100644 app/src/shared/lib/realtime/sync.ts diff --git a/app/package-lock.json b/app/package-lock.json index 619d6ead..fbad2822 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -41,12 +41,12 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", - "argon2-browser": "^1.18.0", "bip39": "^3.1.0", "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "hash-wasm": "^4.12.0", "is-hotkey": "^0.2.0", "libsodium-wrappers-sumo": "^0.8.0", "lucide-react": "^0.544.0", @@ -90,6 +90,8 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-plugin-pwa": "^1.1.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", "vitest": "^3.0.5", "web-vitals": "^5.1.0" } @@ -5351,6 +5353,24 @@ } } }, + "node_modules/@rollup/plugin-virtual": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@rollup/plugin-virtual/-/plugin-virtual-3.0.2.tgz", + "integrity": "sha512-10monEYsBp3scM4/ND4LNH5Rxvh3e/cVeL3jWTgZ2SrQ+BmUoQcopVQvnaMcOnykb1VkxUFuDAN+0FnpTFRy2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, "node_modules/@rollup/pluginutils": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", @@ -5793,6 +5813,239 @@ "sourcemap-codec": "^1.4.8" } }, + "node_modules/@swc/core": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.8.tgz", + "integrity": "sha512-T8keoJjXaSUoVBCIjgL6wAnhADIb09GOELzKg10CjNg+vLX48P93SME6jTfte9MZIm5m+Il57H3rTSk/0kzDUw==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3", + "@swc/types": "^0.1.25" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/swc" + }, + "optionalDependencies": { + "@swc/core-darwin-arm64": "1.15.8", + "@swc/core-darwin-x64": "1.15.8", + "@swc/core-linux-arm-gnueabihf": "1.15.8", + "@swc/core-linux-arm64-gnu": "1.15.8", + "@swc/core-linux-arm64-musl": "1.15.8", + "@swc/core-linux-x64-gnu": "1.15.8", + "@swc/core-linux-x64-musl": "1.15.8", + "@swc/core-win32-arm64-msvc": "1.15.8", + "@swc/core-win32-ia32-msvc": "1.15.8", + "@swc/core-win32-x64-msvc": "1.15.8" + }, + "peerDependencies": { + "@swc/helpers": ">=0.5.17" + }, + "peerDependenciesMeta": { + "@swc/helpers": { + "optional": true + } + } + }, + "node_modules/@swc/core-darwin-arm64": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.8.tgz", + "integrity": "sha512-M9cK5GwyWWRkRGwwCbREuj6r8jKdES/haCZ3Xckgkl8MUQJZA3XB7IXXK1IXRNeLjg6m7cnoMICpXv1v1hlJOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-darwin-x64": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.8.tgz", + "integrity": "sha512-j47DasuOvXl80sKJHSi2X25l44CMc3VDhlJwA7oewC1nV1VsSzwX+KOwE5tLnfORvVJJyeiXgJORNYg4jeIjYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm-gnueabihf": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.8.tgz", + "integrity": "sha512-siAzDENu2rUbwr9+fayWa26r5A9fol1iORG53HWxQL1J8ym4k7xt9eME0dMPXlYZDytK5r9sW8zEA10F2U3Xwg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-gnu": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.8.tgz", + "integrity": "sha512-o+1y5u6k2FfPYbTRUPvurwzNt5qd0NTumCTFscCNuBksycloXY16J8L+SMW5QRX59n4Hp9EmFa3vpvNHRVv1+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-arm64-musl": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.8.tgz", + "integrity": "sha512-koiCqL09EwOP1S2RShCI7NbsQuG6r2brTqUYE7pV7kZm9O17wZ0LSz22m6gVibpwEnw8jI3IE1yYsQTVpluALw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-gnu": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.8.tgz", + "integrity": "sha512-4p6lOMU3bC+Vd5ARtKJ/FxpIC5G8v3XLoPEZ5s7mLR8h7411HWC/LmTXDHcrSXRC55zvAVia1eldy6zDLz8iFQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-linux-x64-musl": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.8.tgz", + "integrity": "sha512-z3XBnbrZAL+6xDGAhJoN4lOueIxC/8rGrJ9tg+fEaeqLEuAtHSW2QHDHxDwkxZMjuF/pZ6MUTjHjbp8wLbuRLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-arm64-msvc": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.8.tgz", + "integrity": "sha512-djQPJ9Rh9vP8GTS/Df3hcc6XP6xnG5c8qsngWId/BLA9oX6C7UzCPAn74BG/wGb9a6j4w3RINuoaieJB3t+7iQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-ia32-msvc": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.8.tgz", + "integrity": "sha512-/wfAgxORg2VBaUoFdytcVBVCgf1isWZIEXB9MZEUty4wwK93M/PxAkjifOho9RN3WrM3inPLabICRCEgdHpKKQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/core-win32-x64-msvc": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.8.tgz", + "integrity": "sha512-GpMePrh9Sl4d61o4KAHOOv5is5+zt6BEXCOCgs/H0FLGeii7j9bWDE8ExvKFy2GRRZVNR1ugsnzaGWHKM6kuzA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=10" + } + }, + "node_modules/@swc/counter": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@swc/counter/-/counter-0.1.3.tgz", + "integrity": "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@swc/types": { + "version": "0.1.25", + "resolved": "https://registry.npmjs.org/@swc/types/-/types-0.1.25.tgz", + "integrity": "sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@swc/counter": "^0.1.3" + } + }, + "node_modules/@swc/wasm": { + "version": "1.15.8", + "resolved": "https://registry.npmjs.org/@swc/wasm/-/wasm-1.15.8.tgz", + "integrity": "sha512-RG2BxGbbsjtddFCo1ghKH6A/BMXbY1eMBfpysV0lJMCpI4DZOjW1BNBnxvBt7YsYmlJtmy5UXIg9/4ekBTFFaQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@tailwindcss/node": { "version": "4.1.14", "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.14.tgz", @@ -8025,12 +8278,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/argon2-browser": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/argon2-browser/-/argon2-browser-1.18.0.tgz", - "integrity": "sha512-ImVAGIItnFnvET1exhsQB7apRztcoC5TnlSqernMJDUjbc/DLq3UEYeXFrLPrlaIl8cVfwnXb6wX2KpFf2zxHw==", - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -11469,6 +11716,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hash-wasm": { + "version": "4.12.0", + "resolved": "https://registry.npmjs.org/hash-wasm/-/hash-wasm-4.12.0.tgz", + "integrity": "sha512-+/2B2rYLb48I/evdOIhP+K/DD2ca2fgBjp6O+GBEnCDk2e4rpeXIK8GvIyRPjTezgmWn9gmKwkQjjx6BtqDHVQ==", + "license": "MIT" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -17473,6 +17726,46 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/vite-plugin-top-level-await": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/vite-plugin-top-level-await/-/vite-plugin-top-level-await-1.6.0.tgz", + "integrity": "sha512-bNhUreLamTIkoulCR9aDXbTbhLk6n1YE8NJUTTxl5RYskNRtzOR0ASzSjBVRtNdjIfngDXo11qOsybGLNsrdww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/plugin-virtual": "^3.0.2", + "@swc/core": "^1.12.14", + "@swc/wasm": "^1.12.14", + "uuid": "10.0.0" + }, + "peerDependencies": { + "vite": ">=2.8" + } + }, + "node_modules/vite-plugin-top-level-await/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/vite-plugin-wasm": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-wasm/-/vite-plugin-wasm-3.5.0.tgz", + "integrity": "sha512-X5VWgCnqiQEGb+omhlBVsvTfxikKtoOgAzQ95+BZ8gQ+VfMHIjSHr0wyvXFQCa0eKQ0fKyaL0kWcEnYqBac4lQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7" + } + }, "node_modules/vite-tsconfig-paths": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", diff --git a/app/package.json b/app/package.json index 84289e6a..f982e8f6 100644 --- a/app/package.json +++ b/app/package.json @@ -50,12 +50,12 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", - "argon2-browser": "^1.18.0", "bip39": "^3.1.0", "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "hash-wasm": "^4.12.0", "is-hotkey": "^0.2.0", "libsodium-wrappers-sumo": "^0.8.0", "lucide-react": "^0.544.0", @@ -104,6 +104,8 @@ "typescript": "^5.7.2", "vite": "^7.1.7", "vite-plugin-pwa": "^1.1.0", + "vite-plugin-top-level-await": "^1.6.0", + "vite-plugin-wasm": "^3.5.0", "vitest": "^3.0.5", "web-vitals": "^5.1.0" } diff --git a/app/src/entities/document/api/index.ts b/app/src/entities/document/api/index.ts index 7894aac5..004e08dd 100644 --- a/app/src/entities/document/api/index.ts +++ b/app/src/entities/document/api/index.ts @@ -177,11 +177,11 @@ export async function downloadSnapshot(params: { export function useCreateDocument() { const qc = useQueryClient() return useMutation({ - mutationFn: (input: { title?: string; parent_id?: string | null; type?: 'folder' | 'document' }) => + mutationFn: (input: { title?: string; parentId?: string | null; type?: 'folder' | 'document' }) => apiCreateDocument({ requestBody: { title: input.title ?? 'Untitled', - parent_id: input.parent_id ?? null, + parentId: input.parentId ?? null, type: input.type, }, }), diff --git a/app/src/entities/file/api/index.ts b/app/src/entities/file/api/index.ts index 51bc8a41..5b4e2d61 100644 --- a/app/src/entities/file/api/index.ts +++ b/app/src/entities/file/api/index.ts @@ -6,6 +6,7 @@ export const fileKeys = { export async function uploadAttachment(documentId: string, file: File) { return apiUploadFile({ - formData: { file: file as any, document_id: documentId } as any, + docId: documentId, + formData: { file }, }) } diff --git a/app/src/features/e2ee/lib/crypto/argon2.ts b/app/src/features/e2ee/lib/crypto/argon2.ts index a560f95c..48dc8498 100644 --- a/app/src/features/e2ee/lib/crypto/argon2.ts +++ b/app/src/features/e2ee/lib/crypto/argon2.ts @@ -10,7 +10,7 @@ * - parallelism: 4 */ -import argon2 from 'argon2-browser' +import { argon2id } from 'hash-wasm' import { getSodium } from './sodium' /** Salt size for Argon2id (16 bytes) */ @@ -63,17 +63,22 @@ export async function deriveKey( throw new Error(`Invalid salt length: expected ${SALT_SIZE}, got ${salt.length}`) } - const result = await argon2.hash({ - pass: passphrase, + const hashHex = await argon2id({ + password: passphrase, salt, - time: params.iterations, - mem: params.memory, + iterations: params.iterations, + memorySize: params.memory, parallelism: params.parallelism, - hashLen: KEY_SIZE, - type: argon2.ArgonType.Argon2id, + hashLength: KEY_SIZE, + outputType: 'hex', }) - return result.hash + // Convert hex string to Uint8Array + const bytes = new Uint8Array(KEY_SIZE) + for (let i = 0; i < KEY_SIZE; i++) { + bytes[i] = parseInt(hashHex.slice(i * 2, i * 2 + 2), 16) + } + return bytes } /** @@ -101,14 +106,14 @@ export async function deriveKeyWithNewSalt( export async function isArgon2Supported(): Promise { try { // Try a minimal hash to check if Argon2 WASM is working - await argon2.hash({ - pass: 'test', + await argon2id({ + password: 'test', salt: new Uint8Array(16), - time: 1, - mem: 1024, + iterations: 1, + memorySize: 1024, parallelism: 1, - hashLen: 32, - type: argon2.ArgonType.Argon2id, + hashLength: 32, + outputType: 'hex', }) return true } catch { diff --git a/app/src/features/e2ee/lib/crypto/ecdh.ts b/app/src/features/e2ee/lib/crypto/ecdh.ts index 08165c7c..0881187e 100644 --- a/app/src/features/e2ee/lib/crypto/ecdh.ts +++ b/app/src/features/e2ee/lib/crypto/ecdh.ts @@ -5,9 +5,9 @@ * Uses @noble/curves for elliptic curve operations. */ -import { p256 } from '@noble/curves/p256' -import { hkdf } from '@noble/hashes/hkdf' -import { sha256 } from '@noble/hashes/sha256' +import { p256 } from '@noble/curves/nist.js' +import { hkdf } from '@noble/hashes/hkdf.js' +import { sha256 } from '@noble/hashes/sha2.js' /** P-256 private key size (32 bytes) */ export const PRIVATE_KEY_SIZE = 32 @@ -33,7 +33,7 @@ export interface EcdhKeyPair { * @returns New ECDH key pair */ export function generateKeyPair(): EcdhKeyPair { - const privateKey = p256.utils.randomPrivateKey() + const privateKey = p256.utils.randomSecretKey() const publicKey = p256.getPublicKey(privateKey, false) // uncompressed return { privateKey, publicKey } @@ -159,9 +159,8 @@ export async function decryptKeyFromSender( */ export function isValidPublicKey(publicKey: Uint8Array): boolean { try { - // Try to create a point from the public key - p256.ProjectivePoint.fromHex(publicKey) - return true + // Try to validate the public key + return p256.utils.isValidPublicKey(publicKey) } catch { return false } diff --git a/app/src/features/e2ee/lib/crypto/pbkdf2.ts b/app/src/features/e2ee/lib/crypto/pbkdf2.ts index 1ad38894..e2a521aa 100644 --- a/app/src/features/e2ee/lib/crypto/pbkdf2.ts +++ b/app/src/features/e2ee/lib/crypto/pbkdf2.ts @@ -63,7 +63,7 @@ export async function deriveKey( const derivedBits = await crypto.subtle.deriveBits( { name: 'PBKDF2', - salt, + salt: salt as BufferSource, iterations, hash: 'SHA-256', }, diff --git a/app/src/features/e2ee/lib/crypto/xchacha20.ts b/app/src/features/e2ee/lib/crypto/xchacha20.ts index 6167e289..83b92188 100644 --- a/app/src/features/e2ee/lib/crypto/xchacha20.ts +++ b/app/src/features/e2ee/lib/crypto/xchacha20.ts @@ -5,7 +5,7 @@ * Note: This does NOT include robustnessTag (differs from secsync) */ -import { getSodium, type Sodium } from './sodium' +import { getSodium } from './sodium' /** XChaCha20-Poly1305 nonce size (24 bytes) */ export const NONCE_SIZE = 24 diff --git a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts index 647ef65e..49a767be 100644 --- a/app/src/features/edit-document/hooks/useCollaborativeDocument.ts +++ b/app/src/features/edit-document/hooks/useCollaborativeDocument.ts @@ -10,6 +10,7 @@ import { fetchDocumentMeta } from '@/entities/document' import { validateShareToken } from '@/entities/share' import { useAuthContext } from '@/features/auth' +import { getKeyManager, SessionLockedError } from '@/features/e2ee' export type RealtimeStatus = 'connecting' | 'connected' | 'disconnected' @@ -73,6 +74,7 @@ async function acquireConnection( token: token ?? null, connect: false, disablePersistence, + workspaceId: workspaceId ?? undefined, }) connectionCache.set(cacheKey, entry) try { @@ -128,6 +130,8 @@ export function useCollaborativeDocument( const [archived, setArchived] = React.useState(false) const [shareReadOnly, setShareReadOnly] = React.useState(false) const [error, setError] = React.useState(null) + const [e2eeUnlocked, setE2eeUnlocked] = React.useState(null) + const [needsE2EEUnlock, setNeedsE2EEUnlock] = React.useState(false) const connectionRef = React.useRef(null) const cacheKeyRef = React.useRef(null) @@ -180,6 +184,77 @@ export function useCollaborativeDocument( useUrlShareTokenFallback, ]) + // Check E2EE unlock status before connecting + React.useEffect(() => { + if (!enabled) { + setE2eeUnlocked(null) + setNeedsE2EEUnlock(false) + return + } + + // Share token access doesn't require E2EE unlock (share keys handle decryption) + const token = resolveShareToken(shareToken, useUrlShareTokenFallback) + if (token) { + setE2eeUnlocked(true) + setNeedsE2EEUnlock(false) + return + } + + // No workspace = no E2EE required + if (!activeWorkspaceId) { + setE2eeUnlocked(true) + setNeedsE2EEUnlock(false) + return + } + + let cancelled = false + ;(async () => { + try { + const keyManager = await getKeyManager() + + // Check if E2EE is set up for this user + const hasKeys = await keyManager.hasKeys() + if (!hasKeys) { + // E2EE not set up - allow connection (will fail at key fetch if needed) + if (!cancelled) { + setE2eeUnlocked(true) + setNeedsE2EEUnlock(false) + } + return + } + + // E2EE is set up - check if session is unlocked + if (keyManager.isUnlocked) { + if (!cancelled) { + setE2eeUnlocked(true) + setNeedsE2EEUnlock(false) + } + } else { + // Session locked - need unlock + if (!cancelled) { + setE2eeUnlocked(false) + setNeedsE2EEUnlock(true) + } + } + } catch (err) { + if (cancelled) return + if (err instanceof SessionLockedError) { + setE2eeUnlocked(false) + setNeedsE2EEUnlock(true) + } else { + // Other errors - allow connection attempt + console.warn('[collaboration] E2EE check failed', err) + setE2eeUnlocked(true) + setNeedsE2EEUnlock(false) + } + } + })() + + return () => { + cancelled = true + } + }, [enabled, shareToken, useUrlShareTokenFallback, activeWorkspaceId]) + React.useEffect(() => { if (!enabled) return const token = resolveShareToken(shareToken, useUrlShareTokenFallback) @@ -249,6 +324,19 @@ export function useCollaborativeDocument( return () => {} } + // Wait for E2EE check to complete + if (e2eeUnlocked === null) { + setStatus('connecting') + return () => {} + } + + // E2EE unlock required - don't connect + if (needsE2EEUnlock) { + setStatus('disconnected') + setError('E2EE session locked. Please unlock to continue.') + return () => {} + } + setStatus('connecting') setError(null) connectionRef.current = null @@ -306,17 +394,9 @@ export function useCollaborativeDocument( const isOnline = typeof navigator === 'undefined' ? true : navigator.onLine provider.shouldConnect = isOnline - const isProviderConnected = (() => { - const anyProvider = provider as any - if (typeof anyProvider?.wsconnected === 'boolean') return anyProvider.wsconnected - const ws = anyProvider?.ws - return Boolean(ws && typeof ws.readyState === 'number' && ws.readyState === 1) - })() if (!isOnline) { updateStatus('disconnected') - } else if (isProviderConnected) { - updateStatus('connected') } else { updateStatus('connecting') provider.connect() @@ -459,6 +539,8 @@ export function useCollaborativeDocument( useUrlShareTokenFallback, trackAwareness, activeWorkspaceId, + e2eeUnlocked, + needsE2EEUnlock, ]) React.useEffect(() => { @@ -484,6 +566,7 @@ export function useCollaborativeDocument( awareness: connectionRef.current?.provider.awareness ?? null, error, archived, + needsE2EEUnlock, } } diff --git a/app/src/features/plugins/lib/runtime.ts b/app/src/features/plugins/lib/runtime.ts index 773dde58..1354475e 100644 --- a/app/src/features/plugins/lib/runtime.ts +++ b/app/src/features/plugins/lib/runtime.ts @@ -511,7 +511,8 @@ async function executeHostAction( const file: File | undefined = args?.file if (!(file instanceof File)) throw fail('BAD_REQUEST', 'file required') const response = await uploadFile({ - formData: { document_id: docId, file } as any, + docId, + formData: { file }, }) return ok(response) } diff --git a/app/src/features/search/ui/SearchDialog.tsx b/app/src/features/search/ui/SearchDialog.tsx index 8c8b8d9a..4b4f7bc6 100644 --- a/app/src/features/search/ui/SearchDialog.tsx +++ b/app/src/features/search/ui/SearchDialog.tsx @@ -122,8 +122,16 @@ export default function SearchDialog({ open, onOpenChange, presetTag }: Props) { let cancelled = false ;(async () => { try { - const res = (await listTags(undefined)) as TagHit[] - if (!cancelled) setTags(res ?? []) + const res = await listTags(undefined) + if (!cancelled) { + // Map API TagEntry to internal TagHit format + // TODO: Decrypt encryptedName when E2EE is implemented + const mapped = (res?.tags ?? []).map((tag) => ({ + name: tag.encryptedName, // Will be decrypted later + count: tag.documentCount, + })) + setTags(mapped) + } } catch { if (!cancelled) setTags([]) } diff --git a/app/src/shared/lib/realtime/ephemeral.ts b/app/src/shared/lib/realtime/ephemeral.ts new file mode 100644 index 00000000..7de7256e --- /dev/null +++ b/app/src/shared/lib/realtime/ephemeral.ts @@ -0,0 +1,561 @@ +/** + * E2EE Ephemeral Message Handlers + * + * Implements ephemeral message protocol for Yjs Awareness. + * Uses DEK directly for encryption with 4-step session handshake. + */ + +import { + encrypt, + decrypt, + sign, + verify, + toBase64, + fromBase64, + canonicalizeAndToBase64, + getSodium, + SIGNATURE_DOMAINS, + type Ed25519KeyPair, + type SigningMessage, +} from '@/features/e2ee' + +// ============================================================================ +// Constants +// ============================================================================ + +/** Session ID length in bytes */ +export const SESSION_ID_LENGTH = 24 + +/** Counter length in bytes */ +export const COUNTER_LENGTH = 4 + +/** Max value for initial random counter */ +const MAX_INITIAL_COUNTER = 2147483647 // Math.floor(0xffffffff / 2) + +// ============================================================================ +// Message Types +// ============================================================================ + +/** + * Ephemeral message types for session handshake. + */ +export const messageTypes = { + /** New client announces presence */ + initialize: 1, + /** Send proof and request proof from remote */ + proofAndRequestProof: 2, + /** Send proof only */ + proof: 3, + /** Actual awareness data */ + message: 4, +} as const + +export type MessageType = keyof typeof messageTypes + +// ============================================================================ +// Types +// ============================================================================ + +/** Validated sessions from other clients */ +export type ValidSessions = { + [authorPublicKey: string]: { + sessionId: string + sessionCounter: number + } +} + +/** Ephemeral session state */ +export interface EphemeralSession { + /** Own session ID (24 bytes, Base64) */ + id: string + /** Own counter (incremented on each send) */ + counter: number + /** Validated sessions from other clients */ + validSessions: ValidSessions +} + +/** Ephemeral message public data (not encrypted, used as AAD) */ +export interface EphemeralPublicData { + docId: string + pubKey: string // Ed25519 signing public key (Base64) +} + +/** Wire format for ephemeral messages */ +export interface EphemeralMessage { + ciphertext: string // Base64 + nonce: string // Base64 + signature: string // Base64 Ed25519 + publicData: EphemeralPublicData +} + +/** Result of verifying and decrypting an ephemeral message */ +export interface VerifyResult { + /** Updated valid sessions */ + validSessions?: ValidSessions + /** Proof to send back (if requested) */ + proof?: Uint8Array + /** Whether to request proof from remote */ + requestProof?: boolean + /** Decrypted content (only for message type) */ + content?: Uint8Array + /** Error if verification failed */ + error?: Error +} + +// ============================================================================ +// Session Management +// ============================================================================ + +/** + * Generate a random session ID. + * + * @returns Base64-encoded session ID (24 bytes) + */ +export function generateSessionId(): string { + const bytes = new Uint8Array(SESSION_ID_LENGTH) + crypto.getRandomValues(bytes) + return uint8ArrayToBase64(bytes) +} + +/** + * Generate a random initial counter. + * + * Using a large random initial value hides metadata about when the session started. + * + * @returns Random counter between 0 and MAX_INITIAL_COUNTER + */ +function generateInitialCounter(): number { + const array = new Uint32Array(1) + crypto.getRandomValues(array) + return array[0] % MAX_INITIAL_COUNTER +} + +/** + * Create a new ephemeral session. + * + * @returns New session with random ID and counter + */ +export function createEphemeralSession(): EphemeralSession { + return { + id: generateSessionId(), + counter: generateInitialCounter(), + validSessions: {}, + } +} + +// ============================================================================ +// Message Creation +// ============================================================================ + +/** + * Create an ephemeral message. + * + * Message structure (before encryption): + * [type (1 byte)] + [sessionId (24 bytes)] + [counter (4 bytes)] + [content] + * + * @param content - Message content (awareness update or proof) + * @param messageType - Type of message + * @param publicData - Public data (docId, pubKey) used as AAD + * @param dek - Document encryption key + * @param signatureKeyPair - Ed25519 signing key pair + * @param session - Current session state + * @returns Encrypted and signed ephemeral message + */ +export async function createEphemeralMessage( + content: Uint8Array, + messageType: MessageType, + publicData: EphemeralPublicData, + dek: Uint8Array, + signatureKeyPair: Ed25519KeyPair, + session: EphemeralSession +): Promise<{ message: EphemeralMessage; updatedSession: EphemeralSession }> { + // Increment counter for this message + const newCounter = session.counter + 1 + const updatedSession = { ...session, counter: newCounter } + + // Build prefixed content: [type] + [sessionId] + [counter] + [content] + const sessionIdBytes = base64ToUint8Array(session.id) + const prefixedContent = prefixWithSessionInfo( + content, + messageTypes[messageType], + sessionIdBytes, + newCounter + ) + + // Encrypt with DEK + const { ciphertext, nonce } = await encrypt(dek, prefixedContent) + + // Encode to Base64 + const ciphertextBase64 = await toBase64(ciphertext) + const nonceBase64 = await toBase64(nonce) + const publicDataBase64 = await canonicalizeAndToBase64(publicData) + + // Build signing message + const signingMessage: SigningMessage = { + ciphertext: ciphertextBase64, + nonce: nonceBase64, + publicData: publicDataBase64, + } + + // Sign the message + const signature = await sign(signatureKeyPair.privateKey, SIGNATURE_DOMAINS.EPHEMERAL, signingMessage) + const signatureBase64 = await toBase64(signature) + + const message: EphemeralMessage = { + ciphertext: ciphertextBase64, + nonce: nonceBase64, + signature: signatureBase64, + publicData, + } + + return { message, updatedSession } +} + +/** + * Create an initialize message to announce presence. + */ +export async function createInitializeMessage( + publicData: EphemeralPublicData, + dek: Uint8Array, + signatureKeyPair: Ed25519KeyPair, + session: EphemeralSession +): Promise<{ message: EphemeralMessage; updatedSession: EphemeralSession }> { + // Initialize message has empty content + return createEphemeralMessage( + new Uint8Array(0), + 'initialize', + publicData, + dek, + signatureKeyPair, + session + ) +} + +// ============================================================================ +// Message Verification and Decryption +// ============================================================================ + +/** + * Verify and decrypt an ephemeral message. + * + * @param message - Received ephemeral message + * @param dek - Document encryption key + * @param currentDocId - Current document ID (for validation) + * @param session - Current session state + * @param signatureKeyPair - Own Ed25519 key pair (for creating proofs) + * @returns Verification result with updated sessions and/or content + */ +export async function verifyAndDecryptEphemeralMessage( + message: EphemeralMessage, + dek: Uint8Array, + currentDocId: string, + session: EphemeralSession, + signatureKeyPair: Ed25519KeyPair +): Promise { + try { + // Validate document ID + if (message.publicData.docId !== currentDocId) { + return { validSessions: session.validSessions } + } + + const senderPublicKey = await fromBase64(message.publicData.pubKey) + const publicDataBase64 = await canonicalizeAndToBase64(message.publicData) + + // Build signing message for verification + const signingMessage: SigningMessage = { + ciphertext: message.ciphertext, + nonce: message.nonce, + publicData: publicDataBase64, + } + + // Verify signature + const signature = await fromBase64(message.signature) + const isValid = await verify(senderPublicKey, signature, SIGNATURE_DOMAINS.EPHEMERAL, signingMessage) + if (!isValid) { + return { error: new Error('EPHEMERAL_ERROR_308') } // Invalid signature + } + + // Decrypt content + const ciphertext = await fromBase64(message.ciphertext) + const nonce = await fromBase64(message.nonce) + let decrypted: Uint8Array + try { + decrypted = await decrypt(dek, ciphertext, nonce) + } catch { + return { error: new Error('EPHEMERAL_ERROR_301') } // Decryption failed + } + + // Parse prefix: [type (1)] + [sessionId (24)] + [counter (4)] + [content] + const { type, sessionId, counter, content } = parsePrefix(decrypted) + const senderPublicKeyBase64 = message.publicData.pubKey + + // Handle by message type + switch (type) { + case messageTypes.initialize: + // Create proof and request proof back + const initProof = await createEphemeralSessionProof( + sessionId, + session.id, + signatureKeyPair + ) + return { + proof: initProof, + requestProof: true, + validSessions: session.validSessions, + } + + case messageTypes.proofAndRequestProof: + case messageTypes.proof: { + // Verify the proof + const isProofValid = await verifyEphemeralSessionProof( + content, + session.id, + sessionId, + senderPublicKey + ) + + if (isProofValid) { + // Session established + const newValidSessions: ValidSessions = { + ...session.validSessions, + [senderPublicKeyBase64]: { + sessionId, + sessionCounter: counter, + }, + } + + // Create response proof if requested + let responseProof: Uint8Array | undefined + if (type === messageTypes.proofAndRequestProof) { + responseProof = await createEphemeralSessionProof( + sessionId, + session.id, + signatureKeyPair + ) + } + + return { + validSessions: newValidSessions, + proof: responseProof, + requestProof: false, + } + } else { + return { validSessions: session.validSessions } + } + } + + case messageTypes.message: { + const existingSession = session.validSessions[senderPublicKeyBase64] + + // Check if session is valid + if (!existingSession || existingSession.sessionId !== sessionId) { + // Unknown session - treat as initialize + const msgProof = await createEphemeralSessionProof( + sessionId, + session.id, + signatureKeyPair + ) + return { + proof: msgProof, + requestProof: true, + validSessions: session.validSessions, + error: new Error('EPHEMERAL_ERROR_302'), // Session not established + } + } + + // Replay attack check + if (existingSession.sessionCounter >= counter) { + return { error: new Error('EPHEMERAL_ERROR_303') } // Replay attack + } + + // Update counter and return content + const newValidSessions: ValidSessions = { + ...session.validSessions, + [senderPublicKeyBase64]: { + sessionId, + sessionCounter: counter, + }, + } + + return { + content, + validSessions: newValidSessions, + } + } + + default: + return { error: new Error('EPHEMERAL_ERROR_305') } // Unknown message type + } + } catch (err) { + console.error('[ephemeral] Error processing message:', err) + return { error: new Error('EPHEMERAL_ERROR_307') } // General error + } +} + +// ============================================================================ +// Session Proof +// ============================================================================ + +/** + * Create a session proof. + * + * The proof is a signature over: + * { remoteClientSessionId, currentClientSessionId } + * + * @param remoteSessionId - Remote client's session ID (Base64) + * @param currentSessionId - Own session ID (Base64) + * @param signatureKeyPair - Own Ed25519 key pair + * @returns Proof as signature bytes + */ +export async function createEphemeralSessionProof( + remoteSessionId: string, + currentSessionId: string, + signatureKeyPair: Ed25519KeyPair +): Promise { + const sodium = await getSodium() + + // Create proof data as JSON + const proofData = JSON.stringify({ + currentClientSessionId: currentSessionId, + remoteClientSessionId: remoteSessionId, + }) + + // Domain + proof data + const domain = 'refmd_ephemeral_session_proof' + const encoder = new TextEncoder() + const domainBytes = encoder.encode(domain) + const dataBytes = encoder.encode(proofData) + + const messageBytes = new Uint8Array(domainBytes.length + dataBytes.length) + messageBytes.set(domainBytes, 0) + messageBytes.set(dataBytes, domainBytes.length) + + return sodium.crypto_sign_detached(messageBytes, signatureKeyPair.privateKey) +} + +/** + * Verify a session proof. + * + * @param proof - Proof bytes (signature) + * @param expectedCurrentSessionId - Expected value of currentClientSessionId in proof + * @param remoteSessionId - Remote client's session ID + * @param remotePublicKey - Remote client's Ed25519 public key + * @returns True if proof is valid + */ +export async function verifyEphemeralSessionProof( + proof: Uint8Array, + expectedCurrentSessionId: string, + remoteSessionId: string, + remotePublicKey: Uint8Array +): Promise { + const sodium = await getSodium() + + // Create expected proof data (note: currentClient is the remote, remoteClient is us) + const proofData = JSON.stringify({ + currentClientSessionId: remoteSessionId, + remoteClientSessionId: expectedCurrentSessionId, + }) + + // Domain + proof data + const domain = 'refmd_ephemeral_session_proof' + const encoder = new TextEncoder() + const domainBytes = encoder.encode(domain) + const dataBytes = encoder.encode(proofData) + + const messageBytes = new Uint8Array(domainBytes.length + dataBytes.length) + messageBytes.set(domainBytes, 0) + messageBytes.set(dataBytes, domainBytes.length) + + try { + return sodium.crypto_sign_verify_detached(proof, messageBytes, remotePublicKey) + } catch { + return false + } +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +/** + * Prefix content with session info. + * + * Format: [type (1)] + [sessionId (24)] + [counter (4)] + [content] + */ +function prefixWithSessionInfo( + content: Uint8Array, + type: number, + sessionId: Uint8Array, + counter: number +): Uint8Array { + const counterBytes = intToUint8Array(counter) + + const result = new Uint8Array(1 + SESSION_ID_LENGTH + COUNTER_LENGTH + content.length) + result[0] = type + result.set(sessionId, 1) + result.set(counterBytes, 1 + SESSION_ID_LENGTH) + result.set(content, 1 + SESSION_ID_LENGTH + COUNTER_LENGTH) + + return result +} + +/** + * Parse prefix from decrypted content. + */ +function parsePrefix(data: Uint8Array): { + type: number + sessionId: string + counter: number + content: Uint8Array +} { + const type = data[0] + const sessionIdBytes = data.slice(1, 1 + SESSION_ID_LENGTH) + const sessionId = uint8ArrayToBase64(sessionIdBytes) + const counterBytes = data.slice(1 + SESSION_ID_LENGTH, 1 + SESSION_ID_LENGTH + COUNTER_LENGTH) + const counter = uint8ArrayToInt(counterBytes) + const content = data.slice(1 + SESSION_ID_LENGTH + COUNTER_LENGTH) + + return { type, sessionId, counter, content } +} + +/** + * Convert integer to 4-byte Uint8Array (big-endian). + */ +function intToUint8Array(num: number): Uint8Array { + const arr = new Uint8Array(4) + arr[0] = (num >> 24) & 0xff + arr[1] = (num >> 16) & 0xff + arr[2] = (num >> 8) & 0xff + arr[3] = num & 0xff + return arr +} + +/** + * Convert 4-byte Uint8Array to integer (big-endian). + */ +function uint8ArrayToInt(arr: Uint8Array): number { + return (arr[0] << 24) | (arr[1] << 16) | (arr[2] << 8) | arr[3] +} + +/** + * Convert Uint8Array to Base64. + */ +function uint8ArrayToBase64(arr: Uint8Array): string { + let binary = '' + for (let i = 0; i < arr.length; i++) { + binary += String.fromCharCode(arr[i]) + } + return btoa(binary) +} + +/** + * Convert Base64 to Uint8Array. + */ +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64) + const arr = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i++) { + arr[i] = binary.charCodeAt(i) + } + return arr +} diff --git a/app/src/shared/lib/realtime/index.ts b/app/src/shared/lib/realtime/index.ts new file mode 100644 index 00000000..e138ba6f --- /dev/null +++ b/app/src/shared/lib/realtime/index.ts @@ -0,0 +1,69 @@ +/** + * E2EE Realtime Sync Module + * + * Provides encrypted Yjs synchronization over WebSocket. + * Replaces y-websocket with E2EE-enabled communication. + */ + +// Main sync functionality +export { + createSecureConnection, + SecureSync, + type SecureConnection, + type SecureConnectionOptions, + type SyncState, + type SyncStatus, + type StatusEvent, + type StatusEventHandler, +} from './sync' + +// Message creation and verification +export { + createUpdate, + createSnapshot, + verifyAndDecryptUpdate, + verifyAndDecryptSnapshot, + decryptInitSnapshot, + decryptSyncUpdate, + isServerInitMessage, + isServerSyncUpdate, + isRealtimeMessage, + type ServerInitMessage, + type ServerSyncUpdate, + type ServerMessage, + type DecryptedUpdate, + type DecryptedSnapshot, + type DecryptedInit, + type DecryptedSyncUpdate, + type RealtimeMessage, + type UpdatePublicData, + type SnapshotPublicData, + type EphemeralPublicData, +} from './messages' + +// Ephemeral (awareness) - session management with 4-step handshake +export { + // Session management + createEphemeralSession, + generateSessionId, + // Message creation + createEphemeralMessage, + createInitializeMessage, + // Message verification + verifyAndDecryptEphemeralMessage, + // Session proof + createEphemeralSessionProof, + verifyEphemeralSessionProof, + // Message types + messageTypes, + // Constants + SESSION_ID_LENGTH, + COUNTER_LENGTH, + // Types + type MessageType, + type ValidSessions, + type EphemeralSession, + type EphemeralPublicData as EphemeralPublicDataType, + type EphemeralMessage, + type VerifyResult, +} from './ephemeral' diff --git a/app/src/shared/lib/realtime/messages.ts b/app/src/shared/lib/realtime/messages.ts new file mode 100644 index 00000000..cdd4f9a4 --- /dev/null +++ b/app/src/shared/lib/realtime/messages.ts @@ -0,0 +1,363 @@ +/** + * E2EE Realtime Message Handlers + * + * Functions for creating and processing encrypted Yjs updates and snapshots. + * Compatible with backend (api/crates/infrastructure/src/documents/realtime/hub.rs) + */ + +import { + encrypt, + decrypt, + sign, + verify, + SIGNATURE_DOMAINS, + type SigningMessage, + canonicalizeAndToBase64, + toBase64, + fromBase64, + fromBase64Json, + type RealtimeMessage, + type UpdatePublicData, + type SnapshotPublicData, + type EphemeralPublicData, + createRealtimeMessage, +} from '@/features/e2ee' + +// Ephemeral messages are now handled by ephemeral.ts +// See: createEphemeralMessage, verifyAndDecryptEphemeralMessage + +// Re-export types for consumers +export type { RealtimeMessage, UpdatePublicData, SnapshotPublicData, EphemeralPublicData } + +// ============================================ +// Types for server messages +// ============================================ + +/** Server init message (snapshot + seq) */ +export interface ServerInitMessage { + type: 'init' + snapshot: { + data: string // Base64 encrypted Yjs state + nonce: string // Base64 nonce + signature: string // Base64 Ed25519 signature + seq_at_snapshot: number // Sequence number at snapshot + } +} + +/** Server sync_update message */ +export interface ServerSyncUpdate { + type: 'sync_update' + update: { + data: string // Base64 encrypted Yjs update + nonce: string // Base64 nonce + signature: string // Base64 Ed25519 signature + public_key: string // Base64 Ed25519 public key + seq: number // Sequence number + } +} + +/** Union type for all server messages */ +export type ServerMessage = ServerInitMessage | ServerSyncUpdate | RealtimeMessage + +// ============================================ +// Create functions (client -> server) +// ============================================ + +/** + * Create an encrypted and signed update message. + * + * @param update - Raw Yjs update bytes + * @param dek - Document encryption key (32 bytes) + * @param signingKeyPair - Ed25519 key pair for signing + * @param publicData - Update metadata + * @returns RealtimeMessage ready to send + */ +export async function createUpdate( + update: Uint8Array, + dek: Uint8Array, + signingKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, + publicData: UpdatePublicData +): Promise { + // 1. Encrypt the update + const { ciphertext, nonce } = await encrypt(dek, update) + + // 2. Encode to Base64 + const ciphertextBase64 = await toBase64(ciphertext) + const nonceBase64 = await toBase64(nonce) + const publicDataBase64 = await canonicalizeAndToBase64(publicData) + + // 3. Build signing message + const signingMessage: SigningMessage = { + ciphertext: ciphertextBase64, + nonce: nonceBase64, + publicData: publicDataBase64, + } + + // 4. Sign the message + const signature = await sign(signingKeyPair.privateKey, SIGNATURE_DOMAINS.UPDATE, signingMessage) + const signatureBase64 = await toBase64(signature) + + // 5. Create RealtimeMessage + return createRealtimeMessage('update', ciphertextBase64, nonceBase64, signatureBase64, publicDataBase64) +} + +/** + * Create an encrypted and signed snapshot message. + * + * @param snapshot - Raw Yjs snapshot bytes (from Y.encodeStateAsUpdateV2) + * @param dek - Document encryption key (32 bytes) + * @param signingKeyPair - Ed25519 key pair for signing + * @param publicData - Snapshot metadata + * @returns RealtimeMessage ready to send + */ +export async function createSnapshot( + snapshot: Uint8Array, + dek: Uint8Array, + signingKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array }, + publicData: SnapshotPublicData +): Promise { + // 1. Encrypt the snapshot + const { ciphertext, nonce } = await encrypt(dek, snapshot) + + // 2. Encode to Base64 + const ciphertextBase64 = await toBase64(ciphertext) + const nonceBase64 = await toBase64(nonce) + const publicDataBase64 = await canonicalizeAndToBase64(publicData) + + // 3. Build signing message + const signingMessage: SigningMessage = { + ciphertext: ciphertextBase64, + nonce: nonceBase64, + publicData: publicDataBase64, + } + + // 4. Sign the message + const signature = await sign(signingKeyPair.privateKey, SIGNATURE_DOMAINS.SNAPSHOT, signingMessage) + const signatureBase64 = await toBase64(signature) + + // 5. Create RealtimeMessage + return createRealtimeMessage('snapshot', ciphertextBase64, nonceBase64, signatureBase64, publicDataBase64) +} + +// NOTE: createAwareness has been removed. +// Use createEphemeralMessage from ephemeral.ts for awareness messages. + +// ============================================ +// Verify and Decrypt functions (for relayed messages) +// ============================================ + +/** Result of verifying and decrypting an update */ +export interface DecryptedUpdate { + /** Decrypted Yjs update bytes */ + update: Uint8Array + /** Parsed public data */ + publicData: UpdatePublicData +} + +/** Result of verifying and decrypting a snapshot */ +export interface DecryptedSnapshot { + /** Decrypted Yjs snapshot bytes */ + snapshot: Uint8Array + /** Parsed public data */ + publicData: SnapshotPublicData +} + +/** + * Verify and decrypt an update message from another client. + * + * @param message - Received RealtimeMessage + * @param dek - Document encryption key (32 bytes) + * @returns Decrypted update with public data + * @throws Error if signature verification fails or decryption fails + */ +export async function verifyAndDecryptUpdate( + message: RealtimeMessage, + dek: Uint8Array +): Promise { + if (message.type !== 'update') { + throw new Error(`Expected update message, got ${message.type}`) + } + + // 1. Parse public data to get sender's public key + const publicData = await fromBase64Json(message.publicData) + const senderPublicKey = await fromBase64(publicData.pubKey) + + // 2. Build signing message for verification + const signingMessage: SigningMessage = { + ciphertext: message.ciphertext, + nonce: message.nonce, + publicData: message.publicData, + } + + // 3. Verify signature + const signature = await fromBase64(message.signature) + const isValid = await verify(senderPublicKey, signature, SIGNATURE_DOMAINS.UPDATE, signingMessage) + + if (!isValid) { + throw new Error('Update signature verification failed') + } + + // 4. Decrypt the update + const ciphertext = await fromBase64(message.ciphertext) + const nonce = await fromBase64(message.nonce) + const update = await decrypt(dek, ciphertext, nonce) + + return { update, publicData } +} + +/** + * Verify and decrypt a snapshot message from another client. + * + * @param message - Received RealtimeMessage + * @param dek - Document encryption key (32 bytes) + * @returns Decrypted snapshot with public data + * @throws Error if signature verification fails or decryption fails + */ +export async function verifyAndDecryptSnapshot( + message: RealtimeMessage, + dek: Uint8Array +): Promise { + if (message.type !== 'snapshot') { + throw new Error(`Expected snapshot message, got ${message.type}`) + } + + // 1. Parse public data to get sender's public key + const publicData = await fromBase64Json(message.publicData) + const senderPublicKey = await fromBase64(publicData.pubKey) + + // 2. Build signing message for verification + const signingMessage: SigningMessage = { + ciphertext: message.ciphertext, + nonce: message.nonce, + publicData: message.publicData, + } + + // 3. Verify signature + const signature = await fromBase64(message.signature) + const isValid = await verify(senderPublicKey, signature, SIGNATURE_DOMAINS.SNAPSHOT, signingMessage) + + if (!isValid) { + throw new Error('Snapshot signature verification failed') + } + + // 4. Decrypt the snapshot + const ciphertext = await fromBase64(message.ciphertext) + const nonce = await fromBase64(message.nonce) + const snapshot = await decrypt(dek, ciphertext, nonce) + + return { snapshot, publicData } +} + +// NOTE: verifyAndDecryptAwareness has been removed. +// Use verifyAndDecryptEphemeralMessage from ephemeral.ts for awareness messages. + +// ============================================ +// Server message decryption (init, sync_update) +// ============================================ + +/** Result of decrypting an init message */ +export interface DecryptedInit { + /** Decrypted Yjs snapshot bytes */ + snapshot: Uint8Array + /** Sequence number at snapshot */ + seqAtSnapshot: number +} + +/** Result of decrypting a sync_update message */ +export interface DecryptedSyncUpdate { + /** Decrypted Yjs update bytes */ + update: Uint8Array + /** Sequence number */ + seq: number + /** Sender's public key */ + publicKey: Uint8Array +} + +/** + * Decrypt an init message from the server. + * Server has already verified the signature. + * + * @param message - Server init message + * @param dek - Document encryption key (32 bytes) + * @returns Decrypted snapshot + */ +export async function decryptInitSnapshot( + message: ServerInitMessage, + dek: Uint8Array +): Promise { + const ciphertext = await fromBase64(message.snapshot.data) + const nonce = await fromBase64(message.snapshot.nonce) + const snapshot = await decrypt(dek, ciphertext, nonce) + + return { + snapshot, + seqAtSnapshot: message.snapshot.seq_at_snapshot, + } +} + +/** + * Decrypt a sync_update message from the server. + * Server has already verified the signature. + * + * @param message - Server sync_update message + * @param dek - Document encryption key (32 bytes) + * @returns Decrypted update + */ +export async function decryptSyncUpdate( + message: ServerSyncUpdate, + dek: Uint8Array +): Promise { + const ciphertext = await fromBase64(message.update.data) + const nonce = await fromBase64(message.update.nonce) + const update = await decrypt(dek, ciphertext, nonce) + const publicKey = await fromBase64(message.update.public_key) + + return { + update, + seq: message.update.seq, + publicKey, + } +} + +// ============================================ +// Message type detection +// ============================================ + +/** + * Check if a message is a server init message. + */ +export function isServerInitMessage(msg: unknown): msg is ServerInitMessage { + return ( + typeof msg === 'object' && + msg !== null && + (msg as ServerInitMessage).type === 'init' && + 'snapshot' in msg + ) +} + +/** + * Check if a message is a server sync_update message. + */ +export function isServerSyncUpdate(msg: unknown): msg is ServerSyncUpdate { + return ( + typeof msg === 'object' && + msg !== null && + (msg as ServerSyncUpdate).type === 'sync_update' && + 'update' in msg + ) +} + +/** + * Check if a message is a relayed RealtimeMessage. + */ +export function isRealtimeMessage(msg: unknown): msg is RealtimeMessage { + if (typeof msg !== 'object' || msg === null) return false + const m = msg as RealtimeMessage + return ( + (m.type === 'update' || m.type === 'snapshot' || m.type === 'awareness') && + typeof m.ciphertext === 'string' && + typeof m.nonce === 'string' && + typeof m.signature === 'string' && + typeof m.publicData === 'string' + ) +} diff --git a/app/src/shared/lib/realtime/sync.ts b/app/src/shared/lib/realtime/sync.ts new file mode 100644 index 00000000..f80fdc31 --- /dev/null +++ b/app/src/shared/lib/realtime/sync.ts @@ -0,0 +1,934 @@ +/** + * E2EE Realtime Sync + * + * Secure synchronization state machine for Yjs documents. + * Replaces y-websocket with E2EE-enabled communication. + */ + +import type * as Y from 'yjs' +import type { Awareness } from 'y-protocols/awareness' +import { + createUpdate, + createSnapshot, + verifyAndDecryptUpdate, + verifyAndDecryptSnapshot, + decryptInitSnapshot, + decryptSyncUpdate, + isServerInitMessage, + isServerSyncUpdate, + isRealtimeMessage, + type ServerMessage, + type UpdatePublicData, + type SnapshotPublicData, +} from './messages' +import { + createEphemeralSession, + createEphemeralMessage, + createInitializeMessage, + verifyAndDecryptEphemeralMessage, + generateSessionId, + type EphemeralSession, + type EphemeralPublicData as EphemeralPublicDataFromEphemeral, +} from './ephemeral' +import { + getKeyManager, + SessionLockedError, + getSodium, + fromBase64, +} from '@/features/e2ee' + +// ============================================ +// Types +// ============================================ + +/** Sync status */ +export type SyncStatus = 'disconnected' | 'connecting' | 'syncing' | 'ready' | 'error' + +/** Sync state */ +export interface SyncState { + status: SyncStatus + lastSeq: number + localClock: number + currentSnapshotId: string | null + updateClocks: Map + error: string | null +} + +/** Status event payload (compatible with y-websocket) */ +export interface StatusEvent { + status: 'connecting' | 'connected' | 'disconnected' +} + +/** Status event handler */ +export type StatusEventHandler = (event: StatusEvent) => void + +/** Options for creating a secure connection */ +export interface SecureConnectionOptions { + token?: string | null + connect?: boolean + workspaceId: string + /** Callback to fetch encrypted KEK from API */ + fetchKek?: () => Promise + /** Callback to fetch encrypted DEK from API */ + fetchDek?: () => Promise<{ encryptedDek: string; nonce: string }> +} + +/** Secure connection interface (compatible with WebsocketProvider API) */ +export interface SecureConnection { + awareness: Awareness + readonly connected: boolean + readonly syncState: SyncState + /** Whether the connection should automatically connect */ + shouldConnect: boolean + connect(): void + disconnect(): void + destroy(): void + /** Listen to status events */ + on(event: 'status', handler: StatusEventHandler): void + /** Stop listening to status events */ + off(event: 'status', handler: StatusEventHandler): void +} + +// ============================================ +// Constants +// ============================================ + +/** Number of updates before creating a new snapshot */ +const SNAPSHOT_THRESHOLD = 100 + +/** Reconnect delay in milliseconds */ +const RECONNECT_DELAY = 1000 + +/** Max reconnect delay */ +const MAX_RECONNECT_DELAY = 30000 + +// ============================================ +// Utility Functions +// ============================================ + +/** + * Compute snapshot proof chain hash. + * Uses BLAKE2b to create a hash of parent snapshot info. + */ +async function computeSnapshotProof( + parentSnapshotId: string, + parentCiphertextHash: string, + updateClocks: Record +): Promise { + const sodium = await getSodium() + + // Build proof data: parentSnapshotId || parentCiphertextHash || sorted(updateClocks) + const clocksJson = JSON.stringify( + Object.entries(updateClocks).sort(([a], [b]) => a.localeCompare(b)) + ) + const proofInput = `${parentSnapshotId}:${parentCiphertextHash}:${clocksJson}` + + // Hash with BLAKE2b (32 bytes) + const proofBytes = sodium.crypto_generichash(32, sodium.from_string(proofInput)) + return sodium.to_base64(proofBytes, sodium.base64_variants.ORIGINAL) +} + +// ============================================ +// SecureSync class +// ============================================ + +/** + * SecureSync - E2EE WebSocket synchronization for Yjs + */ +export class SecureSync { + private doc: Y.Doc + private documentId: string + private workspaceId: string + private serverUrl: string + private token: string | null + private options: SecureConnectionOptions + + private ws: WebSocket | null = null + private awareness: Awareness | null = null + private _connected = false + private _destroyed = false + private _shouldConnect = true + private reconnectAttempts = 0 + private reconnectTimeout: ReturnType | null = null + + // Event listeners + private statusListeners: Set = new Set() + + private dek: Uint8Array | null = null + private signingKeyPair: { publicKey: Uint8Array; privateKey: Uint8Array } | null = null + private publicKeyBase64: string | null = null + + // Ephemeral session state for awareness + private ephemeralSession: EphemeralSession | null = null + + // Parent snapshot ciphertext hash for proof chain + private parentSnapshotCiphertextHash: string = '' + + private state: SyncState = { + status: 'disconnected', + lastSeq: 0, + localClock: 0, + currentSnapshotId: null, + updateClocks: new Map(), + error: null, + } + + private pendingUpdates: Uint8Array[] = [] + private updatesSinceSnapshot = 0 + + // Event handlers + private updateHandler: ((update: Uint8Array, origin: unknown) => void) | null = null + private awarenessHandler: ((changes: { added: number[]; updated: number[]; removed: number[] }, origin: unknown) => void) | null = null + + constructor( + serverUrl: string, + doc: Y.Doc, + documentId: string, + options: SecureConnectionOptions + ) { + this.serverUrl = serverUrl + this.doc = doc + this.documentId = documentId + this.workspaceId = options.workspaceId + this.token = options.token ?? null + this.options = options + } + + /** + * Initialize the secure sync connection. + * Must be called before connect(). + */ + async initialize(): Promise { + const keyManager = getKeyManager() + await keyManager.initialize() + + // Verify session is unlocked + if (!keyManager.isUnlocked) { + throw new SessionLockedError() + } + + // Get signing key pair + this.signingKeyPair = keyManager.getSigningKeyPair() + const publicKeys = await keyManager.getPublicKeysBase64() + this.publicKeyBase64 = publicKeys.signingPublicKey + + // Get KEK for this workspace + const fetchKek = this.options.fetchKek ?? (async () => { + // Default: call API to get workspace KEK + const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' + const response = await fetch(`${baseUrl}/api/workspaces/${this.workspaceId}/kek`, { + headers: this.token ? { Authorization: `Bearer ${this.token}` } : {}, + credentials: 'include', + }) + if (!response.ok) { + throw new Error(`Failed to fetch KEK: ${response.status}`) + } + const data = await response.json() + return data.encryptedKek + }) + + const kek = await keyManager.getWorkspaceKek(this.workspaceId, fetchKek) + + // Get DEK for this document + const fetchDek = this.options.fetchDek ?? (async () => { + // Default: call API to get document DEK + const baseUrl = typeof window !== 'undefined' ? window.location.origin : '' + const response = await fetch(`${baseUrl}/api/documents/${this.documentId}/dek`, { + headers: this.token ? { Authorization: `Bearer ${this.token}` } : {}, + credentials: 'include', + }) + if (!response.ok) { + throw new Error(`Failed to fetch DEK: ${response.status}`) + } + return response.json() + }) + + this.dek = await keyManager.getDocumentDek(this.documentId, kek, fetchDek) + + // Create ephemeral session for awareness + this.ephemeralSession = createEphemeralSession() + + // Initialize Awareness + const { Awareness } = await import('y-protocols/awareness') + this.awareness = new Awareness(this.doc) + } + + /** + * Get the Awareness instance. + */ + getAwareness(): Awareness { + if (!this.awareness) { + throw new Error('SecureSync not initialized. Call initialize() first.') + } + return this.awareness + } + + /** + * Check if connected. + */ + get connected(): boolean { + return this._connected + } + + /** + * Get/set whether the connection should be active. + */ + get shouldConnect(): boolean { + return this._shouldConnect + } + + set shouldConnect(value: boolean) { + this._shouldConnect = value + if (value && !this._connected && !this.ws) { + this.connect() + } else if (!value && this._connected) { + this.disconnect() + } + } + + /** + * Get current sync state. + */ + get syncState(): SyncState { + return { ...this.state } + } + + /** + * Add event listener. + */ + on(event: 'status', handler: StatusEventHandler): void { + if (event === 'status') { + this.statusListeners.add(handler) + } + } + + /** + * Remove event listener. + */ + off(event: 'status', handler: StatusEventHandler): void { + if (event === 'status') { + this.statusListeners.delete(handler) + } + } + + /** + * Emit status event. + */ + private emitStatus(status: 'connecting' | 'connected' | 'disconnected'): void { + const event: StatusEvent = { status } + for (const handler of this.statusListeners) { + try { + handler(event) + } catch (err) { + console.error('[SecureSync] Error in status handler:', err) + } + } + } + + /** + * Connect to the WebSocket server. + */ + connect(): void { + if (this._destroyed) { + console.warn('SecureSync is destroyed, cannot connect') + return + } + + if (this.ws) { + return // Already connected or connecting + } + + this.setState({ status: 'connecting', error: null }) + this.emitStatus('connecting') + + // Build WebSocket URL + const params = new URLSearchParams() + if (this.token) { + params.set('token', this.token) + } + const queryString = params.toString() + const wsUrl = `${this.serverUrl}/${this.documentId}${queryString ? '?' + queryString : ''}` + + this.ws = new WebSocket(wsUrl) + this.ws.binaryType = 'arraybuffer' + + this.ws.onopen = this.handleOpen.bind(this) + this.ws.onmessage = this.handleMessage.bind(this) + this.ws.onclose = this.handleClose.bind(this) + this.ws.onerror = this.handleError.bind(this) + } + + /** + * Disconnect from the WebSocket server. + */ + disconnect(): void { + this.cancelReconnect() + + if (this.ws) { + this.ws.onopen = null + this.ws.onmessage = null + this.ws.onclose = null + this.ws.onerror = null + this.ws.close() + this.ws = null + } + + this._connected = false + this.setState({ status: 'disconnected' }) + this.emitStatus('disconnected') + this.detachDocListeners() + } + + /** + * Destroy the sync instance. + */ + destroy(): void { + this._destroyed = true + this.disconnect() + + if (this.awareness) { + this.awareness.destroy() + this.awareness = null + } + + // Clear keys from memory + if (this.dek) { + this.dek.fill(0) + this.dek = null + } + if (this.signingKeyPair) { + this.signingKeyPair.privateKey.fill(0) + this.signingKeyPair = null + } + + // Clear ephemeral session + this.ephemeralSession = null + } + + // ============================================ + // WebSocket handlers + // ============================================ + + private async handleOpen(): Promise { + this._connected = true + this.reconnectAttempts = 0 + this.setState({ status: 'syncing' }) + this.emitStatus('connected') + this.attachDocListeners() + + // Send initialize message to announce presence (4-step handshake) + await this.sendInitializeMessage() + } + + /** + * Send initialize message to announce presence to other clients. + */ + private async sendInitializeMessage(): Promise { + if (!this._connected || !this.ws || !this.dek || !this.signingKeyPair || !this.publicKeyBase64 || !this.ephemeralSession) { + return + } + + try { + const publicData: EphemeralPublicDataFromEphemeral = { + docId: this.documentId, + pubKey: this.publicKeyBase64, + } + + const { message, updatedSession } = await createInitializeMessage( + publicData, + this.dek, + this.signingKeyPair, + this.ephemeralSession + ) + this.ephemeralSession = updatedSession + + this.ws.send(JSON.stringify({ type: 'awareness', ...message })) + } catch (err) { + console.error('[SecureSync] Error sending initialize message:', err) + } + } + + private async handleMessage(event: MessageEvent): Promise { + try { + // Parse message + let message: ServerMessage + if (typeof event.data === 'string') { + message = JSON.parse(event.data) + } else { + // Binary message - convert to string then parse + const text = new TextDecoder().decode(event.data) + message = JSON.parse(text) + } + + await this.processMessage(message) + } catch (err) { + console.error('[SecureSync] Error processing message:', err) + } + } + + private handleClose(event: CloseEvent): void { + this._connected = false + this.ws = null + this.detachDocListeners() + + if (!this._destroyed && event.code !== 1000 && this._shouldConnect) { + // Abnormal close - attempt reconnect + this.scheduleReconnect() + this.emitStatus('disconnected') + } else { + this.setState({ status: 'disconnected' }) + this.emitStatus('disconnected') + } + } + + private handleError(event: Event): void { + console.error('[SecureSync] WebSocket error:', event) + this.setState({ status: 'error', error: 'WebSocket error' }) + } + + // ============================================ + // Message processing + // ============================================ + + private async processMessage(message: ServerMessage): Promise { + if (!this.dek) { + console.error('[SecureSync] DEK not available') + return + } + + if (isServerInitMessage(message)) { + await this.handleInitMessage(message) + } else if (isServerSyncUpdate(message)) { + await this.handleSyncUpdate(message) + } else if (isRealtimeMessage(message)) { + await this.handleRelayedMessage(message) + } else { + console.warn('[SecureSync] Unknown message type:', message) + } + } + + private async handleInitMessage(message: { type: 'init'; snapshot: { data: string; nonce: string; signature: string; seq_at_snapshot: number } }): Promise { + try { + const { snapshot, seqAtSnapshot } = await decryptInitSnapshot(message, this.dek!) + + // Apply snapshot to document + const Y = await import('yjs') + Y.applyUpdateV2(this.doc, snapshot, 'e2ee-remote') + + this.setState({ + lastSeq: seqAtSnapshot, + status: 'syncing', + }) + } catch (err) { + console.error('[SecureSync] Error processing init message:', err) + this.setState({ status: 'error', error: 'Failed to process init message' }) + } + } + + private async handleSyncUpdate(message: { type: 'sync_update'; update: { data: string; nonce: string; signature: string; public_key: string; seq: number } }): Promise { + try { + const { update, seq } = await decryptSyncUpdate(message, this.dek!) + + // Apply update to document + const Y = await import('yjs') + Y.applyUpdateV2(this.doc, update, 'e2ee-remote') + + this.setState({ lastSeq: Math.max(this.state.lastSeq, seq) }) + + // Check if we've received all sync updates + // The server sends all updates after init, then we're ready + // We consider ourselves ready after receiving updates + if (this.state.status === 'syncing') { + this.setState({ status: 'ready' }) + } + } catch (err) { + console.error('[SecureSync] Error processing sync_update:', err) + } + } + + private async handleRelayedMessage(message: { type: 'update' | 'snapshot' | 'awareness'; ciphertext: string; nonce: string; signature: string; publicData: string }): Promise { + try { + if (message.type === 'update') { + const { update, publicData } = await verifyAndDecryptUpdate(message, this.dek!) + + // Skip our own updates + if (publicData.pubKey === this.publicKeyBase64) { + return + } + + const Y = await import('yjs') + Y.applyUpdateV2(this.doc, update, 'e2ee-remote') + + // Update clocks + const currentClock = this.state.updateClocks.get(publicData.pubKey) ?? 0 + if (publicData.clock > currentClock) { + this.state.updateClocks.set(publicData.pubKey, publicData.clock) + } + + this.updatesSinceSnapshot++ + } else if (message.type === 'snapshot') { + const { snapshot, publicData } = await verifyAndDecryptSnapshot(message, this.dek!) + + // Skip our own snapshots + if (publicData.pubKey === this.publicKeyBase64) { + return + } + + const Y = await import('yjs') + Y.applyUpdateV2(this.doc, snapshot, 'e2ee-remote') + + // Update snapshot info + this.setState({ currentSnapshotId: publicData.snapshotId }) + this.updatesSinceSnapshot = 0 + + // Store ciphertext hash for proof chain + const sodium = await getSodium() + const ciphertextBytes = await fromBase64(message.ciphertext) + const hash = sodium.crypto_generichash(32, ciphertextBytes) + this.parentSnapshotCiphertextHash = sodium.to_base64(hash, sodium.base64_variants.ORIGINAL) + } else if (message.type === 'awareness') { + await this.handleAwarenessMessage(message) + } + } catch (err) { + console.error(`[SecureSync] Error processing ${message.type}:`, err) + } + } + + /** + * Handle incoming awareness message with session handshake. + */ + private async handleAwarenessMessage(message: { type: 'update' | 'snapshot' | 'awareness'; ciphertext: string; nonce: string; signature: string; publicData: string }): Promise { + if (!this.dek || !this.signingKeyPair || !this.ephemeralSession) { + return + } + + // Convert wire format to EphemeralMessage + const ephemeralMessage = { + ciphertext: message.ciphertext, + nonce: message.nonce, + signature: message.signature, + publicData: JSON.parse(atob(message.publicData)) as EphemeralPublicDataFromEphemeral, + } + + // Skip our own messages + if (ephemeralMessage.publicData.pubKey === this.publicKeyBase64) { + return + } + + // Verify, decrypt, and handle session handshake + const result = await verifyAndDecryptEphemeralMessage( + ephemeralMessage, + this.dek, + this.documentId, + this.ephemeralSession, + this.signingKeyPair + ) + + // Update session state + if (result.validSessions) { + this.ephemeralSession = { + ...this.ephemeralSession, + validSessions: result.validSessions, + } + } + + // Send proof response if requested + if (result.proof) { + await this.sendProofResponse( + ephemeralMessage.publicData, + result.proof, + result.requestProof ?? false + ) + } + + // Apply awareness update if content is available + if (result.content && this.awareness) { + try { + const { applyAwarenessUpdate } = await import('y-protocols/awareness') + applyAwarenessUpdate(this.awareness, result.content, null) + } catch (err) { + console.error('[SecureSync] Error applying awareness update:', err) + } + } + } + + /** + * Send proof response message. + */ + private async sendProofResponse( + _remotePublicData: EphemeralPublicDataFromEphemeral, + proof: Uint8Array, + requestProof: boolean + ): Promise { + if (!this._connected || !this.ws || !this.dek || !this.signingKeyPair || !this.publicKeyBase64 || !this.ephemeralSession) { + return + } + + try { + const publicData: EphemeralPublicDataFromEphemeral = { + docId: this.documentId, + pubKey: this.publicKeyBase64, + } + + const messageType = requestProof ? 'proofAndRequestProof' : 'proof' + const { message, updatedSession } = await createEphemeralMessage( + proof, + messageType, + publicData, + this.dek, + this.signingKeyPair, + this.ephemeralSession + ) + this.ephemeralSession = updatedSession + + this.ws.send(JSON.stringify({ type: 'awareness', ...message })) + } catch (err) { + console.error('[SecureSync] Error sending proof response:', err) + } + } + + // ============================================ + // Local change handlers + // ============================================ + + private attachDocListeners(): void { + // Doc update listener + this.updateHandler = (update: Uint8Array, origin: unknown) => { + if (origin === 'e2ee-remote') { + return // Don't re-broadcast remote updates + } + this.handleLocalUpdate(update).catch(console.error) + } + this.doc.on('updateV2', this.updateHandler) + + // Awareness listener + if (this.awareness) { + this.awarenessHandler = ({ added, updated, removed }, origin) => { + if (origin === 'e2ee-remote') { + return + } + const changedClients = [...added, ...updated, ...removed] + this.handleLocalAwarenessChange(changedClients).catch(console.error) + } + this.awareness.on('update', this.awarenessHandler) + } + } + + private detachDocListeners(): void { + if (this.updateHandler) { + this.doc.off('updateV2', this.updateHandler) + this.updateHandler = null + } + + if (this.awareness && this.awarenessHandler) { + this.awareness.off('update', this.awarenessHandler) + this.awarenessHandler = null + } + } + + private async handleLocalUpdate(update: Uint8Array): Promise { + if (!this._connected || !this.ws || !this.dek || !this.signingKeyPair || !this.publicKeyBase64) { + // Queue update for later + this.pendingUpdates.push(update) + return + } + + // Increment local clock + this.state.localClock++ + + const publicData: UpdatePublicData = { + docId: this.documentId, + pubKey: this.publicKeyBase64, + refSnapshotId: this.state.currentSnapshotId ?? '', + clock: this.state.localClock, + } + + try { + const message = await createUpdate(update, this.dek, this.signingKeyPair, publicData) + this.ws.send(JSON.stringify(message)) + + this.updatesSinceSnapshot++ + + // Check if we should create a snapshot + if (this.updatesSinceSnapshot >= SNAPSHOT_THRESHOLD) { + await this.createAndSendSnapshot() + } + } catch (err) { + console.error('[SecureSync] Error sending update:', err) + } + } + + private async handleLocalAwarenessChange(changedClients: number[]): Promise { + if (!this._connected || !this.ws || !this.dek || !this.signingKeyPair || !this.publicKeyBase64 || !this.awareness || !this.ephemeralSession) { + return + } + + try { + const { encodeAwarenessUpdate } = await import('y-protocols/awareness') + const awarenessUpdate = encodeAwarenessUpdate(this.awareness, changedClients) + + const publicData: EphemeralPublicDataFromEphemeral = { + docId: this.documentId, + pubKey: this.publicKeyBase64, + } + + // Use new ephemeral message API with session handshake + const { message, updatedSession } = await createEphemeralMessage( + awarenessUpdate, + 'message', + publicData, + this.dek, + this.signingKeyPair, + this.ephemeralSession + ) + this.ephemeralSession = updatedSession + + this.ws.send(JSON.stringify({ type: 'awareness', ...message })) + } catch (err) { + console.error('[SecureSync] Error sending awareness:', err) + } + } + + private async createAndSendSnapshot(): Promise { + if (!this._connected || !this.ws || !this.dek || !this.signingKeyPair || !this.publicKeyBase64) { + return + } + + try { + const Y = await import('yjs') + const snapshot = Y.encodeStateAsUpdateV2(this.doc) + + // Generate snapshot ID + const snapshotId = generateSessionId() + + // Build update clocks record + const updateClocks: Record = {} + for (const [key, value] of this.state.updateClocks) { + updateClocks[key] = value + } + // Include our own clock + updateClocks[this.publicKeyBase64] = this.state.localClock + + // Compute proof chain + const parentSnapshotProof = this.state.currentSnapshotId + ? await computeSnapshotProof( + this.state.currentSnapshotId, + this.parentSnapshotCiphertextHash, + updateClocks + ) + : '' // No proof for first snapshot + + const publicData: SnapshotPublicData = { + docId: this.documentId, + pubKey: this.publicKeyBase64, + snapshotId, + parentSnapshotId: this.state.currentSnapshotId ?? '', + parentSnapshotProof, + parentSnapshotUpdateClocks: updateClocks, + } + + const message = await createSnapshot(snapshot, this.dek, this.signingKeyPair, publicData) + this.ws.send(JSON.stringify(message)) + + // Compute and store hash of our new snapshot's ciphertext for future proofs + const sodium = await getSodium() + const ciphertextBytes = await fromBase64(message.ciphertext) + const hash = sodium.crypto_generichash(32, ciphertextBytes) + this.parentSnapshotCiphertextHash = sodium.to_base64(hash, sodium.base64_variants.ORIGINAL) + + // Update state + this.setState({ currentSnapshotId: snapshotId }) + this.updatesSinceSnapshot = 0 + } catch (err) { + console.error('[SecureSync] Error sending snapshot:', err) + } + } + + // ============================================ + // Reconnection + // ============================================ + + private scheduleReconnect(): void { + if (this._destroyed || !this._shouldConnect) return + + const delay = Math.min( + RECONNECT_DELAY * Math.pow(2, this.reconnectAttempts), + MAX_RECONNECT_DELAY + ) + + this.reconnectAttempts++ + this.setState({ status: 'disconnected' }) + + this.reconnectTimeout = setTimeout(() => { + this.reconnectTimeout = null + if (this._shouldConnect) { + this.connect() + } + }, delay) + } + + private cancelReconnect(): void { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout) + this.reconnectTimeout = null + } + } + + // ============================================ + // State management + // ============================================ + + private setState(updates: Partial): void { + this.state = { ...this.state, ...updates } + } +} + +// ============================================ +// Factory function +// ============================================ + +/** + * Create a secure E2EE connection for a Yjs document. + * + * This replaces y-websocket's WebsocketProvider. + * + * @param serverUrl - WebSocket server URL + * @param doc - Yjs document + * @param documentId - Document ID + * @param options - Connection options + * @returns SecureConnection interface + */ +export async function createSecureConnection( + serverUrl: string, + doc: Y.Doc, + documentId: string, + options: SecureConnectionOptions +): Promise { + const sync = new SecureSync(serverUrl, doc, documentId, options) + await sync.initialize() + + if (options.connect !== false) { + sync.connect() + } + + return { + awareness: sync.getAwareness(), + get connected() { + return sync.connected + }, + get syncState() { + return sync.syncState + }, + get shouldConnect() { + return sync.shouldConnect + }, + set shouldConnect(value: boolean) { + sync.shouldConnect = value + }, + connect: () => sync.connect(), + disconnect: () => sync.disconnect(), + destroy: () => sync.destroy(), + on: (event: 'status', handler: StatusEventHandler) => sync.on(event, handler), + off: (event: 'status', handler: StatusEventHandler) => sync.off(event, handler), + } +} diff --git a/app/src/shared/lib/yjsConnection.ts b/app/src/shared/lib/yjsConnection.ts index 2d44a4e1..8dc060ef 100644 --- a/app/src/shared/lib/yjsConnection.ts +++ b/app/src/shared/lib/yjsConnection.ts @@ -1,8 +1,9 @@ +import type { Awareness } from 'y-protocols/awareness' import type { IndexeddbPersistence } from 'y-indexeddb' -import type { WebsocketProvider } from 'y-websocket' import type * as Y from 'yjs' import { YJS_SERVER_URL } from '@/shared/lib/config' +import { createSecureConnection, type StatusEventHandler } from './realtime' export type YjsConnectionOptions = { token?: string | null @@ -10,17 +11,36 @@ export type YjsConnectionOptions = { params?: Record disablePersistence?: boolean persistenceKey?: string + workspaceId?: string +} + +/** Status event handler type (compatible with y-websocket) */ +type ProviderStatusHandler = (event: { status: string }) => void + +/** + * Provider-like interface for compatibility with existing code. + * Wraps SecureConnection to provide WebsocketProvider-compatible API. + */ +export interface ProviderLike { + awareness: Awareness + shouldConnect: boolean + connect(): void + disconnect(): void + on(event: 'status', handler: ProviderStatusHandler): void + off(event: 'status', handler: ProviderStatusHandler): void } export type YjsConnection = { doc: Y.Doc - provider: WebsocketProvider + provider: ProviderLike persistence: IndexeddbPersistence | null } -export async function createYjsConnection(documentId: string, options: YjsConnectionOptions = {}): Promise { +export async function createYjsConnection( + documentId: string, + options: YjsConnectionOptions = {} +): Promise { const { Doc } = await import('yjs') - const { WebsocketProvider } = await import('y-websocket') const doc = new Doc() as unknown as Y.Doc const persistenceKey = options.persistenceKey ?? `refmd:${documentId}` @@ -46,20 +66,8 @@ export async function createYjsConnection(documentId: string, options: YjsConnec persistence = null } } - const params: Record = { ...(options.params ?? {}) } - const token = options.token ?? null - if (token) params.token = token - - const provider = new WebsocketProvider( - YJS_SERVER_URL, - documentId, - doc as any, - { - connect: options.connect ?? true, - params, - }, - ) as WebsocketProvider + // Wait for IndexedDB to sync before creating connection if (persistenceReady) { try { await persistenceReady @@ -68,15 +76,50 @@ export async function createYjsConnection(documentId: string, options: YjsConnec } } + // Create E2EE secure connection + const workspaceId = options.workspaceId ?? '' + if (!workspaceId) { + console.warn('[yjs] No workspaceId provided - E2EE will not work correctly') + } + + const connection = await createSecureConnection(YJS_SERVER_URL, doc, documentId, { + token: options.token, + connect: options.connect ?? true, + workspaceId, + }) + + // Create provider-like wrapper for compatibility + const provider: ProviderLike = { + awareness: connection.awareness, + get shouldConnect() { + return connection.shouldConnect + }, + set shouldConnect(value: boolean) { + connection.shouldConnect = value + }, + connect: () => connection.connect(), + disconnect: () => connection.disconnect(), + on: (event: 'status', handler: ProviderStatusHandler) => { + // Cast handler to StatusEventHandler - our status events are compatible + connection.on(event, handler as StatusEventHandler) + }, + off: (event: 'status', handler: ProviderStatusHandler) => { + connection.off(event, handler as StatusEventHandler) + }, + } + return { doc, provider, persistence } } export function destroyYjsConnection(connection: YjsConnection | null | undefined) { if (!connection) return const { provider, doc, persistence } = connection - try { provider.disconnect() } catch {} - try { provider.destroy() } catch {} - try { (doc as any)?.destroy?.() } catch {} + try { + provider.disconnect() + } catch {} + try { + (doc as any)?.destroy?.() + } catch {} if (persistence) { try { void persistence.destroy() diff --git a/app/src/widgets/dashboard/DashboardPage.tsx b/app/src/widgets/dashboard/DashboardPage.tsx index 09395079..80614309 100644 --- a/app/src/widgets/dashboard/DashboardPage.tsx +++ b/app/src/widgets/dashboard/DashboardPage.tsx @@ -48,7 +48,7 @@ export default function DashboardPage() { const createFirstDocument = async () => { setCreating(true) try { - const doc = await createDoc.mutateAsync({ title: 'Untitled', parent_id: null }) + const doc = await createDoc.mutateAsync({ title: 'Untitled', parentId: null }) navigate({ to: '/document/$id', params: { id: doc.id } }) toast.success('Document created') } finally { diff --git a/app/vite.config.ts b/app/vite.config.ts index 0540fb9e..da027494 100644 --- a/app/vite.config.ts +++ b/app/vite.config.ts @@ -5,6 +5,8 @@ import viteReact from '@vitejs/plugin-react' import viteTsConfigPaths from 'vite-tsconfig-paths' import { defineConfig } from 'vite' import { VitePWA } from 'vite-plugin-pwa' +import wasm from 'vite-plugin-wasm' +import topLevelAwait from 'vite-plugin-top-level-await' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import type { Plugin } from 'vite' @@ -58,6 +60,8 @@ export default defineConfig(() => { }, }, plugins: [ + wasm(), + topLevelAwait(), tanstackStartStorageContextClientStub(), tanstackStart(), nitroV2Plugin({ From 645299d59ae263647198f9f0fbd79368f46a6485 Mon Sep 17 00:00:00 2001 From: munenick Date: Sat, 10 Jan 2026 00:33:14 +0900 Subject: [PATCH 12/45] fix: errors --- .../core/services/storage/ingest/markdown.rs | 28 +++++++++++-------- api/crates/bootstrap/src/telemetry.rs | 15 +++++++--- ...701020002_add_e2ee_columns_to_archives.sql | 6 ++++ 3 files changed, 33 insertions(+), 16 deletions(-) create mode 100644 api/migrations/202701020002_add_e2ee_columns_to_archives.sql diff --git a/api/crates/application/src/core/services/storage/ingest/markdown.rs b/api/crates/application/src/core/services/storage/ingest/markdown.rs index 9920be6a..fe827274 100644 --- a/api/crates/application/src/core/services/storage/ingest/markdown.rs +++ b/api/crates/application/src/core/services/storage/ingest/markdown.rs @@ -4,14 +4,13 @@ use super::*; pub(super) struct MarkdownIngestPayload { pub(super) encrypted_hash: String, pub(super) size: i64, + /// True if file is encrypted (RME1 format), false if legacy plaintext + pub(super) is_encrypted: bool, } -/// Parse encrypted file payload (RME1 format) +/// Parse file payload - supports both RME1 encrypted format and legacy plaintext pub(super) fn parse_markdown_payload(bytes: Vec) -> anyhow::Result { - // Validate RME1 magic number - if bytes.len() < 4 || &bytes[0..4] != RME1_MAGIC { - anyhow::bail!("Invalid RME1 format: missing or invalid magic number"); - } + let is_encrypted = bytes.len() >= 4 && &bytes[0..4] == RME1_MAGIC; let encrypted_hash = sha256_hex(&bytes); let size = bytes.len() as i64; @@ -19,6 +18,7 @@ pub(super) fn parse_markdown_payload(bytes: Vec) -> anyhow::Result Date: Sat, 10 Jan 2026 16:00:43 +0900 Subject: [PATCH 13/45] add: support yjs --- api/crates/domain/src/identity/keys.rs | 10 + .../src/documents/realtime/redis/engine.rs | 15 + .../src/http/identity/keys/types.rs | 4 +- .../presentation/src/ws/documents/yjs.rs | 5 +- app/package-lock.json | 228 ++++------ app/package.json | 5 +- app/src/entities/user/api/index.ts | 106 ++++- app/src/entities/workspace/api.ts | 32 +- app/src/features/auth/lib/guards.ts | 34 +- .../features/e2ee/context/e2ee-context.tsx | 271 ++++++++++++ app/src/features/e2ee/hooks/index.ts | 3 + app/src/features/e2ee/hooks/useKeyManager.ts | 177 ++++++++ .../features/e2ee/hooks/useSecurityStatus.ts | 65 +++ .../features/e2ee/hooks/useServerBackup.ts | 146 ++++++ app/src/features/e2ee/index.ts | 27 ++ app/src/features/e2ee/lib/crypto/bip39.ts | 2 + .../e2ee/lib/crypto/buffer-polyfill.ts | 13 + app/src/features/e2ee/lib/document-keys.ts | 102 +++++ app/src/features/e2ee/lib/keys/index.ts | 1 + app/src/features/e2ee/lib/keys/key-manager.ts | 221 ++++++++++ app/src/features/e2ee/lib/keys/key-store.ts | 81 +++- app/src/features/e2ee/lib/migration.ts | 260 +++++++++++ .../features/e2ee/ui/MigrationProgress.tsx | 195 +++++++++ app/src/features/e2ee/ui/PassphraseInput.tsx | 256 +++++++++++ .../features/e2ee/ui/RecoveryKeyDisplay.tsx | 160 +++++++ .../features/e2ee/ui/RecoveryKeyVerify.tsx | 194 ++++++++ app/src/features/e2ee/ui/RestorePrompt.tsx | 222 ++++++++++ .../features/e2ee/ui/SecuritySetupWizard.tsx | 414 ++++++++++++++++++ app/src/features/e2ee/ui/UnlockPrompt.tsx | 208 +++++++++ app/src/features/e2ee/ui/index.ts | 7 + .../hooks/useCollaborativeDocument.ts | 73 ++- .../edit-document/hooks/useEditorBinding.ts | 7 + .../edit-document/lib/editor/index.ts | 1 + app/src/features/edit-document/ui/Editor.tsx | 3 +- .../edit-document/ui/EditorLayout.tsx | 3 + .../features/edit-document/ui/EditorPane.tsx | 11 +- .../model/useFileTreeInteractions.ts | 11 +- app/src/routeTree.gen.ts | 21 + app/src/routes/(auth)/auth/setup.tsx | 89 ++++ app/src/routes/__root.tsx | 19 +- app/src/shared/lib/realtime/sync.ts | 41 +- app/src/shared/ui/progress.tsx | 28 ++ .../document/DocumentMosaicWorkspace.tsx | 6 +- app/src/widgets/document/DocumentPage.tsx | 18 +- app/src/widgets/header/Header.tsx | 7 +- app/src/widgets/share/ShareFolderPage.tsx | 2 +- app/src/widgets/sidebar/FileTree.tsx | 1 + .../temporary/TemporaryDocumentPage.tsx | 7 +- app/vite.config.ts | 2 + 49 files changed, 3573 insertions(+), 241 deletions(-) create mode 100644 app/src/features/e2ee/context/e2ee-context.tsx create mode 100644 app/src/features/e2ee/hooks/index.ts create mode 100644 app/src/features/e2ee/hooks/useKeyManager.ts create mode 100644 app/src/features/e2ee/hooks/useSecurityStatus.ts create mode 100644 app/src/features/e2ee/hooks/useServerBackup.ts create mode 100644 app/src/features/e2ee/lib/crypto/buffer-polyfill.ts create mode 100644 app/src/features/e2ee/lib/document-keys.ts create mode 100644 app/src/features/e2ee/lib/migration.ts create mode 100644 app/src/features/e2ee/ui/MigrationProgress.tsx create mode 100644 app/src/features/e2ee/ui/PassphraseInput.tsx create mode 100644 app/src/features/e2ee/ui/RecoveryKeyDisplay.tsx create mode 100644 app/src/features/e2ee/ui/RecoveryKeyVerify.tsx create mode 100644 app/src/features/e2ee/ui/RestorePrompt.tsx create mode 100644 app/src/features/e2ee/ui/SecuritySetupWizard.tsx create mode 100644 app/src/features/e2ee/ui/UnlockPrompt.tsx create mode 100644 app/src/features/e2ee/ui/index.ts create mode 100644 app/src/routes/(auth)/auth/setup.tsx create mode 100644 app/src/shared/ui/progress.tsx diff --git a/api/crates/domain/src/identity/keys.rs b/api/crates/domain/src/identity/keys.rs index 4a6b8b63..c151ac7a 100644 --- a/api/crates/domain/src/identity/keys.rs +++ b/api/crates/domain/src/identity/keys.rs @@ -6,6 +6,8 @@ use uuid::Uuid; pub const KDF_TYPE_ARGON2ID: &str = "argon2id"; pub const KDF_TYPE_PBKDF2: &str = "pbkdf2"; pub const KEY_TYPE_ECDH_P256: &str = "ecdh-p256"; +pub const KEY_TYPE_X25519: &str = "x25519"; +pub const KEY_TYPE_ED25519: &str = "ed25519"; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KdfType { @@ -33,12 +35,16 @@ impl KdfType { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KeyType { EcdhP256, + X25519, + Ed25519, } impl KeyType { pub fn parse(s: &str) -> Option { match s { KEY_TYPE_ECDH_P256 => Some(Self::EcdhP256), + KEY_TYPE_X25519 => Some(Self::X25519), + KEY_TYPE_ED25519 => Some(Self::Ed25519), _ => None, } } @@ -46,6 +52,8 @@ impl KeyType { pub fn as_str(self) -> &'static str { match self { Self::EcdhP256 => KEY_TYPE_ECDH_P256, + Self::X25519 => KEY_TYPE_X25519, + Self::Ed25519 => KEY_TYPE_ED25519, } } } @@ -117,6 +125,8 @@ mod tests { #[test] fn key_type_parses() { assert_eq!(KeyType::parse("ecdh-p256"), Some(KeyType::EcdhP256)); + assert_eq!(KeyType::parse("x25519"), Some(KeyType::X25519)); + assert_eq!(KeyType::parse("ed25519"), Some(KeyType::Ed25519)); assert_eq!(KeyType::parse("unknown"), None); } } diff --git a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs index 87d3eae6..7e81e7c0 100644 --- a/api/crates/infrastructure/src/documents/realtime/redis/engine.rs +++ b/api/crates/infrastructure/src/documents/realtime/redis/engine.rs @@ -321,11 +321,21 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { }; // Send pending encrypted updates since last snapshot + tracing::info!( + document_id = %doc_uuid, + snapshot_seq = snapshot_seq, + "redis_e2ee_loading_updates_since" + ); if let Ok(updates) = self .persistence .get_updates_since(&doc_uuid, snapshot_seq) .await { + tracing::info!( + document_id = %doc_uuid, + update_count = updates.len(), + "redis_e2ee_sending_sync_updates" + ); for update in updates { let update_msg = serde_json::json!({ "type": "sync_update", @@ -343,6 +353,11 @@ impl RealtimeEngineTrait for RedisRealtimeEngine { tracing::debug!(error = %e, "redis_e2ee_sync_update_send_failed"); break; } + tracing::debug!( + document_id = %doc_uuid, + seq = update.seq, + "redis_e2ee_sync_update_sent" + ); drop(guard); } } diff --git a/api/crates/presentation/src/http/identity/keys/types.rs b/api/crates/presentation/src/http/identity/keys/types.rs index 002b20c5..b5d661dd 100644 --- a/api/crates/presentation/src/http/identity/keys/types.rs +++ b/api/crates/presentation/src/http/identity/keys/types.rs @@ -38,8 +38,8 @@ pub struct RegisterPublicKeyRequest { /// Base64 encoded public key #[schema(value_type = String, format = "byte")] pub public_key: String, - /// Key type (e.g., "ecdh-p256") - #[schema(example = "ecdh-p256")] + /// Key type (x25519, ed25519, or ecdh-p256) + #[schema(example = "x25519")] pub key_type: String, } diff --git a/api/crates/presentation/src/ws/documents/yjs.rs b/api/crates/presentation/src/ws/documents/yjs.rs index a4913468..7a1d9faa 100644 --- a/api/crates/presentation/src/ws/documents/yjs.rs +++ b/api/crates/presentation/src/ws/documents/yjs.rs @@ -156,7 +156,10 @@ impl Stream for WsBinaryStream { std::task::Poll::Ready(Some(Ok(AxumMessage::Binary(b)))) => { return std::task::Poll::Ready(Some(Ok(b))); } - std::task::Poll::Ready(Some(Ok(AxumMessage::Text(_)))) => continue, + // E2EE messages are sent as JSON text - convert to bytes + std::task::Poll::Ready(Some(Ok(AxumMessage::Text(t)))) => { + return std::task::Poll::Ready(Some(Ok(t.into_bytes()))); + } std::task::Poll::Ready(Some(Ok(AxumMessage::Ping(_)))) => continue, std::task::Poll::Ready(Some(Ok(AxumMessage::Pong(_)))) => continue, std::task::Poll::Ready(Some(Ok(AxumMessage::Close(_)))) => { diff --git a/app/package-lock.json b/app/package-lock.json index fbad2822..09163df2 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -41,7 +41,9 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", + "@types/zxcvbn": "^4.4.5", "bip39": "^3.1.0", + "buffer": "^6.0.3", "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -65,7 +67,8 @@ "y-codemirror.next": "^0.3.5", "y-indexeddb": "^9.0.12", "y-websocket": "^1.5.4", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "zxcvbn": "^4.4.2" }, "devDependencies": { "@hey-api/openapi-ts": "^0.86.10", @@ -7247,6 +7250,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/zxcvbn": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/zxcvbn/-/zxcvbn-4.4.5.tgz", + "integrity": "sha512-FZJgC5Bxuqg7Rhsm/bx6gAruHHhDQ55r+s0JhDh8CQ16fD7NsJJ+p8YMMQDhSQoIrSmjpqqYWA96oQVMNkjRyA==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", @@ -8027,6 +8036,31 @@ "node": ">=6" } }, + "node_modules/abstract-leveldown/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -8194,31 +8228,6 @@ "node": ">= 14" } }, - "node_modules/archiver-utils/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver-utils/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -8236,31 +8245,6 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, - "node_modules/archiver/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/archiver/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -8594,7 +8578,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "devOptional": true, "funding": [ { "type": "github", @@ -8736,9 +8719,9 @@ } }, "node_modules/buffer": { - "version": "5.7.1", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", - "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", "funding": [ { "type": "github", @@ -8754,10 +8737,9 @@ } ], "license": "MIT", - "optional": true, "dependencies": { "base64-js": "^1.3.1", - "ieee754": "^1.1.13" + "ieee754": "^1.2.1" } }, "node_modules/buffer-crc32": { @@ -9308,31 +9290,6 @@ "node": ">= 14" } }, - "node_modules/compress-commons/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/compress-commons/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -9444,31 +9401,6 @@ "node": ">= 14" } }, - "node_modules/crc32-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/crc32-stream/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -11919,7 +11851,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "devOptional": true, "funding": [ { "type": "github", @@ -12951,6 +12882,31 @@ "node": ">=6" } }, + "node_modules/level-codec/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/level-concat-iterator": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/level-concat-iterator/-/level-concat-iterator-2.0.1.tgz", @@ -13005,6 +12961,31 @@ "ltgt": "^2.1.2" } }, + "node_modules/level-js/node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, "node_modules/level-packager": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/level-packager/-/level-packager-5.1.1.tgz", @@ -19472,31 +19453,6 @@ "node": ">= 14" } }, - "node_modules/zip-stream/node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/zip-stream/node_modules/readable-stream": { "version": "4.7.0", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", @@ -19522,6 +19478,12 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zxcvbn": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/zxcvbn/-/zxcvbn-4.4.2.tgz", + "integrity": "sha512-Bq0B+ixT/DMyG8kgX2xWcI5jUvCwqrMxSFam7m0lAf78nf04hv6lNCsyLYdyYTrCVMqNDY/206K7eExYCeSyUQ==", + "license": "MIT" } } } diff --git a/app/package.json b/app/package.json index f982e8f6..d57d7a3d 100644 --- a/app/package.json +++ b/app/package.json @@ -50,7 +50,9 @@ "@tanstack/react-start": "^1.132.0", "@tanstack/react-virtual": "^3.10.8", "@tanstack/router-plugin": "^1.132.0", + "@types/zxcvbn": "^4.4.5", "bip39": "^3.1.0", + "buffer": "^6.0.3", "canonicalize": "^2.1.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -74,7 +76,8 @@ "y-codemirror.next": "^0.3.5", "y-indexeddb": "^9.0.12", "y-websocket": "^1.5.4", - "yjs": "^13.6.27" + "yjs": "^13.6.27", + "zxcvbn": "^4.4.2" }, "overrides": { "react-dnd-multi-backend": "^9.0.0", diff --git a/app/src/entities/user/api/index.ts b/app/src/entities/user/api/index.ts index f0109259..9ce2d8ea 100644 --- a/app/src/entities/user/api/index.ts +++ b/app/src/entities/user/api/index.ts @@ -13,8 +13,32 @@ import { oauthState as apiOauthState, refreshSession as apiRefreshSession, listOauthProviders as apiListOauthProviders, + // Security / E2EE APIs + getE2EeStatus as apiGetSecurityStatus, + needsMigration as apiNeedsMigration, + migrateToE2Ee as apiMigrateUserData, + markE2EeSetupComplete as apiMarkSecuritySetupComplete, + getMyPublicKey as apiGetMyPublicKey, + registerPublicKey as apiRegisterPublicKey, + getMasterKeyBackup as apiGetMasterKeyBackup, + storeMasterKeyBackup as apiStoreMasterKeyBackup, + getEncryptedPrivateKey as apiGetEncryptedPrivateKey, + storeEncryptedPrivateKey as apiStoreEncryptedPrivateKey, +} from '@/shared/api' +import type { + SessionResponse, + AuthProvidersResponse, + E2eeStatusResponse, + NeedsMigrationResponse, + MigrationResponse, + UserPublicKeyResponse, + MasterKeyBackupResponse, + EncryptedPrivateKeyResponse, + MigrateRequest, + RegisterPublicKeyRequest, + StoreMasterKeyBackupRequest, + StoreEncryptedPrivateKeyRequest, } from '@/shared/api' -import type { SessionResponse, AuthProvidersResponse } from '@/shared/api' export const userKeys = { me: () => ['me'] as const, @@ -126,3 +150,83 @@ export function useRevokeSession(options?: { }, }) } + +// ============================================ +// Security / E2EE +// ============================================ + +export const securityKeys = { + status: () => ['security', 'status'] as const, + needsMigration: () => ['security', 'needs-migration'] as const, + publicKey: () => ['security', 'public-key'] as const, + masterKeyBackup: () => ['security', 'master-key-backup'] as const, + encryptedPrivateKey: () => ['security', 'encrypted-private-key'] as const, +} + +// API wrapper functions +export async function getSecurityStatus(): Promise { + return apiGetSecurityStatus() +} + +export async function checkNeedsMigration(): Promise { + return apiNeedsMigration() +} + +export async function migrateUserData(request: MigrateRequest): Promise { + return apiMigrateUserData({ requestBody: request }) +} + +export async function markSecuritySetupComplete(): Promise { + await apiMarkSecuritySetupComplete() +} + +export async function getMyPublicKey(): Promise { + return apiGetMyPublicKey() +} + +export async function registerPublicKey(request: RegisterPublicKeyRequest): Promise { + return apiRegisterPublicKey({ requestBody: request }) +} + +export async function getMasterKeyBackup(): Promise { + return apiGetMasterKeyBackup() +} + +export async function storeMasterKeyBackup(request: StoreMasterKeyBackupRequest): Promise { + return apiStoreMasterKeyBackup({ requestBody: request }) +} + +export async function getEncryptedPrivateKey(): Promise { + return apiGetEncryptedPrivateKey() +} + +export async function storeEncryptedPrivateKey(request: StoreEncryptedPrivateKeyRequest): Promise { + return apiStoreEncryptedPrivateKey({ requestBody: request }) +} + +// Query definitions +export const securityStatusQuery = () => ({ + queryKey: securityKeys.status(), + queryFn: () => getSecurityStatus(), + staleTime: 30_000, +}) + +export const needsMigrationQuery = () => ({ + queryKey: securityKeys.needsMigration(), + queryFn: () => checkNeedsMigration(), + staleTime: 30_000, +}) + +// Re-export types +export type { + E2eeStatusResponse as SecurityStatusResponse, + NeedsMigrationResponse, + MigrationResponse, + UserPublicKeyResponse, + MasterKeyBackupResponse, + EncryptedPrivateKeyResponse, + MigrateRequest, + RegisterPublicKeyRequest, + StoreMasterKeyBackupRequest, + StoreEncryptedPrivateKeyRequest, +} diff --git a/app/src/entities/workspace/api.ts b/app/src/entities/workspace/api.ts index d6366112..e16c51a7 100644 --- a/app/src/entities/workspace/api.ts +++ b/app/src/entities/workspace/api.ts @@ -1,10 +1,12 @@ import { - OpenAPI, acceptInvitation as apiAcceptWorkspaceInvitation, createInvitation as apiCreateWorkspaceInvitation, createRole as apiCreateWorkspaceRole, createWorkspace as apiCreateWorkspace, deleteRole as apiDeleteWorkspaceRole, + deleteWorkspace as apiDeleteWorkspace, + getWorkspaceDetail as apiGetWorkspaceDetail, + leaveWorkspace as apiLeaveWorkspace, listInvitations as apiListWorkspaceInvitations, listMembers as apiListWorkspaceMembers, listRoles as apiListWorkspaceRoles, @@ -12,6 +14,7 @@ import { revokeInvitation as apiRevokeWorkspaceInvitation, updateMemberRole as apiUpdateWorkspaceMemberRole, updateRole as apiUpdateWorkspaceRole, + updateWorkspace as apiUpdateWorkspace, } from '@/shared/api' import type { PermissionOverridePayload as ApiPermissionOverridePayload, @@ -20,7 +23,6 @@ import type { WorkspaceResponse, WorkspaceRoleResponse as ApiWorkspaceRoleResponse, } from '@/shared/api' -import { request as __request } from '@/shared/api/client/core/request' export const workspaceKeys = { members: (workspaceId?: string | null) => ['workspace-members', workspaceId] as const, @@ -46,37 +48,19 @@ export type WorkspaceMemberResponse = ApiWorkspaceMemberResponse export type WorkspaceRoleResponse = ApiWorkspaceRoleResponse export function getWorkspace(id: string): Promise { - return __request(OpenAPI, { - method: 'GET', - url: '/api/workspaces/{id}', - path: { id }, - }) as Promise + return apiGetWorkspaceDetail({ id }) as Promise } export function updateWorkspace(id: string, body: UpdateWorkspacePayload): Promise { - return __request(OpenAPI, { - method: 'PUT', - url: '/api/workspaces/{id}', - path: { id }, - body, - mediaType: 'application/json', - }) as Promise + return apiUpdateWorkspace({ id, requestBody: body }) as Promise } export function deleteWorkspace(id: string): Promise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/api/workspaces/{id}', - path: { id }, - }) as Promise + return apiDeleteWorkspace({ id }) as Promise } export function leaveWorkspace(id: string): Promise { - return __request(OpenAPI, { - method: 'POST', - url: '/api/workspaces/{id}/leave', - path: { id }, - }) as Promise + return apiLeaveWorkspace({ id }) as Promise } export function createWorkspace(body: CreateWorkspacePayload) { diff --git a/app/src/features/auth/lib/guards.ts b/app/src/features/auth/lib/guards.ts index 86cc69d4..d32deabc 100644 --- a/app/src/features/auth/lib/guards.ts +++ b/app/src/features/auth/lib/guards.ts @@ -4,7 +4,7 @@ import { redirect } from '@tanstack/react-router' import type { UserResponse } from '@/shared/api' import { validateShareToken } from '@/entities/share' -import { me as fetchCurrentUser, userKeys } from '@/entities/user' +import { me as fetchCurrentUser, userKeys, getSecurityStatus, securityKeys } from '@/entities/user' import { getRuntimeAuthContext } from './runtime-context' import type { AuthMiddlewareContext, AuthRedirectTarget, AuthResolution } from './types' @@ -135,6 +135,26 @@ function isAuthRoute(pathname: string) { return pathname.startsWith('/auth/') } +async function needsSecuritySetup(ctx?: any): Promise { + // Check cached status first + const queryClient = ctx?.context?.queryClient as QueryClient | undefined + if (queryClient) { + const cachedStatus = queryClient.getQueryData(securityKeys.status()) + if (cachedStatus && typeof cachedStatus === 'object' && 'isSetupCompleted' in cachedStatus) { + return !(cachedStatus as { isSetupCompleted: boolean }).isSetupCompleted + } + } + + // Fetch status from API + try { + const status = await getSecurityStatus() + return !status.isSetupCompleted + } catch { + // If we can't determine, assume setup is not needed to avoid blocking + return false + } +} + function getCachedUser(ctx?: any): UserResponse | null { const queryClient = ctx?.context?.queryClient as QueryClient | undefined if (!queryClient) return null @@ -217,10 +237,20 @@ export async function resolveAuthRedirect(ctx?: any): Promise { } export async function appBeforeLoadGuard(ctx?: any) { - const { redirect: target } = await resolveAuthRedirect(ctx) + const { pathname } = resolveLocation(ctx) + const { redirect: target, authenticated } = await resolveAuthRedirect(ctx) if (target) { throw redirect(target) } + + // Check if E2EE setup is needed for authenticated users + // Skip for auth routes (/auth/*) to prevent redirect loops + if (authenticated && !isAuthRoute(pathname)) { + const setupNeeded = await needsSecuritySetup(ctx) + if (setupNeeded) { + throw redirect({ to: '/auth/setup' as '/dashboard' }) + } + } } export const requireAuthGuard = appBeforeLoadGuard diff --git a/app/src/features/e2ee/context/e2ee-context.tsx b/app/src/features/e2ee/context/e2ee-context.tsx new file mode 100644 index 00000000..f077d438 --- /dev/null +++ b/app/src/features/e2ee/context/e2ee-context.tsx @@ -0,0 +1,271 @@ +import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' + +import { useAuthContext } from '@/features/auth' + +import { getKeyManager, type E2EESetupResult } from '../lib/keys' +import { useSecurityStatus } from '../hooks/useSecurityStatus' +import { useServerBackup, type ServerBackup } from '../hooks/useServerBackup' + +export interface E2EEState { + /** Whether KeyManager is initialized */ + isInitialized: boolean + /** Whether E2EE setup has been completed on the server */ + isSetupComplete: boolean + /** Whether the session is unlocked (keys are in memory) */ + isUnlocked: boolean + /** Whether local keys exist in IndexedDB (null = not yet checked) */ + hasLocalKeys: boolean | null + /** Whether data is being loaded */ + loading: boolean + /** Current error message */ + error: string | null + /** Whether the user needs to complete E2EE setup */ + needsSetup: boolean + /** Whether the user needs to migrate existing data */ + needsMigration: boolean + /** Whether keys need to be restored from server (new device) */ + needsRestore: boolean + /** Server backup data (if available) */ + serverBackup: ServerBackup | null + /** Unlock the session with a passphrase */ + unlock: (passphrase: string) => Promise + /** Unlock the session with a recovery key */ + unlockWithRecovery: (mnemonic: string) => Promise + /** Restore keys from server with passphrase */ + restoreFromServer: (passphrase: string) => Promise + /** Restore keys from server with recovery key */ + restoreFromServerWithRecoveryKey: (recoveryKey: string) => Promise + /** Lock the session */ + lock: () => void + /** Set up E2EE for a new user */ + setupE2EE: (passphrase: string) => Promise + /** Clear error state */ + clearError: () => void +} + +const E2EEContext = createContext(null) + +export function E2EEProvider({ children }: { children: React.ReactNode }) { + const { user, loading: authLoading } = useAuthContext() + const { data: securityStatus, isLoading: statusLoading } = useSecurityStatus() + const { data: serverBackup, isLoading: backupLoading } = useServerBackup({ enabled: !!user }) + + const [isInitialized, setIsInitialized] = useState(false) + const [isUnlocked, setIsUnlocked] = useState(false) + const [hasLocalKeys, setHasLocalKeys] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + + // Initialize KeyManager when user is authenticated + useEffect(() => { + if (!user) { + setIsInitialized(false) + setIsUnlocked(false) + setHasLocalKeys(null) + return + } + + const km = getKeyManager() + km.initialize().then(async () => { + setIsInitialized(true) + setIsUnlocked(km.isUnlocked) + // Check if local keys exist + const hasKeys = await km.hasKeys() + setHasLocalKeys(hasKeys) + }) + }, [user]) + + // Lock when user logs out (not during auth loading) + useEffect(() => { + // Only lock if auth is done loading AND user is definitely logged out + if (!authLoading && !user && isUnlocked) { + const km = getKeyManager() + km.lock() + setIsUnlocked(false) + } + }, [authLoading, user, isUnlocked]) + + // Update unlock state periodically (in case of external changes) + useEffect(() => { + if (!isInitialized) return + + const checkUnlockState = () => { + const km = getKeyManager() + setIsUnlocked(km.isUnlocked) + } + + const interval = setInterval(checkUnlockState, 1000) + return () => clearInterval(interval) + }, [isInitialized]) + + const unlock = useCallback(async (passphrase: string) => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.unlockWithPassphrase(passphrase) + setIsUnlocked(true) + } catch (err) { + const message = err instanceof Error ? err.message : 'Failed to unlock' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + const unlockWithRecovery = useCallback(async (mnemonic: string) => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.unlockWithRecoveryKey(mnemonic) + setIsUnlocked(true) + } catch (err) { + const message = err instanceof Error ? err.message : 'Recovery failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + const restoreFromServer = useCallback(async (passphrase: string) => { + if (!serverBackup?.hasBackup || !serverBackup.encryptedKeysBundle || !serverBackup.salt || !serverBackup.kdfType) { + throw new Error('No server backup available') + } + + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.restoreFromServer(passphrase, { + encryptedKeysBundle: serverBackup.encryptedKeysBundle, + salt: serverBackup.salt, + kdfType: serverBackup.kdfType, + kdfParams: serverBackup.kdfParams ?? {}, + }) + setIsUnlocked(true) + setHasLocalKeys(true) + } catch (err) { + const message = err instanceof Error ? err.message : 'Restore failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, [serverBackup]) + + const restoreFromServerWithRecoveryKey = useCallback(async (recoveryKey: string) => { + if (!serverBackup?.hasBackup || !serverBackup.encryptedKeysBundle || !serverBackup.salt || !serverBackup.kdfType) { + throw new Error('No server backup available') + } + + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.restoreFromServerWithRecoveryKey(recoveryKey, { + encryptedKeysBundle: serverBackup.encryptedKeysBundle, + salt: serverBackup.salt, + kdfType: serverBackup.kdfType, + kdfParams: serverBackup.kdfParams ?? {}, + }) + setIsUnlocked(true) + setHasLocalKeys(true) + } catch (err) { + const message = err instanceof Error ? err.message : 'Restore failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, [serverBackup]) + + const lock = useCallback(() => { + const km = getKeyManager() + km.lock() + setIsUnlocked(false) + }, []) + + const setupE2EE = useCallback(async (passphrase: string): Promise => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + const result = await km.setupE2EE(passphrase) + setIsUnlocked(true) + setHasLocalKeys(true) // Mark that local keys now exist + return result + } catch (err) { + const message = err instanceof Error ? err.message : 'Setup failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + const clearError = useCallback(() => { + setError(null) + }, []) + + // Determine if restore from server is needed + // needsRestore = server has E2EE setup + server has backup + local has no keys + const needsRestore = !!( + securityStatus?.isSetupComplete && + serverBackup?.hasBackup && + hasLocalKeys === false + ) + + const value = useMemo( + () => ({ + isInitialized, + isSetupComplete: securityStatus?.isSetupComplete ?? false, + isUnlocked, + hasLocalKeys, + loading: loading || statusLoading || backupLoading, + error, + needsSetup: securityStatus ? !securityStatus.isSetupComplete : false, + needsMigration: securityStatus?.needsMigration ?? false, + needsRestore, + serverBackup: serverBackup ?? null, + unlock, + unlockWithRecovery, + restoreFromServer, + restoreFromServerWithRecoveryKey, + lock, + setupE2EE, + clearError, + }), + [ + isInitialized, + securityStatus, + isUnlocked, + hasLocalKeys, + loading, + statusLoading, + backupLoading, + error, + needsRestore, + serverBackup, + unlock, + unlockWithRecovery, + restoreFromServer, + restoreFromServerWithRecoveryKey, + lock, + setupE2EE, + clearError, + ] + ) + + return {children} +} + +export function useE2EE(): E2EEState { + const context = useContext(E2EEContext) + if (!context) { + throw new Error('useE2EE must be used within E2EEProvider') + } + return context +} diff --git a/app/src/features/e2ee/hooks/index.ts b/app/src/features/e2ee/hooks/index.ts new file mode 100644 index 00000000..5f37fb10 --- /dev/null +++ b/app/src/features/e2ee/hooks/index.ts @@ -0,0 +1,3 @@ +export { useSecurityStatus, useNeedsSecuritySetup } from './useSecurityStatus' +export { useKeyManager } from './useKeyManager' +export { useServerBackup, type ServerBackup } from './useServerBackup' diff --git a/app/src/features/e2ee/hooks/useKeyManager.ts b/app/src/features/e2ee/hooks/useKeyManager.ts new file mode 100644 index 00000000..6a9cf4e3 --- /dev/null +++ b/app/src/features/e2ee/hooks/useKeyManager.ts @@ -0,0 +1,177 @@ +import { useCallback, useEffect, useState, useSyncExternalStore } from 'react' + +import { getKeyManager, type E2EESetupResult } from '@/features/e2ee' + +// External store for KeyManager state +let listeners: Set<() => void> = new Set() +let snapshot = { + isInitialized: false, + isUnlocked: false, +} + +function subscribe(listener: () => void): () => void { + listeners.add(listener) + return () => listeners.delete(listener) +} + +function getSnapshot() { + return snapshot +} + +function updateSnapshot() { + const km = getKeyManager() + const newSnapshot = { + isInitialized: km.isInitialized, + isUnlocked: km.isUnlocked, + } + if ( + newSnapshot.isInitialized !== snapshot.isInitialized || + newSnapshot.isUnlocked !== snapshot.isUnlocked + ) { + snapshot = newSnapshot + listeners.forEach((listener) => listener()) + } +} + +/** + * Hook to interact with the KeyManager. + * Provides methods for E2EE setup, unlock, and lock operations. + */ +export function useKeyManager() { + const { isInitialized, isUnlocked } = useSyncExternalStore(subscribe, getSnapshot, getSnapshot) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(false) + + // Initialize KeyManager on mount + useEffect(() => { + const km = getKeyManager() + if (!km.isInitialized) { + km.initialize().then(() => { + updateSnapshot() + }) + } + }, []) + + /** + * Set up E2EE with a new passphrase. + * @returns Setup result including recovery key + */ + const setupE2EE = useCallback(async (passphrase: string): Promise => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + const result = await km.setupE2EE(passphrase) + updateSnapshot() + return result + } catch (err) { + const message = err instanceof Error ? err.message : 'Setup failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + /** + * Unlock the session with a passphrase. + */ + const unlock = useCallback(async (passphrase: string): Promise => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.unlockWithPassphrase(passphrase) + updateSnapshot() + } catch (err) { + const message = err instanceof Error ? err.message : 'Unlock failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + /** + * Unlock the session with a recovery key. + */ + const unlockWithRecoveryKey = useCallback(async (mnemonic: string): Promise => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + await km.unlockWithRecoveryKey(mnemonic) + updateSnapshot() + } catch (err) { + const message = err instanceof Error ? err.message : 'Recovery failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + /** + * Lock the session. + */ + const lock = useCallback(() => { + const km = getKeyManager() + km.lock() + updateSnapshot() + }, []) + + /** + * Change the passphrase. + * @returns New recovery key + */ + const changePassphrase = useCallback(async (newPassphrase: string): Promise => { + setLoading(true) + setError(null) + try { + const km = getKeyManager() + const recoveryKey = await km.changePassphrase(newPassphrase) + return recoveryKey + } catch (err) { + const message = err instanceof Error ? err.message : 'Change failed' + setError(message) + throw err + } finally { + setLoading(false) + } + }, []) + + /** + * Verify if a passphrase is correct. + */ + const verifyPassphrase = useCallback(async (passphrase: string): Promise => { + try { + const km = getKeyManager() + return await km.verifyPassphrase(passphrase) + } catch { + return false + } + }, []) + + /** + * Check if the user has stored keys. + */ + const hasKeys = useCallback(async (): Promise => { + const km = getKeyManager() + return km.hasKeys() + }, []) + + return { + isInitialized, + isUnlocked, + loading, + error, + setupE2EE, + unlock, + unlockWithRecoveryKey, + lock, + changePassphrase, + verifyPassphrase, + hasKeys, + clearError: () => setError(null), + } +} diff --git a/app/src/features/e2ee/hooks/useSecurityStatus.ts b/app/src/features/e2ee/hooks/useSecurityStatus.ts new file mode 100644 index 00000000..a872a125 --- /dev/null +++ b/app/src/features/e2ee/hooks/useSecurityStatus.ts @@ -0,0 +1,65 @@ +import { useQuery } from '@tanstack/react-query' + +import { securityStatusQuery, needsMigrationQuery } from '@/entities/user' + +export interface SecurityStatus { + /** Whether E2EE setup has been completed */ + isSetupComplete: boolean + /** Whether data migration is needed (existing user) */ + needsMigration: boolean +} + +interface UseSecurityStatusResult { + data: SecurityStatus | undefined + isLoading: boolean + error: Error | null + refetch: () => void +} + +/** + * Hook to fetch and combine security status information. + * Combines E2EE status and migration status into a single interface. + */ +export function useSecurityStatus(): UseSecurityStatusResult { + const statusQuery = useQuery(securityStatusQuery()) + const migrationQuery = useQuery(needsMigrationQuery()) + + const isLoading = statusQuery.isLoading || migrationQuery.isLoading + const error = statusQuery.error ?? migrationQuery.error + + const data: SecurityStatus | undefined = + statusQuery.data && migrationQuery.data + ? { + isSetupComplete: statusQuery.data.isSetupCompleted, + needsMigration: migrationQuery.data.needsMigration, + } + : undefined + + const refetch = () => { + statusQuery.refetch() + migrationQuery.refetch() + } + + return { + data, + isLoading, + error: error as Error | null, + refetch, + } +} + +/** + * Hook to check if security setup is required. + * Returns true if setup is not complete. + */ +export function useNeedsSecuritySetup(): { + needsSetup: boolean | undefined + isLoading: boolean +} { + const { data, isLoading } = useSecurityStatus() + + return { + needsSetup: data ? !data.isSetupComplete : undefined, + isLoading, + } +} diff --git a/app/src/features/e2ee/hooks/useServerBackup.ts b/app/src/features/e2ee/hooks/useServerBackup.ts new file mode 100644 index 00000000..5d4c20cb --- /dev/null +++ b/app/src/features/e2ee/hooks/useServerBackup.ts @@ -0,0 +1,146 @@ +import { useQuery } from '@tanstack/react-query' + +import { + getMasterKeyBackup, + getEncryptedPrivateKey, + type MasterKeyBackupResponse, + type EncryptedPrivateKeyResponse, +} from '@/entities/user' +import type { EncryptedKeysBundle } from '../lib/keys' + +export interface ServerBackup { + /** Whether server has backup data */ + hasBackup: boolean + /** Encrypted keys bundle (if available) */ + encryptedKeysBundle: EncryptedKeysBundle | null + /** Salt for KDF (base64) */ + salt: string | null + /** KDF type */ + kdfType: 'argon2id' | 'pbkdf2' | null + /** KDF parameters */ + kdfParams: { + memory?: number | null + iterations?: number | null + parallelism?: number | null + } | null +} + +/** + * Parse encrypted keys bundle from server response. + */ +function parseKeysBundle(response: EncryptedPrivateKeyResponse): EncryptedKeysBundle | null { + try { + // Check if it's our bundle format (nonce is base64 encoded 'bundle-v1') + const expectedNonce = btoa('bundle-v1') + if (response.nonce !== expectedNonce) { + console.warn('[useServerBackup] Unknown nonce format:', response.nonce) + return null + } + + // Decode base64 and parse JSON + const jsonStr = atob(response.encryptedPrivateKey) + const bundle = JSON.parse(jsonStr) as EncryptedKeysBundle + + // Validate required fields + if ( + !bundle.encryptedEcdhPrivateKey || + !bundle.encryptedEcdhPrivateKeyNonce || + !bundle.encryptedSigningPrivateKey || + !bundle.encryptedSigningPrivateKeyNonce || + !bundle.ecdhPublicKey || + !bundle.signingPublicKey + ) { + console.warn('[useServerBackup] Invalid bundle format') + return null + } + + return bundle + } catch (err) { + console.error('[useServerBackup] Failed to parse keys bundle:', err) + return null + } +} + +/** + * Hook to fetch server backup data for key restoration. + */ +export function useServerBackup(options?: { enabled?: boolean }) { + const masterKeyQuery = useQuery({ + queryKey: ['security', 'master-key-backup'], + queryFn: async () => { + try { + return await getMasterKeyBackup() + } catch (err) { + // 404 means no backup exists + if ((err as { status?: number }).status === 404) { + return null + } + throw err + } + }, + enabled: options?.enabled ?? true, + staleTime: 30_000, + retry: false, + }) + + const encryptedKeyQuery = useQuery({ + queryKey: ['security', 'encrypted-private-key'], + queryFn: async () => { + try { + return await getEncryptedPrivateKey() + } catch (err) { + // 404 means no backup exists + if ((err as { status?: number }).status === 404) { + return null + } + throw err + } + }, + enabled: options?.enabled ?? true, + staleTime: 30_000, + retry: false, + }) + + const isLoading = masterKeyQuery.isLoading || encryptedKeyQuery.isLoading + const error = masterKeyQuery.error ?? encryptedKeyQuery.error + + let data: ServerBackup | undefined + + if (!isLoading && masterKeyQuery.data !== undefined && encryptedKeyQuery.data !== undefined) { + const masterKey = masterKeyQuery.data as MasterKeyBackupResponse | null + const encryptedKey = encryptedKeyQuery.data as EncryptedPrivateKeyResponse | null + + if (masterKey && encryptedKey) { + const bundle = parseKeysBundle(encryptedKey) + data = { + hasBackup: bundle !== null, + encryptedKeysBundle: bundle, + salt: masterKey.salt, + kdfType: (masterKey.kdfType === 'argon2id' || masterKey.kdfType === 'pbkdf2') + ? masterKey.kdfType + : null, + kdfParams: masterKey.kdfParams, + } + } else { + data = { + hasBackup: false, + encryptedKeysBundle: null, + salt: null, + kdfType: null, + kdfParams: null, + } + } + } + + const refetch = () => { + masterKeyQuery.refetch() + encryptedKeyQuery.refetch() + } + + return { + data, + isLoading, + error: error as Error | null, + refetch, + } +} diff --git a/app/src/features/e2ee/index.ts b/app/src/features/e2ee/index.ts index 8c8894bc..5fcb560b 100644 --- a/app/src/features/e2ee/index.ts +++ b/app/src/features/e2ee/index.ts @@ -103,6 +103,7 @@ export { SessionLockedError, KeyNotFoundError, type E2EESetupResult, + type EncryptedKeysBundle, // KeyStore KeyStore, getKeyStore, @@ -174,3 +175,29 @@ export { URL_FRAGMENT_PREFIX, type EncryptedShareKeyForApi, } from './lib/keys' + +// Hooks +export { useSecurityStatus, useNeedsSecuritySetup } from './hooks/useSecurityStatus' +export { useKeyManager } from './hooks/useKeyManager' +export { useServerBackup, type ServerBackup } from './hooks/useServerBackup' + +// Context +export { E2EEProvider, useE2EE, type E2EEState } from './context/e2ee-context' + +// UI Components +export { + PassphraseInput, + RecoveryKeyDisplay, + RecoveryKeyVerify, + MigrationProgress, + SecuritySetupWizard, + UnlockPrompt, + RestorePrompt, +} from './ui' + +// Document key helpers +export { + createDocumentDek, + createDocumentDekIfNeeded, + isE2EEReady, +} from './lib/document-keys' diff --git a/app/src/features/e2ee/lib/crypto/bip39.ts b/app/src/features/e2ee/lib/crypto/bip39.ts index 78ea837d..ee71b449 100644 --- a/app/src/features/e2ee/lib/crypto/bip39.ts +++ b/app/src/features/e2ee/lib/crypto/bip39.ts @@ -5,6 +5,8 @@ * The mnemonic encodes 256 bits of entropy (the UMK). */ +// Import Buffer polyfill first (side-effect import must be before bip39) +import './buffer-polyfill' import * as bip39Lib from 'bip39' /** Number of words in the recovery key */ diff --git a/app/src/features/e2ee/lib/crypto/buffer-polyfill.ts b/app/src/features/e2ee/lib/crypto/buffer-polyfill.ts new file mode 100644 index 00000000..2f495e0a --- /dev/null +++ b/app/src/features/e2ee/lib/crypto/buffer-polyfill.ts @@ -0,0 +1,13 @@ +/** + * Buffer polyfill for browser environment + * This must be imported before any modules that use Buffer (like bip39) + */ + +import { Buffer } from 'buffer' + +// Make Buffer available globally for libraries that expect it +if (typeof globalThis !== 'undefined' && !globalThis.Buffer) { + globalThis.Buffer = Buffer +} + +export { Buffer } diff --git a/app/src/features/e2ee/lib/document-keys.ts b/app/src/features/e2ee/lib/document-keys.ts new file mode 100644 index 00000000..fd34bfaf --- /dev/null +++ b/app/src/features/e2ee/lib/document-keys.ts @@ -0,0 +1,102 @@ +/** + * E2EE Document Key Management + * + * Helpers for creating documents with E2EE encryption keys. + */ + +import { + getKeyManager, + generateDocumentDek, + createEncryptedDekForApi, +} from './keys' +import { + storeDocumentKey, + getMyWorkspaceKey, +} from '@/shared/api' + +/** + * Generate and store a DEK for a newly created document. + * + * This should be called immediately after creating a document. + * + * @param documentId - The ID of the newly created document + * @param workspaceId - The workspace ID (for fetching KEK) + * @throws Error if E2EE is not unlocked or key operations fail + */ +export async function createDocumentDek( + documentId: string, + workspaceId: string +): Promise { + const km = getKeyManager() + + // Ensure E2EE is unlocked + if (!km.isUnlocked) { + throw new Error('E2EE session is locked. Please unlock first.') + } + + // Get workspace KEK + const kek = await km.getWorkspaceKek(workspaceId, async () => { + const response = await getMyWorkspaceKey({ id: workspaceId }) + return response.encryptedKek + }) + + // Generate DEK for the new document + const dek = await generateDocumentDek() + + // Encrypt DEK with workspace KEK + const { encryptedDek, nonce } = await createEncryptedDekForApi(dek, kek) + + // Store the encrypted DEK + await storeDocumentKey({ + id: documentId, + requestBody: { + encryptedDek, + nonce, + keyVersion: 1, // Initial version for new document + }, + }) + + // Clear DEK from memory + dek.fill(0) +} + +/** + * Check if E2EE is enabled and unlocked. + */ +export function isE2EEReady(): boolean { + const km = getKeyManager() + return km.isInitialized && km.isUnlocked +} + +/** + * Create DEK for a new document if E2EE is enabled. + * + * This is a safe wrapper that does nothing if E2EE is not set up or unlocked. + * Use this in document creation flows. + * + * @param documentId - The ID of the newly created document + * @param workspaceId - The workspace ID (for fetching KEK) + * @returns true if DEK was created, false if E2EE is not enabled + */ +export async function createDocumentDekIfNeeded( + documentId: string, + workspaceId: string | null +): Promise { + if (!workspaceId) { + console.warn('[e2ee] Cannot create DEK: no workspace ID') + return false + } + + if (!isE2EEReady()) { + // E2EE not enabled or not unlocked, skip DEK creation + return false + } + + try { + await createDocumentDek(documentId, workspaceId) + return true + } catch (err) { + console.error('[e2ee] Failed to create document DEK:', err) + throw err + } +} diff --git a/app/src/features/e2ee/lib/keys/index.ts b/app/src/features/e2ee/lib/keys/index.ts index d899be08..a8c62266 100644 --- a/app/src/features/e2ee/lib/keys/index.ts +++ b/app/src/features/e2ee/lib/keys/index.ts @@ -16,6 +16,7 @@ export { SessionLockedError, KeyNotFoundError, type E2EESetupResult, + type EncryptedKeysBundle, } from './key-manager' // Key Store (IndexedDB) diff --git a/app/src/features/e2ee/lib/keys/key-manager.ts b/app/src/features/e2ee/lib/keys/key-manager.ts index 5d77b67a..1166ebe1 100644 --- a/app/src/features/e2ee/lib/keys/key-manager.ts +++ b/app/src/features/e2ee/lib/keys/key-manager.ts @@ -5,6 +5,7 @@ * It coordinates between the KeyStore, KeyCache, and individual key modules. */ +import { toBase64, fromBase64 } from '../crypto' import { KeyStore, getKeyStore, type StoredKeys } from './key-store' import { clearAllCaches } from './key-cache' import { @@ -46,6 +47,22 @@ import { decryptDekWithShareKey, } from './share-key' +/** Encrypted keys data for server storage */ +export interface EncryptedKeysBundle { + /** Encrypted ECDH private key (base64) */ + encryptedEcdhPrivateKey: string + /** Nonce for ECDH key encryption (base64) */ + encryptedEcdhPrivateKeyNonce: string + /** Encrypted signing private key (base64) */ + encryptedSigningPrivateKey: string + /** Nonce for signing key encryption (base64) */ + encryptedSigningPrivateKeyNonce: string + /** ECDH public key (base64) */ + ecdhPublicKey: string + /** Signing public key (base64) */ + signingPublicKey: string +} + /** E2EE Setup result */ export interface E2EESetupResult { /** BIP39 recovery key (24 words) - must be shown to user */ @@ -61,6 +78,8 @@ export interface E2EESetupResult { kdf: 'argon2id' | 'pbkdf2' /** KDF parameters */ kdfParams: { memory?: number; iterations: number; parallelism?: number } + /** Encrypted keys bundle for server storage */ + encryptedKeysBundle: EncryptedKeysBundle } /** Session lock error */ @@ -95,11 +114,41 @@ export class KeyManager { /** * Initialize the KeyManager. * Must be called before any other operations. + * Automatically restores UMK from IndexedDB if available. */ async initialize(): Promise { if (this._isInitialized) return await this.keyStore.initialize() this._isInitialized = true + + // Try to auto-restore UMK from IndexedDB + await this.tryAutoUnlock() + } + + /** + * Try to automatically unlock using stored UMK. + * This is called during initialization. + */ + private async tryAutoUnlock(): Promise { + try { + const storedUmk = await this.keyStore.loadSessionUmk() + if (!storedUmk) return + + const storedKeys = await this.keyStore.loadKeys() + if (!storedKeys) { + // No keys stored, clear the orphaned UMK + await this.keyStore.clearSessionUmk() + return + } + + // Try to decrypt user keys with the stored UMK + const userKeys = await decryptUserKeys(storedKeys, storedUmk) + this.umk = storedUmk + this.userKeys = userKeys + } catch { + // Failed to auto-unlock, clear invalid UMK + await this.keyStore.clearSessionUmk() + } } /** @@ -169,6 +218,9 @@ export class KeyManager { await this.keyStore.saveKeys(storedKeys) + // Persist UMK for session continuity (auto-unlock on page reload) + await this.keyStore.saveSessionUmk(umkResult.umk) + // Keep UMK and keys in memory this.umk = umkResult.umk this.userKeys = userKeys @@ -176,12 +228,23 @@ export class KeyManager { // Get public keys for server registration const publicKeys = await getPublicKeysBase64(userKeys) + // Create encrypted keys bundle for server storage + const encryptedKeysBundle: EncryptedKeysBundle = { + encryptedEcdhPrivateKey: await toBase64(encryptedKeys.encryptedEcdhPrivateKey), + encryptedEcdhPrivateKeyNonce: await toBase64(encryptedKeys.encryptedEcdhPrivateKeyNonce), + encryptedSigningPrivateKey: await toBase64(encryptedKeys.encryptedSigningPrivateKey), + encryptedSigningPrivateKeyNonce: await toBase64(encryptedKeys.encryptedSigningPrivateKeyNonce), + ecdhPublicKey: publicKeys.ecdhPublicKey, + signingPublicKey: publicKeys.signingPublicKey, + } + return { recoveryKey: umkResult.recoveryKey, publicKeys, salt: umkResult.salt, kdf: umkResult.kdf, kdfParams: umkResult.kdfParams, + encryptedKeysBundle, } } @@ -216,6 +279,9 @@ export class KeyManager { const userKeys = await decryptUserKeys(storedKeys, umk) this.umk = umk this.userKeys = userKeys + + // Persist UMK for session continuity (auto-unlock on page reload) + await this.keyStore.saveSessionUmk(umk) } catch { // Zero out UMK on failure umk.fill(0) @@ -245,6 +311,9 @@ export class KeyManager { const userKeys = await decryptUserKeys(storedKeys, umk) this.umk = umk this.userKeys = userKeys + + // Persist UMK for session continuity (auto-unlock on page reload) + await this.keyStore.saveSessionUmk(umk) } catch { // Zero out UMK on failure umk.fill(0) @@ -284,6 +353,158 @@ export class KeyManager { return verifyPassphrase(passphrase, storedKeys) } + // ============================================ + // Server Restore + // ============================================ + + /** + * Restore keys from server backup. + * Used when logging in on a new device. + * + * @param passphrase - User's passphrase + * @param serverBackup - Backup data from server + */ + async restoreFromServer( + passphrase: string, + serverBackup: { + encryptedKeysBundle: EncryptedKeysBundle + salt: string + kdfType: 'argon2id' | 'pbkdf2' + kdfParams: { memory?: number | null; iterations?: number | null; parallelism?: number | null } + } + ): Promise { + await this.ensureInitialized() + + // Parse salt from base64 + const salt = await fromBase64(serverBackup.salt) + + // Build KDF params + const kdfParams = serverBackup.kdfType === 'argon2id' + ? { + type: 'argon2id' as const, + memory: serverBackup.kdfParams.memory ?? 65536, + iterations: serverBackup.kdfParams.iterations ?? 3, + parallelism: serverBackup.kdfParams.parallelism ?? 4, + } + : { + type: 'pbkdf2' as const, + iterations: serverBackup.kdfParams.iterations ?? 600000, + } + + // Derive UMK from passphrase + const umk = await deriveUmkFromPassphrase( + passphrase, + salt, + serverBackup.kdfType, + kdfParams + ) + + // Parse encrypted keys from base64 + const bundle = serverBackup.encryptedKeysBundle + const storedKeys: StoredKeys = { + encryptedEcdhPrivateKey: await fromBase64(bundle.encryptedEcdhPrivateKey), + encryptedEcdhPrivateKeyNonce: await fromBase64(bundle.encryptedEcdhPrivateKeyNonce), + encryptedSigningPrivateKey: await fromBase64(bundle.encryptedSigningPrivateKey), + encryptedSigningPrivateKeyNonce: await fromBase64(bundle.encryptedSigningPrivateKeyNonce), + ecdhPublicKey: await fromBase64(bundle.ecdhPublicKey), + signingPublicKey: await fromBase64(bundle.signingPublicKey), + salt, + kdf: serverBackup.kdfType, + kdfParams, + createdAt: Date.now(), + } + + // Try to decrypt user keys to verify passphrase + try { + const userKeys = await decryptUserKeys(storedKeys, umk) + + // Save to local IndexedDB + await this.keyStore.saveKeys(storedKeys) + + // Persist UMK for session continuity (auto-unlock on page reload) + await this.keyStore.saveSessionUmk(umk) + + // Keep in memory + this.umk = umk + this.userKeys = userKeys + } catch { + // Zero out UMK on failure + umk.fill(0) + throw new Error('Incorrect passphrase') + } + } + + /** + * Restore keys from server using recovery key. + * + * @param recoveryKey - BIP39 mnemonic (24 words) + * @param serverBackup - Backup data from server + */ + async restoreFromServerWithRecoveryKey( + recoveryKey: string, + serverBackup: { + encryptedKeysBundle: EncryptedKeysBundle + salt: string + kdfType: 'argon2id' | 'pbkdf2' + kdfParams: { memory?: number | null; iterations?: number | null; parallelism?: number | null } + } + ): Promise { + await this.ensureInitialized() + + // Restore UMK from recovery key + const umk = restoreUmkFromRecoveryKey(recoveryKey) + + // Parse salt from base64 + const salt = await fromBase64(serverBackup.salt) + + // Build KDF params + const kdfParams = serverBackup.kdfType === 'argon2id' + ? { + type: 'argon2id' as const, + memory: serverBackup.kdfParams.memory ?? 65536, + iterations: serverBackup.kdfParams.iterations ?? 3, + parallelism: serverBackup.kdfParams.parallelism ?? 4, + } + : { + type: 'pbkdf2' as const, + iterations: serverBackup.kdfParams.iterations ?? 600000, + } + + // Parse encrypted keys from base64 + const bundle = serverBackup.encryptedKeysBundle + const storedKeys: StoredKeys = { + encryptedEcdhPrivateKey: await fromBase64(bundle.encryptedEcdhPrivateKey), + encryptedEcdhPrivateKeyNonce: await fromBase64(bundle.encryptedEcdhPrivateKeyNonce), + encryptedSigningPrivateKey: await fromBase64(bundle.encryptedSigningPrivateKey), + encryptedSigningPrivateKeyNonce: await fromBase64(bundle.encryptedSigningPrivateKeyNonce), + ecdhPublicKey: await fromBase64(bundle.ecdhPublicKey), + signingPublicKey: await fromBase64(bundle.signingPublicKey), + salt, + kdf: serverBackup.kdfType, + kdfParams, + createdAt: Date.now(), + } + + // Try to decrypt user keys to verify recovery key + try { + const userKeys = await decryptUserKeys(storedKeys, umk) + + // Save to local IndexedDB + await this.keyStore.saveKeys(storedKeys) + + // Persist UMK for session continuity (auto-unlock on page reload) + await this.keyStore.saveSessionUmk(umk) + + // Keep in memory + this.umk = umk + this.userKeys = userKeys + } catch { + // Zero out UMK on failure + umk.fill(0) + throw new Error('Incorrect recovery key') + } + } + // ============================================ // User Keys // ============================================ diff --git a/app/src/features/e2ee/lib/keys/key-store.ts b/app/src/features/e2ee/lib/keys/key-store.ts index 5490b7a5..1c399010 100644 --- a/app/src/features/e2ee/lib/keys/key-store.ts +++ b/app/src/features/e2ee/lib/keys/key-store.ts @@ -8,9 +8,10 @@ import type { Argon2Params, Pbkdf2Params } from '../types' const DB_NAME = 'refmd-e2ee' -const DB_VERSION = 1 +const DB_VERSION = 2 const STORE_NAME = 'keys' const KEYS_ID = 'user-keys' +const SESSION_ID = 'session-umk' /** Stored key data structure */ export interface StoredKeys { @@ -221,6 +222,84 @@ export class KeyStore { }) } + /** + * Save session UMK to IndexedDB for session continuity. + * This allows the session to persist across page reloads. + */ + async saveSessionUmk(umk: Uint8Array): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + + const data = { + id: SESSION_ID, + umk: Array.from(umk), + savedAt: Date.now(), + } + + const request = store.put(data) + + request.onerror = () => { + reject(new Error(`Failed to save session UMK: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve() + } + }) + } + + /** + * Load session UMK from IndexedDB. + * Returns null if no session UMK is stored. + */ + async loadSessionUmk(): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readonly') + const store = transaction.objectStore(STORE_NAME) + const request = store.get(SESSION_ID) + + request.onerror = () => { + reject(new Error(`Failed to load session UMK: ${request.error?.message}`)) + } + + request.onsuccess = () => { + if (!request.result || !request.result.umk) { + resolve(null) + return + } + + resolve(new Uint8Array(request.result.umk)) + } + }) + } + + /** + * Clear session UMK from IndexedDB. + * Called on logout or manual lock. + */ + async clearSessionUmk(): Promise { + const db = await this.ensureDb() + + return new Promise((resolve, reject) => { + const transaction = db.transaction(STORE_NAME, 'readwrite') + const store = transaction.objectStore(STORE_NAME) + const request = store.delete(SESSION_ID) + + request.onerror = () => { + reject(new Error(`Failed to clear session UMK: ${request.error?.message}`)) + } + + request.onsuccess = () => { + resolve() + } + }) + } + /** * Close the database connection */ diff --git a/app/src/features/e2ee/lib/migration.ts b/app/src/features/e2ee/lib/migration.ts new file mode 100644 index 00000000..8d33e521 --- /dev/null +++ b/app/src/features/e2ee/lib/migration.ts @@ -0,0 +1,260 @@ +/** + * E2EE Migration Helper + * + * Handles the migration of existing data to E2EE by generating + * workspace KEKs and document DEKs. + */ + +import { toBase64 } from './crypto' +import { + generateWorkspaceKek, + encryptKekForRecipient, + encodeKekForApi, +} from './keys/workspace-kek' +import { + generateDocumentDek, + createEncryptedDekForApi, +} from './keys/document-dek' +import { + me as fetchMe, + switchWorkspace, + listDocuments as apiListDocuments, + migrateToE2Ee as apiMigrateToE2ee, +} from '@/shared/api' +import type { + MigrateRequest, + MemberEncryptedKekRequest, + EncryptedDekRequest, +} from '@/shared/api' + +export interface MigrationProgress { + stage: 'preparing' | 'generating_keys' | 'migrating' | 'complete' + current: number + total: number + message: string +} + +export interface MigrationResult { + documentsEncrypted: number + filesEncrypted: number + updatesCleared: number + status: string +} + +/** + * Perform E2EE migration for an existing user. + * + * This function: + * 1. Gets all user's workspaces + * 2. For each workspace, generates a KEK + * 3. Lists all documents and generates DEKs + * 4. Calls the migration API to encrypt existing data + * + * @param userPublicKey - User's ECDH public key (for encrypting KEKs) + * @param userId - User's ID + * @param onProgress - Optional progress callback + * @returns Migration result + */ +export async function performMigration( + userPublicKey: Uint8Array, + userId: string, + onProgress?: (progress: MigrationProgress) => void +): Promise { + const report = (progress: MigrationProgress) => { + onProgress?.(progress) + } + + report({ + stage: 'preparing', + current: 0, + total: 0, + message: 'Preparing migration...', + }) + + // Get user info with workspaces + const userInfo = await fetchMe() + const workspaces = userInfo.workspaces || [] + + if (workspaces.length === 0) { + // No workspaces to migrate + return { + documentsEncrypted: 0, + filesEncrypted: 0, + updatesCleared: 0, + status: 'completed', + } + } + + // Track original workspace to restore later + const originalWorkspaceId = userInfo.active_workspace_id + + // Collect all documents across workspaces + const allDocuments: { workspaceId: string; documentId: string }[] = [] + + report({ + stage: 'preparing', + current: 0, + total: workspaces.length, + message: 'Listing documents...', + }) + + // For each workspace, list documents + for (let i = 0; i < workspaces.length; i++) { + const workspace = workspaces[i] + + report({ + stage: 'preparing', + current: i + 1, + total: workspaces.length, + message: `Listing documents in ${workspace.name}...`, + }) + + // Switch to this workspace + await switchWorkspace({ id: workspace.id }) + + // List all documents (including archived) + const docs = await apiListDocuments({ state: 'all' }) + + // Add to collection + for (const doc of docs.items || []) { + // Only include actual documents, not folders + if (doc.type === 'document') { + allDocuments.push({ + workspaceId: workspace.id, + documentId: doc.id, + }) + } + } + } + + // Restore original workspace + if (originalWorkspaceId) { + await switchWorkspace({ id: originalWorkspaceId }) + } + + const totalItems = workspaces.length + allDocuments.length + + report({ + stage: 'generating_keys', + current: 0, + total: totalItems, + message: 'Generating encryption keys...', + }) + + // Generate KEKs for each workspace + const workspaceKeks: { [key: string]: string } = {} + const encryptedWorkspaceKeks: { [key: string]: MemberEncryptedKekRequest[] } = {} + const workspaceKekRaw: { [key: string]: Uint8Array } = {} // Keep raw KEKs for DEK encryption + + for (let i = 0; i < workspaces.length; i++) { + const workspace = workspaces[i] + + report({ + stage: 'generating_keys', + current: i + 1, + total: totalItems, + message: `Generating key for workspace "${workspace.name}"...`, + }) + + // Generate KEK + const kek = await generateWorkspaceKek() + workspaceKekRaw[workspace.id] = kek + + // Store raw KEK (base64) for server-side encryption + workspaceKeks[workspace.id] = await toBase64(kek) + + // Encrypt KEK for the current user + const { encryptedKek, ephemeralPublicKey, nonce } = await encryptKekForRecipient( + kek, + userPublicKey + ) + const encryptedKekBase64 = await encodeKekForApi(encryptedKek, ephemeralPublicKey, nonce) + + encryptedWorkspaceKeks[workspace.id] = [ + { + userId: userId, + encryptedKek: encryptedKekBase64, + }, + ] + } + + // Generate DEKs for each document + const documentDeks: { [key: string]: string } = {} + const encryptedDocumentDeks: { [key: string]: EncryptedDekRequest } = {} + + for (let i = 0; i < allDocuments.length; i++) { + const { workspaceId, documentId } = allDocuments[i] + + report({ + stage: 'generating_keys', + current: workspaces.length + i + 1, + total: totalItems, + message: `Generating key for document ${i + 1}/${allDocuments.length}...`, + }) + + // Generate DEK + const dek = await generateDocumentDek() + + // Store raw DEK (base64) for server-side encryption + documentDeks[documentId] = await toBase64(dek) + + // Encrypt DEK with workspace KEK + const kek = workspaceKekRaw[workspaceId] + if (!kek) { + throw new Error(`No KEK found for workspace ${workspaceId}`) + } + + const { encryptedDek, nonce } = await createEncryptedDekForApi(dek, kek) + encryptedDocumentDeks[documentId] = { + encryptedDek, + nonce, + } + } + + report({ + stage: 'migrating', + current: 0, + total: 1, + message: 'Encrypting data on server...', + }) + + // Call the migration API + const migrateRequest: MigrateRequest = { + workspaceKeks, + documentDeks, + encryptedWorkspaceKeks, + encryptedDocumentDeks, + } + + const result = await apiMigrateToE2ee({ requestBody: migrateRequest }) + + report({ + stage: 'complete', + current: 1, + total: 1, + message: 'Migration complete!', + }) + + // Clear raw KEKs from memory + for (const kek of Object.values(workspaceKekRaw)) { + kek.fill(0) + } + + return { + documentsEncrypted: result.documentsEncrypted, + filesEncrypted: result.filesEncrypted, + updatesCleared: result.updatesCleared, + status: result.status, + } +} + +/** + * Check if migration is needed for the current user. + */ +export async function checkNeedsMigration(): Promise { + const userInfo = await fetchMe() + const workspaces = userInfo.workspaces || [] + + // If user has workspaces but hasn't completed E2EE setup, migration is needed + return workspaces.length > 0 +} diff --git a/app/src/features/e2ee/ui/MigrationProgress.tsx b/app/src/features/e2ee/ui/MigrationProgress.tsx new file mode 100644 index 00000000..66ceb458 --- /dev/null +++ b/app/src/features/e2ee/ui/MigrationProgress.tsx @@ -0,0 +1,195 @@ +import { Check, Loader2, AlertCircle, RefreshCw } from 'lucide-react' +import { useState, useEffect, useCallback } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' + +import { migrateUserData, markSecuritySetupComplete, type MigrateRequest } from '@/entities/user' + +type StepStatus = 'pending' | 'in_progress' | 'completed' | 'error' + +interface MigrationStep { + id: string + label: string + status: StepStatus +} + +interface MigrationProgressProps { + /** Migration request data (keys, etc.) */ + migrationData: MigrateRequest + /** Called when migration completes successfully */ + onComplete: () => void + /** Called when migration fails */ + onError: (error: Error) => void +} + +const INITIAL_STEPS: MigrationStep[] = [ + { id: 'keys', label: 'Registering encryption keys', status: 'pending' }, + { id: 'data', label: 'Encrypting data', status: 'pending' }, + { id: 'verify', label: 'Verifying', status: 'pending' }, + { id: 'complete', label: 'Completing setup', status: 'pending' }, +] + +export function MigrationProgress({ + migrationData, + onComplete, + onError, +}: MigrationProgressProps) { + const [steps, setSteps] = useState(INITIAL_STEPS) + const [error, setError] = useState(null) + const [isRetrying, setIsRetrying] = useState(false) + + const updateStepStatus = useCallback((stepId: string, status: StepStatus) => { + setSteps((prev) => + prev.map((step) => (step.id === stepId ? { ...step, status } : step)) + ) + }, []) + + const runMigration = useCallback(async () => { + setError(null) + setSteps(INITIAL_STEPS) + + try { + // Step 1: Register keys + updateStepStatus('keys', 'in_progress') + await new Promise((r) => setTimeout(r, 500)) // Small delay for UX + updateStepStatus('keys', 'completed') + + // Step 2: Migrate data + updateStepStatus('data', 'in_progress') + await migrateUserData(migrationData) + updateStepStatus('data', 'completed') + + // Step 3: Verify + updateStepStatus('verify', 'in_progress') + await new Promise((r) => setTimeout(r, 300)) + updateStepStatus('verify', 'completed') + + // Step 4: Mark complete + updateStepStatus('complete', 'in_progress') + await markSecuritySetupComplete() + updateStepStatus('complete', 'completed') + + // Success + onComplete() + } catch (err) { + const message = err instanceof Error ? err.message : 'Migration failed' + setError(message) + + // Find the in_progress step and mark it as error + setSteps((prev) => + prev.map((step) => ({ + ...step, + status: step.status === 'in_progress' ? 'error' : step.status, + })) + ) + + onError(err instanceof Error ? err : new Error(message)) + } + }, [migrationData, onComplete, onError, updateStepStatus]) + + const handleRetry = useCallback(async () => { + setIsRetrying(true) + await runMigration() + setIsRetrying(false) + }, [runMigration]) + + // Start migration on mount + useEffect(() => { + runMigration() + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + const progress = steps.filter((s) => s.status === 'completed').length / steps.length + + return ( +
+ {/* Progress Bar */} +
+
+ Migrating... + {Math.round(progress * 100)}% +
+
+
+
+
+ + {/* Steps */} +
+ {steps.map((step) => ( + + ))} +
+ + {/* Error */} + {error && ( +
+
+ +
+

Migration failed

+

{error}

+
+
+ + +
+ )} +
+ ) +} + +function StepItem({ step }: { step: MigrationStep }) { + return ( +
+
+ {step.status === 'completed' && } + {step.status === 'in_progress' && ( + + )} + {step.status === 'error' && } + {step.status === 'pending' && ( + + )} +
+ + {step.label} + +
+ ) +} diff --git a/app/src/features/e2ee/ui/PassphraseInput.tsx b/app/src/features/e2ee/ui/PassphraseInput.tsx new file mode 100644 index 00000000..810c644b --- /dev/null +++ b/app/src/features/e2ee/ui/PassphraseInput.tsx @@ -0,0 +1,256 @@ +import { Eye, EyeOff, Check, X } from 'lucide-react' +import { useState, useCallback, useMemo, useEffect } from 'react' +import zxcvbn from 'zxcvbn' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' + +const MIN_PASSPHRASE_LENGTH = 12 + +interface PassphraseInputProps { + onSubmit: (passphrase: string) => void | Promise + loading?: boolean + error?: string + requireConfirmation?: boolean + minLength?: number + submitLabel?: string +} + +interface StrengthInfo { + score: number + label: string + color: string + feedback: string[] +} + +function getStrengthInfo(result: zxcvbn.ZXCVBNResult): StrengthInfo { + const labels = ['Very weak', 'Weak', 'Fair', 'Strong', 'Very strong'] + const colors = [ + 'bg-red-500', + 'bg-orange-500', + 'bg-yellow-500', + 'bg-lime-500', + 'bg-green-500', + ] + + const feedback = [ + ...result.feedback.suggestions, + result.feedback.warning, + ].filter(Boolean) as string[] + + return { + score: result.score, + label: labels[result.score], + color: colors[result.score], + feedback, + } +} + +export function PassphraseInput({ + onSubmit, + loading = false, + error, + requireConfirmation = true, + minLength = MIN_PASSPHRASE_LENGTH, + submitLabel = 'Next', +}: PassphraseInputProps) { + const [passphrase, setPassphrase] = useState('') + const [confirmation, setConfirmation] = useState('') + const [showPassphrase, setShowPassphrase] = useState(false) + const [showConfirmation, setShowConfirmation] = useState(false) + const [touched, setTouched] = useState(false) + + const strength = useMemo(() => { + if (!passphrase) return null + return getStrengthInfo(zxcvbn(passphrase)) + }, [passphrase]) + + const validations = useMemo(() => { + return { + minLength: passphrase.length >= minLength, + strongEnough: strength ? strength.score >= 2 : false, + matches: !requireConfirmation || passphrase === confirmation, + } + }, [passphrase, confirmation, minLength, strength, requireConfirmation]) + + const isValid = validations.minLength && validations.strongEnough && validations.matches + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + setTouched(true) + if (isValid && !loading) { + try { + await onSubmit(passphrase) + } catch { + // Error is handled by the parent component via error prop + } + } + }, + [isValid, loading, onSubmit, passphrase] + ) + + // Clear confirmation when passphrase changes + useEffect(() => { + if (requireConfirmation && passphrase !== confirmation && confirmation) { + // Keep confirmation as-is, validation will show mismatch + } + }, [passphrase, confirmation, requireConfirmation]) + + return ( +
+ {/* Passphrase Input */} +
+ +
+ setPassphrase(e.target.value)} + onBlur={() => setTouched(true)} + placeholder={`${minLength}+ characters`} + autoComplete="new-password" + aria-invalid={touched && !validations.minLength} + disabled={loading} + className="pr-10" + /> + +
+ + {/* Strength Meter */} + {passphrase && strength && ( +
+
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+
+ = 2 ? 'text-green-600' : 'text-destructive')}> + {strength.label} + + {strength.feedback.length > 0 && ( + + {strength.feedback[0]} + + )} +
+
+ )} + + {/* Validation Checklist */} +
+ 0} + /> + 0} + /> +
+
+ + {/* Confirmation Input */} + {requireConfirmation && ( +
+ +
+ setConfirmation(e.target.value)} + placeholder="Enter again" + autoComplete="new-password" + aria-invalid={touched && confirmation.length > 0 && !validations.matches} + disabled={loading} + className="pr-10" + /> + +
+ {confirmation && !validations.matches && ( +

Passphrases do not match

+ )} +
+ )} + + {/* Error Message */} + {error && ( +

{error}

+ )} + + {/* Submit Button */} + + + ) +} + +function ValidationItem({ + valid, + label, + touched, +}: { + valid: boolean + label: string + touched: boolean +}) { + if (!touched) { + return ( +
+
+ {label} +
+ ) + } + + return ( +
+ {valid ? : } + {label} +
+ ) +} diff --git a/app/src/features/e2ee/ui/RecoveryKeyDisplay.tsx b/app/src/features/e2ee/ui/RecoveryKeyDisplay.tsx new file mode 100644 index 00000000..a23b4372 --- /dev/null +++ b/app/src/features/e2ee/ui/RecoveryKeyDisplay.tsx @@ -0,0 +1,160 @@ +import { Copy, Download, Eye, EyeOff, AlertTriangle } from 'lucide-react' +import { useState, useCallback, useMemo } from 'react' +import { toast } from 'sonner' + +import { cn } from '@/shared/lib/utils' +import { Alert, AlertDescription } from '@/shared/ui/alert' +import { Button } from '@/shared/ui/button' + +interface RecoveryKeyDisplayProps { + /** Recovery key (24 words space-separated) */ + recoveryKey: string + /** Called when user confirms they have saved the key */ + onConfirm: () => void +} + +export function RecoveryKeyDisplay({ recoveryKey, onConfirm }: RecoveryKeyDisplayProps) { + const [isRevealed, setIsRevealed] = useState(false) + const [hasCopied, setHasCopied] = useState(false) + + const words = useMemo(() => recoveryKey.split(' '), [recoveryKey]) + + const handleCopy = useCallback(async () => { + try { + await navigator.clipboard.writeText(recoveryKey) + setHasCopied(true) + toast.success('Recovery key copied to clipboard') + } catch { + toast.error('Failed to copy') + } + }, [recoveryKey]) + + const handleDownload = useCallback(() => { + const content = `RefMD Recovery Key +==================== + +Please store this file in a secure location. +If you forget your passphrase, you can restore your account with this recovery key. + +Recovery Key (24 words): +${words.map((word, i) => `${(i + 1).toString().padStart(2, ' ')}. ${word}`).join('\n')} + +Warning: +- Do not share this file with anyone +- Do not store it in cloud storage +- We recommend writing it down on paper and storing it in a safe place + +Generated: ${new Date().toISOString()} +` + + const blob = new Blob([content], { type: 'text/plain' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'refmd-recovery-key.txt' + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) + + toast.success('Recovery key downloaded') + }, [words]) + + return ( +
+ {/* Warning Alert */} + + + + Important: This recovery key will not be shown again. + Please save it in a secure location. If you forget your passphrase, + you will not be able to recover your account without this key. + + + + {/* Recovery Key Grid */} +
+
+ Recovery Key (24 words) + +
+ +
+ {words.map((word, index) => ( +
+ + {index + 1}. + + + {word} + +
+ ))} +
+
+ + {/* Action Buttons */} +
+ + +
+ + {/* Confirm Button */} + + + {!hasCopied && !isRevealed && ( +

+ Please reveal or copy your recovery key before continuing +

+ )} +
+ ) +} diff --git a/app/src/features/e2ee/ui/RecoveryKeyVerify.tsx b/app/src/features/e2ee/ui/RecoveryKeyVerify.tsx new file mode 100644 index 00000000..778ef8b3 --- /dev/null +++ b/app/src/features/e2ee/ui/RecoveryKeyVerify.tsx @@ -0,0 +1,194 @@ +import { ArrowLeft, Check, X } from 'lucide-react' +import { useState, useCallback, useMemo } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' + +import { generateVerificationIndices, getWordsAtIndices } from '../lib/crypto/bip39' + +interface RecoveryKeyVerifyProps { + /** Recovery key (24 words space-separated) */ + recoveryKey: string + /** Called when verification is successful */ + onVerified: () => void + /** Called when user wants to go back */ + onBack: () => void +} + +export function RecoveryKeyVerify({ + recoveryKey, + onVerified, + onBack, +}: RecoveryKeyVerifyProps) { + const [confirmChecked, setConfirmChecked] = useState(false) + const [inputs, setInputs] = useState>({}) + const [error, setError] = useState(null) + + // Generate random indices to verify (2 random words) + const verificationIndices = useMemo(() => { + return generateVerificationIndices(2) + }, []) + + // Get expected words at those indices + const expectedWords = useMemo(() => { + return getWordsAtIndices(recoveryKey, verificationIndices) + }, [recoveryKey, verificationIndices]) + + // Check if current inputs match + const isCorrect = useMemo(() => { + return verificationIndices.every((index, i) => { + const input = inputs[index]?.toLowerCase().trim() + const expected = expectedWords[i]?.toLowerCase() + return input === expected + }) + }, [inputs, verificationIndices, expectedWords]) + + const isComplete = useMemo(() => { + return verificationIndices.every((index) => { + const input = inputs[index]?.trim() + return input && input.length > 0 + }) + }, [inputs, verificationIndices]) + + const handleInputChange = useCallback((index: number, value: string) => { + setInputs((prev) => ({ ...prev, [index]: value })) + setError(null) + }, []) + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault() + + if (!confirmChecked) { + setError('Please confirm you have saved the recovery key') + return + } + + if (!isCorrect) { + setError('The entered words are incorrect') + return + } + + onVerified() + }, + [confirmChecked, isCorrect, onVerified] + ) + + return ( +
+ {/* Instructions */} +
+

+ To confirm that you have correctly saved your recovery key, + please enter the following words. +

+
+ + {/* Word Inputs */} +
+ {verificationIndices.map((index, i) => ( + handleInputChange(index, value)} + isCorrect={ + inputs[index]?.toLowerCase().trim() === expectedWords[i]?.toLowerCase() + } + showValidation={!!inputs[index]?.trim()} + /> + ))} +
+ + {/* Confirmation Checkbox */} +
+ +
+ + {/* Error Message */} + {error && ( +

{error}

+ )} + + {/* Action Buttons */} +
+ + +
+
+ ) +} + +function WordInput({ + index, + value, + onChange, + isCorrect, + showValidation, +}: { + index: number + value: string + onChange: (value: string) => void + isCorrect: boolean + showValidation: boolean +}) { + return ( +
+ +
+ onChange(e.target.value)} + placeholder="Enter word" + autoComplete="off" + autoCapitalize="off" + spellCheck={false} + className={cn( + 'pr-10', + showValidation && (isCorrect ? 'border-green-500' : 'border-destructive') + )} + /> + {showValidation && ( +
+ {isCorrect ? ( + + ) : ( + + )} +
+ )} +
+
+ ) +} diff --git a/app/src/features/e2ee/ui/RestorePrompt.tsx b/app/src/features/e2ee/ui/RestorePrompt.tsx new file mode 100644 index 00000000..d605d5c5 --- /dev/null +++ b/app/src/features/e2ee/ui/RestorePrompt.tsx @@ -0,0 +1,222 @@ +import { Download, Lock, Key, AlertCircle, Loader2 } from 'lucide-react' +import { useState, useCallback } from 'react' + +import { cn } from '@/shared/lib/utils' +import { Button } from '@/shared/ui/button' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/shared/ui/card' +import { Input } from '@/shared/ui/input' +import { Label } from '@/shared/ui/label' + +import { useE2EE } from '../context/e2ee-context' + +type RestoreMode = 'passphrase' | 'recovery' + +interface RestorePromptProps { + /** Called when restore is successful */ + onRestored?: () => void + /** Whether to show the component inline (no card wrapper) */ + inline?: boolean +} + +export function RestorePrompt({ onRestored, inline = false }: RestorePromptProps) { + const { + restoreFromServer, + restoreFromServerWithRecoveryKey, + loading, + error, + clearError, + } = useE2EE() + const [mode, setMode] = useState('passphrase') + const [passphrase, setPassphrase] = useState('') + const [recoveryKey, setRecoveryKey] = useState('') + + const handlePassphraseSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!passphrase.trim() || loading) return + + try { + await restoreFromServer(passphrase) + onRestored?.() + } catch { + // Error is handled by context + } + }, + [passphrase, loading, restoreFromServer, onRestored] + ) + + const handleRecoverySubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault() + if (!recoveryKey.trim() || loading) return + + try { + await restoreFromServerWithRecoveryKey(recoveryKey) + onRestored?.() + } catch { + // Error is handled by context + } + }, + [recoveryKey, loading, restoreFromServerWithRecoveryKey, onRestored] + ) + + const switchMode = useCallback( + (newMode: RestoreMode) => { + setMode(newMode) + clearError() + setPassphrase('') + setRecoveryKey('') + }, + [clearError] + ) + + const content = ( +
+ {/* Info Banner */} +
+
+ +
+

New device detected

+

+ Your encryption keys will be restored from the server backup. + Enter your passphrase to decrypt them. +

+
+
+
+ + {/* Mode Tabs */} +
+ + +
+ + {/* Passphrase Form */} + {mode === 'passphrase' && ( +
+
+ + setPassphrase(e.target.value)} + placeholder="Enter your passphrase" + autoComplete="current-password" + disabled={loading} + /> +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ )} + + {/* Recovery Key Form */} + {mode === 'recovery' && ( +
+
+ +