feat: add integrations

Signed-off-by: kjuulh <contact@kjuulh.io>
This commit is contained in:
2026-03-08 23:00:14 +01:00
parent 5a5f9a3003
commit 646581ff44
65 changed files with 7774 additions and 127 deletions

View File

@@ -11,6 +11,9 @@ serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
rand.workspace = true
hmac.workspace = true
sha2.workspace = true
tracing.workspace = true
[dev-dependencies]
tokio = { workspace = true, features = ["macros", "rt"] }

View File

@@ -0,0 +1,744 @@
pub mod nats;
pub mod router;
pub mod webhook;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// ── Integration types ────────────────────────────────────────────────
/// An org-level notification integration (Slack workspace, webhook URL, etc.).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Integration {
pub id: String,
pub organisation: String,
pub integration_type: IntegrationType,
pub name: String,
pub config: IntegrationConfig,
pub enabled: bool,
pub created_by: String,
pub created_at: String,
pub updated_at: String,
/// The raw API token, only populated when the integration is first created.
/// After creation, this is None (only the hash is stored).
#[serde(skip_serializing_if = "Option::is_none")]
pub api_token: Option<String>,
}
/// Supported integration types.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum IntegrationType {
Slack,
Webhook,
}
impl IntegrationType {
pub fn as_str(&self) -> &'static str {
match self {
Self::Slack => "slack",
Self::Webhook => "webhook",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"slack" => Some(Self::Slack),
"webhook" => Some(Self::Webhook),
_ => None,
}
}
pub fn display_name(&self) -> &'static str {
match self {
Self::Slack => "Slack",
Self::Webhook => "Webhook",
}
}
}
/// Type-specific configuration for an integration.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IntegrationConfig {
Slack {
team_id: String,
team_name: String,
channel_id: String,
channel_name: String,
access_token: String,
webhook_url: String,
},
Webhook {
url: String,
#[serde(default)]
secret: Option<String>,
#[serde(default)]
headers: HashMap<String, String>,
},
}
// ── Notification rules ───────────────────────────────────────────────
/// Which event types an integration should receive.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationRule {
pub id: String,
pub integration_id: String,
pub notification_type: String,
pub enabled: bool,
}
/// Known notification event types.
pub const NOTIFICATION_TYPES: &[&str] = &[
"release_annotated",
"release_started",
"release_succeeded",
"release_failed",
];
// ── Delivery log ─────────────────────────────────────────────────────
/// Record of a notification delivery attempt.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationDelivery {
pub id: String,
pub integration_id: String,
pub notification_id: String,
pub status: DeliveryStatus,
pub error_message: Option<String>,
pub attempted_at: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeliveryStatus {
Delivered,
Failed,
Pending,
}
impl DeliveryStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Delivered => "delivered",
Self::Failed => "failed",
Self::Pending => "pending",
}
}
pub fn parse(s: &str) -> Option<Self> {
match s {
"delivered" => Some(Self::Delivered),
"failed" => Some(Self::Failed),
"pending" => Some(Self::Pending),
_ => None,
}
}
}
// ── Create/Update inputs ─────────────────────────────────────────────
#[derive(Debug, Clone)]
pub struct CreateIntegrationInput {
pub organisation: String,
pub integration_type: IntegrationType,
pub name: String,
pub config: IntegrationConfig,
pub created_by: String,
}
// ── Error type ───────────────────────────────────────────────────────
#[derive(Debug, Clone, thiserror::Error)]
pub enum IntegrationError {
#[error("not found: {0}")]
NotFound(String),
#[error("duplicate: {0}")]
Duplicate(String),
#[error("invalid input: {0}")]
InvalidInput(String),
#[error("store error: {0}")]
Store(String),
#[error("encryption error: {0}")]
Encryption(String),
}
// ── Repository trait ─────────────────────────────────────────────────
/// Persistence trait for integration management. Implemented by forage-db.
#[async_trait::async_trait]
pub trait IntegrationStore: Send + Sync {
/// List all integrations for an organisation.
async fn list_integrations(
&self,
organisation: &str,
) -> Result<Vec<Integration>, IntegrationError>;
/// Get a single integration by ID (must belong to the given org).
async fn get_integration(
&self,
organisation: &str,
id: &str,
) -> Result<Integration, IntegrationError>;
/// Create a new integration with default notification rules (all enabled).
async fn create_integration(
&self,
input: &CreateIntegrationInput,
) -> Result<Integration, IntegrationError>;
/// Enable or disable an integration.
async fn set_integration_enabled(
&self,
organisation: &str,
id: &str,
enabled: bool,
) -> Result<(), IntegrationError>;
/// Delete an integration and its rules/deliveries (cascading).
async fn delete_integration(
&self,
organisation: &str,
id: &str,
) -> Result<(), IntegrationError>;
/// List notification rules for an integration.
async fn list_rules(
&self,
integration_id: &str,
) -> Result<Vec<NotificationRule>, IntegrationError>;
/// Set whether a specific notification type is enabled for an integration.
async fn set_rule_enabled(
&self,
integration_id: &str,
notification_type: &str,
enabled: bool,
) -> Result<(), IntegrationError>;
/// Record a delivery attempt.
async fn record_delivery(
&self,
integration_id: &str,
notification_id: &str,
status: DeliveryStatus,
error_message: Option<&str>,
) -> Result<(), IntegrationError>;
/// List enabled integrations for an org that have a matching rule for the given event type.
async fn list_matching_integrations(
&self,
organisation: &str,
notification_type: &str,
) -> Result<Vec<Integration>, IntegrationError>;
/// List recent delivery attempts for an integration, newest first.
async fn list_deliveries(
&self,
integration_id: &str,
limit: usize,
) -> Result<Vec<NotificationDelivery>, IntegrationError>;
/// Look up an integration by its API token hash. Used for API authentication.
async fn get_integration_by_token_hash(
&self,
token_hash: &str,
) -> Result<Integration, IntegrationError>;
}
// ── Token generation ────────────────────────────────────────────────
/// Generate a crypto-random API token for an integration.
/// Format: `fgi_` prefix + 32 bytes hex-encoded.
pub fn generate_api_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rng().fill_bytes(&mut bytes);
let encoded = hex_encode(&bytes);
format!("fgi_{encoded}")
}
/// SHA-256 hash of a token for storage. Only the hash is persisted.
pub fn hash_api_token(token: &str) -> String {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(token.as_bytes());
hex_encode(&hash)
}
fn hex_encode(data: &[u8]) -> String {
data.iter().map(|b| format!("{b:02x}")).collect()
}
// ── Validation ───────────────────────────────────────────────────────
/// Validate a webhook URL. Must be HTTPS (or localhost for development).
pub fn validate_webhook_url(url: &str) -> Result<(), IntegrationError> {
if url.starts_with("https://") {
return Ok(());
}
if url.starts_with("http://localhost") || url.starts_with("http://127.0.0.1") {
return Ok(());
}
Err(IntegrationError::InvalidInput(
"Webhook URL must use HTTPS".to_string(),
))
}
/// Validate an integration name (reuse slug rules: lowercase alphanumeric + hyphens, max 64).
pub fn validate_integration_name(name: &str) -> Result<(), IntegrationError> {
if name.is_empty() {
return Err(IntegrationError::InvalidInput(
"Integration name cannot be empty".to_string(),
));
}
if name.len() > 64 {
return Err(IntegrationError::InvalidInput(
"Integration name too long (max 64 characters)".to_string(),
));
}
// Allow more characters than slugs: spaces, #, etc. for human-readable names
if name.chars().any(|c| c.is_control()) {
return Err(IntegrationError::InvalidInput(
"Integration name contains invalid characters".to_string(),
));
}
Ok(())
}
// ── In-memory store (for tests) ──────────────────────────────────────
/// In-memory integration store for testing. Not for production use.
pub struct InMemoryIntegrationStore {
integrations: std::sync::Mutex<Vec<Integration>>,
rules: std::sync::Mutex<Vec<NotificationRule>>,
deliveries: std::sync::Mutex<Vec<NotificationDelivery>>,
/// Stores token_hash -> integration_id for lookup.
token_hashes: std::sync::Mutex<HashMap<String, String>>,
}
impl InMemoryIntegrationStore {
pub fn new() -> Self {
Self {
integrations: std::sync::Mutex::new(Vec::new()),
rules: std::sync::Mutex::new(Vec::new()),
deliveries: std::sync::Mutex::new(Vec::new()),
token_hashes: std::sync::Mutex::new(HashMap::new()),
}
}
}
impl Default for InMemoryIntegrationStore {
fn default() -> Self {
Self::new()
}
}
/// Prefix for integration API tokens.
pub const TOKEN_PREFIX: &str = "fgi_";
#[async_trait::async_trait]
impl IntegrationStore for InMemoryIntegrationStore {
async fn list_integrations(
&self,
organisation: &str,
) -> Result<Vec<Integration>, IntegrationError> {
let store = self.integrations.lock().unwrap();
Ok(store
.iter()
.filter(|i| i.organisation == organisation)
.cloned()
.collect())
}
async fn get_integration(
&self,
organisation: &str,
id: &str,
) -> Result<Integration, IntegrationError> {
let store = self.integrations.lock().unwrap();
store
.iter()
.find(|i| i.id == id && i.organisation == organisation)
.cloned()
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))
}
async fn create_integration(
&self,
input: &CreateIntegrationInput,
) -> Result<Integration, IntegrationError> {
let mut store = self.integrations.lock().unwrap();
if store
.iter()
.any(|i| i.organisation == input.organisation && i.name == input.name)
{
return Err(IntegrationError::Duplicate(format!(
"Integration '{}' already exists",
input.name
)));
}
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let raw_token = generate_api_token();
let token_hash = hash_api_token(&raw_token);
let integration = Integration {
id: id.clone(),
organisation: input.organisation.clone(),
integration_type: input.integration_type,
name: input.name.clone(),
config: input.config.clone(),
enabled: true,
created_by: input.created_by.clone(),
created_at: now.clone(),
updated_at: now,
api_token: Some(raw_token),
};
// Store without the raw token
let stored = Integration { api_token: None, ..integration.clone() };
store.push(stored);
// Store token hash
self.token_hashes.lock().unwrap().insert(token_hash, id.clone());
// Create default rules
let mut rules = self.rules.lock().unwrap();
for nt in NOTIFICATION_TYPES {
rules.push(NotificationRule {
id: uuid::Uuid::new_v4().to_string(),
integration_id: id.clone(),
notification_type: nt.to_string(),
enabled: true,
});
}
Ok(integration)
}
async fn set_integration_enabled(
&self,
organisation: &str,
id: &str,
enabled: bool,
) -> Result<(), IntegrationError> {
let mut store = self.integrations.lock().unwrap();
let integ = store
.iter_mut()
.find(|i| i.id == id && i.organisation == organisation)
.ok_or_else(|| IntegrationError::NotFound(id.to_string()))?;
integ.enabled = enabled;
Ok(())
}
async fn delete_integration(
&self,
organisation: &str,
id: &str,
) -> Result<(), IntegrationError> {
let mut store = self.integrations.lock().unwrap();
let len = store.len();
store.retain(|i| !(i.id == id && i.organisation == organisation));
if store.len() == len {
return Err(IntegrationError::NotFound(id.to_string()));
}
// Cascade delete rules
let mut rules = self.rules.lock().unwrap();
rules.retain(|r| r.integration_id != id);
Ok(())
}
async fn list_rules(
&self,
integration_id: &str,
) -> Result<Vec<NotificationRule>, IntegrationError> {
let rules = self.rules.lock().unwrap();
Ok(rules
.iter()
.filter(|r| r.integration_id == integration_id)
.cloned()
.collect())
}
async fn set_rule_enabled(
&self,
integration_id: &str,
notification_type: &str,
enabled: bool,
) -> Result<(), IntegrationError> {
let mut rules = self.rules.lock().unwrap();
if let Some(rule) = rules
.iter_mut()
.find(|r| r.integration_id == integration_id && r.notification_type == notification_type)
{
rule.enabled = enabled;
} else {
rules.push(NotificationRule {
id: uuid::Uuid::new_v4().to_string(),
integration_id: integration_id.to_string(),
notification_type: notification_type.to_string(),
enabled,
});
}
Ok(())
}
async fn record_delivery(
&self,
integration_id: &str,
notification_id: &str,
status: DeliveryStatus,
error_message: Option<&str>,
) -> Result<(), IntegrationError> {
let mut deliveries = self.deliveries.lock().unwrap();
deliveries.push(NotificationDelivery {
id: uuid::Uuid::new_v4().to_string(),
integration_id: integration_id.to_string(),
notification_id: notification_id.to_string(),
status,
error_message: error_message.map(|s| s.to_string()),
attempted_at: chrono::Utc::now().to_rfc3339(),
});
Ok(())
}
async fn list_deliveries(
&self,
integration_id: &str,
limit: usize,
) -> Result<Vec<NotificationDelivery>, IntegrationError> {
let deliveries = self.deliveries.lock().unwrap();
let mut matching: Vec<_> = deliveries
.iter()
.filter(|d| d.integration_id == integration_id)
.cloned()
.collect();
// Sort newest first (by attempted_at descending)
matching.sort_by(|a, b| b.attempted_at.cmp(&a.attempted_at));
matching.truncate(limit);
Ok(matching)
}
async fn list_matching_integrations(
&self,
organisation: &str,
notification_type: &str,
) -> Result<Vec<Integration>, IntegrationError> {
let store = self.integrations.lock().unwrap();
let rules = self.rules.lock().unwrap();
Ok(store
.iter()
.filter(|i| {
i.organisation == organisation
&& i.enabled
&& rules.iter().any(|r| {
r.integration_id == i.id
&& r.notification_type == notification_type
&& r.enabled
})
})
.cloned()
.collect())
}
async fn get_integration_by_token_hash(
&self,
token_hash: &str,
) -> Result<Integration, IntegrationError> {
let hashes = self.token_hashes.lock().unwrap();
let id = hashes
.get(token_hash)
.ok_or_else(|| IntegrationError::NotFound("invalid token".to_string()))?
.clone();
drop(hashes);
let store = self.integrations.lock().unwrap();
store
.iter()
.find(|i| i.id == id)
.cloned()
.ok_or(IntegrationError::NotFound(id))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn integration_type_roundtrip() {
for t in &[IntegrationType::Slack, IntegrationType::Webhook] {
let s = t.as_str();
assert_eq!(IntegrationType::parse(s), Some(*t));
}
}
#[test]
fn integration_type_unknown_returns_none() {
assert_eq!(IntegrationType::parse("discord"), None);
assert_eq!(IntegrationType::parse(""), None);
}
#[test]
fn delivery_status_roundtrip() {
for s in &[
DeliveryStatus::Delivered,
DeliveryStatus::Failed,
DeliveryStatus::Pending,
] {
let str = s.as_str();
assert_eq!(DeliveryStatus::parse(str), Some(*s));
}
}
#[test]
fn validate_webhook_url_https() {
assert!(validate_webhook_url("https://example.com/hook").is_ok());
}
#[test]
fn validate_webhook_url_localhost() {
assert!(validate_webhook_url("http://localhost:8080/hook").is_ok());
assert!(validate_webhook_url("http://127.0.0.1:8080/hook").is_ok());
}
#[test]
fn validate_webhook_url_http_rejected() {
assert!(validate_webhook_url("http://example.com/hook").is_err());
}
#[test]
fn validate_integration_name_valid() {
assert!(validate_integration_name("my-slack").is_ok());
assert!(validate_integration_name("#deploys").is_ok());
assert!(validate_integration_name("Production alerts").is_ok());
}
#[test]
fn validate_integration_name_empty() {
assert!(validate_integration_name("").is_err());
}
#[test]
fn validate_integration_name_too_long() {
assert!(validate_integration_name(&"a".repeat(65)).is_err());
}
#[test]
fn validate_integration_name_control_chars() {
assert!(validate_integration_name("bad\x00name").is_err());
}
#[test]
fn integration_config_slack_serde_roundtrip() {
let config = IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "My Team".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-token".into(),
webhook_url: "https://hooks.slack.com/...".into(),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
match parsed {
IntegrationConfig::Slack { team_id, .. } => assert_eq!(team_id, "T123"),
_ => panic!("expected Slack config"),
}
}
#[test]
fn integration_config_webhook_serde_roundtrip() {
let config = IntegrationConfig::Webhook {
url: "https://example.com/hook".into(),
secret: Some("s3cret".into()),
headers: HashMap::from([("X-Custom".into(), "value".into())]),
};
let json = serde_json::to_string(&config).unwrap();
let parsed: IntegrationConfig = serde_json::from_str(&json).unwrap();
match parsed {
IntegrationConfig::Webhook { url, secret, headers } => {
assert_eq!(url, "https://example.com/hook");
assert_eq!(secret.as_deref(), Some("s3cret"));
assert_eq!(headers.get("X-Custom").map(|s| s.as_str()), Some("value"));
}
_ => panic!("expected Webhook config"),
}
}
#[test]
fn notification_types_are_known() {
assert_eq!(NOTIFICATION_TYPES.len(), 4);
assert!(NOTIFICATION_TYPES.contains(&"release_failed"));
}
#[test]
fn generate_api_token_has_prefix_and_length() {
let token = generate_api_token();
assert!(token.starts_with("fgi_"));
// fgi_ (4) + 64 hex chars (32 bytes) = 68 total
assert_eq!(token.len(), 68);
}
#[test]
fn generate_api_token_is_unique() {
let t1 = generate_api_token();
let t2 = generate_api_token();
assert_ne!(t1, t2);
}
#[test]
fn hash_api_token_is_deterministic() {
let token = "fgi_abcdef1234567890";
let h1 = hash_api_token(token);
let h2 = hash_api_token(token);
assert_eq!(h1, h2);
assert_eq!(h1.len(), 64); // SHA-256 = 32 bytes = 64 hex chars
}
#[test]
fn hash_api_token_different_for_different_tokens() {
let h1 = hash_api_token("fgi_token_one");
let h2 = hash_api_token("fgi_token_two");
assert_ne!(h1, h2);
}
#[tokio::test]
async fn in_memory_store_creates_with_api_token() {
let store = InMemoryIntegrationStore::new();
let created = store
.create_integration(&CreateIntegrationInput {
organisation: "myorg".into(),
integration_type: IntegrationType::Webhook,
name: "test-hook".into(),
config: IntegrationConfig::Webhook {
url: "https://example.com/hook".into(),
secret: None,
headers: HashMap::new(),
},
created_by: "user-1".into(),
})
.await
.unwrap();
// Token is returned on creation
assert!(created.api_token.is_some());
let token = created.api_token.unwrap();
assert!(token.starts_with("fgi_"));
// Token lookup works
let token_hash = hash_api_token(&token);
let found = store.get_integration_by_token_hash(&token_hash).await.unwrap();
assert_eq!(found.id, created.id);
assert!(found.api_token.is_none()); // not stored in plaintext
// Stored integration doesn't have the raw token
let listed = store.list_integrations("myorg").await.unwrap();
assert!(listed[0].api_token.is_none());
}
}

