feat(mounts): P2 — read-write REST for external mounts

Adds full CRUD on mount contents, mirroring the P1 read pattern (handlers/
services classify; authorization stays in the service via the mount-root
folder grant; the provider does the I/O).

- mkdir / rename / delete / move-within branch inside FolderService and
  FileManagementService (router injected into both)
- streaming upload via a new ExternalUploadService: the upload handler detects a
  mount destination BEFORE the CAS ingest and streams the multipart body
  straight to the provider (no BLAKE3/dedup). `write_stream` now takes a
  lifetime-bound boxed stream so the borrowing multipart field can be passed
  without buffering.
- deletes on mounts are permanent (no trash): the trash-first folder handler
  routes `ext:` ids straight to the provider delete; file delete goes through the
  branched delete_and_cleanup
- cross-backend move/copy (mount ↔ native, or between mounts) is forbidden
  (UnsupportedOperation); the mount root itself cannot be renamed/moved/deleted
- every mutation emits a `target:"audit" event="external_mount.write"` line
- shared mount_dto builders synthesize FolderDto/FileDto from a provider MountStat

Tests: 529 unit + integration tests for mkdir/rename/delete, file rename/delete,
streaming upload, cross-boundary forbid, and stranger-denied — all against real
Postgres + a real provider (testcontainers).
This commit is contained in:
Bradley Nelson
2026-06-25 00:30:10 -06:00
parent 3c31695579
commit 8e3e31da4d
10 changed files with 714 additions and 12 deletions
@@ -32,7 +32,8 @@ use crate::domain::services::external_mount_id::NodeId;
///
/// Boxed (not generic) so the trait stays object-safe. Callers map their body's
/// error type to `std::io::Error` before constructing it.
pub type MountByteStream = Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>;
pub type MountByteStream<'a> =
Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send + 'a>>;
/// One entry returned by [`ExternalMountProvider::list_dir`].
#[derive(Debug, Clone)]
@@ -126,7 +127,7 @@ pub trait ExternalMountProvider: Send + Sync + 'static {
&self,
parent: &NodeId,
name: &str,
body: MountByteStream,
body: MountByteStream<'_>,
) -> Result<MountStat, DomainError>;
/// Rename an entry in place (same parent). Returns the renamed entry's stat.