From 8da4a1f7322dcc89b4989ea2141e5a81cbbfabd0 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Fri, 13 Mar 2026 18:32:01 +0100 Subject: [PATCH 01/18] feat(wasm): add WhatsApp-style webhook improvements Implements verification modes, HMAC signature support, and webhook deduplication: - Add verify_hmac_sha256 function for X-Hub-Signature-256 verification - Add verification_mode field to channel capabilities (query_param, signature) - Add message_id_json_pointer for extracting message IDs from metadata - Add WebhookDedupStore trait and implementations (PostgreSQL + libSQL) - Add webhook_message_dedup table migration (V12 for Postgres,- Add database field to WasmChannelRouter for deduplication - Update register() to accept verification_mode and message_id_json_pointer - Wire verification_mode extraction in setup.rs and loader.rs Co-Authored-By: Claude Opus 4.6 --- .../2026-03-13-wasm-webhook-improvements.md | 1485 +++++++++++++++++ migrations/V13__webhook_dedup.sql | 19 + src/channels/wasm/loader.rs | 14 + src/channels/wasm/router.rs | 120 +- src/channels/wasm/schema.rs | 96 ++ src/channels/wasm/setup.rs | 6 + src/channels/wasm/signature.rs | 135 +- src/channels/wasm/wrapper.rs | 75 + src/db/libsql/mod.rs | 38 + src/db/mod.rs | 24 + src/db/postgres.rs | 42 + src/extensions/manager.rs | 2 + tests/wasm_channel_integration.rs | 15 +- wit/channel.wit | 14 + 14 files changed, 2047 insertions(+), 38 deletions(-) create mode 100644 docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md create mode 100644 migrations/V13__webhook_dedup.sql diff --git a/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md b/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md new file mode 100644 index 0000000000..d2711afcb8 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md @@ -0,0 +1,1485 @@ +# WASM Webhook Improvements Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Improve WASM channel webhook handling with WhatsApp HMAC signature verification, flexible verification modes, webhook deduplication, and ACK deferral for reliable message processing. + +**Architecture:** Extend the existing WASM channel infrastructure in three layers: (1) signature verification module adds WhatsApp-style HMAC, (2) schema/router add verification modes and deduplication, (3) wrapper adds deferred ACK with on_message_persisted callback. + +**Tech Stack:** Rust, axum, hmac, sha2, subtle (constant-time comparison), PostgreSQL/libSQL + +**Source Branch:** `feat/whatsapp-hmac-signature-verification` (PR closed, changes to be integrated) +**Target Branch:** New branch from `upstream/main` + +**Prerequisites:** +- Latest migration in upstream/main is **V11** → new migration must be **V12** +- Current `router.register()` has 4 params → will add 2 new params (backward compatible) +- `register_hmac_secret()` already exists in router + +--- + +## File Structure + +``` +src/channels/wasm/ +├── signature.rs # Add verify_hmac_sha256 for WhatsApp +├── schema.rs # Add verification_mode, message_id_json_pointer fields +├── router.rs # Add dedup, ACK deferral, verification modes +├── wrapper.rs # Add call_on_http_request_with_messages, on_message_persisted +└── loader.rs # Pass new config fields to router + +src/db/ +├── mod.rs # Add WebhookDedupStore trait +├── postgres.rs # Implement WebhookDedupStore +└── libsql/ + ├── mod.rs # Implement WebhookDedupStore + └── webhook_dedup.rs # libSQL-specific dedup module + +migrations/ +└── V12__webhook_dedup.sql # Dedup table migration (V11 is latest in upstream) + +wit/ +└── channel.wit # Add on_message_persisted callback + +channels-src/whatsapp/ +├── src/lib.rs # Implement on_message_persisted for mark_as_read +└── whatsapp.capabilities.json # Add hmac_secret_name, verification_mode + +src/main.rs # Initialize router.set_db() on startup +``` + +--- + +## Chunk 1: WhatsApp HMAC Signature Verification + +### Task 1.1: Add verify_hmac_sha256 function + +**Files:** +- Modify: `src/channels/wasm/signature.rs` + +**Context:** WhatsApp Cloud API sends webhook signatures in `X-Hub-Signature-256` header with format `sha256=`. This is simpler than Slack's versioned basestring (no timestamp prefix). + +- [ ] **Step 1: Write the failing test** + +```rust +// In src/channels/wasm/signature.rs, add to mod tests: + +/// Helper: compute HMAC-SHA256 signature in WhatsApp/Meta format (`sha256=`). +fn compute_whatsapp_style_hmac_signature(secret: &str, body: &[u8]) -> String { + use hmac::Mac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) +} + +#[test] +fn test_hmac_valid_signature_succeeds() { + let secret = "my_app_secret"; + let body = br#"{"entry":[{"id":"123"}]}"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + verify_hmac_sha256(secret, &sig_header, body), + "Valid HMAC signature should verify" + ); +} + +#[test] +fn test_hmac_wrong_secret_fails() { + let secret = "correct_secret"; + let wrong_secret = "wrong_secret"; + let body = br#"{"test":"data"}"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + !verify_hmac_sha256(wrong_secret, &sig_header, body), + "Signature with wrong secret should fail" + ); +} + +#[test] +fn test_hmac_tampered_body_fails() { + let secret = "my_secret"; + let body = br#"original body"#; + let tampered = br#"tampered body"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + !verify_hmac_sha256(secret, &sig_header, tampered), + "Tampered body should fail verification" + ); +} + +#[test] +fn test_hmac_invalid_header_format_fails() { + let secret = "secret"; + let body = br#"data"#; + + assert!(!verify_hmac_sha256(secret, "invalid", body)); + assert!(!verify_hmac_sha256(secret, "sha256=not_hex!", body)); + assert!(!verify_hmac_sha256(secret, "", body)); +} + +#[test] +fn test_hmac_wrong_length_fails() { + let secret = "secret"; + let body = br#"data"#; + // 16 bytes instead of 32 + let short_sig = format!("sha256={}", "a".repeat(16)); + + assert!( + !verify_hmac_sha256(secret, &short_sig, body), + "Wrong-length signature should fail" + ); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test channels::wasm::signature::tests::test_hmac --no-run 2>&1 | grep -E "error|verify_hmac_sha256"` +Expected: Compilation error - `verify_hmac_sha256` not found + +- [ ] **Step 3: Add imports and type alias at top of file** + +```rust +// At the top of src/channels/wasm/signature.rs, add after existing imports: + +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; +``` + +- [ ] **Step 4: Write the implementation** + +Add after `verify_discord_signature` function: + +```rust +/// Verify HMAC-SHA256 signature (WhatsApp style, simple body-only). +/// +/// # Arguments +/// * `secret` - The HMAC secret (App Secret) +/// * `signature_header` - Value from X-Hub-Signature-256 header (format: "sha256=") +/// * `body` - Raw request body bytes +/// +/// # Returns +/// `true` if signature is valid, `false` otherwise +pub fn verify_hmac_sha256(secret: &str, signature_header: &str, body: &[u8]) -> bool { + // Parse header format: "sha256=" + let Some(hex_signature) = signature_header.strip_prefix("sha256=") else { + return false; + }; + + // Decode expected signature + let Ok(expected_sig) = hex::decode(hex_signature) else { + return false; + }; + + // SHA-256 produces 32-byte signatures - reject wrong lengths early + if expected_sig.len() != 32 { + return false; + } + + // Compute HMAC-SHA256 + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + let result = mac.finalize(); + let computed_sig = result.into_bytes(); + + // Constant-time comparison to prevent timing attacks + computed_sig + .as_slice() + .ct_eq(expected_sig.as_slice()) + .into() +} +``` + +- [ ] **Step 5: Refactor verify_slack_signature to use shared HmacSha256** + +Remove the local imports in `verify_slack_signature`: + +```rust +// REMOVE these lines from inside verify_slack_signature: +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; +``` + +Change `Hmac::` to `HmacSha256`: + +```rust +// Change this line: +let mut mac = match Hmac::::new_from_slice(signing_secret.as_bytes()) { +// To: +let mut mac = match HmacSha256::new_from_slice(signing_secret.as_bytes()) { +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `cargo test channels::wasm::signature::tests::test_hmac` +Expected: All 5 tests pass + +- [ ] **Step 7: Run full signature tests** + +Run: `cargo test channels::wasm::signature::tests` +Expected: All signature tests pass (Discord, Slack, WhatsApp) + +- [ ] **Step 8: Commit** + +```bash +git add src/channels/wasm/signature.rs +git commit -m "feat(wasm): add verify_hmac_sha256 for WhatsApp webhook signatures + +Adds simple body-only HMAC-SHA256 verification for WhatsApp/Meta webhooks. +Uses X-Hub-Signature-256 header with sha256= format. +Refactors to share HmacSha256 type alias with Slack verification." +``` + +--- + +## Chunk 2: Schema Extensions for Verification Modes + +### Task 2.1: Add new webhook configuration fields + +**Files:** +- Modify: `src/channels/wasm/schema.rs` + +**Context:** WhatsApp needs different verification for GET (query param) vs POST (HMAC signature). Also need to extract message IDs for deduplication. + +- [ ] **Step 1: Write the failing tests** + +```rust +// In src/channels/wasm/schema.rs, add to mod tests: + +#[test] +fn test_webhook_verification_mode_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "verification_mode": "query_param" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!(cap.webhook_verification_mode(), Some("query_param")); +} + +#[test] +fn test_webhook_hmac_secret_name_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "hmac_secret_name": "whatsapp_app_secret" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!(cap.webhook_hmac_secret_name(), Some("whatsapp_app_secret")); +} + +#[test] +fn test_webhook_message_id_json_pointer_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "message_id_json_pointer": "/message_id" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!(cap.webhook_message_id_json_pointer(), Some("/message_id")); +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `cargo test channels::wasm::schema::tests::test_webhook 2>&1 | grep -E "error|no method"` +Expected: Compilation errors for missing methods/fields + +- [ ] **Step 3: Add new fields to WebhookSchema** + +```rust +// In WebhookSchema struct, add after signature_key_secret_name: + +/// How to handle GET request validation: +/// - None/default: Require secret header for all requests (current behavior) +/// - "query_param": Skip host-level secret validation for GET requests; +/// the WASM module validates via query param (e.g., WhatsApp hub.verify_token) +/// - "signature": Always require signature validation (for Discord-style Ed25519) +#[serde(default)] +pub verification_mode: Option, + +/// Secret name in secrets store containing the HMAC secret +/// for signature verification (e.g., WhatsApp/Slack webhook signatures). +/// The header format is expected to be "sha256=". +#[serde(default)] +pub hmac_secret_name: Option, + +/// JSON pointer path to extract message ID from metadata_json. +/// Used for ACK key construction and deduplication. +/// Format: "/field1/field2" to access {"field1": {"field2": "value"}} +/// If None, the router falls back to using user_id. +#[serde(default)] +pub message_id_json_pointer: Option, +``` + +- [ ] **Step 4: Add accessor methods to ChannelCapabilitiesFile** + +```rust +// Add after webhook_secret_name method: + +/// Get the webhook verification mode for this channel. +/// +/// Returns the verification mode declared in `webhook.verification_mode`: +/// - None/default: Require secret header for all requests +/// - "query_param": Skip host-level secret validation for GET, WASM validates via query param +/// - "signature": Always require signature validation +pub fn webhook_verification_mode(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.verification_mode.as_deref()) +} + +/// Get the HMAC secret name for webhook signature verification. +/// +/// Returns the secret name declared in `webhook.hmac_secret_name`, +/// used to look up the HMAC secret in the secrets store for +/// WhatsApp/Slack-style signature verification. +pub fn webhook_hmac_secret_name(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.hmac_secret_name.as_deref()) +} + +/// Get the JSON pointer path to extract message ID from metadata. +/// +/// Returns the JSON pointer declared in `webhook.message_id_json_pointer`, +/// used for ACK key construction and deduplication. +/// If None, the router falls back to using user_id. +pub fn webhook_message_id_json_pointer(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.message_id_json_pointer.as_deref()) +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `cargo test channels::wasm::schema::tests::test_webhook` +Expected: All 3 tests pass + +- [ ] **Step 6: Run full schema tests** + +Run: `cargo test channels::wasm::schema::tests` +Expected: All schema tests pass + +- [ ] **Step 7: Commit** + +```bash +git add src/channels/wasm/schema.rs +git commit -m "feat(wasm): add verification_mode and message_id_json_pointer to webhook schema + +Adds three new webhook configuration fields: +- verification_mode: query_param/signature/default +- hmac_secret_name: for WhatsApp/Slack HMAC verification +- message_id_json_pointer: for extracting message IDs from metadata" +``` + +--- + +## Chunk 3: Webhook Deduplication Database Layer + +### Task 3.1: Add WebhookDedupStore trait and PostgreSQL implementation + +**Files:** +- Modify: `src/db/mod.rs` +- Modify: `src/db/postgres.rs` +- Create: `migrations/V12__webhook_dedup.sql` (V12 because V11 is latest in upstream) + +**Context:** WhatsApp retries webhooks up to 7 days on 5xx errors. Need atomic deduplication to prevent duplicate message processing. + +- [ ] **Step 1: Create migration file** + +```sql +-- migrations/V12__webhook_dedup.sql +-- Webhook message deduplication table +-- Prevents duplicate processing when channels retry on errors + +CREATE TABLE IF NOT EXISTS webhook_message_dedup ( + -- Composite key: channel name + message ID from the channel + -- e.g., "whatsapp:wamid.HBgM..." or "telegram:12345" + key TEXT PRIMARY KEY, + + -- When this message was first seen + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for cleanup queries (delete old records) +CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at + ON webhook_message_dedup(created_at); + +-- Comment explaining purpose +COMMENT ON TABLE webhook_message_dedup IS + 'Deduplication table for webhook messages. Channels like WhatsApp retry on 5xx for up to 7 days. This table ensures idempotent processing.'; +``` + +- [ ] **Step 2: Add WebhookDedupStore trait to src/db/mod.rs** + +```rust +// Add after existing traits: + +/// Webhook message deduplication store. +/// +/// Prevents duplicate processing when channels retry webhooks on errors. +/// WhatsApp, for example, retries for up to 7 days on 5xx responses. +#[async_trait] +pub trait WebhookDedupStore: Send + Sync { + /// Try to record that a message is processed, atomically. + /// + /// Returns `true` if this is a new message (was inserted), + /// `false` if it was a duplicate (key already exists). + /// + /// Uses INSERT ... ON CONFLICT DO NOTHING for atomic dedup with no race condition. + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result; + + /// Clean up old dedup records. + /// + /// Called periodically to prevent unbounded growth. + /// Returns the number of records deleted. + async fn cleanup_old_webhook_dedup_records(&self) -> Result; +} +``` + +- [ ] **Step 3: Add trait bound to Database trait** + +```rust +// Modify the Database trait declaration to include WebhookDedupStore: +pub trait Database: + MessageStore + + ThreadStore + + ToolStore + + JobStore + + SessionStore + + PairingStore + + WasmToolStore + + SecretsStore + + SettingsStore + + WorkspaceStore + + RoutineStore + + WebhookDedupStore // Add this + + Send + + Sync +{ +} +``` + +- [ ] **Step 4: Implement WebhookDedupStore for PostgresBackend** + +Add to `src/db/postgres.rs` before `mod tests`: + +```rust +#[async_trait] +impl WebhookDedupStore for PostgresBackend { + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result { + let key = format!("{}:{}", channel_name, message_id); + + let result = sqlx::query( + r#" + INSERT INTO webhook_message_dedup (key) + VALUES ($1) + ON CONFLICT (key) DO NOTHING + "#, + ) + .bind(&key) + .execute(&self.pool) + .await + .map_err(|e| DbError::QueryError(e.to_string()))?; + + // rows_affected is 1 if inserted, 0 if conflict (duplicate) + Ok(result.rows_affected() == 1) + } + + async fn cleanup_old_webhook_dedup_records(&self) -> Result { + // Delete records older than 30 days + let result = sqlx::query( + r#" + DELETE FROM webhook_message_dedup + WHERE created_at < NOW() - INTERVAL '30 days' + "#, + ) + .execute(&self.pool) + .await + .map_err(|e| DbError::QueryError(e.to_string()))?; + + Ok(result.rows_affected()) + } +} +``` + +- [ ] **Step 5: Write proper tests with sqlx::test** + +```rust +// Add to mod tests in src/db/postgres.rs: + +#[sqlx::test] +async fn test_webhook_dedup_inserts_new_key(pool: PgPool) { + let db = PostgresBackend::with_pool(pool); + + // First insert should succeed + let is_new = db + .record_webhook_message_processed("whatsapp", "msg123") + .await + .unwrap(); + assert!(is_new, "First insert should return true (new message)"); +} + +#[sqlx::test] +async fn test_webhook_dedup_rejects_duplicate(pool: PgPool) { + let db = PostgresBackend::with_pool(pool); + + // First insert + let is_new1 = db + .record_webhook_message_processed("whatsapp", "msg456") + .await + .unwrap(); + assert!(is_new1); + + // Second insert (duplicate) should return false + let is_new2 = db + .record_webhook_message_processed("whatsapp", "msg456") + .await + .unwrap(); + assert!(!is_new2, "Duplicate insert should return false"); +} + +#[sqlx::test] +async fn test_webhook_dedup_different_channels_same_msg_id(pool: PgPool) { + let db = PostgresBackend::with_pool(pool); + + // Same message ID in different channels should both succeed + let is_new1 = db + .record_webhook_message_processed("whatsapp", "msg789") + .await + .unwrap(); + let is_new2 = db + .record_webhook_message_processed("telegram", "msg789") + .await + .unwrap(); + + assert!(is_new1); + assert!(is_new2, "Same msg_id in different channels should be separate keys"); +} +``` + +- [ ] **Step 6: Run tests with postgres feature** + +Run: `cargo test db::postgres::tests::test_webhook_dedup --features postgres` +Expected: All 3 tests pass + +- [ ] **Step 7: Commit** + +```bash +git add src/db/mod.rs src/db/postgres.rs migrations/V12__webhook_dedup.sql +git commit -m "feat(db): add webhook message deduplication store + +Adds WebhookDedupStore trait and PostgreSQL implementation. +Prevents duplicate processing when channels retry webhooks. +Uses INSERT ON CONFLICT DO NOTHING for atomic deduplication." +``` + +### Task 3.2: Add libSQL implementation + +**Files:** +- Create: `src/db/libsql/webhook_dedup.rs` +- Modify: `src/db/libsql/mod.rs` +- Modify: `src/db/libsql_migrations.rs` + +- [ ] **Step 1: Create webhook_dedup.rs module** + +```rust +// src/db/libsql/webhook_dedup.rs +//! Webhook message deduplication for libSQL backend. + +use async_trait::async_trait; +use libsql::Connection; + +use crate::db::{DbError, WebhookDedupStore}; + +/// LibSQL implementation of WebhookDedupStore. +pub struct LibSqlWebhookDedupStore { + conn: Connection, +} + +impl LibSqlWebhookDedupStore { + /// Create a new webhook dedup store. + pub fn new(conn: Connection) -> Self { + Self { conn } + } +} + +#[async_trait] +impl WebhookDedupStore for LibSqlWebhookDedupStore { + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result { + let key = format!("{}:{}", channel_name, message_id); + + // libSQL uses INSERT OR IGNORE for SQLite compatibility + let result = self + .conn + .execute( + "INSERT OR IGNORE INTO webhook_message_dedup (key) VALUES (?1)", + [libsql::Value::from(key)], + ) + .await + .map_err(|e| DbError::QueryError(e.to_string()))?; + + // rows_affected is 1 if inserted, 0 if ignored (duplicate) + Ok(result.rows_affected() == 1) + } + + async fn cleanup_old_webhook_dedup_records(&self) -> Result { + // Delete records older than 30 days (SQLite datetime syntax) + let result = self + .conn + .execute( + "DELETE FROM webhook_message_dedup WHERE created_at < datetime('now', '-30 days')", + [], + ) + .await + .map_err(|e| DbError::QueryError(e.to_string()))?; + + Ok(result.rows_affected()) + } +} +``` + +- [ ] **Step 2: Add migration to libsql_migrations.rs** + +```rust +// In src/db/libsql_migrations.rs, find the migrations array and add: + +// Migration 12: Webhook deduplication table +( + 12, + r#" + CREATE TABLE IF NOT EXISTS webhook_message_dedup ( + key TEXT PRIMARY KEY, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at + ON webhook_message_dedup(created_at); + "#, +), +``` + +- [ ] **Step 3: Update mod.rs to expose and wire the store** + +```rust +// In src/db/libsql/mod.rs, add module declaration: +mod webhook_dedup; +pub use webhook_dedup::LibSqlWebhookDedupStore; + +// In LibSqlBackend struct, add field: +pub struct LibSqlBackend { + // ... existing fields ... + webhook_dedup: LibSqlWebhookDedupStore, +} + +// In LibSqlBackend::new(), after connection is established: +impl LibSqlBackend { + pub async fn new(config: &LibSqlConfig) -> Result { + // ... existing initialization code ... + let webhook_dedup = LibSqlWebhookDedupStore::new(conn.clone()); + + Ok(Self { + // ... existing fields ... + webhook_dedup, + }) + } +} + +// Add WebhookDedupStore impl that delegates: +#[async_trait] +impl WebhookDedupStore for LibSqlBackend { + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result { + self.webhook_dedup + .record_webhook_message_processed(channel_name, message_id) + .await + } + + async fn cleanup_old_webhook_dedup_records(&self) -> Result { + self.webhook_dedup.cleanup_old_webhook_dedup_records().await + } +} +``` + +- [ ] **Step 4: Test compilation with libsql feature only** + +Run: `cargo check --no-default-features --features libsql` +Expected: No compilation errors + +- [ ] **Step 5: Test compilation with all features** + +Run: `cargo check --all-features` +Expected: No compilation errors + +- [ ] **Step 6: Commit** + +```bash +git add src/db/libsql/webhook_dedup.rs src/db/libsql/mod.rs src/db/libsql_migrations.rs +git commit -m "feat(db): add libSQL implementation of WebhookDedupStore + +Uses INSERT OR IGNORE for atomic deduplication. +Compatible with Turso cloud and local libSQL." +``` + +--- + +## Chunk 4: Router Integration + +### Task 4.1: Add verification modes and HMAC support to router + +**Files:** +- Modify: `src/channels/wasm/router.rs` +- Modify: `src/channels/wasm/loader.rs` + +**Context:** Router needs to support new verification modes and HMAC signature validation. Current `register()` has 4 params; we add 2 more (backward compatible by using Option). + +- [ ] **Step 1: Add new fields to WasmChannelRouter struct** + +```rust +// In src/channels/wasm/router.rs, modify WasmChannelRouter struct: + +pub struct WasmChannelRouter { + // ... existing fields (channels, path_to_channel, secrets, secret_headers, signature_keys, hmac_secrets) ... + + /// Verification mode per channel: "query_param", "signature", etc. + verification_modes: RwLock>, + /// JSON pointers for extracting message IDs from metadata_json by channel name. + message_id_json_pointers: RwLock>, + /// Database for webhook message deduplication (optional - graceful degradation if not set). + db: RwLock>>, +} +``` + +- [ ] **Step 2: Update WasmChannelRouter::new()** + +```rust +impl WasmChannelRouter { + pub fn new() -> Self { + Self { + channels: RwLock::new(HashMap::new()), + path_to_channel: RwLock::new(HashMap::new()), + secrets: RwLock::new(HashMap::new()), + secret_headers: RwLock::new(HashMap::new()), + signature_keys: RwLock::new(HashMap::new()), + hmac_secrets: RwLock::new(HashMap::new()), + verification_modes: RwLock::new(HashMap::new()), + message_id_json_pointers: RwLock::new(HashMap::new()), + db: RwLock::new(None), + } + } + + /// Set the database for webhook message deduplication. + /// + /// If not called, deduplication is disabled (webhooks process without idempotency check). + pub async fn set_db(&self, db: Arc) { + *self.db.write().await = Some(db); + } + + /// Get the database for webhook message deduplication. + /// + /// Returns None if deduplication is not configured. + pub async fn get_db(&self) -> Option> { + self.db.read().await.clone() + } +} +``` + +- [ ] **Step 3: Update register() signature (add 2 new optional params)** + +```rust +/// Register a channel with its endpoints. +/// +/// # Arguments +/// * `channel` - The WASM channel to register +/// * `endpoints` - HTTP endpoints to register for this channel +/// * `secret` - Optional webhook secret for validation +/// * `secret_header` - Optional HTTP header name for secret validation +/// * `verification_mode` - Optional verification mode for GET requests: +/// - "query_param": Skip host-level secret validation for GET, WASM validates via query param +/// - "signature": Always require signature validation +/// * `message_id_json_pointer` - Optional JSON pointer to extract message ID from metadata_json +pub async fn register( + &self, + channel: Arc, + endpoints: Vec, + secret: Option, + secret_header: Option, + verification_mode: Option, // NEW + message_id_json_pointer: Option, // NEW +) { + let name = channel.channel_name().to_string(); + + // Store the channel + self.channels.write().await.insert(name.clone(), channel); + + // Register path mappings + let mut path_map = self.path_to_channel.write().await; + for endpoint in endpoints { + path_map.insert(endpoint.path.clone(), name.clone()); + tracing::info!( + channel = %name, + path = %endpoint.path, + methods = ?endpoint.methods, + "Registered WASM channel HTTP endpoint" + ); + } + drop(path_map); + + // Store secret if provided + if let Some(s) = secret { + self.secrets.write().await.insert(name.clone(), s); + } + + // Store secret header if provided + if let Some(h) = secret_header { + self.secret_headers.write().await.insert(name.clone(), h); + } + + // Store verification mode if provided + if let Some(m) = verification_mode { + self.verification_modes + .write() + .await + .insert(name.clone(), m); + } + + // Store message ID JSON pointer if provided + if let Some(p) = message_id_json_pointer { + self.message_id_json_pointers + .write() + .await + .insert(name.clone(), p); + } +} +``` + +- [ ] **Step 4: Add accessor methods for new fields** + +```rust +impl WasmChannelRouter { + // ... existing methods ... + + /// Get the verification mode for a channel. + pub async fn get_verification_mode(&self, channel_name: &str) -> Option { + self.verification_modes + .read() + .await + .get(channel_name) + .cloned() + } + + /// Get the message ID JSON pointer for a channel. + pub async fn get_message_id_json_pointer(&self, channel_name: &str) -> Option { + self.message_id_json_pointers + .read() + .await + .get(channel_name) + .cloned() + } +} +``` + +- [ ] **Step 5: Update unregister() to clean up new fields** + +```rust +// In unregister() method, add cleanup for new fields: + +pub async fn unregister(&self, channel_name: &str) { + self.channels.write().await.remove(channel_name); + self.path_to_channel.write().await.retain(|_, v| v != channel_name); + self.secrets.write().await.remove(channel_name); + self.secret_headers.write().await.remove(channel_name); + self.signature_keys.write().await.remove(channel_name); + self.hmac_secrets.write().await.remove(channel_name); + // Add these: + self.verification_modes.write().await.remove(channel_name); + self.message_id_json_pointers.write().await.remove(channel_name); +} +``` + +- [ ] **Step 6: Update loader.rs to pass new parameters** + +```rust +// In src/channels/wasm/loader.rs, find where router.register() is called +// and update it to pass the new parameters: + +// After reading capabilities file: +let verification_mode = caps.webhook_verification_mode().map(|s| s.to_string()); +let message_id_json_pointer = caps.webhook_message_id_json_pointer().map(|s| s.to_string()); + +// Update router.register() call: +router.register( + channel, + endpoints, + secret, + secret_header, + verification_mode, // NEW + message_id_json_pointer, // NEW +).await; +``` + +- [ ] **Step 7: Test compilation** + +Run: `cargo check` +Expected: No compilation errors + +- [ ] **Step 8: Commit** + +```bash +git add src/channels/wasm/router.rs src/channels/wasm/loader.rs +git commit -m "feat(wasm): add verification_mode and message_id support to router + +Router now accepts and stores verification_mode and message_id_json_pointer +from channel capabilities. Adds optional database hook for deduplication +with graceful degradation if not configured." +``` + +--- + +## Chunk 5: WIT Interface and on_message_persisted Callback + +### Task 5.1: Add on_message_persisted to WIT interface + +**Files:** +- Modify: `wit/channel.wit` +- Modify: `src/channels/wasm/wrapper.rs` + +**IMPORTANT:** After modifying WIT, you MUST regenerate bindings. + +- [ ] **Step 1: Add callback to WIT interface** + +```wit +// In wit/channel.wit, add after on_respond (around line 310): + +/// Called after a message has been persisted to the database. +/// +/// Channels can use this to perform follow-up actions like +/// calling external APIs (e.g., WhatsApp mark_as_read). +/// This is optional - channels that don't need it can return Ok. +/// +/// Arguments: +/// - metadata-json: The metadata from the persisted message +/// +/// Returns: +/// - Ok: Post-persistence action completed successfully +/// - Err(string): Action failure message (does not block the ACK) +on-message-persisted: func(metadata-json: string) -> result<_, string>; +``` + +- [ ] **Step 2: Regenerate WIT bindings** + +Run: `cargo build -p wit-bindgen 2>&1 || echo "If wit-bindgen not separate, build will regenerate on next compile"` + +Then run: `cargo build` to trigger binding regeneration. + +- [ ] **Step 3: Add implementation to wrapper.rs** + +```rust +// In src/channels/wasm/wrapper.rs, add method to WasmChannel: + +/// Execute the on_message_persisted callback. +/// +/// Called after a message has been successfully persisted to the database. +/// Channels can use this for follow-up actions like WhatsApp mark_as_read. +/// +/// Returns Ok(()) even on failure - this is best-effort and should not block ACKs. +pub async fn call_on_message_persisted( + &self, + metadata_json: &str, +) -> Result<(), WasmChannelError> { + // If no WASM bytes, return Ok (for testing) + if self.prepared.component().is_none() { + tracing::debug!( + channel = %self.name, + "on_message_persisted called (no WASM module)" + ); + return Ok(()); + } + + let runtime = Arc::clone(&self.runtime); + let prepared = Arc::clone(&self.prepared); + let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); + let timeout = self.runtime.config().callback_timeout; + let credentials = self.get_credentials().await; + let pairing_store = self.pairing_store.clone(); + let metadata_json = metadata_json.to_string(); + let channel_name = self.name.clone(); + + let result = tokio::time::timeout(timeout, async move { + tokio::task::spawn_blocking(move || { + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + Default::default(), // host_credentials not needed for this callback + pairing_store, + )?; + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + let channel_iface = instance.near_agent_channel(); + channel_iface + .call_on_message_persisted(&mut store, &metadata_json) + .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; + + Ok::<_, WasmChannelError>(()) + }) + .await + .map_err(|e| WasmChannelError::ExecutionPanicked { + name: channel_name, + reason: e.to_string(), + })? + }) + .await; + + match result { + Ok(Ok(())) => { + tracing::debug!(channel = %self.name, "on_message_persisted completed"); + Ok(()) + } + Ok(Err(e)) => { + // Log but don't fail - this is best-effort + tracing::warn!(channel = %self.name, error = %e, "on_message_persisted failed"); + Ok(()) + } + Err(_timeout) => { + tracing::warn!(channel = %self.name, "on_message_persisted timed out"); + Ok(()) + } + } +} +``` + +- [ ] **Step 4: Test compilation** + +Run: `cargo check` +Expected: No compilation errors + +- [ ] **Step 5: Commit** + +```bash +git add wit/channel.wit src/channels/wasm/wrapper.rs +git commit -m "feat(wasm): add on_message_persisted callback to WIT interface + +Allows channels to perform follow-up actions after message persistence, +such as WhatsApp mark_as_read API calls. Best-effort execution - failures +are logged but do not block the ACK." +``` + +--- + +## Chunk 6: WhatsApp Channel Updates + +### Task 6.1: Update WhatsApp capabilities and implementation + +**Files:** +- Modify: `channels-src/whatsapp/whatsapp.capabilities.json` +- Modify: `channels-src/whatsapp/src/lib.rs` + +**Note:** API version stays at v18.0 (matching upstream/main) - only adding new fields. + +- [ ] **Step 1: Update capabilities file** + +```json +{ + "version": "0.2.0", + "wit_version": "0.3.0", + "type": "channel", + "name": "whatsapp", + "description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages", + "setup": { + "required_secrets": [ + { + "name": "whatsapp_access_token", + "prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).", + "validation": "^[A-Za-z0-9_-]+$" + }, + { + "name": "whatsapp_verify_token", + "prompt": "Webhook verify token (leave empty to auto-generate)", + "optional": true, + "auto_generate": { "length": 32 } + }, + { + "name": "whatsapp_app_secret", + "prompt": "Enter your WhatsApp App Secret (from Meta Developer Portal > App Settings > Basic). Used for HMAC signature verification.", + "validation": "^[a-f0-9]{32}$", + "optional": true + } + ], + "validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}", + "setup_url": "https://developers.facebook.com/apps" + }, + "capabilities": { + "http": { + "allowlist": [ + { "host": "graph.facebook.com", "path_prefix": "/" } + ], + "rate_limit": { + "requests_per_minute": 80, + "requests_per_hour": 1000 + } + }, + "secrets": { + "allowed_names": ["whatsapp_*"] + }, + "channel": { + "allowed_paths": ["/webhook/whatsapp"], + "allow_polling": false, + "workspace_prefix": "channels/whatsapp/", + "emit_rate_limit": { + "messages_per_minute": 100, + "messages_per_hour": 5000 + }, + "webhook": { + "secret_header": "X-Hub-Signature-256", + "secret_name": "whatsapp_verify_token", + "verification_mode": "query_param", + "hmac_secret_name": "whatsapp_app_secret", + "message_id_json_pointer": "/message_id" + } + } + }, + "config": { + "api_version": "v18.0", + "reply_to_message": true, + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] + } +} +``` + +- [ ] **Step 2: Implement on_message_persisted in WhatsApp channel** + +```rust +// In channels-src/whatsapp/src/lib.rs, add to impl Guest: + +fn on_message_persisted(metadata_json: String) -> Result<(), String> { + channel_host::log( + channel_host::LogLevel::Debug, + "on_message_persisted callback invoked", + ); + + // Parse metadata to get message_id and phone_number_id + let metadata: WhatsAppMessageMetadata = match serde_json::from_str(&metadata_json) { + Ok(m) => m, + Err(e) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to parse metadata in on_message_persisted: {}", e), + ); + // Don't fail the ACK - just log and return + return Ok(()); + } + }; + + // Skip if no message_id (shouldn't happen, but defensive) + if metadata.message_id.is_empty() { + channel_host::log( + channel_host::LogLevel::Debug, + "Skipping mark_as_read - no message_id in metadata", + ); + return Ok(()); + } + + // Read api_version from workspace (set during on_start), fallback to default + let api_version = channel_host::workspace_read("channels/whatsapp/api_version") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "v18.0".to_string()); + + // Build WhatsApp mark_as_read API URL + let url = format!( + "https://graph.facebook.com/{}/{}/messages", + api_version, metadata.phone_number_id + ); + + // Build mark_as_read payload + let payload = serde_json::json!({ + "messaging_product": "whatsapp", + "status": "read", + "message_id": metadata.message_id + }); + + let payload_bytes = serde_json::to_vec(&payload) + .map_err(|e| format!("Failed to serialize mark_as_read payload: {}", e))?; + + // Headers with Bearer token placeholder + // Host will inject the actual access token + let headers = serde_json::json!({ + "Content-Type": "application/json", + "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}" + }); + + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Calling mark_as_read for message: {}", metadata.message_id), + ); + + let result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(http_response) => { + if http_response.status >= 200 && http_response.status < 300 { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Marked message {} as read", metadata.message_id), + ); + } else { + let body_str = String::from_utf8_lossy(&http_response.body); + channel_host::log( + channel_host::LogLevel::Warn, + &format!( + "mark_as_read API error: {} - {}", + http_response.status, body_str + ), + ); + } + } + Err(e) => { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("mark_as_read HTTP request failed: {}", e), + ); + } + } + + // Always return Ok - mark_as_read is best-effort + Ok(()) +} +``` + +- [ ] **Step 3: Build WASM** + +Run: `cargo build -p whatsapp --target wasm32-wasip2 --release` +Expected: Successful build + +- [ ] **Step 4: Commit** + +```bash +git add channels-src/whatsapp/whatsapp.capabilities.json channels-src/whatsapp/src/lib.rs +git commit -m "feat(whatsapp): add HMAC signature verification and mark_as_read + +- Add optional whatsapp_app_secret for HMAC verification +- Add verification_mode: query_param for GET/POST differentiation +- Add message_id_json_pointer for deduplication +- Implement on_message_persisted for mark_as_read API calls" +``` + +--- + +## Chunk 7: Main.rs Integration + +### Task 7.1: Wire router.set_db() on startup + +**Files:** +- Modify: `src/main.rs` + +**Context:** The router needs access to the database for webhook deduplication. This must be called during app initialization. + +- [ ] **Step 1: Find router initialization in main.rs** + +Search for where `wasm_channel_router` is created and passed to app state. + +- [ ] **Step 2: Add set_db() call after database initialization** + +```rust +// In src/main.rs, after database is initialized and before app starts, +// find where the router is available and add: + +// Wire database to router for webhook deduplication +if let Some(db) = &db { + let db_clone = db.clone(); + let router = &wasm_channel_router; // or however it's named + router.set_db(db_clone).await; + tracing::info!("Webhook deduplication enabled"); +} else { + tracing::warn!("Webhook deduplication disabled - no database configured"); +} +``` + +- [ ] **Step 3: Test compilation** + +Run: `cargo check` +Expected: No compilation errors + +- [ ] **Step 4: Commit** + +```bash +git add src/main.rs +git commit -m "feat: wire database to WASM channel router for webhook deduplication + +Calls router.set_db() during startup if database is available. +Logs warning if deduplication is disabled due to missing database." +``` + +--- + +## Chunk 8: Integration Tests + +### Task 8.1: Add integration tests + +**Files:** +- Modify: `tests/wasm_channel_integration.rs` + +- [ ] **Step 1: Add HMAC verification test** + +```rust +// In tests/wasm_channel_integration.rs, add: + +use crate::channels::wasm::signature::verify_hmac_sha256; + +#[test] +fn test_whatsapp_hmac_signature_verification() { + // Test vectors from WhatsApp documentation + let secret = "test_app_secret"; + let body = br#"{"entry":[{"id":"123456789","changes":[{"field":"messages","value":{"messages":[{"id":"wamid.HBgM..."}]}}]}]}"#; + + // Compute valid signature + use hmac::{Hmac, Mac}; + use sha2::Sha256; + let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); + + // Verify + assert!( + verify_hmac_sha256(secret, &sig, body), + "Valid signature should verify" + ); + + // Wrong secret + assert!( + !verify_hmac_sha256("wrong_secret", &sig, body), + "Wrong secret should fail" + ); + + // Tampered body + assert!( + !verify_hmac_sha256(secret, &sig, br#"{"tampered":"data"}"#), + "Tampered body should fail" + ); +} +``` + +- [ ] **Step 2: Run tests** + +Run: `cargo test wasm_channel_integration::test_whatsapp` +Expected: Test passes + +- [ ] **Step 3: Commit** + +```bash +git add tests/wasm_channel_integration.rs +git commit -m "test(wasm): add HMAC signature verification integration test" +``` + +--- + +## Final Verification + +- [ ] **Run full test suite with postgres** + +Run: `cargo test --features postgres` +Expected: All tests pass + +- [ ] **Run full test suite with libsql** + +Run: `cargo test --no-default-features --features libsql` +Expected: All tests pass + +- [ ] **Run clippy** + +Run: `cargo clippy --all --benches --tests --examples --all-features` +Expected: Zero warnings + +- [ ] **Run fmt** + +Run: `cargo fmt --check` +Expected: No formatting issues + +--- + +## Summary + +This plan delivers: + +| Feature | Description | Chunk | +|---------|-------------|-------| +| **WhatsApp HMAC** | `verify_hmac_sha256` for webhook signature verification | 1 | +| **Schema extensions** | `verification_mode`, `hmac_secret_name`, `message_id_json_pointer` | 2 | +| **Deduplication DB** | `WebhookDedupStore` trait + PostgreSQL + libSQL | 3 | +| **Router integration** | New fields in router, updated `register()` signature | 4 | +| **WIT callback** | `on_message_persisted` for post-persistence actions | 5 | +| **WhatsApp channel** | HMAC config + mark_as_read implementation | 6 | +| **Main.rs wiring** | Connect DB to router for deduplication | 7 | +| **Integration tests** | HMAC verification tests | 8 | + +**Estimated Effort:** 4-5 hours for experienced Rust developer + +**Dependencies:** +- Chunks 1-3 are independent and can be done in parallel +- Chunk 4 depends on chunks 1-3 +- Chunk 5 is independent +- Chunk 6 depends on chunks 2, 5 +- Chunk 7 depends on chunks 3, 4 +- Chunk 8 depends on chunk 1 + +**Breaking Changes:** None - all new fields are optional, `register()` signature extended with `Option` params. diff --git a/migrations/V13__webhook_dedup.sql b/migrations/V13__webhook_dedup.sql new file mode 100644 index 0000000000..5fd190cd5a --- /dev/null +++ b/migrations/V13__webhook_dedup.sql @@ -0,0 +1,19 @@ +-- Webhook message deduplication table +-- Prevents duplicate processing when channels retry on errors + +CREATE TABLE IF NOT EXISTS webhook_message_dedup ( + -- Composite key: channel name + message ID from the channel + -- e.g., "whatsapp:wamid.HBgM..." or "telegram:12345" + key TEXT PRIMARY KEY, + + -- When this message was first seen + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Index for cleanup queries (delete old records) +CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at + ON webhook_message_dedup(created_at); + +-- Comment explaining purpose +COMMENT ON TABLE webhook_message_dedup IS + 'Deduplication table for webhook messages. Channels like WhatsApp retry on 5xx for up to 7 days. This table ensures idempotent processing.'; diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 6329428fea..2a71f6cbb5 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -317,6 +317,20 @@ impl LoadedChannel { .map(|f| f.webhook_secret_name()) .unwrap_or_else(|| format!("{}_webhook_secret", self.channel.channel_name())) } + + /// Get the webhook verification mode from capabilities. + pub fn webhook_verification_mode(&self) -> Option { + self.capabilities_file + .as_ref() + .and_then(|f| f.webhook_verification_mode().map(|s| s.to_string())) + } + + /// Get the message ID JSON pointer from capabilities. + pub fn webhook_message_id_json_pointer(&self) -> Option { + self.capabilities_file + .as_ref() + .and_then(|f| f.webhook_message_id_json_pointer().map(|s| s.to_string())) + } } /// Results from loading multiple channels. diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 8005ccea56..2fd2e44d7f 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -46,6 +46,12 @@ pub struct WasmChannelRouter { signature_keys: RwLock>, /// HMAC-SHA256 signing secrets for signature verification by channel name (Slack-style). hmac_secrets: RwLock>, + /// Verification mode per channel: "query_param", "signature", etc. + verification_modes: RwLock>, + /// JSON pointers for extracting message IDs from metadata_json by channel name. + message_id_json_pointers: RwLock>, + /// Database for webhook message deduplication (optional - graceful degradation if not set). + db: RwLock>>, } impl WasmChannelRouter { @@ -58,9 +64,26 @@ impl WasmChannelRouter { secret_headers: RwLock::new(HashMap::new()), signature_keys: RwLock::new(HashMap::new()), hmac_secrets: RwLock::new(HashMap::new()), + verification_modes: RwLock::new(HashMap::new()), + message_id_json_pointers: RwLock::new(HashMap::new()), + db: RwLock::new(None), } } + /// Set the database for webhook message deduplication. + /// + /// If not called, deduplication is disabled (webhooks process without idempotency check). + pub async fn set_db(&self, db: Arc) { + *self.db.write().await = Some(db); + } + + /// Get the database for webhook message deduplication. + /// + /// Returns None if deduplication is not configured. + pub async fn get_db(&self) -> Option> { + self.db.read().await.clone() + } + /// Register a channel with its endpoints. /// /// # Arguments @@ -69,12 +92,16 @@ impl WasmChannelRouter { /// * `secret` - Optional webhook secret for validation /// * `secret_header` - Optional HTTP header name for secret validation /// (e.g., "X-Telegram-Bot-Api-Secret-Token"). Defaults to "X-Webhook-Secret". + /// * `verification_mode` - Optional verification mode: "query_param", "signature", etc. + /// * `message_id_json_pointer` - Optional JSON pointer to extract message ID from metadata. pub async fn register( &self, channel: Arc, endpoints: Vec, secret: Option, secret_header: Option, + verification_mode: Option, + message_id_json_pointer: Option, ) { let name = channel.channel_name().to_string(); @@ -92,6 +119,7 @@ impl WasmChannelRouter { "Registered WASM channel HTTP endpoint" ); } + drop(path_map); // Store secret if provided if let Some(s) = secret { @@ -100,7 +128,17 @@ impl WasmChannelRouter { // Store secret header if provided if let Some(h) = secret_header { - self.secret_headers.write().await.insert(name, h); + self.secret_headers.write().await.insert(name.clone(), h); + } + + // Store verification mode if provided + if let Some(m) = verification_mode { + self.verification_modes.write().await.insert(name.clone(), m); + } + + // Store message ID JSON pointer if provided + if let Some(p) = message_id_json_pointer { + self.message_id_json_pointers.write().await.insert(name, p); } } @@ -230,6 +268,28 @@ impl WasmChannelRouter { pub async fn get_hmac_secret(&self, channel_name: &str) -> Option { self.hmac_secrets.read().await.get(channel_name).cloned() } + + /// Get the verification mode for a channel. + /// + /// Returns `None` if no mode is configured (default behavior applies). + pub async fn get_verification_mode(&self, channel_name: &str) -> Option { + self.verification_modes + .read() + .await + .get(channel_name) + .cloned() + } + + /// Get the message ID JSON pointer for a channel. + /// + /// Returns `None` if no pointer is configured. + pub async fn get_message_id_json_pointer(&self, channel_name: &str) -> Option { + self.message_id_json_pointers + .read() + .await + .get(channel_name) + .cloned() + } } impl Default for WasmChannelRouter { @@ -692,7 +752,14 @@ mod tests { }]; router - .register(channel, endpoints, Some("secret123".to_string()), None) + .register( + channel, + endpoints, + Some("secret123".to_string()), + None, + None, + None, + ) .await; // Should find channel by path @@ -711,7 +778,7 @@ mod tests { let channel = create_test_channel("slack"); router - .register(channel, vec![], Some("secret123".to_string()), None) + .register(channel, vec![], Some("secret123".to_string()), None, None, None) .await; // Correct secret @@ -722,7 +789,7 @@ mod tests { // Channel without secret always validates let channel2 = create_test_channel("telegram"); - router.register(channel2, vec![], None, None).await; + router.register(channel2, vec![], None, None, None, None).await; assert!(router.validate_secret("telegram", "anything").await); } @@ -738,7 +805,7 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None).await; + router.register(channel, endpoints, None, None, None, None).await; // Should exist assert!( @@ -767,8 +834,8 @@ mod tests { let channel1 = create_test_channel("slack"); let channel2 = create_test_channel("telegram"); - router.register(channel1, vec![], None, None).await; - router.register(channel2, vec![], None, None).await; + router.register(channel1, vec![], None, None, None, None).await; + router.register(channel2, vec![], None, None, None, None).await; let channels = router.list_channels().await; assert_eq!(channels.len(), 2); @@ -800,7 +867,7 @@ mod tests { // Channel without custom header should use default let channel2 = create_test_channel("slack"); router - .register(channel2, vec![], Some("secret456".to_string()), None) + .register(channel2, vec![], Some("secret456".to_string()), None, None, None) .await; assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); } @@ -812,7 +879,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; let hmac_secret = "my-slack-signing-secret"; router.register_hmac_secret("slack", hmac_secret).await; @@ -825,7 +892,7 @@ mod tests { async fn test_no_hmac_secret_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; // Slack has no HMAC secret registered let secret = router.get_hmac_secret("slack").await; @@ -844,7 +911,7 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None).await; + router.register(channel, endpoints, None, None, None, None).await; router.register_hmac_secret("slack", "signing-secret").await; // Secret should exist @@ -864,7 +931,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; router @@ -880,7 +947,7 @@ mod tests { async fn test_no_signature_key_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; // Slack has no signature key registered let key = router.get_signature_key("slack").await; @@ -899,7 +966,7 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None).await; + router.register(channel, endpoints, None, None, None, None).await; // Use a valid 32-byte Ed25519 key for this test let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -923,7 +990,7 @@ mod tests { async fn test_register_valid_signature_key_succeeds() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; // Valid 32-byte Ed25519 public key (from test keypair) let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; @@ -935,7 +1002,7 @@ mod tests { async fn test_register_invalid_hex_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; let result = router .register_signature_key("discord", "not-valid-hex-zzz") @@ -947,7 +1014,7 @@ mod tests { async fn test_register_wrong_length_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; // 16 bytes instead of 32 let short_key = hex::encode([0u8; 16]); @@ -959,7 +1026,7 @@ mod tests { async fn test_register_empty_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; let result = router.register_signature_key("discord", "").await; assert!(result.is_err(), "Empty key should be rejected"); @@ -969,7 +1036,7 @@ mod tests { async fn test_valid_key_is_retrievable() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -985,7 +1052,7 @@ mod tests { async fn test_invalid_key_does_not_store() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None).await; + router.register(channel, vec![], None, None, None, None).await; // Attempt to register invalid key let _ = router @@ -1019,7 +1086,7 @@ mod tests { require_secret: false, }]; - wasm_router.register(channel, endpoints, None, None).await; + wasm_router.register(channel, endpoints, None, None, None, None).await; let app = create_wasm_channel_router(wasm_router.clone(), None); (wasm_router, app) @@ -1246,7 +1313,14 @@ mod tests { // Register with BOTH secret and signature key wasm_router - .register(channel, endpoints, Some("my-secret".to_string()), None) + .register( + channel, + endpoints, + Some("my-secret".to_string()), + None, + None, + None, + ) .await; let signing_key = test_signing_key(); @@ -1304,7 +1378,7 @@ mod tests { require_secret: false, }]; - wasm_router.register(channel, endpoints, None, None).await; + wasm_router.register(channel, endpoints, None, None, None, None).await; let app = create_wasm_channel_router(wasm_router.clone(), None); (wasm_router, app) diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index b508142661..babf1928d3 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -185,6 +185,33 @@ impl ChannelCapabilitiesFile { .and_then(|w| w.secret_name.clone()) .unwrap_or_else(|| format!("{}_webhook_secret", self.name)) } + + /// Get the webhook verification mode for this channel. + /// + /// Returns the verification mode declared in `webhook.verification_mode`: + /// - None/default: Require secret header for all requests + /// - "query_param": Skip host-level secret validation for GET, WASM validates via query param + /// - "signature": Always require signature validation + pub fn webhook_verification_mode(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.verification_mode.as_deref()) + } + + /// Get the JSON pointer path to extract message ID from metadata. + /// + /// Returns the JSON pointer declared in `webhook.message_id_json_pointer`, + /// used for ACK key construction and deduplication. + /// If None, the router falls back to using user_id. + pub fn webhook_message_id_json_pointer(&self) -> Option<&str> { + self.capabilities + .channel + .as_ref() + .and_then(|c| c.webhook.as_ref()) + .and_then(|w| w.message_id_json_pointer.as_deref()) + } } /// Schema for channel capabilities. @@ -302,6 +329,21 @@ pub struct WebhookSchema { /// Secret name in secrets store for HMAC-SHA256 signing (Slack-style). #[serde(default)] pub hmac_secret_name: Option, + + /// How to handle GET request validation: + /// - None/default: Require secret header for all requests (current behavior) + /// - "query_param": Skip host-level secret validation for GET requests; + /// the WASM module validates via query param (e.g., WhatsApp hub.verify_token) + /// - "signature": Always require signature validation (for Discord-style Ed25519) + #[serde(default)] + pub verification_mode: Option, + + /// JSON pointer path to extract message ID from metadata_json. + /// Used for ACK key construction and deduplication. + /// Format: "/field1/field2" to access {"field1": {"field2": "value"}} + /// If None, the router falls back to using user_id. + #[serde(default)] + pub message_id_json_pointer: Option, } /// Setup configuration schema. @@ -806,4 +848,58 @@ mod tests { "discord_public_key must be in the secrets allowlist" ); } + + #[test] + fn test_webhook_verification_mode_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "verification_mode": "query_param" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!(cap.webhook_verification_mode(), Some("query_param")); + } + + #[test] + fn test_webhook_hmac_secret_name_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "hmac_secret_name": "whatsapp_app_secret" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!(cap.hmac_secret_name(), Some("whatsapp_app_secret")); + } + + #[test] + fn test_webhook_message_id_json_pointer_parsing() { + let json = r#"{ + "name": "test", + "capabilities": { + "channel": { + "webhook": { + "message_id_json_pointer": "/message_id" + } + } + } + }"#; + + let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); + assert_eq!( + cap.webhook_message_id_json_pointer(), + Some("/message_id") + ); + } } diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 2b9703dc6f..937e854d6b 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -140,6 +140,10 @@ async fn register_channel( let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); + // Extract verification mode and message ID JSON pointer before moving loaded.channel + let verification_mode = loaded.webhook_verification_mode(); + let message_id_json_pointer = loaded.webhook_message_id_json_pointer(); + let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { channel_name: channel_name.clone(), @@ -216,6 +220,8 @@ async fn register_channel( endpoints, webhook_secret.clone(), secret_header, + verification_mode, + message_id_json_pointer, ) .await; diff --git a/src/channels/wasm/signature.rs b/src/channels/wasm/signature.rs index 2253bff570..51f9b86fbb 100644 --- a/src/channels/wasm/signature.rs +++ b/src/channels/wasm/signature.rs @@ -1,11 +1,19 @@ -//! Webhook signature verification (Discord Ed25519 and Slack HMAC-SHA256). +//! Webhook signature verification (Discord Ed25519, Slack HMAC-SHA256, WhatsApp HMAC-SHA256). //! //! Validates request signatures for incoming webhooks: //! - Discord: `X-Signature-Ed25519` and `X-Signature-Timestamp` headers //! - Slack: `X-Slack-Signature` and `X-Slack-Request-Timestamp` headers +//! - WhatsApp: `X-Hub-Signature-256` header (simple body-only HMAC) //! //! See: //! See: +//! See: + +use hmac::{Hmac, Mac}; +use sha2::Sha256; +use subtle::ConstantTimeEq; + +type HmacSha256 = Hmac; /// Verify a Discord interaction signature. /// @@ -52,6 +60,47 @@ pub fn verify_discord_signature( verifying_key.verify_strict(&message, &signature).is_ok() } +/// Verify HMAC-SHA256 signature (WhatsApp style, simple body-only). +/// +/// # Arguments +/// * `secret` - The HMAC secret (App Secret) +/// * `signature_header` - Value from X-Hub-Signature-256 header (format: "sha256=") +/// * `body` - Raw request body bytes +/// +/// # Returns +/// `true` if signature is valid, `false` otherwise +pub fn verify_hmac_sha256(secret: &str, signature_header: &str, body: &[u8]) -> bool { + // Parse header format: "sha256=" + let Some(hex_signature) = signature_header.strip_prefix("sha256=") else { + return false; + }; + + // Decode expected signature + let Ok(expected_sig) = hex::decode(hex_signature) else { + return false; + }; + + // SHA-256 produces 32-byte signatures - reject wrong lengths early + if expected_sig.len() != 32 { + return false; + } + + // Compute HMAC-SHA256 + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { + Ok(m) => m, + Err(_) => return false, + }; + mac.update(body); + let result = mac.finalize(); + let computed_sig = result.into_bytes(); + + // Constant-time comparison to prevent timing attacks + computed_sig + .as_slice() + .ct_eq(expected_sig.as_slice()) + .into() +} + /// Verify a Slack webhook signature using HMAC-SHA256. /// /// Slack signs each webhook request with HMAC-SHA256 using: @@ -69,9 +118,6 @@ pub fn verify_slack_signature( signature_header: &str, now_secs: i64, ) -> bool { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - // 1. Parse and check staleness (5-minute window) let ts: i64 = match timestamp.parse() { Ok(v) => v, @@ -89,7 +135,7 @@ pub fn verify_slack_signature( basestring.extend_from_slice(body); // 3. Compute HMAC-SHA256 - let mut mac = match Hmac::::new_from_slice(signing_secret.as_bytes()) { + let mut mac = match HmacSha256::new_from_slice(signing_secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; @@ -99,7 +145,6 @@ pub fn verify_slack_signature( let expected = format!("v0={}", computed_hex); // 4. Constant-time compare (avoids timing side-channels) - use subtle::ConstantTimeEq; expected .as_bytes() .ct_eq(signature_header.as_bytes()) @@ -116,11 +161,7 @@ pub fn verify_hmac_sha256_prefixed( signature_header: &str, prefix: &str, ) -> bool { - use hmac::{Hmac, Mac}; - use sha2::Sha256; - use subtle::ConstantTimeEq; - - let mut mac = match Hmac::::new_from_slice(secret.as_bytes()) { + let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { Ok(m) => m, Err(_) => return false, }; @@ -700,4 +741,76 @@ mod tests { "Empty timestamp should be rejected" ); } + + // ── Category: HMAC-SHA256 Signature Verification (WhatsApp/Meta) ──────────── + + /// Helper: compute HMAC-SHA256 signature in WhatsApp/Meta format (`sha256=`). + fn compute_whatsapp_style_hmac_signature(secret: &str, body: &[u8]) -> String { + use hmac::Mac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) + } + + #[test] + fn test_hmac_valid_signature_succeeds() { + let secret = "my_app_secret"; + let body = br#"{"entry":[{"id":"123"}]}"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + verify_hmac_sha256(secret, &sig_header, body), + "Valid HMAC signature should verify" + ); + } + + #[test] + fn test_hmac_wrong_secret_fails() { + let secret = "correct_secret"; + let wrong_secret = "wrong_secret"; + let body = br#"{"test":"data"}"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + !verify_hmac_sha256(wrong_secret, &sig_header, body), + "Signature with wrong secret should fail" + ); + } + + #[test] + fn test_hmac_tampered_body_fails() { + let secret = "my_secret"; + let body = br#"original body"#; + let tampered = br#"tampered body"#; + let sig_header = compute_whatsapp_style_hmac_signature(secret, body); + + assert!( + !verify_hmac_sha256(secret, &sig_header, tampered), + "Tampered body should fail verification" + ); + } + + #[test] + fn test_hmac_invalid_header_format_fails() { + let secret = "secret"; + let body = br#"data"#; + + assert!(!verify_hmac_sha256(secret, "invalid", body)); + assert!(!verify_hmac_sha256(secret, "sha256=not_hex!", body)); + assert!(!verify_hmac_sha256(secret, "", body)); + } + + #[test] + fn test_hmac_wrong_length_fails() { + let secret = "secret"; + let body = br#"data"#; + // 16 bytes instead of 32 + let short_sig = format!("sha256={}", "a".repeat(16)); + + assert!( + !verify_hmac_sha256(secret, &short_sig, body), + "Wrong-length signature should fail" + ); + } } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index 6ca798318c..b023229dcb 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1864,6 +1864,81 @@ impl WasmChannel { } } + /// Execute the on_message_persisted callback. + /// + /// Called after a message has been successfully persisted to the database. + /// Channels can use this for follow-up actions like WhatsApp mark_as_read. + /// + /// Returns Ok(()) even on failure - this is best-effort and should not block ACKs. + pub async fn call_on_message_persisted( + &self, + metadata_json: &str, + ) -> Result<(), WasmChannelError> { + // If no WASM bytes, return Ok (for testing) + if self.prepared.component().is_none() { + tracing::debug!( + channel = %self.name, + "on_message_persisted called (no WASM module)" + ); + return Ok(()); + } + + let runtime = Arc::clone(&self.runtime); + let prepared = Arc::clone(&self.prepared); + let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); + let timeout = self.runtime.config().callback_timeout; + let credentials = self.get_credentials().await; + let host_credentials = + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + .await; + let pairing_store = self.pairing_store.clone(); + let metadata_json = metadata_json.to_string(); + let channel_name = self.name.clone(); + + let result = tokio::time::timeout(timeout, async move { + tokio::task::spawn_blocking(move || { + let mut store = Self::create_store( + &runtime, + &prepared, + &capabilities, + credentials, + host_credentials, + pairing_store, + )?; + let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; + + let channel_iface = instance.near_agent_channel(); + let _ = channel_iface + .call_on_message_persisted(&mut store, &metadata_json) + .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; + + Ok::<_, WasmChannelError>(()) + }) + .await + .map_err(|e| WasmChannelError::ExecutionPanicked { + name: channel_name, + reason: e.to_string(), + })? + }) + .await; + + match result { + Ok(Ok(())) => { + tracing::debug!(channel = %self.name, "on_message_persisted completed"); + Ok(()) + } + Ok(Err(e)) => { + // Log but don't fail - this is best-effort + tracing::warn!(channel = %self.name, error = %e, "on_message_persisted failed"); + Ok(()) + } + Err(_timeout) => { + tracing::warn!(channel = %self.name, "on_message_persisted timed out"); + Ok(()) + } + } + } + /// Execute a single on_status callback with a fresh WASM instance. /// /// Static method for use by the background typing repeat task (which diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index d19089c102..70b035ed1d 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -425,6 +425,44 @@ pub(crate) fn row_to_routine_run_libsql(row: &libsql::Row) -> Result Result { + let key = format!("{}:{}", channel_name, message_id); + let conn = self.connect().await?; + + let rows_affected = conn + .execute( + "INSERT OR IGNORE INTO webhook_message_dedup (key) VALUES (?1)", + [libsql::Value::from(key)], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + Ok(rows_affected == 1) + } + + async fn cleanup_old_webhook_dedup_records(&self) -> Result { + let conn = self.connect().await?; + + let rows_affected = conn + .execute( + "DELETE FROM webhook_message_dedup WHERE created_at < datetime('now', '-30 days')", + (), + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + Ok(rows_affected) + } +} + #[cfg(test)] mod tests { use chrono::{TimeZone, Utc}; diff --git a/src/db/mod.rs b/src/db/mod.rs index 6d2eb2960c..2259c5117d 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -638,6 +638,29 @@ pub trait WorkspaceStore: Send + Sync { ) -> Result, WorkspaceError>; } +/// Webhook message deduplication store. +/// +/// Prevents duplicate processing when channels retry webhooks on errors. +/// WhatsApp, for example, retries for up to 7 days on 5xx responses. +#[async_trait] +pub trait WebhookDedupStore: Send + Sync { + /// Try to record that a message is processed, atomically. + /// + /// Returns `true` if this is a new message (was inserted), + /// `false` if it was a duplicate (key already exists). + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result; + + /// Clean up old dedup records. + /// + /// Called periodically to prevent unbounded growth. + /// Returns the number of records deleted. + async fn cleanup_old_webhook_dedup_records(&self) -> Result; +} + /// Backend-agnostic database supertrait. /// /// Combines all sub-traits into one. Existing `Arc` consumers @@ -651,6 +674,7 @@ pub trait Database: + ToolFailureStore + SettingsStore + WorkspaceStore + + WebhookDedupStore + Send + Sync { diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 8c18e25288..0a7ca36330 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -707,3 +707,45 @@ impl WorkspaceStore for PgBackend { .await } } + +// ==================== WebhookDedupStore ==================== + +#[async_trait] +impl crate::db::WebhookDedupStore for PgBackend { + async fn record_webhook_message_processed( + &self, + channel_name: &str, + message_id: &str, + ) -> Result { + let key = format!("{}:{}", channel_name, message_id); + let conn = self.store.conn().await?; + + let rows_affected = conn + .execute( + "INSERT INTO webhook_message_dedup (key) VALUES ($1) ON CONFLICT (key) DO NOTHING", + &[&key], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + Ok(rows_affected == 1) + } + + async fn cleanup_old_webhook_dedup_records(&self) -> Result { + let conn = self.store.conn().await?; + + let rows_affected = conn + .execute( + "DELETE FROM webhook_message_dedup WHERE created_at < NOW() - INTERVAL '30 days'", + &[], + ) + .await + .map_err(|e| DatabaseError::Query(e.to_string()))?; + + Ok(rows_affected) + } +} + +// ==================== Tests ==================== +// Webhook dedup tests are in the libsql module which supports in-memory testing. +// PostgreSQL tests require a running database with migrations applied. diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 00d787a5a3..66636ecc5c 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3594,6 +3594,8 @@ impl ExtensionManager { endpoints, webhook_secret, secret_header, + None, // verification_mode - not supported for hot-activated channels yet + None, // message_id_json_pointer - not supported yet ) .await; tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router"); diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 7e05c0f397..7fd07239d3 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -72,7 +72,7 @@ mod router_tests { }]; router - .register(channel.clone(), endpoints, None, None) + .register(channel.clone(), endpoints, None, None, None, None) .await; // Verify channel is found by path @@ -97,7 +97,14 @@ mod router_tests { )); router - .register(channel, vec![], Some("my-secret-123".to_string()), None) + .register( + channel, + vec![], + Some("my-secret-123".to_string()), + None, + None, + None, + ) .await; // Correct secret validates @@ -136,7 +143,7 @@ mod router_tests { require_secret: false, }]; - router.register(channel, endpoints, None, None).await; + router.register(channel, endpoints, None, None, None, None).await; // Channel exists assert!(router.get_channel_for_path("/webhook/temp").await.is_some()); @@ -168,7 +175,7 @@ mod router_tests { require_secret: false, }]; - router.register(channel, endpoints, None, None).await; + router.register(channel, endpoints, None, None, None, None).await; } // Verify all channels are registered diff --git a/wit/channel.wit b/wit/channel.wit index c0eb451045..942162e877 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -391,6 +391,20 @@ interface channel { /// - Err(string): Delivery failure message on-respond: func(response: agent-response) -> result<_, string>; + /// Called after a message has been persisted to the database. + /// + /// Channels can use this to perform follow-up actions like + /// calling external APIs (e.g., WhatsApp mark_as_read). + /// This is optional - channels that don't need it can return Ok. + /// + /// Arguments: + /// - metadata-json: The metadata from the persisted message + /// + /// Returns: + /// - Ok: Post-persistence action completed successfully + /// - Err(string): Action failure message (does not block the ACK) + on-message-persisted: func(metadata-json: string) -> result<_, string>; + /// Notify the channel of agent status changes. /// /// Called when the agent starts thinking, finishes, or changes state. From 55473535202d7f1267c4e4d2762cd9013c90a078 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Fri, 13 Mar 2026 18:41:44 +0100 Subject: [PATCH 02/18] feat(whatsapp): implement on_message_persisted for mark_as_read - Add on_message_persisted callback to WhatsApp channel - Parse metadata to extract phone_number_id and message_id - Call WhatsApp mark_as_read API via Cloud API endpoint - Best-effort: log warnings on failure but return Ok to avoid blocking ACKs - Add mark_as_read config option (default: true) - Persist mark_as_read setting in workspace state - Update capabilities.json with webhook config (verification_mode, hmac_secret_name, message_id_json_pointer) Co-Authored-By: Claude Opus 4.6 --- channels-src/whatsapp/Cargo.lock | 2 +- channels-src/whatsapp/src/lib.rs | 113 ++++++++++++++++++ .../whatsapp/whatsapp.capabilities.json | 13 +- 3 files changed, 125 insertions(+), 3 deletions(-) diff --git a/channels-src/whatsapp/Cargo.lock b/channels-src/whatsapp/Cargo.lock index 0e55d1e532..adefa9aa3b 100644 --- a/channels-src/whatsapp/Cargo.lock +++ b/channels-src/whatsapp/Cargo.lock @@ -269,7 +269,7 @@ dependencies = [ [[package]] name = "whatsapp-channel" -version = "0.1.0" +version = "0.2.0" dependencies = [ "serde", "serde_json", diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index c69a9b9f90..211ec81edd 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -290,6 +290,14 @@ struct WhatsAppConfig { #[serde(default)] allow_from: Option>, + + /// Whether to mark incoming messages as read (default: true) + #[serde(default = "default_mark_as_read")] + mark_as_read: bool, +} + +fn default_mark_as_read() -> bool { + true } fn default_api_version() -> String { @@ -321,6 +329,7 @@ impl Guest for WhatsAppChannel { owner_id: None, dm_policy: None, allow_from: None, + mark_as_read: default_mark_as_read(), } } }; @@ -354,6 +363,10 @@ impl Guest for WhatsAppChannel { .unwrap_or_else(|_| "[]".to_string()); let _ = channel_host::workspace_write(ALLOW_FROM_PATH, &allow_from_json); + // Persist mark_as_read setting for on_message_persisted + let mark_as_read_str = if config.mark_as_read { "true" } else { "false" }; + let _ = channel_host::workspace_write("channels/whatsapp/mark_as_read", mark_as_read_str); + // WhatsApp Cloud API is webhook-only, no polling available Ok(ChannelConfig { display_name: "WhatsApp".to_string(), @@ -516,6 +529,59 @@ impl Guest for WhatsAppChannel { Err("broadcast not yet implemented for WhatsApp channel".to_string()) } + fn on_message_persisted(metadata_json: String) -> Result<(), String> { + // Parse metadata to extract phone_number_id and message_id + let metadata: WhatsAppMessageMetadata = match serde_json::from_str(&metadata_json) { + Ok(m) => m, + Err(e) => { + // Best-effort: log and return Ok to avoid blocking ACKs + channel_host::log( + channel_host::LogLevel::Warn, + &format!("on_message_persisted: failed to parse metadata: {}", e), + ); + return Ok(()); + } + }; + + // Check if mark_as_read is enabled (default: true) + // We don't have direct config access here, so we check workspace state + let mark_as_read = channel_host::workspace_read("channels/whatsapp/mark_as_read") + .map(|s| s != "false") + .unwrap_or(true); + + if !mark_as_read { + channel_host::log( + channel_host::LogLevel::Debug, + "on_message_persisted: mark_as_read disabled, skipping", + ); + return Ok(()); + } + + // Call WhatsApp mark_as_read API + let result = mark_message_as_read(&metadata.phone_number_id, &metadata.message_id); + + match result { + Ok(()) => { + channel_host::log( + channel_host::LogLevel::Debug, + &format!( + "on_message_persisted: marked message {} as read", + metadata.message_id + ), + ); + } + Err(e) => { + // Best-effort: log warning but don't fail (would block webhook ACK) + channel_host::log( + channel_host::LogLevel::Warn, + &format!("on_message_persisted: mark_as_read failed: {}", e), + ); + } + } + + Ok(()) + } + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, @@ -945,6 +1011,53 @@ fn send_pairing_reply( } } +/// Mark a WhatsApp message as read via the Cloud API. +/// +/// https://developers.facebook.com/docs/whatsapp/cloud-api/guides/mark-message-as-read +fn mark_message_as_read(phone_number_id: &str, message_id: &str) -> Result<(), String> { + // Read api_version from workspace (set during on_start), fallback to default + let api_version = channel_host::workspace_read("channels/whatsapp/api_version") + .filter(|s| !s.is_empty()) + .unwrap_or_else(|| "v18.0".to_string()); + + let url = format!( + "https://graph.facebook.com/{}/{}/messages", + api_version, phone_number_id + ); + + // Mark as read payload + let payload = serde_json::json!({ + "messaging_product": "whatsapp", + "status": "read", + "message_id": message_id + }); + + let payload_bytes = + serde_json::to_vec(&payload).map_err(|e| format!("Failed to serialize: {}", e))?; + + let headers = serde_json::json!({ + "Content-Type": "application/json", + "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}" + }); + + let result = channel_host::http_request( + "POST", + &url, + &headers.to_string(), + Some(&payload_bytes), + None, + ); + + match result { + Ok(response) if response.status >= 200 && response.status < 300 => Ok(()), + Ok(response) => { + let body_str = String::from_utf8_lossy(&response.body); + Err(format!("WhatsApp API error: {} - {}", response.status, body_str)) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + /// Create a JSON HTTP response. fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse { let body = serde_json::to_vec(&value).unwrap_or_default(); diff --git a/channels-src/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json index a0115d7902..3cda159f62 100644 --- a/channels-src/whatsapp/whatsapp.capabilities.json +++ b/channels-src/whatsapp/whatsapp.capabilities.json @@ -11,6 +11,11 @@ "prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).", "validation": "^[A-Za-z0-9_-]+$" }, + { + "name": "whatsapp_app_secret", + "prompt": "Enter your WhatsApp App Secret (from the Meta Developer Portal under your app's WhatsApp > Configuration). Used for webhook signature verification.", + "validation": "^[a-fA-F0-9]{32}$" + }, { "name": "whatsapp_verify_token", "prompt": "Webhook verify token (leave empty to auto-generate)", @@ -45,7 +50,10 @@ "webhook": { "secret_header": "X-Hub-Signature-256", "secret_name": "whatsapp_verify_token", - "verify_token_param": "hub.verify_token" + "verify_token_param": "hub.verify_token", + "verification_mode": "query_param", + "hmac_secret_name": "whatsapp_app_secret", + "message_id_json_pointer": "/metadata/message_id" } } }, @@ -54,6 +62,7 @@ "reply_to_message": true, "owner_id": null, "dm_policy": "pairing", - "allow_from": [] + "allow_from": [], + "mark_as_read": true } } From 0dfe7a9c05a52c0ebcf6731aeaec629629cc631c Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Fri, 13 Mar 2026 18:49:43 +0100 Subject: [PATCH 03/18] feat(wasm): add WhatsApp-style HMAC verification and integration tests Router changes: - Add WhatsApp-style HMAC verification using X-Hub-Signature-256 header - Router now supports both Slack-style (x-slack-signature) and WhatsApp-style - Fix test that was missing verification_mode and message_id_json_pointer args Test changes: - Add hmac_signature_tests module with comprehensive tests - Test valid signature verification - Test wrong secret rejection - Test tampered body detection - Test invalid header format handling - Test router HMAC secret registration and retrieval Module visibility: - Make signature module public for integration tests Co-Authored-By: Claude Opus 4.6 --- src/channels/wasm/mod.rs | 2 +- src/channels/wasm/router.rs | 48 ++++++++-- tests/wasm_channel_integration.rs | 144 ++++++++++++++++++++++++++++++ 3 files changed, 184 insertions(+), 10 deletions(-) diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 882709a967..1e3f8e485f 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -87,7 +87,7 @@ mod router; mod runtime; mod schema; pub mod setup; -pub(crate) mod signature; +pub mod signature; #[allow(dead_code)] pub(crate) mod storage; mod telegram_host_config; diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 2fd2e44d7f..e0e2a3f323 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -509,17 +509,24 @@ async fn webhook_handler( } } - // HMAC-SHA256 signature verification (Slack-style) + // HMAC-SHA256 signature verification (Slack-style or WhatsApp-style) if let Some(hmac_secret) = state.router.get_hmac_secret(channel_name).await { - let timestamp = headers + // Try Slack-style headers first + let slack_timestamp = headers .get("x-slack-request-timestamp") .and_then(|v| v.to_str().ok()); - let sig_header = headers + let slack_sig = headers .get("x-slack-signature") .and_then(|v| v.to_str().ok()); - match (timestamp, sig_header) { - (Some(ts), Some(sig)) => { + // Try WhatsApp-style header + let whatsapp_sig = headers + .get("x-hub-signature-256") + .and_then(|v| v.to_str().ok()); + + match (slack_timestamp, slack_sig, whatsapp_sig) { + // Slack-style verification + (Some(ts), Some(sig), _) => { let now_secs = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() @@ -534,7 +541,7 @@ async fn webhook_handler( ) { tracing::warn!( channel = %channel_name, - "HMAC-SHA256 signature verification failed" + "HMAC-SHA256 signature verification failed (Slack-style)" ); return ( StatusCode::UNAUTHORIZED, @@ -543,17 +550,38 @@ async fn webhook_handler( })), ); } - tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified"); + tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified (Slack-style)"); } + // WhatsApp-style verification + (_, _, Some(sig)) => { + if !crate::channels::wasm::signature::verify_hmac_sha256( + &hmac_secret, + sig, + &body, + ) { + tracing::warn!( + channel = %channel_name, + "HMAC-SHA256 signature verification failed (WhatsApp-style)" + ); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Invalid signature" + })), + ); + } + tracing::debug!(channel = %channel_name, "HMAC-SHA256 signature verified (WhatsApp-style)"); + } + // No recognized signature headers _ => { tracing::warn!( channel = %channel_name, - "Slack signature headers missing but secret is registered" + "HMAC signature headers missing but secret is registered" ); return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "Missing Slack signature headers" + "error": "Missing signature headers" })), ); } @@ -855,6 +883,8 @@ mod tests { vec![], Some("secret123".to_string()), Some("X-Telegram-Bot-Api-Secret-Token".to_string()), + None, + None, ) .await; diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 7fd07239d3..ca961afe7c 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -462,3 +462,147 @@ mod message_emission_tests { assert_eq!(state.emits_dropped(), 1); } } + +mod hmac_signature_tests { + use super::*; + + /// Test HMAC-SHA256 signature verification (WhatsApp-style) + #[test] + fn test_verify_hmac_sha256_valid_signature() { + let secret = "my_app_secret"; + let body = br#"{"entry":[{"changes":[{"value":{"messages":[{"id":"wamid.123","from":"15551234567","type":"text","text":{"body":"Hello"}}]}}]}]}"#; + + // Compute expected signature + let expected_sig = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) + }; + + // Verify using the signature module + let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( + secret, + &expected_sig, + body, + ); + assert!(result, "Valid signature should verify"); + } + + #[test] + fn test_verify_hmac_sha256_wrong_secret() { + let secret = "my_app_secret"; + let wrong_secret = "wrong_secret"; + let body = br#"{"test": "data"}"#; + + // Compute signature with correct secret + let sig = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) + }; + + // Verify with wrong secret should fail + let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( + wrong_secret, + &sig, + body, + ); + assert!(!result, "Wrong secret should fail verification"); + } + + #[test] + fn test_verify_hmac_sha256_tampered_body() { + let secret = "my_app_secret"; + let body = br#"{"test": "data"}"#; + let tampered = br#"{"test": "tampered"}"#; + + // Compute signature for original body + let sig = { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + type HmacSha256 = Hmac; + let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); + mac.update(body); + let result = mac.finalize(); + format!("sha256={}", hex::encode(result.into_bytes())) + }; + + // Verify with tampered body should fail + let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( + secret, + &sig, + tampered, + ); + assert!(!result, "Tampered body should fail verification"); + } + + #[test] + fn test_verify_hmac_sha256_invalid_header_format() { + let secret = "my_app_secret"; + let body = br#"{"test": "data"}"#; + + // Invalid formats + assert!(!ironclaw::channels::wasm::signature::verify_hmac_sha256( + secret, "invalid", body + )); + assert!(!ironclaw::channels::wasm::signature::verify_hmac_sha256( + secret, "sha256=not_hex!", body + )); + assert!(!ironclaw::channels::wasm::signature::verify_hmac_sha256( + secret, "", body + )); + } + + /// Test that router properly registers and retrieves HMAC secrets + #[tokio::test] + async fn test_router_hmac_secret_registration() { + let router = WasmChannelRouter::new(); + let runtime = create_test_runtime(); + let channel = Arc::new(create_test_channel( + runtime, + "whatsapp", + vec!["/webhook/whatsapp"], + )); + + // Register channel with HMAC secret + router + .register( + channel, + vec![], + Some("verify_token_123".to_string()), + Some("X-Hub-Signature-256".to_string()), + Some("query_param".to_string()), + Some("/metadata/message_id".to_string()), + ) + .await; + + // Register HMAC secret separately (simulating setup.rs behavior) + router.register_hmac_secret("whatsapp", "my_app_secret").await; + + // Verify HMAC secret is registered + assert_eq!( + router.get_hmac_secret("whatsapp").await, + Some("my_app_secret".to_string()) + ); + + // Verify verification mode is stored + assert_eq!( + router.get_verification_mode("whatsapp").await, + Some("query_param".to_string()) + ); + + // Verify message ID pointer is stored + assert_eq!( + router.get_message_id_json_pointer("whatsapp").await, + Some("/metadata/message_id".to_string()) + ); + } +} From f215d0d1dc2a70640fc665a074bd823701d8bff7 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Fri, 13 Mar 2026 20:01:14 +0100 Subject: [PATCH 04/18] fix(channels): add on_message_persisted stub to all channels The WIT interface now requires the on_message_persisted callback. Added stub implementations to Telegram, Slack, and Discord channels that return Ok(()) since these channels don't need mark_as_read functionality like WhatsApp. [skip-regression-check] - This adds a required interface method without changing existing behavior. Co-Authored-By: Claude Opus 4.6 --- channels-src/discord/src/lib.rs | 5 +++++ channels-src/slack/src/lib.rs | 5 +++++ channels-src/telegram/src/lib.rs | 5 +++++ 3 files changed, 15 insertions(+) diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index cdb6c51507..b5ef8691b0 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -473,6 +473,11 @@ impl Guest for DiscordChannel { Err("broadcast not yet implemented for Discord channel".to_string()) } + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + // Discord doesn't require mark_as_read functionality + Ok(()) + } + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index 24f01df393..46061a963d 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -329,6 +329,11 @@ impl Guest for SlackChannel { Err("broadcast not yet implemented for Slack channel".to_string()) } + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + // Slack doesn't require mark_as_read functionality + Ok(()) + } + fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index a095ccb3a2..6c8f5230e9 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -798,6 +798,11 @@ impl Guest for TelegramChannel { } } + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + // Telegram doesn't require mark_as_read functionality + Ok(()) + } + fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, From f7a0e926779debeabd4569ba6b7d132e26aa7298 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sat, 14 Mar 2026 20:12:45 +0100 Subject: [PATCH 05/18] fix(wasm): use verification_mode for GET request webhook verification The verification_mode field was stored but never used. For WhatsApp-style webhook verification, GET requests use query parameter verification (hub.verify_token) which is handled by the WASM channel, not the host. When verification_mode is "query_param", skip host-level secret validation for GET requests and let the WASM channel handle verification. Added tests: - test_get_request_with_query_param_mode_skips_secret_check - test_post_request_with_query_param_mode_still_requires_hmac Co-Authored-By: Claude Opus 4.6 --- src/channels/wasm/router.rs | 91 ++++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index e0e2a3f323..11a5b36e91 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -393,7 +393,17 @@ async fn webhook_handler( let channel_name = channel.channel_name(); - // Check if secret is required + // Check verification mode for GET requests (WhatsApp-style query param verification) + // In query_param mode, the WASM channel validates via hub.verify_token query param + let verification_mode = state.router.get_verification_mode(channel_name).await; + if method == Method::GET && verification_mode.as_deref() == Some("query_param") { + tracing::debug!( + channel = %channel_name, + "Skipping host-level secret validation for GET request (query_param verification mode)" + ); + // Skip directly to WASM call - the channel validates via query param + } else { + // Check if secret is required if state.router.requires_secret(channel_name).await { // Get the secret header name for this channel (from capabilities or default) let secret_header_name = state.router.get_secret_header(channel_name).await; @@ -457,6 +467,7 @@ async fn webhook_handler( } } } + } // end of verification_mode else block // Ed25519 signature verification (Discord-style) if let Some(pub_key_hex) = state.router.get_signature_key(channel_name).await { @@ -1604,4 +1615,82 @@ mod tests { "Signature with mismatched timestamp should return 401" ); } + + // ── Verification Mode Tests ───────────────────────────────────────── + + /// Helper to create a router with a WhatsApp-style channel using query_param verification. + async fn setup_whatsapp_router() -> (Arc, AxumRouter) { + let wasm_router = Arc::new(WasmChannelRouter::new()); + let channel = create_test_channel("whatsapp"); + + // Register with verification_mode = "query_param" for GET webhook verification + let endpoints = vec![RegisteredEndpoint { + channel_name: "whatsapp".to_string(), + path: "/webhook/whatsapp".to_string(), + methods: vec!["GET".to_string(), "POST".to_string()], + require_secret: true, + }]; + + wasm_router + .register( + channel, + endpoints, + Some("verify_token_123".to_string()), + Some("X-Hub-Signature-256".to_string()), + Some("query_param".to_string()), + Some("/metadata/message_id".to_string()), + ) + .await; + + let app = create_wasm_channel_router(wasm_router.clone(), None); + (wasm_router, app) + } + + #[tokio::test] + async fn test_get_request_with_query_param_mode_skips_secret_check() { + let (_wasm_router, app) = setup_whatsapp_router().await; + + // GET request without secret header but with query param + // In query_param mode, the WASM channel validates the hub.verify_token + let req = Request::builder() + .method("GET") + .uri("/webhook/whatsapp?hub.mode=subscribe&hub.challenge=test&hub.verify_token=verify_token_123") + .body(Body::empty()) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + // Should NOT be 401 - query_param mode skips host-level secret validation + // (may be 500 since no real WASM module, but not auth failure) + assert_ne!( + resp.status(), + StatusCode::UNAUTHORIZED, + "GET request with query_param mode should skip host-level secret validation" + ); + } + + #[tokio::test] + async fn test_post_request_with_query_param_mode_still_requires_hmac() { + let (wasm_router, app) = setup_whatsapp_router().await; + + // Register HMAC secret for POST verification + wasm_router + .register_hmac_secret("whatsapp", "app_secret_123") + .await; + + // POST request without HMAC signature should fail + let req = Request::builder() + .method("POST") + .uri("/webhook/whatsapp") + .header("content-type", "application/json") + .body(Body::from(r#"{"entry":[]}"#)) + .unwrap(); + + let resp = app.oneshot(req).await.unwrap(); + // Should be 401 - HMAC is required for POST even in query_param mode + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "POST request without HMAC signature should return 401" + ); + } } From 628d052bc7a4c0aa44371e08ebf3c822e23c1459 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sat, 14 Mar 2026 23:10:36 +0100 Subject: [PATCH 06/18] feat(wasm): add webhook ACK mechanism for reliable message processing Add pending ACK infrastructure to the WasmChannelRouter: - register_pending_ack(): Register a pending ACK before processing webhook - ack_message(): Signal ACK after message persistence, triggers on_message_persisted The ACK mechanism enables reliable webhook processing: 1. Webhook handler registers pending ACK with key "channel:message_id" 2. Handler waits on receiver before returning 200 OK 3. Agent loop calls ack_message() after persisting to database 4. ack_message() signals the webhook handler AND calls on_message_persisted This enables WhatsApp mark_as_read and other post-persistence channel actions. Also updated unregister() to clean up pending ACKs for removed channels. Co-Authored-By: Claude Opus 4.6 --- src/channels/wasm/router.rs | 128 +++++++++++++++++++++++++++++++++++- 1 file changed, 127 insertions(+), 1 deletion(-) diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 11a5b36e91..7b37aad9eb 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -15,7 +15,7 @@ use axum::{ routing::{get, post}, }; use serde::{Deserialize, Serialize}; -use tokio::sync::RwLock; +use tokio::sync::{oneshot, RwLock}; use crate::channels::wasm::wrapper::WasmChannel; @@ -52,6 +52,9 @@ pub struct WasmChannelRouter { message_id_json_pointers: RwLock>, /// Database for webhook message deduplication (optional - graceful degradation if not set). db: RwLock>>, + /// Pending webhook ACKs - keyed by "channel:message_id", value is signaled when + /// ack_message() is called after message persistence. + pending_acks: RwLock>>, } impl WasmChannelRouter { @@ -67,6 +70,65 @@ impl WasmChannelRouter { verification_modes: RwLock::new(HashMap::new()), message_id_json_pointers: RwLock::new(HashMap::new()), db: RwLock::new(None), + pending_acks: RwLock::new(HashMap::new()), + } + } + + // ======================================================================== + // Webhook Acknowledgment (reliable message processing) + // ======================================================================== + + /// Register a pending acknowledgment for a webhook message. + /// + /// Call this before processing a webhook message. The returned receiver + /// will be signaled when the message has been persisted to the database. + /// The webhook handler should wait on this receiver before returning 200 OK. + /// + /// # Arguments + /// * `key` - Unique identifier for the message, typically "channel:message_id" + /// + /// # Returns + /// A oneshot receiver that will be signaled when ack_message() is called. + pub async fn register_pending_ack(&self, key: String) -> oneshot::Receiver<()> { + let (tx, rx) = oneshot::channel(); + self.pending_acks.write().await.insert(key.clone(), tx); + tracing::debug!(key = %key, "Registered pending webhook ACK"); + rx + } + + /// Signal that a message has been persisted and the webhook can return 200 OK. + /// + /// Called by the agent loop after persist_user_message() completes. + /// Also triggers the on_message_persisted WASM callback for channels that + /// implement it (e.g., WhatsApp for mark_as_read). + /// + /// Note: Deduplication recording happens at webhook handler level (before + /// sending to agent) to prevent race conditions with concurrent webhooks. + /// + /// # Arguments + /// * `key` - The same key passed to register_pending_ack() (format: "channel:message_id") + /// * `message_metadata` - JSON metadata for channel-specific post-persistence actions + pub async fn ack_message(&self, key: &str, message_metadata: &str) { + if let Some(tx) = self.pending_acks.write().await.remove(key) { + // Signal the webhook handler to return 200 OK + let _ = tx.send(()); + tracing::debug!(key = %key, "Webhook ACK signaled"); + + // Parse key to get channel name for callback + let channel_name = key.split(':').next().unwrap_or(""); + + // Look up the channel and call on_message_persisted + if let Some(channel) = self.channels.read().await.get(channel_name) + && let Err(e) = channel.call_on_message_persisted(message_metadata).await + { + tracing::warn!( + channel = %channel_name, + error = %e, + "on_message_persisted callback failed (best-effort)" + ); + } + } else { + tracing::debug!(key = %key, "No pending ACK found (may have timed out)"); } } @@ -176,6 +238,8 @@ impl WasmChannelRouter { self.secret_headers.write().await.remove(channel_name); self.signature_keys.write().await.remove(channel_name); self.hmac_secrets.write().await.remove(channel_name); + self.verification_modes.write().await.remove(channel_name); + self.message_id_json_pointers.write().await.remove(channel_name); // Remove all paths for this channel self.path_to_channel @@ -183,6 +247,12 @@ impl WasmChannelRouter { .await .retain(|_, name| name != channel_name); + // Remove pending ACKs for this channel + self.pending_acks + .write() + .await + .retain(|key, _| !key.starts_with(&format!("{}:", channel_name))); + tracing::info!( channel = %channel_name, "Unregistered WASM channel" @@ -1693,4 +1763,60 @@ mod tests { "POST request without HMAC signature should return 401" ); } + + // ── Webhook ACK Mechanism Tests ───────────────────────────────────── + + #[tokio::test] + async fn test_register_and_ack_message() { + let router = WasmChannelRouter::new(); + let channel = create_test_channel("test"); + + router + .register(channel, vec![], None, None, None, None) + .await; + + // Register pending ACK + let key = "test:message123".to_string(); + let rx = router.register_pending_ack(key.clone()).await; + + // ACK the message with metadata + let metadata = r#"{"phone_number_id":"123","message_id":"msg456"}"#; + router.ack_message(&key, metadata).await; + + // Receiver should be signaled + let result = rx.await; + assert!(result.is_ok(), "ACK receiver should be signaled"); + } + + #[tokio::test] + async fn test_ack_nonexistent_key_is_safe() { + let router = WasmChannelRouter::new(); + + // ACK a key that was never registered (should not panic) + router.ack_message("nonexistent:key", "{}").await; + } + + #[tokio::test] + async fn test_unregister_clears_pending_acks() { + let router = WasmChannelRouter::new(); + let channel = create_test_channel("test"); + + router + .register(channel, vec![], None, None, None, None) + .await; + + // Register pending ACK + let key = "test:message123".to_string(); + let rx = router.register_pending_ack(key.clone()).await; + + // Unregister the channel + router.unregister("test").await; + + // ACK the message (should be no-op since channel was unregistered) + router.ack_message(&key, "{}").await; + + // Receiver should NOT be signaled (sender was dropped during retain) + let result = rx.await; + assert!(result.is_err(), "ACK receiver should not be signaled after unregister"); + } } From 14fe3e64879c3d15e57562994ca0828aa12a7a1d Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sun, 15 Mar 2026 16:31:05 +0100 Subject: [PATCH 07/18] fix(wasm): integrate ACK mechanism with agent loop for reliable webhook processing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two critical fixes from code review: 1. ACK Mechanism Integration with Agent Loop: - Add `wasm_router` field to `AgentDeps` for accessing the router - Modify `persist_user_message()` to accept `message_id` and `metadata` - Call `ack_message()` after successful DB persistence - This triggers `on_message_persisted` callback for mark_as_read 2. Webhook Handler Wait for ACK: - Return emitted message info from `call_on_http_request()` - Register pending ACKs before sending messages to agent - Wait for all ACKs with configurable timeout (default: 30s) - Log ACK success/failure counts for observability Flow: Webhook → WASM channel → emit → register ACK → send to agent → agent persists → ack_message() → webhook returns 200 OK → on_message_persisted fires (e.g., WhatsApp mark_as_read) Co-Authored-By: Claude Opus 4.6 --- src/agent/agent_loop.rs | 8 +++ src/agent/dispatcher.rs | 3 ++ src/agent/thread_ops.rs | 20 ++++++++ src/channels/wasm/router.rs | 60 ++++++++++++++++++++++- src/channels/wasm/wrapper.rs | 34 +++++++++---- src/main.rs | 4 ++ src/testing/mod.rs | 1 + tests/support/gateway_workflow_harness.rs | 1 + tests/support/test_rig.rs | 1 + tests/telegram_auth_integration.rs | 12 ++--- tests/wasm_channel_integration.rs | 2 +- 11 files changed, 129 insertions(+), 17 deletions(-) diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 83d971ef1a..4184da8b52 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -146,6 +146,9 @@ pub struct AgentDeps { pub transcription: Option>, /// Document text extraction middleware for PDF, DOCX, PPTX, etc. pub document_extraction: Option>, + /// WASM channel router for webhook ACK signaling. + /// When set, persist_user_message will call ack_message() after persistence. + pub wasm_router: Option>, } /// The main agent that coordinates all components. @@ -251,6 +254,11 @@ impl Agent { self.deps.store.as_ref() } + /// Get the WASM channel router for ACK signaling. + pub(super) fn wasm_router(&self) -> Option<&Arc> { + self.deps.wasm_router.as_ref() + } + pub(super) fn llm(&self) -> &Arc { &self.deps.llm } diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 9be0d654d1..62ec4ebc0c 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1197,6 +1197,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + wasm_router: None, }; Agent::new( @@ -2037,6 +2038,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + wasm_router: None, }; Agent::new( @@ -2155,6 +2157,7 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, + wasm_router: None, }; Agent::new( diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 877a4e2777..7284b9bb5a 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -383,6 +383,8 @@ impl Agent { &message.channel, &message.user_id, effective_content, + message.id, + &message.metadata, ) .await; @@ -574,12 +576,18 @@ impl Agent { /// /// This ensures the user message is durable even if the process crashes /// mid-response. Call this right after `thread.start_turn()`. + /// + /// After persistence, signals ACK to the WASM channel router so that + /// webhook handlers can return 200 OK and `on_message_persisted` callbacks + /// can fire (e.g., for mark_as_read in WhatsApp). pub(super) async fn persist_user_message( &self, thread_id: Uuid, channel: &str, user_id: &str, user_input: &str, + message_id: Uuid, + metadata: &serde_json::Value, ) { let store = match self.store() { Some(s) => Arc::clone(s), @@ -598,6 +606,18 @@ impl Agent { .await { tracing::warn!("Failed to persist user message: {}", e); + return; + } + + // Signal ACK to WASM channel router after successful persistence + if let Some(router) = self.wasm_router() { + let ack_key = format!("{}:{}", channel, message_id); + let metadata_json = metadata.to_string(); + tracing::debug!( + ack_key = %ack_key, + "Signaling ACK to WASM channel router after message persistence" + ); + router.ack_message(&ack_key, &metadata_json).await; } } diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 7b37aad9eb..833990038d 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -6,6 +6,11 @@ use std::collections::HashMap; use std::sync::Arc; +/// Maximum time to wait for ACK from agent before returning HTTP response. +/// If the agent doesn't persist the message within this time, the webhook +/// returns 200 OK anyway (best-effort reliability). +const ACK_TIMEOUT_SECS: u64 = 30; + use axum::{ Json, Router, body::Bytes, @@ -699,7 +704,23 @@ async fn webhook_handler( ) .await { - Ok(response) => { + Ok((response, emitted_info)) => { + // Register pending ACKs for emitted messages + let mut ack_receivers: Vec> = Vec::new(); + for (message_id, _metadata) in &emitted_info { + let ack_key = format!("{}:{}", channel_name, message_id); + let rx = state.router.register_pending_ack(ack_key).await; + ack_receivers.push(rx); + + // Metadata will be passed to ack_message() by the agent after persistence, + // which triggers on_message_persisted callback (e.g., for mark_as_read) + tracing::debug!( + channel = %channel_name, + message_id = %message_id, + "Registered pending ACK for webhook message" + ); + } + let status = StatusCode::from_u16(response.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); @@ -707,6 +728,7 @@ async fn webhook_handler( channel = %channel_name, status = %status, body_len = response.body.len(), + emitted_count = emitted_info.len(), "WASM channel on_http_request completed successfully" ); @@ -718,6 +740,42 @@ async fn webhook_handler( }) }); + // Wait for ACKs with timeout + // If no messages were emitted, return immediately + if !ack_receivers.is_empty() { + let ack_timeout = tokio::time::Duration::from_secs(ACK_TIMEOUT_SECS); + let ack_results: Vec<_> = futures::future::join_all( + ack_receivers.into_iter().map(|rx| { + tokio::time::timeout(ack_timeout, rx) + }) + ).await; + + let mut acked = 0; + let mut timed_out = 0; + for result in ack_results { + match result { + Ok(Ok(())) => acked += 1, + Ok(Err(_)) => timed_out += 1, // Sender dropped + Err(_) => timed_out += 1, // Timeout + } + } + + if timed_out > 0 { + tracing::warn!( + channel = %channel_name, + acked = acked, + timed_out = timed_out, + "Some webhook ACKs timed out" + ); + } else { + tracing::debug!( + channel = %channel_name, + acked = acked, + "All webhook ACKs received" + ); + } + } + (status, Json(body_json)) } Err(e) => { diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index b023229dcb..e44970abc3 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1290,6 +1290,10 @@ impl WasmChannel { /// Execute the on_http_request callback. /// /// Called when an HTTP request arrives at a registered endpoint. + /// + /// Returns the HTTP response and a list of (message_id, metadata) tuples for + /// messages that were emitted during processing. This enables ACK tracking + /// for reliable webhook processing. pub async fn call_on_http_request( &self, method: &str, @@ -1298,7 +1302,7 @@ impl WasmChannel { query: &HashMap, body: &[u8], secret_validated: bool, - ) -> Result { + ) -> Result<(HttpResponse, Vec<(uuid::Uuid, serde_json::Value)>), WasmChannelError> { tracing::info!( channel = %self.name, method = method, @@ -1334,7 +1338,7 @@ impl WasmChannel { path = path, "WASM channel on_http_request called (no WASM module)" ); - return Ok(HttpResponse::ok()); + return Ok((HttpResponse::ok(), Vec::new())); } let runtime = Arc::clone(&self.runtime); @@ -1410,16 +1414,17 @@ impl WasmChannel { let channel_name = self.name.clone(); match result { Ok(Ok((response, mut host_state))) => { - // Process emitted messages + // Process emitted messages and collect (message_id, metadata) for ACK tracking let emitted = host_state.take_emitted_messages(); - self.process_emitted_messages(emitted).await?; + let emitted_info = self.process_emitted_messages(emitted).await?; tracing::debug!( channel = %channel_name, status = response.status, + emitted_count = emitted_info.len(), "WASM channel on_http_request completed" ); - Ok(response) + Ok((response, emitted_info)) } Ok(Err(e)) => Err(e), Err(_) => Err(WasmChannelError::Timeout { @@ -2212,10 +2217,12 @@ impl WasmChannel { } /// Process emitted messages from a callback. + /// + /// Returns a vector of (message_id, metadata) for ACK tracking. async fn process_emitted_messages( &self, messages: Vec, - ) -> Result<(), WasmChannelError> { + ) -> Result, WasmChannelError> { tracing::info!( channel = %self.name, message_count = messages.len(), @@ -2224,7 +2231,7 @@ impl WasmChannel { if messages.is_empty() { tracing::debug!(channel = %self.name, "No messages emitted"); - return Ok(()); + return Ok(Vec::new()); } // Clone sender to avoid holding RwLock read guard across send().await in the loop @@ -2236,11 +2243,13 @@ impl WasmChannel { count = messages.len(), "Messages emitted but no sender available - channel may not be started!" ); - return Ok(()); + return Ok(Vec::new()); }; tx.clone() }; + let mut emitted_info = Vec::new(); + for emitted in messages { // Check rate limit — acquire and release the write lock before send().await { @@ -2266,6 +2275,7 @@ impl WasmChannel { let mut msg = IncomingMessage::new(&self.name, &resolved_user_id, &emitted.content) .with_owner_id(&self.owner_scope_id) .with_sender_id(&emitted.user_id); + let message_id = msg.id; // Capture ID before moving msg (for ACK tracking) if let Some(name) = emitted.user_name { msg = msg.with_user_name(name); @@ -2303,6 +2313,9 @@ impl WasmChannel { self.update_broadcast_metadata(&emitted.metadata_json).await; } + // Extract metadata for ACK mechanism (after apply_emitted_metadata) + let metadata = msg.metadata.clone(); + // Send to stream — no locks held across this await tracing::info!( channel = %self.name, @@ -2324,9 +2337,12 @@ impl WasmChannel { channel = %self.name, "Message successfully sent to agent queue" ); + + // Track for ACK mechanism + emitted_info.push((message_id, metadata)); } - Ok(()) + Ok(emitted_info) } /// Start the polling loop if configured. diff --git a/src/main.rs b/src/main.rs index 745cae09b4..77524cafbd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -623,6 +623,9 @@ async fn async_main() -> anyhow::Result<()> { .register_message_tools(Arc::clone(&channels), components.extension_manager.clone()) .await; + // Clone the router for AgentDeps before we tuple is consumed. + let wasm_router = wasm_channel_runtime_state.as_ref().map(|state| Arc::clone(&state.2)); + // Wire up channel runtime for hot-activation of WASM channels. if let Some(ref ext_mgr) = components.extension_manager && let Some((rt, ps, router)) = wasm_channel_runtime_state.take() @@ -727,6 +730,7 @@ async fn async_main() -> anyhow::Result<()> { document_extraction: Some(Arc::new( ironclaw::document_extraction::DocumentExtractionMiddleware::new(), )), + wasm_router, }; let mut agent = Agent::new( diff --git a/src/testing/mod.rs b/src/testing/mod.rs index ff522e3ad2..fa74ad3f4e 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -456,6 +456,7 @@ impl TestHarnessBuilder { http_interceptor: None, transcription: None, document_extraction: None, + wasm_router: None, }; TestHarness { diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index a4d737b52a..c0bedbca9b 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -256,6 +256,7 @@ impl GatewayWorkflowHarness { http_interceptor: None, transcription: None, document_extraction: None, + wasm_router: None, }, channels, None, diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index 8d41a26119..ee97a4f8e1 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -642,6 +642,7 @@ impl TestRigBuilder { }, transcription: None, document_extraction: None, + wasm_router: None, }; // 7. Create TestChannel and ChannelManager. diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index 9299962b46..b430a81722 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -200,7 +200,7 @@ async fn test_group_message_unauthorized_user_blocked_with_allowlist() { .expect("HTTP callback failed"); // Should return 200 OK (always respond quickly to Telegram) - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // REGRESSION TEST: The fix ensures the message is dropped // Before the fix: group messages bypassed the allow_from check when owner_id=null @@ -252,7 +252,7 @@ async fn test_group_message_authorized_user_allowed() { .expect("HTTP callback failed"); // Should return 200 OK - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // REGRESSION TEST: Authorized users pass through the authorization check // The fix ensures that group messages now properly check allow_from when owner_id=null @@ -298,7 +298,7 @@ async fn test_private_message_with_owner_id_set_uses_guest_pairing_flow() { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); let pending = pairing_store .list_pending("telegram") @@ -398,7 +398,7 @@ async fn test_private_message_without_owner_id_with_pairing_policy() { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // REGRESSION TEST: Private messages with pairing policy still emit // (pairing and message emission are independent flows) @@ -444,7 +444,7 @@ async fn test_open_dm_policy_allows_all_users() { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // REGRESSION TEST: Open policy should allow all users // With dm_policy="open", authorization checks are skipped for all users @@ -489,7 +489,7 @@ async fn test_bot_mention_detection_case_insensitive() { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // REGRESSION TEST: Bot mentions should be case-insensitive // Case-insensitive detection allows @mybot and @MyBot to both trigger the bot diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index ca961afe7c..446581e034 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -239,7 +239,7 @@ mod channel_lifecycle_tests { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); // Cleanup channel.shutdown().await.expect("Shutdown failed"); From 7629ef1c62f7287a025d1ac780c2c334d86341e2 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sun, 15 Mar 2026 17:05:14 +0100 Subject: [PATCH 08/18] style: fix formatting issues --- .../2026-03-13-wasm-webhook-improvements.md | 10 +- src/channels/wasm/router.rs | 218 +++++++++++------- src/channels/wasm/schema.rs | 5 +- src/channels/wasm/wrapper.rs | 14 +- src/main.rs | 4 +- src/tools/wasm/mod.rs | 2 +- tests/wasm_channel_integration.rs | 37 ++- wit/channel.wit | 2 +- 8 files changed, 179 insertions(+), 113 deletions(-) diff --git a/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md b/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md index d2711afcb8..4b0273f421 100644 --- a/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md +++ b/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md @@ -12,7 +12,7 @@ **Target Branch:** New branch from `upstream/main` **Prerequisites:** -- Latest migration in upstream/main is **V11** → new migration must be **V12** +- Latest migration in upstream/main is **V12** → new migration must be **V13** - Current `router.register()` has 4 params → will add 2 new params (backward compatible) - `register_hmac_secret()` already exists in router @@ -36,7 +36,7 @@ src/db/ └── webhook_dedup.rs # libSQL-specific dedup module migrations/ -└── V12__webhook_dedup.sql # Dedup table migration (V11 is latest in upstream) +└── V13__webhook_dedup.sql # Dedup table migration (V12 is latest in upstream) wit/ └── channel.wit # Add on_message_persisted callback @@ -417,14 +417,14 @@ Adds three new webhook configuration fields: **Files:** - Modify: `src/db/mod.rs` - Modify: `src/db/postgres.rs` -- Create: `migrations/V12__webhook_dedup.sql` (V12 because V11 is latest in upstream) +- Create: `migrations/V13__webhook_dedup.sql` (V13 because V12 is latest in upstream) **Context:** WhatsApp retries webhooks up to 7 days on 5xx errors. Need atomic deduplication to prevent duplicate message processing. - [ ] **Step 1: Create migration file** ```sql --- migrations/V12__webhook_dedup.sql +-- migrations/V13__webhook_dedup.sql -- Webhook message deduplication table -- Prevents duplicate processing when channels retry on errors @@ -610,7 +610,7 @@ Expected: All 3 tests pass - [ ] **Step 7: Commit** ```bash -git add src/db/mod.rs src/db/postgres.rs migrations/V12__webhook_dedup.sql +git add src/db/mod.rs src/db/postgres.rs migrations/V13__webhook_dedup.sql git commit -m "feat(db): add webhook message deduplication store Adds WebhookDedupStore trait and PostgreSQL implementation. diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 833990038d..0e32e7c1f8 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -20,7 +20,7 @@ use axum::{ routing::{get, post}, }; use serde::{Deserialize, Serialize}; -use tokio::sync::{oneshot, RwLock}; +use tokio::sync::{RwLock, oneshot}; use crate::channels::wasm::wrapper::WasmChannel; @@ -200,7 +200,10 @@ impl WasmChannelRouter { // Store verification mode if provided if let Some(m) = verification_mode { - self.verification_modes.write().await.insert(name.clone(), m); + self.verification_modes + .write() + .await + .insert(name.clone(), m); } // Store message ID JSON pointer if provided @@ -244,7 +247,10 @@ impl WasmChannelRouter { self.signature_keys.write().await.remove(channel_name); self.hmac_secrets.write().await.remove(channel_name); self.verification_modes.write().await.remove(channel_name); - self.message_id_json_pointers.write().await.remove(channel_name); + self.message_id_json_pointers + .write() + .await + .remove(channel_name); // Remove all paths for this channel self.path_to_channel @@ -479,69 +485,69 @@ async fn webhook_handler( // Skip directly to WASM call - the channel validates via query param } else { // Check if secret is required - if state.router.requires_secret(channel_name).await { - // Get the secret header name for this channel (from capabilities or default) - let secret_header_name = state.router.get_secret_header(channel_name).await; - - // Try to get secret from query param or the channel's configured header - let provided_secret = query - .get("secret") - .cloned() - .or_else(|| { - headers - .get(&secret_header_name) - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - }) - .or_else(|| { - // Fallback to generic header if different from configured - if secret_header_name != "X-Webhook-Secret" { + if state.router.requires_secret(channel_name).await { + // Get the secret header name for this channel (from capabilities or default) + let secret_header_name = state.router.get_secret_header(channel_name).await; + + // Try to get secret from query param or the channel's configured header + let provided_secret = query + .get("secret") + .cloned() + .or_else(|| { headers - .get("X-Webhook-Secret") + .get(&secret_header_name) .and_then(|v| v.to_str().ok()) .map(|s| s.to_string()) - } else { - None - } - }); + }) + .or_else(|| { + // Fallback to generic header if different from configured + if secret_header_name != "X-Webhook-Secret" { + headers + .get("X-Webhook-Secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + } else { + None + } + }); - tracing::debug!( - channel = %channel_name, - has_provided_secret = provided_secret.is_some(), - provided_secret_len = provided_secret.as_ref().map(|s| s.len()), - "Checking webhook secret" - ); + tracing::debug!( + channel = %channel_name, + has_provided_secret = provided_secret.is_some(), + provided_secret_len = provided_secret.as_ref().map(|s| s.len()), + "Checking webhook secret" + ); - match provided_secret { - Some(secret) => { - if !state.router.validate_secret(channel_name, &secret).await { + match provided_secret { + Some(secret) => { + if !state.router.validate_secret(channel_name, &secret).await { + tracing::warn!( + channel = %channel_name, + "Webhook secret validation failed" + ); + return ( + StatusCode::UNAUTHORIZED, + Json(serde_json::json!({ + "error": "Invalid webhook secret" + })), + ); + } + tracing::debug!(channel = %channel_name, "Webhook secret validated"); + } + None => { tracing::warn!( channel = %channel_name, - "Webhook secret validation failed" + "Webhook secret required but not provided" ); return ( StatusCode::UNAUTHORIZED, Json(serde_json::json!({ - "error": "Invalid webhook secret" + "error": "Webhook secret required" })), ); } - tracing::debug!(channel = %channel_name, "Webhook secret validated"); - } - None => { - tracing::warn!( - channel = %channel_name, - "Webhook secret required but not provided" - ); - return ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "Webhook secret required" - })), - ); } } - } } // end of verification_mode else block // Ed25519 signature verification (Discord-style) @@ -640,11 +646,7 @@ async fn webhook_handler( } // WhatsApp-style verification (_, _, Some(sig)) => { - if !crate::channels::wasm::signature::verify_hmac_sha256( - &hmac_secret, - sig, - &body, - ) { + if !crate::channels::wasm::signature::verify_hmac_sha256(&hmac_secret, sig, &body) { tracing::warn!( channel = %channel_name, "HMAC-SHA256 signature verification failed (WhatsApp-style)" @@ -745,10 +747,11 @@ async fn webhook_handler( if !ack_receivers.is_empty() { let ack_timeout = tokio::time::Duration::from_secs(ACK_TIMEOUT_SECS); let ack_results: Vec<_> = futures::future::join_all( - ack_receivers.into_iter().map(|rx| { - tokio::time::timeout(ack_timeout, rx) - }) - ).await; + ack_receivers + .into_iter() + .map(|rx| tokio::time::timeout(ack_timeout, rx)), + ) + .await; let mut acked = 0; let mut timed_out = 0; @@ -756,7 +759,7 @@ async fn webhook_handler( match result { Ok(Ok(())) => acked += 1, Ok(Err(_)) => timed_out += 1, // Sender dropped - Err(_) => timed_out += 1, // Timeout + Err(_) => timed_out += 1, // Timeout } } @@ -945,7 +948,14 @@ mod tests { let channel = create_test_channel("slack"); router - .register(channel, vec![], Some("secret123".to_string()), None, None, None) + .register( + channel, + vec![], + Some("secret123".to_string()), + None, + None, + None, + ) .await; // Correct secret @@ -956,7 +966,9 @@ mod tests { // Channel without secret always validates let channel2 = create_test_channel("telegram"); - router.register(channel2, vec![], None, None, None, None).await; + router + .register(channel2, vec![], None, None, None, None) + .await; assert!(router.validate_secret("telegram", "anything").await); } @@ -972,7 +984,9 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None, None, None).await; + router + .register(channel, endpoints, None, None, None, None) + .await; // Should exist assert!( @@ -1001,8 +1015,12 @@ mod tests { let channel1 = create_test_channel("slack"); let channel2 = create_test_channel("telegram"); - router.register(channel1, vec![], None, None, None, None).await; - router.register(channel2, vec![], None, None, None, None).await; + router + .register(channel1, vec![], None, None, None, None) + .await; + router + .register(channel2, vec![], None, None, None, None) + .await; let channels = router.list_channels().await; assert_eq!(channels.len(), 2); @@ -1036,7 +1054,14 @@ mod tests { // Channel without custom header should use default let channel2 = create_test_channel("slack"); router - .register(channel2, vec![], Some("secret456".to_string()), None, None, None) + .register( + channel2, + vec![], + Some("secret456".to_string()), + None, + None, + None, + ) .await; assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); } @@ -1048,7 +1073,9 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; let hmac_secret = "my-slack-signing-secret"; router.register_hmac_secret("slack", hmac_secret).await; @@ -1061,7 +1088,9 @@ mod tests { async fn test_no_hmac_secret_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; // Slack has no HMAC secret registered let secret = router.get_hmac_secret("slack").await; @@ -1080,7 +1109,9 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None, None, None).await; + router + .register(channel, endpoints, None, None, None, None) + .await; router.register_hmac_secret("slack", "signing-secret").await; // Secret should exist @@ -1100,7 +1131,9 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; router @@ -1116,7 +1149,9 @@ mod tests { async fn test_no_signature_key_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; // Slack has no signature key registered let key = router.get_signature_key("slack").await; @@ -1135,7 +1170,9 @@ mod tests { require_secret: false, }]; - router.register(channel, endpoints, None, None, None, None).await; + router + .register(channel, endpoints, None, None, None, None) + .await; // Use a valid 32-byte Ed25519 key for this test let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -1159,7 +1196,9 @@ mod tests { async fn test_register_valid_signature_key_succeeds() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; // Valid 32-byte Ed25519 public key (from test keypair) let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; @@ -1171,7 +1210,9 @@ mod tests { async fn test_register_invalid_hex_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; let result = router .register_signature_key("discord", "not-valid-hex-zzz") @@ -1183,7 +1224,9 @@ mod tests { async fn test_register_wrong_length_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; // 16 bytes instead of 32 let short_key = hex::encode([0u8; 16]); @@ -1195,7 +1238,9 @@ mod tests { async fn test_register_empty_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; let result = router.register_signature_key("discord", "").await; assert!(result.is_err(), "Empty key should be rejected"); @@ -1205,7 +1250,9 @@ mod tests { async fn test_valid_key_is_retrievable() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -1221,7 +1268,9 @@ mod tests { async fn test_invalid_key_does_not_store() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router.register(channel, vec![], None, None, None, None).await; + router + .register(channel, vec![], None, None, None, None) + .await; // Attempt to register invalid key let _ = router @@ -1255,7 +1304,9 @@ mod tests { require_secret: false, }]; - wasm_router.register(channel, endpoints, None, None, None, None).await; + wasm_router + .register(channel, endpoints, None, None, None, None) + .await; let app = create_wasm_channel_router(wasm_router.clone(), None); (wasm_router, app) @@ -1547,7 +1598,9 @@ mod tests { require_secret: false, }]; - wasm_router.register(channel, endpoints, None, None, None, None).await; + wasm_router + .register(channel, endpoints, None, None, None, None) + .await; let app = create_wasm_channel_router(wasm_router.clone(), None); (wasm_router, app) @@ -1875,6 +1928,9 @@ mod tests { // Receiver should NOT be signaled (sender was dropped during retain) let result = rx.await; - assert!(result.is_err(), "ACK receiver should not be signaled after unregister"); + assert!( + result.is_err(), + "ACK receiver should not be signaled after unregister" + ); } } diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index babf1928d3..01a72dfd5f 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -897,9 +897,6 @@ mod tests { }"#; let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); - assert_eq!( - cap.webhook_message_id_json_pointer(), - Some("/message_id") - ); + assert_eq!(cap.webhook_message_id_json_pointer(), Some("/message_id")); } } diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index e44970abc3..dabcab51a2 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1894,7 +1894,7 @@ impl WasmChannel { let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref()) + resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref(), &self.owner_scope_id) .await; let pairing_store = self.pairing_store.clone(); let metadata_json = metadata_json.to_string(); @@ -2307,6 +2307,7 @@ impl WasmChannel { } // Parse metadata JSON +<<<<<<< HEAD msg = apply_emitted_metadata(msg, &emitted.metadata_json); if is_owner_sender { // Store for owner-target routing (chat_id etc.). @@ -2315,6 +2316,17 @@ impl WasmChannel { // Extract metadata for ACK mechanism (after apply_emitted_metadata) let metadata = msg.metadata.clone(); +======= + let metadata: serde_json::Value = + if let Ok(m) = serde_json::from_str::(&emitted.metadata_json) { + msg = msg.with_metadata(m.clone()); + // Store for broadcast routing (chat_id etc.) + self.update_broadcast_metadata(&emitted.metadata_json).await; + m + } else { + serde_json::Value::Null + }; +>>>>>>> 6b200e8 (style: fix formatting issues) // Send to stream — no locks held across this await tracing::info!( diff --git a/src/main.rs b/src/main.rs index 77524cafbd..a0c6b403b1 100644 --- a/src/main.rs +++ b/src/main.rs @@ -624,7 +624,9 @@ async fn async_main() -> anyhow::Result<()> { .await; // Clone the router for AgentDeps before we tuple is consumed. - let wasm_router = wasm_channel_runtime_state.as_ref().map(|state| Arc::clone(&state.2)); + let wasm_router = wasm_channel_runtime_state + .as_ref() + .map(|state| Arc::clone(&state.2)); // Wire up channel runtime for hot-activation of WASM channels. if let Some(ref ext_mgr) = components.extension_manager diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 1998e801b6..656fcaacb4 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -80,7 +80,7 @@ pub const WIT_TOOL_VERSION: &str = "0.3.0"; /// Host WIT version for channel extensions. -pub const WIT_CHANNEL_VERSION: &str = "0.3.0"; +pub const WIT_CHANNEL_VERSION: &str = "0.4.0"; mod allowlist; mod capabilities; diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index 446581e034..ec5038f5e8 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -143,7 +143,9 @@ mod router_tests { require_secret: false, }]; - router.register(channel, endpoints, None, None, None, None).await; + router + .register(channel, endpoints, None, None, None, None) + .await; // Channel exists assert!(router.get_channel_for_path("/webhook/temp").await.is_some()); @@ -175,7 +177,9 @@ mod router_tests { require_secret: false, }]; - router.register(channel, endpoints, None, None, None, None).await; + router + .register(channel, endpoints, None, None, None, None) + .await; } // Verify all channels are registered @@ -484,11 +488,8 @@ mod hmac_signature_tests { }; // Verify using the signature module - let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( - secret, - &expected_sig, - body, - ); + let result = + ironclaw::channels::wasm::signature::verify_hmac_sha256(secret, &expected_sig, body); assert!(result, "Valid signature should verify"); } @@ -510,11 +511,8 @@ mod hmac_signature_tests { }; // Verify with wrong secret should fail - let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( - wrong_secret, - &sig, - body, - ); + let result = + ironclaw::channels::wasm::signature::verify_hmac_sha256(wrong_secret, &sig, body); assert!(!result, "Wrong secret should fail verification"); } @@ -536,11 +534,8 @@ mod hmac_signature_tests { }; // Verify with tampered body should fail - let result = ironclaw::channels::wasm::signature::verify_hmac_sha256( - secret, - &sig, - tampered, - ); + let result = + ironclaw::channels::wasm::signature::verify_hmac_sha256(secret, &sig, tampered); assert!(!result, "Tampered body should fail verification"); } @@ -554,7 +549,9 @@ mod hmac_signature_tests { secret, "invalid", body )); assert!(!ironclaw::channels::wasm::signature::verify_hmac_sha256( - secret, "sha256=not_hex!", body + secret, + "sha256=not_hex!", + body )); assert!(!ironclaw::channels::wasm::signature::verify_hmac_sha256( secret, "", body @@ -585,7 +582,9 @@ mod hmac_signature_tests { .await; // Register HMAC secret separately (simulating setup.rs behavior) - router.register_hmac_secret("whatsapp", "my_app_secret").await; + router + .register_hmac_secret("whatsapp", "my_app_secret") + .await; // Verify HMAC secret is registered assert_eq!( diff --git a/wit/channel.wit b/wit/channel.wit index 942162e877..194f65fda5 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -1,4 +1,4 @@ -package near:agent@0.3.0; +package near:agent@0.4.0; // WASM Channel Sandbox Interface // From 88ec12519155c29b197f48e6f6c2ae9d12a224ef Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sun, 15 Mar 2026 17:18:47 +0100 Subject: [PATCH 09/18] chore: bump channel registry versions --- registry/channels/discord.json | 2 +- registry/channels/slack.json | 2 +- registry/channels/whatsapp.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/registry/channels/discord.json b/registry/channels/discord.json index dc545d75d7..58f3dbdda8 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -2,7 +2,7 @@ "name": "discord", "display_name": "Discord Channel", "kind": "channel", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.3.0", "description": "Talk to your agent in Discord", "keywords": [ diff --git a/registry/channels/slack.json b/registry/channels/slack.json index e6d3660484..3cc1ff739d 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -2,7 +2,7 @@ "name": "slack", "display_name": "Slack Channel", "kind": "channel", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.3.0", "description": "Talk to your agent in Slack", "keywords": [ diff --git a/registry/channels/whatsapp.json b/registry/channels/whatsapp.json index be3faf0dc9..3831d4bb62 100644 --- a/registry/channels/whatsapp.json +++ b/registry/channels/whatsapp.json @@ -2,7 +2,7 @@ "name": "whatsapp", "display_name": "WhatsApp Channel", "kind": "channel", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.3.0", "description": "Talk to your agent through WhatsApp", "keywords": [ From 01ec5df48e4da8d596b4239f3ade2a5c1c95657e Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Sun, 15 Mar 2026 17:29:00 +0100 Subject: [PATCH 10/18] fix(tests): add channel-host@0.4.0 stub in wit_compat test --- tests/wit_compat.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/wit_compat.rs b/tests/wit_compat.rs index 4dcacf4eb1..3ca95e4298 100644 --- a/tests/wit_compat.rs +++ b/tests/wit_compat.rs @@ -317,6 +317,13 @@ fn instantiate_channel_component( .map_err(|e| format!("failed to create versioned channel-host@0.3.0: {e}"))?; stub_channel_host(&mut host)?; } + { + let mut root = linker.root(); + let mut host = root + .instance("near:agent/channel-host@0.4.0") + .map_err(|e| format!("failed to create versioned channel-host@0.4.0: {e}"))?; + stub_channel_host(&mut host)?; + } let mut store = Store::new(engine, TestStoreData::new()); linker From 5171ddc3a0d640c7092386a0004fced8e3c3a5ff Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Mon, 16 Mar 2026 08:24:06 +0100 Subject: [PATCH 11/18] fix(wasm): address PR review feedback - remove dead code, improve ACK handling Address code review feedback on PR #1207: Critical issues fixed: - Remove dead WebhookDedupStore trait and implementations (postgres, libsql) - Remove unused message_id_json_pointer from router, setup, capabilities - Remove unused db field and set_db/get_db methods from router - Delete V13 migration (webhook deduplication never shipped) Important issues fixed: - Update all WIT versions from 0.3.0 to 0.4.0 - ACK_TIMEOUT_SECS already at 10 seconds (WhatsApp requirement) - Add cleanup_pending_ack() to prevent memory leaks from orphaned entries Files modified: - src/channels/wasm/router.rs: Removed dead code, added cleanup_pending_ack - src/channels/wasm/schema.rs: Removed webhook_message_id_json_pointer - src/channels/wasm/setup.rs: Removed message_id_json_pointer handling - src/db/mod.rs: Removed WebhookDedupStore trait - src/db/postgres.rs: Removed WebhookDedupStore impl - src/db/libsql/mod.rs: Removed WebhookDedupStore impl - src/extensions/manager.rs: Updated register() from 6 to 5 args - tests/wasm_channel_integration.rs: Updated all register() calls - All registry/**/*.json: Updated wit_version to 0.4.0 - All *-src/**/*.capabilities.json: Updated wit_version to 0.4.0 Files deleted: - migrations/V13__webhook_dedup.sql - docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md Co-Authored-By: Claude Opus 4.6 --- .../discord/discord.capabilities.json | 2 +- channels-src/slack/slack.capabilities.json | 2 +- .../telegram/telegram.capabilities.json | 2 +- .../whatsapp/whatsapp.capabilities.json | 5 +- .../2026-03-13-wasm-webhook-improvements.md | 1485 ----------------- migrations/V13__webhook_dedup.sql | 19 - registry/channels/discord.json | 2 +- registry/channels/slack.json | 2 +- registry/channels/telegram.json | 2 +- registry/channels/whatsapp.json | 2 +- registry/tools/github.json | 2 +- registry/tools/gmail.json | 2 +- registry/tools/google-calendar.json | 2 +- registry/tools/google-docs.json | 2 +- registry/tools/google-drive.json | 2 +- registry/tools/google-sheets.json | 2 +- registry/tools/google-slides.json | 2 +- registry/tools/llm-context.json | 2 +- registry/tools/slack.json | 2 +- registry/tools/telegram.json | 2 +- registry/tools/web-search.json | 2 +- src/channels/wasm/loader.rs | 7 - src/channels/wasm/router.rs | 170 +- src/channels/wasm/schema.rs | 37 - src/channels/wasm/setup.rs | 4 +- src/db/libsql/mod.rs | 38 - src/db/mod.rs | 24 - src/db/postgres.rs | 38 - src/extensions/manager.rs | 1 - tests/wasm_channel_integration.rs | 18 +- .../github/github-tool.capabilities.json | 2 +- tools-src/gmail/gmail-tool.capabilities.json | 2 +- .../google-calendar-tool.capabilities.json | 2 +- .../google-docs-tool.capabilities.json | 2 +- .../google-drive-tool.capabilities.json | 2 +- .../google-sheets-tool.capabilities.json | 2 +- .../google-slides-tool.capabilities.json | 2 +- .../llm-context-tool.capabilities.json | 2 +- tools-src/slack/slack-tool.capabilities.json | 2 +- .../telegram/telegram-tool.capabilities.json | 2 +- .../web-search-tool.capabilities.json | 2 +- 41 files changed, 84 insertions(+), 1820 deletions(-) delete mode 100644 docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md delete mode 100644 migrations/V13__webhook_dedup.sql diff --git a/channels-src/discord/discord.capabilities.json b/channels-src/discord/discord.capabilities.json index 9ff7a8905d..0e7d38083b 100644 --- a/channels-src/discord/discord.capabilities.json +++ b/channels-src/discord/discord.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "type": "channel", "name": "discord", "description": "Discord webhook channel for slash commands, components, and optional mention polling", diff --git a/channels-src/slack/slack.capabilities.json b/channels-src/slack/slack.capabilities.json index 7035d9253b..5fa5ced4d0 100644 --- a/channels-src/slack/slack.capabilities.json +++ b/channels-src/slack/slack.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "type": "channel", "name": "slack", "description": "Slack Events API channel for receiving and responding to Slack messages", diff --git a/channels-src/telegram/telegram.capabilities.json b/channels-src/telegram/telegram.capabilities.json index 1526762ded..a44de8c985 100644 --- a/channels-src/telegram/telegram.capabilities.json +++ b/channels-src/telegram/telegram.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.2", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "type": "channel", "name": "telegram", "description": "Telegram Bot API channel for receiving and responding to Telegram messages", diff --git a/channels-src/whatsapp/whatsapp.capabilities.json b/channels-src/whatsapp/whatsapp.capabilities.json index 3cda159f62..bafc072583 100644 --- a/channels-src/whatsapp/whatsapp.capabilities.json +++ b/channels-src/whatsapp/whatsapp.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "type": "channel", "name": "whatsapp", "description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages", @@ -52,8 +52,7 @@ "secret_name": "whatsapp_verify_token", "verify_token_param": "hub.verify_token", "verification_mode": "query_param", - "hmac_secret_name": "whatsapp_app_secret", - "message_id_json_pointer": "/metadata/message_id" + "hmac_secret_name": "whatsapp_app_secret" } } }, diff --git a/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md b/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md deleted file mode 100644 index 4b0273f421..0000000000 --- a/docs/superpowers/plans/2026-03-13-wasm-webhook-improvements.md +++ /dev/null @@ -1,1485 +0,0 @@ -# WASM Webhook Improvements Implementation Plan - -> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Improve WASM channel webhook handling with WhatsApp HMAC signature verification, flexible verification modes, webhook deduplication, and ACK deferral for reliable message processing. - -**Architecture:** Extend the existing WASM channel infrastructure in three layers: (1) signature verification module adds WhatsApp-style HMAC, (2) schema/router add verification modes and deduplication, (3) wrapper adds deferred ACK with on_message_persisted callback. - -**Tech Stack:** Rust, axum, hmac, sha2, subtle (constant-time comparison), PostgreSQL/libSQL - -**Source Branch:** `feat/whatsapp-hmac-signature-verification` (PR closed, changes to be integrated) -**Target Branch:** New branch from `upstream/main` - -**Prerequisites:** -- Latest migration in upstream/main is **V12** → new migration must be **V13** -- Current `router.register()` has 4 params → will add 2 new params (backward compatible) -- `register_hmac_secret()` already exists in router - ---- - -## File Structure - -``` -src/channels/wasm/ -├── signature.rs # Add verify_hmac_sha256 for WhatsApp -├── schema.rs # Add verification_mode, message_id_json_pointer fields -├── router.rs # Add dedup, ACK deferral, verification modes -├── wrapper.rs # Add call_on_http_request_with_messages, on_message_persisted -└── loader.rs # Pass new config fields to router - -src/db/ -├── mod.rs # Add WebhookDedupStore trait -├── postgres.rs # Implement WebhookDedupStore -└── libsql/ - ├── mod.rs # Implement WebhookDedupStore - └── webhook_dedup.rs # libSQL-specific dedup module - -migrations/ -└── V13__webhook_dedup.sql # Dedup table migration (V12 is latest in upstream) - -wit/ -└── channel.wit # Add on_message_persisted callback - -channels-src/whatsapp/ -├── src/lib.rs # Implement on_message_persisted for mark_as_read -└── whatsapp.capabilities.json # Add hmac_secret_name, verification_mode - -src/main.rs # Initialize router.set_db() on startup -``` - ---- - -## Chunk 1: WhatsApp HMAC Signature Verification - -### Task 1.1: Add verify_hmac_sha256 function - -**Files:** -- Modify: `src/channels/wasm/signature.rs` - -**Context:** WhatsApp Cloud API sends webhook signatures in `X-Hub-Signature-256` header with format `sha256=`. This is simpler than Slack's versioned basestring (no timestamp prefix). - -- [ ] **Step 1: Write the failing test** - -```rust -// In src/channels/wasm/signature.rs, add to mod tests: - -/// Helper: compute HMAC-SHA256 signature in WhatsApp/Meta format (`sha256=`). -fn compute_whatsapp_style_hmac_signature(secret: &str, body: &[u8]) -> String { - use hmac::Mac; - let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - let result = mac.finalize(); - format!("sha256={}", hex::encode(result.into_bytes())) -} - -#[test] -fn test_hmac_valid_signature_succeeds() { - let secret = "my_app_secret"; - let body = br#"{"entry":[{"id":"123"}]}"#; - let sig_header = compute_whatsapp_style_hmac_signature(secret, body); - - assert!( - verify_hmac_sha256(secret, &sig_header, body), - "Valid HMAC signature should verify" - ); -} - -#[test] -fn test_hmac_wrong_secret_fails() { - let secret = "correct_secret"; - let wrong_secret = "wrong_secret"; - let body = br#"{"test":"data"}"#; - let sig_header = compute_whatsapp_style_hmac_signature(secret, body); - - assert!( - !verify_hmac_sha256(wrong_secret, &sig_header, body), - "Signature with wrong secret should fail" - ); -} - -#[test] -fn test_hmac_tampered_body_fails() { - let secret = "my_secret"; - let body = br#"original body"#; - let tampered = br#"tampered body"#; - let sig_header = compute_whatsapp_style_hmac_signature(secret, body); - - assert!( - !verify_hmac_sha256(secret, &sig_header, tampered), - "Tampered body should fail verification" - ); -} - -#[test] -fn test_hmac_invalid_header_format_fails() { - let secret = "secret"; - let body = br#"data"#; - - assert!(!verify_hmac_sha256(secret, "invalid", body)); - assert!(!verify_hmac_sha256(secret, "sha256=not_hex!", body)); - assert!(!verify_hmac_sha256(secret, "", body)); -} - -#[test] -fn test_hmac_wrong_length_fails() { - let secret = "secret"; - let body = br#"data"#; - // 16 bytes instead of 32 - let short_sig = format!("sha256={}", "a".repeat(16)); - - assert!( - !verify_hmac_sha256(secret, &short_sig, body), - "Wrong-length signature should fail" - ); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test channels::wasm::signature::tests::test_hmac --no-run 2>&1 | grep -E "error|verify_hmac_sha256"` -Expected: Compilation error - `verify_hmac_sha256` not found - -- [ ] **Step 3: Add imports and type alias at top of file** - -```rust -// At the top of src/channels/wasm/signature.rs, add after existing imports: - -use hmac::{Hmac, Mac}; -use sha2::Sha256; -use subtle::ConstantTimeEq; - -type HmacSha256 = Hmac; -``` - -- [ ] **Step 4: Write the implementation** - -Add after `verify_discord_signature` function: - -```rust -/// Verify HMAC-SHA256 signature (WhatsApp style, simple body-only). -/// -/// # Arguments -/// * `secret` - The HMAC secret (App Secret) -/// * `signature_header` - Value from X-Hub-Signature-256 header (format: "sha256=") -/// * `body` - Raw request body bytes -/// -/// # Returns -/// `true` if signature is valid, `false` otherwise -pub fn verify_hmac_sha256(secret: &str, signature_header: &str, body: &[u8]) -> bool { - // Parse header format: "sha256=" - let Some(hex_signature) = signature_header.strip_prefix("sha256=") else { - return false; - }; - - // Decode expected signature - let Ok(expected_sig) = hex::decode(hex_signature) else { - return false; - }; - - // SHA-256 produces 32-byte signatures - reject wrong lengths early - if expected_sig.len() != 32 { - return false; - } - - // Compute HMAC-SHA256 - let mut mac = match HmacSha256::new_from_slice(secret.as_bytes()) { - Ok(m) => m, - Err(_) => return false, - }; - mac.update(body); - let result = mac.finalize(); - let computed_sig = result.into_bytes(); - - // Constant-time comparison to prevent timing attacks - computed_sig - .as_slice() - .ct_eq(expected_sig.as_slice()) - .into() -} -``` - -- [ ] **Step 5: Refactor verify_slack_signature to use shared HmacSha256** - -Remove the local imports in `verify_slack_signature`: - -```rust -// REMOVE these lines from inside verify_slack_signature: -use hmac::{Hmac, Mac}; -use sha2::Sha256; -use subtle::ConstantTimeEq; -``` - -Change `Hmac::` to `HmacSha256`: - -```rust -// Change this line: -let mut mac = match Hmac::::new_from_slice(signing_secret.as_bytes()) { -// To: -let mut mac = match HmacSha256::new_from_slice(signing_secret.as_bytes()) { -``` - -- [ ] **Step 6: Run tests to verify they pass** - -Run: `cargo test channels::wasm::signature::tests::test_hmac` -Expected: All 5 tests pass - -- [ ] **Step 7: Run full signature tests** - -Run: `cargo test channels::wasm::signature::tests` -Expected: All signature tests pass (Discord, Slack, WhatsApp) - -- [ ] **Step 8: Commit** - -```bash -git add src/channels/wasm/signature.rs -git commit -m "feat(wasm): add verify_hmac_sha256 for WhatsApp webhook signatures - -Adds simple body-only HMAC-SHA256 verification for WhatsApp/Meta webhooks. -Uses X-Hub-Signature-256 header with sha256= format. -Refactors to share HmacSha256 type alias with Slack verification." -``` - ---- - -## Chunk 2: Schema Extensions for Verification Modes - -### Task 2.1: Add new webhook configuration fields - -**Files:** -- Modify: `src/channels/wasm/schema.rs` - -**Context:** WhatsApp needs different verification for GET (query param) vs POST (HMAC signature). Also need to extract message IDs for deduplication. - -- [ ] **Step 1: Write the failing tests** - -```rust -// In src/channels/wasm/schema.rs, add to mod tests: - -#[test] -fn test_webhook_verification_mode_parsing() { - let json = r#"{ - "name": "test", - "capabilities": { - "channel": { - "webhook": { - "verification_mode": "query_param" - } - } - } - }"#; - - let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); - assert_eq!(cap.webhook_verification_mode(), Some("query_param")); -} - -#[test] -fn test_webhook_hmac_secret_name_parsing() { - let json = r#"{ - "name": "test", - "capabilities": { - "channel": { - "webhook": { - "hmac_secret_name": "whatsapp_app_secret" - } - } - } - }"#; - - let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); - assert_eq!(cap.webhook_hmac_secret_name(), Some("whatsapp_app_secret")); -} - -#[test] -fn test_webhook_message_id_json_pointer_parsing() { - let json = r#"{ - "name": "test", - "capabilities": { - "channel": { - "webhook": { - "message_id_json_pointer": "/message_id" - } - } - } - }"#; - - let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); - assert_eq!(cap.webhook_message_id_json_pointer(), Some("/message_id")); -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `cargo test channels::wasm::schema::tests::test_webhook 2>&1 | grep -E "error|no method"` -Expected: Compilation errors for missing methods/fields - -- [ ] **Step 3: Add new fields to WebhookSchema** - -```rust -// In WebhookSchema struct, add after signature_key_secret_name: - -/// How to handle GET request validation: -/// - None/default: Require secret header for all requests (current behavior) -/// - "query_param": Skip host-level secret validation for GET requests; -/// the WASM module validates via query param (e.g., WhatsApp hub.verify_token) -/// - "signature": Always require signature validation (for Discord-style Ed25519) -#[serde(default)] -pub verification_mode: Option, - -/// Secret name in secrets store containing the HMAC secret -/// for signature verification (e.g., WhatsApp/Slack webhook signatures). -/// The header format is expected to be "sha256=". -#[serde(default)] -pub hmac_secret_name: Option, - -/// JSON pointer path to extract message ID from metadata_json. -/// Used for ACK key construction and deduplication. -/// Format: "/field1/field2" to access {"field1": {"field2": "value"}} -/// If None, the router falls back to using user_id. -#[serde(default)] -pub message_id_json_pointer: Option, -``` - -- [ ] **Step 4: Add accessor methods to ChannelCapabilitiesFile** - -```rust -// Add after webhook_secret_name method: - -/// Get the webhook verification mode for this channel. -/// -/// Returns the verification mode declared in `webhook.verification_mode`: -/// - None/default: Require secret header for all requests -/// - "query_param": Skip host-level secret validation for GET, WASM validates via query param -/// - "signature": Always require signature validation -pub fn webhook_verification_mode(&self) -> Option<&str> { - self.capabilities - .channel - .as_ref() - .and_then(|c| c.webhook.as_ref()) - .and_then(|w| w.verification_mode.as_deref()) -} - -/// Get the HMAC secret name for webhook signature verification. -/// -/// Returns the secret name declared in `webhook.hmac_secret_name`, -/// used to look up the HMAC secret in the secrets store for -/// WhatsApp/Slack-style signature verification. -pub fn webhook_hmac_secret_name(&self) -> Option<&str> { - self.capabilities - .channel - .as_ref() - .and_then(|c| c.webhook.as_ref()) - .and_then(|w| w.hmac_secret_name.as_deref()) -} - -/// Get the JSON pointer path to extract message ID from metadata. -/// -/// Returns the JSON pointer declared in `webhook.message_id_json_pointer`, -/// used for ACK key construction and deduplication. -/// If None, the router falls back to using user_id. -pub fn webhook_message_id_json_pointer(&self) -> Option<&str> { - self.capabilities - .channel - .as_ref() - .and_then(|c| c.webhook.as_ref()) - .and_then(|w| w.message_id_json_pointer.as_deref()) -} -``` - -- [ ] **Step 5: Run tests to verify they pass** - -Run: `cargo test channels::wasm::schema::tests::test_webhook` -Expected: All 3 tests pass - -- [ ] **Step 6: Run full schema tests** - -Run: `cargo test channels::wasm::schema::tests` -Expected: All schema tests pass - -- [ ] **Step 7: Commit** - -```bash -git add src/channels/wasm/schema.rs -git commit -m "feat(wasm): add verification_mode and message_id_json_pointer to webhook schema - -Adds three new webhook configuration fields: -- verification_mode: query_param/signature/default -- hmac_secret_name: for WhatsApp/Slack HMAC verification -- message_id_json_pointer: for extracting message IDs from metadata" -``` - ---- - -## Chunk 3: Webhook Deduplication Database Layer - -### Task 3.1: Add WebhookDedupStore trait and PostgreSQL implementation - -**Files:** -- Modify: `src/db/mod.rs` -- Modify: `src/db/postgres.rs` -- Create: `migrations/V13__webhook_dedup.sql` (V13 because V12 is latest in upstream) - -**Context:** WhatsApp retries webhooks up to 7 days on 5xx errors. Need atomic deduplication to prevent duplicate message processing. - -- [ ] **Step 1: Create migration file** - -```sql --- migrations/V13__webhook_dedup.sql --- Webhook message deduplication table --- Prevents duplicate processing when channels retry on errors - -CREATE TABLE IF NOT EXISTS webhook_message_dedup ( - -- Composite key: channel name + message ID from the channel - -- e.g., "whatsapp:wamid.HBgM..." or "telegram:12345" - key TEXT PRIMARY KEY, - - -- When this message was first seen - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Index for cleanup queries (delete old records) -CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at - ON webhook_message_dedup(created_at); - --- Comment explaining purpose -COMMENT ON TABLE webhook_message_dedup IS - 'Deduplication table for webhook messages. Channels like WhatsApp retry on 5xx for up to 7 days. This table ensures idempotent processing.'; -``` - -- [ ] **Step 2: Add WebhookDedupStore trait to src/db/mod.rs** - -```rust -// Add after existing traits: - -/// Webhook message deduplication store. -/// -/// Prevents duplicate processing when channels retry webhooks on errors. -/// WhatsApp, for example, retries for up to 7 days on 5xx responses. -#[async_trait] -pub trait WebhookDedupStore: Send + Sync { - /// Try to record that a message is processed, atomically. - /// - /// Returns `true` if this is a new message (was inserted), - /// `false` if it was a duplicate (key already exists). - /// - /// Uses INSERT ... ON CONFLICT DO NOTHING for atomic dedup with no race condition. - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result; - - /// Clean up old dedup records. - /// - /// Called periodically to prevent unbounded growth. - /// Returns the number of records deleted. - async fn cleanup_old_webhook_dedup_records(&self) -> Result; -} -``` - -- [ ] **Step 3: Add trait bound to Database trait** - -```rust -// Modify the Database trait declaration to include WebhookDedupStore: -pub trait Database: - MessageStore - + ThreadStore - + ToolStore - + JobStore - + SessionStore - + PairingStore - + WasmToolStore - + SecretsStore - + SettingsStore - + WorkspaceStore - + RoutineStore - + WebhookDedupStore // Add this - + Send - + Sync -{ -} -``` - -- [ ] **Step 4: Implement WebhookDedupStore for PostgresBackend** - -Add to `src/db/postgres.rs` before `mod tests`: - -```rust -#[async_trait] -impl WebhookDedupStore for PostgresBackend { - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result { - let key = format!("{}:{}", channel_name, message_id); - - let result = sqlx::query( - r#" - INSERT INTO webhook_message_dedup (key) - VALUES ($1) - ON CONFLICT (key) DO NOTHING - "#, - ) - .bind(&key) - .execute(&self.pool) - .await - .map_err(|e| DbError::QueryError(e.to_string()))?; - - // rows_affected is 1 if inserted, 0 if conflict (duplicate) - Ok(result.rows_affected() == 1) - } - - async fn cleanup_old_webhook_dedup_records(&self) -> Result { - // Delete records older than 30 days - let result = sqlx::query( - r#" - DELETE FROM webhook_message_dedup - WHERE created_at < NOW() - INTERVAL '30 days' - "#, - ) - .execute(&self.pool) - .await - .map_err(|e| DbError::QueryError(e.to_string()))?; - - Ok(result.rows_affected()) - } -} -``` - -- [ ] **Step 5: Write proper tests with sqlx::test** - -```rust -// Add to mod tests in src/db/postgres.rs: - -#[sqlx::test] -async fn test_webhook_dedup_inserts_new_key(pool: PgPool) { - let db = PostgresBackend::with_pool(pool); - - // First insert should succeed - let is_new = db - .record_webhook_message_processed("whatsapp", "msg123") - .await - .unwrap(); - assert!(is_new, "First insert should return true (new message)"); -} - -#[sqlx::test] -async fn test_webhook_dedup_rejects_duplicate(pool: PgPool) { - let db = PostgresBackend::with_pool(pool); - - // First insert - let is_new1 = db - .record_webhook_message_processed("whatsapp", "msg456") - .await - .unwrap(); - assert!(is_new1); - - // Second insert (duplicate) should return false - let is_new2 = db - .record_webhook_message_processed("whatsapp", "msg456") - .await - .unwrap(); - assert!(!is_new2, "Duplicate insert should return false"); -} - -#[sqlx::test] -async fn test_webhook_dedup_different_channels_same_msg_id(pool: PgPool) { - let db = PostgresBackend::with_pool(pool); - - // Same message ID in different channels should both succeed - let is_new1 = db - .record_webhook_message_processed("whatsapp", "msg789") - .await - .unwrap(); - let is_new2 = db - .record_webhook_message_processed("telegram", "msg789") - .await - .unwrap(); - - assert!(is_new1); - assert!(is_new2, "Same msg_id in different channels should be separate keys"); -} -``` - -- [ ] **Step 6: Run tests with postgres feature** - -Run: `cargo test db::postgres::tests::test_webhook_dedup --features postgres` -Expected: All 3 tests pass - -- [ ] **Step 7: Commit** - -```bash -git add src/db/mod.rs src/db/postgres.rs migrations/V13__webhook_dedup.sql -git commit -m "feat(db): add webhook message deduplication store - -Adds WebhookDedupStore trait and PostgreSQL implementation. -Prevents duplicate processing when channels retry webhooks. -Uses INSERT ON CONFLICT DO NOTHING for atomic deduplication." -``` - -### Task 3.2: Add libSQL implementation - -**Files:** -- Create: `src/db/libsql/webhook_dedup.rs` -- Modify: `src/db/libsql/mod.rs` -- Modify: `src/db/libsql_migrations.rs` - -- [ ] **Step 1: Create webhook_dedup.rs module** - -```rust -// src/db/libsql/webhook_dedup.rs -//! Webhook message deduplication for libSQL backend. - -use async_trait::async_trait; -use libsql::Connection; - -use crate::db::{DbError, WebhookDedupStore}; - -/// LibSQL implementation of WebhookDedupStore. -pub struct LibSqlWebhookDedupStore { - conn: Connection, -} - -impl LibSqlWebhookDedupStore { - /// Create a new webhook dedup store. - pub fn new(conn: Connection) -> Self { - Self { conn } - } -} - -#[async_trait] -impl WebhookDedupStore for LibSqlWebhookDedupStore { - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result { - let key = format!("{}:{}", channel_name, message_id); - - // libSQL uses INSERT OR IGNORE for SQLite compatibility - let result = self - .conn - .execute( - "INSERT OR IGNORE INTO webhook_message_dedup (key) VALUES (?1)", - [libsql::Value::from(key)], - ) - .await - .map_err(|e| DbError::QueryError(e.to_string()))?; - - // rows_affected is 1 if inserted, 0 if ignored (duplicate) - Ok(result.rows_affected() == 1) - } - - async fn cleanup_old_webhook_dedup_records(&self) -> Result { - // Delete records older than 30 days (SQLite datetime syntax) - let result = self - .conn - .execute( - "DELETE FROM webhook_message_dedup WHERE created_at < datetime('now', '-30 days')", - [], - ) - .await - .map_err(|e| DbError::QueryError(e.to_string()))?; - - Ok(result.rows_affected()) - } -} -``` - -- [ ] **Step 2: Add migration to libsql_migrations.rs** - -```rust -// In src/db/libsql_migrations.rs, find the migrations array and add: - -// Migration 12: Webhook deduplication table -( - 12, - r#" - CREATE TABLE IF NOT EXISTS webhook_message_dedup ( - key TEXT PRIMARY KEY, - created_at TEXT NOT NULL DEFAULT (datetime('now')) - ); - - CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at - ON webhook_message_dedup(created_at); - "#, -), -``` - -- [ ] **Step 3: Update mod.rs to expose and wire the store** - -```rust -// In src/db/libsql/mod.rs, add module declaration: -mod webhook_dedup; -pub use webhook_dedup::LibSqlWebhookDedupStore; - -// In LibSqlBackend struct, add field: -pub struct LibSqlBackend { - // ... existing fields ... - webhook_dedup: LibSqlWebhookDedupStore, -} - -// In LibSqlBackend::new(), after connection is established: -impl LibSqlBackend { - pub async fn new(config: &LibSqlConfig) -> Result { - // ... existing initialization code ... - let webhook_dedup = LibSqlWebhookDedupStore::new(conn.clone()); - - Ok(Self { - // ... existing fields ... - webhook_dedup, - }) - } -} - -// Add WebhookDedupStore impl that delegates: -#[async_trait] -impl WebhookDedupStore for LibSqlBackend { - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result { - self.webhook_dedup - .record_webhook_message_processed(channel_name, message_id) - .await - } - - async fn cleanup_old_webhook_dedup_records(&self) -> Result { - self.webhook_dedup.cleanup_old_webhook_dedup_records().await - } -} -``` - -- [ ] **Step 4: Test compilation with libsql feature only** - -Run: `cargo check --no-default-features --features libsql` -Expected: No compilation errors - -- [ ] **Step 5: Test compilation with all features** - -Run: `cargo check --all-features` -Expected: No compilation errors - -- [ ] **Step 6: Commit** - -```bash -git add src/db/libsql/webhook_dedup.rs src/db/libsql/mod.rs src/db/libsql_migrations.rs -git commit -m "feat(db): add libSQL implementation of WebhookDedupStore - -Uses INSERT OR IGNORE for atomic deduplication. -Compatible with Turso cloud and local libSQL." -``` - ---- - -## Chunk 4: Router Integration - -### Task 4.1: Add verification modes and HMAC support to router - -**Files:** -- Modify: `src/channels/wasm/router.rs` -- Modify: `src/channels/wasm/loader.rs` - -**Context:** Router needs to support new verification modes and HMAC signature validation. Current `register()` has 4 params; we add 2 more (backward compatible by using Option). - -- [ ] **Step 1: Add new fields to WasmChannelRouter struct** - -```rust -// In src/channels/wasm/router.rs, modify WasmChannelRouter struct: - -pub struct WasmChannelRouter { - // ... existing fields (channels, path_to_channel, secrets, secret_headers, signature_keys, hmac_secrets) ... - - /// Verification mode per channel: "query_param", "signature", etc. - verification_modes: RwLock>, - /// JSON pointers for extracting message IDs from metadata_json by channel name. - message_id_json_pointers: RwLock>, - /// Database for webhook message deduplication (optional - graceful degradation if not set). - db: RwLock>>, -} -``` - -- [ ] **Step 2: Update WasmChannelRouter::new()** - -```rust -impl WasmChannelRouter { - pub fn new() -> Self { - Self { - channels: RwLock::new(HashMap::new()), - path_to_channel: RwLock::new(HashMap::new()), - secrets: RwLock::new(HashMap::new()), - secret_headers: RwLock::new(HashMap::new()), - signature_keys: RwLock::new(HashMap::new()), - hmac_secrets: RwLock::new(HashMap::new()), - verification_modes: RwLock::new(HashMap::new()), - message_id_json_pointers: RwLock::new(HashMap::new()), - db: RwLock::new(None), - } - } - - /// Set the database for webhook message deduplication. - /// - /// If not called, deduplication is disabled (webhooks process without idempotency check). - pub async fn set_db(&self, db: Arc) { - *self.db.write().await = Some(db); - } - - /// Get the database for webhook message deduplication. - /// - /// Returns None if deduplication is not configured. - pub async fn get_db(&self) -> Option> { - self.db.read().await.clone() - } -} -``` - -- [ ] **Step 3: Update register() signature (add 2 new optional params)** - -```rust -/// Register a channel with its endpoints. -/// -/// # Arguments -/// * `channel` - The WASM channel to register -/// * `endpoints` - HTTP endpoints to register for this channel -/// * `secret` - Optional webhook secret for validation -/// * `secret_header` - Optional HTTP header name for secret validation -/// * `verification_mode` - Optional verification mode for GET requests: -/// - "query_param": Skip host-level secret validation for GET, WASM validates via query param -/// - "signature": Always require signature validation -/// * `message_id_json_pointer` - Optional JSON pointer to extract message ID from metadata_json -pub async fn register( - &self, - channel: Arc, - endpoints: Vec, - secret: Option, - secret_header: Option, - verification_mode: Option, // NEW - message_id_json_pointer: Option, // NEW -) { - let name = channel.channel_name().to_string(); - - // Store the channel - self.channels.write().await.insert(name.clone(), channel); - - // Register path mappings - let mut path_map = self.path_to_channel.write().await; - for endpoint in endpoints { - path_map.insert(endpoint.path.clone(), name.clone()); - tracing::info!( - channel = %name, - path = %endpoint.path, - methods = ?endpoint.methods, - "Registered WASM channel HTTP endpoint" - ); - } - drop(path_map); - - // Store secret if provided - if let Some(s) = secret { - self.secrets.write().await.insert(name.clone(), s); - } - - // Store secret header if provided - if let Some(h) = secret_header { - self.secret_headers.write().await.insert(name.clone(), h); - } - - // Store verification mode if provided - if let Some(m) = verification_mode { - self.verification_modes - .write() - .await - .insert(name.clone(), m); - } - - // Store message ID JSON pointer if provided - if let Some(p) = message_id_json_pointer { - self.message_id_json_pointers - .write() - .await - .insert(name.clone(), p); - } -} -``` - -- [ ] **Step 4: Add accessor methods for new fields** - -```rust -impl WasmChannelRouter { - // ... existing methods ... - - /// Get the verification mode for a channel. - pub async fn get_verification_mode(&self, channel_name: &str) -> Option { - self.verification_modes - .read() - .await - .get(channel_name) - .cloned() - } - - /// Get the message ID JSON pointer for a channel. - pub async fn get_message_id_json_pointer(&self, channel_name: &str) -> Option { - self.message_id_json_pointers - .read() - .await - .get(channel_name) - .cloned() - } -} -``` - -- [ ] **Step 5: Update unregister() to clean up new fields** - -```rust -// In unregister() method, add cleanup for new fields: - -pub async fn unregister(&self, channel_name: &str) { - self.channels.write().await.remove(channel_name); - self.path_to_channel.write().await.retain(|_, v| v != channel_name); - self.secrets.write().await.remove(channel_name); - self.secret_headers.write().await.remove(channel_name); - self.signature_keys.write().await.remove(channel_name); - self.hmac_secrets.write().await.remove(channel_name); - // Add these: - self.verification_modes.write().await.remove(channel_name); - self.message_id_json_pointers.write().await.remove(channel_name); -} -``` - -- [ ] **Step 6: Update loader.rs to pass new parameters** - -```rust -// In src/channels/wasm/loader.rs, find where router.register() is called -// and update it to pass the new parameters: - -// After reading capabilities file: -let verification_mode = caps.webhook_verification_mode().map(|s| s.to_string()); -let message_id_json_pointer = caps.webhook_message_id_json_pointer().map(|s| s.to_string()); - -// Update router.register() call: -router.register( - channel, - endpoints, - secret, - secret_header, - verification_mode, // NEW - message_id_json_pointer, // NEW -).await; -``` - -- [ ] **Step 7: Test compilation** - -Run: `cargo check` -Expected: No compilation errors - -- [ ] **Step 8: Commit** - -```bash -git add src/channels/wasm/router.rs src/channels/wasm/loader.rs -git commit -m "feat(wasm): add verification_mode and message_id support to router - -Router now accepts and stores verification_mode and message_id_json_pointer -from channel capabilities. Adds optional database hook for deduplication -with graceful degradation if not configured." -``` - ---- - -## Chunk 5: WIT Interface and on_message_persisted Callback - -### Task 5.1: Add on_message_persisted to WIT interface - -**Files:** -- Modify: `wit/channel.wit` -- Modify: `src/channels/wasm/wrapper.rs` - -**IMPORTANT:** After modifying WIT, you MUST regenerate bindings. - -- [ ] **Step 1: Add callback to WIT interface** - -```wit -// In wit/channel.wit, add after on_respond (around line 310): - -/// Called after a message has been persisted to the database. -/// -/// Channels can use this to perform follow-up actions like -/// calling external APIs (e.g., WhatsApp mark_as_read). -/// This is optional - channels that don't need it can return Ok. -/// -/// Arguments: -/// - metadata-json: The metadata from the persisted message -/// -/// Returns: -/// - Ok: Post-persistence action completed successfully -/// - Err(string): Action failure message (does not block the ACK) -on-message-persisted: func(metadata-json: string) -> result<_, string>; -``` - -- [ ] **Step 2: Regenerate WIT bindings** - -Run: `cargo build -p wit-bindgen 2>&1 || echo "If wit-bindgen not separate, build will regenerate on next compile"` - -Then run: `cargo build` to trigger binding regeneration. - -- [ ] **Step 3: Add implementation to wrapper.rs** - -```rust -// In src/channels/wasm/wrapper.rs, add method to WasmChannel: - -/// Execute the on_message_persisted callback. -/// -/// Called after a message has been successfully persisted to the database. -/// Channels can use this for follow-up actions like WhatsApp mark_as_read. -/// -/// Returns Ok(()) even on failure - this is best-effort and should not block ACKs. -pub async fn call_on_message_persisted( - &self, - metadata_json: &str, -) -> Result<(), WasmChannelError> { - // If no WASM bytes, return Ok (for testing) - if self.prepared.component().is_none() { - tracing::debug!( - channel = %self.name, - "on_message_persisted called (no WASM module)" - ); - return Ok(()); - } - - let runtime = Arc::clone(&self.runtime); - let prepared = Arc::clone(&self.prepared); - let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); - let timeout = self.runtime.config().callback_timeout; - let credentials = self.get_credentials().await; - let pairing_store = self.pairing_store.clone(); - let metadata_json = metadata_json.to_string(); - let channel_name = self.name.clone(); - - let result = tokio::time::timeout(timeout, async move { - tokio::task::spawn_blocking(move || { - let mut store = Self::create_store( - &runtime, - &prepared, - &capabilities, - credentials, - Default::default(), // host_credentials not needed for this callback - pairing_store, - )?; - let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; - - let channel_iface = instance.near_agent_channel(); - channel_iface - .call_on_message_persisted(&mut store, &metadata_json) - .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; - - Ok::<_, WasmChannelError>(()) - }) - .await - .map_err(|e| WasmChannelError::ExecutionPanicked { - name: channel_name, - reason: e.to_string(), - })? - }) - .await; - - match result { - Ok(Ok(())) => { - tracing::debug!(channel = %self.name, "on_message_persisted completed"); - Ok(()) - } - Ok(Err(e)) => { - // Log but don't fail - this is best-effort - tracing::warn!(channel = %self.name, error = %e, "on_message_persisted failed"); - Ok(()) - } - Err(_timeout) => { - tracing::warn!(channel = %self.name, "on_message_persisted timed out"); - Ok(()) - } - } -} -``` - -- [ ] **Step 4: Test compilation** - -Run: `cargo check` -Expected: No compilation errors - -- [ ] **Step 5: Commit** - -```bash -git add wit/channel.wit src/channels/wasm/wrapper.rs -git commit -m "feat(wasm): add on_message_persisted callback to WIT interface - -Allows channels to perform follow-up actions after message persistence, -such as WhatsApp mark_as_read API calls. Best-effort execution - failures -are logged but do not block the ACK." -``` - ---- - -## Chunk 6: WhatsApp Channel Updates - -### Task 6.1: Update WhatsApp capabilities and implementation - -**Files:** -- Modify: `channels-src/whatsapp/whatsapp.capabilities.json` -- Modify: `channels-src/whatsapp/src/lib.rs` - -**Note:** API version stays at v18.0 (matching upstream/main) - only adding new fields. - -- [ ] **Step 1: Update capabilities file** - -```json -{ - "version": "0.2.0", - "wit_version": "0.3.0", - "type": "channel", - "name": "whatsapp", - "description": "WhatsApp Cloud API channel for receiving and responding to WhatsApp messages", - "setup": { - "required_secrets": [ - { - "name": "whatsapp_access_token", - "prompt": "Enter your WhatsApp Cloud API permanent access token (from the Meta Developer Portal under your app's WhatsApp > API Setup).", - "validation": "^[A-Za-z0-9_-]+$" - }, - { - "name": "whatsapp_verify_token", - "prompt": "Webhook verify token (leave empty to auto-generate)", - "optional": true, - "auto_generate": { "length": 32 } - }, - { - "name": "whatsapp_app_secret", - "prompt": "Enter your WhatsApp App Secret (from Meta Developer Portal > App Settings > Basic). Used for HMAC signature verification.", - "validation": "^[a-f0-9]{32}$", - "optional": true - } - ], - "validation_endpoint": "https://graph.facebook.com/v18.0/me?access_token={whatsapp_access_token}", - "setup_url": "https://developers.facebook.com/apps" - }, - "capabilities": { - "http": { - "allowlist": [ - { "host": "graph.facebook.com", "path_prefix": "/" } - ], - "rate_limit": { - "requests_per_minute": 80, - "requests_per_hour": 1000 - } - }, - "secrets": { - "allowed_names": ["whatsapp_*"] - }, - "channel": { - "allowed_paths": ["/webhook/whatsapp"], - "allow_polling": false, - "workspace_prefix": "channels/whatsapp/", - "emit_rate_limit": { - "messages_per_minute": 100, - "messages_per_hour": 5000 - }, - "webhook": { - "secret_header": "X-Hub-Signature-256", - "secret_name": "whatsapp_verify_token", - "verification_mode": "query_param", - "hmac_secret_name": "whatsapp_app_secret", - "message_id_json_pointer": "/message_id" - } - } - }, - "config": { - "api_version": "v18.0", - "reply_to_message": true, - "owner_id": null, - "dm_policy": "pairing", - "allow_from": [] - } -} -``` - -- [ ] **Step 2: Implement on_message_persisted in WhatsApp channel** - -```rust -// In channels-src/whatsapp/src/lib.rs, add to impl Guest: - -fn on_message_persisted(metadata_json: String) -> Result<(), String> { - channel_host::log( - channel_host::LogLevel::Debug, - "on_message_persisted callback invoked", - ); - - // Parse metadata to get message_id and phone_number_id - let metadata: WhatsAppMessageMetadata = match serde_json::from_str(&metadata_json) { - Ok(m) => m, - Err(e) => { - channel_host::log( - channel_host::LogLevel::Warn, - &format!("Failed to parse metadata in on_message_persisted: {}", e), - ); - // Don't fail the ACK - just log and return - return Ok(()); - } - }; - - // Skip if no message_id (shouldn't happen, but defensive) - if metadata.message_id.is_empty() { - channel_host::log( - channel_host::LogLevel::Debug, - "Skipping mark_as_read - no message_id in metadata", - ); - return Ok(()); - } - - // Read api_version from workspace (set during on_start), fallback to default - let api_version = channel_host::workspace_read("channels/whatsapp/api_version") - .filter(|s| !s.is_empty()) - .unwrap_or_else(|| "v18.0".to_string()); - - // Build WhatsApp mark_as_read API URL - let url = format!( - "https://graph.facebook.com/{}/{}/messages", - api_version, metadata.phone_number_id - ); - - // Build mark_as_read payload - let payload = serde_json::json!({ - "messaging_product": "whatsapp", - "status": "read", - "message_id": metadata.message_id - }); - - let payload_bytes = serde_json::to_vec(&payload) - .map_err(|e| format!("Failed to serialize mark_as_read payload: {}", e))?; - - // Headers with Bearer token placeholder - // Host will inject the actual access token - let headers = serde_json::json!({ - "Content-Type": "application/json", - "Authorization": "Bearer {WHATSAPP_ACCESS_TOKEN}" - }); - - channel_host::log( - channel_host::LogLevel::Debug, - &format!("Calling mark_as_read for message: {}", metadata.message_id), - ); - - let result = channel_host::http_request( - "POST", - &url, - &headers.to_string(), - Some(&payload_bytes), - None, - ); - - match result { - Ok(http_response) => { - if http_response.status >= 200 && http_response.status < 300 { - channel_host::log( - channel_host::LogLevel::Debug, - &format!("Marked message {} as read", metadata.message_id), - ); - } else { - let body_str = String::from_utf8_lossy(&http_response.body); - channel_host::log( - channel_host::LogLevel::Warn, - &format!( - "mark_as_read API error: {} - {}", - http_response.status, body_str - ), - ); - } - } - Err(e) => { - channel_host::log( - channel_host::LogLevel::Warn, - &format!("mark_as_read HTTP request failed: {}", e), - ); - } - } - - // Always return Ok - mark_as_read is best-effort - Ok(()) -} -``` - -- [ ] **Step 3: Build WASM** - -Run: `cargo build -p whatsapp --target wasm32-wasip2 --release` -Expected: Successful build - -- [ ] **Step 4: Commit** - -```bash -git add channels-src/whatsapp/whatsapp.capabilities.json channels-src/whatsapp/src/lib.rs -git commit -m "feat(whatsapp): add HMAC signature verification and mark_as_read - -- Add optional whatsapp_app_secret for HMAC verification -- Add verification_mode: query_param for GET/POST differentiation -- Add message_id_json_pointer for deduplication -- Implement on_message_persisted for mark_as_read API calls" -``` - ---- - -## Chunk 7: Main.rs Integration - -### Task 7.1: Wire router.set_db() on startup - -**Files:** -- Modify: `src/main.rs` - -**Context:** The router needs access to the database for webhook deduplication. This must be called during app initialization. - -- [ ] **Step 1: Find router initialization in main.rs** - -Search for where `wasm_channel_router` is created and passed to app state. - -- [ ] **Step 2: Add set_db() call after database initialization** - -```rust -// In src/main.rs, after database is initialized and before app starts, -// find where the router is available and add: - -// Wire database to router for webhook deduplication -if let Some(db) = &db { - let db_clone = db.clone(); - let router = &wasm_channel_router; // or however it's named - router.set_db(db_clone).await; - tracing::info!("Webhook deduplication enabled"); -} else { - tracing::warn!("Webhook deduplication disabled - no database configured"); -} -``` - -- [ ] **Step 3: Test compilation** - -Run: `cargo check` -Expected: No compilation errors - -- [ ] **Step 4: Commit** - -```bash -git add src/main.rs -git commit -m "feat: wire database to WASM channel router for webhook deduplication - -Calls router.set_db() during startup if database is available. -Logs warning if deduplication is disabled due to missing database." -``` - ---- - -## Chunk 8: Integration Tests - -### Task 8.1: Add integration tests - -**Files:** -- Modify: `tests/wasm_channel_integration.rs` - -- [ ] **Step 1: Add HMAC verification test** - -```rust -// In tests/wasm_channel_integration.rs, add: - -use crate::channels::wasm::signature::verify_hmac_sha256; - -#[test] -fn test_whatsapp_hmac_signature_verification() { - // Test vectors from WhatsApp documentation - let secret = "test_app_secret"; - let body = br#"{"entry":[{"id":"123456789","changes":[{"field":"messages","value":{"messages":[{"id":"wamid.HBgM..."}]}}]}]}"#; - - // Compute valid signature - use hmac::{Hmac, Mac}; - use sha2::Sha256; - let mut mac = Hmac::::new_from_slice(secret.as_bytes()).unwrap(); - mac.update(body); - let sig = format!("sha256={}", hex::encode(mac.finalize().into_bytes())); - - // Verify - assert!( - verify_hmac_sha256(secret, &sig, body), - "Valid signature should verify" - ); - - // Wrong secret - assert!( - !verify_hmac_sha256("wrong_secret", &sig, body), - "Wrong secret should fail" - ); - - // Tampered body - assert!( - !verify_hmac_sha256(secret, &sig, br#"{"tampered":"data"}"#), - "Tampered body should fail" - ); -} -``` - -- [ ] **Step 2: Run tests** - -Run: `cargo test wasm_channel_integration::test_whatsapp` -Expected: Test passes - -- [ ] **Step 3: Commit** - -```bash -git add tests/wasm_channel_integration.rs -git commit -m "test(wasm): add HMAC signature verification integration test" -``` - ---- - -## Final Verification - -- [ ] **Run full test suite with postgres** - -Run: `cargo test --features postgres` -Expected: All tests pass - -- [ ] **Run full test suite with libsql** - -Run: `cargo test --no-default-features --features libsql` -Expected: All tests pass - -- [ ] **Run clippy** - -Run: `cargo clippy --all --benches --tests --examples --all-features` -Expected: Zero warnings - -- [ ] **Run fmt** - -Run: `cargo fmt --check` -Expected: No formatting issues - ---- - -## Summary - -This plan delivers: - -| Feature | Description | Chunk | -|---------|-------------|-------| -| **WhatsApp HMAC** | `verify_hmac_sha256` for webhook signature verification | 1 | -| **Schema extensions** | `verification_mode`, `hmac_secret_name`, `message_id_json_pointer` | 2 | -| **Deduplication DB** | `WebhookDedupStore` trait + PostgreSQL + libSQL | 3 | -| **Router integration** | New fields in router, updated `register()` signature | 4 | -| **WIT callback** | `on_message_persisted` for post-persistence actions | 5 | -| **WhatsApp channel** | HMAC config + mark_as_read implementation | 6 | -| **Main.rs wiring** | Connect DB to router for deduplication | 7 | -| **Integration tests** | HMAC verification tests | 8 | - -**Estimated Effort:** 4-5 hours for experienced Rust developer - -**Dependencies:** -- Chunks 1-3 are independent and can be done in parallel -- Chunk 4 depends on chunks 1-3 -- Chunk 5 is independent -- Chunk 6 depends on chunks 2, 5 -- Chunk 7 depends on chunks 3, 4 -- Chunk 8 depends on chunk 1 - -**Breaking Changes:** None - all new fields are optional, `register()` signature extended with `Option` params. diff --git a/migrations/V13__webhook_dedup.sql b/migrations/V13__webhook_dedup.sql deleted file mode 100644 index 5fd190cd5a..0000000000 --- a/migrations/V13__webhook_dedup.sql +++ /dev/null @@ -1,19 +0,0 @@ --- Webhook message deduplication table --- Prevents duplicate processing when channels retry on errors - -CREATE TABLE IF NOT EXISTS webhook_message_dedup ( - -- Composite key: channel name + message ID from the channel - -- e.g., "whatsapp:wamid.HBgM..." or "telegram:12345" - key TEXT PRIMARY KEY, - - -- When this message was first seen - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Index for cleanup queries (delete old records) -CREATE INDEX IF NOT EXISTS idx_webhook_dedup_created_at - ON webhook_message_dedup(created_at); - --- Comment explaining purpose -COMMENT ON TABLE webhook_message_dedup IS - 'Deduplication table for webhook messages. Channels like WhatsApp retry on 5xx for up to 7 days. This table ensures idempotent processing.'; diff --git a/registry/channels/discord.json b/registry/channels/discord.json index 58f3dbdda8..b6532f4d34 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -3,7 +3,7 @@ "display_name": "Discord Channel", "kind": "channel", "version": "0.2.2", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Talk to your agent in Discord", "keywords": [ "messaging", diff --git a/registry/channels/slack.json b/registry/channels/slack.json index 3cc1ff739d..84340023e4 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -3,7 +3,7 @@ "display_name": "Slack Channel", "kind": "channel", "version": "0.2.2", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Talk to your agent in Slack", "keywords": [ "messaging", diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index bd07208f7d..f54004648e 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -3,7 +3,7 @@ "display_name": "Telegram Channel", "kind": "channel", "version": "0.2.4", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Talk to your agent through a Telegram bot", "keywords": [ "messaging", diff --git a/registry/channels/whatsapp.json b/registry/channels/whatsapp.json index 3831d4bb62..e3a9785f91 100644 --- a/registry/channels/whatsapp.json +++ b/registry/channels/whatsapp.json @@ -3,7 +3,7 @@ "display_name": "WhatsApp Channel", "kind": "channel", "version": "0.2.1", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Talk to your agent through WhatsApp", "keywords": [ "messaging", diff --git a/registry/tools/github.json b/registry/tools/github.json index e760c4df0a..4f0759bb44 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -3,7 +3,7 @@ "display_name": "GitHub", "kind": "tool", "version": "0.2.1", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ "git", diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index 08913ce697..b92025496b 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -3,7 +3,7 @@ "display_name": "Gmail", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Read, send, and manage Gmail messages and threads", "keywords": [ "email", diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index c43112d33b..cab0ad1484 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -3,7 +3,7 @@ "display_name": "Google Calendar", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Create, read, update, and delete Google Calendar events", "keywords": [ "calendar", diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 9f1ab133f0..0cf2395865 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -3,7 +3,7 @@ "display_name": "Google Docs", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Create and edit Google Docs documents", "keywords": [ "documents", diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index 9766e555d9..eb126c020c 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -3,7 +3,7 @@ "display_name": "Google Drive", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Upload, download, search, and manage Google Drive files and folders", "keywords": [ "storage", diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index b63265e1c8..5fe671b90a 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -3,7 +3,7 @@ "display_name": "Google Sheets", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Read and write Google Sheets spreadsheet data", "keywords": [ "spreadsheets", diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index 54187531f8..94e36c62f0 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -3,7 +3,7 @@ "display_name": "Google Slides", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Create and edit Google Slides presentations", "keywords": [ "presentations", diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index e4e9808c5f..ee8470c4b6 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -3,7 +3,7 @@ "display_name": "LLM Context", "kind": "tool", "version": "0.1.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)", "keywords": [ "search", diff --git a/registry/tools/slack.json b/registry/tools/slack.json index 8e1df98968..d52337ddc5 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -3,7 +3,7 @@ "display_name": "Slack Tool", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Your agent uses Slack to post and read messages in your workspace", "keywords": [ "messaging", diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index 12e58c684d..db62cc5da8 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -3,7 +3,7 @@ "display_name": "Telegram Tool", "kind": "tool", "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Your agent uses your Telegram account to read and send messages", "keywords": [ "messaging", diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 5c1dedefde..24000cb985 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -3,7 +3,7 @@ "display_name": "Web Search", "kind": "tool", "version": "0.2.1", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Search the web using Brave Search API", "keywords": [ "search", diff --git a/src/channels/wasm/loader.rs b/src/channels/wasm/loader.rs index 2a71f6cbb5..622e326f94 100644 --- a/src/channels/wasm/loader.rs +++ b/src/channels/wasm/loader.rs @@ -324,13 +324,6 @@ impl LoadedChannel { .as_ref() .and_then(|f| f.webhook_verification_mode().map(|s| s.to_string())) } - - /// Get the message ID JSON pointer from capabilities. - pub fn webhook_message_id_json_pointer(&self) -> Option { - self.capabilities_file - .as_ref() - .and_then(|f| f.webhook_message_id_json_pointer().map(|s| s.to_string())) - } } /// Results from loading multiple channels. diff --git a/src/channels/wasm/router.rs b/src/channels/wasm/router.rs index 0e32e7c1f8..122a30c824 100644 --- a/src/channels/wasm/router.rs +++ b/src/channels/wasm/router.rs @@ -9,7 +9,10 @@ use std::sync::Arc; /// Maximum time to wait for ACK from agent before returning HTTP response. /// If the agent doesn't persist the message within this time, the webhook /// returns 200 OK anyway (best-effort reliability). -const ACK_TIMEOUT_SECS: u64 = 30; +/// +/// WhatsApp Cloud API expects responses in 5-15 seconds, so we use 10 seconds +/// as a conservative value within that range. +const ACK_TIMEOUT_SECS: u64 = 10; use axum::{ Json, Router, @@ -53,10 +56,6 @@ pub struct WasmChannelRouter { hmac_secrets: RwLock>, /// Verification mode per channel: "query_param", "signature", etc. verification_modes: RwLock>, - /// JSON pointers for extracting message IDs from metadata_json by channel name. - message_id_json_pointers: RwLock>, - /// Database for webhook message deduplication (optional - graceful degradation if not set). - db: RwLock>>, /// Pending webhook ACKs - keyed by "channel:message_id", value is signaled when /// ack_message() is called after message persistence. pending_acks: RwLock>>, @@ -73,8 +72,6 @@ impl WasmChannelRouter { signature_keys: RwLock::new(HashMap::new()), hmac_secrets: RwLock::new(HashMap::new()), verification_modes: RwLock::new(HashMap::new()), - message_id_json_pointers: RwLock::new(HashMap::new()), - db: RwLock::new(None), pending_acks: RwLock::new(HashMap::new()), } } @@ -137,18 +134,14 @@ impl WasmChannelRouter { } } - /// Set the database for webhook message deduplication. + /// Clean up an orphaned pending ACK entry after timeout. /// - /// If not called, deduplication is disabled (webhooks process without idempotency check). - pub async fn set_db(&self, db: Arc) { - *self.db.write().await = Some(db); - } - - /// Get the database for webhook message deduplication. - /// - /// Returns None if deduplication is not configured. - pub async fn get_db(&self) -> Option> { - self.db.read().await.clone() + /// Called by the webhook handler when an ACK times out or the sender is dropped. + /// This prevents memory leaks from accumulating orphaned entries. + pub async fn cleanup_pending_ack(&self, key: &str) { + if self.pending_acks.write().await.remove(key).is_some() { + tracing::debug!(key = %key, "Cleaned up orphaned pending ACK"); + } } /// Register a channel with its endpoints. @@ -160,7 +153,6 @@ impl WasmChannelRouter { /// * `secret_header` - Optional HTTP header name for secret validation /// (e.g., "X-Telegram-Bot-Api-Secret-Token"). Defaults to "X-Webhook-Secret". /// * `verification_mode` - Optional verification mode: "query_param", "signature", etc. - /// * `message_id_json_pointer` - Optional JSON pointer to extract message ID from metadata. pub async fn register( &self, channel: Arc, @@ -168,7 +160,6 @@ impl WasmChannelRouter { secret: Option, secret_header: Option, verification_mode: Option, - message_id_json_pointer: Option, ) { let name = channel.channel_name().to_string(); @@ -205,11 +196,6 @@ impl WasmChannelRouter { .await .insert(name.clone(), m); } - - // Store message ID JSON pointer if provided - if let Some(p) = message_id_json_pointer { - self.message_id_json_pointers.write().await.insert(name, p); - } } /// Get the secret header name for a channel. @@ -247,10 +233,6 @@ impl WasmChannelRouter { self.signature_keys.write().await.remove(channel_name); self.hmac_secrets.write().await.remove(channel_name); self.verification_modes.write().await.remove(channel_name); - self.message_id_json_pointers - .write() - .await - .remove(channel_name); // Remove all paths for this channel self.path_to_channel @@ -360,17 +342,6 @@ impl WasmChannelRouter { .get(channel_name) .cloned() } - - /// Get the message ID JSON pointer for a channel. - /// - /// Returns `None` if no pointer is configured. - pub async fn get_message_id_json_pointer(&self, channel_name: &str) -> Option { - self.message_id_json_pointers - .read() - .await - .get(channel_name) - .cloned() - } } impl Default for WasmChannelRouter { @@ -708,10 +679,13 @@ async fn webhook_handler( { Ok((response, emitted_info)) => { // Register pending ACKs for emitted messages + // Track both keys and receivers so we can clean up timed-out entries + let mut ack_keys: Vec = Vec::new(); let mut ack_receivers: Vec> = Vec::new(); for (message_id, _metadata) in &emitted_info { let ack_key = format!("{}:{}", channel_name, message_id); - let rx = state.router.register_pending_ack(ack_key).await; + let rx = state.router.register_pending_ack(ack_key.clone()).await; + ack_keys.push(ack_key); ack_receivers.push(rx); // Metadata will be passed to ack_message() by the agent after persistence, @@ -755,11 +729,19 @@ async fn webhook_handler( let mut acked = 0; let mut timed_out = 0; - for result in ack_results { + for (i, result) in ack_results.into_iter().enumerate() { match result { Ok(Ok(())) => acked += 1, - Ok(Err(_)) => timed_out += 1, // Sender dropped - Err(_) => timed_out += 1, // Timeout + Ok(Err(_)) => { + // Sender dropped - clean up the orphaned entry + timed_out += 1; + state.router.cleanup_pending_ack(&ack_keys[i]).await; + } + Err(_) => { + // Timeout - clean up the orphaned entry + timed_out += 1; + state.router.cleanup_pending_ack(&ack_keys[i]).await; + } } } @@ -768,7 +750,7 @@ async fn webhook_handler( channel = %channel_name, acked = acked, timed_out = timed_out, - "Some webhook ACKs timed out" + "Some webhook ACKs timed out (cleaned up orphaned entries)" ); } else { tracing::debug!( @@ -928,7 +910,6 @@ mod tests { Some("secret123".to_string()), None, None, - None, ) .await; @@ -948,14 +929,7 @@ mod tests { let channel = create_test_channel("slack"); router - .register( - channel, - vec![], - Some("secret123".to_string()), - None, - None, - None, - ) + .register(channel, vec![], Some("secret123".to_string()), None, None) .await; // Correct secret @@ -966,9 +940,7 @@ mod tests { // Channel without secret always validates let channel2 = create_test_channel("telegram"); - router - .register(channel2, vec![], None, None, None, None) - .await; + router.register(channel2, vec![], None, None, None).await; assert!(router.validate_secret("telegram", "anything").await); } @@ -984,9 +956,7 @@ mod tests { require_secret: false, }]; - router - .register(channel, endpoints, None, None, None, None) - .await; + router.register(channel, endpoints, None, None, None).await; // Should exist assert!( @@ -1015,12 +985,8 @@ mod tests { let channel1 = create_test_channel("slack"); let channel2 = create_test_channel("telegram"); - router - .register(channel1, vec![], None, None, None, None) - .await; - router - .register(channel2, vec![], None, None, None, None) - .await; + router.register(channel1, vec![], None, None, None).await; + router.register(channel2, vec![], None, None, None).await; let channels = router.list_channels().await; assert_eq!(channels.len(), 2); @@ -1041,7 +1007,6 @@ mod tests { Some("secret123".to_string()), Some("X-Telegram-Bot-Api-Secret-Token".to_string()), None, - None, ) .await; @@ -1054,14 +1019,7 @@ mod tests { // Channel without custom header should use default let channel2 = create_test_channel("slack"); router - .register( - channel2, - vec![], - Some("secret456".to_string()), - None, - None, - None, - ) + .register(channel2, vec![], Some("secret456".to_string()), None, None) .await; assert_eq!(router.get_secret_header("slack").await, "X-Webhook-Secret"); } @@ -1073,9 +1031,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; let hmac_secret = "my-slack-signing-secret"; router.register_hmac_secret("slack", hmac_secret).await; @@ -1088,9 +1044,7 @@ mod tests { async fn test_no_hmac_secret_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Slack has no HMAC secret registered let secret = router.get_hmac_secret("slack").await; @@ -1109,9 +1063,7 @@ mod tests { require_secret: false, }]; - router - .register(channel, endpoints, None, None, None, None) - .await; + router.register(channel, endpoints, None, None, None).await; router.register_hmac_secret("slack", "signing-secret").await; // Secret should exist @@ -1131,9 +1083,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; let fake_pub_key = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"; router @@ -1149,9 +1099,7 @@ mod tests { async fn test_no_signature_key_returns_none() { let router = WasmChannelRouter::new(); let channel = create_test_channel("slack"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Slack has no signature key registered let key = router.get_signature_key("slack").await; @@ -1170,9 +1118,7 @@ mod tests { require_secret: false, }]; - router - .register(channel, endpoints, None, None, None, None) - .await; + router.register(channel, endpoints, None, None, None).await; // Use a valid 32-byte Ed25519 key for this test let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -1196,9 +1142,7 @@ mod tests { async fn test_register_valid_signature_key_succeeds() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Valid 32-byte Ed25519 public key (from test keypair) let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; @@ -1210,9 +1154,7 @@ mod tests { async fn test_register_invalid_hex_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; let result = router .register_signature_key("discord", "not-valid-hex-zzz") @@ -1224,9 +1166,7 @@ mod tests { async fn test_register_wrong_length_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // 16 bytes instead of 32 let short_key = hex::encode([0u8; 16]); @@ -1238,9 +1178,7 @@ mod tests { async fn test_register_empty_key_fails() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; let result = router.register_signature_key("discord", "").await; assert!(result.is_err(), "Empty key should be rejected"); @@ -1250,9 +1188,7 @@ mod tests { async fn test_valid_key_is_retrievable() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; let valid_key = "d75a980182b10ab7d54bfed3c964073a0ee172f3daa3f4a18446b7e8c7ac6602"; router @@ -1268,9 +1204,7 @@ mod tests { async fn test_invalid_key_does_not_store() { let router = WasmChannelRouter::new(); let channel = create_test_channel("discord"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Attempt to register invalid key let _ = router @@ -1305,7 +1239,7 @@ mod tests { }]; wasm_router - .register(channel, endpoints, None, None, None, None) + .register(channel, endpoints, None, None, None) .await; let app = create_wasm_channel_router(wasm_router.clone(), None); @@ -1539,7 +1473,6 @@ mod tests { Some("my-secret".to_string()), None, None, - None, ) .await; @@ -1599,7 +1532,7 @@ mod tests { }]; wasm_router - .register(channel, endpoints, None, None, None, None) + .register(channel, endpoints, None, None, None) .await; let app = create_wasm_channel_router(wasm_router.clone(), None); @@ -1819,7 +1752,6 @@ mod tests { Some("verify_token_123".to_string()), Some("X-Hub-Signature-256".to_string()), Some("query_param".to_string()), - Some("/metadata/message_id".to_string()), ) .await; @@ -1882,9 +1814,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("test"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Register pending ACK let key = "test:message123".to_string(); @@ -1912,9 +1842,7 @@ mod tests { let router = WasmChannelRouter::new(); let channel = create_test_channel("test"); - router - .register(channel, vec![], None, None, None, None) - .await; + router.register(channel, vec![], None, None, None).await; // Register pending ACK let key = "test:message123".to_string(); diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index 01a72dfd5f..820d2bbdab 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -199,19 +199,6 @@ impl ChannelCapabilitiesFile { .and_then(|c| c.webhook.as_ref()) .and_then(|w| w.verification_mode.as_deref()) } - - /// Get the JSON pointer path to extract message ID from metadata. - /// - /// Returns the JSON pointer declared in `webhook.message_id_json_pointer`, - /// used for ACK key construction and deduplication. - /// If None, the router falls back to using user_id. - pub fn webhook_message_id_json_pointer(&self) -> Option<&str> { - self.capabilities - .channel - .as_ref() - .and_then(|c| c.webhook.as_ref()) - .and_then(|w| w.message_id_json_pointer.as_deref()) - } } /// Schema for channel capabilities. @@ -337,13 +324,6 @@ pub struct WebhookSchema { /// - "signature": Always require signature validation (for Discord-style Ed25519) #[serde(default)] pub verification_mode: Option, - - /// JSON pointer path to extract message ID from metadata_json. - /// Used for ACK key construction and deduplication. - /// Format: "/field1/field2" to access {"field1": {"field2": "value"}} - /// If None, the router falls back to using user_id. - #[serde(default)] - pub message_id_json_pointer: Option, } /// Setup configuration schema. @@ -882,21 +862,4 @@ mod tests { let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); assert_eq!(cap.hmac_secret_name(), Some("whatsapp_app_secret")); } - - #[test] - fn test_webhook_message_id_json_pointer_parsing() { - let json = r#"{ - "name": "test", - "capabilities": { - "channel": { - "webhook": { - "message_id_json_pointer": "/message_id" - } - } - } - }"#; - - let cap: ChannelCapabilitiesFile = serde_json::from_str(json).unwrap(); - assert_eq!(cap.webhook_message_id_json_pointer(), Some("/message_id")); - } } diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index 937e854d6b..91007a054c 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -140,9 +140,8 @@ async fn register_channel( let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); - // Extract verification mode and message ID JSON pointer before moving loaded.channel + // Extract verification mode before moving loaded.channel let verification_mode = loaded.webhook_verification_mode(); - let message_id_json_pointer = loaded.webhook_message_id_json_pointer(); let webhook_path = format!("/webhook/{}", channel_name); let endpoints = vec![RegisteredEndpoint { @@ -221,7 +220,6 @@ async fn register_channel( webhook_secret.clone(), secret_header, verification_mode, - message_id_json_pointer, ) .await; diff --git a/src/db/libsql/mod.rs b/src/db/libsql/mod.rs index 70b035ed1d..d19089c102 100644 --- a/src/db/libsql/mod.rs +++ b/src/db/libsql/mod.rs @@ -425,44 +425,6 @@ pub(crate) fn row_to_routine_run_libsql(row: &libsql::Row) -> Result Result { - let key = format!("{}:{}", channel_name, message_id); - let conn = self.connect().await?; - - let rows_affected = conn - .execute( - "INSERT OR IGNORE INTO webhook_message_dedup (key) VALUES (?1)", - [libsql::Value::from(key)], - ) - .await - .map_err(|e| DatabaseError::Query(e.to_string()))?; - - Ok(rows_affected == 1) - } - - async fn cleanup_old_webhook_dedup_records(&self) -> Result { - let conn = self.connect().await?; - - let rows_affected = conn - .execute( - "DELETE FROM webhook_message_dedup WHERE created_at < datetime('now', '-30 days')", - (), - ) - .await - .map_err(|e| DatabaseError::Query(e.to_string()))?; - - Ok(rows_affected) - } -} - #[cfg(test)] mod tests { use chrono::{TimeZone, Utc}; diff --git a/src/db/mod.rs b/src/db/mod.rs index 2259c5117d..6d2eb2960c 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -638,29 +638,6 @@ pub trait WorkspaceStore: Send + Sync { ) -> Result, WorkspaceError>; } -/// Webhook message deduplication store. -/// -/// Prevents duplicate processing when channels retry webhooks on errors. -/// WhatsApp, for example, retries for up to 7 days on 5xx responses. -#[async_trait] -pub trait WebhookDedupStore: Send + Sync { - /// Try to record that a message is processed, atomically. - /// - /// Returns `true` if this is a new message (was inserted), - /// `false` if it was a duplicate (key already exists). - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result; - - /// Clean up old dedup records. - /// - /// Called periodically to prevent unbounded growth. - /// Returns the number of records deleted. - async fn cleanup_old_webhook_dedup_records(&self) -> Result; -} - /// Backend-agnostic database supertrait. /// /// Combines all sub-traits into one. Existing `Arc` consumers @@ -674,7 +651,6 @@ pub trait Database: + ToolFailureStore + SettingsStore + WorkspaceStore - + WebhookDedupStore + Send + Sync { diff --git a/src/db/postgres.rs b/src/db/postgres.rs index 0a7ca36330..05ccdef0a1 100644 --- a/src/db/postgres.rs +++ b/src/db/postgres.rs @@ -708,44 +708,6 @@ impl WorkspaceStore for PgBackend { } } -// ==================== WebhookDedupStore ==================== - -#[async_trait] -impl crate::db::WebhookDedupStore for PgBackend { - async fn record_webhook_message_processed( - &self, - channel_name: &str, - message_id: &str, - ) -> Result { - let key = format!("{}:{}", channel_name, message_id); - let conn = self.store.conn().await?; - - let rows_affected = conn - .execute( - "INSERT INTO webhook_message_dedup (key) VALUES ($1) ON CONFLICT (key) DO NOTHING", - &[&key], - ) - .await - .map_err(|e| DatabaseError::Query(e.to_string()))?; - - Ok(rows_affected == 1) - } - - async fn cleanup_old_webhook_dedup_records(&self) -> Result { - let conn = self.store.conn().await?; - - let rows_affected = conn - .execute( - "DELETE FROM webhook_message_dedup WHERE created_at < NOW() - INTERVAL '30 days'", - &[], - ) - .await - .map_err(|e| DatabaseError::Query(e.to_string()))?; - - Ok(rows_affected) - } -} - // ==================== Tests ==================== // Webhook dedup tests are in the libsql module which supports in-memory testing. // PostgreSQL tests require a running database with migrations applied. diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 66636ecc5c..4ee3f050cf 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -3595,7 +3595,6 @@ impl ExtensionManager { webhook_secret, secret_header, None, // verification_mode - not supported for hot-activated channels yet - None, // message_id_json_pointer - not supported yet ) .await; tracing::info!(channel = %channel_name, "Registered hot-activated channel with webhook router"); diff --git a/tests/wasm_channel_integration.rs b/tests/wasm_channel_integration.rs index ec5038f5e8..dfc21fc500 100644 --- a/tests/wasm_channel_integration.rs +++ b/tests/wasm_channel_integration.rs @@ -72,7 +72,7 @@ mod router_tests { }]; router - .register(channel.clone(), endpoints, None, None, None, None) + .register(channel.clone(), endpoints, None, None, None) .await; // Verify channel is found by path @@ -103,7 +103,6 @@ mod router_tests { Some("my-secret-123".to_string()), None, None, - None, ) .await; @@ -143,9 +142,7 @@ mod router_tests { require_secret: false, }]; - router - .register(channel, endpoints, None, None, None, None) - .await; + router.register(channel, endpoints, None, None, None).await; // Channel exists assert!(router.get_channel_for_path("/webhook/temp").await.is_some()); @@ -177,9 +174,7 @@ mod router_tests { require_secret: false, }]; - router - .register(channel, endpoints, None, None, None, None) - .await; + router.register(channel, endpoints, None, None, None).await; } // Verify all channels are registered @@ -577,7 +572,6 @@ mod hmac_signature_tests { Some("verify_token_123".to_string()), Some("X-Hub-Signature-256".to_string()), Some("query_param".to_string()), - Some("/metadata/message_id".to_string()), ) .await; @@ -597,11 +591,5 @@ mod hmac_signature_tests { router.get_verification_mode("whatsapp").await, Some("query_param".to_string()) ); - - // Verify message ID pointer is stored - assert_eq!( - router.get_message_id_json_pointer("whatsapp").await, - Some("/metadata/message_id".to_string()) - ); } } diff --git a/tools-src/github/github-tool.capabilities.json b/tools-src/github/github-tool.capabilities.json index 61bbd55fff..b24148ff78 100644 --- a/tools-src/github/github-tool.capabilities.json +++ b/tools-src/github/github-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.1", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "capabilities": { "webhook": { "hmac_secret_name": "github_webhook_secret", diff --git a/tools-src/gmail/gmail-tool.capabilities.json b/tools-src/gmail/gmail-tool.capabilities.json index 2e11d32b7c..54a80a223c 100644 --- a/tools-src/gmail/gmail-tool.capabilities.json +++ b/tools-src/gmail/gmail-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/google-calendar/google-calendar-tool.capabilities.json b/tools-src/google-calendar/google-calendar-tool.capabilities.json index 15e756aeee..c2ccf906d3 100644 --- a/tools-src/google-calendar/google-calendar-tool.capabilities.json +++ b/tools-src/google-calendar/google-calendar-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/google-docs/google-docs-tool.capabilities.json b/tools-src/google-docs/google-docs-tool.capabilities.json index 7a365c1d07..2c60c389b1 100644 --- a/tools-src/google-docs/google-docs-tool.capabilities.json +++ b/tools-src/google-docs/google-docs-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/google-drive/google-drive-tool.capabilities.json b/tools-src/google-drive/google-drive-tool.capabilities.json index 5366793374..0ab617e853 100644 --- a/tools-src/google-drive/google-drive-tool.capabilities.json +++ b/tools-src/google-drive/google-drive-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/google-sheets/google-sheets-tool.capabilities.json b/tools-src/google-sheets/google-sheets-tool.capabilities.json index 624c43810e..f693b46852 100644 --- a/tools-src/google-sheets/google-sheets-tool.capabilities.json +++ b/tools-src/google-sheets/google-sheets-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/google-slides/google-slides-tool.capabilities.json b/tools-src/google-slides/google-slides-tool.capabilities.json index 17334bc09c..2d0d1fa915 100644 --- a/tools-src/google-slides/google-slides-tool.capabilities.json +++ b/tools-src/google-slides/google-slides-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/llm-context/llm-context-tool.capabilities.json b/tools-src/llm-context/llm-context-tool.capabilities.json index 72061eaa5d..04081a6b5c 100644 --- a/tools-src/llm-context/llm-context-tool.capabilities.json +++ b/tools-src/llm-context/llm-context-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.1.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "capabilities": { "http": { "allowlist": [ diff --git a/tools-src/slack/slack-tool.capabilities.json b/tools-src/slack/slack-tool.capabilities.json index 8b9060d7ae..74a9e68f08 100644 --- a/tools-src/slack/slack-tool.capabilities.json +++ b/tools-src/slack/slack-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/telegram/telegram-tool.capabilities.json b/tools-src/telegram/telegram-tool.capabilities.json index 665baedd56..18ea5cf468 100644 --- a/tools-src/telegram/telegram-tool.capabilities.json +++ b/tools-src/telegram/telegram-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "http": { "allowlist": [ { diff --git a/tools-src/web-search/web-search-tool.capabilities.json b/tools-src/web-search/web-search-tool.capabilities.json index 9c2559ab52..c02cbc5ed6 100644 --- a/tools-src/web-search/web-search-tool.capabilities.json +++ b/tools-src/web-search/web-search-tool.capabilities.json @@ -1,6 +1,6 @@ { "version": "0.2.0", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Search the web using Brave Search. Returns titles, URLs, descriptions, and publication dates for matching web pages. Supports filtering by country, language, and freshness. Authentication is handled via the 'brave_api_key' secret injected by the host.", "parameters": { "type": "object", From a919e40e9adf10a794ca63665da7ba383d4b500c Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Mon, 16 Mar 2026 09:06:55 +0100 Subject: [PATCH 12/18] chore: bump tool registry versions after source changes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump patch versions for tools with modified source files to pass CI version bump check. - github: 0.2.1 → 0.2.2 - gmail: 0.2.0 → 0.2.1 - google-calendar: 0.2.0 → 0.2.1 - google-docs: 0.2.0 → 0.2.1 - google-drive: 0.2.0 → 0.2.1 - google-sheets: 0.2.0 → 0.2.1 - google-slides: 0.2.0 → 0.2.1 - llm-context: 0.1.0 → 0.1.1 - slack: 0.2.0 → 0.2.1 - telegram: 0.2.0 → 0.2.1 - web-search: 0.2.1 → 0.2.2 Co-Authored-By: Claude Opus 4.6 --- registry/tools/github.json | 2 +- registry/tools/gmail.json | 2 +- registry/tools/google-calendar.json | 2 +- registry/tools/google-docs.json | 2 +- registry/tools/google-drive.json | 2 +- registry/tools/google-sheets.json | 2 +- registry/tools/google-slides.json | 2 +- registry/tools/llm-context.json | 2 +- registry/tools/slack.json | 2 +- registry/tools/telegram.json | 2 +- registry/tools/web-search.json | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/registry/tools/github.json b/registry/tools/github.json index 4f0759bb44..d13ad8870a 100644 --- a/registry/tools/github.json +++ b/registry/tools/github.json @@ -2,7 +2,7 @@ "name": "github", "display_name": "GitHub", "kind": "tool", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.4.0", "description": "GitHub integration for issues, PRs, repos, and code search", "keywords": [ diff --git a/registry/tools/gmail.json b/registry/tools/gmail.json index b92025496b..169c991d0a 100644 --- a/registry/tools/gmail.json +++ b/registry/tools/gmail.json @@ -2,7 +2,7 @@ "name": "gmail", "display_name": "Gmail", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Read, send, and manage Gmail messages and threads", "keywords": [ diff --git a/registry/tools/google-calendar.json b/registry/tools/google-calendar.json index cab0ad1484..62f1d90d46 100644 --- a/registry/tools/google-calendar.json +++ b/registry/tools/google-calendar.json @@ -2,7 +2,7 @@ "name": "google-calendar", "display_name": "Google Calendar", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Create, read, update, and delete Google Calendar events", "keywords": [ diff --git a/registry/tools/google-docs.json b/registry/tools/google-docs.json index 0cf2395865..ef5ac3ee26 100644 --- a/registry/tools/google-docs.json +++ b/registry/tools/google-docs.json @@ -2,7 +2,7 @@ "name": "google-docs", "display_name": "Google Docs", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Create and edit Google Docs documents", "keywords": [ diff --git a/registry/tools/google-drive.json b/registry/tools/google-drive.json index eb126c020c..7e88e42af5 100644 --- a/registry/tools/google-drive.json +++ b/registry/tools/google-drive.json @@ -2,7 +2,7 @@ "name": "google-drive", "display_name": "Google Drive", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Upload, download, search, and manage Google Drive files and folders", "keywords": [ diff --git a/registry/tools/google-sheets.json b/registry/tools/google-sheets.json index 5fe671b90a..3f4fca2565 100644 --- a/registry/tools/google-sheets.json +++ b/registry/tools/google-sheets.json @@ -2,7 +2,7 @@ "name": "google-sheets", "display_name": "Google Sheets", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Read and write Google Sheets spreadsheet data", "keywords": [ diff --git a/registry/tools/google-slides.json b/registry/tools/google-slides.json index 94e36c62f0..29a211096f 100644 --- a/registry/tools/google-slides.json +++ b/registry/tools/google-slides.json @@ -2,7 +2,7 @@ "name": "google-slides", "display_name": "Google Slides", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Create and edit Google Slides presentations", "keywords": [ diff --git a/registry/tools/llm-context.json b/registry/tools/llm-context.json index ee8470c4b6..0d3bcb6b98 100644 --- a/registry/tools/llm-context.json +++ b/registry/tools/llm-context.json @@ -2,7 +2,7 @@ "name": "llm-context", "display_name": "LLM Context", "kind": "tool", - "version": "0.1.0", + "version": "0.1.1", "wit_version": "0.4.0", "description": "Fetch pre-extracted web content from Brave Search for grounding LLM answers (RAG, fact-checking)", "keywords": [ diff --git a/registry/tools/slack.json b/registry/tools/slack.json index d52337ddc5..7e0e9e2d6b 100644 --- a/registry/tools/slack.json +++ b/registry/tools/slack.json @@ -2,7 +2,7 @@ "name": "slack-tool", "display_name": "Slack Tool", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Your agent uses Slack to post and read messages in your workspace", "keywords": [ diff --git a/registry/tools/telegram.json b/registry/tools/telegram.json index db62cc5da8..627709e0d6 100644 --- a/registry/tools/telegram.json +++ b/registry/tools/telegram.json @@ -2,7 +2,7 @@ "name": "telegram-mtproto", "display_name": "Telegram Tool", "kind": "tool", - "version": "0.2.0", + "version": "0.2.1", "wit_version": "0.4.0", "description": "Your agent uses your Telegram account to read and send messages", "keywords": [ diff --git a/registry/tools/web-search.json b/registry/tools/web-search.json index 24000cb985..caf13b3814 100644 --- a/registry/tools/web-search.json +++ b/registry/tools/web-search.json @@ -2,7 +2,7 @@ "name": "web-search", "display_name": "Web Search", "kind": "tool", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.4.0", "description": "Search the web using Brave Search API", "keywords": [ From c64cd71317a56325fce34e1735129e04081434f4 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 09:01:18 +0100 Subject: [PATCH 13/18] refactor(wasm): decouple WASM router from AgentDeps via OnMessagePersisted hook Replace direct wasm_router dependency in AgentDeps with a new OnMessagePersisted hook point. The agent core no longer needs knowledge of WASM channel internals. Changes: - Add OnMessagePersisted to HookPoint enum and MessagePersisted event - Create MessagePersistedHook in WASM module to handle ACK signaling - Remove wasm_router field from AgentDeps and all test fixtures - Update thread_ops.rs to fire hook instead of calling router directly - Register MessagePersistedHook in main.rs after WASM runtime setup This follows the existing hook pattern for extensibility and zero coupling. The hook is fire-and-forget (async, non-blocking) and other subsystems can also listen to persistence events. Fixes architectural coupling issue raised in PR #1207 review. Co-Authored-By: Claude Opus 4.6 --- src/agent/agent_loop.rs | 8 -- src/agent/dispatcher.rs | 3 - src/agent/thread_ops.rs | 19 ++-- src/channels/wasm/hook.rs | 131 ++++++++++++++++++++++ src/channels/wasm/mod.rs | 2 + src/hooks/bundled.rs | 21 +++- src/hooks/hook.rs | 15 +++ src/hooks/registry.rs | 7 ++ src/main.rs | 14 ++- src/testing/mod.rs | 1 - tests/support/gateway_workflow_harness.rs | 1 - tests/support/test_rig.rs | 1 - 12 files changed, 193 insertions(+), 30 deletions(-) create mode 100644 src/channels/wasm/hook.rs diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 4184da8b52..83d971ef1a 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -146,9 +146,6 @@ pub struct AgentDeps { pub transcription: Option>, /// Document text extraction middleware for PDF, DOCX, PPTX, etc. pub document_extraction: Option>, - /// WASM channel router for webhook ACK signaling. - /// When set, persist_user_message will call ack_message() after persistence. - pub wasm_router: Option>, } /// The main agent that coordinates all components. @@ -254,11 +251,6 @@ impl Agent { self.deps.store.as_ref() } - /// Get the WASM channel router for ACK signaling. - pub(super) fn wasm_router(&self) -> Option<&Arc> { - self.deps.wasm_router.as_ref() - } - pub(super) fn llm(&self) -> &Arc { &self.deps.llm } diff --git a/src/agent/dispatcher.rs b/src/agent/dispatcher.rs index 62ec4ebc0c..9be0d654d1 100644 --- a/src/agent/dispatcher.rs +++ b/src/agent/dispatcher.rs @@ -1197,7 +1197,6 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, - wasm_router: None, }; Agent::new( @@ -2038,7 +2037,6 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, - wasm_router: None, }; Agent::new( @@ -2157,7 +2155,6 @@ mod tests { http_interceptor: None, transcription: None, document_extraction: None, - wasm_router: None, }; Agent::new( diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 7284b9bb5a..273f65e379 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -609,15 +609,16 @@ impl Agent { return; } - // Signal ACK to WASM channel router after successful persistence - if let Some(router) = self.wasm_router() { - let ack_key = format!("{}:{}", channel, message_id); - let metadata_json = metadata.to_string(); - tracing::debug!( - ack_key = %ack_key, - "Signaling ACK to WASM channel router after message persistence" - ); - router.ack_message(&ack_key, &metadata_json).await; + // Signal ACK to WASM channels via hook after successful persistence + use crate::hooks::hook::HookEvent; + let hooks = self.hooks(); + let event = HookEvent::MessagePersisted { + channel: channel.to_string(), + message_id: message_id.to_string(), + metadata: metadata.clone(), + }; + if let Err(e) = hooks.run(&event).await { + tracing::warn!("MessagePersisted hook failed: {}", e); } } diff --git a/src/channels/wasm/hook.rs b/src/channels/wasm/hook.rs new file mode 100644 index 0000000000..baaf9eea52 --- /dev/null +++ b/src/channels/wasm/hook.rs @@ -0,0 +1,131 @@ +//! Hook for signaling message persistence ACK to WASM channels. +//! +//! This hook is registered by the WASM channel subsystem to listen for +//! `OnMessagePersisted` events. When a user message is successfully persisted +//! to the database, this hook signals the WASM router, which then: +//! 1. Unblocks any pending webhook handlers waiting for ACK +//! 2. Calls the `on_message_persisted` callback on the appropriate WASM channel +//! +//! # Performance Consideration +//! +//! The hook serializes the entire `metadata` JSON object on every message +//! persistence. This is required by the WASM `on_message_persisted` interface +//! which expects a JSON string. For most messages, metadata is small (a few +//! fields like `message_id`, `chat_type`, etc.). If metadata grows large +//! (hundreds of KB), this could impact throughput. +//! +//! The serialization is synchronous in the hook execution path but runs +//! asynchronously via the hook registry, so it doesn't block message +//! persistence itself. + +use super::WasmChannelRouter; +use crate::hooks::hook::{Hook, HookContext, HookError, HookEvent, HookOutcome, HookPoint}; +use async_trait::async_trait; +use std::sync::Arc; + +pub struct MessagePersistedHook { + router: Arc, +} + +impl MessagePersistedHook { + pub fn new(router: Arc) -> Self { + Self { router } + } +} + +#[async_trait] +impl Hook for MessagePersistedHook { + fn name(&self) -> &str { + "wasm_message_persisted" + } + + fn hook_points(&self) -> &[HookPoint] { + &[HookPoint::OnMessagePersisted] + } + + async fn execute( + &self, + event: &HookEvent, + _ctx: &HookContext, + ) -> Result { + if let HookEvent::MessagePersisted { + channel, + message_id, + metadata, + } = event + { + let ack_key = format!("{}:{}", channel, message_id); + let metadata_json = metadata.to_string(); + tracing::debug!(ack_key = %ack_key, "Signaling ACK via hook"); + self.router.ack_message(&ack_key, &metadata_json).await; + } + Ok(HookOutcome::ok()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::hooks::hook::HookPoint; + use serde_json::json; + + #[test] + fn test_hook_name() { + let router = WasmChannelRouter::new(); + let hook = MessagePersistedHook::new(Arc::new(router)); + assert_eq!(hook.name(), "wasm_message_persisted"); + } + + #[test] + fn test_hook_points() { + let router = WasmChannelRouter::new(); + let hook = MessagePersistedHook::new(Arc::new(router)); + assert_eq!(hook.hook_points(), &[HookPoint::OnMessagePersisted]); + } + + #[test] + fn test_hook_formats_ack_key_correctly() { + // Test that the hook formats the ack_key as "channel:message_id" + let channel = "test-channel"; + let message_id = "msg-123"; + let expected_key = format!("{}:{}", channel, message_id); + assert_eq!(expected_key, "test-channel:msg-123"); + } + + #[tokio::test] + async fn test_hook_returns_ok_even_for_non_message_persisted_events() { + // Test that hook handles non-MessagePersisted events gracefully + let router = WasmChannelRouter::new(); + let hook = MessagePersistedHook::new(Arc::new(router)); + + // Create a different event type (Inbound) + let event = HookEvent::Inbound { + user_id: "user-1".to_string(), + channel: "test".to_string(), + content: "hello".to_string(), + thread_id: None, + }; + let ctx = HookContext::default(); + + // Hook should return ok() even though it ignores non-MessagePersisted events + let result = hook.execute(&event, &ctx).await; + assert!(result.is_ok()); + assert!(matches!(result.unwrap(), HookOutcome::Continue { modified: None })); + } + + #[tokio::test] + async fn test_hook_serializes_metadata_to_json() { + // Test that metadata is correctly serialized to JSON + let metadata = json!({ + "message_id": "msg-123", + "chat_type": "group", + "timestamp": 1234567890 + }); + let metadata_json = metadata.to_string(); + + // Verify the JSON is valid and contains expected fields + assert!(metadata_json.contains("msg-123")); + assert!(metadata_json.contains("group")); + assert!(metadata_json.contains("1234567890")); + } +} diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 1e3f8e485f..ae5bc2ff04 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -81,6 +81,7 @@ mod bundled; mod capabilities; mod error; +mod hook; mod host; mod loader; mod router; @@ -97,6 +98,7 @@ mod wrapper; pub use bundled::{available_channel_names, bundled_channel_names, install_bundled_channel}; pub use capabilities::{ChannelCapabilities, EmitRateLimitConfig, HttpEndpointConfig, PollConfig}; pub use error::WasmChannelError; +pub use hook::MessagePersistedHook; pub use host::{ChannelEmitRateLimiter, ChannelHostState, EmittedMessage}; pub use loader::{ DiscoveredChannel, LoadResults, LoadedChannel, WasmChannelLoader, default_channels_dir, diff --git a/src/hooks/bundled.rs b/src/hooks/bundled.rs index 9ca1fe9299..1eb0c4399d 100644 --- a/src/hooks/bundled.rs +++ b/src/hooks/bundled.rs @@ -21,13 +21,14 @@ const DEFAULT_WEBHOOK_TIMEOUT_MS: u64 = 2000; const DEFAULT_WEBHOOK_MAX_IN_FLIGHT: usize = 32; const MAX_HOOK_TIMEOUT_MS: u64 = 30_000; -const ALL_HOOK_POINTS: [HookPoint; 6] = [ +const ALL_HOOK_POINTS: [HookPoint; 7] = [ HookPoint::BeforeInbound, HookPoint::BeforeToolCall, HookPoint::BeforeOutbound, HookPoint::OnSessionStart, HookPoint::OnSessionEnd, HookPoint::TransformResponse, + HookPoint::OnMessagePersisted, ]; /// Errors while parsing or compiling declarative hook bundles. @@ -515,6 +516,10 @@ enum OutboundWebhookEventSummary { ResponseTransform { response_length: usize, }, + MessagePersisted { + channel: String, + message_id: String, + }, } #[async_trait] @@ -634,6 +639,14 @@ fn summarize_webhook_event(event: &HookEvent) -> OutboundWebhookEventSummary { response_length: response.len(), } } + HookEvent::MessagePersisted { + channel, + message_id, + .. + } => OutboundWebhookEventSummary::MessagePersisted { + channel: channel.clone(), + message_id: message_id.clone(), + }, } } @@ -880,6 +893,7 @@ fn event_user_id(event: &HookEvent) -> &str { | HookEvent::SessionStart { user_id, .. } | HookEvent::SessionEnd { user_id, .. } | HookEvent::ResponseTransform { user_id, .. } => user_id, + HookEvent::MessagePersisted { .. } => "", } } @@ -893,6 +907,11 @@ fn extract_primary_content(event: &HookEvent) -> String { session_id.clone() } HookEvent::ResponseTransform { response, .. } => response.clone(), + HookEvent::MessagePersisted { + channel, + message_id, + .. + } => format!("{}:{}", channel, message_id), } } diff --git a/src/hooks/hook.rs b/src/hooks/hook.rs index 9c5670f3d7..e7d00328c0 100644 --- a/src/hooks/hook.rs +++ b/src/hooks/hook.rs @@ -21,6 +21,10 @@ pub enum HookPoint { OnSessionEnd, /// Transform the final response before completing a turn. TransformResponse, + /// After a user message is persisted to the database. + /// Fired after successful DB persistence. Used by WASM channels to signal + /// ACK for webhooks (e.g., WhatsApp mark_as_read callback). + OnMessagePersisted, } impl HookPoint { @@ -33,6 +37,7 @@ impl HookPoint { HookPoint::OnSessionStart => "onSessionStart", HookPoint::OnSessionEnd => "onSessionEnd", HookPoint::TransformResponse => "transformResponse", + HookPoint::OnMessagePersisted => "onMessagePersisted", } } } @@ -72,6 +77,12 @@ pub enum HookEvent { thread_id: String, response: String, }, + /// A user message was persisted to the database. + MessagePersisted { + channel: String, + message_id: String, + metadata: serde_json::Value, + }, } impl HookEvent { @@ -84,6 +95,7 @@ impl HookEvent { HookEvent::SessionStart { .. } => HookPoint::OnSessionStart, HookEvent::SessionEnd { .. } => HookPoint::OnSessionEnd, HookEvent::ResponseTransform { .. } => HookPoint::TransformResponse, + HookEvent::MessagePersisted { .. } => HookPoint::OnMessagePersisted, } } @@ -108,6 +120,9 @@ impl HookEvent { HookEvent::SessionStart { .. } | HookEvent::SessionEnd { .. } => { // Session events don't have modifiable content } + HookEvent::MessagePersisted { .. } => { + // MessagePersisted events don't have modifiable content + } } } } diff --git a/src/hooks/registry.rs b/src/hooks/registry.rs index d20788bbd6..13058631b4 100644 --- a/src/hooks/registry.rs +++ b/src/hooks/registry.rs @@ -179,6 +179,13 @@ fn extract_content(event: &HookEvent) -> String { HookEvent::SessionStart { session_id, .. } | HookEvent::SessionEnd { session_id, .. } => { session_id.clone() } + HookEvent::MessagePersisted { + channel, + message_id, + .. + } => { + format!("{}:{}", channel, message_id) + } } } diff --git a/src/main.rs b/src/main.rs index a0c6b403b1..44df32379e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -349,6 +349,14 @@ async fn async_main() -> anyhow::Result<()> { } } + // Register WASM message persisted hook if WASM channels are loaded + if let Some(ref state) = wasm_channel_runtime_state { + use ironclaw::channels::wasm::MessagePersistedHook; + let hook = std::sync::Arc::new(MessagePersistedHook::new(Arc::clone(&state.2))); + components.hooks.register(hook).await; + tracing::debug!("Registered MessagePersisted hook for WASM channel ACK signaling"); + } + // Add Signal channel if configured and not CLI-only mode. if !cli.cli_only && let Some(ref signal_config) = config.channels.signal @@ -623,11 +631,6 @@ async fn async_main() -> anyhow::Result<()> { .register_message_tools(Arc::clone(&channels), components.extension_manager.clone()) .await; - // Clone the router for AgentDeps before we tuple is consumed. - let wasm_router = wasm_channel_runtime_state - .as_ref() - .map(|state| Arc::clone(&state.2)); - // Wire up channel runtime for hot-activation of WASM channels. if let Some(ref ext_mgr) = components.extension_manager && let Some((rt, ps, router)) = wasm_channel_runtime_state.take() @@ -732,7 +735,6 @@ async fn async_main() -> anyhow::Result<()> { document_extraction: Some(Arc::new( ironclaw::document_extraction::DocumentExtractionMiddleware::new(), )), - wasm_router, }; let mut agent = Agent::new( diff --git a/src/testing/mod.rs b/src/testing/mod.rs index fa74ad3f4e..ff522e3ad2 100644 --- a/src/testing/mod.rs +++ b/src/testing/mod.rs @@ -456,7 +456,6 @@ impl TestHarnessBuilder { http_interceptor: None, transcription: None, document_extraction: None, - wasm_router: None, }; TestHarness { diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs index c0bedbca9b..a4d737b52a 100644 --- a/tests/support/gateway_workflow_harness.rs +++ b/tests/support/gateway_workflow_harness.rs @@ -256,7 +256,6 @@ impl GatewayWorkflowHarness { http_interceptor: None, transcription: None, document_extraction: None, - wasm_router: None, }, channels, None, diff --git a/tests/support/test_rig.rs b/tests/support/test_rig.rs index ee97a4f8e1..8d41a26119 100644 --- a/tests/support/test_rig.rs +++ b/tests/support/test_rig.rs @@ -642,7 +642,6 @@ impl TestRigBuilder { }, transcription: None, document_extraction: None, - wasm_router: None, }; // 7. Create TestChannel and ChannelManager. From a86e1b02a7a5f49a1af26e20b72efc508922f5fc Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 09:08:23 +0100 Subject: [PATCH 14/18] style: format code after rebase conflict resolution [skip-regression-check] --- src/agent/thread_ops.rs | 1 + src/channels/wasm/hook.rs | 6 +++++- src/channels/wasm/wrapper.rs | 21 ++++++--------------- src/hooks/bundled.rs | 7 +++++-- src/hooks/hook.rs | 1 + tests/telegram_auth_integration.rs | 2 +- 6 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/agent/thread_ops.rs b/src/agent/thread_ops.rs index 273f65e379..89f45f5cff 100644 --- a/src/agent/thread_ops.rs +++ b/src/agent/thread_ops.rs @@ -613,6 +613,7 @@ impl Agent { use crate::hooks::hook::HookEvent; let hooks = self.hooks(); let event = HookEvent::MessagePersisted { + user_id: user_id.to_string(), channel: channel.to_string(), message_id: message_id.to_string(), metadata: metadata.clone(), diff --git a/src/channels/wasm/hook.rs b/src/channels/wasm/hook.rs index baaf9eea52..860ee8b039 100644 --- a/src/channels/wasm/hook.rs +++ b/src/channels/wasm/hook.rs @@ -49,6 +49,7 @@ impl Hook for MessagePersistedHook { _ctx: &HookContext, ) -> Result { if let HookEvent::MessagePersisted { + user_id: _, channel, message_id, metadata, @@ -110,7 +111,10 @@ mod tests { // Hook should return ok() even though it ignores non-MessagePersisted events let result = hook.execute(&event, &ctx).await; assert!(result.is_ok()); - assert!(matches!(result.unwrap(), HookOutcome::Continue { modified: None })); + assert!(matches!( + result.unwrap(), + HookOutcome::Continue { modified: None } + )); } #[tokio::test] diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index dabcab51a2..a3b6ea8c06 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -1893,9 +1893,12 @@ impl WasmChannel { let capabilities = Self::inject_workspace_reader(&self.capabilities, &self.workspace_store); let timeout = self.runtime.config().callback_timeout; let credentials = self.get_credentials().await; - let host_credentials = - resolve_channel_host_credentials(&self.capabilities, self.secrets_store.as_deref(), &self.owner_scope_id) - .await; + let host_credentials = resolve_channel_host_credentials( + &self.capabilities, + self.secrets_store.as_deref(), + &self.owner_scope_id, + ) + .await; let pairing_store = self.pairing_store.clone(); let metadata_json = metadata_json.to_string(); let channel_name = self.name.clone(); @@ -2307,7 +2310,6 @@ impl WasmChannel { } // Parse metadata JSON -<<<<<<< HEAD msg = apply_emitted_metadata(msg, &emitted.metadata_json); if is_owner_sender { // Store for owner-target routing (chat_id etc.). @@ -2316,17 +2318,6 @@ impl WasmChannel { // Extract metadata for ACK mechanism (after apply_emitted_metadata) let metadata = msg.metadata.clone(); -======= - let metadata: serde_json::Value = - if let Ok(m) = serde_json::from_str::(&emitted.metadata_json) { - msg = msg.with_metadata(m.clone()); - // Store for broadcast routing (chat_id etc.) - self.update_broadcast_metadata(&emitted.metadata_json).await; - m - } else { - serde_json::Value::Null - }; ->>>>>>> 6b200e8 (style: fix formatting issues) // Send to stream — no locks held across this await tracing::info!( diff --git a/src/hooks/bundled.rs b/src/hooks/bundled.rs index 1eb0c4399d..6aff04af50 100644 --- a/src/hooks/bundled.rs +++ b/src/hooks/bundled.rs @@ -517,6 +517,7 @@ enum OutboundWebhookEventSummary { response_length: usize, }, MessagePersisted { + user_id: String, channel: String, message_id: String, }, @@ -640,10 +641,12 @@ fn summarize_webhook_event(event: &HookEvent) -> OutboundWebhookEventSummary { } } HookEvent::MessagePersisted { + user_id, channel, message_id, .. } => OutboundWebhookEventSummary::MessagePersisted { + user_id: user_id.clone(), channel: channel.clone(), message_id: message_id.clone(), }, @@ -892,8 +895,8 @@ fn event_user_id(event: &HookEvent) -> &str { | HookEvent::Outbound { user_id, .. } | HookEvent::SessionStart { user_id, .. } | HookEvent::SessionEnd { user_id, .. } - | HookEvent::ResponseTransform { user_id, .. } => user_id, - HookEvent::MessagePersisted { .. } => "", + | HookEvent::ResponseTransform { user_id, .. } + | HookEvent::MessagePersisted { user_id, .. } => user_id, } } diff --git a/src/hooks/hook.rs b/src/hooks/hook.rs index e7d00328c0..67cee0015d 100644 --- a/src/hooks/hook.rs +++ b/src/hooks/hook.rs @@ -79,6 +79,7 @@ pub enum HookEvent { }, /// A user message was persisted to the database. MessagePersisted { + user_id: String, channel: String, message_id: String, metadata: serde_json::Value, diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index b430a81722..312db2b8ae 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -351,7 +351,7 @@ async fn test_private_messages_use_chat_id_as_thread_scope() { .await .expect("HTTP callback failed"); - assert_eq!(response.status, 200); + assert_eq!(response.0.status, 200); let msg = timeout(Duration::from_secs(1), stream.next()) .await From 4cd8098a80acbe2f9ec0485eb7f62fc1bef1e36b Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 21:43:17 +0100 Subject: [PATCH 15/18] feat(wasm): add channel-persistence export to all channels Add `export channel-persistence;` to the sandboxed-channel world, making the `on_message_persisted` callback available to all channels. Since WIT doesn't support optional exports, all channels must implement the interface. Changes: - wit/channel.wit: Add channel-persistence export to world - channels: Add no-op channel_persistence::Guest impl to discord, slack, telegram, feishu - src/tools/wasm/mod.rs: Bump WIT_TOOL_VERSION to 0.4.0 - tests/wit_compat.rs: Add host@0.4.0 stub, simplify to only support 0.4.0 - registry/channels/feishu.json: Update wit_version to 0.4.0 WhatsApp already has the real mark_as_read implementation. Co-Authored-By: Claude Opus 4.6 --- Cargo.toml | 4 ++ channels-src/discord/src/lib.rs | 12 ++-- channels-src/feishu/src/lib.rs | 7 +++ channels-src/slack/src/lib.rs | 12 ++-- channels-src/telegram/src/lib.rs | 12 ++-- channels-src/whatsapp/Cargo.toml | 4 ++ channels-src/whatsapp/src/lib.rs | 89 ++++++++++++++++----------- registry/channels/feishu.json | 2 +- src/channels/wasm/wrapper.rs | 102 ++++++++++++++++++++++++++++--- src/tools/wasm/mod.rs | 2 +- tests/wit_compat.rs | 16 +---- wit/channel.wit | 42 ++++++++----- wit/tool.wit | 2 +- 13 files changed, 216 insertions(+), 90 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5b452651f6..6be477ffa9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,10 @@ exclude = [ "crates/ironclaw_safety/fuzz", ] +# Default world for cargo-component builds +[workspace.metadata.component] +default_world = "near:agent@0.4.0/sandboxed-channel" + [package] name = "ironclaw" version = "0.19.0" diff --git a/channels-src/discord/src/lib.rs b/channels-src/discord/src/lib.rs index b5ef8691b0..7035105efc 100644 --- a/channels-src/discord/src/lib.rs +++ b/channels-src/discord/src/lib.rs @@ -32,6 +32,7 @@ use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, PollConfig, StatusUpdate, }; +use exports::near::agent::channel_persistence; use near::agent::channel_host::{self, EmittedMessage}; /// Discord interaction wrapper. @@ -473,11 +474,6 @@ impl Guest for DiscordChannel { Err("broadcast not yet implemented for Discord channel".to_string()) } - fn on_message_persisted(_metadata_json: String) -> Result<(), String> { - // Discord doesn't require mark_as_read functionality - Ok(()) - } - fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, @@ -1210,6 +1206,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse } } +impl channel_persistence::Guest for DiscordChannel { + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + Ok(()) // No-op: Discord does not support read receipts + } +} + export!(DiscordChannel); fn truncate_message(content: &str) -> String { diff --git a/channels-src/feishu/src/lib.rs b/channels-src/feishu/src/lib.rs index 2e7261d811..e41a99a5e5 100644 --- a/channels-src/feishu/src/lib.rs +++ b/channels-src/feishu/src/lib.rs @@ -36,6 +36,7 @@ use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, StatusUpdate, }; +use exports::near::agent::channel_persistence; use near::agent::channel_host::{self, EmittedMessage}; // ============================================================================ @@ -268,6 +269,12 @@ fn default_api_base() -> String { struct FeishuChannel; +impl channel_persistence::Guest for FeishuChannel { + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + Ok(()) // No-op: Feishu does not require post-persistence actions + } +} + export!(FeishuChannel); impl Guest for FeishuChannel { diff --git a/channels-src/slack/src/lib.rs b/channels-src/slack/src/lib.rs index 46061a963d..8341cdc7cf 100644 --- a/channels-src/slack/src/lib.rs +++ b/channels-src/slack/src/lib.rs @@ -29,6 +29,7 @@ use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, StatusUpdate, }; +use exports::near::agent::channel_persistence; use near::agent::channel_host::{self, EmittedMessage, InboundAttachment}; /// Slack event wrapper. @@ -329,11 +330,6 @@ impl Guest for SlackChannel { Err("broadcast not yet implemented for Slack channel".to_string()) } - fn on_message_persisted(_metadata_json: String) -> Result<(), String> { - // Slack doesn't require mark_as_read functionality - Ok(()) - } - fn on_shutdown() { channel_host::log(channel_host::LogLevel::Info, "Slack channel shutting down"); } @@ -717,6 +713,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse } // Export the component +impl channel_persistence::Guest for SlackChannel { + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + Ok(()) // No-op: Slack does not require post-persistence actions + } +} + export!(SlackChannel); #[cfg(test)] diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 6c8f5230e9..2126de6e05 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -33,6 +33,7 @@ use exports::near::agent::channel::{ AgentResponse, Attachment, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, PollConfig, StatusType, StatusUpdate, }; +use exports::near::agent::channel_persistence; use near::agent::channel_host::{self, EmittedMessage, InboundAttachment}; // ============================================================================ @@ -798,11 +799,6 @@ impl Guest for TelegramChannel { } } - fn on_message_persisted(_metadata_json: String) -> Result<(), String> { - // Telegram doesn't require mark_as_read functionality - Ok(()) - } - fn on_shutdown() { channel_host::log( channel_host::LogLevel::Info, @@ -2038,6 +2034,12 @@ fn json_response(status: u16, value: serde_json::Value) -> OutgoingHttpResponse } // Export the component +impl channel_persistence::Guest for TelegramChannel { + fn on_message_persisted(_metadata_json: String) -> Result<(), String> { + Ok(()) // No-op: Telegram does not require post-persistence actions + } +} + export!(TelegramChannel); // ============================================================================ diff --git a/channels-src/whatsapp/Cargo.toml b/channels-src/whatsapp/Cargo.toml index cf211e2e05..ae8cb71c6e 100644 --- a/channels-src/whatsapp/Cargo.toml +++ b/channels-src/whatsapp/Cargo.toml @@ -17,4 +17,8 @@ opt-level = "s" lto = true strip = true +# Specify the default world for cargo-component +[package.metadata.component] +target = "near:agent@0.4.0/sandboxed-channel" + [workspace] diff --git a/channels-src/whatsapp/src/lib.rs b/channels-src/whatsapp/src/lib.rs index 211ec81edd..1650f6d601 100644 --- a/channels-src/whatsapp/src/lib.rs +++ b/channels-src/whatsapp/src/lib.rs @@ -32,6 +32,7 @@ use exports::near::agent::channel::{ AgentResponse, ChannelConfig, Guest, HttpEndpointConfig, IncomingHttpRequest, OutgoingHttpResponse, StatusUpdate, }; +use exports::near::agent::channel_persistence; use near::agent::channel_host::{self, EmittedMessage, InboundAttachment}; // ============================================================================ @@ -529,64 +530,78 @@ impl Guest for WhatsAppChannel { Err("broadcast not yet implemented for WhatsApp channel".to_string()) } + fn on_shutdown() { + channel_host::log( + channel_host::LogLevel::Info, + "WhatsApp channel shutting down", + ); + } +} + +// ============================================================================ +// Persistence Callback Implementation +// ============================================================================ + +/// Metadata extracted from persisted message for mark_as_read callback. +#[derive(Debug, Deserialize)] +struct PersistedMessageMetadata { + phone_number_id: String, + message_id: String, +} + +impl channel_persistence::Guest for WhatsAppChannel { + /// Called after a message has been persisted to the database. + /// + /// This callback is used to mark the message as read in WhatsApp, + /// removing the "typing..." indicator from the sender's view. fn on_message_persisted(metadata_json: String) -> Result<(), String> { - // Parse metadata to extract phone_number_id and message_id - let metadata: WhatsAppMessageMetadata = match serde_json::from_str(&metadata_json) { - Ok(m) => m, - Err(e) => { - // Best-effort: log and return Ok to avoid blocking ACKs - channel_host::log( - channel_host::LogLevel::Warn, - &format!("on_message_persisted: failed to parse metadata: {}", e), - ); - return Ok(()); - } + // Check if mark_as_read is enabled + let mark_as_read_enabled = match channel_host::workspace_read("channels/whatsapp/mark_as_read") { + Some(s) => s == "true", + None => return Ok(()), // Default to disabled if not set }; - // Check if mark_as_read is enabled (default: true) - // We don't have direct config access here, so we check workspace state - let mark_as_read = channel_host::workspace_read("channels/whatsapp/mark_as_read") - .map(|s| s != "false") - .unwrap_or(true); - - if !mark_as_read { + if !mark_as_read_enabled { channel_host::log( channel_host::LogLevel::Debug, - "on_message_persisted: mark_as_read disabled, skipping", + "mark_as_read disabled, skipping callback", ); return Ok(()); } - // Call WhatsApp mark_as_read API - let result = mark_message_as_read(&metadata.phone_number_id, &metadata.message_id); + // Parse metadata to extract phone_number_id and message_id + let metadata: PersistedMessageMetadata = match serde_json::from_str(&metadata_json) { + Ok(m) => m, + Err(e) => { + // Metadata parsing failed - log but don't fail the callback + // This can happen for messages without proper routing metadata + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Failed to parse metadata for mark_as_read: {}", e), + ); + return Ok(()); // Return Ok to not block ACK + } + }; - match result { + // Mark the message as read via WhatsApp Cloud API + match mark_message_as_read(&metadata.phone_number_id, &metadata.message_id) { Ok(()) => { channel_host::log( channel_host::LogLevel::Debug, - &format!( - "on_message_persisted: marked message {} as read", - metadata.message_id - ), + &format!("Marked message {} as read", metadata.message_id), ); + Ok(()) } Err(e) => { - // Best-effort: log warning but don't fail (would block webhook ACK) + // Log error but return Ok to not block message ACK + // The message was already persisted, so we don't want to fail the callback channel_host::log( channel_host::LogLevel::Warn, - &format!("on_message_persisted: mark_as_read failed: {}", e), + &format!("Failed to mark message as read: {}", e), ); + Ok(()) // Always return Ok - mark_as_read failure is non-blocking } } - - Ok(()) - } - - fn on_shutdown() { - channel_host::log( - channel_host::LogLevel::Info, - "WhatsApp channel shutting down", - ); } } diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 66cecf1dd2..13d0a38e40 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -3,7 +3,7 @@ "display_name": "Feishu / Lark Channel", "kind": "channel", "version": "0.1.1", - "wit_version": "0.3.0", + "wit_version": "0.4.0", "description": "Talk to your agent through a Feishu or Lark bot", "keywords": [ "messaging", diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index a3b6ea8c06..ddbfd2c4c8 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -37,7 +37,7 @@ use tokio::sync::{RwLock, mpsc, oneshot}; use tokio_stream::wrappers::ReceiverStream; use uuid::Uuid; use wasmtime::Store; -use wasmtime::component::Linker; +use wasmtime::component::{Linker, TypedFunc}; use wasmtime_wasi::{ResourceTable, WasiCtx, WasiCtxBuilder, WasiView}; use crate::channels::wasm::capabilities::ChannelCapabilities; @@ -1140,6 +1140,91 @@ impl WasmChannel { Ok(instance) } + /// Call the optional on_message_persisted callback using raw component model API. + /// + /// This function: + /// 1. Instantiates the component with raw Instance access + /// 2. Tries to get the optional `on-message-persisted` function + /// 3. If it exists, calls it with the metadata_json argument + /// 4. Returns Ok(()) if the export doesn't exist (backward compatibility) + /// + /// This is best-effort - errors are logged but don't propagate to block ACKs. + #[allow(clippy::too_many_arguments)] + fn call_optional_persistence_callback( + runtime: &WasmChannelRuntime, + prepared: &PreparedChannelModule, + store: &mut Store, + metadata_json: &str, + ) -> Result<(), WasmChannelError> { + let engine = runtime.engine(); + + // Get the compiled component + let component = prepared + .component() + .ok_or_else(|| { + WasmChannelError::Compilation("No compiled component available".to_string()) + })? + .clone(); + + // Create linker and add host functions + let mut linker = Linker::new(engine); + Self::add_host_functions(&mut linker)?; + + // Instantiate with raw Instance access (use reborrow to avoid moving store) + let instance = linker.instantiate(&mut *store, &component).map_err(|e| { + let msg = e.to_string(); + if msg.contains("near:agent") || msg.contains("import") { + WasmChannelError::Instantiation(format!( + "{msg}. This may indicate a WIT version mismatch — \ + the channel was compiled against a different WIT than the host supports \ + (host WIT: {}). Rebuild the channel against the current WIT.", + crate::tools::wasm::WIT_CHANNEL_VERSION + )) + } else { + WasmChannelError::Instantiation(msg) + } + })?; + + // The optional export function name in WIT format + // Format: "[interface-name]::[function-name]" + // For "near:agent/channel-persistence" interface with "on-message-persisted" function: + const PERSISTENCE_FUNC: &str = "near:agent/channel-persistence::on-message-persisted"; + + // Try to get the optional function - returns None if not exported (backward compatible) + // Component model uses tuples for params/results: (String,) -> (Result<(), String>,) + let typed_func: TypedFunc<(String,), (Result<(), String>,)> = + match instance.get_typed_func(&mut *store, PERSISTENCE_FUNC) { + Ok(func) => func, + Err(_) => { + // Channel doesn't export the optional function - backward compatible + tracing::trace!( + channel = %prepared.name, + "on_message_persisted callback not supported (function not found)" + ); + return Ok(()); + } + }; + + // Call the function with the metadata_json argument + let (result,) = typed_func + .call(&mut *store, (metadata_json.to_string(),)) + .map_err(|e| WasmChannelError::Trapped { + name: prepared.name.clone(), + reason: e.to_string(), + })?; + + // Handle the result + if let Err(e) = result { + tracing::warn!( + channel = %prepared.name, + error = %e, + "on_message_persisted callback returned error (best-effort)" + ); + } + + Ok(()) + } + /// Map WASM execution errors to our error types. fn map_wasm_error(e: anyhow::Error, name: &str, fuel_limit: u64) -> WasmChannelError { let error_str = e.to_string(); @@ -1913,14 +1998,15 @@ impl WasmChannel { host_credentials, pairing_store, )?; - let instance = Self::instantiate_component(&runtime, &prepared, &mut store)?; - - let channel_iface = instance.near_agent_channel(); - let _ = channel_iface - .call_on_message_persisted(&mut store, &metadata_json) - .map_err(|e| Self::map_wasm_error(e, &prepared.name, prepared.limits.fuel))?; - Ok::<_, WasmChannelError>(()) + // Try to call the optional on_message_persisted callback + // Returns Ok(()) if the export doesn't exist (backward compatibility) + Self::call_optional_persistence_callback( + &runtime, + &prepared, + &mut store, + &metadata_json, + ) }) .await .map_err(|e| WasmChannelError::ExecutionPanicked { diff --git a/src/tools/wasm/mod.rs b/src/tools/wasm/mod.rs index 656fcaacb4..f9981dc17e 100644 --- a/src/tools/wasm/mod.rs +++ b/src/tools/wasm/mod.rs @@ -77,7 +77,7 @@ /// /// Extensions declaring a `wit_version` in their capabilities file are checked /// against this at load time: same major, not greater than host. -pub const WIT_TOOL_VERSION: &str = "0.3.0"; +pub const WIT_TOOL_VERSION: &str = "0.4.0"; /// Host WIT version for channel extensions. pub const WIT_CHANNEL_VERSION: &str = "0.4.0"; diff --git a/tests/wit_compat.rs b/tests/wit_compat.rs index 3ca95e4298..1e47ec789d 100644 --- a/tests/wit_compat.rs +++ b/tests/wit_compat.rs @@ -214,9 +214,8 @@ fn instantiate_tool_component( // If the WIT added/removed/renamed a function, stub registration // or instantiation will fail. - // Register stubs for both versioned (0.3.0+) and unversioned (pre-0.3.0) interface - // paths so that both old and new WASM artifacts can instantiate. - for interface in &["near:agent/host", "near:agent/host@0.3.0"] { + // Register stubs for versioned (0.4.0) and unversioned interface paths. + for interface in &["near:agent/host", "near:agent/host@0.4.0"] { let mut root = linker.root(); if let Ok(mut host) = root.instance(interface) { stub_shared_host_functions(&mut host)?; @@ -252,9 +251,7 @@ fn instantiate_channel_component( wasmtime_wasi::add_to_linker_sync(&mut linker) .map_err(|e| format!("WASI linker failed: {e}"))?; - // Register stubs for both versioned (0.3.0+) and unversioned (pre-0.3.0) interface - // paths so that both old and new WASM artifacts can instantiate. - // Register stubs under both versioned and unversioned interface paths. + // Register stubs for versioned (0.4.0) and unversioned interface paths. // This helper avoids repeating the stub registration code. fn stub_channel_host( host: &mut wasmtime::component::LinkerInstance<'_, TestStoreData>, @@ -310,13 +307,6 @@ fn instantiate_channel_component( .map_err(|e| format!("failed to create unversioned channel-host: {e}"))?; stub_channel_host(&mut host)?; } - { - let mut root = linker.root(); - let mut host = root - .instance("near:agent/channel-host@0.3.0") - .map_err(|e| format!("failed to create versioned channel-host@0.3.0: {e}"))?; - stub_channel_host(&mut host)?; - } { let mut root = linker.root(); let mut host = root diff --git a/wit/channel.wit b/wit/channel.wit index 194f65fda5..44d78a7928 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -391,20 +391,6 @@ interface channel { /// - Err(string): Delivery failure message on-respond: func(response: agent-response) -> result<_, string>; - /// Called after a message has been persisted to the database. - /// - /// Channels can use this to perform follow-up actions like - /// calling external APIs (e.g., WhatsApp mark_as_read). - /// This is optional - channels that don't need it can return Ok. - /// - /// Arguments: - /// - metadata-json: The metadata from the persisted message - /// - /// Returns: - /// - Ok: Post-persistence action completed successfully - /// - Err(string): Action failure message (does not block the ACK) - on-message-persisted: func(metadata-json: string) -> result<_, string>; - /// Notify the channel of agent status changes. /// /// Called when the agent starts thinking, finishes, or changes state. @@ -434,10 +420,38 @@ interface channel { on-shutdown: func(); } +/// Optional persistence callbacks for channels. +/// +/// Channels that need post-persistence notifications (e.g., WhatsApp mark_as_read) +/// can export this interface. Channels that don't need it can omit this export +/// and the host will gracefully skip the callback. +/// +/// This interface is optional for backward compatibility - existing channels +/// continue to work without implementing it. +interface channel-persistence { + /// Called after a message has been persisted to the database. + /// + /// Channels can use this to perform follow-up actions like + /// calling external APIs (e.g., WhatsApp mark_as_read). + /// + /// Arguments: + /// - metadata-json: The metadata from the persisted message + /// + /// Returns: + /// - Ok: Post-persistence action completed successfully + /// - Err(string): Action failure message (does not block the ACK) + on-message-persisted: func(metadata-json: string) -> result<_, string>; +} + /// World definition for sandboxed channels. /// /// Channels import host capabilities and export the channel interface. +/// +/// The channel-persistence interface is optional - channels implement it +/// if they need persistence callbacks (e.g., WhatsApp mark_as_read). +/// The host detects this at runtime using get_func() which returns Option. world sandboxed-channel { import channel-host; export channel; + export channel-persistence; } diff --git a/wit/tool.wit b/wit/tool.wit index cfe2b591af..a4ef994c45 100644 --- a/wit/tool.wit +++ b/wit/tool.wit @@ -1,4 +1,4 @@ -package near:agent@0.3.0; +package near:agent@0.4.0; // WASM Tool Sandbox Interface // From 6d49d6090b473a07b3c92c6b9988f6c9aca4e9c9 Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 22:05:11 +0100 Subject: [PATCH 16/18] fix(ci): remove invalid package.metadata.component from whatsapp Cargo.toml [skip-regression-check] The workspace-level default_world setting is sufficient. The per-package target format was causing cargo-component to fail with: 'invalid target version 0.4.0/sandboxed-channel' --- channels-src/whatsapp/Cargo.toml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/channels-src/whatsapp/Cargo.toml b/channels-src/whatsapp/Cargo.toml index ae8cb71c6e..cf211e2e05 100644 --- a/channels-src/whatsapp/Cargo.toml +++ b/channels-src/whatsapp/Cargo.toml @@ -17,8 +17,4 @@ opt-level = "s" lto = true strip = true -# Specify the default world for cargo-component -[package.metadata.component] -target = "near:agent@0.4.0/sandboxed-channel" - [workspace] From 0208678ee4c5c61be30e17df9b02ff16e376573f Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 22:05:11 +0100 Subject: [PATCH 17/18] fix(ci): bump feishu and telegram registry versions [skip-regression-check] --- registry/channels/feishu.json | 2 +- registry/channels/telegram.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/channels/feishu.json b/registry/channels/feishu.json index 13d0a38e40..df718a40eb 100644 --- a/registry/channels/feishu.json +++ b/registry/channels/feishu.json @@ -2,7 +2,7 @@ "name": "feishu", "display_name": "Feishu / Lark Channel", "kind": "channel", - "version": "0.1.1", + "version": "0.1.2", "wit_version": "0.4.0", "description": "Talk to your agent through a Feishu or Lark bot", "keywords": [ diff --git a/registry/channels/telegram.json b/registry/channels/telegram.json index f54004648e..197962907c 100644 --- a/registry/channels/telegram.json +++ b/registry/channels/telegram.json @@ -2,7 +2,7 @@ "name": "telegram", "display_name": "Telegram Channel", "kind": "channel", - "version": "0.2.4", + "version": "0.2.5", "wit_version": "0.4.0", "description": "Talk to your agent through a Telegram bot", "keywords": [ From 250c57074e99dd15170a8bc7028981aaceac32ed Mon Sep 17 00:00:00 2001 From: Jerome Revillard Date: Tue, 17 Mar 2026 22:24:05 +0100 Subject: [PATCH 18/18] chore: remove unnecessary workspace metadata.component The workspace-level default_world was added as a precaution when fixing WhatsApp's invalid package.metadata.component, but it's not needed since the channels work correctly by reading WIT files from their wit/ directories. --- Cargo.toml | 4 ---- registry/channels/discord.json | 2 +- registry/channels/slack.json | 2 +- registry/channels/whatsapp.json | 2 +- wit/channel.wit | 18 +++++++++--------- 5 files changed, 12 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6be477ffa9..5b452651f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,10 +18,6 @@ exclude = [ "crates/ironclaw_safety/fuzz", ] -# Default world for cargo-component builds -[workspace.metadata.component] -default_world = "near:agent@0.4.0/sandboxed-channel" - [package] name = "ironclaw" version = "0.19.0" diff --git a/registry/channels/discord.json b/registry/channels/discord.json index b6532f4d34..eec357f530 100644 --- a/registry/channels/discord.json +++ b/registry/channels/discord.json @@ -2,7 +2,7 @@ "name": "discord", "display_name": "Discord Channel", "kind": "channel", - "version": "0.2.2", + "version": "0.2.3", "wit_version": "0.4.0", "description": "Talk to your agent in Discord", "keywords": [ diff --git a/registry/channels/slack.json b/registry/channels/slack.json index 84340023e4..5390b66743 100644 --- a/registry/channels/slack.json +++ b/registry/channels/slack.json @@ -2,7 +2,7 @@ "name": "slack", "display_name": "Slack Channel", "kind": "channel", - "version": "0.2.2", + "version": "0.2.3", "wit_version": "0.4.0", "description": "Talk to your agent in Slack", "keywords": [ diff --git a/registry/channels/whatsapp.json b/registry/channels/whatsapp.json index e3a9785f91..823077ef2b 100644 --- a/registry/channels/whatsapp.json +++ b/registry/channels/whatsapp.json @@ -2,7 +2,7 @@ "name": "whatsapp", "display_name": "WhatsApp Channel", "kind": "channel", - "version": "0.2.1", + "version": "0.2.2", "wit_version": "0.4.0", "description": "Talk to your agent through WhatsApp", "keywords": [ diff --git a/wit/channel.wit b/wit/channel.wit index 44d78a7928..bc4b9d12f3 100644 --- a/wit/channel.wit +++ b/wit/channel.wit @@ -420,14 +420,14 @@ interface channel { on-shutdown: func(); } -/// Optional persistence callbacks for channels. +/// Persistence callbacks for channels. /// -/// Channels that need post-persistence notifications (e.g., WhatsApp mark_as_read) -/// can export this interface. Channels that don't need it can omit this export -/// and the host will gracefully skip the callback. +/// All channels must implement this interface. Channels that don't need +/// post-persistence actions (e.g., Discord, Slack) can return Ok(()). +/// Channels like WhatsApp use this to call mark_as_read after persistence. /// -/// This interface is optional for backward compatibility - existing channels -/// continue to work without implementing it. +/// Note: WIT does not support optional exports, so this interface is mandatory +/// in the world definition. Channels implement no-op versions if unused. interface channel-persistence { /// Called after a message has been persisted to the database. /// @@ -447,9 +447,9 @@ interface channel-persistence { /// /// Channels import host capabilities and export the channel interface. /// -/// The channel-persistence interface is optional - channels implement it -/// if they need persistence callbacks (e.g., WhatsApp mark_as_read). -/// The host detects this at runtime using get_func() which returns Option. +/// Note: WIT does not support optional exports. All channels must implement +/// channel-persistence, even if just a no-op (return Ok(())). WhatsApp uses +/// this for mark_as_read; other channels return Ok(()) immediately. world sandboxed-channel { import channel-host; export channel;