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).
This commit is contained in:
198
crates/quicproquo-server/src/hooks.rs
Normal file
198
crates/quicproquo-server/src/hooks.rs
Normal file
@@ -0,0 +1,198 @@
|
||||
//! 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])
|
||||
}
|
||||
Reference in New Issue
Block a user