use anyhow::Context;
use serde_json::{json, Value};
use tandem_types::TenantContext;
use super::automation_webhook_store::{secret_digest, secret_material_key};
use crate::automation_v2::types::{
normalize_automation_webhook_provider, AutomationWebhookNotionVerification,
AutomationWebhookNotionVerificationStatus,
};
use crate::util::time::now_ms;
use crate::{AppState, AutomationWebhookDeliveryStatus, AutomationWebhookTriggerRecord};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AutomationWebhookNotionIntake {
NotApplicable,
Captured,
Ignored,
}
impl AppState {
pub(crate) async fn handle_automation_webhook_notion_verification(
&self,
public_path_token: &str,
body: &[u8],
has_notion_signature: bool,
received_at_ms: u64,
) -> AutomationWebhookNotionIntake {
if has_notion_signature {
return AutomationWebhookNotionIntake::NotApplicable;
}
let Some(_token) = extract_notion_verification_token(body) else {
return AutomationWebhookNotionIntake::NotApplicable;
};
let Some(trigger) = self
.get_automation_webhook_trigger_by_public_token(public_path_token)
.await
else {
return AutomationWebhookNotionIntake::NotApplicable;
};
if normalize_automation_webhook_provider(&trigger.provider).as_deref() != Some("notion") {
return AutomationWebhookNotionIntake::NotApplicable;
}
let status = trigger
.notion_verification
.as_ref()
.map(|verification| verification.status)
.unwrap_or_default();
let body_digest = super::automation_webhook_body_digest(body);
if status != AutomationWebhookNotionVerificationStatus::AwaitingToken {
let _ = self
.record_automation_webhook_rejection(
&trigger,
None,
body_digest,
AutomationWebhookDeliveryStatus::Suppressed,
"notion_verification_token_ignored",
received_at_ms,
json!({ "notion_verification": "ignored", "reason": "already_received" }),
None,
)
.await;
return AutomationWebhookNotionIntake::Ignored;
}
let applied = match self
.store_notion_verification_token(&trigger, body, received_at_ms)
.await
{
Ok(applied) => applied,
Err(error) => {
tracing::warn!(
target: "tandem_server::state",
error = ?error,
trigger_id = %trigger.trigger_id,
"failed to store notion verification token"
);
return AutomationWebhookNotionIntake::NotApplicable;
}
};
if !applied {
let _ = self
.record_automation_webhook_rejection(
&trigger,
None,
body_digest,
AutomationWebhookDeliveryStatus::Suppressed,
"notion_verification_token_ignored",
received_at_ms,
json!({ "notion_verification": "ignored", "reason": "already_received" }),
None,
)
.await;
return AutomationWebhookNotionIntake::Ignored;
}
let _ = self
.record_automation_webhook_rejection(
&trigger,
None,
body_digest,
AutomationWebhookDeliveryStatus::Received,
"notion_verification_token_received",
received_at_ms,
json!({ "notion_verification": "token_received" }),
None,
)
.await;
AutomationWebhookNotionIntake::Captured
}
async fn store_notion_verification_token(
&self,
trigger: &AutomationWebhookTriggerRecord,
body: &[u8],
received_at_ms: u64,
) -> anyhow::Result<bool> {
let token = extract_notion_verification_token(body)
.context("missing verification_token in notion verification body")?;
let _guard = self.automation_webhook_persistence.lock().await;
let (secret_ref, tenant_context) = {
let triggers = self.automation_webhook_triggers.read().await;
let stored = triggers
.get(&trigger.trigger_id)
.context("notion trigger not found")?;
let status = stored
.notion_verification
.as_ref()
.map(|verification| verification.status)
.unwrap_or_default();
if status != AutomationWebhookNotionVerificationStatus::AwaitingToken {
return Ok(false);
}
(
stored.secret.secret_ref.clone(),
stored.tenant_context.clone(),
)
};
let key = secret_material_key(&secret_ref);
{
let mut materials = self.automation_webhook_secret_material.write().await;
let material = materials
.get_mut(&key)
.context("notion trigger secret material not found")?;
if material.trigger_id != trigger.trigger_id
|| material.tenant_context.org_id != tenant_context.org_id
|| material.tenant_context.workspace_id != tenant_context.workspace_id
{
anyhow::bail!("notion verification token tenant/trigger binding mismatch");
}
material.secret = token.clone();
}
self.persist_automation_webhook_secret_material_locked()
.await?;
let digest = secret_digest(&token, &tenant_context, &trigger.trigger_id);
{
let mut triggers = self.automation_webhook_triggers.write().await;
let stored = triggers
.get_mut(&trigger.trigger_id)
.context("notion trigger not found")?;
stored.secret.secret_digest = digest;
let verification = stored
.notion_verification
.get_or_insert_with(AutomationWebhookNotionVerification::default);
verification.status = AutomationWebhookNotionVerificationStatus::TokenReceived;
verification.token_received_at_ms = Some(received_at_ms);
verification.token_revealed_at_ms = None;
verification.verified_at_ms = None;
stored.updated_at_ms = received_at_ms;
}
self.persist_automation_webhook_triggers_locked().await?;
Ok(true)
}
pub(crate) async fn reveal_automation_webhook_notion_verification_token(
&self,
tenant_context: &TenantContext,
automation_id: &str,
trigger_id: &str,
) -> anyhow::Result<Option<String>> {
let _guard = self.automation_webhook_persistence.lock().await;
let secret_ref = {
let triggers = self.automation_webhook_triggers.read().await;
let Some(trigger) = triggers.get(trigger_id).filter(|trigger| {
trigger.tenant_matches(tenant_context) && trigger.automation_id == automation_id
}) else {
return Ok(None);
};
let available = trigger
.notion_verification
.as_ref()
.map(AutomationWebhookNotionVerification::token_available_for_reveal)
.unwrap_or(false);
if !available {
return Ok(None);
}
trigger.secret.secret_ref.clone()
};
let token = {
let materials = self.automation_webhook_secret_material.read().await;
materials
.get(&secret_material_key(&secret_ref))
.filter(|material| {
material.trigger_id == trigger_id
&& material.tenant_context.org_id == tenant_context.org_id
&& material.tenant_context.workspace_id == tenant_context.workspace_id
})
.map(|material| material.secret.clone())
};
let Some(token) = token else {
return Ok(None);
};
{
let mut triggers = self.automation_webhook_triggers.write().await;
if let Some(trigger) = triggers.get_mut(trigger_id) {
if let Some(verification) = trigger.notion_verification.as_mut() {
verification.token_revealed_at_ms = Some(now_ms());
}
trigger.updated_at_ms = now_ms();
}
}
self.persist_automation_webhook_triggers_locked().await?;
Ok(Some(token))
}
}
fn extract_notion_verification_token(body: &[u8]) -> Option<String> {
let value: Value = serde_json::from_slice(body).ok()?;
value
.get("verification_token")
.and_then(Value::as_str)
.map(str::trim)
.filter(|token| !token.is_empty())
.map(ToString::to_string)
}