Skip to main content

fraiseql_functions/host/
mod.rs

1//! Host context trait for function runtime access to FraiseQL services.
2
3use std::future::Future;
4
5use fraiseql_error::Result;
6
7use crate::types::{EventPayload, LogEntry, LogLevel};
8
9#[cfg(feature = "host-live")]
10pub mod before_mutation;
11
12#[cfg(feature = "host-live")]
13pub mod live;
14// Gated with `live` because both read `HostContextConfig` (the SSRF allowlist and
15// the timeouts) out of it, and `request_query` additionally names `fraiseql-core`'s
16// `GuestQueryBridge` — which is exactly what `host-live` pulls in.
17#[cfg(feature = "host-live")]
18pub mod outbound_http;
19#[cfg(feature = "host-live")]
20pub mod request_query;
21
22// Not feature-gated: the object-safe host contract is what a *caller* needs to
23// hand a live host to `FunctionObserver::invoke_with_context`, and the
24// `before:mutation` chain (#1328) does that from `fraiseql-server`'s ungated
25// `routes::before_mutation`. Compiling a trait definition and a blanket impl
26// costs a build with no runtime nothing; making the caller feature-gated would
27// have cost it a second, drifting host shape.
28pub mod dyn_context;
29
30#[cfg(any(feature = "runtime-wasm", feature = "runtime-deno"))]
31pub mod runtime_pinned;
32
33/// The guest-facing projection of an authenticated identity, shared by every host
34/// that answers `fraiseql_auth_context`.
35///
36/// One function, not one per host: the set of claims a guest may see is a
37/// security decision, and a second copy of it is a second place to forget
38/// `ip_address` is not in it. Deliberately excludes everything sensitive — the IP
39/// address, the raw token, the issuer/audience.
40///
41/// `email` / `display_name` are the connected user's verified identity — the
42/// per-user sending address a paired outbound email must use (see
43/// [`crate::outbound::resolve_sender_identity`]). They are `null` when the
44/// authenticated identity carries none.
45#[cfg(feature = "host-live")]
46#[must_use]
47pub fn auth_context_json(context: &fraiseql_core::security::SecurityContext) -> serde_json::Value {
48    serde_json::json!({
49        "sub": context.user_id,
50        "user_id": context.user_id, // Alias for convenience
51        "roles": context.roles,
52        "scopes": context.scopes,
53        "tenant_id": context.tenant_id,
54        "email": context.email,
55        "display_name": context.display_name,
56        "expires_at": context.expires_at.to_rfc3339(),
57        "authenticated_at": context.authenticated_at.to_rfc3339(),
58    })
59}
60
61/// Response from an HTTP request.
62#[derive(Debug, Clone)]
63pub struct HttpResponse {
64    /// HTTP status code.
65    pub status:  u16,
66    /// Response headers.
67    pub headers: Vec<(String, String)>,
68    /// Response body.
69    pub body:    Vec<u8>,
70}
71
72/// Trait for providing host services to functions (queries, storage, HTTP, etc.).
73///
74/// This trait is implemented by the FraiseQL server to allow functions to call
75/// back into the server's services during execution.
76///
77/// The `#[trait_variant::make]` macro generates `SendHostContext` which is
78/// object-safe for `Box<dyn SendHostContext>` dynamic dispatch.
79#[allow(clippy::trait_duplication_in_bounds)] // Reason: trait_variant::make expands to bounds that look duplicated to clippy
80#[trait_variant::make(SendHostContext: Send)]
81pub trait HostContext: Send + Sync {
82    /// Execute a GraphQL query.
83    ///
84    /// # Errors
85    ///
86    /// Returns `Err` if the query fails to execute.
87    fn query(
88        &self,
89        graphql: &str,
90        variables: serde_json::Value,
91    ) -> impl Future<Output = Result<serde_json::Value>> + Send;
92
93    /// Execute a raw SQL query.
94    ///
95    /// # Errors
96    ///
97    /// Returns `Err` if the query fails to execute or is classified as insecure.
98    fn sql_query(
99        &self,
100        sql: &str,
101        params: &[serde_json::Value],
102    ) -> impl Future<Output = Result<Vec<serde_json::Value>>> + Send;
103
104    /// Make an HTTP request.
105    ///
106    /// # Errors
107    ///
108    /// Returns `Err` if the request fails or is blocked (e.g., SSRF check).
109    fn http_request(
110        &self,
111        method: &str,
112        url: &str,
113        headers: &[(String, String)],
114        body: Option<&[u8]>,
115    ) -> impl Future<Output = Result<HttpResponse>> + Send;
116
117    /// Retrieve an object from storage.
118    ///
119    /// # Errors
120    ///
121    /// Returns `Err` if the object does not exist or access is denied.
122    fn storage_get(&self, bucket: &str, key: &str) -> impl Future<Output = Result<Vec<u8>>> + Send;
123
124    /// Store an object to storage.
125    ///
126    /// # Errors
127    ///
128    /// Returns `Err` if the write fails or access is denied.
129    fn storage_put(
130        &self,
131        bucket: &str,
132        key: &str,
133        body: &[u8],
134        content_type: &str,
135    ) -> impl Future<Output = Result<()>> + Send;
136
137    /// Send an email.
138    ///
139    /// The `from` is **host-owned**: it is resolved by the host from the
140    /// authenticated identity, never supplied by the guest (a `from` field in the
141    /// guest request is ignored). The request carries only `to`/`subject`/body.
142    ///
143    /// # Errors
144    ///
145    /// Returns `Err` if no sender-identity resolver or transport is configured, the
146    /// sender identity cannot be resolved (fail-closed), or the relay rejects the
147    /// message.
148    fn send_email(
149        &self,
150        request: &crate::outbound::SendEmailRequest,
151    ) -> impl Future<Output = Result<crate::outbound::SendEmailResponse>> + Send;
152
153    /// Get the current authenticated user's context.
154    ///
155    /// # Errors
156    ///
157    /// Returns `Err` if authentication information is unavailable.
158    fn auth_context(&self) -> Result<serde_json::Value>;
159
160    /// Get an environment variable.
161    ///
162    /// Returns `Ok(None)` if the variable is not set.
163    ///
164    /// # Errors
165    ///
166    /// Returns `Err` if the variable is blocked from access.
167    fn env_var(&self, name: &str) -> Result<Option<String>>;
168
169    /// Get the current event payload (for reference).
170    fn event_payload(&self) -> &EventPayload;
171
172    /// Log a message to the tracing subscriber.
173    fn log(&self, level: LogLevel, message: &str);
174
175    /// A per-dispatch idempotency token, stable across retries of the same
176    /// dispatch and distinct per dispatch.
177    ///
178    /// `None` on invocation paths that are not durably dispatched (the bare sync
179    /// `invoke` path, or a mock host that does not set one). The durable
180    /// dispatcher derives the token from the dispatch's stable identity and
181    /// injects it so a guest can pass it straight to a downstream money/mail
182    /// idempotency header, keeping an at-least-once dispatch at-most-once end to
183    /// end. Defaults to `None` so only hosts that carry a token override it.
184    fn idempotency_token(&self) -> Option<String> {
185        None
186    }
187
188    /// Read this source's durable opaque cursor (Model B pull sources, #573).
189    ///
190    /// Returns the JSON value the source last advanced to via
191    /// [`advance_cursor`](Self::advance_cursor), or `None` if it has never advanced.
192    /// Defaults to `None` on every host not bound to a source — a non-source
193    /// invocation has no cursor — so only source-bound hosts override it.
194    ///
195    /// # Errors
196    ///
197    /// Returns `Err` if the cursor load fails on a source-bound host.
198    fn cursor(&self) -> impl Future<Output = Result<Option<serde_json::Value>>> + Send {
199        async { Ok(None) }
200    }
201
202    /// Advance this source's durable cursor to `value` (Model B pull sources, #573).
203    ///
204    /// The value is opaque and owned by the source. Defaults to failing on a host
205    /// not bound to a source, so a guest that is not a scheduled source cannot move
206    /// a cursor.
207    ///
208    /// # Errors
209    ///
210    /// Returns `Err` on a host not bound to a source (the default), or if the write
211    /// fails / lost a concurrent race on a source-bound host.
212    fn advance_cursor(&self, value: serde_json::Value) -> impl Future<Output = Result<()>> + Send {
213        async move {
214            let _ = value;
215            Err(fraiseql_error::FraiseQLError::validation(
216                "advance_cursor: this function is not a scheduled source (no cursor binding)",
217            ))
218        }
219    }
220}
221
222/// A no-op host context for testing WASM execution without real backends.
223///
224/// All I/O methods return `Unsupported` errors. Logs are captured in-memory for test verification.
225pub struct NoopHostContext {
226    event_payload: EventPayload,
227    logs:          std::sync::Arc<std::sync::Mutex<Vec<LogEntry>>>,
228}
229
230impl NoopHostContext {
231    /// Create a new no-op host context for testing.
232    #[must_use]
233    pub fn new(event_payload: EventPayload) -> Self {
234        Self {
235            event_payload,
236            logs: std::sync::Arc::new(std::sync::Mutex::new(Vec::new())),
237        }
238    }
239
240    /// Get a copy of all captured logs (for test verification).
241    ///
242    /// # Panics
243    ///
244    /// Panics if the Mutex is poisoned (should never happen in normal operation).
245    #[must_use]
246    pub fn captured_logs(&self) -> Vec<LogEntry> {
247        self.logs.lock().expect("log mutex poisoned").clone()
248    }
249}
250
251// Reason: `HostContext` is async because the live implementation performs I/O —
252// GraphQL queries, HTTP calls, secret lookups. Impls whose methods refuse or
253// answer from memory still have to present the awaited signature the trait
254// defines and every guest call site uses.
255#[allow(unknown_lints, clippy::unused_async_trait_impl)]
256impl HostContext for NoopHostContext {
257    async fn query(
258        &self,
259        _graphql: &str,
260        _variables: serde_json::Value,
261    ) -> Result<serde_json::Value> {
262        Err(fraiseql_error::FraiseQLError::Unsupported {
263            message: "HostContext::query not implemented".to_string(),
264        })
265    }
266
267    async fn sql_query(
268        &self,
269        _sql: &str,
270        _params: &[serde_json::Value],
271    ) -> Result<Vec<serde_json::Value>> {
272        Err(fraiseql_error::FraiseQLError::Unsupported {
273            message: "HostContext::sql_query not implemented".to_string(),
274        })
275    }
276
277    async fn http_request(
278        &self,
279        _method: &str,
280        _url: &str,
281        _headers: &[(String, String)],
282        _body: Option<&[u8]>,
283    ) -> Result<HttpResponse> {
284        Err(fraiseql_error::FraiseQLError::Unsupported {
285            message: "HostContext::http_request not implemented".to_string(),
286        })
287    }
288
289    async fn storage_get(&self, _bucket: &str, _key: &str) -> Result<Vec<u8>> {
290        Err(fraiseql_error::FraiseQLError::Unsupported {
291            message: "HostContext::storage_get not implemented".to_string(),
292        })
293    }
294
295    async fn storage_put(
296        &self,
297        _bucket: &str,
298        _key: &str,
299        _body: &[u8],
300        _content_type: &str,
301    ) -> Result<()> {
302        Err(fraiseql_error::FraiseQLError::Unsupported {
303            message: "HostContext::storage_put not implemented".to_string(),
304        })
305    }
306
307    async fn send_email(
308        &self,
309        _request: &crate::outbound::SendEmailRequest,
310    ) -> Result<crate::outbound::SendEmailResponse> {
311        Err(fraiseql_error::FraiseQLError::Unsupported {
312            message: "HostContext::send_email not implemented".to_string(),
313        })
314    }
315
316    fn auth_context(&self) -> Result<serde_json::Value> {
317        Err(fraiseql_error::FraiseQLError::Unsupported {
318            message: "HostContext::auth_context not implemented".to_string(),
319        })
320    }
321
322    fn env_var(&self, _name: &str) -> Result<Option<String>> {
323        Ok(None)
324    }
325
326    fn event_payload(&self) -> &EventPayload {
327        &self.event_payload
328    }
329
330    fn log(&self, level: LogLevel, message: &str) {
331        let entry = LogEntry {
332            level,
333            message: message.to_string(),
334            timestamp: chrono::Utc::now(),
335        };
336        self.logs.lock().expect("log mutex poisoned").push(entry);
337    }
338}
339
340#[cfg(test)]
341mod tests;