1use 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 Tenant,
20 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 #[serde(default, skip_serializing_if = "Option::is_none")]
31 pub tenant_id: Option<TenantId>,
32 pub event_kinds: Vec<String>,
35 pub url: String,
37 #[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#[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 Dead,
85}
86
87#[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 #[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#[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}