feat(v1.1.6): realtime channels + v1.1.5 follow-ups + version bumps

Server-side realtime SSE on per-app pub/sub topics, plus the three
v1.1.5 follow-ups and the version bumps.

Realtime:
- topics registry (0021) + admin endpoints + Capability::AppTopicManage
  (-> app:admin; no new scope).
- GET /realtime/topics/{topic} SSE endpoint (orchestrator-core data
  plane): Host -> app, RealtimeAuthority gate (404 missing/internal,
  401 bad/absent token), broadcast::Receiver stream + heartbeat.
- RealtimeBroadcaster / RealtimeEvent / RealtimeAuthority traits
  (picloud-shared); InProcessBroadcaster + GC (orchestrator-core);
  DB-backed RealtimeAuthorityImpl (manager-core). Publish path fans out
  to in-process subscribers after the durable outbox commit (best-effort,
  panic-isolated).
- HMAC subscriber tokens (subscriber_token.rs) + app_secrets table (0022)
  + pubsub::subscriber_token SDK (schema 1.6 -> 1.7). TTL clamp + env
  overrides.
- Dashboard Topics tab (register/list/edit/delete, prominent external
  badge, flip confirmation).

v1.1.5 follow-ups:
- Empty blobs accepted (NewFile/FileUpdate::validate) + round-trip test.
- Orphan *.tmp.* sweeper (spawn_files_orphan_sweep).
- Dispatcher e2e tests, one per trigger kind (DATABASE_URL-gated).

Versions: workspace 1.1.6, SDK 1.7, dashboard 0.12.0. Schema-snapshot
golden re-blessed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-04 20:18:50 +02:00
parent d064681c49
commit fcbcc576a2
35 changed files with 4333 additions and 63 deletions

View File