View File

@@ -0,0 +1,164 @@
use serde::{Deserialize, Serialize};
use super::router::{NotificationEvent, ReleaseContext};
/// Wire format for notification events published to NATS JetStream.
/// Mirrors `NotificationEvent` with serde support.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationEnvelope {
pub id: String,
pub notification_type: String,
pub title: String,
pub body: String,
pub organisation: String,
pub project: String,
pub timestamp: String,
pub release: Option<ReleaseContextEnvelope>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleaseContextEnvelope {
pub slug: String,
pub artifact_id: String,
pub destination: String,
pub environment: String,
pub source_username: String,
pub commit_sha: String,
pub commit_branch: String,
pub error_message: Option<String>,
}
impl From<&NotificationEvent> for NotificationEnvelope {
fn from(e: &NotificationEvent) -> Self {
Self {
id: e.id.clone(),
notification_type: e.notification_type.clone(),
title: e.title.clone(),
body: e.body.clone(),
organisation: e.organisation.clone(),
project: e.project.clone(),
timestamp: e.timestamp.clone(),
release: e.release.as_ref().map(|r| ReleaseContextEnvelope {
slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(),
destination: r.destination.clone(),
environment: r.environment.clone(),
source_username: r.source_username.clone(),
commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.clone(),
error_message: r.error_message.clone(),
}),
}
}
}
impl From<NotificationEnvelope> for NotificationEvent {
fn from(e: NotificationEnvelope) -> Self {
Self {
id: e.id,
notification_type: e.notification_type,
title: e.title,
body: e.body,
organisation: e.organisation,
project: e.project,
timestamp: e.timestamp,
release: e.release.map(|r| ReleaseContext {
slug: r.slug,
artifact_id: r.artifact_id,
destination: r.destination,
environment: r.environment,
source_username: r.source_username,
commit_sha: r.commit_sha,
commit_branch: r.commit_branch,
error_message: r.error_message,
}),
}
}
}
/// Build the NATS subject for a notification event.
/// Format: `forage.notifications.{org}.{type}`
pub fn notification_subject(organisation: &str, notification_type: &str) -> String {
format!("forage.notifications.{organisation}.{notification_type}")
}
/// The stream name used for notification delivery.
pub const STREAM_NAME: &str = "FORAGE_NOTIFICATIONS";
/// Subject filter for the stream (captures all orgs and types).
pub const STREAM_SUBJECTS: &str = "forage.notifications.>";
/// Durable consumer name for webhook dispatchers.
pub const CONSUMER_NAME: &str = "forage-webhook-dispatcher";
#[cfg(test)]
mod tests {
use super::*;
fn test_event() -> NotificationEvent {
NotificationEvent {
id: "notif-1".into(),
notification_type: "release_failed".into(),
title: "Release failed".into(),
body: "Container timeout".into(),
organisation: "acme-corp".into(),
project: "my-service".into(),
timestamp: "2026-03-09T14:30:00Z".into(),
release: Some(ReleaseContext {
slug: "v1.2.3".into(),
artifact_id: "art_123".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "alice".into(),
commit_sha: "abc1234def".into(),
commit_branch: "main".into(),
error_message: Some("health check timeout".into()),
}),
}
}
#[test]
fn envelope_roundtrip() {
let event = test_event();
let envelope = NotificationEnvelope::from(&event);
let json = serde_json::to_string(&envelope).unwrap();
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
let restored: NotificationEvent = parsed.into();
assert_eq!(restored.id, event.id);
assert_eq!(restored.notification_type, event.notification_type);
assert_eq!(restored.organisation, event.organisation);
assert_eq!(restored.project, event.project);
let r = restored.release.unwrap();
let orig = event.release.unwrap();
assert_eq!(r.slug, orig.slug);
assert_eq!(r.error_message, orig.error_message);
}
#[test]
fn envelope_without_release() {
let event = NotificationEvent {
id: "n2".into(),
notification_type: "release_started".into(),
title: "Starting".into(),
body: String::new(),
organisation: "org".into(),
project: "proj".into(),
timestamp: "2026-03-09T00:00:00Z".into(),
release: None,
};
let envelope = NotificationEnvelope::from(&event);
let json = serde_json::to_string(&envelope).unwrap();
let parsed: NotificationEnvelope = serde_json::from_str(&json).unwrap();
let restored: NotificationEvent = parsed.into();
assert!(restored.release.is_none());
}
#[test]
fn notification_subject_format() {
assert_eq!(
notification_subject("acme-corp", "release_failed"),
"forage.notifications.acme-corp.release_failed"
);
}
}

