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:
9
crates/quicproquo-plugin-api/Cargo.toml
Normal file
9
crates/quicproquo-plugin-api/Cargo.toml
Normal file
@@ -0,0 +1,9 @@
|
||||
[package]
|
||||
name = "quicproquo-plugin-api"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "C-ABI vtable for quicproquo server plugins. No std dependency; usable from bare-metal plugin authors."
|
||||
license = "MIT"
|
||||
|
||||
# No dependencies — intentionally minimal so plugin authors have zero forced transitive deps.
|
||||
[dependencies]
|
||||
190
crates/quicproquo-plugin-api/src/lib.rs
Normal file
190
crates/quicproquo-plugin-api/src/lib.rs
Normal file
@@ -0,0 +1,190 @@
|
||||
//! quicproquo server plugin API — C-ABI vtable.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! Every plugin is a `cdylib` that exports one symbol:
|
||||
//!
|
||||
//! ```c
|
||||
//! extern "C" int32_t qpq_plugin_init(HookVTable *vtable);
|
||||
//! ```
|
||||
//!
|
||||
//! The server passes a zeroed [`HookVTable`] to `qpq_plugin_init`. The plugin
|
||||
//! fills in whichever function pointers it cares about and returns `0` on
|
||||
//! success or a negative error code on failure. Unused slots remain null and
|
||||
//! the server treats them as no-ops.
|
||||
//!
|
||||
//! # Wire types
|
||||
//!
|
||||
//! All event structs are passed by const pointer across the FFI boundary. The
|
||||
//! server owns the memory; plugin code must not store these pointers beyond the
|
||||
//! duration of the callback.
|
||||
//!
|
||||
//! # Thread safety
|
||||
//!
|
||||
//! Hook callbacks are called from the Tokio worker thread that handles the RPC.
|
||||
//! Plugins must be `Send + Sync` in practice (the server wraps them in `Arc`).
|
||||
//! Global plugin state should be guarded with `Mutex` or `RwLock` if mutable.
|
||||
//!
|
||||
//! # Return values
|
||||
//!
|
||||
//! Hooks that can reject an operation return [`HookResult`]. The server maps
|
||||
//! `HOOK_CONTINUE` to `HookAction::Continue` and any other value to
|
||||
//! `HookAction::Reject` with the reason string from [`HookVTable::error_message`].
|
||||
|
||||
#![no_std]
|
||||
|
||||
/// Plugin init returned success.
|
||||
pub const PLUGIN_OK: i32 = 0;
|
||||
|
||||
/// Hook should allow the operation to proceed.
|
||||
pub const HOOK_CONTINUE: i32 = 0;
|
||||
|
||||
/// Hook wants to reject the operation. Fill [`HookVTable::error_message`] with
|
||||
/// a null-terminated reason before returning this.
|
||||
pub const HOOK_REJECT: i32 = 1;
|
||||
|
||||
// ── Event structs (C-compatible) ─────────────────────────────────────────────
|
||||
|
||||
/// Event data for message enqueue operations.
|
||||
///
|
||||
/// Passed by pointer to [`HookVTable::on_message_enqueue`].
|
||||
/// All pointer fields are valid for the duration of the callback only.
|
||||
#[repr(C)]
|
||||
pub struct CMessageEvent {
|
||||
/// Sender's Ed25519 identity key (32 bytes), or null if sealed sender.
|
||||
pub sender_identity: *const u8,
|
||||
/// Length of `sender_identity`; 0 when null.
|
||||
pub sender_identity_len: usize,
|
||||
/// Recipient's Ed25519 identity key (32 bytes).
|
||||
pub recipient_key: *const u8,
|
||||
pub recipient_key_len: usize,
|
||||
/// Channel ID (16 bytes).
|
||||
pub channel_id: *const u8,
|
||||
pub channel_id_len: usize,
|
||||
/// Length of the encrypted payload.
|
||||
pub payload_len: usize,
|
||||
/// Server-assigned sequence number.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// Event data for authentication operations.
|
||||
#[repr(C)]
|
||||
pub struct CAuthEvent {
|
||||
/// Null-terminated username string.
|
||||
pub username: *const u8,
|
||||
pub username_len: usize,
|
||||
/// Non-zero on success.
|
||||
pub success: i32,
|
||||
/// Null-terminated failure reason (empty on success).
|
||||
pub failure_reason: *const u8,
|
||||
pub failure_reason_len: usize,
|
||||
}
|
||||
|
||||
/// Event data for channel creation operations.
|
||||
#[repr(C)]
|
||||
pub struct CChannelEvent {
|
||||
pub channel_id: *const u8,
|
||||
pub channel_id_len: usize,
|
||||
pub initiator_key: *const u8,
|
||||
pub initiator_key_len: usize,
|
||||
pub peer_key: *const u8,
|
||||
pub peer_key_len: usize,
|
||||
/// Non-zero if this is a freshly created channel.
|
||||
pub was_new: i32,
|
||||
}
|
||||
|
||||
/// Event data for message fetch operations.
|
||||
#[repr(C)]
|
||||
pub struct CFetchEvent {
|
||||
pub recipient_key: *const u8,
|
||||
pub recipient_key_len: usize,
|
||||
pub channel_id: *const u8,
|
||||
pub channel_id_len: usize,
|
||||
pub message_count: usize,
|
||||
}
|
||||
|
||||
// ── HookVTable ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// C-ABI function-pointer table filled by [`qpq_plugin_init`].
|
||||
///
|
||||
/// All fields default to null (no-op). The server only calls a slot when its
|
||||
/// pointer is non-null. The `user_data` field is passed as the first argument
|
||||
/// to every hook; use it to thread plugin-private state without global variables.
|
||||
#[repr(C)]
|
||||
pub struct HookVTable {
|
||||
/// Opaque pointer to plugin-private state. The server passes this as the
|
||||
/// first argument to every hook callback. May be null.
|
||||
pub user_data: *mut core::ffi::c_void,
|
||||
|
||||
/// Called before a message is stored. Return [`HOOK_CONTINUE`] or
|
||||
/// [`HOOK_REJECT`]. On reject, populate `error_message`.
|
||||
pub on_message_enqueue: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
event: *const CMessageEvent,
|
||||
) -> i32,
|
||||
>,
|
||||
|
||||
/// Called after a batch of messages is enqueued (fire-and-forget, no return value).
|
||||
pub on_batch_enqueue: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
events: *const CMessageEvent,
|
||||
count: usize,
|
||||
),
|
||||
>,
|
||||
|
||||
/// Called after a login attempt (fire-and-forget).
|
||||
pub on_auth: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
event: *const CAuthEvent,
|
||||
),
|
||||
>,
|
||||
|
||||
/// Called after a channel is created or looked up (fire-and-forget).
|
||||
pub on_channel_created: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
event: *const CChannelEvent,
|
||||
),
|
||||
>,
|
||||
|
||||
/// Called after messages are fetched (fire-and-forget).
|
||||
pub on_fetch: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
event: *const CFetchEvent,
|
||||
),
|
||||
>,
|
||||
|
||||
/// Called after a user completes OPAQUE registration (fire-and-forget).
|
||||
pub on_user_registered: Option<
|
||||
unsafe extern "C" fn(
|
||||
user_data: *mut core::ffi::c_void,
|
||||
username: *const u8,
|
||||
username_len: usize,
|
||||
identity_key: *const u8,
|
||||
identity_key_len: usize,
|
||||
),
|
||||
>,
|
||||
|
||||
/// When a hook returns [`HOOK_REJECT`], the server calls this to retrieve
|
||||
/// the null-terminated rejection reason. The returned pointer must remain
|
||||
/// valid until the next call on the same `user_data`. May be null (server
|
||||
/// will use a generic message).
|
||||
pub error_message: Option<
|
||||
unsafe extern "C" fn(user_data: *mut core::ffi::c_void) -> *const u8,
|
||||
>,
|
||||
|
||||
/// Called by the server when it is done with this plugin (shutdown).
|
||||
/// Release resources / join threads here. May be null.
|
||||
pub destroy: Option<unsafe extern "C" fn(user_data: *mut core::ffi::c_void)>,
|
||||
}
|
||||
|
||||
// Safety: user_data is an opaque pointer managed by the plugin. The plugin is
|
||||
// responsible for its own thread safety. The server only calls hook functions
|
||||
// one at a time per plugin (wrapped in a single Arc). Plugins that mutate
|
||||
// user_data through callbacks must use interior mutability.
|
||||
unsafe impl Send for HookVTable {}
|
||||
unsafe impl Sync for HookVTable {}
|
||||
Reference in New Issue
Block a user