@@ -177,8 +177,11 @@ pub enum FilesError {
impl NewFile {
/// Validate required fields + length caps at the SDK boundary.
/// `data` must be non-empty (v1.1.5 treats an empty blob as a
/// missing `data` field — see HANDBACK §7).
///
/// Empty `data` is **accepted** as a valid stored state (v1.1.6
/// relaxed the v1.1.5 rejection — empty files are a legitimate use
/// case: sentinels, placeholders, zero-byte uploads. See HANDBACK
/// §7). `name` and `content_type` are still required.
///
/// # Errors
///
@@ -191,9 +194,6 @@ impl NewFile {
if self.content_type.trim().is_empty() {
return Err(FilesError::MissingField("content_type"));
}
if self.data.is_empty() {
return Err(FilesError::MissingField("data"));
}
if self.name.len() > MAX_FILE_NAME_BYTES {
return Err(FilesError::NameTooLong(self.name.len()));
}
@@ -218,9 +218,9 @@ impl FileUpdate {
/// Returns the field-specific [`FilesError`] for the first failing
/// check.
pub fn validate(&self, max_size: usize) -> Result<(), FilesError> {
if self.data.is_empty() {
return Err(FilesError::MissingField("data"));
}
// Empty replacement bytes are accepted (v1.1.6 relaxation —
// consistent with NewFile::validate; updating a file to zero
// bytes is as legitimate as creating one).
if let Some(name) = &self.name {
if name.trim().is_empty() {
return Err(FilesError::MissingField("name"));

View File

@@ -21,11 +21,14 @@ pub mod log_sink;
pub mod modules;
pub mod outbox_writer;
pub mod pubsub;
pub mod realtime;
pub mod realtime_authority;
pub mod route;
pub mod sandbox;
pub mod script;
pub mod sdk_cx;
pub mod services;
pub mod subscriber_token;
pub mod trigger_event;
pub mod validator;
pub mod version;
@@ -54,6 +57,8 @@ pub use outbox_writer::{HttpDispatchPayload, NewHttpOutbox, OutboxWriter, Outbox
pub use pubsub::{
topic_matches, validate_topic_pattern, NoopPubsubService, PubsubError, PubsubService,
};
pub use realtime::{BroadcasterError, NoopRealtimeBroadcaster, RealtimeBroadcaster, RealtimeEvent};
pub use realtime_authority::{DenyAllRealtimeAuthority, RealtimeAuthority, SubscribeDenied};
pub use route::{DispatchMode, HostKind, PathKind, Route};
pub use sandbox::ScriptSandbox;
pub use script::{Script, ScriptKind};

View File

@@ -30,6 +30,32 @@ pub trait PubsubService: Send + Sync {
topic: &str,
message: serde_json::Value,
) -> Result<(), PubsubError>;
/// Mint an HMAC-signed realtime subscriber token (v1.1.6). Backs the
/// `pubsub::subscriber_token(topics, ttl)` Rhai SDK call. The minted
/// token authorizes an external SSE client to subscribe to the given
/// `topics` for `ttl_seconds` (clamped to the configured bounds; the
/// configured default applies when `ttl_seconds` is `None`).
///
/// Every topic must already be registered as externally subscribable
/// in `cx.app_id`; `cx.principal` must be `Some` (anonymous
/// public-HTTP scripts can't mint). See [`PubsubError::SubscriberToken`]
/// for the rejection messages.
///
/// The default impl errors `Unavailable` so test fakes and the
/// `NoopPubsubService` keep compiling; the real minting lives in
/// manager-core's `PubsubServiceImpl`.
async fn mint_subscriber_token(
&self,
cx: &SdkCallCx,
topics: Vec<String>,
ttl_seconds: Option<i64>,
) -> Result<String, PubsubError> {
let _ = (cx, topics, ttl_seconds);
Err(PubsubError::Unavailable(
"subscriber tokens are not wired in".into(),
))
}
}
#[derive(Debug, Error)]
@@ -47,6 +73,13 @@ pub enum PubsubError {
#[error("pubsub rejected: {0}")]
Rejected(String),
/// A `pubsub::subscriber_token` mint was rejected (empty topics,
/// unregistered topic, ttl out of range, anonymous caller). The
/// string is the full user-facing message; the SDK surfaces it
/// verbatim so scripts see the documented wording.
#[error("{0}")]
SubscriberToken(String),
/// Anything else — Postgres unavailable, etc.
#[error("pubsub backend error: {0}")]
Unavailable(String),

View File

@@ -0,0 +1,86 @@
//! `RealtimeBroadcaster` — the in-process fan-out seam for SSE realtime
//! delivery (v1.1.6).
//!
//! Structurally a sibling of [`crate::inbox::InboxResolver`]: the trait
//! lives here in `picloud-shared` because the publish side
//! (`PubsubServiceImpl` in manager-core) and the subscribe side (the SSE
//! handler in orchestrator-core) live in different crates and both need
//! one shared instance. The in-process impl lives in orchestrator-core
//! (`Mutex<HashMap<(AppId, topic), broadcast::Sender>>`); cluster mode
//! (v1.3+) swaps it for a Postgres `LISTEN/NOTIFY`-backed resolver behind
//! the same trait without touching either caller.
//!
//! Delivery is **best-effort, at-most-once**: this is the realtime path,
//! NOT the durable one. Durable trigger fan-out (retry / dead-letter)
//! goes through the outbox and is the publish caller's separate concern.
//! A slow SSE consumer loses the oldest events (bounded broadcast
//! buffer); SSE's own transport-layer auto-reconnect is the recovery
//! mechanism (no server-side replay in v1.1.6).
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use thiserror::Error;
use tokio::sync::broadcast;
use crate::AppId;
/// A single realtime event delivered to in-process SSE subscribers. The
/// SSE handler serializes this to `data: {...}\n\n` on the wire.
#[derive(Debug, Clone)]
pub struct RealtimeEvent {
pub topic: String,
pub message: serde_json::Value,
pub published_at: DateTime<Utc>,
}
#[derive(Debug, Error)]
pub enum BroadcasterError {
/// Reserved for backends that can fail to register a subscriber
/// (e.g. the cluster-mode `LISTEN/NOTIFY` resolver). The in-process
/// impl never returns this.
#[error("realtime broadcaster unavailable: {0}")]
Unavailable(String),
}
#[async_trait]
pub trait RealtimeBroadcaster: Send + Sync {
/// Subscribe to events on `(app_id, topic)`. Returns a receiver that
/// yields events until dropped. Channels are created lazily on first
/// subscribe.
async fn subscribe(
&self,
app_id: AppId,
topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError>;
/// Publish an event to in-process subscribers. NOT durable — the
/// outbox-backed durable fan-out is the publish caller's separate
/// concern. A publish with no live subscribers is a silent no-op.
async fn publish(&self, app_id: AppId, topic: &str, event: RealtimeEvent);
/// 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);
}
/// Bootstrap / test impl: subscribe yields a receiver on a throwaway
/// channel, publish is a no-op. Lets a `Services`-style bundle build
/// without the real registry wired in.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoopRealtimeBroadcaster;
#[async_trait]
impl RealtimeBroadcaster for NoopRealtimeBroadcaster {
async fn subscribe(
&self,
_app_id: AppId,
_topic: &str,
) -> Result<broadcast::Receiver<RealtimeEvent>, BroadcasterError> {
let (_tx, rx) = broadcast::channel(1);
Ok(rx)
}
async fn publish(&self, _app_id: AppId, _topic: &str, _event: RealtimeEvent) {}
async fn drop_topic(&self, _app_id: AppId, _topic: &str) {}
}

View File

@@ -0,0 +1,70 @@
//! `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)
}
}

View File

@@ -0,0 +1,200 @@
//! HMAC-signed realtime subscriber tokens (v1.1.6, design notes §5).
//!
//! A token is a compact, URL-safe, two-part string:
//!
//! ```text
//! <base64url(payload)>.<base64url(signature)>
//! ```
//!
//! where `payload` is the JSON [`TokenClaims`] and `signature` is
//! `HMAC-SHA256(app_signing_key, base64url(payload))`. Tokens are minted
//! by scripts via `pubsub::subscriber_token` (the minter lives in
//! manager-core's `PubsubServiceImpl`) and verified by the SSE subscribe
//! path (the verifier lives in manager-core's `RealtimeAuthority` impl).
//! Both sides depend on this module so the byte-for-byte contract has a
//! single home.
//!
//! There is no per-token revocation in v1.1.6 by design: HMAC bearers
//! can't be individually revoked. Rotating an app's signing key
//! invalidates every token for that app wholesale; short TTLs are the
//! safety mechanism.
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use thiserror::Error;
use crate::AppId;
type HmacSha256 = Hmac<Sha256>;
/// The signed payload. `exp` / `iat` are Unix seconds.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct TokenClaims {
pub app_id: AppId,
pub topics: Vec<String>,
pub exp: i64,
pub iat: i64,
}
impl TokenClaims {
/// Does this token grant access to `topic`?
#[must_use]
pub fn allows_topic(&self, topic: &str) -> bool {
self.topics.iter().any(|t| t == topic)
}
/// Is the token expired relative to `now_unix` (Unix seconds)?
#[must_use]
pub fn is_expired(&self, now_unix: i64) -> bool {
now_unix >= self.exp
}
}
#[derive(Debug, Error, PartialEq, Eq)]
pub enum TokenError {
#[error("token is malformed")]
Malformed,
#[error("token signature is invalid")]
BadSignature,
#[error("token has expired")]
Expired,
}
/// Sign `claims` with `key`, producing the `payload.signature` string.
#[must_use]
pub fn sign(key: &[u8], claims: &TokenClaims) -> String {
// `serde_json` on a fixed-field struct never fails to serialize.
let payload_json = serde_json::to_vec(claims).expect("TokenClaims serialize");
let payload_b64 = URL_SAFE_NO_PAD.encode(&payload_json);
let sig = mac_sign(key, payload_b64.as_bytes());
let sig_b64 = URL_SAFE_NO_PAD.encode(sig);
format!("{payload_b64}.{sig_b64}")
}
/// Verify `token` against `key` and check expiry against `now_unix`
/// (Unix seconds). Returns the decoded [`TokenClaims`] on success.
///
/// Topic-scope checking (is the requested topic in the token's list?)
/// is the caller's responsibility via [`TokenClaims::allows_topic`] —
/// this function proves authenticity + liveness only.
///
/// # Errors
///
/// [`TokenError::Malformed`] if the shape / base64 / JSON is wrong,
/// [`TokenError::BadSignature`] if the HMAC doesn't match, or
/// [`TokenError::Expired`] if `now_unix >= exp`.
pub fn verify(key: &[u8], token: &str, now_unix: i64) -> Result<TokenClaims, TokenError> {
let (payload_b64, sig_b64) = token.split_once('.').ok_or(TokenError::Malformed)?;
let provided_sig = URL_SAFE_NO_PAD
.decode(sig_b64)
.map_err(|_| TokenError::Malformed)?;
// Constant-time verify of the MAC over the exact payload bytes.
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(payload_b64.as_bytes());
mac.verify_slice(&provided_sig)
.map_err(|_| TokenError::BadSignature)?;
// Signature good → decode the claims and check expiry.
let payload_json = URL_SAFE_NO_PAD
.decode(payload_b64)
.map_err(|_| TokenError::Malformed)?;
let claims: TokenClaims =
serde_json::from_slice(&payload_json).map_err(|_| TokenError::Malformed)?;
if claims.is_expired(now_unix) {
return Err(TokenError::Expired);
}
Ok(claims)
}
fn mac_sign(key: &[u8], data: &[u8]) -> Vec<u8> {
let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
mac.update(data);
mac.finalize().into_bytes().to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
fn claims(app: AppId, topics: &[&str], exp: i64) -> TokenClaims {
TokenClaims {
app_id: app,
topics: topics.iter().map(|s| (*s).to_string()).collect(),
iat: 1000,
exp,
}
}
#[test]
fn round_trip_verifies() {
let key = b"super-secret-key-bytes-0123456789";
let app = AppId::new();
let c = claims(app, &["chat.room.1", "user.notify"], 5000);
let token = sign(key, &c);
let got = verify(key, &token, 2000).expect("valid token verifies");
assert_eq!(got, c);
assert!(got.allows_topic("chat.room.1"));
assert!(!got.allows_topic("chat.room.2"));
}
#[test]
fn tampered_payload_fails() {
let key = b"super-secret-key-bytes-0123456789";
let app = AppId::new();
let token = sign(key, &claims(app, &["t"], 5000));
// Flip a character in the payload half.
let (payload, sig) = token.split_once('.').unwrap();
let mut bytes = payload.as_bytes().to_vec();
bytes[0] ^= 0x01;
let tampered = format!("{}.{sig}", String::from_utf8_lossy(&bytes));
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
}
#[test]
fn tampered_signature_fails() {
let key = b"super-secret-key-bytes-0123456789";
let app = AppId::new();
let token = sign(key, &claims(app, &["t"], 5000));
let (payload, _sig) = token.split_once('.').unwrap();
// A valid-base64 but wrong signature.
let bogus = URL_SAFE_NO_PAD.encode([0u8; 32]);
let tampered = format!("{payload}.{bogus}");
assert_eq!(verify(key, &tampered, 2000), Err(TokenError::BadSignature));
}
#[test]
fn different_key_fails() {
let app = AppId::new();
let token = sign(
b"key-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
&claims(app, &["t"], 5000),
);
assert_eq!(
verify(b"key-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", &token, 2000),
Err(TokenError::BadSignature)
);
}
#[test]
fn expired_token_fails_at_expiry_check() {
let key = b"super-secret-key-bytes-0123456789";
let app = AppId::new();
let token = sign(key, &claims(app, &["t"], 5000));
// now == exp → expired (>= boundary).
assert_eq!(verify(key, &token, 5000), Err(TokenError::Expired));
assert_eq!(verify(key, &token, 9999), Err(TokenError::Expired));
}
#[test]
fn malformed_token_fails() {
let key = b"super-secret-key-bytes-0123456789";
assert_eq!(verify(key, "no-dot-here", 0), Err(TokenError::Malformed));
assert_eq!(verify(key, "a.b.c", 0), Err(TokenError::Malformed));
}
}

View File

@@ -49,7 +49,16 @@ pub const PRODUCT_VERSION: &str = env!("CARGO_PKG_VERSION");
/// publish-time fan-out and `ctx.event.pubsub` for pubsub-trigger
/// handlers. The `Services` bundle gains `files: Arc<dyn FilesService>`
/// and `pubsub: Arc<dyn PubsubService>`.
pub const SDK_VERSION: &str = "1.6";
///
/// 1.7 additions (v1.1.6): `pubsub::subscriber_token(topics, ttl)` —
/// mints an HMAC-signed realtime subscriber token for externally-
/// subscribable topics (requires an authenticated principal). This is
/// the only new script-visible surface; the rest of the release is
/// server-side (the SSE `/realtime/topics/{topic}` endpoint; the
/// `RealtimeBroadcaster` / `RealtimeEvent` / `RealtimeAuthority` traits;
/// the `topics` registry + admin endpoints; the `@picloud/client`
/// TypeScript package).
pub const SDK_VERSION: &str = "1.7";
/// HTTP API major version. Appears in URL paths as `/api/v{N}/...`.
/// Bump (new integer + new URL prefix) when the request/response