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:
2026-03-03 22:47:38 +01:00
parent b6483dedbc
commit dc4e4e49a0
62 changed files with 6959 additions and 62 deletions

View File

@@ -0,0 +1 @@
target/

View File

@@ -0,0 +1,17 @@
# This is a standalone cdylib crate outside the main workspace.
# It depends on quicproquo-plugin-api via a relative path.
[workspace]
[package]
name = "rate_limit_plugin"
version = "0.1.0"
edition = "2021"
description = "Reference quicproquo server plugin: per-recipient payload-size rate limiter."
license = "MIT"
# Compile as a shared library (.so / .dylib) for dynamic loading by qpq-server.
[lib]
crate-type = ["cdylib"]
[dependencies]
quicproquo-plugin-api = { path = "../../../crates/quicproquo-plugin-api" }

View File

@@ -0,0 +1,108 @@
//! Reference quicproquo server plugin: payload-size rate limiter.
//!
//! Rejects any single message whose payload exceeds `MAX_PAYLOAD_BYTES`. In a
//! real deployment you would extend this with per-sender token-bucket logic,
//! but this example intentionally stays simple so the plugin API surface is
//! easy to follow.
//!
//! # Building
//!
//! ```bash
//! cargo build --release -p rate_limit_plugin
//! # Output: target/release/librate_limit_plugin.so (Linux)
//! # target/release/librate_limit_plugin.dylib (macOS)
//! ```
//!
//! # Deploying
//!
//! Copy the resulting `.so` / `.dylib` into your plugin directory and start
//! the server with `--plugin-dir /path/to/plugins`.
//!
//! # Config (via TOML)
//!
//! ```toml
//! plugin_dir = "/etc/qpq/plugins"
//! ```
use std::ffi::c_void;
use quicproquo_plugin_api::{CMessageEvent, HookVTable, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK};
/// Maximum allowed encrypted payload size in bytes.
const MAX_PAYLOAD_BYTES: usize = 512 * 1024; // 512 KiB
/// Plugin-private state, heap-allocated and passed as `user_data`.
struct PluginState {
/// Last rejection reason as a null-terminated C string owned by us.
last_error: Option<std::ffi::CString>,
}
impl PluginState {
fn new() -> *mut Self {
Box::into_raw(Box::new(Self { last_error: None }))
}
fn set_error(&mut self, msg: &str) {
// Replace previous error (if any); CString silently truncates interior NUL bytes,
// but our format strings never contain them.
self.last_error = std::ffi::CString::new(msg).ok();
}
}
// ── Hook callbacks ────────────────────────────────────────────────────────────
unsafe extern "C" fn on_message_enqueue(
user_data: *mut c_void,
event: *const CMessageEvent,
) -> i32 {
let state = &mut *(user_data as *mut PluginState);
let payload_len = (*event).payload_len;
if payload_len > MAX_PAYLOAD_BYTES {
state.set_error(&format!(
"payload {} bytes exceeds limit {} bytes",
payload_len, MAX_PAYLOAD_BYTES
));
return HOOK_REJECT;
}
HOOK_CONTINUE
}
unsafe extern "C" fn error_message(user_data: *mut c_void) -> *const u8 {
let state = &*(user_data as *mut PluginState);
match &state.last_error {
Some(s) => s.as_ptr() as *const u8,
None => std::ptr::null(),
}
}
unsafe extern "C" fn destroy(user_data: *mut c_void) {
if !user_data.is_null() {
drop(Box::from_raw(user_data as *mut PluginState));
}
}
// ── Plugin entry point ────────────────────────────────────────────────────────
/// Called by the server once at startup. Fill `vtable` with function pointers
/// and return `PLUGIN_OK` (0) on success.
///
/// # Safety
///
/// `vtable` must be a valid pointer to a zeroed `HookVTable` as provided by
/// `qpq-server`. Do not call from any other context.
#[no_mangle]
pub unsafe extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> i32 {
if vtable.is_null() {
return -1;
}
let vtable = &mut *vtable;
vtable.user_data = PluginState::new() as *mut c_void;
vtable.on_message_enqueue = Some(on_message_enqueue);
vtable.error_message = Some(error_message);
vtable.destroy = Some(destroy);
PLUGIN_OK
}