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,212 @@
use std::fs;
use std::path::Path;
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
let crate_name = sanitize_name(name);
let dir = output.join(&crate_name);
if dir.exists() {
return Err(format!("directory already exists: {}", dir.display()));
}
let src_dir = dir.join("src");
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
// Cargo.toml
let cargo_toml = format!(
r#"[package]
name = "{crate_name}"
version = "0.1.0"
edition = "2021"
description = "quicproquo bot: {name}"
license = "MIT"
[dependencies]
quicproquo-bot = {{ git = "https://github.com/nickvidal/quicproquo" }}
tokio = {{ version = "1", features = ["macros", "rt-multi-thread"] }}
anyhow = "1"
tracing = "0.1"
tracing-subscriber = {{ version = "0.3", features = ["env-filter"] }}
"#,
crate_name = crate_name,
name = name,
);
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
// src/main.rs
let main_rs = format!(
r#"//! quicproquo bot: {name}
//!
//! A bot that connects to a quicproquo server and responds to messages.
//!
//! Usage:
//! {crate_name} --server 127.0.0.1:7000 --username my-bot --password secret
//!
//! Environment variables (alternative to CLI args):
//! QPQ_SERVER, QPQ_USERNAME, QPQ_PASSWORD, QPQ_CA_CERT, QPQ_STATE_PATH
use quicproquo_bot::{{Bot, BotConfig}};
#[tokio::main]
async fn main() -> anyhow::Result<()> {{
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "info".into()),
)
.init();
// --- Configuration ---
let server = env_or("QPQ_SERVER", "127.0.0.1:7000");
let username = env_or("QPQ_USERNAME", "{crate_name}");
let password = env_or("QPQ_PASSWORD", "changeme");
let ca_cert = env_or("QPQ_CA_CERT", "server-cert.der");
let state_path = env_or("QPQ_STATE_PATH", "{crate_name}-state.bin");
let config = BotConfig::new(&server, &username, &password)
.ca_cert(&ca_cert)
.state_path(&state_path);
// --- Connect and authenticate ---
tracing::info!("connecting to {{server}} as {{username}}...");
let bot = Bot::connect(config).await?;
tracing::info!("authenticated as {{}} (key: {{}})", bot.username(), &bot.identity_key_hex()[..16]);
// --- Main loop: poll for messages and respond ---
tracing::info!("listening for messages (Ctrl+C to stop)...");
loop {{
let messages = bot.receive(5000).await?;
for msg in messages {{
tracing::info!("[{{}}] {{}}", msg.sender, msg.text);
// --- Add your command handlers here ---
if let Some(response) = handle_message(&msg.sender, &msg.text) {{
bot.send_dm(&msg.sender, &response).await?;
}}
}}
}}
}}
/// Process an incoming message and optionally return a response.
///
/// Add your bot's command logic here.
fn handle_message(sender: &str, text: &str) -> Option<String> {{
let text = text.trim();
// !help — list available commands
if text == "!help" {{
return Some(
"Available commands:\n\
!help — show this message\n\
!echo <text> — echo back the text\n\
!whoami — show your username\n\
!ping — pong!"
.to_string(),
);
}}
// !echo <text> — echo back
if let Some(rest) = text.strip_prefix("!echo ") {{
return Some(rest.to_string());
}}
// !whoami — tell the sender their username
if text == "!whoami" {{
return Some(format!("You are {{sender}}"));
}}
// !ping — respond with pong
if text == "!ping" {{
return Some("pong!".to_string());
}}
// Unknown command or regular message — no response
None
}}
fn env_or(key: &str, default: &str) -> String {{
std::env::var(key).unwrap_or_else(|_| default.to_string())
}}
"#,
name = name,
crate_name = crate_name,
);
write_file(&src_dir.join("main.rs"), &main_rs)?;
// README
let readme = format!(
r#"# {name} — quicproquo bot
## Quick start
```bash
# Build
cargo build
# Run (make sure a qpq server is running)
QPQ_SERVER=127.0.0.1:7000 \
QPQ_USERNAME={crate_name} \
QPQ_PASSWORD=changeme \
QPQ_CA_CERT=path/to/server-cert.der \
cargo run
```
## Commands
| Command | Description |
|---------|-------------|
| `!help` | Show available commands |
| `!echo <text>` | Echo back the text |
| `!whoami` | Show your username |
| `!ping` | Respond with "pong!" |
## Adding commands
Edit the `handle_message` function in `src/main.rs`:
```rust
fn handle_message(sender: &str, text: &str) -> Option<String> {{
if text == "!mycommand" {{
return Some("my response".to_string());
}}
None
}}
```
## Pipe mode
For shell integration, use the Bot SDK's JSON pipe mode:
```bash
echo '{{"action":"send","to":"alice","text":"hello"}}' | my-bot
echo '{{"action":"recv","timeout_ms":5000}}' | my-bot
```
## Documentation
- [Bot SDK docs](https://github.com/nickvidal/quicproquo/blob/main/docs/src/getting-started/bot-sdk.md)
- [Server Hooks](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md)
"#,
name = name,
crate_name = crate_name,
);
write_file(&dir.join("README.md"), &readme)?;
println!("Created bot project: {}", dir.display());
println!();
println!(" cd {crate_name}");
println!(" # Edit src/main.rs to add your commands");
println!(" QPQ_SERVER=127.0.0.1:7000 QPQ_PASSWORD=secret cargo run");
println!();
println!("The bot responds to !help, !echo, !whoami, !ping out of the box.");
Ok(())
}
fn sanitize_name(name: &str) -> String {
name.replace(['-', ' '], "_")
}
fn write_file(path: &Path, content: &str) -> Result<(), String> {
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
}

