Files
quicproquo/crates/quicproquo-server/src/hooks.rs
Chris Nennemann dc4e4e49a0 feat: Phase 9 — developer experience, extensibility, and community growth
New crates:
- quicproquo-bot: Bot SDK with polling API + JSON pipe mode
- quicproquo-kt: Key Transparency Merkle log (RFC 9162 subset)
- quicproquo-plugin-api: no_std C-compatible plugin vtable API
- quicproquo-gen: scaffolding tool (qpq-gen plugin/bot/rpc/hook)

Server features:
- ServerHooks trait wired into all RPC handlers (enqueue, fetch, auth,
  channel, registration) with plugin rejection support
- Dynamic plugin loader (libloading) with --plugin-dir config
- Delivery proof canary tokens (Ed25519 server signatures on enqueue)
- Key Transparency Merkle log with inclusion proofs on resolveUser

Core library:
- Safety numbers (60-digit HMAC-SHA256 key verification codes)
- Verifiable transcript archive (CBOR + ChaCha20-Poly1305 + hash chain)
- Delivery proof verification utility
- Criterion benchmarks (hybrid KEM, MLS, identity, sealed sender, padding)

Client:
- /verify REPL command for out-of-band key verification
- Full-screen TUI via Ratatui (feature-gated --features tui)
- qpq export / qpq export-verify CLI subcommands
- KT inclusion proof verification on user resolution

Also: ROADMAP Phase 9 added, bot SDK docs, server hooks docs,
crate-responsibilities updated, example plugins (rate_limit, logging).
2026-03-03 22:47:38 +01:00

199 lines
6.1 KiB
Rust

//! Server-side plugin hooks for extending quicproquo.
//!
//! Implement the [`ServerHooks`] trait to intercept server events — message delivery,
//! authentication, channel creation, and more. Hooks fire after validation but before
//! storage, so they can inspect, log, or reject operations.
//!
//! # Built-in implementations
//!
//! - [`NoopHooks`] — does nothing (default when no hooks are configured)
//! - [`TracingHooks`] — logs all events via `tracing` at info/debug level
//!
//! # Writing a custom hook
//!
//! ```rust,ignore
//! use quicproquo_server::hooks::{ServerHooks, HookAction, MessageEvent};
//!
//! struct ModeratorHook {
//! banned_words: Vec<String>,
//! }
//!
//! impl ServerHooks for ModeratorHook {
//! fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
//! // Can't inspect encrypted content (E2E), but can enforce rate limits,
//! // payload size limits, or sender restrictions.
//! if event.payload_len > 1_000_000 {
//! return HookAction::Reject("payload too large".into());
//! }
//! HookAction::Continue
//! }
//! }
//! ```
/// The result of a hook invocation.
#[derive(Clone, Debug)]
pub enum HookAction {
/// Allow the operation to proceed.
Continue,
/// Reject the operation with a reason (returned to the client as an error).
Reject(String),
}
/// Event data for message enqueue operations.
#[derive(Clone, Debug)]
pub struct MessageEvent {
/// Sender's identity key (32 bytes), if known (None in sealed sender mode).
pub sender_identity: Option<Vec<u8>>,
/// Recipient's identity key (32 bytes).
pub recipient_key: Vec<u8>,
/// Channel ID (16 bytes) if this is a DM channel message.
pub channel_id: Vec<u8>,
/// Length of the encrypted payload in bytes.
pub payload_len: usize,
/// Server-assigned sequence number.
pub seq: u64,
}
/// Event data for authentication operations.
#[derive(Clone, Debug)]
pub struct AuthEvent {
/// The username attempting to authenticate.
pub username: String,
/// Whether the authentication succeeded.
pub success: bool,
/// Failure reason (empty on success).
pub failure_reason: String,
}
/// Event data for channel creation operations.
#[derive(Clone, Debug)]
pub struct ChannelEvent {
/// The channel's unique ID (16 bytes).
pub channel_id: Vec<u8>,
/// Identity key of the initiator.
pub initiator_key: Vec<u8>,
/// Identity key of the peer.
pub peer_key: Vec<u8>,
/// True if this is a newly created channel (initiator creates the MLS group).
pub was_new: bool,
}
/// Event data for message fetch operations.
#[derive(Clone, Debug)]
pub struct FetchEvent {
/// Identity key of the fetcher.
pub recipient_key: Vec<u8>,
/// Channel ID being fetched from.
pub channel_id: Vec<u8>,
/// Number of messages returned.
pub message_count: usize,
}
/// Trait for server-side plugin hooks.
///
/// All methods have default implementations that return [`HookAction::Continue`],
/// so you only need to override the events you care about.
///
/// Hooks are called synchronously in the RPC handler path. Keep them fast —
/// offload heavy work (HTTP calls, disk I/O) to background tasks.
pub trait ServerHooks: Send + Sync {
/// Called after validation, before a message is stored in the delivery queue.
///
/// Return `HookAction::Reject` to prevent delivery.
fn on_message_enqueue(&self, _event: &MessageEvent) -> HookAction {
HookAction::Continue
}
/// Called after a batch of messages is enqueued.
fn on_batch_enqueue(&self, _events: &[MessageEvent]) {
// Default: no-op
}
/// Called after a successful or failed login attempt.
fn on_auth(&self, _event: &AuthEvent) {
// Default: no-op
}
/// Called after a channel is created or looked up.
fn on_channel_created(&self, _event: &ChannelEvent) {
// Default: no-op
}
/// Called after messages are fetched from the delivery queue.
fn on_fetch(&self, _event: &FetchEvent) {
// Default: no-op
}
/// Called when a user registers (OPAQUE registration complete).
fn on_user_registered(&self, _username: &str, _identity_key: &[u8]) {
// Default: no-op
}
}
/// No-op hook implementation (default).
pub struct NoopHooks;
impl ServerHooks for NoopHooks {}
/// Hook implementation that logs all events via `tracing`.
pub struct TracingHooks;
impl ServerHooks for TracingHooks {
fn on_message_enqueue(&self, event: &MessageEvent) -> HookAction {
tracing::info!(
recipient_prefix = %hex_prefix(&event.recipient_key),
payload_len = event.payload_len,
seq = event.seq,
has_sender = event.sender_identity.is_some(),
"hook: message enqueued"
);
HookAction::Continue
}
fn on_batch_enqueue(&self, events: &[MessageEvent]) {
tracing::info!(
count = events.len(),
"hook: batch enqueue"
);
}
fn on_auth(&self, event: &AuthEvent) {
if event.success {
tracing::info!(username = %event.username, "hook: login success");
} else {
tracing::warn!(
username = %event.username,
reason = %event.failure_reason,
"hook: login failure"
);
}
}
fn on_channel_created(&self, event: &ChannelEvent) {
tracing::info!(
channel_id = %hex_prefix(&event.channel_id),
was_new = event.was_new,
"hook: channel created"
);
}
fn on_fetch(&self, event: &FetchEvent) {
if event.message_count > 0 {
tracing::debug!(
recipient_prefix = %hex_prefix(&event.recipient_key),
count = event.message_count,
"hook: messages fetched"
);
}
}
fn on_user_registered(&self, username: &str, _identity_key: &[u8]) {
tracing::info!(username = %username, "hook: user registered");
}
}
fn hex_prefix(bytes: &[u8]) -> String {
let n = bytes.len().min(4);
hex::encode(&bytes[..n])
}