feat(shared-topics): GroupPubsubService + shared publish fan-out + SDK handle (D2)

The runtime for shared topics:
- pubsub_repo: fan_out_publish gains `AND t.shared = FALSE` (a shared
  trigger never fires on a per-app publish); new fan_out_shared_publish
  matches `shared = true` pubsub triggers on the resolved owning group,
  each delivery stamped with the writer app_id (M2 model).
- GroupPubsubService trait (shared) + GroupPubsubServiceImpl: resolve the
  owning group (kind='topic') from cx.app_id's chain, require editor+
  (GroupPubsubPublish, fails closed for anon), size-cap, fan out.
- SDK: `pubsub::shared_topic("name")` -> GroupTopicHandle with
  `.publish(subtopic, msg)` / `.publish(msg)`; wired through Services +
  the picloud binary (reuses the one PubsubRepo).
- authz: Capability::GroupPubsubPublish(GroupId), editor+ / script:write.

The owning-group chain walk is the isolation boundary; a sibling-subtree
app never resolves the topic.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-02 21:48:59 +02:00
parent 1c7e8886d8
commit 9626d15863
10 changed files with 430 additions and 43 deletions

View File

@@ -0,0 +1,84 @@
//! `GroupPubsubService` — the §11.6 D2 shared group-TOPIC publish contract.
//!
//! The pub/sub counterpart to [`crate::GroupKvService`]. A script reaches it via
//! the explicit `pubsub::shared_topic("name")` handle (distinct from the app-
//! private `pubsub::publish_durable`). A shared topic is **storeless**: there is
//! no message log — a publish fans out to the `shared = true` pubsub triggers on
//! the OWNING GROUP (resolved by walking the calling app's ancestor chain for
//! the nearest group that declares the topic shared). That ancestry walk is the
//! isolation boundary — a foreign app's chain never contains the owning group,
//! so the topic does not resolve. The trait takes **no** group id (owner
//! derivation stays inside the impl), matching `GroupKvService`.
//!
//! Trust model (§11.6): a publish is a WRITE to shared state, so it requires an
//! authenticated principal with editor+ on the owning group (fails closed for
//! an anonymous caller), matching `GroupKvService` writes.
use async_trait::async_trait;
use thiserror::Error;
use crate::SdkCallCx;
#[async_trait]
pub trait GroupPubsubService: Send + Sync {
/// Publish `message` to the group shared topic `namespace` (optionally under
/// `subtopic`, so `("events", "created")` publishes `events.created`).
/// Resolves the owning group (kind `topic`) from `cx.app_id`'s chain,
/// requires editor+, and fans out to `shared = true` pubsub triggers on that
/// group. Returns the number of delivery rows written (0 when no trigger
/// matched — the publish still succeeds).
async fn publish(
&self,
cx: &SdkCallCx,
namespace: &str,
subtopic: &str,
message: serde_json::Value,
) -> Result<u32, GroupPubsubError>;
}
/// Stub for test bundles that don't exercise shared topics. Every call errors so
/// accidental use surfaces clearly.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopGroupPubsubService;
#[async_trait]
impl GroupPubsubService for NoopGroupPubsubService {
async fn publish(
&self,
_cx: &SdkCallCx,
_namespace: &str,
_subtopic: &str,
_message: serde_json::Value,
) -> Result<u32, GroupPubsubError> {
Err(GroupPubsubError::Backend(
"group pubsub is not wired in".into(),
))
}
}
/// Failure modes surfaced to the Rhai bridge.
#[derive(Debug, Error)]
pub enum GroupPubsubError {
/// Empty topic namespace; rejected at the SDK boundary.
#[error("shared topic name must not be empty")]
InvalidTopic,
/// No group on the calling app's ancestor chain declares this topic shared —
/// the structural "not shared with you" boundary. Also the outcome for a
/// foreign app naming another subtree's topic.
#[error("topic {0:?} is not a shared group topic for this app")]
CollectionNotShared(String),
/// Caller lacked the required capability. A publish always needs an
/// authenticated editor+ on the owning group (fails closed for anon).
#[error("forbidden")]
Forbidden,
/// Message exceeds the per-message JSON-encoded size cap.
#[error("message too large: {actual} bytes exceeds the {limit}-byte cap")]
MessageTooLarge { limit: usize, actual: usize },
/// Storage / backend failure.
#[error("backend error: {0}")]
Backend(String),
}

View File

@@ -19,6 +19,7 @@ pub mod group;
pub mod group_docs;
pub mod group_files;
pub mod group_kv;
pub mod group_pubsub;
pub mod http;
pub mod ids;
pub mod inbox;
@@ -71,6 +72,7 @@ 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 group_pubsub::{GroupPubsubError, GroupPubsubService, NoopGroupPubsubService};
pub use http::{HttpError, HttpRequest, HttpResponse, HttpService, NoopHttpService};
pub use ids::{
AdminUserId, ApiKeyId, AppId, AppUserId, ExecutionId, GroupId, InvitationId, QueueMessageId,

View File

@@ -21,12 +21,12 @@ use std::sync::Arc;
use crate::{
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,
GroupFilesService, GroupKvService, GroupPubsubService, HttpService, InvokeService, KvService,
ModuleSource, NoopDeadLetterService, NoopDocsService, NoopEmailService, NoopEventEmitter,
NoopFilesService, NoopGroupDocsService, NoopGroupFilesService, NoopGroupKvService,
NoopGroupPubsubService, 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
@@ -133,6 +133,12 @@ pub struct Services {
/// `files::shared_collection(name)`. Wired via [`Services::with_group_files`];
/// defaults to `NoopGroupFilesService`.
pub group_files: Arc<dyn GroupFilesService>,
/// Shared cross-app group TOPICS (§11.6 D2). Scripts get
/// `pubsub::shared_topic(name)` — publish to a group-scoped topic watched by
/// `shared = true` group pubsub triggers. Wired via
/// [`Services::with_group_pubsub`]; defaults to `NoopGroupPubsubService`.
pub group_pubsub: Arc<dyn GroupPubsubService>,
}
impl Services {
@@ -178,6 +184,7 @@ impl Services {
group_kv: Arc::new(NoopGroupKvService),
group_docs: Arc::new(NoopGroupDocsService),
group_files: Arc::new(NoopGroupFilesService),
group_pubsub: Arc::new(NoopGroupPubsubService),
}
}
@@ -203,6 +210,13 @@ impl Services {
self
}
/// Set the §11.6 D2 shared group-TOPIC service (picloud binary; tests leave noop).
#[must_use]
pub fn with_group_pubsub(mut self, group_pubsub: Arc<dyn GroupPubsubService>) -> Self {
self.group_pubsub = group_pubsub;
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