From 78a468de8348cb13346dff38dc5498a95939fa03 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Tue, 30 Jun 2026 08:04:30 +0200 Subject: [PATCH] =?UTF-8?q?feat(modules):=20GroupDocsService=20+=20docs::s?= =?UTF-8?q?hared=5Fcollection=20handle=20(=C2=A711.6=20docs=20C3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The runtime for shared group docs collections, mirroring the group-KV vertical with the docs surface (filter parsing, JSON-object validation, value-size cap). - shared: GroupDocsService trait + GroupDocsError (+ CollectionNotShared) + NoopGroupDocsService; group_docs field on Services + with_group_docs(). - manager-core: GroupDocsServiceImpl — owning_group resolves kind='docs' from cx.app_id's chain (CollectionNotShared off-chain); reads-open (script_gate GroupDocsRead) / writes-authed (script_gate_require_principal GroupDocsWrite); reuses parse_filter + docs value-size cap; no events. Unit tests cover off-chain CollectionNotShared, anon-read-OK/anon-write-Forbidden, non-object data rejected. - executor-core: GroupDocsHandle + docs::shared_collection constructor + the create/get/find/find_one/update/delete/list methods (dispatch by receiver type), reusing doc_to_map/parse_doc_id. - picloud: wire PostgresGroupDocsRepo + GroupDocsServiceImpl. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/executor-core/src/sdk/docs.rs | 189 ++++++- crates/manager-core/src/group_docs_service.rs | 483 ++++++++++++++++++ crates/manager-core/src/lib.rs | 2 + crates/picloud/src/lib.rs | 30 +- crates/shared/src/group_docs.rs | 181 +++++++ crates/shared/src/lib.rs | 2 + crates/shared/src/services.rs | 29 +- 7 files changed, 897 insertions(+), 19 deletions(-) create mode 100644 crates/manager-core/src/group_docs_service.rs create mode 100644 crates/shared/src/group_docs.rs diff --git a/crates/executor-core/src/sdk/docs.rs b/crates/executor-core/src/sdk/docs.rs index 6a7c087..cc9ea94 100644 --- a/crates/executor-core/src/sdk/docs.rs +++ b/crates/executor-core/src/sdk/docs.rs @@ -23,7 +23,7 @@ use std::sync::Arc; -use picloud_shared::{DocId, DocRow, DocsService, SdkCallCx, Services}; +use picloud_shared::{DocId, DocRow, DocsService, GroupDocsService, SdkCallCx, Services}; use rhai::{Array, Dynamic, Engine as RhaiEngine, EvalAltResult, Map, Module}; use uuid::Uuid; @@ -38,8 +38,20 @@ pub struct DocsHandle { cx: Arc, } +/// §11.6 shared-collection handle, returned by `docs::shared_collection(name)`. +/// A distinct Rhai type from `DocsHandle` so a script's choice of +/// private-vs-shared scope is explicit. Routes through `GroupDocsService`, which +/// resolves the owning group from `cx.app_id` (the script never names a group). +#[derive(Clone)] +pub struct GroupDocsHandle { + collection: String, + service: Arc, + cx: Arc, +} + pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc) { let docs_service = services.docs.clone(); + let group_docs_service = services.group_docs.clone(); let mut module = Module::new(); { @@ -59,6 +71,23 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc Result> { + if name.is_empty() { + return Err("docs::shared_collection name must not be empty".into()); + } + Ok(GroupDocsHandle { + collection: name.to_string(), + service: group_docs_service.clone(), + cx: cx.clone(), + }) + }, + ); + } engine.register_static_module("docs", module.into()); engine.register_type_with_name::("DocsHandle"); @@ -70,6 +99,17 @@ pub(super) fn register(engine: &mut RhaiEngine, services: &Services, cx: Arc("GroupDocsHandle"); + + register_group_create(engine); + register_group_get(engine); + register_group_find(engine); + register_group_find_one(engine); + register_group_update(engine); + register_group_delete(engine); + register_group_list(engine); } fn register_create(engine: &mut RhaiEngine) { @@ -218,6 +258,153 @@ fn list_call( Ok(m) } +// --- GroupDocsHandle methods (§11.6 shared docs collections) --------------- + +fn register_group_create(engine: &mut RhaiEngine) { + engine.register_fn( + "create", + |handle: &mut GroupDocsHandle, data: Map| -> Result> { + let h = handle.clone(); + let json = dynamic_to_json(&Dynamic::from(data)); + let id = block_on("docs", async move { + h.service.create(&h.cx, &h.collection, json).await + })?; + Ok(id.to_string()) + }, + ); +} + +fn register_group_get(engine: &mut RhaiEngine) { + engine.register_fn( + "get", + |handle: &mut GroupDocsHandle, id: &str| -> Result> { + let h = handle.clone(); + let parsed_id = parse_doc_id(id)?; + let row = block_on("docs", async move { + h.service.get(&h.cx, &h.collection, parsed_id).await + })?; + Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d)))) + }, + ); +} + +fn register_group_find(engine: &mut RhaiEngine) { + engine.register_fn( + "find", + |handle: &mut GroupDocsHandle, filter: Map| -> Result> { + let h = handle.clone(); + let json = dynamic_to_json(&Dynamic::from(filter)); + let rows = block_on("docs", async move { + h.service.find(&h.cx, &h.collection, json).await + })?; + Ok(rows + .iter() + .map(|d| Dynamic::from(doc_to_map(d))) + .collect::>()) + }, + ); +} + +fn register_group_find_one(engine: &mut RhaiEngine) { + engine.register_fn( + "find_one", + |handle: &mut GroupDocsHandle, filter: Map| -> Result> { + let h = handle.clone(); + let json = dynamic_to_json(&Dynamic::from(filter)); + let row = block_on("docs", async move { + h.service.find_one(&h.cx, &h.collection, json).await + })?; + Ok(row.map_or(Dynamic::UNIT, |d| Dynamic::from(doc_to_map(&d)))) + }, + ); +} + +fn register_group_update(engine: &mut RhaiEngine) { + engine.register_fn( + "update", + |handle: &mut GroupDocsHandle, id: &str, data: Map| -> Result<(), Box> { + let h = handle.clone(); + let parsed_id = parse_doc_id(id)?; + let json = dynamic_to_json(&Dynamic::from(data)); + block_on("docs", async move { + h.service + .update(&h.cx, &h.collection, parsed_id, json) + .await + }) + }, + ); +} + +fn register_group_delete(engine: &mut RhaiEngine) { + engine.register_fn( + "delete", + |handle: &mut GroupDocsHandle, id: &str| -> Result> { + let h = handle.clone(); + let parsed_id = parse_doc_id(id)?; + block_on("docs", async move { + h.service.delete(&h.cx, &h.collection, parsed_id).await + }) + }, + ); +} + +fn register_group_list(engine: &mut RhaiEngine) { + engine.register_fn( + "list", + |handle: &mut GroupDocsHandle| -> Result> { + group_list_call(handle, None, 0) + }, + ); + engine.register_fn( + "list", + |handle: &mut GroupDocsHandle, args: Map| -> Result> { + let cursor = match args.get("cursor") { + Some(d) if !d.is_unit() => { + Some(d.clone().into_string().map_err(|_| -> Box { + "docs::list: 'cursor' must be a string or ()".into() + })?) + } + _ => None, + }; + let limit = match args.get("limit") { + Some(d) if !d.is_unit() => { + let n = d.as_int().map_err(|_| -> Box { + "docs::list: 'limit' must be an integer".into() + })?; + u32::try_from(n.max(0)).unwrap_or(0) + } + _ => 0, + }; + group_list_call(handle, cursor, limit) + }, + ); +} + +fn group_list_call( + handle: &GroupDocsHandle, + cursor: Option, + limit: u32, +) -> Result> { + let h = handle.clone(); + let page = block_on("docs", async move { + h.service + .list(&h.cx, &h.collection, cursor.as_deref(), limit) + .await + })?; + let mut m = Map::new(); + let docs: Array = page + .docs + .iter() + .map(|d| Dynamic::from(doc_to_map(d))) + .collect(); + m.insert("docs".into(), docs.into()); + m.insert( + "next_cursor".into(), + page.next_cursor.map_or(Dynamic::UNIT, Dynamic::from), + ); + Ok(m) +} + /// Build the `{ id, data, created_at, updated_at }` envelope per /// Decision D. Scripts read user fields via `doc.data.`; `id` /// and timestamps are direct children of the envelope. diff --git a/crates/manager-core/src/group_docs_service.rs b/crates/manager-core/src/group_docs_service.rs new file mode 100644 index 0000000..35e9f2c --- /dev/null +++ b/crates/manager-core/src/group_docs_service.rs @@ -0,0 +1,483 @@ +//! `GroupDocsServiceImpl` — wires `GroupDocsRepo` + the group-collection +//! registry underneath the `picloud_shared::GroupDocsService` trait that scripts +//! reach via the `docs::shared_collection("name")` Rhai handle (§11.6). +//! +//! Combines the group-KV service pattern (owner resolution from `cx.app_id`, +//! reads-open / writes-authed authz) with the docs surface (filter parsing, +//! JSON-object validation, value-size cap). 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::{ + DocId, DocRow, DocsListPage, GroupDocsError, GroupDocsService, GroupId, SdkCallCx, +}; + +use crate::authz::{self, AuthzRepo, Capability}; +use crate::docs_filter::{parse_filter, FilterParseError}; +use crate::docs_service::docs_max_value_bytes_from_env; +use crate::group_collection_repo::GroupCollectionResolver; +use crate::group_docs_repo::{GroupDocsRepo, GroupDocsRepoError}; + +/// The registry `kind` this service resolves. +const KIND_DOCS: &str = "docs"; + +pub struct GroupDocsServiceImpl { + repo: Arc, + resolver: Arc, + authz: Arc, + max_value_bytes: usize, +} + +impl GroupDocsServiceImpl { + #[must_use] + pub fn new( + repo: Arc, + resolver: Arc, + authz: Arc, + ) -> Self { + Self::with_max_value_bytes(repo, resolver, authz, docs_max_value_bytes_from_env()) + } + + #[must_use] + pub fn with_max_value_bytes( + repo: Arc, + resolver: Arc, + authz: Arc, + max_value_bytes: usize, + ) -> Self { + Self { + repo, + resolver, + authz, + max_value_bytes, + } + } + + /// The structural boundary: resolve the collection to its owning group + /// (kind=`docs`) on the calling app's chain. `CollectionNotShared` when no + /// ancestor group declares it. + async fn owning_group( + &self, + cx: &SdkCallCx, + collection: &str, + ) -> Result { + if collection.is_empty() { + return Err(GroupDocsError::InvalidCollection); + } + self.resolver + .resolve_owning_group(cx.app_id, collection, KIND_DOCS) + .await + .map_err(|e| GroupDocsError::Backend(e.to_string()))? + .ok_or_else(|| GroupDocsError::CollectionNotShared(collection.to_string())) + } + + fn check_data_size(&self, data: &serde_json::Value) -> Result<(), GroupDocsError> { + let encoded_len = serde_json::to_vec(data) + .map(|v| v.len()) + .map_err(|e| GroupDocsError::Backend(format!("encode doc data: {e}")))?; + if encoded_len > self.max_value_bytes { + return Err(GroupDocsError::ValueTooLarge { + limit: self.max_value_bytes, + actual: encoded_len, + }); + } + Ok(()) + } + + async fn check_read(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> { + authz::script_gate( + &*self.authz, + cx, + Capability::GroupDocsRead(group_id), + || GroupDocsError::Forbidden, + GroupDocsError::Backend, + ) + .await + } + + async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupDocsError> { + // 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::GroupDocsWrite(group_id), + || GroupDocsError::Forbidden, + GroupDocsError::Backend, + ) + .await + } +} + +fn validate_data(data: &serde_json::Value) -> Result<(), GroupDocsError> { + if !data.is_object() { + return Err(GroupDocsError::InvalidData); + } + Ok(()) +} + +impl From for GroupDocsError { + fn from(e: GroupDocsRepoError) -> Self { + Self::Backend(e.to_string()) + } +} + +impl From for GroupDocsError { + fn from(e: FilterParseError) -> Self { + match e { + FilterParseError::InvalidFilter(s) => Self::InvalidFilter(s), + FilterParseError::UnsupportedOperator(s) => Self::UnsupportedOperator(s), + } + } +} + +#[async_trait] +impl GroupDocsService for GroupDocsServiceImpl { + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + data: serde_json::Value, + ) -> Result { + let group_id = self.owning_group(cx, collection).await?; + validate_data(&data)?; + self.check_data_size(&data)?; + self.check_write(cx, group_id).await?; + Ok(self.repo.create(group_id, collection, data).await?.id) + } + + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result, GroupDocsError> { + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + Ok(self.repo.get(group_id, collection, id).await?) + } + + async fn find( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, GroupDocsError> { + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + let parsed = parse_filter(&filter)?; + Ok(self.repo.find(group_id, collection, &parsed).await?) + } + + async fn find_one( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, GroupDocsError> { + let group_id = self.owning_group(cx, collection).await?; + self.check_read(cx, group_id).await?; + let mut parsed = parse_filter(&filter)?; + if parsed.limit.is_none() { + parsed.limit = Some(1); + } + let rows = self.repo.find(group_id, collection, &parsed).await?; + Ok(rows.into_iter().next()) + } + + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + data: serde_json::Value, + ) -> Result<(), GroupDocsError> { + let group_id = self.owning_group(cx, collection).await?; + validate_data(&data)?; + self.check_data_size(&data)?; + self.check_write(cx, group_id).await?; + match self.repo.update(group_id, collection, id, data).await? { + Some(_) => Ok(()), + None => Err(GroupDocsError::NotFound), + } + } + + async fn delete( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result { + let group_id = self.owning_group(cx, collection).await?; + self.check_write(cx, group_id).await?; + Ok(self.repo.delete(group_id, collection, id).await?.is_some()) + } + + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result { + 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. +// ---------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::authz::{AuthzError, AuthzRepo}; + use crate::docs_filter::DocsFilter; + use chrono::Utc; + use picloud_shared::{ + AdminUserId, AppId, AppRole, ExecutionId, InstanceRole, Principal, RequestId, ScriptId, + UserId, + }; + use std::collections::HashMap; + use tokio::sync::Mutex; + use uuid::Uuid; + + #[derive(Default)] + struct InMemoryGroupDocsRepo { + // (group, collection) -> id -> data + data: Mutex>>, + } + + fn row(id: Uuid, data: serde_json::Value) -> DocRow { + DocRow { + id, + data, + created_at: Utc::now(), + updated_at: Utc::now(), + } + } + + #[async_trait] + impl GroupDocsRepo for InMemoryGroupDocsRepo { + async fn create( + &self, + group_id: GroupId, + collection: &str, + data: serde_json::Value, + ) -> Result { + let id = Uuid::new_v4(); + self.data + .lock() + .await + .entry((group_id, collection.to_string())) + .or_default() + .insert(id, data.clone()); + Ok(row(id, data)) + } + + async fn get( + &self, + group_id: GroupId, + collection: &str, + id: DocId, + ) -> Result, GroupDocsRepoError> { + Ok(self + .data + .lock() + .await + .get(&(group_id, collection.to_string())) + .and_then(|m| m.get(&id).cloned()) + .map(|d| row(id, d))) + } + + // The fake ignores the filter (filter SQL is covered by docs_repo tests + // + the journey); returns all docs in the collection. + async fn find( + &self, + group_id: GroupId, + collection: &str, + _filter: &DocsFilter, + ) -> Result, GroupDocsRepoError> { + Ok(self + .data + .lock() + .await + .get(&(group_id, collection.to_string())) + .map(|m| m.iter().map(|(id, d)| row(*id, d.clone())).collect()) + .unwrap_or_default()) + } + + async fn update( + &self, + group_id: GroupId, + collection: &str, + id: DocId, + data: serde_json::Value, + ) -> Result, GroupDocsRepoError> { + let mut guard = self.data.lock().await; + let coll = guard.entry((group_id, collection.to_string())).or_default(); + Ok(coll.insert(id, data)) + } + + async fn delete( + &self, + group_id: GroupId, + collection: &str, + id: DocId, + ) -> Result, GroupDocsRepoError> { + Ok(self + .data + .lock() + .await + .get_mut(&(group_id, collection.to_string())) + .and_then(|m| m.remove(&id))) + } + + async fn list( + &self, + _group_id: GroupId, + _collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + Ok(DocsListPage { + docs: vec![], + 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) -> GroupDocsServiceImpl { + GroupDocsServiceImpl::new( + Arc::new(InMemoryGroupDocsRepo::default()), + Arc::new(resolver), + Arc::new(DenyingAuthzRepo), + ) + } + + #[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, "articles".into()), group); + let docs = svc(resolver); + + let cx_a = cx_with(app_a, Some(owner())); + let id = docs + .create(&cx_a, "articles", serde_json::json!({"t": "hi"})) + .await + .unwrap(); + assert!(docs.get(&cx_a, "articles", id).await.unwrap().is_some()); + + let cx_b = cx_with(app_b, Some(owner())); + let err = docs + .find(&cx_b, "articles", serde_json::json!({})) + .await + .unwrap_err(); + assert!(matches!(err, GroupDocsError::CollectionNotShared(c) if c == "articles")); + } + + #[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, "articles".into()), group); + let docs = svc(resolver); + + let owner_cx = cx_with(app, Some(owner())); + docs.create(&owner_cx, "articles", serde_json::json!({"t": "hi"})) + .await + .unwrap(); + + // Anonymous READ (find) is allowed. + let anon = cx_with(app, None); + let hits = docs + .find(&anon, "articles", serde_json::json!({})) + .await + .unwrap(); + assert_eq!(hits.len(), 1); + + // Anonymous WRITE (create) fails closed. + let err = docs + .create(&anon, "articles", serde_json::json!({"t": "x"})) + .await + .unwrap_err(); + assert!(matches!(err, GroupDocsError::Forbidden)); + } + + #[tokio::test] + async fn non_object_data_rejected() { + let app = AppId::new(); + let group = GroupId::new(); + let mut resolver = FakeResolver::default(); + resolver.map.insert((app, "articles".into()), group); + let docs = svc(resolver); + let cx = cx_with(app, Some(owner())); + let err = docs + .create(&cx, "articles", serde_json::json!("not-an-object")) + .await + .unwrap_err(); + assert!(matches!(err, GroupDocsError::InvalidData)); + } +} diff --git a/crates/manager-core/src/lib.rs b/crates/manager-core/src/lib.rs index 7bf9707..1e0bce8 100644 --- a/crates/manager-core/src/lib.rs +++ b/crates/manager-core/src/lib.rs @@ -52,6 +52,7 @@ pub mod files_sweep; pub mod gc; pub mod group_collection_repo; pub mod group_docs_repo; +pub mod group_docs_service; pub mod group_kv_repo; pub mod group_kv_service; pub mod group_members_repo; @@ -182,6 +183,7 @@ pub use files_sweep::{spawn_files_orphan_sweep, sweep_orphan_tmp_files, SweepSta pub use gc::{spawn_abandoned_gc, spawn_app_user_token_gc, spawn_dead_letter_gc}; pub use group_collection_repo::{GroupCollectionResolver, PostgresGroupCollectionResolver}; pub use group_docs_repo::{GroupDocsRepo, GroupDocsRepoError, PostgresGroupDocsRepo}; +pub use group_docs_service::GroupDocsServiceImpl; 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 d047a64..4c9f952 100644 --- a/crates/picloud/src/lib.rs +++ b/crates/picloud/src/lib.rs @@ -19,15 +19,16 @@ use picloud_manager_core::{ AppDomainRepository, AppMembersRepository, AppMembersState, AppRepository, ApplyService, AppsState, AuthState, AuthzRepo, DeadLetterRepo, DeadLettersState, DevEmailState, Dispatcher, DocsServiceImpl, EmailInboundState, EmailServiceImpl, FilesAdminState, FilesConfig, - FilesServiceImpl, FsFilesRepo, GroupKvServiceImpl, GroupMembersRepository, GroupRepository, - GroupsState, HttpConfig, HttpServiceImpl, InboundNonceDedup, KvAdminState, KvServiceImpl, - OutboxEventEmitter, OutboxRepo, PostgresAbandonedRepo, PostgresAdminSessionRepository, - PostgresAdminUserRepository, PostgresApiKeyRepository, PostgresAppDomainRepository, - PostgresAppMembersRepository, PostgresAppRepository, PostgresAppSecretsRepo, - PostgresAppUserInvitationRepo, PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, - PostgresAppUserRoleRepo, PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, - PostgresDeadLetterRepo, PostgresDeadLetterService, PostgresDocsRepo, - PostgresExecutionLogRepository, PostgresExecutionLogSink, PostgresGroupCollectionResolver, + FilesServiceImpl, FsFilesRepo, GroupDocsServiceImpl, GroupKvServiceImpl, + GroupMembersRepository, GroupRepository, GroupsState, HttpConfig, HttpServiceImpl, + InboundNonceDedup, KvAdminState, KvServiceImpl, OutboxEventEmitter, OutboxRepo, + PostgresAbandonedRepo, PostgresAdminSessionRepository, PostgresAdminUserRepository, + PostgresApiKeyRepository, PostgresAppDomainRepository, PostgresAppMembersRepository, + PostgresAppRepository, PostgresAppSecretsRepo, PostgresAppUserInvitationRepo, + PostgresAppUserPasswordResetRepo, PostgresAppUserRepository, PostgresAppUserRoleRepo, + PostgresAppUserSessionRepository, PostgresAppUserVerificationRepo, PostgresDeadLetterRepo, + PostgresDeadLetterService, PostgresDocsRepo, PostgresExecutionLogRepository, + PostgresExecutionLogSink, PostgresGroupCollectionResolver, PostgresGroupDocsRepo, PostgresGroupKvRepo, PostgresGroupMembersRepository, PostgresGroupRepository, PostgresKvRepo, PostgresOutboxRepo, PostgresPubsubRepo, PostgresRouteRepository, PostgresScriptRepository, PostgresSecretsRepo, PostgresTopicRepo, PostgresTriggerRepo, PostgresVarsRepo, @@ -167,6 +168,14 @@ pub async fn build_app( authz.clone(), picloud_manager_core::kv_service::kv_max_value_bytes_from_env(), )); + // §11.6 shared group collections (docs): group-keyed group_docs store. + let group_docs: Arc = + Arc::new(GroupDocsServiceImpl::with_max_value_bytes( + Arc::new(PostgresGroupDocsRepo::new(pool.clone())), + Arc::new(PostgresGroupCollectionResolver::new(pool.clone())), + authz.clone(), + picloud_manager_core::docs_service::docs_max_value_bytes_from_env(), + )); let docs: Arc = Arc::new(DocsServiceImpl::with_max_value_bytes( docs_repo, authz.clone(), @@ -350,7 +359,8 @@ pub async fn build_app( invoke, vars, ) - .with_group_kv(group_kv); + .with_group_kv(group_kv) + .with_group_docs(group_docs); // 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_docs.rs b/crates/shared/src/group_docs.rs new file mode 100644 index 0000000..6af3c24 --- /dev/null +++ b/crates/shared/src/group_docs.rs @@ -0,0 +1,181 @@ +//! `GroupDocsService` — the §11.6 shared group-collection DOCS contract. +//! +//! The cross-app-sharing counterpart to [`crate::DocsService`], analogous to +//! [`crate::GroupKvService`]. A script reaches it via the explicit +//! `docs::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='docs'`. 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. + +use async_trait::async_trait; +use thiserror::Error; + +use crate::{DocId, DocRow, DocsListPage, SdkCallCx}; + +#[async_trait] +pub trait GroupDocsService: Send + Sync { + async fn create( + &self, + cx: &SdkCallCx, + collection: &str, + data: serde_json::Value, + ) -> Result; + + async fn get( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result, GroupDocsError>; + + async fn find( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, GroupDocsError>; + + async fn find_one( + &self, + cx: &SdkCallCx, + collection: &str, + filter: serde_json::Value, + ) -> Result, GroupDocsError>; + + async fn update( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + data: serde_json::Value, + ) -> Result<(), GroupDocsError>; + + async fn delete( + &self, + cx: &SdkCallCx, + collection: &str, + id: DocId, + ) -> Result; + + async fn list( + &self, + cx: &SdkCallCx, + collection: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result; +} + +/// Stub for test bundles that don't exercise shared docs collections. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopGroupDocsService; + +#[async_trait] +impl GroupDocsService for NoopGroupDocsService { + async fn create( + &self, + _cx: &SdkCallCx, + _collection: &str, + _data: serde_json::Value, + ) -> Result { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn get( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + ) -> Result, GroupDocsError> { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn find( + &self, + _cx: &SdkCallCx, + _collection: &str, + _filter: serde_json::Value, + ) -> Result, GroupDocsError> { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn find_one( + &self, + _cx: &SdkCallCx, + _collection: &str, + _filter: serde_json::Value, + ) -> Result, GroupDocsError> { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn update( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + _data: serde_json::Value, + ) -> Result<(), GroupDocsError> { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn delete( + &self, + _cx: &SdkCallCx, + _collection: &str, + _id: DocId, + ) -> Result { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } + + async fn list( + &self, + _cx: &SdkCallCx, + _collection: &str, + _cursor: Option<&str>, + _limit: u32, + ) -> Result { + Err(GroupDocsError::Backend("group docs is not wired in".into())) + } +} + +/// Failure modes surfaced to the Rhai bridge. Mirrors [`crate::DocsError`] plus +/// the §11.6 `CollectionNotShared` boundary. +#[derive(Debug, Error)] +pub enum GroupDocsError { + #[error("collection name must not be empty")] + InvalidCollection, + + /// No group on the calling app's ancestor chain declares this collection + /// shared with `kind='docs'` — the structural "not shared with you" + /// boundary (also the outcome for a foreign app). + #[error("collection {0:?} is not a shared group docs collection for this app")] + CollectionNotShared(String), + + #[error("document data must be a JSON object")] + InvalidData, + + #[error("invalid filter: {0}")] + InvalidFilter(String), + + #[error("unsupported operator: {0}")] + UnsupportedOperator(String), + + #[error("document not found")] + NotFound, + + /// 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 docs: data too large ({actual} bytes; limit {limit})")] + ValueTooLarge { limit: usize, actual: usize }, + + #[error("group docs backend error: {0}")] + Backend(String), +} diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index 3e2eb85..de1c99b 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -16,6 +16,7 @@ pub mod exec_summary; pub mod execution_log; pub mod files; pub mod group; +pub mod group_docs; pub mod group_kv; pub mod http; pub mod ids; @@ -66,6 +67,7 @@ pub use files::{ SAFE_RENDER_FALLBACK, }; pub use group::Group; +pub use group_docs::{GroupDocsError, GroupDocsService, NoopGroupDocsService}; 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 5e29335..3c3ae16 100644 --- a/crates/shared/src/services.rs +++ b/crates/shared/src/services.rs @@ -20,12 +20,12 @@ use std::sync::Arc; use crate::{ - DeadLetterService, DocsService, EmailService, FilesService, GroupKvService, HttpService, - InvokeService, KvService, ModuleSource, NoopDeadLetterService, NoopDocsService, - NoopEmailService, NoopEventEmitter, NoopFilesService, NoopGroupKvService, NoopHttpService, - NoopInvokeService, NoopKvService, NoopModuleSource, NoopPubsubService, NoopQueueService, - NoopSecretsService, NoopUsersService, NoopVarsService, PubsubService, QueueService, - SecretsService, ServiceEventEmitter, UsersService, VarsService, + 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, }; /// SDK service bundle. See module docs for the lifecycle and the v1.1.x @@ -122,6 +122,11 @@ pub struct Services { /// `NoopGroupKvService` so test bundles that don't exercise sharing build /// unchanged. pub group_kv: Arc, + + /// Shared cross-app group DOCS collections (§11.6). Scripts get + /// `docs::shared_collection(name)`. Wired via [`Services::with_group_docs`]; + /// defaults to `NoopGroupDocsService`. + pub group_docs: Arc, } impl Services { @@ -165,17 +170,25 @@ impl Services { // in via `with_group_kv`. Keeps the ~14 `Services::new` call sites // (mostly tests) unchanged — a field addition, not a param. group_kv: Arc::new(NoopGroupKvService), + group_docs: Arc::new(NoopGroupDocsService), } } - /// Set the §11.6 shared group-collection service (the picloud binary wires - /// the Postgres-backed impl; tests leave the noop default). + /// Set the §11.6 shared group-KV service (the picloud binary wires the + /// Postgres-backed impl; tests leave the noop default). #[must_use] pub fn with_group_kv(mut self, group_kv: Arc) -> Self { self.group_kv = group_kv; self } + /// Set the §11.6 shared group-DOCS service (picloud binary; tests leave noop). + #[must_use] + pub fn with_group_docs(mut self, group_docs: Arc) -> Self { + self.group_docs = group_docs; + 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