Skip to main content

roder_api/
hosted_hooks.rs

1//! Hosted hook contracts (roadmap phase 72, Task 1): tenant- or
2//! system-scoped event hooks with delivery, retry, and execution-result
3//! types. Hook URLs and headers are operator-supplied configuration;
4//! signing secrets are referenced by id only and never serialized.
5
6use serde::{Deserialize, Serialize};
7use time::OffsetDateTime;
8
9use crate::events::EventEnvelope;
10use crate::identity::{PrincipalContext, TenantContext, TenantId};
11
12pub type HookId = String;
13pub type HookDeliveryId = String;
14
15#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
16#[serde(rename_all = "snake_case")]
17pub enum HookScope {
18    /// Fires only for events belonging to the owning tenant.
19    Tenant,
20    /// Fires for all tenants (service operators only).
21    System,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(rename_all = "camelCase")]
26pub struct HostedHookDefinition {
27    pub id: HookId,
28    pub scope: HookScope,
29    /// Owning tenant; required for tenant scope, absent for system scope.
30    #[serde(default, skip_serializing_if = "Option::is_none")]
31    pub tenant_id: Option<TenantId>,
32    /// Canonical event-kind prefixes this hook subscribes to
33    /// (e.g. `turn.`, `thread.`); empty = no events.
34    pub event_kinds: Vec<String>,
35    /// Delivery target URL (HTTPS in production deployments).
36    pub url: String,
37    /// Reference to a signing secret managed by the host (`env:NAME` or a
38    /// secret-store id). The secret value itself never round-trips.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub signing_secret_ref: Option<String>,
41    pub enabled: bool,
42    #[serde(with = "time::serde::rfc3339")]
43    pub created_at: OffsetDateTime,
44    #[serde(with = "time::serde::rfc3339")]
45    pub updated_at: OffsetDateTime,
46}
47
48impl HostedHookDefinition {
49    pub fn matches(&self, event_kind: &str) -> bool {
50        self.enabled
51            && self
52                .event_kinds
53                .iter()
54                .any(|prefix| event_kind.starts_with(prefix))
55    }
56}
57
58/// Retry policy for failed deliveries (bounded exponential backoff).
59#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "camelCase")]
61pub struct HookRetryPolicy {
62    pub max_attempts: u32,
63    pub initial_backoff_ms: u64,
64    pub max_backoff_ms: u64,
65}
66
67impl Default for HookRetryPolicy {
68    fn default() -> Self {
69        Self {
70            max_attempts: 5,
71            initial_backoff_ms: 1_000,
72            max_backoff_ms: 60_000,
73        }
74    }
75}
76
77#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "snake_case")]
79pub enum HookDeliveryStatus {
80    Pending,
81    Delivered,
82    Failed,
83    /// Permanently abandoned after exhausting the retry policy.
84    Dead,
85}
86
87/// One delivery attempt lifecycle for a matched event.
88#[derive(Debug, Clone, Serialize, Deserialize)]
89#[serde(rename_all = "camelCase")]
90pub struct HookDelivery {
91    pub id: HookDeliveryId,
92    pub hook_id: HookId,
93    pub event_kind: String,
94    pub status: HookDeliveryStatus,
95    pub attempts: u32,
96    /// Coarse last failure class (`timeout`, `http_4xx`, `http_5xx`,
97    /// `connect`); never response bodies or headers.
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub last_error: Option<String>,
100    #[serde(with = "time::serde::rfc3339")]
101    pub created_at: OffsetDateTime,
102    #[serde(default, with = "time::serde::rfc3339::option")]
103    pub delivered_at: Option<OffsetDateTime>,
104}
105
106/// The payload a hook target receives: the canonical envelope plus the
107/// hosted actor/tenant context, attached here (rather than on every local
108/// event constructor) so local mode stays unchanged.
109#[derive(Debug, Clone, Serialize, Deserialize)]
110#[serde(rename_all = "camelCase")]
111pub struct HostedAuditEnvelope {
112    pub envelope: EventEnvelope,
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub tenant: Option<TenantContext>,
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub actor: Option<PrincipalContext>,
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    fn hook(scope: HookScope, kinds: Vec<&str>) -> HostedHookDefinition {
124        HostedHookDefinition {
125            id: "hook-1".to_string(),
126            scope,
127            tenant_id: matches!(scope, HookScope::Tenant).then(|| "tenant-a".to_string()),
128            event_kinds: kinds.into_iter().map(str::to_string).collect(),
129            url: "https://hooks.example.test/roder".to_string(),
130            signing_secret_ref: Some("env:RODER_HOOK_SECRET".to_string()),
131            enabled: true,
132            created_at: OffsetDateTime::UNIX_EPOCH,
133            updated_at: OffsetDateTime::UNIX_EPOCH,
134        }
135    }
136
137    #[test]
138    fn hook_matching_uses_prefixes_and_enabled_flag() {
139        let hook = hook(HookScope::Tenant, vec!["turn.", "thread."]);
140        assert!(hook.matches("turn.completed"));
141        assert!(hook.matches("thread.created"));
142        assert!(!hook.matches("tool.call_started"));
143
144        let mut disabled = hook.clone();
145        disabled.enabled = false;
146        assert!(!disabled.matches("turn.completed"));
147    }
148
149    #[test]
150    fn hook_definitions_serialize_secret_references_not_secrets() {
151        let hook = hook(HookScope::System, vec!["extension."]);
152        let json = serde_json::to_value(&hook).unwrap();
153        assert_eq!(json["signingSecretRef"], "env:RODER_HOOK_SECRET");
154        assert_eq!(json["scope"], "system");
155        let round_trip: HostedHookDefinition = serde_json::from_value(json).unwrap();
156        assert_eq!(round_trip, hook);
157    }
158
159    #[test]
160    fn retry_policy_defaults_are_bounded() {
161        let policy = HookRetryPolicy::default();
162        assert!(policy.max_attempts >= 1);
163        assert!(policy.initial_backoff_ms <= policy.max_backoff_ms);
164    }
165}