mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
feat: receive relay events via webhook callbacks (#1254)
* feat: receive relay events via webhook callbacks instead of SSE Replace the SSE pull model with push-based webhook callbacks from channel-relay. Eliminates the reconnect loop, stream token auth, and SSE parser — events arrive via HTTP POST to /relay/events. - Add webhook handler with HMAC signature verification - Simplify RelayChannel to use mpsc from webhook handler - Remove SSE connect/reconnect/parse logic from RelayClient - Add register_callback() to RelayClient for callback URL registration - Update activation flow to create event channel and register callback - Wire relay webhook endpoint into web gateway * fix: address review feedback on webhook callback PR - Return 503 when relay event channel is full/closed (enables retry) - Reject malformed timestamps with 400 instead of proceeding - Allow relay activation without settings store (no-store/ephemeral mode) - Check installed_relay_extensions set in is_relay_channel for no-db mode - Fix staging test constructors for new RelayChannel signature * security: adapt relay client to new channel-relay auth model Adapts the relay integration to the hardened channel-relay security model: - Switch from X-API-Key header to Authorization: Bearer sk-agent-* for all relay API calls (chat-api token verification) - Remove register_callback() — PUT /callbacks endpoint removed - Remove event_callback_url from initiate_oauth() — parameter removed - Make signing_secret a required field in RelayConfig (new env var: CHANNEL_RELAY_SIGNING_SECRET) - Update integration tests for Bearer auth and removed endpoints Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: use server-side approval tokens, remove caller-supplied routing - Approval flow now calls POST /approvals to register server-side record, then embeds only the opaque approval_token in button value - Remove instance_id parameter from proxy_provider() — channel-relay no longer accepts it (uses verified identity) - Remove instance_id and user_id from initiate_oauth() — channel-relay derives them from the Bearer token - Add create_approval() to RelayClient Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass webhook_url during OAuth so callback_url is set on connection The channel-relay OAuth flow now accepts webhook_url to set the callback_url during connection creation. IronClaw computes its webhook URL from callback_base + webhook_path and passes it during initiate_oauth. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove webhook_url from OAuth initiation Channel-relay now derives the callback URL from chat-api's instance_url. IronClaw no longer supplies webhook_url during OAuth — the relay is the authority on where events get delivered. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: cargo fmt Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove all URL params from OAuth initiation IronClaw no longer supplies any URLs to channel-relay. The relay derives all URLs from the trusted instance_url in chat-api. initiate_oauth() takes no parameters. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: restore CSRF nonce for OAuth callback validation Re-add nonce generation and secret storage in auth_channel_relay. The nonce is passed to channel-relay as state_nonce param (not a URL). Channel-relay embeds it in the signed state and appends it to the redirect URL so IronClaw's callback handler can validate and activate. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: per-instance callback signing secrets relay_signing_secret() now prefers OPENCLAW_GATEWAY_TOKEN (per-instance) over the shared CHANNEL_RELAY_SIGNING_SECRET. A compromised instance can no longer forge callbacks to other instances on the same relay. CHANNEL_RELAY_SIGNING_SECRET is now optional in RelayConfig. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: clean per-instance callback secrets, no shared secrets, no fallbacks Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: pass team_id to get_signing_secret for workspace-scoped lookup Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * security: remove sender_id from create_approval — relay derives it Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: remove stale relay sender_id validation * fix: harden relay webhook activation lifecycle --------- Co-authored-by: Pierre <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Pierre
Claude Opus 4.6
parent
09e1c97a27
commit
52ca9d6588
+71
-179
@@ -2,18 +2,12 @@
|
||||
//!
|
||||
//! Uses real HTTP servers on random ports (no mock framework).
|
||||
|
||||
use std::convert::Infallible;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::Query,
|
||||
http::StatusCode,
|
||||
response::sse::{Event, KeepAlive, Sse},
|
||||
routing::{get, post},
|
||||
};
|
||||
use futures::stream;
|
||||
use ironclaw::channels::relay::client::{RelayClient, RelayError};
|
||||
use ironclaw::channels::relay::client::{ChannelEvent, RelayClient};
|
||||
use secrecy::SecretString;
|
||||
use serde::Deserialize;
|
||||
use tokio::net::TcpListener;
|
||||
@@ -37,109 +31,79 @@ fn test_client(base_url: &str) -> RelayClient {
|
||||
.expect("client build")
|
||||
}
|
||||
|
||||
// ── SSE stream mock ─────────────────────────────────────────────────────
|
||||
// ── Signing secret fetch ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sse_stream_receives_events() {
|
||||
async fn test_get_signing_secret_returns_decoded_bytes() {
|
||||
let secret_hex = hex::encode([1u8; 32]);
|
||||
let secret_hex_clone = secret_hex.clone();
|
||||
let app = Router::new().route(
|
||||
"/stream",
|
||||
get(
|
||||
|Query(params): Query<std::collections::HashMap<String, String>>| async move {
|
||||
// Verify token is passed
|
||||
assert!(params.contains_key("token"));
|
||||
|
||||
let events = vec![
|
||||
Ok::<_, Infallible>(
|
||||
Event::default().event("message").data(
|
||||
serde_json::json!({
|
||||
"event_type": "message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T123",
|
||||
"channel_id": "C456",
|
||||
"sender_id": "U789",
|
||||
"content": "hello world"
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
),
|
||||
Ok(Event::default().event("message").data(
|
||||
serde_json::json!({
|
||||
"event_type": "direct_message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T123",
|
||||
"channel_id": "D001",
|
||||
"sender_id": "U789",
|
||||
"content": "dm text"
|
||||
})
|
||||
.to_string(),
|
||||
)),
|
||||
];
|
||||
|
||||
Sse::new(stream::iter(events)).keep_alive(KeepAlive::default())
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let (mut event_stream, handle) = client.connect_stream("test-token", 30).await.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let first = event_stream.next().await.expect("first event");
|
||||
assert_eq!(first.event_type, "message");
|
||||
assert_eq!(first.text(), "hello world");
|
||||
assert_eq!(first.team_id(), "T123");
|
||||
|
||||
let second = event_stream.next().await.expect("second event");
|
||||
assert_eq!(second.event_type, "direct_message");
|
||||
assert_eq!(second.text(), "dm text");
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ── Token renewal flow ──────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_expired_returns_error() {
|
||||
let app = Router::new().route("/stream", get(|| async { StatusCode::UNAUTHORIZED }));
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
match client.connect_stream("expired-token", 30).await {
|
||||
Err(RelayError::TokenExpired) => {} // expected
|
||||
Err(other) => panic!("expected TokenExpired, got: {other}"),
|
||||
Ok(_) => panic!("expected error, got Ok"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_token_renewal() {
|
||||
let call_count = std::sync::Arc::new(AtomicUsize::new(0));
|
||||
let call_count_clone = call_count.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/stream/renew",
|
||||
post(move |Json(body): Json<serde_json::Value>| {
|
||||
let count = call_count_clone.clone();
|
||||
async move {
|
||||
count.fetch_add(1, Ordering::SeqCst);
|
||||
assert!(body.get("instance_id").is_some());
|
||||
assert!(body.get("user_id").is_some());
|
||||
Json(serde_json::json!({
|
||||
"stream_token": "renewed-token-123"
|
||||
}))
|
||||
}
|
||||
"/relay/signing-secret",
|
||||
get(move || {
|
||||
let s = secret_hex_clone.clone();
|
||||
async move { Json(serde_json::json!({"signing_secret": s})) }
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let new_token = client.renew_token("inst-1", "user-1").await.unwrap();
|
||||
assert_eq!(new_token, "renewed-token-123");
|
||||
assert_eq!(call_count.load(Ordering::SeqCst), 1);
|
||||
let secret = client.get_signing_secret("T123").await.unwrap();
|
||||
assert_eq!(secret, vec![1u8; 32]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_signing_secret_404_returns_error() {
|
||||
let app = Router::new().route(
|
||||
"/relay/signing-secret",
|
||||
get(|| async { (axum::http::StatusCode::NOT_FOUND, "not found") }),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let result = client.get_signing_secret("T123").await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_signing_secret_invalid_hex_returns_protocol_error() {
|
||||
let app = Router::new().route(
|
||||
"/relay/signing-secret",
|
||||
get(|| async { Json(serde_json::json!({"signing_secret": "not-hex"})) }),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let err = client
|
||||
.get_signing_secret("T123")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("invalid signing_secret hex"), "got: {err}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_signing_secret_wrong_length_returns_protocol_error() {
|
||||
let short_secret_hex = hex::encode([7u8; 31]);
|
||||
let app = Router::new().route(
|
||||
"/relay/signing-secret",
|
||||
get(move || {
|
||||
let s = short_secret_hex.clone();
|
||||
async move { Json(serde_json::json!({"signing_secret": s})) }
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let err = client
|
||||
.get_signing_secret("T123")
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string();
|
||||
assert!(err.contains("expected 32 bytes"), "got: {err}");
|
||||
}
|
||||
|
||||
// ── Proxy call ──────────────────────────────────────────────────────────
|
||||
@@ -171,7 +135,7 @@ async fn test_proxy_provider_sends_correct_payload() {
|
||||
"text": "Hello from test",
|
||||
});
|
||||
let resp = client
|
||||
.proxy_provider("slack", "T123", "chat.postMessage", body, None)
|
||||
.proxy_provider("slack", "T123", "chat.postMessage", body)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp["ok"], true);
|
||||
@@ -200,18 +164,18 @@ async fn test_list_connections() {
|
||||
assert!(!conns[1].connected);
|
||||
}
|
||||
|
||||
// ── API key header ──────────────────────────────────────────────────────
|
||||
// ── Bearer token auth ────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_api_key_sent_in_header() {
|
||||
async fn test_bearer_token_sent_in_header() {
|
||||
let app = Router::new().route(
|
||||
"/connections",
|
||||
get(|headers: axum::http::HeaderMap| async move {
|
||||
let key = headers
|
||||
.get("X-API-Key")
|
||||
let auth = headers
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("");
|
||||
assert_eq!(key, "test-api-key");
|
||||
assert_eq!(auth, "Bearer test-api-key");
|
||||
Json(serde_json::json!([]))
|
||||
}),
|
||||
);
|
||||
@@ -233,82 +197,10 @@ fn test_relay_client_new_succeeds() {
|
||||
assert!(client.is_ok());
|
||||
}
|
||||
|
||||
// ── SSE UTF-8 chunk boundary ────────────────────────────────────────────
|
||||
|
||||
/// Verify that multi-byte UTF-8 characters split across SSE chunks are
|
||||
/// not corrupted (no U+FFFD replacement characters).
|
||||
#[tokio::test]
|
||||
async fn test_sse_stream_preserves_multibyte_utf8_across_chunks() {
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
let sent = std::sync::Arc::new(AtomicBool::new(false));
|
||||
let sent_clone = sent.clone();
|
||||
|
||||
let app = Router::new().route(
|
||||
"/stream",
|
||||
get(move |_: Query<std::collections::HashMap<String, String>>| {
|
||||
let sent = sent_clone.clone();
|
||||
async move {
|
||||
// Build SSE payload with emoji that will be split mid-character
|
||||
let event_data = serde_json::json!({
|
||||
"event_type": "message",
|
||||
"provider": "slack",
|
||||
"provider_scope": "T1",
|
||||
"channel_id": "C1",
|
||||
"sender_id": "U1",
|
||||
"content": "hello 🦀 world"
|
||||
});
|
||||
let payload = format!("event: message\ndata: {}\n\n", event_data);
|
||||
let bytes = payload.into_bytes();
|
||||
|
||||
// Split in the middle of the 4-byte crab emoji
|
||||
let crab_pos = bytes
|
||||
.windows(4)
|
||||
.position(|w| w == [0xF0, 0x9F, 0xA6, 0x80])
|
||||
.unwrap();
|
||||
let split_at = crab_pos + 2;
|
||||
|
||||
let chunk1 = bytes[..split_at].to_vec();
|
||||
let chunk2 = bytes[split_at..].to_vec();
|
||||
|
||||
sent.store(true, Ordering::SeqCst);
|
||||
|
||||
let events = vec![
|
||||
Ok::<_, Infallible>(axum::body::Bytes::from(chunk1)),
|
||||
Ok(axum::body::Bytes::from(chunk2)),
|
||||
];
|
||||
|
||||
axum::response::Response::builder()
|
||||
.header("content-type", "text/event-stream")
|
||||
.body(axum::body::Body::from_stream(stream::iter(events)))
|
||||
.unwrap()
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
let base_url = start_server(app).await;
|
||||
let client = test_client(&base_url);
|
||||
|
||||
let (mut event_stream, handle) = client.connect_stream("tok", 30).await.unwrap();
|
||||
|
||||
use futures::StreamExt;
|
||||
let event = event_stream.next().await.expect("should get event");
|
||||
assert_eq!(
|
||||
event.text(),
|
||||
"hello 🦀 world",
|
||||
"emoji should not be corrupted"
|
||||
);
|
||||
assert!(sent.load(Ordering::SeqCst));
|
||||
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
// ── Channel event field validation ──────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_channel_event_missing_fields_detected() {
|
||||
use ironclaw::channels::relay::client::ChannelEvent;
|
||||
|
||||
// Event with empty sender_id should be detectable
|
||||
let json = r#"{"event_type": "message", "provider_scope": "T1", "channel_id": "C1", "sender_id": "", "content": "test"}"#;
|
||||
let event: ChannelEvent = serde_json::from_str(json).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user