//! `RealtimeAuthority` — the SSE subscribe authorization seam (v1.1.6). //! //! The SSE endpoint (`GET /realtime/topics/{topic}`) is a data-plane //! surface in orchestrator-core, but deciding whether a subscribe is //! allowed needs a `topics` table read plus (for token-gated topics) an //! HMAC verify against the app's signing key — both of which require DB //! access and the signing-key material that must NOT leak into the //! data-plane crate. This trait keeps all of that inside the manager-core //! impl: orchestrator-core only ever sees the three-way verdict below. //! //! `NotFound` is deliberately returned for *both* "no such topic" and //! "topic exists but isn't externally subscribable" so the endpoint //! can't be used to probe which internal topics exist (design notes §5). use async_trait::async_trait; use crate::AppId; /// Why a subscribe attempt was refused. The SSE handler maps these to /// HTTP status codes. #[derive(Debug, Clone, PartialEq, Eq)] pub enum SubscribeDenied { /// No externally-subscribable topic by that name in this app → 404. /// Used for genuinely-missing topics AND internal-only ones, so the /// endpoint doesn't leak which internal topics exist. NotFound, /// The topic is token-gated and the presented token was missing, /// malformed, badly signed, expired, or not scoped to this topic → /// 401 (generic; never says which check failed). Unauthorized, /// Backend failure (DB unavailable, etc.) → 500. Backend(String), } #[async_trait] pub trait RealtimeAuthority: Send + Sync { /// Decide whether an external client may subscribe to /// `(app_id, topic)`. `token` is the bearer/query token if the /// client presented one (`None` otherwise). /// /// Returns `Ok(())` when the subscribe is permitted (public topic, /// or token-gated topic with a valid token scoped to it). /// /// # Errors /// /// [`SubscribeDenied`] — see the variants for the status mapping. async fn authorize_subscribe( &self, app_id: AppId, topic: &str, token: Option<&str>, ) -> Result<(), SubscribeDenied>; } /// Bootstrap impl: denies everything as `NotFound`. Replaced in /// `build_app` with the manager-core DB-backed authority. #[derive(Debug, Default, Clone, Copy)] pub struct DenyAllRealtimeAuthority; #[async_trait] impl RealtimeAuthority for DenyAllRealtimeAuthority { async fn authorize_subscribe( &self, _app_id: AppId, _topic: &str, _token: Option<&str>, ) -> Result<(), SubscribeDenied> { Err(SubscribeDenied::NotFound) } }