View File

@@ -0,0 +1,134 @@
pub fn generate(name: &str) -> Result<(), String> {
let snake = name.to_lowercase().replace(['-', ' '], "_");
let pascal = to_pascal_case(&snake);
println!("=== Adding hook event: on_{snake} ===");
println!();
println!("Follow these steps to add a new `on_{snake}` hook event.");
println!();
// Step 1: Event struct
println!("--- Step 1: Event struct ---");
println!("File: crates/quicproquo-server/src/hooks.rs");
println!();
println!(
r#"/// Event data for {snake} operations.
#[derive(Clone, Debug)]
pub struct {pascal}Event {{
// TODO: add your event fields here
// Example:
// pub channel_id: Vec<u8>,
// pub user_key: Vec<u8>,
}}
"#,
);
// Step 2: Trait method
println!("--- Step 2: Trait method ---");
println!("File: crates/quicproquo-server/src/hooks.rs");
println!();
println!("Add to the `ServerHooks` trait:");
println!();
println!(
r#" /// Called when {snake} occurs.
fn on_{snake}(&self, _event: &{pascal}Event) {{
// Default: no-op
}}
"#,
);
// Step 3: TracingHooks implementation
println!("--- Step 3: TracingHooks implementation ---");
println!("File: crates/quicproquo-server/src/hooks.rs");
println!();
println!("Add to `impl ServerHooks for TracingHooks`:");
println!();
println!(
r#" fn on_{snake}(&self, _event: &{pascal}Event) {{
tracing::info!("hook: {snake}");
}}
"#,
);
// Step 4: Plugin API (C-compatible struct)
println!("--- Step 4: Plugin API ---");
println!("File: crates/quicproquo-plugin-api/src/lib.rs");
println!();
println!("Add a C-compatible event struct:");
println!();
println!(
r#"#[repr(C)]
pub struct C{pascal}Event {{
// TODO: mirror the fields from {pascal}Event using C-compatible types
// Use *const u8 + len for byte slices, *const c_char for strings
}}
"#,
);
println!("Add to `HookVTable`:");
println!();
println!(
r#" pub on_{snake}: Option<extern "C" fn(*mut c_void, *const C{pascal}Event)>,
"#,
);
// Step 5: Wire into PluginHooks
println!("--- Step 5: PluginHooks dispatch ---");
println!("File: crates/quicproquo-server/src/plugin_loader.rs");
println!();
println!("Add to `impl ServerHooks for PluginHooks`:");
println!();
println!(
r#" fn on_{snake}(&self, event: &{pascal}Event) {{
if let Some(hook_fn) = self.vtable.on_{snake} {{
let c_event = C{pascal}Event {{
// TODO: convert fields
}};
hook_fn(self.vtable.user_data, &c_event);
}}
}}
"#,
);
// Step 6: Call the hook
println!("--- Step 6: Call the hook in the RPC handler ---");
println!("In the relevant handler file under crates/quicproquo-server/src/node_service/:");
println!();
println!(
r#" use crate::hooks::{pascal}Event;
// At the appropriate point in the handler:
self.hooks.on_{snake}(&{pascal}Event {{
// fill in fields
}});
"#,
);
// Step 7: Verify
println!("--- Step 7: Verify ---");
println!(" cargo build -p quicproquo-plugin-api");
println!(" cargo build -p quicproquo-server");
println!(" cargo test -p quicproquo-server");
println!();
// Summary
println!("=== Files to modify ===");
println!(" [modify] crates/quicproquo-server/src/hooks.rs");
println!(" [modify] crates/quicproquo-plugin-api/src/lib.rs");
println!(" [modify] crates/quicproquo-server/src/plugin_loader.rs");
println!(" [modify] crates/quicproquo-server/src/node_service/<handler>.rs");
Ok(())
}
fn to_pascal_case(snake: &str) -> String {
snake
.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
}
})
.collect()
}

