feat(realtime): external SSE subscription for group shared topics (§11.6 D2 / Track A M6)

Shared TOPICS fanned out only to in-cluster trigger handlers; external clients
could not subscribe (per-app topics already can). Add SSE for shared topics.

- RealtimeBroadcaster gains a parallel (group_id, topic) channel map:
  subscribe_group / publish_group / drop_group_topic (default no-ops so
  NoopRealtimeBroadcaster + test doubles are untouched). InProcessBroadcaster
  implements them with a second map; GC + channel_count span both.
- Route GET /realtime/shared/topics/{topic}: Host->app dispatch (as the per-app
  route), then RealtimeAuthority::authorize_subscribe_shared resolves the OWNING
  GROUP from the app's chain (kind=topic, root segment). Reads-open model — the
  resolution IS the authorization, consistent with in-script shared reads; a
  foreign-subtree app never resolves (404, the isolation boundary). No principal
  machinery needed.
- GroupPubsubServiceImpl::with_realtime bridges a shared-topic publish to the
  owning-group channel (best-effort) after the durable trigger fan-out.
- Host wires the broadcaster into the group pubsub service + the collection
  resolver into the authority.

Auth-model note: chose reads-open (subtree app's Host is the grant) over
"authenticated principal + GroupKvRead" — it's both simpler and faithful to how
shared-collection reads already work. Pinned by realtime broadcaster group-map
tests, realtime_api shared-route tests (404 + stream), and
group_pubsub_service::publish_bridges_to_the_group_broadcaster. No migration.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-11 15:33:39 +02:00
parent 0f05c270d1
commit b8b047368e
8 changed files with 569 additions and 25 deletions

View File

@@ -16,7 +16,10 @@
use std::sync::Arc;
use async_trait::async_trait;
use picloud_shared::{GroupId, GroupPubsubError, GroupPubsubService, SdkCallCx, TriggerEvent};
use picloud_shared::{
GroupId, GroupPubsubError, GroupPubsubService, RealtimeBroadcaster, RealtimeEvent, SdkCallCx,
TriggerEvent,
};
use crate::authz::{self, AuthzRepo, Capability};
use crate::group_collection_repo::GroupCollectionResolver;
@@ -31,6 +34,10 @@ pub struct GroupPubsubServiceImpl {
resolver: Arc<dyn GroupCollectionResolver>,
authz: Arc<dyn AuthzRepo>,
max_message_bytes: usize,
/// §11.6 D2: the realtime broadcaster, attached by the host via
/// [`Self::with_realtime`]. `None` in test bundles → no external SSE fan-out
/// (durable trigger fan-out is unaffected).
realtime: Option<Arc<dyn RealtimeBroadcaster>>,
}
impl GroupPubsubServiceImpl {
@@ -45,9 +52,19 @@ impl GroupPubsubServiceImpl {
resolver,
authz,
max_message_bytes: pubsub_max_message_bytes_from_env(),
realtime: None,
}
}
/// §11.6 D2: attach the realtime broadcaster so a shared-topic publish also
/// fans out to external SSE subscribers (in addition to the durable
/// `shared = true` trigger fan-out). Mirrors `PubsubServiceImpl::with_realtime`.
#[must_use]
pub fn with_realtime(mut self, broadcaster: Arc<dyn RealtimeBroadcaster>) -> Self {
self.realtime = Some(broadcaster);
self
}
/// 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.
@@ -107,10 +124,11 @@ impl GroupPubsubService for GroupPubsubServiceImpl {
} else {
format!("{namespace}.{subtopic}")
};
let published_at = chrono::Utc::now();
let event = TriggerEvent::Pubsub {
topic: topic.clone(),
message,
published_at: chrono::Utc::now(),
message: message.clone(),
published_at,
};
let payload = serde_json::to_value(&event)
.map_err(|e| GroupPubsubError::Backend(format!("event serialize: {e}")))?;
@@ -120,9 +138,183 @@ impl GroupPubsubService for GroupPubsubServiceImpl {
trigger_depth: cx.trigger_depth,
root_execution_id: cx.root_execution_id,
};
self.repo
// Durable trigger fan-out FIRST (the authoritative delivery path).
let count = self
.repo
.fan_out_shared_publish(publish_ctx, group_id, &topic, payload)
.await
.map_err(|e| GroupPubsubError::Backend(e.to_string()))
.map_err(|e| GroupPubsubError::Backend(e.to_string()))?;
// Then the best-effort realtime broadcast to external SSE subscribers,
// keyed by the OWNING GROUP so every subtree subscriber shares one
// channel. A broadcast failure never fails the publish.
if let Some(realtime) = &self.realtime {
realtime
.publish_group(
group_id,
&topic,
RealtimeEvent {
topic: topic.clone(),
message,
published_at,
},
)
.await;
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use picloud_shared::{
AdminUserId, AppId, BroadcasterError, ExecutionId, InstanceRole, Principal,
RealtimeBroadcaster, RealtimeEvent, RequestId, ScriptId,
};
use std::sync::Mutex;
use tokio::sync::broadcast;
use crate::authz::AuthzError;
use crate::pubsub_repo::PubsubRepoError;
/// Records `publish_group` calls so the bridge can be asserted.
#[derive(Default)]
struct RecordingBroadcaster {
group_publishes: Mutex<Vec<(GroupId, String, serde_json::Value)>>,
}
#[async_trait]
impl RealtimeBroadcaster for RecordingBroadcaster {
async fn subscribe(
&self,
_: picloud_shared::AppId,
_: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
Ok(broadcast::channel(1).1)
}
async fn publish(&self, _: picloud_shared::AppId, _: &str, _: RealtimeEvent) {}
async fn drop_topic(&self, _: picloud_shared::AppId, _: &str) {}
async fn publish_group(&self, group_id: GroupId, topic: &str, event: RealtimeEvent) {
self.group_publishes
.lock()
.unwrap()
.push((group_id, topic.to_string(), event.message));
}
}
struct FakeRepo;
#[async_trait]
impl PubsubRepo for FakeRepo {
async fn fan_out_publish(
&self,
_: PublishCtx,
_: &str,
_: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
Ok(0)
}
async fn fan_out_shared_publish(
&self,
_: PublishCtx,
_: GroupId,
_: &str,
_: serde_json::Value,
) -> Result<u32, PubsubRepoError> {
Ok(2) // pretend two shared triggers matched
}
}
struct FixedResolver(GroupId);
#[async_trait]
impl GroupCollectionResolver for FixedResolver {
async fn resolve_owning_group(
&self,
_: AppId,
_: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok((kind == KIND_TOPIC).then_some(self.0))
}
}
struct DenyAuthz;
#[async_trait]
impl AuthzRepo for DenyAuthz {
async fn membership(
&self,
_: picloud_shared::UserId,
_: AppId,
) -> Result<Option<picloud_shared::AppRole>, AuthzError> {
Ok(None)
}
}
fn owner_cx(app_id: AppId) -> SdkCallCx {
SdkCallCx {
app_id,
script_id: ScriptId::new(),
// Instance Owner bypasses the capability check (as in the group_kv
// tests), so DenyAuthz is fine — we're testing the broadcast bridge.
principal: Some(Principal {
user_id: AdminUserId::new(),
instance_role: InstanceRole::Owner,
scopes: None,
app_binding: None,
}),
execution_id: ExecutionId::new(),
request_id: RequestId::new(),
trigger_depth: 0,
root_execution_id: ExecutionId::new(),
is_dead_letter_handler: false,
event: None,
}
}
#[tokio::test]
async fn publish_bridges_to_the_group_broadcaster() {
// §11.6 D2: a shared-topic publish fans out to the durable triggers AND,
// when a broadcaster is attached, to the OWNING GROUP's realtime channel
// with the full topic (`namespace.subtopic`).
let group = GroupId::new();
let app = AppId::new();
let recorder = Arc::new(RecordingBroadcaster::default());
let svc = GroupPubsubServiceImpl::new(
Arc::new(FakeRepo),
Arc::new(FixedResolver(group)),
Arc::new(DenyAuthz),
)
.with_realtime(recorder.clone());
let count = svc
.publish(
&owner_cx(app),
"events",
"created",
serde_json::json!({ "id": 1 }),
)
.await
.unwrap();
assert_eq!(count, 2, "durable fan-out count is returned");
let calls = recorder.group_publishes.lock().unwrap();
assert_eq!(calls.len(), 1, "one realtime broadcast");
assert_eq!(calls[0].0, group, "keyed by the owning group");
assert_eq!(calls[0].1, "events.created", "full topic");
assert_eq!(calls[0].2, serde_json::json!({ "id": 1 }));
}
#[tokio::test]
async fn publish_without_broadcaster_still_fans_out() {
// No broadcaster attached → durable fan-out only, no panic.
let group = GroupId::new();
let svc = GroupPubsubServiceImpl::new(
Arc::new(FakeRepo),
Arc::new(FixedResolver(group)),
Arc::new(DenyAuthz),
);
let count = svc
.publish(&owner_cx(AppId::new()), "events", "", serde_json::json!({}))
.await
.unwrap();
assert_eq!(count, 2);
}
}

View File

@@ -22,11 +22,18 @@ use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use picloud_shared::{subscriber_token, AppId, RealtimeAuthority, SubscribeDenied, UsersService};
use picloud_shared::{
subscriber_token, AppId, GroupId, RealtimeAuthority, SubscribeDenied, UsersService,
};
use crate::app_secrets_repo::AppSecretsRepo;
use crate::group_collection_repo::GroupCollectionResolver;
use crate::topic_repo::{TopicAuthMode, TopicRepo};
/// The registry `kind` a shared topic is declared under (mirrors
/// `group_pubsub_service::KIND_TOPIC`).
const KIND_TOPIC: &str = "topic";
/// F-S-008: TTL on cached signing-key entries. Once key rotation
/// lands, every running process keeps accepting tokens signed by the
/// old key until restart — bounded eviction is the simplest defence.
@@ -37,6 +44,9 @@ pub struct RealtimeAuthorityImpl {
topics: Arc<dyn TopicRepo>,
secrets: Arc<dyn AppSecretsRepo>,
users: Arc<dyn UsersService>,
/// §11.6 D2: resolves a shared-topic name to its owning group on the
/// subscriber app's ancestor chain (the isolation boundary).
collections: Arc<dyn GroupCollectionResolver>,
key_cache: Mutex<HashMap<AppId, (std::time::Instant, Vec<u8>)>>,
}
@@ -46,11 +56,13 @@ impl RealtimeAuthorityImpl {
topics: Arc<dyn TopicRepo>,
secrets: Arc<dyn AppSecretsRepo>,
users: Arc<dyn UsersService>,
collections: Arc<dyn GroupCollectionResolver>,
) -> Self {
Self {
topics,
secrets,
users,
collections,
key_cache: Mutex::new(HashMap::new()),
}
}
@@ -152,6 +164,24 @@ impl RealtimeAuthority for RealtimeAuthorityImpl {
}
}
}
async fn authorize_subscribe_shared(
&self,
app_id: AppId,
topic: &str,
) -> Result<GroupId, SubscribeDenied> {
// The declared collection is the topic's ROOT segment (`events.created`
// → `events`), matching the publish-side validation. Resolving it against
// the subscriber app's chain (kind=`topic`) is both the existence check
// and the authorization — reads are open to the subtree; a foreign app's
// chain never reaches the owning group, so it 404s (isolation boundary).
let root = topic.split('.').next().unwrap_or(topic);
self.collections
.resolve_owning_group(app_id, root, KIND_TOPIC)
.await
.map_err(|e| SubscribeDenied::Backend(e.to_string()))?
.ok_or(SubscribeDenied::NotFound)
}
}
#[cfg(test)]
@@ -222,6 +252,26 @@ mod tests {
}
}
/// Fake shared-topic resolver: `Some((declared_name, owning_group))` resolves
/// that one topic name (kind=topic) to the group; everything else → None.
#[derive(Default)]
struct FakeCollections(Option<(String, GroupId)>);
#[async_trait]
impl GroupCollectionResolver for FakeCollections {
async fn resolve_owning_group(
&self,
_app_id: AppId,
name: &str,
kind: &str,
) -> Result<Option<GroupId>, sqlx::Error> {
Ok(self
.0
.as_ref()
.filter(|(n, _)| n == name && kind == KIND_TOPIC)
.map(|(_, g)| *g))
}
}
fn authority(
topics: Vec<(AppId, Topic)>,
key_app: AppId,
@@ -231,9 +281,32 @@ mod tests {
Arc::new(FakeTopics(topics)),
Arc::new(FakeSecrets(key_app, key)),
Arc::new(picloud_shared::NoopUsersService),
Arc::new(FakeCollections::default()),
)
}
#[tokio::test]
async fn shared_topic_resolves_owning_group_or_404() {
let app = AppId::new();
let group = GroupId::new();
let auth = RealtimeAuthorityImpl::new(
Arc::new(FakeTopics(vec![])),
Arc::new(FakeSecrets(app, vec![0u8; 32])),
Arc::new(picloud_shared::NoopUsersService),
Arc::new(FakeCollections(Some(("events".into(), group)))),
);
// The full topic's ROOT segment resolves; returns the OWNING group.
assert_eq!(
auth.authorize_subscribe_shared(app, "events.created").await,
Ok(group)
);
// An undeclared topic name → NotFound (the isolation/existence boundary).
assert_eq!(
auth.authorize_subscribe_shared(app, "secrets").await,
Err(SubscribeDenied::NotFound)
);
}
#[tokio::test]
async fn missing_topic_is_not_found() {
let app = AppId::new();
@@ -590,6 +663,7 @@ mod tests {
app_id,
user: stub_user(app_id),
}),
Arc::new(FakeCollections::default()),
)
}
@@ -652,6 +726,7 @@ mod tests {
app_id: app_a,
user: stub_user(app_a),
}),
Arc::new(FakeCollections::default()),
);
assert_eq!(
a.authorize_subscribe(app_b, "chat", Some("app-a-token"))