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:
@@ -22,7 +22,7 @@ use chrono::{DateTime, Utc};
|
||||
use thiserror::Error;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::AppId;
|
||||
use crate::{AppId, GroupId};
|
||||
|
||||
/// A single realtime event delivered to in-process SSE subscribers. The
|
||||
/// SSE handler serializes this to `data: {...}\n\n` on the wire.
|
||||
@@ -61,6 +61,34 @@ pub trait RealtimeBroadcaster: Send + Sync {
|
||||
/// Drop every subscriber for a topic (called on topic DELETE). Live
|
||||
/// receivers observe a closed channel and disconnect cleanly.
|
||||
async fn drop_topic(&self, app_id: AppId, topic: &str);
|
||||
|
||||
// ---- §11.6 D2: group SHARED topics -------------------------------------
|
||||
// A shared topic fans out to every subtree app that subscribes, so it is
|
||||
// keyed by the OWNING GROUP (not a single app) — one channel per
|
||||
// `(group_id, topic)`. Default impls are no-ops so non-realtime bundles
|
||||
// (NoopRealtimeBroadcaster, test doubles) need no change.
|
||||
|
||||
/// Subscribe to a group shared topic's realtime stream.
|
||||
async fn subscribe_group(
|
||||
&self,
|
||||
group_id: GroupId,
|
||||
topic: &str,
|
||||
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
|
||||
let _ = (group_id, topic);
|
||||
let (_tx, rx) = broadcast::channel(1);
|
||||
Ok(rx)
|
||||
}
|
||||
|
||||
/// Publish to a group shared topic's in-process subscribers (best-effort;
|
||||
/// no live subscriber is a silent no-op).
|
||||
async fn publish_group(&self, group_id: GroupId, topic: &str, event: RealtimeEvent) {
|
||||
let _ = (group_id, topic, event);
|
||||
}
|
||||
|
||||
/// Drop every subscriber for a group shared topic.
|
||||
async fn drop_group_topic(&self, group_id: GroupId, topic: &str) {
|
||||
let _ = (group_id, topic);
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap / test impl: subscribe yields a receiver on a throwaway
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use crate::AppId;
|
||||
use crate::{AppId, GroupId};
|
||||
|
||||
/// Why a subscribe attempt was refused. The SSE handler maps these to
|
||||
/// HTTP status codes.
|
||||
@@ -50,6 +50,24 @@ pub trait RealtimeAuthority: Send + Sync {
|
||||
topic: &str,
|
||||
token: Option<&str>,
|
||||
) -> Result<(), SubscribeDenied>;
|
||||
|
||||
/// §11.6 D2: decide whether a subscriber reaching the platform via `app_id`'s
|
||||
/// host may subscribe to the group SHARED topic `topic`. Consistent with the
|
||||
/// shared-collection trust model, **reads are open**: the authorization IS the
|
||||
/// owning-group resolution — a group on `app_id`'s ancestor chain must declare
|
||||
/// `topic`'s root segment a shared `kind='topic'` collection. Returns that
|
||||
/// OWNING GROUP's id (so the caller subscribes to the group channel), or
|
||||
/// `NotFound` when no ancestor group declares it (the chain walk is the
|
||||
/// isolation boundary — a foreign-subtree app never resolves it). Default:
|
||||
/// `NotFound`.
|
||||
async fn authorize_subscribe_shared(
|
||||
&self,
|
||||
app_id: AppId,
|
||||
topic: &str,
|
||||
) -> Result<GroupId, SubscribeDenied> {
|
||||
let _ = (app_id, topic);
|
||||
Err(SubscribeDenied::NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
/// Bootstrap impl: denies everything as `NotFound`. Replaced in
|
||||
|
||||
Reference in New Issue
Block a user