View File

@@ -0,0 +1,4 @@
pub mod bot;
pub mod hook;
pub mod plugin;
pub mod rpc;

View File

@@ -0,0 +1,186 @@
use std::fs;
use std::path::Path;
pub fn generate(name: &str, output: &Path) -> Result<(), String> {
let crate_name = sanitize_name(name);
let dir = output.join(&crate_name);
if dir.exists() {
return Err(format!("directory already exists: {}", dir.display()));
}
let src_dir = dir.join("src");
fs::create_dir_all(&src_dir).map_err(|e| format!("create dir: {e}"))?;
// Cargo.toml
let cargo_toml = format!(
r#"[package]
name = "{crate_name}"
version = "0.1.0"
edition = "2021"
description = "quicproquo server plugin: {name}"
license = "MIT"
[lib]
crate-type = ["cdylib"]
# Empty workspace — this plugin builds independently of the qpq workspace.
[workspace]
[dependencies]
quicproquo-plugin-api = {{ git = "https://github.com/nickvidal/quicproquo", default-features = false }}
"#,
crate_name = crate_name,
name = name,
);
write_file(&dir.join("Cargo.toml"), &cargo_toml)?;
// src/lib.rs
let lib_rs = format!(
r#"//! quicproquo server plugin: {name}
//!
//! Build with: cargo build --release
//! Install: cp target/release/lib{crate_name}.so /path/to/plugins/
//! The server loads it automatically when started with --plugin-dir.
use quicproquo_plugin_api::{{HookVTable, CMessageEvent, HOOK_CONTINUE, HOOK_REJECT, PLUGIN_OK}};
use std::ffi::CString;
use std::os::raw::c_int;
/// Plugin state — allocate on the heap in init, free in destroy.
struct PluginState {{
/// Example: maximum allowed payload size in bytes.
max_payload_bytes: usize,
/// Stored rejection message (must outlive the hook call).
reject_msg: Option<CString>,
}}
/// Called by the server on plugin load.
///
/// Fill the vtable with your hook implementations. Return PLUGIN_OK on success.
#[no_mangle]
pub extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> c_int {{
let state = Box::new(PluginState {{
max_payload_bytes: 1_000_000, // 1 MB limit
reject_msg: None,
}});
let vt = unsafe {{ &mut *vtable }};
vt.user_data = Box::into_raw(state) as *mut _;
vt.on_message_enqueue = Some(on_message_enqueue);
vt.error_message = Some(error_message);
vt.destroy = Some(destroy);
eprintln!("[{name}] plugin loaded");
PLUGIN_OK
}}
/// Hook: called before each message is stored in the delivery queue.
///
/// Return HOOK_CONTINUE to allow, HOOK_REJECT to block.
extern "C" fn on_message_enqueue(
user_data: *mut std::ffi::c_void,
event: *const CMessageEvent,
) -> c_int {{
let state = unsafe {{ &mut *(user_data as *mut PluginState) }};
let event = unsafe {{ &*event }};
if event.payload_len > state.max_payload_bytes {{
let msg = format!(
"payload too large: {{}} > {{}} bytes",
event.payload_len, state.max_payload_bytes
);
state.reject_msg = CString::new(msg).ok();
return HOOK_REJECT;
}}
HOOK_CONTINUE
}}
/// Return a pointer to the rejection error message (valid until next hook call).
extern "C" fn error_message(
user_data: *mut std::ffi::c_void,
) -> *const std::os::raw::c_char {{
let state = unsafe {{ &*(user_data as *const PluginState) }};
match &state.reject_msg {{
Some(msg) => msg.as_ptr(),
None => std::ptr::null(),
}}
}}
/// Cleanup: free the plugin state.
extern "C" fn destroy(user_data: *mut std::ffi::c_void) {{
if !user_data.is_null() {{
unsafe {{ drop(Box::from_raw(user_data as *mut PluginState)) }};
}}
eprintln!("[{name}] plugin unloaded");
}}
"#,
name = name,
crate_name = crate_name,
);
write_file(&src_dir.join("lib.rs"), &lib_rs)?;
// README
let readme = format!(
r#"# {name} — quicproquo server plugin
## Build
```bash
cargo build --release
```
## Install
Copy the shared library to the server's plugin directory:
```bash
cp target/release/lib{crate_name}.so /path/to/plugins/
```
Start the server with:
```bash
qpq-server --plugin-dir /path/to/plugins/
```
## Hooks
This plugin implements `on_message_enqueue` to reject oversized payloads.
Edit `src/lib.rs` to add your own logic. Available hooks:
| Hook | Purpose |
|------|---------|
| `on_message_enqueue` | Inspect/reject messages before delivery (return `HOOK_REJECT`) |
| `on_batch_enqueue` | Observe batch message delivery |
| `on_auth` | Observe login success/failure |
| `on_channel_created` | Observe channel creation |
| `on_fetch` | Observe message fetch operations |
| `on_user_registered` | Observe new user registration |
See the [Server Hooks documentation](https://github.com/nickvidal/quicproquo/blob/main/docs/src/internals/server-hooks.md) for details.
"#,
name = name,
crate_name = crate_name,
);
write_file(&dir.join("README.md"), &readme)?;
println!("Created plugin project: {}", dir.display());
println!();
println!(" cd {crate_name}");
println!(" cargo build --release");
println!(" cp target/release/lib{crate_name}.so /path/to/plugins/");
println!();
println!("Edit src/lib.rs to implement your hook logic.");
Ok(())
}
fn sanitize_name(name: &str) -> String {
name.replace(['-', ' '], "_")
}
fn write_file(path: &Path, content: &str) -> Result<(), String> {
fs::write(path, content).map_err(|e| format!("write {}: {e}", path.display()))
}

View File

@@ -0,0 +1,129 @@
pub fn generate(name: &str) -> Result<(), String> {
let snake = to_snake_case(name);
let camel = name.to_string();
println!("=== Adding RPC method: {camel} ===");
println!();
println!("Follow these steps to add a new `{camel}` RPC method.");
println!("Each step shows the file and the code to add.");
println!();
// Step 1: Schema
println!("--- Step 1: Cap'n Proto schema ---");
println!("File: schemas/node.capnp");
println!();
println!("Add to the `interface NodeService` block:");
println!();
println!(
r#" {camel} @N (auth :AuthContext, <your params here>) -> (<your results here>);
"#,
);
println!(" (Replace @N with the next ordinal number in the interface.)");
println!();
println!("Then rebuild the proto crate:");
println!(" cargo build -p quicproquo-proto");
println!();
// Step 2: Handler module
println!("--- Step 2: Handler module ---");
println!("File: crates/quicproquo-server/src/node_service/{snake}.rs");
println!();
println!(
r#"use capnp::capability::Promise;
use quicproquo_proto::node_capnp::node_service;
use crate::auth::{{coded_error, validate_auth_context}};
use crate::error_codes::*;
use super::NodeServiceImpl;
impl NodeServiceImpl {{
pub fn handle_{snake}(
&mut self,
params: node_service::{camel}Params,
mut results: node_service::{camel}Results,
) -> Promise<(), capnp::Error> {{
let p = match params.get() {{
Ok(p) => p,
Err(e) => return Promise::err(coded_error(E020_BAD_PARAMS, e)),
}};
let auth_ctx = match validate_auth_context(&self.auth_cfg, &self.sessions, p.get_auth()) {{
Ok(ctx) => ctx,
Err(e) => return Promise::err(e),
}};
// TODO: implement your logic here
Promise::ok(())
}}
}}
"#,
);
// Step 3: Wire into mod.rs
println!("--- Step 3: Register in mod.rs ---");
println!("File: crates/quicproquo-server/src/node_service/mod.rs");
println!();
println!("Add to the module declarations at the top:");
println!(" mod {snake};");
println!();
println!("Add to the `impl node_service::Server for NodeServiceImpl` block:");
println!();
println!(
r#" fn {snake}(
&mut self,
params: node_service::{camel}Params,
results: node_service::{camel}Results,
) -> capnp::capability::Promise<(), capnp::Error> {{
self.handle_{snake}(params, results)
}}
"#,
);
// Step 4: Storage (if needed)
println!("--- Step 4: Storage trait (if needed) ---");
println!("File: crates/quicproquo-server/src/storage.rs");
println!();
println!("If your RPC method needs persistent storage, add a method to the Store trait:");
println!();
println!(
r#" fn {snake}(&self, /* params */) -> Result</* return */, StorageError>;
"#,
);
println!("Then implement it in:");
println!(" - crates/quicproquo-server/src/sql_store.rs (SQLite backend)");
println!(" - crates/quicproquo-server/src/storage.rs (FileBackedStore)");
println!();
// Step 5: Hook (if needed)
println!("--- Step 5: Hook event (optional) ---");
println!("If you want plugins to observe this RPC, run:");
println!(" qpq-gen hook {snake}");
println!();
// Step 6: Verify
println!("--- Step 6: Verify ---");
println!(" cargo build -p quicproquo-server");
println!(" cargo test -p quicproquo-server");
println!();
// Summary
println!("=== Files to create/modify ===");
println!(" [modify] schemas/node.capnp");
println!(" [create] crates/quicproquo-server/src/node_service/{snake}.rs");
println!(" [modify] crates/quicproquo-server/src/node_service/mod.rs");
println!(" [modify] crates/quicproquo-server/src/storage.rs (if needed)");
println!(" [modify] crates/quicproquo-server/src/sql_store.rs (if needed)");
Ok(())
}
fn to_snake_case(s: &str) -> String {
let mut result = String::with_capacity(s.len() + 4);
for (i, ch) in s.chars().enumerate() {
if ch.is_uppercase() && i > 0 {
result.push('_');
}
result.push(ch.to_ascii_lowercase());
}
result
}

