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,15 @@
# This is a standalone cdylib crate outside the main workspace.
[workspace]
[package]
name = "logging_plugin"
version = "0.1.0"
edition = "2021"
description = "Reference quicproquo server plugin: logs all hook events to stderr."
license = "MIT"
[lib]
crate-type = ["cdylib"]
[dependencies]
quicproquo-plugin-api = { path = "../../../crates/quicproquo-plugin-api" }

View File

@@ -0,0 +1,162 @@
//! Reference quicproquo server plugin: logs all hook events to stderr.
//!
//! This plugin demonstrates every hook point in the `HookVTable` API. It
//! writes a single-line human-readable record to stderr for each server event.
//! No state is required, so `user_data` is left null.
//!
//! # Building
//!
//! ```bash
//! cargo build --release -p logging_plugin
//! # Output: target/release/liblogging_plugin.so (Linux)
//! # target/release/liblogging_plugin.dylib (macOS)
//! ```
//!
//! # Deploying
//!
//! ```bash
//! cp target/release/liblogging_plugin.so /etc/qpq/plugins/
//! qpq-server --plugin-dir /etc/qpq/plugins
//! ```
use std::ffi::c_void;
use std::slice;
use quicproquo_plugin_api::{
CAuthEvent, CChannelEvent, CFetchEvent, CMessageEvent, HookVTable, HOOK_CONTINUE, PLUGIN_OK,
};
// ── Helpers ───────────────────────────────────────────────────────────────────
fn hex_prefix(ptr: *const u8, len: usize) -> String {
if ptr.is_null() || len == 0 {
return "(none)".to_string();
}
let bytes = unsafe { slice::from_raw_parts(ptr, len.min(4)) };
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
fn str_from_raw(ptr: *const u8, len: usize) -> &'static str {
if ptr.is_null() || len == 0 {
return "";
}
// Safety: the server owns the memory and it remains valid for the callback duration.
let bytes = unsafe { slice::from_raw_parts(ptr, len) };
std::str::from_utf8(bytes).unwrap_or("<invalid utf8>")
}
// ── Hook callbacks ────────────────────────────────────────────────────────────
unsafe extern "C" fn on_message_enqueue(
_user_data: *mut c_void,
event: *const CMessageEvent,
) -> i32 {
let e = &*event;
eprintln!(
"[qpq-plugin:logging] enqueue: recipient={} payload_len={} seq={} has_sender={}",
hex_prefix(e.recipient_key, e.recipient_key_len),
e.payload_len,
e.seq,
!e.sender_identity.is_null(),
);
HOOK_CONTINUE
}
unsafe extern "C" fn on_batch_enqueue(
_user_data: *mut c_void,
events: *const CMessageEvent,
count: usize,
) {
eprintln!("[qpq-plugin:logging] batch_enqueue: count={}", count);
let events = slice::from_raw_parts(events, count);
for (i, e) in events.iter().enumerate() {
eprintln!(
"[qpq-plugin:logging] [{}/{}] recipient={} seq={}",
i + 1,
count,
hex_prefix(e.recipient_key, e.recipient_key_len),
e.seq,
);
}
}
unsafe extern "C" fn on_auth(_user_data: *mut c_void, event: *const CAuthEvent) {
let e = &*event;
let username = str_from_raw(e.username, e.username_len);
if e.success != 0 {
eprintln!("[qpq-plugin:logging] auth: user='{}' SUCCESS", username);
} else {
let reason = str_from_raw(e.failure_reason, e.failure_reason_len);
eprintln!(
"[qpq-plugin:logging] auth: user='{}' FAILURE reason='{}'",
username, reason
);
}
}
unsafe extern "C" fn on_channel_created(
_user_data: *mut c_void,
event: *const CChannelEvent,
) {
let e = &*event;
eprintln!(
"[qpq-plugin:logging] channel_created: channel={} was_new={} initiator={}",
hex_prefix(e.channel_id, e.channel_id_len),
e.was_new != 0,
hex_prefix(e.initiator_key, e.initiator_key_len),
);
}
unsafe extern "C" fn on_fetch(_user_data: *mut c_void, event: *const CFetchEvent) {
let e = &*event;
if e.message_count > 0 {
eprintln!(
"[qpq-plugin:logging] fetch: recipient={} count={}",
hex_prefix(e.recipient_key, e.recipient_key_len),
e.message_count,
);
}
}
unsafe extern "C" fn on_user_registered(
_user_data: *mut c_void,
username: *const u8,
username_len: usize,
identity_key: *const u8,
identity_key_len: usize,
) {
let name = str_from_raw(username, username_len);
eprintln!(
"[qpq-plugin:logging] user_registered: user='{}' key={}",
name,
hex_prefix(identity_key, identity_key_len),
);
}
// ── Plugin entry point ────────────────────────────────────────────────────────
/// Called by the server once at startup.
///
/// # Safety
///
/// `vtable` must point to a zeroed `HookVTable` as provided by `qpq-server`.
#[no_mangle]
pub unsafe extern "C" fn qpq_plugin_init(vtable: *mut HookVTable) -> i32 {
if vtable.is_null() {
return -1;
}
let v = &mut *vtable;
// user_data is not needed — all callbacks are stateless.
v.user_data = std::ptr::null_mut();
v.on_message_enqueue = Some(on_message_enqueue);
v.on_batch_enqueue = Some(on_batch_enqueue);
v.on_auth = Some(on_auth);
v.on_channel_created = Some(on_channel_created);
v.on_fetch = Some(on_fetch);
v.on_user_registered = Some(on_user_registered);
// error_message and destroy not needed (no state, never rejects).
eprintln!("[qpq-plugin:logging] initialized");
PLUGIN_OK
}