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:
84
crates/shared/src/group_pubsub.rs
Normal file
84
crates/shared/src/group_pubsub.rs
Normal 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),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user