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;