diff --git a/crates/executor-core/src/sdk/files.rs b/crates/executor-core/src/sdk/files.rs index 2cd6dab..10926d2 100644 --- a/crates/executor-core/src/sdk/files.rs +++ b/crates/executor-core/src/sdk/files.rs @@ -24,7 +24,9 @@ use std::sync::Arc; use super::bridge::block_on; -use picloud_shared::{FileMeta, FileUpdate, FilesService, NewFile, SdkCallCx, Services}; +use picloud_shared::{ + FileMeta, FileUpdate, FilesService, GroupFilesService, NewFile, SdkCallCx, Services, +}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; /// Per-call handle captured by the Rhai SDK. Cheap to clone (two Arcs @@ -36,8 +38,20 @@ pub struct FilesHandle { cx: Arc, } +/// §11.6 shared-collection handle, returned by `files::shared_collection(name)`. +/// Same method surface as `FilesHandle`, but routes through the +/// `GroupFilesService` — the owning group is resolved from `cx.app_id`'s +/// ancestor chain inside the service (the isolation boundary). +#[derive(Clone)] +pub struct GroupFilesHandle { + collection: String, + service: Arc, + cx: Arc, +} + pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { let files_service = services.files.clone(); + let group_files_service = services.group_files.clone(); let mut module = Module::new(); { @@ -57,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { + if name.is_empty() { + return Err("files::shared_collection name must not be empty".into()); + } + Ok(GroupFilesHandle { + collection: name.to_string(), + service: group_files_service.clone(), + cx: cx.clone(), + }) + }, + ); + } engine.register_static_module("files", module.into()); engine.register_type_with_name::("FilesHandle"); @@ -67,6 +98,15 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc("GroupFilesHandle"); + register_group_create(engine); + register_group_head(engine); + register_group_get(engine); + register_group_update(engine); + register_group_delete(engine); + register_group_list(engine); } fn register_create(engine: &mut RhaiEngine) { @@ -220,6 +260,163 @@ fn list_call( Ok(m) } +// --- GroupFilesHandle methods (§11.6 shared files collections) ------------- +// Bodies mirror the app handle's; only the receiver type and service differ +// (Rhai dispatches `create`/`head`/... by the handle's concrete type). + +fn register_group_create(engine: &mut RhaiEngine) { + engine.register_fn( + "create", + |handle: &mut GroupFilesHandle, meta: Map| -> Result> { + let name = require_string(&meta, "name")?; + let content_type = require_string(&meta, "content_type")?; + let data = require_blob(&meta, "data")?; + let h = handle.clone(); + let new = NewFile { + name, + content_type, + data, + }; + let id = block_on("files", async move { + h.service.create(&h.cx, &h.collection, new).await + })?; + Ok(id.to_string()) + }, + ); +} + +fn register_group_head(engine: &mut RhaiEngine) { + engine.register_fn( + "head", + |handle: &mut GroupFilesHandle, id: &str| -> Result> { + let h = handle.clone(); + let id = id.to_string(); + let meta = block_on("files", async move { + h.service.head(&h.cx, &h.collection, &id).await + })?; + Ok(meta.map_or(Dynamic::UNIT, |m| file_meta_to_map(&m).into())) + }, + ); +} + +fn register_group_get(engine: &mut RhaiEngine) { + engine.register_fn( + "get", + |handle: &mut GroupFilesHandle, id: &str| -> Result> { + let h = handle.clone(); + let id = id.to_string(); + let bytes = block_on("files", async move { + h.service.get(&h.cx, &h.collection, &id).await + })?; + Ok(bytes.map_or(Dynamic::UNIT, Dynamic::from_blob)) + }, + ); +} + +fn register_group_update(engine: &mut RhaiEngine) { + engine.register_fn( + "update", + |handle: &mut GroupFilesHandle, id: &str, meta: Map| -> Result<(), Box> { + let data = require_blob(&meta, "data")?; + let name = optional_string(&meta, "name")?; + let content_type = optional_string(&meta, "content_type")?; + let h = handle.clone(); + let id = id.to_string(); + let upd = FileUpdate { + data, + name, + content_type, + }; + block_on("files", async move { + h.service.update(&h.cx, &h.collection, &id, upd).await + }) + }, + ); +} + +fn register_group_delete(engine: &mut RhaiEngine) { + engine.register_fn( + "delete", + |handle: &mut GroupFilesHandle, id: &str| -> Result> { + let h = handle.clone(); + let id = id.to_string(); + block_on("files", async move { + h.service.delete(&h.cx, &h.collection, &id).await + }) + }, + ); +} + +fn register_group_list(engine: &mut RhaiEngine) { + engine.register_fn( + "list", + |handle: &mut GroupFilesHandle| -> Result> { + group_list_call(handle, None, 0) + }, + ); + engine.register_fn( + "list", + |handle: &mut GroupFilesHandle, cursor: &str| -> Result> { + group_list_call(handle, Some(cursor.to_string()), 0) + }, + ); + engine.register_fn( + "list", + |handle: &mut GroupFilesHandle, + cursor: &str, + limit: i64| + -> Result> { + let limit = u32::try_from(limit.max(0)).unwrap_or(0); + group_list_call(handle, Some(cursor.to_string()), limit) + }, + ); + engine.register_fn( + "list", + |handle: &mut GroupFilesHandle, opts: Map| -> Result> { + let cursor = match opts.get("cursor") { + Some(v) if !v.is_unit() => { + Some(v.clone().into_string().map_err(|_| -> Box { + "files: list cursor must be a string".into() + })?) + } + _ => None, + }; + let limit = match opts.get("limit") { + Some(v) if !v.is_unit() => { + u32::try_from(v.as_int().unwrap_or(0).max(0)).unwrap_or(0) + } + _ => 0, + }; + group_list_call(handle, cursor, limit) + }, + ); +} + +fn group_list_call( + handle: &GroupFilesHandle, + cursor: Option, + limit: u32, +) -> Result> { + let h = handle.clone(); + let page = block_on("files", async move { + h.service + .list(&h.cx, &h.collection, cursor.as_deref(), limit) + .await + })?; + let mut m = Map::new(); + let files: Array = page + .files + .iter() + .map(|meta| Dynamic::from(file_meta_to_map(meta))) + .collect(); + m.insert("files".into(), files.into()); + m.insert( + "next_cursor".into(), + page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from), + ); + Ok(m) +} + /// Render a `FileMeta` into the Rhai map shape scripts see from /// `head` / `list`. fn file_meta_to_map(meta: &FileMeta) -> Map { diff --git a/crates/manager-core/src/files_repo.rs b/crates/manager-core/src/files_repo.rs index 588e4e4..261a476 100644 --- a/crates/manager-core/src/files_repo.rs +++ b/crates/manager-core/src/files_repo.rs @@ -255,12 +255,7 @@ pub(crate) fn shard_dir_at( .join(&id_str[..2]) } -pub(crate) fn final_path_at( - root: &Path, - owner_rel: &Path, - collection: &str, - id: Uuid, -) -> PathBuf { +pub(crate) fn final_path_at(root: &Path, owner_rel: &Path, collection: &str, id: Uuid) -> PathBuf { let id_str = id.to_string(); shard_dir_at(root, owner_rel, collection, &id_str).join(&id_str) } diff --git a/crates/manager-core/src/group_files_service.rs b/crates/manager-core/src/group_files_service.rs new file mode 100644 index 0000000..41cc3c7 --- /dev/null +++ b/crates/manager-core/src/group_files_service.rs @@ -0,0 +1,492 @@ +//! `GroupFilesServiceImpl` — wires `GroupFilesRepo` + the group-collection +//! registry underneath the `picloud_shared::GroupFilesService` trait that scripts +//! reach via the `files::shared_collection("name")` Rhai handle (§11.6). +//! +//! Combines the group-KV/docs service pattern (owner resolution from `cx.app_id`, +//! reads-open / writes-authed authz) with the files surface (collection +//! path-validation, field + size-cap validation, content-type sanitization). No +//! event emission in the MVP (the "group trigger has no app to watch" deferral). + +use std::sync::Arc; + +use async_trait::async_trait; +use picloud_shared::{ + sanitize_stored_content_type, validate_files_collection, FileMeta, FileUpdate, FilesListPage, + GroupFilesError, GroupFilesService, GroupId, NewFile, SdkCallCx, +}; +use uuid::Uuid; + +use crate::authz::{self, AuthzRepo, Capability}; +use crate::files_repo::FileUpdated; +use crate::group_collection_repo::GroupCollectionResolver; +use crate::group_files_repo::{GroupFilesRepo, GroupFilesRepoError}; + +/// The registry `kind` this service resolves. +const KIND_FILES: &str = "files"; + +pub struct GroupFilesServiceImpl { + repo: Arc, + resolver: Arc, + authz: Arc, + max_file_size_bytes: usize, +} + +impl GroupFilesServiceImpl { + #[must_use] + pub fn new( + repo: Arc, + resolver: Arc, + authz: Arc, + max_file_size_bytes: usize, + ) -> Self { + Self { + repo, + resolver, + authz, + max_file_size_bytes, + } + } + + /// The structural boundary: resolve the collection to its owning group + /// (kind=`files`) on the calling app's chain. `CollectionNotShared` when no + /// ancestor group declares it. + async fn owning_group( + &self, + cx: &SdkCallCx, + collection: &str, + ) -> Result { + self.resolver + .resolve_owning_group(cx.app_id, collection, KIND_FILES) + .await + .map_err(|e| GroupFilesError::Backend(e.to_string()))? + .ok_or_else(|| GroupFilesError::CollectionNotShared(collection.to_string())) + } + + async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> { + authz::script_gate( + &*self.authz, + cx, + Capability::GroupFilesRead(group_id), + || GroupFilesError::Forbidden, + GroupFilesError::Backend, + ) + .await + } + + async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupFilesError> { + // Fails closed for an anonymous principal — shared mutation always needs + // an authenticated editor+ on the owning group. + authz::script_gate_require_principal( + &*self.authz, + cx, + Capability::GroupFilesWrite(group_id), + || GroupFilesError::Forbidden, + GroupFilesError::Backend, + ) + .await + } +} + +/// Invalid UUIDs aren't an error shape the SDK exposes — for reads/deletes they +/// simply mean "no such file" (mirrors `files_service::parse_id`). +fn parse_id(id: &str) -> Option { + Uuid::parse_str(id).ok() +} + +impl From for GroupFilesError { + fn from(e: GroupFilesRepoError) -> Self { + match e { + GroupFilesRepoError::Corrupted => Self::Corrupted, + GroupFilesRepoError::InvalidCollection(c) => Self::InvalidCollection(c), + other => Self::Backend(other.to_string()), + } + } +} + +#[async_trait] +impl GroupFilesService for GroupFilesServiceImpl { + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + mut new: NewFile, + ) -> Result { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + new.validate(self.max_file_size_bytes)?; + // Coerce dangerous render types to application/octet-stream after the + // shape checks pass (same as app files, audit 2026-06-11 C-2). + new.content_type = sanitize_stored_content_type(&new.content_type); + self.check_write(cx, group_id).await?; + Ok(self.repo.create(group_id, collection, new).await?.id) + } + + async fn head( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result, GroupFilesError> { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + let Some(uuid) = parse_id(id) else { + return Ok(None); + }; + Ok(self.repo.head(group_id, collection, uuid).await?) + } + + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result>, GroupFilesError> { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + let Some(uuid) = parse_id(id) else { + return Ok(None); + }; + Ok(self.repo.get(group_id, collection, uuid).await?) + } + + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + mut upd: FileUpdate, + ) -> Result<(), GroupFilesError> { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + upd.validate(self.max_file_size_bytes)?; + if let Some(ct) = upd.content_type.as_deref() { + upd.content_type = Some(sanitize_stored_content_type(ct)); + } + self.check_write(cx, group_id).await?; + let Some(uuid) = parse_id(id) else { + return Err(GroupFilesError::NotFound); + }; + match self.repo.update(group_id, collection, uuid, upd).await? { + Some(FileUpdated { .. }) => Ok(()), + None => Err(GroupFilesError::NotFound), + } + } + + async fn delete( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + self.check_write(cx, group_id).await?; + let Some(uuid) = parse_id(id) else { + return Ok(false); + }; + Ok(self + .repo + .delete(group_id, collection, uuid) + .await? + .is_some()) + } + + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result { + validate_files_collection(collection)?; + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + Ok(self.repo.list(group_id, collection, cursor, limit).await?) + } +} + +// ---------------------------------------------------------------------------- +// Tests — in-memory repo + a fake resolver so unit tests don't need Postgres or +// a filesystem. The on-disk atomic-write/checksum mechanics are covered by the +// tempdir tests in `files_repo.rs`. +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::authz::{AuthzError, AuthzRepo}; + use crate::group_files_repo::GroupFilesRepoError; + use chrono::Utc; + use picloud_shared::{ + AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, + UserId, + }; + use std::collections::HashMap; + use tokio::sync::Mutex; + + #[derive(Default)] + struct InMemoryGroupFilesRepo { + #[allow(clippy::type_complexity)] + data: Mutex)>>, + } + + fn meta(id: Uuid, collection: &str, new: &NewFile) -> FileMeta { + FileMeta { + id, + collection: collection.to_string(), + name: new.name.clone(), + content_type: new.content_type.clone(), + size: new.data.len() as u64, + checksum: String::new(), + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[async_trait] + impl GroupFilesRepo for InMemoryGroupFilesRepo { + async fn create( + &self, + group_id: GroupId, + collection: &str, + new: NewFile, + ) -> Result { + let id = Uuid::new_v4(); + let m = meta(id, collection, &new); + self.data.lock().await.insert( + (group_id, collection.to_string(), id), + (m.clone(), new.data), + ); + Ok(m) + } + + async fn head( + &self, + group_id: GroupId, + collection: &str, + id: Uuid, + ) -> Result, GroupFilesRepoError> { + Ok(self + .data + .lock() + .await + .get(&(group_id, collection.to_string(), id)) + .map(|(m, _)| m.clone())) + } + + async fn get( + &self, + group_id: GroupId, + collection: &str, + id: Uuid, + ) -> Result>, GroupFilesRepoError> { + Ok(self + .data + .lock() + .await + .get(&(group_id, collection.to_string(), id)) + .map(|(_, b)| b.clone())) + } + + async fn update( + &self, + group_id: GroupId, + collection: &str, + id: Uuid, + upd: FileUpdate, + ) -> Result, GroupFilesRepoError> { + let mut data = self.data.lock().await; + let key = (group_id, collection.to_string(), id); + let Some((prev, _)) = data.get(&key).cloned() else { + return Ok(None); + }; + let mut m = prev.clone(); + m.size = upd.data.len() as u64; + if let Some(n) = &upd.name { + m.name = n.clone(); + } + data.insert(key, (m.clone(), upd.data)); + Ok(Some(FileUpdated { new: m, prev })) + } + + async fn delete( + &self, + group_id: GroupId, + collection: &str, + id: Uuid, + ) -> Result, GroupFilesRepoError> { + Ok(self + .data + .lock() + .await + .remove(&(group_id, collection.to_string(), id)) + .map(|(m, _)| m)) + } + + async fn list( + &self, + group_id: GroupId, + collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + let files = self + .data + .lock() + .await + .iter() + .filter(|((g, c, _), _)| *g == group_id && c == collection) + .map(|(_, (m, _))| m.clone()) + .collect(); + Ok(FilesListPage { + files, + next_cursor: None, + }) + } + } + + #[derive(Default)] + struct FakeResolver { + map: HashMap<(AppId, String), GroupId>, + } + + #[async_trait] + impl GroupCollectionResolver for FakeResolver { + async fn resolve_owning_group( + &self, + app_id: AppId, + name: &str, + _kind: &str, + ) -> Result, sqlx::Error> { + Ok(self.map.get(&(app_id, name.to_lowercase())).copied()) + } + } + + #[derive(Default)] + struct DenyingAuthzRepo; + + #[async_trait] + impl AuthzRepo for DenyingAuthzRepo { + async fn membership( + &self, + _user_id: UserId, + _app_id: AppId, + ) -> Result, AuthzError> { + Ok(None) + } + } + + fn cx_with(app_id: AppId, principal: Option) -> SdkCallCx { + SdkCallCx { + app_id, + script_id: ScriptId::new(), + principal, + execution_id: ExecutionId::new(), + request_id: RequestId::new(), + trigger_depth: 0, + root_execution_id: ExecutionId::new(), + is_dead_letter_handler: false, + event: None, + } + } + + fn owner() -> Principal { + Principal { + user_id: AdminUserId::new(), + instance_role: InstanceRole::Owner, + scopes: None, + app_binding: None, + } + } + + fn svc(resolver: FakeResolver) -> GroupFilesServiceImpl { + GroupFilesServiceImpl::new( + Arc::new(InMemoryGroupFilesRepo::default()), + Arc::new(resolver), + Arc::new(DenyingAuthzRepo), + 10 * 1024 * 1024, + ) + } + + fn new_file(name: &str, data: &[u8]) -> NewFile { + NewFile { + name: name.to_string(), + content_type: "application/octet-stream".to_string(), + data: data.to_vec(), + } + } + + #[tokio::test] + async fn unrelated_app_gets_collection_not_shared() { + let app_a = AppId::new(); + let app_b = AppId::new(); + let group = GroupId::new(); + let mut resolver = FakeResolver::default(); + resolver.map.insert((app_a, "assets".into()), group); + let files = svc(resolver); + + let cx_a = cx_with(app_a, Some(owner())); + let id = files + .create(&cx_a, "assets", new_file("a.txt", b"hi")) + .await + .unwrap(); + assert!(files + .get(&cx_a, "assets", &id.to_string()) + .await + .unwrap() + .is_some()); + + let cx_b = cx_with(app_b, Some(owner())); + let err = files.list(&cx_b, "assets", None, 100).await.unwrap_err(); + assert!(matches!(err, GroupFilesError::CollectionNotShared(c) if c == "assets")); + } + + #[tokio::test] + async fn reads_open_writes_require_auth() { + let app = AppId::new(); + let group = GroupId::new(); + let mut resolver = FakeResolver::default(); + resolver.map.insert((app, "assets".into()), group); + let files = svc(resolver); + + let owner_cx = cx_with(app, Some(owner())); + let id = files + .create(&owner_cx, "assets", new_file("a.txt", b"hi")) + .await + .unwrap(); + + // Anonymous READ (get) is allowed. + let anon = cx_with(app, None); + let bytes = files.get(&anon, "assets", &id.to_string()).await.unwrap(); + assert_eq!(bytes, Some(b"hi".to_vec())); + + // Anonymous WRITE (create) fails closed. + let err = files + .create(&anon, "assets", new_file("x.txt", b"x")) + .await + .unwrap_err(); + assert!(matches!(err, GroupFilesError::Forbidden)); + } + + #[tokio::test] + async fn oversize_blob_rejected() { + let app = AppId::new(); + let group = GroupId::new(); + let mut resolver = FakeResolver::default(); + resolver.map.insert((app, "assets".into()), group); + let files = GroupFilesServiceImpl::new( + Arc::new(InMemoryGroupFilesRepo::default()), + Arc::new(resolver), + Arc::new(DenyingAuthzRepo), + 8, // tiny cap + ); + let cx = cx_with(app, Some(owner())); + let err = files + .create(&cx, "assets", new_file("big", b"123456789")) + .await + .unwrap_err(); + assert!(matches!(err, GroupFilesError::TooLarge { limit: 8, .. })); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 54dc00c..fde4759 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -54,6 +54,7 @@ pub mod group_collection_repo; pub mod group_docs_repo; pub mod group_docs_service; pub mod group_files_repo; +pub mod group_files_service; pub mod group_kv_repo; pub mod group_kv_service; pub mod group_members_repo; @@ -186,6 +187,7 @@ pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollection pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo}; pub use group_docs_service::GroupDocsServiceImpl; pub use group_files_repo::{FsGroupFilesRepo, GroupFilesRepo, GroupFilesRepoError}; +pub use group_files_service::GroupFilesServiceImpl; pub use group_kv_repo::{GroupKvRepo, GroupKvRepoError, PostgresGroupKvRepo}; pub use group_kv_service::GroupKvServiceImpl; pub use group_members_repo::{ diff --git a/crates/picloud/src/lib.rs b/crates/picloud/src/lib.rs index 4c9f952..4c2b5b1 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -19,10 +19,10 @@ use picloud_manager_core::{ AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, - FilesServiceImpl, FsFilesRepo, GroupDocsServiceImpl, GroupKvServiceImpl, - GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, - InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, - PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + FilesServiceImpl, FsFilesRepo, FsGroupFilesRepo, GroupDocsServiceImpl, GroupFilesServiceImpl, + GroupKvServiceImpl, GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, + HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, + OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, @@ -214,6 +214,16 @@ pub async fn build_app( events.clone(), files_max_size, )); + // §11.6 shared group collections (files): group-keyed blobs under + // `/files/groups//...`, resolved from cx.app_id's chain. + // Shares the per-file size cap; no events (no app to attribute a trigger to). + let group_files: Arc = + Arc::new(GroupFilesServiceImpl::new( + Arc::new(FsGroupFilesRepo::new(pool.clone(), files_root.clone())), + Arc::new(PostgresGroupCollectionResolver::new(pool.clone())), + authz.clone(), + files_max_size, + )); // v1.1.6 realtime: the in-process broadcaster is shared between the // publish path (PubsubServiceImpl fans out to SSE subscribers after // the durable outbox fan-out) and the SSE endpoint (subscribe side). @@ -360,7 +370,8 @@ pub async fn build_app( vars, ) .with_group_kv(group_kv) - .with_group_docs(group_docs); + .with_group_docs(group_docs) + .with_group_files(group_files); // v1.1.9: keep the invoke depth bound aligned with the dispatcher's // trigger-depth bound (same counter under the hood). let engine_limits = Limits { diff --git a/crates/shared/src/group_files.rs b/crates/shared/src/group_files.rs new file mode 100644 index 0000000..8b5f03d --- /dev/null +++ b/crates/shared/src/group_files.rs @@ -0,0 +1,203 @@ +//! `GroupFilesService` — the §11.6 shared group-collection FILES contract. +//! +//! The cross-app-sharing counterpart to [`crate::FilesService`], analogous to +//! [`crate::GroupKvService`] / [`crate::GroupDocsService`]. A script reaches it +//! via the explicit `files::shared_collection("name")` handle. The owner of the +//! data is not `cx.app_id`: the impl resolves the **owning group** by walking the +//! calling app's ancestor chain for the nearest group that declares the +//! collection shared with `kind='files'`. That ancestry walk is the isolation +//! boundary — the trait surface takes **no** group id (owner derivation stays in +//! the impl). +//! +//! Trust model (§11.6): **reads are open** to any subtree script (anonymous +//! public HTTP included); **writes require an authenticated principal** with +//! editor+ on the owning group. Same shape as group KV/docs. + +use async_trait::async_trait; +use thiserror::Error; +use uuid::Uuid; + +use crate::{FileMeta, FileUpdate, FilesListPage, NewFile, SdkCallCx}; + +#[async_trait] +pub trait GroupFilesService: Send + Sync { + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + new: NewFile, + ) -> Result; + + async fn head( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result, GroupFilesError>; + + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result>, GroupFilesError>; + + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + upd: FileUpdate, + ) -> Result<(), GroupFilesError>; + + async fn delete( + &self, + cx: &SdkCallCx, + collection: &str, + id: &str, + ) -> Result; + + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result; +} + +/// Stub for test bundles that don't exercise shared files collections. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopGroupFilesService; + +#[async_trait] +impl GroupFilesService for NoopGroupFilesService { + async fn create( + &self, + _cx: &SdkCallCx, + _collection: &str, + _new: NewFile, + ) -> Result { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } + + async fn head( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: &str, + ) -> Result, GroupFilesError> { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } + + async fn get( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: &str, + ) -> Result>, GroupFilesError> { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } + + async fn update( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: &str, + _upd: FileUpdate, + ) -> Result<(), GroupFilesError> { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } + + async fn delete( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: &str, + ) -> Result { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } + + async fn list( + &self, + _cx: &SdkCallCx, + _collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + Err(GroupFilesError::Backend( + "group files is not wired in".into(), + )) + } +} + +/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::FilesError`] plus +/// the §11.6 `CollectionNotShared` boundary. +#[derive(Debug, Error)] +pub enum GroupFilesError { + #[error("invalid collection name: {0}")] + InvalidCollection(String), + + /// No group on the calling app's ancestor chain declares this collection + /// shared with `kind='files'` — the structural "not shared with you" + /// boundary (also the outcome for a foreign app). + #[error("collection {0:?} is not a shared group files collection for this app")] + CollectionNotShared(String), + + #[error("missing required field: {0}")] + MissingField(&'static str), + + #[error("file too large: {size} bytes exceeds limit of {limit} bytes")] + TooLarge { size: usize, limit: usize }, + + #[error("file name too long: {0} bytes exceeds 255")] + NameTooLong(usize), + + #[error("content_type too long: {0} bytes exceeds 127")] + ContentTypeTooLong(usize), + + #[error("file not found")] + NotFound, + + #[error("file content corrupted (checksum mismatch)")] + Corrupted, + + /// For reads this is only raised when `cx.principal.is_some()`; for WRITES + /// it is also raised when the principal is absent (writes fail closed). + #[error("forbidden")] + Forbidden, + + #[error("group files backend error: {0}")] + Backend(String), +} + +/// Map the shared [`crate::FilesError`] — returned by `validate_collection` +/// (re-exported as `validate_files_collection`) and the `NewFile`/`FileUpdate` +/// validators that the group-files service reuses — onto the group error. Lives +/// here (not in manager-core) so the orphan rule is satisfied. +impl From for GroupFilesError { + fn from(e: crate::FilesError) -> Self { + use crate::FilesError as F; + match e { + F::InvalidCollection(c) => Self::InvalidCollection(c), + F::MissingField(f) => Self::MissingField(f), + F::TooLarge { size, limit } => Self::TooLarge { size, limit }, + F::NameTooLong(n) => Self::NameTooLong(n), + F::ContentTypeTooLong(n) => Self::ContentTypeTooLong(n), + F::NotFound => Self::NotFound, + F::Corrupted => Self::Corrupted, + F::Forbidden => Self::Forbidden, + F::Backend(s) => Self::Backend(s), + } + } +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index de1c99b..924af55 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -17,6 +17,7 @@ pub mod execution_log; pub mod files; pub mod group; pub mod group_docs; +pub mod group_files; pub mod group_kv; pub mod http; pub mod ids; @@ -68,6 +69,7 @@ pub use files::{ }; pub use group::Group; pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService}; +pub use group_files::{GroupFilesError, GroupFilesService, NoopGroupFilesService}; pub use group_kv::{GroupKvError, GroupKvService, NoopGroupKvService}; pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService}; pub use ids::{ diff --git a/crates/shared/src/services.rs b/crates/shared/src/services.rs index 3c3ae16..e0f17de 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -20,12 +20,13 @@ use std::sync::Arc; use crate::{ - DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, GroupKvService, - HttpService, InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, - NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupDocsService, NoopGroupKvService, - NoopHttpService, NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, - NoopQueueService, NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, - QueueService, SecretsService, ServiceEventEmitter, UsersService, VarsService, + DeadLetterService, DocsService, EmailService, FilesService, GroupDocsService, + GroupFilesService, GroupKvService, HttpService, InvokeService, KvService, ModuleSource, + NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter, NoopFilesService, + NoopGroupDocsService, NoopGroupFilesService, NoopGroupKvService, NoopHttpService, + NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, + NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService, + SecretsService, ServiceEventEmitter, UsersService, VarsService, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -127,6 +128,11 @@ pub struct Services { /// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`]; /// defaults to `NoopGroupDocsService`. pub group_docs: Arc, + + /// Shared cross-app group FILES collections (§11.6). Scripts get + /// `files::shared_collection(name)`. Wired via [`Services::with_group_files`]; + /// defaults to `NoopGroupFilesService`. + pub group_files: Arc, } impl Services { @@ -171,6 +177,7 @@ impl Services { // (mostly tests) unchanged — a field addition, not a param. group_kv: Arc::new(NoopGroupKvService), group_docs: Arc::new(NoopGroupDocsService), + group_files: Arc::new(NoopGroupFilesService), } } @@ -189,6 +196,13 @@ impl Services { self } + /// Set the §11.6 shared group-FILES service (picloud binary; tests leave noop). + #[must_use] + pub fn with_group_files(mut self, group_files: Arc) -> Self { + self.group_files = group_files; + self + } + /// All-noop bundle for tests that build an `Engine` but don't /// exercise the stateful services. Returns the same shape as /// `Services::new` so callers can't accidentally rely on a stub