use serde::{Deserialize, Serialize};
use time::OffsetDateTime;
use crate::events::EventEnvelope;
use crate::identity::{PrincipalContext, TenantContext, TenantId};
pub type HookId = String;
pub type HookDeliveryId = String;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookScope {
Tenant,
System,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HostedHookDefinition {
pub id: HookId,
pub scope: HookScope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant_id: Option<TenantId>,
pub event_kinds: Vec<String>,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signing_secret_ref: Option<String>,
pub enabled: bool,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub updated_at: OffsetDateTime,
}
impl HostedHookDefinition {
pub fn matches(&self, event_kind: &str) -> bool {
self.enabled
&& self
.event_kinds
.iter()
.any(|prefix| event_kind.starts_with(prefix))
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HookRetryPolicy {
pub max_attempts: u32,
pub initial_backoff_ms: u64,
pub max_backoff_ms: u64,
}
impl Default for HookRetryPolicy {
fn default() -> Self {
Self {
max_attempts: 5,
initial_backoff_ms: 1_000,
max_backoff_ms: 60_000,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum HookDeliveryStatus {
Pending,
Delivered,
Failed,
Dead,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HookDelivery {
pub id: HookDeliveryId,
pub hook_id: HookId,
pub event_kind: String,
pub status: HookDeliveryStatus,
pub attempts: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_error: Option<String>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(default, with = "time::serde::rfc3339::option")]
pub delivered_at: Option<OffsetDateTime>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HostedAuditEnvelope {
pub envelope: EventEnvelope,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tenant: Option<TenantContext>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<PrincipalContext>,
}
#[cfg(test)]
mod tests {
use super::*;
fn hook(scope: HookScope, kinds: Vec<&str>) -> HostedHookDefinition {
HostedHookDefinition {
id: "hook-1".to_string(),
scope,
tenant_id: matches!(scope, HookScope::Tenant).then(|| "tenant-a".to_string()),
event_kinds: kinds.into_iter().map(str::to_string).collect(),
url: "https://hooks.example.test/roder".to_string(),
signing_secret_ref: Some("env:RODER_HOOK_SECRET".to_string()),
enabled: true,
created_at: OffsetDateTime::UNIX_EPOCH,
updated_at: OffsetDateTime::UNIX_EPOCH,
}
}
#[test]
fn hook_matching_uses_prefixes_and_enabled_flag() {
let hook = hook(HookScope::Tenant, vec!["turn.", "thread."]);
assert!(hook.matches("turn.completed"));
assert!(hook.matches("thread.created"));
assert!(!hook.matches("tool.call_started"));
let mut disabled = hook.clone();
disabled.enabled = false;
assert!(!disabled.matches("turn.completed"));
}
#[test]
fn hook_definitions_serialize_secret_references_not_secrets() {
let hook = hook(HookScope::System, vec!["extension."]);
let json = serde_json::to_value(&hook).unwrap();
assert_eq!(json["signingSecretRef"], "env:RODER_HOOK_SECRET");
assert_eq!(json["scope"], "system");
let round_trip: HostedHookDefinition = serde_json::from_value(json).unwrap();
assert_eq!(round_trip, hook);
}
#[test]
fn retry_policy_defaults_are_bounded() {
let policy = HookRetryPolicy::default();
assert!(policy.max_attempts >= 1);
assert!(policy.initial_backoff_ms <= policy.max_backoff_ms);
}
}