Skip to main content

zeph_gateway/
handlers.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use axum::Json;
5use axum::extract::State;
6use axum::extract::rejection::JsonRejection;
7use axum::http::StatusCode;
8use axum::response::IntoResponse;
9
10use super::server::AppState;
11
12/// JSON body returned for all error responses from `POST /webhook`.
13#[derive(serde::Serialize)]
14struct ErrorResponse {
15    error: String,
16    status: u16,
17}
18
19/// JSON body expected on `POST /webhook`.
20///
21/// All three fields are required.  Individual field limits are enforced by
22/// [`WebhookPayload::validate`] before the message is forwarded to the agent.
23#[derive(serde::Deserialize)]
24pub(crate) struct WebhookPayload {
25    /// Logical channel name (e.g. `"discord"`, `"slack"`). Maximum 256 bytes.
26    pub channel: String,
27    /// Display name or identifier of the message sender. Maximum 256 bytes.
28    pub sender: String,
29    /// Raw message content. Maximum 65 536 bytes.
30    pub body: String,
31}
32
33impl WebhookPayload {
34    /// Validate field lengths before forwarding to the agent.
35    ///
36    /// Returns `Ok(())` when all fields are within their limits, or `Err` with a
37    /// human-readable description of the first violation.
38    ///
39    /// | Field | Limit |
40    /// |---|---|
41    /// | `sender` | 256 bytes |
42    /// | `channel` | 256 bytes |
43    /// | `body` | 65 536 bytes |
44    pub(crate) fn validate(&self) -> Result<(), &'static str> {
45        if self.sender.len() > 256 {
46            return Err("sender exceeds 256 bytes");
47        }
48        if self.channel.len() > 256 {
49            return Err("channel exceeds 256 bytes");
50        }
51        if self.body.len() > 65536 {
52            return Err("body exceeds 65536 bytes");
53        }
54        Ok(())
55    }
56}
57
58/// JSON body returned by a successful `POST /webhook` call.
59#[derive(serde::Serialize)]
60struct WebhookResponse {
61    /// Always `"accepted"` on success.
62    status: &'static str,
63}
64
65/// A validated, control-character-stripped webhook payload forwarded to the agent-input
66/// forwarder (`forward_webhooks` in the `zeph` binary).
67///
68/// Deliberately carries `sender`/`channel`/`body` as separate fields rather than a
69/// pre-formatted `"[sender@channel] body"` string: deciding whether `body` is a recognized
70/// slash command (and, if so, skipping both the display prefix and the `ExternalUntrusted`
71/// sanitizer wrap so the agent's dispatch registries see the raw command) requires
72/// `zeph-commands`/`zeph-core`, neither of which this crate depends on. That decision is made
73/// downstream by the forwarder, which already depends on both.
74#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct WebhookMessage {
76    /// Display name or identifier of the message sender, control-character-stripped.
77    pub sender: String,
78    /// Logical channel name (e.g. `"discord"`, `"slack"`), control-character-stripped.
79    pub channel: String,
80    /// Raw message body, control-character-stripped.
81    pub body: String,
82}
83
84/// JSON body returned by `GET /health`.
85#[derive(serde::Serialize)]
86struct HealthResponse {
87    /// Always `"ok"`.
88    status: &'static str,
89    /// Seconds elapsed since the server started.
90    uptime_secs: u64,
91}
92
93/// Handler for `POST /webhook`.
94///
95/// Validates the payload, sanitises `sender`, `channel`, and `body` by stripping
96/// control characters, then forwards a [`WebhookMessage`] on the internal webhook
97/// channel. Display-prefix formatting, slash-command detection, and
98/// `ExternalUntrusted` sanitization all happen downstream in the forwarder (see
99/// [`WebhookMessage`]'s doc comment for why).
100///
101/// The send is wrapped in a timeout (`AppState::webhook_send_timeout`).  If the
102/// agent cannot consume the message within that window, the handler returns
103/// `503 Service Unavailable` rather than blocking the Axum worker indefinitely.
104///
105/// # Responses
106///
107/// | Status | Condition |
108/// |---|---|
109/// | 200 | Message accepted and queued |
110/// | 422 | Payload failed field-length validation |
111/// | 503 | Internal channel closed or send timed out due to backpressure |
112#[tracing::instrument(name = "gateway.webhook", skip_all)]
113pub(crate) async fn webhook_handler(
114    State(state): State<AppState>,
115    payload: Result<Json<WebhookPayload>, JsonRejection>,
116) -> impl IntoResponse {
117    let Json(payload) = match payload {
118        Ok(p) => p,
119        Err(e) => {
120            return (
121                e.status(),
122                Json(ErrorResponse {
123                    error: e.body_text(),
124                    status: e.status().as_u16(),
125                }),
126            )
127                .into_response();
128        }
129    };
130    if let Err(e) = payload.validate() {
131        return (
132            StatusCode::UNPROCESSABLE_ENTITY,
133            Json(ErrorResponse {
134                error: e.to_string(),
135                status: StatusCode::UNPROCESSABLE_ENTITY.as_u16(),
136            }),
137        )
138            .into_response();
139    }
140    let sender = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.sender);
141    let channel = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.channel);
142    let body = zeph_common::sanitize::strip_control_chars_preserve_whitespace(&payload.body);
143    let msg = WebhookMessage {
144        sender,
145        channel,
146        body,
147    };
148    match tokio::time::timeout(state.webhook_send_timeout, state.webhook_tx.send(msg)).await {
149        Ok(Ok(())) => Json(WebhookResponse { status: "accepted" }).into_response(),
150        Ok(Err(_)) => (
151            StatusCode::SERVICE_UNAVAILABLE,
152            Json(ErrorResponse {
153                error: "agent unavailable".to_string(),
154                status: StatusCode::SERVICE_UNAVAILABLE.as_u16(),
155            }),
156        )
157            .into_response(),
158        Err(_elapsed) => {
159            tracing::warn!(
160                timeout_secs = state.webhook_send_timeout.as_secs_f64(),
161                "webhook send timed out: agent backpressure"
162            );
163            (
164                StatusCode::SERVICE_UNAVAILABLE,
165                Json(ErrorResponse {
166                    error: "service unavailable: agent backpressure".to_string(),
167                    status: StatusCode::SERVICE_UNAVAILABLE.as_u16(),
168                }),
169            )
170                .into_response()
171        }
172    }
173}
174
175/// Handler for `GET /health`.
176///
177/// Returns a JSON object with a static `"ok"` status and the server uptime in
178/// seconds.  This endpoint bypasses authentication and rate limiting so that
179/// load balancers can poll it freely.
180///
181/// # Response body
182///
183/// ```json
184/// { "status": "ok", "uptime_secs": 42 }
185/// ```
186#[tracing::instrument(name = "gateway.health", skip_all)]
187pub(crate) async fn health_handler(State(state): State<AppState>) -> impl IntoResponse {
188    Json(HealthResponse {
189        status: "ok",
190        uptime_secs: state.started_at.elapsed().as_secs(),
191    })
192}
193
194/// Handler for `GET /metrics` (Prometheus scrape endpoint).
195///
196/// Returns the current registry contents encoded as `OpenMetrics` 1.0.0 text format, suitable for
197/// scraping by Prometheus or any compatible monitoring system.
198///
199/// This handler requires `State<Arc<Registry>>` injected via the nested router in
200/// [`crate::GatewayServer::with_metrics_registry`].
201///
202/// # Responses
203///
204/// | Status | Condition |
205/// |---|---|
206/// | 200 | Registry encoded successfully; `Content-Type: application/openmetrics-text; version=1.0.0; charset=utf-8` |
207/// | 500 | Registry encoding failed (logged as error) |
208#[cfg(feature = "prometheus")]
209#[tracing::instrument(name = "gateway.metrics", skip_all)]
210pub(crate) async fn metrics_handler(
211    axum::extract::State(registry): axum::extract::State<
212        std::sync::Arc<prometheus_client::registry::Registry>,
213    >,
214) -> impl axum::response::IntoResponse {
215    let mut buf = String::new();
216    match prometheus_client::encoding::text::encode(&mut buf, &registry) {
217        Ok(()) => (
218            [(
219                axum::http::header::CONTENT_TYPE,
220                "application/openmetrics-text; version=1.0.0; charset=utf-8",
221            )],
222            buf,
223        )
224            .into_response(),
225        Err(e) => {
226            tracing::error!("failed to encode prometheus metrics: {e}");
227            (
228                axum::http::StatusCode::INTERNAL_SERVER_ERROR,
229                "metrics encoding failed",
230            )
231                .into_response()
232        }
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use std::time::{Duration, Instant};
240
241    #[test]
242    fn health_response_serializes() {
243        let resp = HealthResponse {
244            status: "ok",
245            uptime_secs: 42,
246        };
247        let json = serde_json::to_string(&resp).unwrap();
248        assert!(json.contains("\"status\":\"ok\""));
249    }
250
251    #[test]
252    fn webhook_payload_deserializes() {
253        let json = r#"{"channel":"discord","sender":"user1","body":"hello"}"#;
254        let payload: WebhookPayload = serde_json::from_str(json).unwrap();
255        assert_eq!(payload.channel, "discord");
256        assert_eq!(payload.sender, "user1");
257        assert_eq!(payload.body, "hello");
258    }
259
260    #[test]
261    fn validate_accepts_valid_payload() {
262        let payload = WebhookPayload {
263            channel: "ch".into(),
264            sender: "user".into(),
265            body: "hello".into(),
266        };
267        assert!(payload.validate().is_ok());
268    }
269
270    #[test]
271    fn validate_rejects_oversized_sender() {
272        let payload = WebhookPayload {
273            channel: "ch".into(),
274            sender: "a".repeat(257),
275            body: "hello".into(),
276        };
277        assert!(payload.validate().is_err());
278    }
279
280    #[test]
281    fn validate_rejects_oversized_channel() {
282        let payload = WebhookPayload {
283            channel: "c".repeat(257),
284            sender: "user".into(),
285            body: "hello".into(),
286        };
287        assert!(payload.validate().is_err());
288    }
289
290    #[test]
291    fn validate_rejects_oversized_body() {
292        let payload = WebhookPayload {
293            channel: "ch".into(),
294            sender: "user".into(),
295            body: "b".repeat(65537),
296        };
297        assert!(payload.validate().is_err());
298    }
299
300    #[test]
301    fn sanitize_strips_control_chars_keeps_newline() {
302        let input = "hel\x01lo\x7f\nworld";
303        let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
304        assert_eq!(result, "hello\nworld");
305    }
306
307    #[test]
308    fn sanitize_strips_null_byte() {
309        let input = "he\x00llo";
310        let result = zeph_common::sanitize::strip_control_chars_preserve_whitespace(input);
311        assert_eq!(result, "hello");
312    }
313
314    /// When the webhook channel is full and the send times out, the handler must
315    /// return 503 rather than blocking the Axum worker indefinitely.
316    #[tokio::test]
317    async fn webhook_handler_returns_503_on_send_timeout() {
318        use axum::extract::State;
319        use axum::response::IntoResponse as _;
320
321        let (tx, _rx) = tokio::sync::mpsc::channel::<WebhookMessage>(1);
322        // Fill the channel so the next send() will block.
323        tx.send(WebhookMessage {
324            sender: "fill".into(),
325            channel: "fill".into(),
326            body: "fill".into(),
327        })
328        .await
329        .unwrap();
330
331        let state = AppState {
332            webhook_tx: tx,
333            started_at: Instant::now(),
334            webhook_send_timeout: Duration::from_millis(5),
335        };
336
337        let payload = WebhookPayload {
338            channel: "ch".into(),
339            sender: "user".into(),
340            body: "hello".into(),
341        };
342
343        let response = webhook_handler(State(state), Ok(axum::Json(payload)))
344            .await
345            .into_response();
346        assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE);
347    }
348
349    #[test]
350    fn validate_accepts_at_limit_sender() {
351        let payload = WebhookPayload {
352            channel: "ch".into(),
353            sender: "a".repeat(256),
354            body: "hello".into(),
355        };
356        assert!(payload.validate().is_ok());
357    }
358
359    #[test]
360    fn validate_accepts_at_limit_channel() {
361        let payload = WebhookPayload {
362            channel: "c".repeat(256),
363            sender: "user".into(),
364            body: "hello".into(),
365        };
366        assert!(payload.validate().is_ok());
367    }
368
369    #[test]
370    fn validate_accepts_at_limit_body() {
371        let payload = WebhookPayload {
372            channel: "ch".into(),
373            sender: "user".into(),
374            body: "b".repeat(65536),
375        };
376        assert!(payload.validate().is_ok());
377    }
378
379    #[tokio::test]
380    async fn webhook_handler_sanitizes_body() {
381        use axum::extract::State;
382        use axum::response::IntoResponse as _;
383
384        let (tx, mut rx) = tokio::sync::mpsc::channel::<WebhookMessage>(4);
385        let state = AppState {
386            webhook_tx: tx,
387            started_at: Instant::now(),
388            webhook_send_timeout: Duration::from_secs(1),
389        };
390
391        let payload = WebhookPayload {
392            channel: "ch".into(),
393            sender: "user".into(),
394            body: "hel\x01lo\x7fworld".into(),
395        };
396
397        let response = webhook_handler(State(state), Ok(axum::Json(payload)))
398            .await
399            .into_response();
400        assert_eq!(response.status(), StatusCode::OK);
401        let msg = rx.try_recv().expect("message must be forwarded");
402        assert_eq!(
403            msg,
404            WebhookMessage {
405                sender: "user".into(),
406                channel: "ch".into(),
407                body: "helloworld".into(),
408            }
409        );
410    }
411}