feat(msg-bus): prepare engine
This commit is contained in:
Generated
+58
-14
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user