feat(msg-bus): prepare engine

This commit is contained in:
Edouard Vanbelle
2026-09-10 00:02:30 +02:00
parent 711b00db2a
commit 1b824cb45c
13 changed files with 2650 additions and 17 deletions
Generated
+58 -14
View File
@@ -166,7 +166,7 @@ version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -177,7 +177,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -765,12 +765,13 @@ dependencies = [
[[package]]
name = "axum"
version = "0.8.9"
version = "0.8.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
checksum = "8b52af3cb4058c895d37317bb27508dccc8e5f2d39454016b297bf4a400597b8"
dependencies = [
"axum-core",
"axum-macros",
"base64 0.22.1",
"bytes",
"form_urlencoded",
"futures-util",
@@ -790,8 +791,10 @@ dependencies = [
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sha1 0.10.6",
"sync_wrapper",
"tokio",
"tokio-tungstenite",
"tower",
"tower-layer",
"tower-service",
@@ -1998,6 +2001,12 @@ dependencies = [
"parking_lot_core",
]
[[package]]
name = "data-encoding"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "datasketches"
version = "0.2.0"
@@ -2289,7 +2298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -3320,7 +3329,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2 0.6.4",
"socket2 0.5.10",
"tokio",
"tower-service",
"tracing",
@@ -3671,7 +3680,7 @@ checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -4419,7 +4428,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.59.0",
]
[[package]]
@@ -5265,7 +5274,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls 0.23.40",
"socket2 0.6.4",
"socket2 0.5.10",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -5302,7 +5311,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2 0.6.4",
"socket2 0.5.10",
"tracing",
"windows-sys 0.60.2",
]
@@ -5820,7 +5829,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -6343,7 +6352,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [
"libc",
"windows-sys 0.61.2",
"windows-sys 0.60.2",
]
[[package]]
@@ -6855,7 +6864,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
"windows-sys 0.52.0",
]
[[package]]
@@ -7086,6 +7095,18 @@ dependencies = [
"tokio-util",
]
[[package]]
name = "tokio-tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
dependencies = [
"futures-util",
"log",
"tokio",
"tungstenite",
]
[[package]]
name = "tokio-util"
version = "0.7.18"
@@ -7363,6 +7384,23 @@ version = "0.25.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31"
[[package]]
name = "tungstenite"
version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442"
dependencies = [
"bytes",
"data-encoding",
"http 1.4.0",
"httparse",
"log",
"rand 0.9.4",
"sha1 0.10.6",
"thiserror 2.0.18",
"utf-8",
]
[[package]]
name = "type1-encoding-parser"
version = "0.1.1"
@@ -7539,6 +7577,12 @@ version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "utf-8"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
[[package]]
name = "utf8-ranges"
version = "1.0.5"
@@ -8323,7 +8367,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.2",
"windows-sys 0.48.0",
]
[[package]]
+1 -1
View File
@@ -25,7 +25,7 @@ bin-dir = "oxicloud-{ version }-{ target }/{ bin }{ binary-ext }"
[dependencies]
mimalloc = { version = "0.1.52", default-features = false }
axum = { version = "0.8.9", features = ["multipart", "http1", "http2", "tokio", "macros"] }
axum = { version = "0.8.8", features = ["multipart", "http1", "http2", "tokio", "macros", "ws"] }
# "process" was previously enabled implicitly through aws-config's feature
# unification; ffmpeg_video_frame_service needs it, so declare it ourselves.
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-util", "net", "time", "sync", "fs", "process", "signal"] }
File diff suppressed because it is too large Load Diff
+1
View File
@@ -22,6 +22,7 @@ pub mod music_ports;
pub mod opaque_ports;
pub mod outbound;
pub mod plugin_ports;
pub mod realtime_ports;
pub mod recent_ports;
pub mod resource_access_hook;
pub mod share_ports;
+493
View File
@@ -0,0 +1,493 @@
//! Realtime message-bus port — the seam every service publishes through and
//! every WS session subscribes on.
//!
//! # Design (see `docs/plan/message-bus.md`)
//!
//! - [`RealtimeBus`] is the **local-facing** trait: services publish, the WS
//! handler subscribes. It never involves the network.
//! - [`BusReplicator`] is the OPTIONAL seam that mirrors local publishes to
//! and from a broker (pg `LISTEN/NOTIFY`, RabbitMQ, NATS). Callers see only
//! [`RealtimeBus`]; a real replicator plugs into the in-process impl without
//! touching consumers. Day-1 impl is [`NoopReplicator`].
//!
//! # MVP scope
//!
//! Ships the smallest slice that lets the smoke test verify a folder
//! subscription receives file/folder-created events and rejects subscribes
//! to folders the caller can't `Read`:
//!
//! - Topics: [`Topic::Folder`] and [`Topic::UserAuthz`]
//! - Events: [`RealtimeEvent::FileCreated`], [`RealtimeEvent::FolderCreated`]
//!
//! Adding a variant is a one-line change plus a match arm in `to_wire_key` /
//! `parse` / `required_perm`. Other topics (`file:{id}`, `job:{id}`,
//! `collab:{id}`, `user:{u}:notifications`, …) land with their producers in
//! Phase-A follow-ups.
//!
//! # Wire protocol
//!
//! JSON-RPC 2.0 for control + events (text frames), Yjs sync protocol for
//! CRDT (binary frames). This module owns the JSON-RPC error-code
//! vocabulary; see [`error_code`].
use std::pin::Pin;
use std::sync::Arc;
use futures::Stream;
use serde::{Deserialize, Serialize};
use tokio::sync::Notify;
use uuid::Uuid;
use crate::common::errors::DomainError;
// ════════════════════════════════════════════════════════════════════════════
// Topic — a typed key on the bus
// ════════════════════════════════════════════════════════════════════════════
/// A topic on the realtime bus. Typed enum, not a string — prevents typos
/// and gives exhaustive matching in the AuthZ dispatch and the wire encoder.
///
/// Encodes to a stable dotted wire key that maps naturally onto RabbitMQ
/// topic-exchange routing keys or NATS subjects when the [`BusReplicator`]
/// seam is filled in later.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum Topic {
/// A folder's mutation stream — file/subfolder created/deleted/renamed/
/// moved in or out. Consumed by the folder view for live refresh.
Folder(Uuid),
/// A user's private authz-change channel. The WS handler will auto-
/// subscribe the caller and evict stale subs when its events fire once
/// the eviction wiring lands (Phase-A follow-up).
UserAuthz(Uuid),
}
impl Topic {
/// Stable dotted wire form used by the JSON-RPC control frames and any
/// future broker routing keys. Reverse of [`Topic::parse`].
pub fn to_wire_key(&self) -> String {
match self {
Topic::Folder(id) => format!("folder:{id}"),
Topic::UserAuthz(id) => format!("user:{id}:authz"),
}
}
/// Parse a wire-form topic string. Rejects unknown shapes with a stable
/// error kind so the WS handler can respond with a JSON-RPC error object
/// (`topic_forbidden` for unknown topic shapes, `no_read` for known
/// shapes the caller can't reach — the latter after the AuthZ check).
pub fn parse(s: &str) -> Result<Self, ParseTopicErr> {
if let Some(rest) = s.strip_prefix("folder:") {
let id = Uuid::parse_str(rest).map_err(|_| ParseTopicErr::BadUuid)?;
return Ok(Topic::Folder(id));
}
if let Some(rest) = s.strip_prefix("user:")
&& let Some((id_str, "authz")) = rest.rsplit_once(':')
{
let id = Uuid::parse_str(id_str).map_err(|_| ParseTopicErr::BadUuid)?;
return Ok(Topic::UserAuthz(id));
}
Err(ParseTopicErr::Unknown)
}
/// Which permission check the WS handler must run before allowing a
/// subscribe. Three classes per plan (see
/// `docs/plan/message-bus.md § AuthZ model`):
///
/// - Resource-scoped: default `Read` on the resource (Phase-B adds
/// `Share`/`Comment` for the stricter topics).
/// - Identity-scoped: `caller_id == subject_uuid`. No admin bypass.
/// - Role-scoped / bespoke: not represented in this MVP.
pub fn required_perm(&self) -> AuthzCheck {
match self {
Topic::Folder(id) => AuthzCheck::ResourceRead {
resource: BusResource::Folder(*id),
},
Topic::UserAuthz(id) => AuthzCheck::IdentityMatch { user_id: *id },
}
}
}
/// Parse failure for a wire-form topic string. Kept small — the WS handler
/// maps every variant to `topic_forbidden` on the wire (both a bad UUID and
/// an unknown shape are indistinguishable from the caller's perspective).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ParseTopicErr {
/// The prefix was recognized but the UUID inside didn't parse.
BadUuid,
/// The topic string didn't match any known shape (typo, or a topic
/// that isn't in this MVP).
Unknown,
}
// ════════════════════════════════════════════════════════════════════════════
// AuthzCheck — the gate class the WS handler dispatches on
// ════════════════════════════════════════════════════════════════════════════
/// Resource kinds the bus knows how to gate on. Deliberately a small closed
/// enum, not the full `domain::authorization::Resource` — the bus does not
/// need every resource type in the domain, and keeping this separate avoids
/// dragging domain-shaped churn into the port.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BusResource {
Folder(Uuid),
// File(Uuid), Drive(Uuid), Calendar(Uuid), AddressBook(Uuid) land with
// their topic variants.
}
/// The check the WS handler must run at subscribe time. Split into the three
/// classes described in `docs/plan/message-bus.md § AuthZ model`, so a new
/// topic variant with a new gate shape is a compile error at the dispatch
/// site rather than a runtime "unhandled" bug.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthzCheck {
/// Class 1 — Resource-scoped, default gate is Read on the resource.
/// Extend to `ResourceShare`/`ResourceComment` when the Phase-B topics
/// (`file:{id}:shares`, `file:{id}:comments`) land.
ResourceRead { resource: BusResource },
/// Class 2 — Identity-scoped. `caller_id` must equal `user_id`.
/// No admin bypass — privacy is a hard rule.
IdentityMatch { user_id: Uuid },
// Class 3 (role-scoped `admin:*`) and the bespoke `job:{id}` check
// land with their topic variants.
}
// ════════════════════════════════════════════════════════════════════════════
// RealtimeEvent — the payload
// ════════════════════════════════════════════════════════════════════════════
/// A fact that has just become true. Emitted by services AFTER commit,
/// never inside a DB transaction — a rollback would otherwise fan out a
/// lie.
///
/// Payloads are **thin facts** (ids + actor + verb): the client refetches
/// details via REST when it needs them. This keeps the AuthZ surface small
/// (thin payloads can't leak fields the caller couldn't already read via
/// REST) and keeps events well under the ~8 KB pg NOTIFY cap when the
/// `PgListenReplicator` seam is filled in later.
///
/// Wire form uses `#[serde(tag = "event", rename_all = "snake_case")]`;
/// discriminator strings are the JSON-RPC notification `event` field. New
/// denial cause / new event = new variant, never repurpose an existing one,
/// per project convention.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "event", rename_all = "snake_case")]
pub enum RealtimeEvent {
/// A file was created inside `parent_id`.
FileCreated {
file_id: Uuid,
name: String,
parent_id: Uuid,
actor: Uuid,
},
/// A sub-folder was created inside `parent_id`.
FolderCreated {
folder_id: Uuid,
name: String,
parent_id: Uuid,
actor: Uuid,
},
}
// ════════════════════════════════════════════════════════════════════════════
// JSON-RPC 2.0 error codes — stable, never repurpose
// ════════════════════════════════════════════════════════════════════════════
/// JSON-RPC 2.0 `error.code` values used on the WS wire. Follows the spec's
/// "server-defined" range `-32000` to `-32099` for our application-defined
/// codes; the standard `-326xx` envelope codes are re-exported here too so
/// the WS handler has one place to reach for.
///
/// See `docs/plan/message-bus.md § JSON-RPC error codes` for the
/// wire-`message`/audit-`reason` mapping.
pub mod error_code {
/// Resource-scoped topic, caller lacks Read (or resource doesn't exist —
/// indistinguishable to caller by design). Anti-enum invariant.
pub const NO_READ: i32 = -32001;
/// Resource-scoped topic requiring `Share`, caller has Read but not
/// Share. Applies to `file:{id}:shares` (Phase B).
pub const NO_SHARE: i32 = -32002;
/// Resource-scoped topic requiring `Comment` (`file:{id}:comments`
/// Phase B).
pub const NO_COMMENT: i32 = -32003;
/// Identity-scoped mismatch OR unknown/malformed topic. Same wire code
/// regardless of whether the target user exists — anti-enum.
pub const TOPIC_FORBIDDEN: i32 = -32004;
/// Per-connection sub cap hit.
pub const SUB_LIMIT: i32 = -32005;
/// Subscribe-frame token bucket exhausted.
pub const RATE_LIMITED: i32 = -32006;
/// CRDT edit frame from a caller without `Edit`. Emitted as an
/// `rt.write_denied` notification (not tied to a request `id`).
pub const NO_EDIT: i32 = -32007;
// ────────────────────── JSON-RPC 2.0 standard codes ─────────────────────
// Re-exported so the WS handler doesn't reach for two constant lists.
/// Server-side failure the client should retry.
pub const INTERNAL_ERROR: i32 = -32603;
/// Malformed JSON-RPC envelope (missing `method`, wrong `jsonrpc`
/// version).
pub const INVALID_REQUEST: i32 = -32600;
/// Method outside the `rt.*` allowlist.
pub const METHOD_NOT_FOUND: i32 = -32601;
/// Method known but `params` shape wrong (missing `topic`, unparseable).
pub const INVALID_PARAMS: i32 = -32602;
}
// ════════════════════════════════════════════════════════════════════════════
// RealtimeBus — the port
// ════════════════════════════════════════════════════════════════════════════
/// The local-facing message bus. Fire-and-forget publish, stream subscribe.
///
/// `publish` is intentionally synchronous — services must not `await` under
/// a DB transaction (a slow subscriber could hold the tx open) and services
/// should not care whether fan-out is happening in a background task or not.
///
/// `subscribe` returns a `Stream` so the impl can change (broadcast, mpsc,
/// pg listener) without churn at the consumer.
pub trait RealtimeBus: Send + Sync + 'static {
/// Fan an event out to every current subscriber of `topic`. Never
/// blocks; slow subscribers are dropped by the impl (they'll reconnect
/// and refetch).
fn publish(&self, topic: &Topic, event: RealtimeEvent);
/// Subscribe to `topic`. The returned stream yields events until the
/// subscriber is dropped or the impl kicks it out (e.g. for lagging
/// too far behind).
fn subscribe(&self, topic: &Topic) -> BusStream;
}
/// Boxed stream returned by [`RealtimeBus::subscribe`]. Aliased so
/// consumers don't need to spell out the `Pin<Box<...>>` shape.
pub type BusStream = Pin<Box<dyn Stream<Item = RealtimeEvent> + Send>>;
// ════════════════════════════════════════════════════════════════════════════
// BusReplicator — the multi-instance seam (day-1 noop)
// ════════════════════════════════════════════════════════════════════════════
/// Cross-instance replicator. Sits BESIDE [`RealtimeBus`], not in front of
/// it — the bus does the local fan-out; the replicator forwards outbound
/// publishes to the broker (pg NOTIFY, RabbitMQ, NATS) and injects inbound
/// broker messages back into the local bus.
///
/// V1 ships [`NoopReplicator`]. The trait is declared today so wiring the
/// day the second impl arrives is drop-in.
#[async_trait::async_trait]
pub trait BusReplicator: Send + Sync + 'static {
/// Called by the local bus for every publish. Fire-and-forget — must not
/// block or await; forwarding to the broker happens on a background task
/// owned by the impl.
fn on_local_publish(&self, topic: &Topic, event: &RealtimeEvent);
/// Long-running consumer task: reads remote messages and re-publishes
/// locally. Returns when `shutdown` is notified — DI calls
/// `shutdown.notify_one()` on graceful shutdown.
///
/// **Shutdown semantics:** use `Notify::notify_one` (not
/// `notify_waiters`) at the signalling site: `notify_one` stores a
/// permit if no waiter is currently parked, so signal-before-park is
/// safe. `notify_waiters` silently drops signals sent before parking
/// and creates a race. This constrains the impl to a single-waiter
/// shutdown handle; multi-task replicators must spin their own
/// `CancellationToken`-style fan-out internally.
async fn run(self: Arc<Self>, shutdown: Arc<Notify>) -> Result<(), DomainError>;
}
/// Day-1 replicator: does nothing. Wired unconditionally so callers hold
/// `Arc<dyn BusReplicator>` uniformly. Swapped for a real impl when
/// multi-instance deployment matters.
#[derive(Default)]
pub struct NoopReplicator;
#[async_trait::async_trait]
impl BusReplicator for NoopReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) {
// Intentionally empty. Local fan-out already happened in the bus.
}
async fn run(self: Arc<Self>, shutdown: Arc<Notify>) -> Result<(), DomainError> {
// Park until shutdown so the DI-managed handle stays alive with the
// same lifecycle as a future real replicator.
shutdown.notified().await;
Ok(())
}
}
// ════════════════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn folder_topic_roundtrip() {
let id = Uuid::new_v4();
let t = Topic::Folder(id);
let wire = t.to_wire_key();
assert_eq!(wire, format!("folder:{id}"));
assert_eq!(Topic::parse(&wire).unwrap(), t);
}
#[test]
fn user_authz_topic_roundtrip() {
let id = Uuid::new_v4();
let t = Topic::UserAuthz(id);
let wire = t.to_wire_key();
assert_eq!(wire, format!("user:{id}:authz"));
assert_eq!(Topic::parse(&wire).unwrap(), t);
}
#[test]
fn parse_rejects_bad_uuid() {
assert_eq!(
Topic::parse("folder:not-a-uuid"),
Err(ParseTopicErr::BadUuid)
);
}
#[test]
fn parse_rejects_unknown_shape() {
assert_eq!(Topic::parse(""), Err(ParseTopicErr::Unknown));
assert_eq!(Topic::parse("unknown:x"), Err(ParseTopicErr::Unknown));
assert_eq!(
Topic::parse(&format!("user:{}", Uuid::new_v4())),
Err(ParseTopicErr::Unknown),
"user:<uuid> without :authz suffix is not a known topic in MVP"
);
}
#[test]
fn required_perm_folder_is_resource_read() {
let id = Uuid::new_v4();
assert_eq!(
Topic::Folder(id).required_perm(),
AuthzCheck::ResourceRead {
resource: BusResource::Folder(id)
}
);
}
#[test]
fn required_perm_user_authz_is_identity_match() {
let id = Uuid::new_v4();
assert_eq!(
Topic::UserAuthz(id).required_perm(),
AuthzCheck::IdentityMatch { user_id: id }
);
}
#[test]
fn event_serializes_with_snake_case_discriminator() {
// The `#[serde(tag = "event")]` shape is the WS wire contract for
// the `rt.event` JSON-RPC notification's `params.event` field. Pin
// it with a snapshot so accidental rename of the enum variant
// fails the test instead of silently breaking clients.
let ev = RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["event"], "file_created");
assert_eq!(json["name"], "notes.md");
let ev = RealtimeEvent::FolderCreated {
folder_id: Uuid::nil(),
name: "docs".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let json = serde_json::to_value(&ev).unwrap();
assert_eq!(json["event"], "folder_created");
}
#[test]
fn event_roundtrip() {
let file_id = Uuid::new_v4();
let parent_id = Uuid::new_v4();
let actor = Uuid::new_v4();
let original = RealtimeEvent::FileCreated {
file_id,
name: "a.txt".into(),
parent_id,
actor,
};
let json = serde_json::to_string(&original).unwrap();
let decoded: RealtimeEvent = serde_json::from_str(&json).unwrap();
assert_eq!(decoded, original);
}
#[test]
fn error_codes_stay_in_the_defined_ranges() {
// Application-defined codes live in the JSON-RPC "server-defined"
// range -32000..=-32099. Standard envelope codes live in
// -32700..=-32600. A refactor that moves a value out of its range
// is a wire-break — pin it here.
for code in [
error_code::NO_READ,
error_code::NO_SHARE,
error_code::NO_COMMENT,
error_code::TOPIC_FORBIDDEN,
error_code::SUB_LIMIT,
error_code::RATE_LIMITED,
error_code::NO_EDIT,
] {
assert!(
(-32099..=-32000).contains(&code),
"app-defined code {code} outside -32099..=-32000"
);
}
for code in [
error_code::INTERNAL_ERROR,
error_code::INVALID_REQUEST,
error_code::METHOD_NOT_FOUND,
error_code::INVALID_PARAMS,
] {
assert!(
(-32700..=-32600).contains(&code),
"standard code {code} outside -32700..=-32600"
);
}
}
#[tokio::test]
async fn noop_replicator_parks_until_notified() {
let repl = Arc::new(NoopReplicator);
let shutdown = Arc::new(Notify::new());
let handle = tokio::spawn({
let repl = Arc::clone(&repl);
let shutdown = Arc::clone(&shutdown);
async move { BusReplicator::run(repl, shutdown).await }
});
// on_local_publish is a no-op that should not panic or spawn work.
repl.on_local_publish(
&Topic::Folder(Uuid::nil()),
&RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "x".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
},
);
// `notify_one` (not `notify_waiters`) so the signal survives if the
// spawned task hasn't yet reached `.notified().await` — permit
// queues instead of being dropped. See BusReplicator docs.
shutdown.notify_one();
handle.await.unwrap().unwrap();
}
}
@@ -56,6 +56,13 @@ pub struct FileUploadService {
/// (`create_file_from_owned_blob_with_perms`); `None` in minimal test
/// wiring.
instant_upload: Option<InstantUploadDeps>,
/// Realtime message bus. When wired, `upload_file_streaming`
/// publishes a `FileCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new file appear in
/// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path.
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>,
}
/// Everything the instant-upload path needs beyond the upload service's own
@@ -78,6 +85,7 @@ impl FileUploadService {
resource_access_hook: None,
authorization: None,
instant_upload: None,
bus: None,
}
}
@@ -95,6 +103,7 @@ impl FileUploadService {
resource_access_hook: None,
authorization: None,
instant_upload: None,
bus: None,
}
}
@@ -108,6 +117,18 @@ impl FileUploadService {
self
}
/// Wire the realtime message bus. Enables live folder-view updates:
/// after `upload_file_streaming` commits, a `FileCreated` event
/// fires on `Topic::Folder(parent_id)` — subscribers see the new
/// file appear without polling.
pub fn with_realtime_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
) -> Self {
self.bus = Some(bus);
self
}
/// Wires the authorization engine, dedup index and quota service that
/// power the instant-upload path.
///
@@ -454,6 +475,27 @@ impl FileUploadUseCase for FileUploadService {
// The caller just created this file — surface it in Recent so the
// "I just uploaded X" UX matches the pre-SvelteKit behaviour.
self.notify_file_accessed(caller_id, &dto.id);
// Realtime fan-out AFTER commit — subscribers to the parent
// folder's topic see the new file appear live. Silent no-op if
// the bus isn't wired (stubs / tests) or the file landed at
// drive-root (no folder id → nothing to publish on).
if let (Some(bus), Some(parent_folder_id)) = (&self.bus, dto.folder_id.as_deref())
&& let (Ok(parent_uuid), Ok(file_uuid)) =
(Uuid::parse_str(parent_folder_id), Uuid::parse_str(&dto.id))
{
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FileCreated {
file_id: file_uuid,
name: dto.name.clone(),
parent_id: parent_uuid,
actor: caller_id,
},
);
}
Ok(dto)
}
@@ -49,6 +49,13 @@ pub struct FolderService {
/// on cross-drive MOVE. Silently skipped when unwired (stubs).
storage_usage:
Option<Arc<crate::application::services::storage_usage_service::StorageUsageService>>,
/// Realtime message bus. When wired, `create_folder_with_perms`
/// publishes a `FolderCreated` event on `Topic::Folder(parent_id)`
/// after the DB commit — subscribers see the new folder appear in
/// their live folder view. Optional so stub / test factories can
/// build the service without a bus; a `None` bus is a silent no-op
/// on the publish path (no fan-out, no audit).
bus: Option<Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>>,
}
impl FolderService {
@@ -66,9 +73,21 @@ impl FolderService {
file_lifecycle,
drive_repo: None,
storage_usage: None,
bus: None,
}
}
/// Wire the realtime message bus. Enables live folder-view updates:
/// after `create_folder_with_perms` commits, a `FolderCreated` event
/// fires on `Topic::Folder(parent_id)`. Off in stubs / tests.
pub fn with_realtime_bus(
mut self,
bus: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus>,
) -> Self {
self.bus = Some(bus);
self
}
/// Borrow the external-mount classifier (handlers branch on this before
/// treating an id as a native UUID).
pub fn mount_router(&self) -> &MountRouter {
@@ -366,10 +385,39 @@ impl FolderUseCase for FolderService {
)
.await?;
// Snapshot the parent UUID before the move so the post-commit
// publish can address `Topic::Folder(parent_uuid)` without
// re-borrowing `dto.parent_id` (which is moved into
// `create_folder`).
let parent_uuid_for_publish = Uuid::parse_str(parent_id).ok();
let folder = self
.folder_storage
.create_folder(dto.name, dto.parent_id, caller_id)
.await?;
// Publish AFTER commit — never before, never inside the write.
// Silent no-op if the bus isn't wired (stubs/tests) or the
// parent uuid didn't parse (won't happen — AuthZ above already
// parsed it — but the None-fallthrough keeps the publish path
// infallible).
if let (Some(bus), Some(parent_uuid), Ok(folder_uuid)) = (
&self.bus,
parent_uuid_for_publish,
Uuid::parse_str(folder.id()),
) {
use crate::application::ports::realtime_ports::{RealtimeEvent, Topic};
bus.publish(
&Topic::Folder(parent_uuid),
RealtimeEvent::FolderCreated {
folder_id: folder_uuid,
name: folder.name().to_owned(),
parent_id: parent_uuid,
actor: caller_id,
},
);
}
Ok(FolderDto::from(folder))
}
+43 -2
View File
@@ -703,7 +703,14 @@ impl AppServiceFactory {
resource_access_hook: Option<
Arc<dyn crate::application::ports::resource_access_hook::ResourceAccessHook>,
>,
bus: &Arc<crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus>,
) -> ApplicationServices {
// Upcast the concrete bus once — service builders take the
// trait object so the wire remains stable across future bus
// impls.
let bus_trait: Arc<dyn crate::application::ports::realtime_ports::RealtimeBus> =
bus.clone();
// Main services
let folder_service = Arc::new(
FolderService::new(
@@ -724,7 +731,10 @@ impl AppServiceFactory {
// MOVE. Reuses the `check_drive_quota` the upload path
// already runs. Without this, a Move that would push the
// destination past its cap succeeds silently.
.with_storage_usage(storage_usage.clone()),
.with_storage_usage(storage_usage.clone())
// Realtime fan-out on `create_folder_with_perms` — the
// parent-folder subscribers see new sub-folders live.
.with_realtime_bus(bus_trait.clone()),
);
// Built before the upload/management services so the plugin lifecycle
@@ -771,7 +781,11 @@ impl AppServiceFactory {
authz.clone(),
core.dedup_service.clone(),
storage_usage.clone(),
);
)
// Realtime fan-out — every successful `upload_file_streaming`
// publishes a `FileCreated` event on the parent folder's
// topic so open folder views refresh live.
.with_realtime_bus(bus_trait.clone());
if let Some(hook) = resource_access_hook.clone() {
svc = svc.with_resource_access_hook(hook);
}
@@ -1775,6 +1789,17 @@ impl AppServiceFactory {
crate::application::services::external_mount_router::MountRouter::new(mount_registry),
);
// Realtime bus: single instance for the app lifetime, wired
// with a no-op replicator (multi-instance broker is a follow-up
// per `docs/plan/message-bus.md § Roadmap`). Constructed here
// so `create_application_services` can hand it to services that
// publish after their DB commits (`FolderService`,
// `FileUploadService`, …). Spawns its own GC task in
// `with_replicator` — no supervisor setup required.
let bus = crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus::with_replicator(
Arc::new(crate::application::ports::realtime_ports::NoopReplicator),
);
let mut apps = self.create_application_services(
&core,
&repos,
@@ -1786,6 +1811,7 @@ impl AppServiceFactory {
plugin_dispatch.clone(),
mount_router.clone(),
Some(resource_access_hook.clone()),
&bus,
);
// 5. Share service
@@ -2284,6 +2310,7 @@ impl AppServiceFactory {
db_pool: Some(pool.clone()),
maintenance_pool: Some(maintenance_pool),
mount_router,
bus,
auth_service: auth_services,
opaque_service,
opaque_repo,
@@ -3177,6 +3204,20 @@ pub struct AppState {
/// method (which still owns the authorization check).
pub mount_router:
Arc<crate::application::services::external_mount_router::MountRouter>,
/// Realtime message bus. Always present — an empty bus (no
/// subscribers, no publishes) costs a single `DashMap` allocation.
/// The WS handler reads `subscribe`; service publish hooks
/// (`FolderService::create_folder_with_perms`,
/// `FileManagementService`'s file-create commit) call `publish`
/// AFTER their DB transaction commits.
///
/// Stored as the concrete type (not `Arc<dyn RealtimeBus>`) so the
/// GC task's `Weak<Self>` lifecycle is legible from di.rs. Consumers
/// that only need the trait obtain it via
/// `Arc::clone(&state.bus) as Arc<dyn RealtimeBus>`.
pub bus: Arc<
crate::infrastructure::services::in_process_realtime_bus::InProcessRealtimeBus,
>,
pub auth_service: Option<AuthServices>,
/// OPAQUE aPAKE substrate (RFC 9807). Populated only when
/// [`OpaqueConfig::effective_mode`] is not `Off` — that method
@@ -0,0 +1,347 @@
//! In-process `RealtimeBus` — one `broadcast::Sender` per active topic,
//! held in a [`DashMap`] keyed by [`Topic`]. Publish is fire-and-forget,
//! subscribe returns a `Stream` backed by [`BroadcastStream`].
//!
//! # Slow-subscriber policy
//!
//! `tokio::sync::broadcast` drops the oldest queued message when a
//! subscriber can't keep up (ring is bounded to [`BROADCAST_RING_CAPACITY`]).
//! When a subscriber's stream sees a `Lagged` marker, the WS handler kills
//! that session with a JSON-RPC `rt.revoked` notification (reason
//! `slow_consumer`) and lets the client reconnect + refetch. That policy
//! lives in the handler; this module just surfaces the `Lagged` variant.
//!
//! # Topic GC
//!
//! When the last subscriber of a topic drops, `broadcast::Sender::receiver_count`
//! falls to zero. New publishes on that topic still succeed (they hit the
//! now-orphaned sender), but the entry stays in the map. A background GC
//! task periodically sweeps entries whose `receiver_count == 0`. Kept
//! simple: no ref-count tracking, no watchdog — a sweep every
//! [`GC_INTERVAL`] is enough for our fan-out volume.
use std::sync::Arc;
use std::time::Duration;
use dashmap::DashMap;
use futures::StreamExt;
use tokio::sync::broadcast;
use tokio_stream::wrappers::BroadcastStream;
use crate::application::ports::realtime_ports::{
BusReplicator, BusStream, RealtimeBus, RealtimeEvent, Topic,
};
/// Per-topic ring-buffer size for slow subscribers. When a subscriber lags
/// past this, the broadcast channel starts dropping the oldest messages and
/// signals `Lagged`. Sized generously — fan-out volume per topic is low
/// (folder mutations, job step ticks) so pressure comes from a genuinely
/// dead consumer, not from a normal traffic spike.
pub const BROADCAST_RING_CAPACITY: usize = 256;
/// How often the GC task sweeps empty topics. Short enough that a burst of
/// short-lived subs (folder navigations) doesn't grow the map indefinitely,
/// long enough that GC overhead stays trivial.
pub const GC_INTERVAL: Duration = Duration::from_secs(60);
/// The in-process implementation of [`RealtimeBus`].
///
/// Callers hold `Arc<InProcessRealtimeBus>` (or `Arc<dyn RealtimeBus>`).
/// The struct owns its topic map and — when constructed via
/// [`InProcessRealtimeBus::with_replicator`] — an [`Arc<dyn BusReplicator>`]
/// that gets fed every local publish for outbound broker forwarding.
pub struct InProcessRealtimeBus {
topics: DashMap<Topic, broadcast::Sender<RealtimeEvent>>,
replicator: Arc<dyn BusReplicator>,
}
impl InProcessRealtimeBus {
/// Construct with a replicator. In v1 that's a
/// [`crate::application::ports::realtime_ports::NoopReplicator`]; when
/// multi-instance ships, it becomes the pg-NOTIFY or broker impl.
///
/// The GC task holds a [`Weak`] handle so it exits naturally when the
/// last outer `Arc` drops — matches OxiCloud's DI convention that
/// background tasks are dropped on runtime shutdown, no explicit
/// signal needed.
pub fn with_replicator(replicator: Arc<dyn BusReplicator>) -> Arc<Self> {
let bus = Arc::new(Self {
topics: DashMap::new(),
replicator,
});
bus.spawn_gc();
bus
}
/// Spawn the periodic GC task. Holds `Weak<Self>` so it does not keep
/// the bus alive past the last outer `Arc` drop; the next
/// upgrade-and-sweep call after that returns `None` and the loop
/// exits.
fn spawn_gc(self: &Arc<Self>) {
let weak = Arc::downgrade(self);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(GC_INTERVAL);
// First tick fires immediately; skip it so we don't sweep an
// empty map on startup.
ticker.tick().await;
loop {
ticker.tick().await;
match weak.upgrade() {
Some(bus) => bus.gc_empty_topics(),
None => break,
}
}
});
}
/// Remove topics whose broadcast sender has no live receivers. Called
/// on the GC ticker.
fn gc_empty_topics(&self) {
self.topics
.retain(|_topic, sender| sender.receiver_count() > 0);
}
/// For tests + observability: how many topics currently have a
/// broadcast sender in the map.
pub fn active_topic_count(&self) -> usize {
self.topics.len()
}
/// Get-or-insert the broadcast sender for `topic`, returning a fresh
/// receiver. Used by both `publish` (for the sender) and `subscribe`
/// (for the receiver) — one code path for the map insert avoids a race
/// where publish creates a sender concurrent subscribers miss.
fn sender_for(&self, topic: &Topic) -> broadcast::Sender<RealtimeEvent> {
self.topics
.entry(*topic)
.or_insert_with(|| broadcast::channel(BROADCAST_RING_CAPACITY).0)
.clone()
}
}
impl RealtimeBus for InProcessRealtimeBus {
fn publish(&self, topic: &Topic, event: RealtimeEvent) {
// Feed the replicator FIRST — if it were called after local fan-out,
// an unwind on a broken subscriber could skip broker forwarding.
// `on_local_publish` is a sync fire-and-forget contract; slow
// replicators must background their I/O themselves.
self.replicator.on_local_publish(topic, &event);
// If nobody is subscribed, don't allocate a sender just to drop
// its message. `broadcast::Sender::send` returns Err when there
// are no receivers — cheaper still to short-circuit here.
if let Some(sender) = self.topics.get(topic) {
// `send` never blocks; it drops the oldest when the ring is
// full, signalling `Lagged` on that subscriber's next recv.
let _ = sender.send(event);
}
// Else: no active subs. Event is lost by design (see
// `docs/plan/message-bus.md § Failure modes`).
}
fn subscribe(&self, topic: &Topic) -> BusStream {
let receiver = self.sender_for(topic).subscribe();
// `BroadcastStream` yields `Result<T, BroadcastStreamRecvError>`;
// filter out the `Lagged` variant here and terminate the stream on
// it so the WS handler sees a clean "the stream ended" signal
// rather than having to match on the error. The handler is
// responsible for emitting the `rt.revoked` notification with
// `slow_consumer` reason on such a termination.
let stream = BroadcastStream::new(receiver).take_while(|item| {
let keep = item.is_ok();
async move { keep }
});
Box::pin(stream.filter_map(|item| async move { item.ok() }))
}
}
// ════════════════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
use crate::application::ports::realtime_ports::NoopReplicator;
use futures::StreamExt;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::time::Duration;
use tokio::sync::Notify;
use uuid::Uuid;
fn make_bus() -> Arc<InProcessRealtimeBus> {
InProcessRealtimeBus::with_replicator(Arc::new(NoopReplicator))
}
fn folder_topic() -> Topic {
Topic::Folder(Uuid::new_v4())
}
fn file_created(parent_id: Uuid) -> RealtimeEvent {
RealtimeEvent::FileCreated {
file_id: Uuid::new_v4(),
name: "a.txt".into(),
parent_id,
actor: Uuid::new_v4(),
}
}
/// Positive fan-out: a subscriber to a topic receives an event
/// published on that same topic.
#[tokio::test]
async fn subscriber_receives_publish_on_same_topic() {
let bus = make_bus();
let topic = folder_topic();
let mut stream = bus.subscribe(&topic);
// Give the subscriber a moment to install (broadcast::Sender::send
// silently fails against a not-yet-installed receiver; the
// subscribe() call above is synchronous but the receiver still
// needs to be registered on the sender's side before publish).
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event = file_created(parent);
bus.publish(&topic, event.clone());
let received = tokio::time::timeout(Duration::from_millis(200), stream.next())
.await
.expect("event should arrive within 200ms")
.expect("stream must yield at least once");
assert_eq!(received, event);
}
/// Topic isolation: a subscriber to folder A does not receive events
/// published on folder B. This is the invariant the smoke test's
/// Scenario 2 asserts end-to-end; verifying it in-unit here catches
/// bugs early.
#[tokio::test]
async fn subscriber_does_not_receive_other_topic() {
let bus = make_bus();
let topic_a = folder_topic();
let topic_b = folder_topic();
assert_ne!(topic_a, topic_b);
let mut stream_a = bus.subscribe(&topic_a);
let parent_b = match topic_b {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event_b = file_created(parent_b);
bus.publish(&topic_b, event_b);
// The A subscriber must NOT see B's event. Poll with a short
// timeout — if the isolation is broken we'll see the event; if
// it holds we'll time out.
let result = tokio::time::timeout(Duration::from_millis(100), stream_a.next()).await;
assert!(
result.is_err(),
"subscriber to topic A must not observe events published on topic B \
(got {:?})",
result.ok().flatten()
);
}
/// Multiple subscribers to the same topic all see each publish.
#[tokio::test]
async fn multi_subscriber_fanout() {
let bus = make_bus();
let topic = folder_topic();
let mut s1 = bus.subscribe(&topic);
let mut s2 = bus.subscribe(&topic);
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
let event = file_created(parent);
bus.publish(&topic, event.clone());
let r1 = tokio::time::timeout(Duration::from_millis(200), s1.next())
.await
.unwrap()
.unwrap();
let r2 = tokio::time::timeout(Duration::from_millis(200), s2.next())
.await
.unwrap()
.unwrap();
assert_eq!(r1, event);
assert_eq!(r2, event);
}
/// Publishing with no subscribers is a no-op (does not panic, does not
/// grow the map into an orphan-sender state we later have to sweep).
#[tokio::test]
async fn publish_with_no_subscribers_is_noop() {
let bus = make_bus();
let topic = folder_topic();
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
bus.publish(&topic, file_created(parent));
assert_eq!(
bus.active_topic_count(),
0,
"publish without any subscribe must not insert into topics map"
);
}
/// The replicator sees every publish, exactly once per publish.
/// Locks in the "feed replicator FIRST" contract without asserting on
/// broker semantics we don't control from unit-tests.
#[tokio::test]
async fn replicator_is_notified_on_publish() {
struct CountingReplicator {
count: AtomicUsize,
}
#[async_trait::async_trait]
impl BusReplicator for CountingReplicator {
fn on_local_publish(&self, _topic: &Topic, _event: &RealtimeEvent) {
self.count.fetch_add(1, Ordering::SeqCst);
}
async fn run(
self: Arc<Self>,
shutdown: Arc<Notify>,
) -> Result<(), crate::common::errors::DomainError> {
shutdown.notified().await;
Ok(())
}
}
let counter = Arc::new(CountingReplicator {
count: AtomicUsize::new(0),
});
let bus = InProcessRealtimeBus::with_replicator(Arc::clone(&counter) as Arc<_>);
let topic = folder_topic();
let _sub = bus.subscribe(&topic);
let parent = match topic {
Topic::Folder(id) => id,
_ => unreachable!(),
};
bus.publish(&topic, file_created(parent));
bus.publish(&topic, file_created(parent));
bus.publish(&topic, file_created(parent));
assert_eq!(counter.count.load(Ordering::SeqCst), 3);
}
/// Dropping the last subscriber leaves the topic sender orphaned until
/// the GC sweeps it. We don't wait for the timer here (that would make
/// the test slow); instead we call `gc_empty_topics` directly to
/// verify the sweep does what it promises.
#[tokio::test]
async fn gc_removes_empty_topics() {
let bus = make_bus();
let topic = folder_topic();
{
let _sub = bus.subscribe(&topic);
assert_eq!(bus.active_topic_count(), 1);
}
// Subscriber dropped. Sender is still in the map, but receiver
// count is zero — the sweep should reclaim it.
bus.gc_empty_topics();
assert_eq!(bus.active_topic_count(), 0);
}
}
+1
View File
@@ -27,6 +27,7 @@ pub mod files_consistency_service;
pub mod folders_consistency_service;
pub mod grant_cleanup_service;
pub mod image_transcode_service;
pub mod in_process_realtime_bus;
pub mod jwt_service;
pub mod last_seen_tracker;
pub mod local_blob_backend;
+1
View File
@@ -23,6 +23,7 @@ pub mod opaque_auth_handler;
pub mod people_handler;
pub mod photos_handler;
pub mod recent_handler;
pub mod rt_ws;
pub mod search_handler;
pub mod share_handler;
pub mod subject_group_handler;
+552
View File
@@ -0,0 +1,552 @@
//! Realtime bus WebSocket handler — the endpoint every WS session
//! multiplexes over. See `docs/plan/message-bus.md § Wire protocol`.
//!
//! # Wire
//!
//! JSON-RPC 2.0 for control + events (text frames). Binary frames are
//! reserved for the Yjs sync protocol (collab editor, Phase A follow-up)
//! and are IGNORED in MVP.
//!
//! Methods accepted in MVP:
//! - `rt.subscribe { topic }` → `{ subscribed: "<topic>" }` or JSON-RPC error.
//! - `rt.unsubscribe { topic }` → `{ unsubscribed: "<topic>" }`.
//! - `rt.ping` → `{ pong: true }`.
//!
//! Server-initiated notifications:
//! - `rt.event { topic, event, data, actor, ts }` — an event published to
//! a topic the caller is subscribed to.
//!
//! # Auth
//!
//! Route sits under `protected_api` (see `src/interfaces/api/routes.rs`)
//! so `auth_middleware` runs first. Cookie AND `Authorization: Bearer`
//! paths both produce a `CurrentUserId` extension the handler extracts.
//! Browser-side subprotocol bearer (`Sec-WebSocket-Protocol:
//! authorization.bearer.<jwt>`) is a Phase-A follow-up — the MVP relies
//! on the Authorization header, which programmatic clients (the
//! `rt-hurl-helper` smoke test) set directly.
//!
//! # Limits
//!
//! Per-connection outbound `mpsc::Sender` bounded to 512 — full → close.
//! Per-connection subscription cap 128. Frame-size cap not enforced in
//! MVP; the underlying tokio-tungstenite default is 64 MiB which is more
//! than adequate for JSON-RPC control traffic.
use std::collections::HashMap;
use std::sync::Arc;
use axum::extract::State;
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
use axum::response::Response;
use futures::StreamExt;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use uuid::Uuid;
use crate::application::ports::authorization_ports::AuthorizationEngine;
use crate::application::ports::realtime_ports::{
AuthzCheck, BusResource, ParseTopicErr, RealtimeBus, RealtimeEvent, Topic, error_code,
};
use crate::common::di::AppState;
use crate::domain::services::authorization::{Permission, Resource, Subject};
use crate::interfaces::middleware::auth::CurrentUserId;
/// Max simultaneous subscriptions on a single WS session. Beyond this the
/// server responds `-32005 sub_limit` and the client is expected to
/// unsubscribe before subscribing to another topic.
const MAX_SUBSCRIPTIONS_PER_CONNECTION: usize = 128;
/// Outbound mpsc capacity per session. When full → we close the WS
/// (client reconnects, refetches). Sized so a subscriber blocked on the
/// socket layer doesn't back-pressure into the bus's broadcast ring.
const OUTBOUND_CHANNEL_CAPACITY: usize = 512;
// ════════════════════════════════════════════════════════════════════════════
// JSON-RPC 2.0 envelope types
// ════════════════════════════════════════════════════════════════════════════
/// Marker constant for the `jsonrpc` field.
const JSONRPC_V2: &str = "2.0";
/// Inbound JSON-RPC envelope — deserialize-tolerant so a client can
/// send `rt.ping` without `params`, or a notification without an `id`.
///
/// Response/notification serialization uses the more strongly-typed
/// [`RpcResponse`] and [`RpcNotification`] types below.
#[derive(Debug, Deserialize)]
struct RpcRequest {
#[serde(rename = "jsonrpc")]
_jsonrpc: Option<String>,
/// `id` is `None` for notifications (which the client-side of MVP
/// never sends). We accept it in the shape but do not treat missing
/// `id` as a permitted request — every `rt.*` method requires
/// `id`-correlated responses in MVP.
id: Option<Value>,
method: Option<String>,
#[serde(default)]
params: Value,
}
/// Server → client response for a request (both success and error use
/// this shape; exactly one of `result`/`error` is populated per spec).
#[derive(Debug, Serialize)]
struct RpcResponse<'a> {
jsonrpc: &'static str,
id: Value,
#[serde(skip_serializing_if = "Option::is_none")]
result: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
error: Option<RpcError<'a>>,
}
#[derive(Debug, Serialize)]
struct RpcError<'a> {
code: i32,
message: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
}
/// Server → client notification (id-less). Emitted for `rt.event` (bus
/// fan-out) and `rt.revoked` (subscription eviction).
#[derive(Debug, Serialize)]
struct RpcNotification<'a> {
jsonrpc: &'static str,
method: &'a str,
params: Value,
}
// ════════════════════════════════════════════════════════════════════════════
// Handler entrypoint
// ════════════════════════════════════════════════════════════════════════════
/// `GET /api/rt/ws` — WS upgrade handler. Sits under `protected_api` so
/// [`CurrentUserId`] resolves against a valid session before we reach
/// `on_upgrade`.
///
/// Returns whatever `WebSocketUpgrade::on_upgrade` produces (an HTTP 101
/// Switching Protocols with the WebSocket handshake headers).
pub async fn rt_ws_handler(
ws: WebSocketUpgrade,
CurrentUserId(caller_id): CurrentUserId,
State(state): State<Arc<AppState>>,
) -> Response {
ws.on_upgrade(move |socket| handle_session(socket, caller_id, state))
}
// ════════════════════════════════════════════════════════════════════════════
// Session loop
// ════════════════════════════════════════════════════════════════════════════
/// A per-topic subscription: the join handle for the reader task that
/// drains the bus stream into `out_tx`. Dropping does NOT abort a spawned
/// tokio task — we must call `.abort()` explicitly on unsubscribe.
struct Sub {
reader: JoinHandle<()>,
}
impl Drop for Sub {
fn drop(&mut self) {
// Belt-and-braces: if `remove()` bypasses `.abort()` for some
// future call path, dropping still stops the reader.
self.reader.abort();
}
}
async fn handle_session(mut socket: WebSocket, caller_id: Uuid, state: Arc<AppState>) {
// Outbound queue — every path that produces a text frame for the
// client enqueues here; the writer half of the select drains.
let (out_tx, mut out_rx) = mpsc::channel::<String>(OUTBOUND_CHANNEL_CAPACITY);
// Active subscriptions on this session. Keyed by the wire-form topic
// string so an incoming `rt.unsubscribe` with the same string is
// recognised without re-parsing.
let mut subs: HashMap<String, Sub> = HashMap::new();
loop {
tokio::select! {
// biased: process outbound before inbound so an event burst
// doesn't get overtaken by a control-frame handshake.
biased;
outbound = out_rx.recv() => {
match outbound {
Some(text) => {
if socket.send(Message::Text(text.into())).await.is_err() {
break;
}
}
None => break, // out_tx dropped — unreachable but safe
}
}
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Text(txt))) => {
if let Some(reply) =
handle_text_frame(&txt, caller_id, &state, &mut subs, &out_tx).await
&& socket.send(Message::Text(reply.into())).await.is_err() {
break;
}
}
Some(Ok(Message::Binary(_))) => {
// Reserved for Yjs sync protocol frames (collab
// editor, Phase A follow-up). Silently ignored in
// MVP so a future client that speaks binary
// frames on the same connection isn't rejected.
}
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {
// Handled by axum's WebSocket state machine.
}
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
}
}
}
}
// Session cleanup: abort every subscription reader task.
subs.clear();
}
// ════════════════════════════════════════════════════════════════════════════
// Frame handling
// ════════════════════════════════════════════════════════════════════════════
/// Parse one inbound text frame as a JSON-RPC 2.0 request and dispatch
/// it. Returns the response string to send back (empty option if the
/// dispatch already enqueued via `out_tx`).
async fn handle_text_frame(
text: &str,
caller_id: Uuid,
state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<String>,
) -> Option<String> {
// Parse envelope. On malformed JSON: reply with an id-less error per
// JSON-RPC 2.0 (id = null when the request couldn't be parsed).
let req: RpcRequest = match serde_json::from_str(text) {
Ok(r) => r,
Err(_) => {
return Some(error_response(
Value::Null,
error_code::INVALID_REQUEST,
"invalid_request",
None,
));
}
};
let id = req.id.unwrap_or(Value::Null);
let Some(method) = req.method else {
return Some(error_response(
id,
error_code::INVALID_REQUEST,
"invalid_request",
None,
));
};
match method.as_str() {
"rt.subscribe" => {
Some(handle_subscribe(id, req.params, caller_id, state, subs, out_tx).await)
}
"rt.unsubscribe" => Some(handle_unsubscribe(id, req.params, subs)),
"rt.ping" => Some(success_response(id, serde_json::json!({ "pong": true }))),
_ => Some(error_response(
id,
error_code::METHOD_NOT_FOUND,
"method_not_found",
Some(serde_json::json!({ "method": method })),
)),
}
}
async fn handle_subscribe(
id: Value,
params: Value,
caller_id: Uuid,
state: &Arc<AppState>,
subs: &mut HashMap<String, Sub>,
out_tx: &mpsc::Sender<String>,
) -> String {
// Extract topic.
let topic_str = match params.get("topic").and_then(Value::as_str) {
Some(s) => s.to_owned(),
None => {
return error_response(
id,
error_code::INVALID_PARAMS,
"invalid_params",
Some(serde_json::json!({ "missing": "topic" })),
);
}
};
// Guard against runaway subscribers pinning server memory.
if subs.len() >= MAX_SUBSCRIPTIONS_PER_CONNECTION && !subs.contains_key(&topic_str) {
audit_denied(caller_id, &topic_str, "sub_limit");
return error_response(
id,
error_code::SUB_LIMIT,
"sub_limit",
Some(serde_json::json!({ "topic": topic_str })),
);
}
// Idempotent: re-subscribing to an already-active topic acks with
// no side effects. Client reconnect logic can replay its topic set
// without dedup.
if subs.contains_key(&topic_str) {
return success_response(id, serde_json::json!({ "subscribed": topic_str }));
}
// Parse topic.
let topic = match Topic::parse(&topic_str) {
Ok(t) => t,
Err(ParseTopicErr::BadUuid) | Err(ParseTopicErr::Unknown) => {
// Both parse failures collapse to `topic_forbidden` on the
// wire — the caller cannot distinguish "unknown shape" from
// "shape known but resource doesn't exist" without hinting
// an enumeration oracle.
audit_denied(caller_id, &topic_str, "unknown_topic");
return error_response(
id,
error_code::TOPIC_FORBIDDEN,
"topic_forbidden",
Some(serde_json::json!({ "topic": topic_str })),
);
}
};
// AuthZ dispatch — one match arm per gate class. Adding a new topic
// variant with a new gate shape is a compile error here.
match topic.required_perm() {
AuthzCheck::ResourceRead { resource } => {
let domain_resource = match resource {
BusResource::Folder(uuid) => Resource::Folder(uuid),
};
if state
.authorization
.require(Subject::User(caller_id), Permission::Read, domain_resource)
.await
.is_err()
{
// Anti-enum: "no such resource" and "no read" collapse
// to the same wire code. Audit records the truth.
audit_denied(caller_id, &topic_str, "no_read");
return error_response(
id,
error_code::NO_READ,
"no_read",
Some(serde_json::json!({ "topic": topic_str })),
);
}
}
AuthzCheck::IdentityMatch { user_id } => {
if user_id != caller_id {
audit_denied(caller_id, &topic_str, "identity_mismatch");
return error_response(
id,
error_code::TOPIC_FORBIDDEN,
"topic_forbidden",
Some(serde_json::json!({ "topic": topic_str })),
);
}
}
}
// AuthZ passed — install the subscription and spawn a reader task
// that forwards bus events to the outbound channel as `rt.event`
// notifications.
let stream = RealtimeBus::subscribe(state.bus.as_ref(), &topic);
let topic_wire = topic_str.clone();
let out_tx_task = out_tx.clone();
let reader = tokio::spawn(async move {
let mut stream = stream;
while let Some(event) = stream.next().await {
let notification = event_notification(&topic_wire, &event);
if out_tx_task.send(notification).await.is_err() {
// Session's outbound channel closed — receiver dropped.
break;
}
}
});
subs.insert(topic_str.clone(), Sub { reader });
success_response(id, serde_json::json!({ "subscribed": topic_str }))
}
fn handle_unsubscribe(id: Value, params: Value, subs: &mut HashMap<String, Sub>) -> String {
let Some(topic_str) = params.get("topic").and_then(Value::as_str) else {
return error_response(
id,
error_code::INVALID_PARAMS,
"invalid_params",
Some(serde_json::json!({ "missing": "topic" })),
);
};
// Idempotent: removing a topic the session isn't subscribed to is
// still a success ack, per plan.
subs.remove(topic_str);
success_response(id, serde_json::json!({ "unsubscribed": topic_str }))
}
// ════════════════════════════════════════════════════════════════════════════
// Envelope helpers
// ════════════════════════════════════════════════════════════════════════════
fn success_response(id: Value, result: Value) -> String {
serde_json::to_string(&RpcResponse {
jsonrpc: JSONRPC_V2,
id,
result: Some(result),
error: None,
})
.expect("RpcResponse always serializes")
}
fn error_response(id: Value, code: i32, message: &str, data: Option<Value>) -> String {
serde_json::to_string(&RpcResponse {
jsonrpc: JSONRPC_V2,
id,
result: None,
error: Some(RpcError {
code,
message,
data,
}),
})
.expect("RpcResponse always serializes")
}
/// Build an `rt.event` JSON-RPC notification for a bus event.
///
/// Payload discipline (see plan): thin facts only. The `RealtimeEvent`'s
/// own `#[serde(tag = "event")]` shape provides `event` + variant fields
/// under one flat object; we lift them into `params.data` alongside a
/// `topic` selector for the client.
fn event_notification(topic_wire: &str, event: &RealtimeEvent) -> String {
// Serialize the event to extract `event` (discriminator) and the
// remaining fields as `data`. Two-step to avoid re-inventing the
// enum's discriminator string here.
let event_json = serde_json::to_value(event).expect("RealtimeEvent always serializes");
let (event_name, data) = split_event_discriminator(event_json);
let params = serde_json::json!({
"topic": topic_wire,
"event": event_name,
"data": data,
});
serde_json::to_string(&RpcNotification {
jsonrpc: JSONRPC_V2,
method: "rt.event",
params,
})
.expect("RpcNotification always serializes")
}
/// Given a `RealtimeEvent` serialised as `{ "event": "file_created", ...rest }`,
/// split into `(event_name, rest)`. Falls back to `("unknown", full)` if
/// the shape doesn't match (defensive — shouldn't happen given the enum
/// derive, but a future untagged variant would land here).
fn split_event_discriminator(mut event_json: Value) -> (String, Value) {
if let Some(obj) = event_json.as_object_mut()
&& let Some(Value::String(name)) = obj.remove("event")
{
return (name, Value::Object(obj.clone()));
}
("unknown".to_owned(), event_json)
}
// ════════════════════════════════════════════════════════════════════════════
// Audit
// ════════════════════════════════════════════════════════════════════════════
fn audit_denied(caller_id: Uuid, topic: &str, reason: &'static str) {
tracing::info!(
target: "audit",
event = "realtime.subscribe_denied",
reason = reason,
caller_id = %caller_id,
topic = %topic,
"👮🏻‍♂️ realtime subscribe rejected",
);
}
// ════════════════════════════════════════════════════════════════════════════
// Tests
// ════════════════════════════════════════════════════════════════════════════
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn success_response_shape() {
let s = success_response(
Value::Number(42.into()),
serde_json::json!({ "subscribed": "folder:x" }),
);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 42);
assert_eq!(v["result"]["subscribed"], "folder:x");
assert!(v.get("error").is_none());
}
#[test]
fn error_response_shape() {
let s = error_response(
Value::Number(7.into()),
error_code::NO_READ,
"no_read",
Some(serde_json::json!({ "topic": "folder:x" })),
);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["id"], 7);
assert_eq!(v["error"]["code"], error_code::NO_READ);
assert_eq!(v["error"]["message"], "no_read");
assert_eq!(v["error"]["data"]["topic"], "folder:x");
assert!(v.get("result").is_none());
}
#[test]
fn event_notification_shape() {
let event = RealtimeEvent::FileCreated {
file_id: Uuid::nil(),
name: "notes.md".into(),
parent_id: Uuid::nil(),
actor: Uuid::nil(),
};
let s = event_notification("folder:abc", &event);
let v: Value = serde_json::from_str(&s).unwrap();
assert_eq!(v["jsonrpc"], "2.0");
assert_eq!(v["method"], "rt.event");
assert_eq!(v["params"]["topic"], "folder:abc");
assert_eq!(v["params"]["event"], "file_created");
assert_eq!(v["params"]["data"]["name"], "notes.md");
// The discriminator field must have been lifted OUT of `data` —
// otherwise the client sees `data.event` alongside the
// top-level `event`, which is confusing and violates the plan's
// wire shape.
assert!(v["params"]["data"].get("event").is_none());
}
#[test]
fn split_event_discriminator_extracts_and_removes() {
let input = serde_json::json!({
"event": "file_created",
"file_id": "00000000-0000-0000-0000-000000000000",
"name": "x",
});
let (name, rest) = split_event_discriminator(input);
assert_eq!(name, "file_created");
assert!(rest.get("event").is_none());
assert_eq!(rest["name"], "x");
}
}
+10
View File
@@ -674,6 +674,16 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
.with_state(app_state.clone());
router = router.nest("/users", users_router);
// Realtime bus WebSocket. Auth (session cookie or bearer JWT) via
// the same `auth_middleware` the rest of `/api/*` gets; the handler
// extracts `CurrentUserId` from the extension the middleware
// installs. See `docs/plan/message-bus.md` and the module doc on
// `rt_ws` for the JSON-RPC 2.0 wire.
router = router.route(
"/rt/ws",
get(crate::interfaces::api::handlers::rt_ws::rt_ws_handler).with_state(app_state.clone()),
);
// Collector for any unknown `/api/*` path. Without this, an
// unmatched API URL falls through Axum's matcher to the
// ServeDir fallback and is logged under `http::web` — wrong