//! `GroupPubsubServiceImpl` — wires the group-collection registry + the pubsub //! fan-out repo underneath the `picloud_shared::GroupPubsubService` trait that //! scripts reach via the `pubsub::shared_topic("name")` Rhai handle (§11.6 D2). //! //! A shared topic is storeless — a publish resolves the OWNING GROUP (the //! nearest ancestor group declaring the topic shared, kind `topic`), then fans //! out to that group's `shared = true` pubsub triggers, each delivery stamped //! with the WRITER `cx.app_id` so the handler runs under the publishing app //! (matching the M2 shared-collection trigger model). Owner resolution is the //! isolation boundary — a foreign app's chain never reaches the owning group. //! //! Trust model: a publish is a WRITE, so it uses `script_gate_require_principal` //! (anonymous fails closed — shared publish always needs an authenticated //! editor+ on the owning group). use std::sync::Arc; use async_trait::async_trait; use picloud_shared::{GroupId, GroupPubsubError, GroupPubsubService, SdkCallCx, TriggerEvent}; use crate::authz::{self, AuthzRepo, Capability}; use crate::group_collection_repo::GroupCollectionResolver; use crate::pubsub_repo::{PublishCtx, PubsubRepo}; use crate::pubsub_service::pubsub_max_message_bytes_from_env; /// The registry `kind` this service resolves. const KIND_TOPIC: &str = "topic"; pub struct GroupPubsubServiceImpl { repo: Arc, resolver: Arc, authz: Arc, max_message_bytes: usize, } impl GroupPubsubServiceImpl { #[must_use] pub fn new( repo: Arc, resolver: Arc, authz: Arc, ) -> Self { Self { repo, resolver, authz, max_message_bytes: pubsub_max_message_bytes_from_env(), } } /// The structural boundary: resolve the topic namespace to its owning group /// on the calling app's chain. `CollectionNotShared` when no ancestor group /// declares it — the same outcome a foreign app gets, by construction. async fn owning_group( &self, cx: &SdkCallCx, namespace: &str, ) -> Result { self.resolver .resolve_owning_group(cx.app_id, namespace, KIND_TOPIC) .await .map_err(|e| GroupPubsubError::Backend(e.to_string()))? .ok_or_else(|| GroupPubsubError::CollectionNotShared(namespace.to_string())) } async fn check_write(&self, cx: &SdkCallCx, group_id: GroupId) -> Result<(), GroupPubsubError> { authz::script_gate_require_principal( &*self.authz, cx, Capability::GroupPubsubPublish(group_id), || GroupPubsubError::Forbidden, GroupPubsubError::Backend, ) .await } } #[async_trait] impl GroupPubsubService for GroupPubsubServiceImpl { async fn publish( &self, cx: &SdkCallCx, namespace: &str, subtopic: &str, message: serde_json::Value, ) -> Result { if namespace.trim().is_empty() { return Err(GroupPubsubError::InvalidTopic); } // Size cap first (no DB) — a cheap reject before the resolve/authz work. let encoded_len = serde_json::to_vec(&message) .map(|v| v.len()) .map_err(|e| GroupPubsubError::Backend(format!("encode message: {e}")))?; if encoded_len > self.max_message_bytes { return Err(GroupPubsubError::MessageTooLarge { limit: self.max_message_bytes, actual: encoded_len, }); } let group_id = self.owning_group(cx, namespace).await?; self.check_write(cx, group_id).await?; // The full topic is `namespace` or `namespace.subtopic`; shared triggers // match it with the same `topic_matches` semantics as the per-app path. let topic = if subtopic.trim().is_empty() { namespace.to_string() } else { format!("{namespace}.{subtopic}") }; let event = TriggerEvent::Pubsub { topic: topic.clone(), message, published_at: chrono::Utc::now(), }; let payload = serde_json::to_value(&event) .map_err(|e| GroupPubsubError::Backend(format!("event serialize: {e}")))?; let publish_ctx = PublishCtx { app_id: cx.app_id, origin_principal: cx.principal.as_ref().map(|p| p.user_id), trigger_depth: cx.trigger_depth, root_execution_id: cx.root_execution_id, }; self.repo .fan_out_shared_publish(publish_ctx, group_id, &topic, payload) .await .map_err(|e| GroupPubsubError::Backend(e.to_string())) } }