View File

@@ -0,0 +1,55 @@
use clap::{Parser, Subcommand};
use std::path::PathBuf;
mod generators;
#[derive(Parser)]
#[command(name = "qpq-gen", about = "Code generators for quicproquo")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
/// Scaffold a new server plugin (dynamic .so/.dylib)
Plugin {
/// Plugin name (e.g. "rate-limiter", "audit-log")
name: String,
/// Output directory (default: current directory)
#[arg(short, long, default_value = ".")]
output: PathBuf,
},
/// Scaffold a new bot project using the Bot SDK
Bot {
/// Bot name (e.g. "echo-bot", "moderation-bot")
name: String,
/// Output directory (default: current directory)
#[arg(short, long, default_value = ".")]
output: PathBuf,
},
/// Show instructions for adding a new Cap'n Proto RPC method
Rpc {
/// RPC method name in camelCase (e.g. "listChannels")
name: String,
},
/// Show instructions for adding a new server hook event
Hook {
/// Hook event name in snake_case (e.g. "message_deleted")
name: String,
},
}
fn main() {
let cli = Cli::parse();
let result = match cli.command {
Command::Plugin { name, output } => generators::plugin::generate(&name, &output),
Command::Bot { name, output } => generators::bot::generate(&name, &output),
Command::Rpc { name } => generators::rpc::generate(&name),
Command::Hook { name } => generators::hook::generate(&name),
};
if let Err(e) = result {
eprintln!("error: {e}");
std::process::exit(1);
}
}