View File

@@ -0,0 +1,399 @@
use super::{Integration, IntegrationConfig, IntegrationStore};
use super::webhook::{ReleasePayload, WebhookPayload};
/// A notification event from Forest, normalized for routing.
#[derive(Debug, Clone)]
pub struct NotificationEvent {
pub id: String,
pub notification_type: String,
pub title: String,
pub body: String,
pub organisation: String,
pub project: String,
pub timestamp: String,
pub release: Option<ReleaseContext>,
}
/// Release context from the notification event.
#[derive(Debug, Clone)]
pub struct ReleaseContext {
pub slug: String,
pub artifact_id: String,
pub destination: String,
pub environment: String,
pub source_username: String,
pub commit_sha: String,
pub commit_branch: String,
pub error_message: Option<String>,
}
/// A dispatch task produced by the router: what to send where.
#[derive(Debug, Clone)]
#[allow(clippy::large_enum_variant)]
pub enum DispatchTask {
Webhook {
integration_id: String,
url: String,
secret: Option<String>,
headers: std::collections::HashMap<String, String>,
payload: WebhookPayload,
},
Slack {
integration_id: String,
webhook_url: String,
message: SlackMessage,
},
}
/// A formatted Slack message (Block Kit compatible).
#[derive(Debug, Clone, serde::Serialize)]
pub struct SlackMessage {
pub text: String,
pub color: String,
pub blocks: Vec<serde_json::Value>,
}
/// Route a notification event to dispatch tasks based on matching integrations.
pub fn route_notification(
event: &NotificationEvent,
integrations: &[Integration],
) -> Vec<DispatchTask> {
let payload = build_webhook_payload(event);
integrations
.iter()
.map(|integration| match &integration.config {
IntegrationConfig::Webhook {
url,
secret,
headers,
} => DispatchTask::Webhook {
integration_id: integration.id.clone(),
url: url.clone(),
secret: secret.clone(),
headers: headers.clone(),
payload: payload.clone(),
},
IntegrationConfig::Slack { webhook_url, .. } => {
let message = format_slack_message(event);
DispatchTask::Slack {
integration_id: integration.id.clone(),
webhook_url: webhook_url.clone(),
message,
}
}
})
.collect()
}
/// Find matching integrations and produce dispatch tasks.
pub async fn route_notification_for_org(
store: &dyn IntegrationStore,
event: &NotificationEvent,
) -> Vec<DispatchTask> {
match store
.list_matching_integrations(&event.organisation, &event.notification_type)
.await
{
Ok(integrations) => route_notification(event, &integrations),
Err(e) => {
tracing::error!(org = %event.organisation, error = %e, "failed to list matching integrations");
vec![]
}
}
}
fn build_webhook_payload(event: &NotificationEvent) -> WebhookPayload {
WebhookPayload {
event: event.notification_type.clone(),
timestamp: event.timestamp.clone(),
organisation: event.organisation.clone(),
project: event.project.clone(),
notification_id: event.id.clone(),
title: event.title.clone(),
body: event.body.clone(),
release: event.release.as_ref().map(|r| ReleasePayload {
slug: r.slug.clone(),
artifact_id: r.artifact_id.clone(),
destination: r.destination.clone(),
environment: r.environment.clone(),
source_username: r.source_username.clone(),
commit_sha: r.commit_sha.clone(),
commit_branch: r.commit_branch.clone(),
error_message: r.error_message.clone(),
}),
}
}
fn format_slack_message(event: &NotificationEvent) -> SlackMessage {
let color = match event.notification_type.as_str() {
"release_succeeded" => "#36a64f",
"release_failed" => "#dc3545",
"release_started" => "#0d6efd",
"release_annotated" => "#6c757d",
_ => "#6c757d",
};
let status_emoji = match event.notification_type.as_str() {
"release_succeeded" => ":white_check_mark:",
"release_failed" => ":x:",
"release_started" => ":rocket:",
"release_annotated" => ":memo:",
_ => ":bell:",
};
// Fallback text (shown in notifications/previews)
let text = format!("{} {}", status_emoji, event.title);
// Build Block Kit blocks
let mut blocks: Vec<serde_json::Value> = Vec::new();
// Header
blocks.push(serde_json::json!({
"type": "header",
"text": {
"type": "plain_text",
"text": event.title,
"emoji": true
}
}));
// Body section (if present)
if !event.body.is_empty() {
blocks.push(serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": event.body
}
}));
}
// Release metadata fields
if let Some(ref r) = event.release {
let mut fields = vec![
serde_json::json!({
"type": "mrkdwn",
"text": format!("*Organisation*\n{}", event.organisation)
}),
serde_json::json!({
"type": "mrkdwn",
"text": format!("*Project*\n{}", event.project)
}),
];
if !r.destination.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Destination*\n`{}`", r.destination)
}));
}
if !r.environment.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Environment*\n{}", r.environment)
}));
}
if !r.commit_sha.is_empty() {
let short_sha = &r.commit_sha[..r.commit_sha.len().min(7)];
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Commit*\n`{}`", short_sha)
}));
}
if !r.commit_branch.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Branch*\n`{}`", r.commit_branch)
}));
}
if !r.source_username.is_empty() {
fields.push(serde_json::json!({
"type": "mrkdwn",
"text": format!("*Author*\n{}", r.source_username)
}));
}
blocks.push(serde_json::json!({
"type": "section",
"fields": fields
}));
// Error message (if any)
if let Some(ref err) = r.error_message {
blocks.push(serde_json::json!({
"type": "section",
"text": {
"type": "mrkdwn",
"text": format!(":warning: *Error:* {}", err)
}
}));
}
}
// Context line with timestamp
blocks.push(serde_json::json!({
"type": "context",
"elements": [{
"type": "mrkdwn",
"text": format!("{} | {}", event.notification_type.replace('_', " "), event.timestamp)
}]
}));
SlackMessage {
text,
color: color.to_string(),
blocks,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
fn test_event() -> NotificationEvent {
NotificationEvent {
id: "notif-1".into(),
notification_type: "release_failed".into(),
title: "Release failed".into(),
body: "Container timeout".into(),
organisation: "test-org".into(),
project: "my-project".into(),
timestamp: "2026-03-09T14:30:00Z".into(),
release: Some(ReleaseContext {
slug: "test-release".into(),
artifact_id: "art_123".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "alice".into(),
commit_sha: "abc1234def".into(),
commit_branch: "main".into(),
error_message: Some("health check timeout".into()),
}),
}
}
fn webhook_integration(id: &str) -> Integration {
Integration {
id: id.into(),
organisation: "test-org".into(),
integration_type: super::super::IntegrationType::Webhook,
name: "prod-alerts".into(),
config: IntegrationConfig::Webhook {
url: "https://hooks.example.com/test".into(),
secret: Some("s3cret".into()),
headers: HashMap::new(),
},
enabled: true,
created_by: "user-1".into(),
created_at: "2026-03-09T00:00:00Z".into(),
updated_at: "2026-03-09T00:00:00Z".into(),
api_token: None,
}
}
fn slack_integration(id: &str) -> Integration {
Integration {
id: id.into(),
organisation: "test-org".into(),
integration_type: super::super::IntegrationType::Slack,
name: "#deploys".into(),
config: IntegrationConfig::Slack {
team_id: "T123".into(),
team_name: "Test".into(),
channel_id: "C456".into(),
channel_name: "#deploys".into(),
access_token: "xoxb-test".into(),
webhook_url: "https://hooks.slack.com/test".into(),
},
enabled: true,
created_by: "user-1".into(),
created_at: "2026-03-09T00:00:00Z".into(),
updated_at: "2026-03-09T00:00:00Z".into(),
api_token: None,
}
}
#[test]
fn route_to_webhook() {
let event = test_event();
let integrations = vec![webhook_integration("w1")];
let tasks = route_notification(&event, &integrations);
assert_eq!(tasks.len(), 1);
match &tasks[0] {
DispatchTask::Webhook {
integration_id,
url,
secret,
payload,
..
} => {
assert_eq!(integration_id, "w1");
assert_eq!(url, "https://hooks.example.com/test");
assert_eq!(secret.as_deref(), Some("s3cret"));
assert_eq!(payload.event, "release_failed");
assert_eq!(payload.organisation, "test-org");
}
_ => panic!("expected Webhook task"),
}
}
#[test]
fn route_to_slack() {
let event = test_event();
let integrations = vec![slack_integration("s1")];
let tasks = route_notification(&event, &integrations);
assert_eq!(tasks.len(), 1);
match &tasks[0] {
DispatchTask::Slack {
integration_id,
message,
..
} => {
assert_eq!(integration_id, "s1");
assert!(message.text.contains("Release failed"));
assert_eq!(message.color, "#dc3545"); // red for failure
}
_ => panic!("expected Slack task"),
}
}
#[test]
fn route_to_multiple_integrations() {
let event = test_event();
let integrations = vec![webhook_integration("w1"), slack_integration("s1")];
let tasks = route_notification(&event, &integrations);
assert_eq!(tasks.len(), 2);
}
#[test]
fn route_to_empty_integrations() {
let event = test_event();
let tasks = route_notification(&event, &[]);
assert!(tasks.is_empty());
}
#[test]
fn slack_message_color_success() {
let mut event = test_event();
event.notification_type = "release_succeeded".into();
let msg = format_slack_message(&event);
assert_eq!(msg.color, "#36a64f");
}
#[test]
fn slack_message_includes_error() {
let event = test_event();
let msg = format_slack_message(&event);
// Error message is rendered in blocks, not the fallback text field
let blocks_str = serde_json::to_string(&msg.blocks).unwrap();
assert!(blocks_str.contains("health check timeout"));
}
}

