Skip to main content

ryu_webhook_ingress/
host.rs

1//! The `WebhookIngressHost` seam — the narrow inversion of every kernel coupling
2//! this crate needs, so the crate has ZERO dependency on `apps/core`.
3//!
4//! Precedent: `RecipesHost`/`QuestsHost`/`ClipsHost` (`apps/core/src/*_host.rs`).
5//! Core installs its implementation once at boot via [`set_global_host`];
6//! `apps/core/src/webhook_ingress_host.rs` is the kernel side.
7//!
8//! **Acceptance line (why the trait is only leaf lookups + crypto):** all the
9//! *decisions* stay in this crate — kind resolution, URL composition, SSE parse,
10//! delivery dedup, the replay window, path routing, and the fail-closed
11//! `WorkflowWebhookOutcome` ladder. The host only performs leaf operations that
12//! genuinely live in the kernel: composio signature crypto over the configured
13//! secret, the composio store fan-out, starting a workflow run, the raw
14//! workflow-webhook-secret lookup, the auth token, the data dir, and the mesh
15//! funnel. If a routing/fail-closed decision ever moved into a host method this
16//! would be a facade, not an extraction.
17
18use std::path::PathBuf;
19use std::sync::{Arc, OnceLock};
20
21use anyhow::{anyhow, Result};
22use serde_json::Value;
23
24/// The raw result of looking up a workflow's webhook trigger secret. The crate
25/// (not the host) owns the empty-secret → `NoSecret` decision, so this returns
26/// the trigger's `secret` field verbatim (`Secret(None)` when the trigger exists
27/// but carries no secret at all).
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum WorkflowWebhookSecret {
30    /// No workflow with this id exists.
31    NotFound,
32    /// The workflow exists but declares no `Webhook` trigger.
33    NoTrigger,
34    /// The workflow has a webhook trigger; carries its (optional) secret field.
35    Secret(Option<String>),
36}
37
38/// Every kernel coupling the webhook-ingress engine needs, inverted. `dyn`-stored
39/// (→ `async_trait`), installed once at boot. Implemented by Core; the crate's own
40/// tests install a mock.
41#[async_trait::async_trait]
42pub trait WebhookIngressHost: Send + Sync {
43    // ── Composio (the trust-relay + global-secret path) ──────────────────────
44    /// Whether a Composio key is configured (the RyuRelay opt-in-by-use gate).
45    fn composio_is_configured(&self) -> bool;
46    /// Whether this node has at least one workflow declaring a `Webhook`
47    /// trigger. The second explicit-use signal that opens the RyuRelay ingress:
48    /// a user-created webhook trigger is reachable only when Core is registered
49    /// with the relay, so the presence of one is enough to justify the outbound
50    /// subscription (the composio key is not the only legitimate use).
51    fn has_webhook_trigger(&self) -> bool;
52    /// Verify an inbound Composio webhook against the global Composio secret.
53    fn verify_webhook_signature(&self, raw_body: &[u8], signature: Option<&str>) -> bool;
54    /// Verify a per-workflow webhook against a trigger-specific secret.
55    fn verify_workflow_webhook_signature(
56        &self,
57        secret: &str,
58        raw_body: &[u8],
59        signature: Option<&str>,
60    ) -> bool;
61    /// Fan a verified Composio payload out to the triggers store, returning the
62    /// number of agent runs fired. `None` when the store is not initialised.
63    async fn composio_handle_webhook(&self, payload: &Value) -> Option<usize>;
64    /// Start a workflow run seeded with the trigger payload; returns the run id.
65    async fn run_workflow_for_trigger(
66        &self,
67        workflow_id: &str,
68        payload_json: &str,
69    ) -> Result<String>;
70    /// Raw lookup of a workflow's webhook-trigger secret (no decisions applied).
71    fn workflow_webhook_secret(&self, workflow_id: &str) -> WorkflowWebhookSecret;
72
73    // ── Auth + local infra ───────────────────────────────────────────────────
74    /// This node's auth bearer token (`~/.ryu/auth.json`), if logged in.
75    fn auth_token(&self) -> Option<String>;
76    /// The `~/.ryu` data dir (where the relay token is persisted).
77    fn data_dir(&self) -> PathBuf;
78
79    // ── Mesh (Tailscale Funnel) ──────────────────────────────────────────────
80    /// Ensure a Funnel is serving `port`, returning its public base URL.
81    async fn ensure_funnel(&self, port: u16) -> Result<String>;
82    /// The active Funnel base URL for `port`, if any.
83    async fn funnel_url(&self, port: u16) -> Option<String>;
84}
85
86/// Process-global host, installed once at boot by `apps/core`.
87fn host_slot() -> &'static OnceLock<Arc<dyn WebhookIngressHost>> {
88    static HOST: OnceLock<Arc<dyn WebhookIngressHost>> = OnceLock::new();
89    &HOST
90}
91
92/// Install the host implementation. Called once from `apps/core` at startup
93/// (unconditionally — Core consumes this crate as a non-optional dependency and
94/// the public webhook routes reach it in every build). Idempotent: a second call
95/// is ignored.
96pub fn set_global_host(host: Arc<dyn WebhookIngressHost>) {
97    let _ = host_slot().set(host);
98}
99
100/// Fetch the installed host, erroring if [`set_global_host`] was never called.
101pub(crate) fn host() -> Result<Arc<dyn WebhookIngressHost>> {
102    host_slot()
103        .get()
104        .cloned()
105        .ok_or_else(|| anyhow!("webhook-ingress host not initialized"))
106}
107
108/// The installed host, or `None` when uninstalled (for the sync, best-effort
109/// callers that must not panic — e.g. [`crate::relay_inbound_url`]).
110pub(crate) fn host_opt() -> Option<Arc<dyn WebhookIngressHost>> {
111    host_slot().get().cloned()
112}