Skip to main content

heldar_kernel/models/
webhooks.rs

1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use sqlx::types::Json;
4use sqlx::FromRow;
5
6/// Deserialize a PRESENT field into `Some(inner)`. Combined with `#[serde(default)]` (which leaves a
7/// missing field as `None`), this yields three states: omitted = `None`, null = `Some(None)`,
8/// value = `Some(Some(v))`.
9fn de_field_present<'de, D>(deserializer: D) -> Result<Option<Option<String>>, D::Error>
10where
11    D: serde::Deserializer<'de>,
12{
13    Ok(Some(Option::<String>::deserialize(deserializer)?))
14}
15
16/// Mask a webhook URL for display: keep only `scheme://host[:port]` and append `/…` so the path/token
17/// is never revealed. Returns None for an empty url; a url without a scheme is masked to `…` (it may
18/// be a bare token).
19pub fn mask_webhook_url(url: &str) -> Option<String> {
20    let url = url.trim();
21    if url.is_empty() {
22        return None;
23    }
24    match url.split_once("://") {
25        Some((scheme, rest)) => {
26            let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
27            let authority = &rest[..authority_end];
28            if authority_end < rest.len() {
29                Some(format!("{scheme}://{authority}/…"))
30            } else {
31                Some(format!("{scheme}://{authority}"))
32            }
33        }
34        None => Some("…".to_string()),
35    }
36}
37
38/// A webhook subscription row as stored. `secret` (the HMAC signing key) is never serialized; use
39/// [`WebhookSubscriptionView`] for output. `event_types` is a JSON array of type names; the sentinel
40/// `["*"]` matches every event type, otherwise it is an exact-membership set. `cursor_at` is the
41/// per-subscription delivery cursor (an `events.created_at`); NULL means "start at now" (no backlog).
42#[derive(Debug, Clone, FromRow)]
43pub struct WebhookSubscription {
44    pub id: String,
45    pub name: String,
46    pub url: String,
47    pub event_types: Json<Vec<String>>,
48    pub min_severity: String,
49    pub secret: Option<String>,
50    pub enabled: bool,
51    pub cursor_at: Option<DateTime<Utc>>,
52    pub created_at: DateTime<Utc>,
53    pub updated_at: DateTime<Utc>,
54}
55
56/// Client-facing subscription view: the `secret` is replaced by a `has_secret` flag and never echoed.
57#[derive(Debug, Clone, Serialize)]
58pub struct WebhookSubscriptionView {
59    pub id: String,
60    pub name: String,
61    pub url: String,
62    pub event_types: Vec<String>,
63    pub min_severity: String,
64    /// Whether an HMAC signing secret is configured (the value itself is never returned).
65    pub has_secret: bool,
66    pub enabled: bool,
67    pub cursor_at: Option<DateTime<Utc>>,
68    pub created_at: DateTime<Utc>,
69    pub updated_at: DateTime<Utc>,
70}
71
72impl From<WebhookSubscription> for WebhookSubscriptionView {
73    fn from(s: WebhookSubscription) -> Self {
74        WebhookSubscriptionView {
75            id: s.id,
76            name: s.name,
77            url: s.url,
78            event_types: s.event_types.0,
79            min_severity: s.min_severity,
80            has_secret: s.secret.as_deref().map(|v| !v.is_empty()).unwrap_or(false),
81            enabled: s.enabled,
82            cursor_at: s.cursor_at,
83            created_at: s.created_at,
84            updated_at: s.updated_at,
85        }
86    }
87}
88
89#[derive(Debug, Deserialize)]
90pub struct WebhookSubscriptionCreate {
91    pub name: String,
92    pub url: String,
93    /// Omitted/empty = all types (`["*"]`).
94    pub event_types: Option<Vec<String>>,
95    /// `info` | `warning` | `critical` (default `info`).
96    pub min_severity: Option<String>,
97    /// Optional HMAC-SHA256 signing secret.
98    pub secret: Option<String>,
99    pub enabled: Option<bool>,
100}
101
102/// Partial update; an ABSENT field is left unchanged. `secret` is three-state: omitted = unchanged,
103/// null = clear the secret, a value = set it (the outer `Option` distinguishes "field omitted" from
104/// an explicit null — see [`de_field_present`]).
105#[derive(Debug, Deserialize, Default)]
106pub struct WebhookSubscriptionUpdate {
107    pub name: Option<String>,
108    pub url: Option<String>,
109    pub event_types: Option<Vec<String>>,
110    pub min_severity: Option<String>,
111    #[serde(default, deserialize_with = "de_field_present")]
112    pub secret: Option<Option<String>>,
113    pub enabled: Option<bool>,
114}
115
116/// One webhook delivery attempt (the at-least-once retry ledger). `status` is `delivered` | `failed`.
117#[derive(Debug, Clone, Serialize, FromRow)]
118pub struct WebhookDelivery {
119    pub id: String,
120    pub subscription_id: String,
121    pub event_id: Option<String>,
122    pub event_type: Option<String>,
123    pub status: String,
124    pub attempts: i64,
125    pub response_code: Option<i64>,
126    pub error: Option<String>,
127    pub created_at: DateTime<Utc>,
128    pub delivered_at: Option<DateTime<Utc>>,
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn mask_webhook_url_hides_path_and_token() {
137        // Path/query/fragment are dropped behind an ellipsis; scheme + host (and port) are kept.
138        assert_eq!(
139            mask_webhook_url("https://hooks.slack.com/services/T000/B000/XXXXSECRET"),
140            Some("https://hooks.slack.com/…".to_string())
141        );
142        assert_eq!(
143            mask_webhook_url("https://example.com:8443/alert?token=abc"),
144            Some("https://example.com:8443/…".to_string())
145        );
146        // Host-only urls keep just scheme://host.
147        assert_eq!(
148            mask_webhook_url("https://example.com"),
149            Some("https://example.com".to_string())
150        );
151        // Empty/whitespace => None; schemeless => fully masked (may be a bare token).
152        assert_eq!(mask_webhook_url("   "), None);
153        assert_eq!(mask_webhook_url("not-a-url"), Some("…".to_string()));
154    }
155
156    #[test]
157    fn webhook_update_secret_is_three_state() {
158        // Omitted => None (leave the signing secret unchanged).
159        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"enabled": true}"#).unwrap();
160        assert!(u.secret.is_none());
161        assert_eq!(u.enabled, Some(true));
162        // Explicit null => Some(None) (clear the secret).
163        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": null}"#).unwrap();
164        assert_eq!(u.secret, Some(None));
165        // A value => Some(Some(v)) (set the secret).
166        let u: WebhookSubscriptionUpdate = serde_json::from_str(r#"{"secret": "s3cr3t"}"#).unwrap();
167        assert_eq!(u.secret, Some(Some("s3cr3t".to_string())));
168    }
169}