View File

@@ -0,0 +1,116 @@
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
/// The JSON payload delivered to webhook integrations.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookPayload {
pub event: String,
pub timestamp: String,
pub organisation: String,
pub project: String,
pub notification_id: String,
pub title: String,
pub body: String,
pub release: Option<ReleasePayload>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReleasePayload {
pub slug: String,
pub artifact_id: String,
pub destination: String,
pub environment: String,
pub source_username: String,
pub commit_sha: String,
pub commit_branch: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_message: Option<String>,
}
/// Compute HMAC-SHA256 signature for a webhook payload.
/// Returns hex-encoded signature prefixed with "sha256=".
pub fn sign_payload(body: &[u8], secret: &str) -> String {
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes())
.expect("HMAC accepts any key length");
mac.update(body);
let result = mac.finalize().into_bytes();
format!("sha256={}", hex_encode(&result))
}
fn hex_encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for b in bytes {
out.push_str(&format!("{b:02x}"));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sign_payload_produces_hex_signature() {
let sig = sign_payload(b"hello world", "my-secret");
assert!(sig.starts_with("sha256="));
assert_eq!(sig.len(), 7 + 64); // "sha256=" + 64 hex chars
}
#[test]
fn sign_payload_deterministic() {
let a = sign_payload(b"test body", "key");
let b = sign_payload(b"test body", "key");
assert_eq!(a, b);
}
#[test]
fn sign_payload_different_keys_differ() {
let a = sign_payload(b"body", "key1");
let b = sign_payload(b"body", "key2");
assert_ne!(a, b);
}
#[test]
fn webhook_payload_serializes() {
let payload = WebhookPayload {
event: "release_failed".into(),
timestamp: "2026-03-09T14:30:00Z".into(),
organisation: "test-org".into(),
project: "my-project".into(),
notification_id: "notif-123".into(),
title: "Release failed".into(),
body: "Container health check timeout".into(),
release: Some(ReleasePayload {
slug: "test-release".into(),
artifact_id: "art_123".into(),
destination: "prod-eu".into(),
environment: "production".into(),
source_username: "alice".into(),
commit_sha: "abc1234".into(),
commit_branch: "main".into(),
error_message: Some("timeout".into()),
}),
};
let json = serde_json::to_string(&payload).unwrap();
assert!(json.contains("release_failed"));
assert!(json.contains("prod-eu"));
}
#[test]
fn webhook_payload_without_release() {
let payload = WebhookPayload {
event: "release_annotated".into(),
timestamp: "2026-03-09T14:30:00Z".into(),
organisation: "test-org".into(),
project: "my-project".into(),
notification_id: "notif-456".into(),
title: "Annotated".into(),
body: "A note".into(),
release: None,
};
let json = serde_json::to_string(&payload).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["release"].is_null());
}
}

View File

@@ -1,6 +1,7 @@
pub mod auth;
pub mod session;
pub mod platform;
pub mod integrations;
pub mod registry;
pub mod deployments;
pub mod billing;

View File

@@ -319,6 +319,14 @@ pub enum PlatformError {
Other(String),
}
/// A user's notification preference for a specific event type + channel.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationPreference {
pub notification_type: String,
pub channel: String,
pub enabled: bool,
}
/// Trait for platform data from forest-server (organisations, projects, artifacts).
/// Separate from `ForestAuth` which handles identity.
#[async_trait::async_trait]
@@ -546,6 +554,19 @@ pub trait ForestPlatform: Send + Sync {
access_token: &str,
artifact_id: &str,
) -> Result<String, PlatformError>;
async fn get_notification_preferences(
&self,
access_token: &str,
) -> Result<Vec<NotificationPreference>, PlatformError>;
async fn set_notification_preference(
&self,
access_token: &str,
notification_type: &str,
channel: &str,
enabled: bool,
) -> Result<(), PlatformError>;
}
#[cfg(test)]