Skip to main content

boatramp_node/
handlers.rs

1//! WebAssembly handler-runtime assembly (moved from the binary — node-library N2b).
2//!
3//! Builds `boatramp_server::HandlerRuntime` from `[handlers]` config: the wasmtime
4//! engine plus the libsql `sql` binding (single-node file per site, a cluster sqld
5//! namespace, or an external Postgres/MySQL). Handlers-gated; a lean node gets a
6//! disabled runtime. Lives here (not the backend-agnostic `boatramp-server`)
7//! because it drives the concrete `boatramp-storage` SQL backends.
8
9#[cfg(feature = "handlers")]
10use crate::error::Error;
11use crate::error::Result;
12use boatramp_core::deploy::DeployStore;
13use boatramp_core::envelope::KeyEnvelope;
14use boatramp_core::kv::KvStore;
15use std::path::Path;
16use std::sync::Arc;
17
18/// Default async-lane wall-clock ceiling: 15 minutes. Large enough for a
19/// genuinely long background job (an LLM generation, a batch transform) while
20/// staying bounded — work that needs longer belongs in a workflow, one bounded
21/// invocation per step. The lease that guards a crashed-node reclaim is sized
22/// from this, so a bounded value also bounds the orphan-recovery window.
23#[cfg(feature = "handlers")]
24const DEFAULT_ASYNC_TIMEOUT_MS: u64 = 15 * 60 * 1000;
25
26/// Default async-lane concurrency: a small, isolated pool. The point of the
27/// separate budget is that a burst of long background jobs cannot exhaust the
28/// (much larger) request pool live site traffic draws from.
29#[cfg(feature = "handlers")]
30const DEFAULT_ASYNC_CONCURRENCY: usize = 8;
31
32/// Default streaming-lane wall-clock: like the async lane, a long-lived streaming
33/// response (SSE, agent token streaming) can run for minutes.
34#[cfg(feature = "handlers")]
35const DEFAULT_STREAMING_TIMEOUT_MS: u64 = 15 * 60 * 1000;
36
37/// Default streaming-lane concurrency: larger than the async drain because
38/// concurrent connected SSE clients are the expected shape, but still an isolated
39/// budget so a burst can't touch the fast request pool.
40#[cfg(feature = "handlers")]
41const DEFAULT_STREAMING_CONCURRENCY: usize = 64;
42
43/// Build the WebAssembly handler runtime. With the `handlers` feature it wraps a
44/// wasmtime engine serving the kv/blob bindings from the server's own backends;
45/// otherwise it is an empty placeholder (handler routes fall through to static).
46#[cfg(feature = "handlers")]
47#[allow(clippy::too_many_arguments)]
48pub async fn build_handler_runtime(
49    kv: Arc<dyn KvStore>,
50    storage: Arc<dyn boatramp_core::Storage>,
51    data_dir: &Path,
52    handlers_cfg: Option<&crate::config::HandlersConfig>,
53    messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
54    max_blob_bytes: u64,
55    max_component_bytes: u64,
56    // Posture: whether a guest's outbound `wasi:http` may reach private/loopback hosts.
57    allow_guest_private_egress: bool,
58    // Posture: the instance's own serve socket(s) a guest self-call may reach (empty ⇒ off).
59    self_egress_addrs: Vec<std::net::SocketAddr>,
60    // Posture: whether a site handler's / function's `secrets` map may resolve a bare /
61    // `env:` reference against the serve process's own environment (on under single-tenant/
62    // dev, off under multi-tenant — an untrusted tenant must not name arbitrary host env vars).
63    allow_env_secret_refs: bool,
64    // The deploy store (for a managed compute-backed `sql` database's endpoint
65    // resolution) and the `[secrets]` envelope (to seal a managed credential).
66    deploy: &DeployStore,
67    secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
68) -> Result<boatramp_server::HandlerRuntime> {
69    // Two engine ceilings by lane. The **sync** ceiling bounds connection-bearing
70    // requests (site handlers, synchronous invokes) — kept tight so a slow
71    // handler can't pin a client, a proxy, and the shared request pool; default
72    // 10s. The **async** ceiling bounds the durable drain / workflow / trigger /
73    // messaging path — no client is connected and the work is retried +
74    // dead-lettered, so it can run far longer (default 15 min) on its own
75    // concurrency budget, which is how a legitimately long background job (e.g.
76    // an LLM generation) can declare and actually get minutes of runtime without
77    // ever starving live site traffic.
78    let defaults = boatramp_handlers::Limits::default();
79    let sync_limits = boatramp_handlers::Limits {
80        timeout_ms: handlers_cfg
81            .and_then(|h| h.sync_max_timeout_ms)
82            .unwrap_or(defaults.timeout_ms),
83        ..defaults
84    };
85    let async_limits = boatramp_handlers::Limits {
86        timeout_ms: handlers_cfg
87            .and_then(|h| h.async_max_timeout_ms)
88            .unwrap_or(DEFAULT_ASYNC_TIMEOUT_MS),
89        max_concurrency: handlers_cfg
90            .and_then(|h| h.async_max_concurrency)
91            .unwrap_or(DEFAULT_ASYNC_CONCURRENCY),
92        fuel: handlers_cfg.and_then(|h| h.async_max_fuel),
93        ..sync_limits
94    };
95    // The **streaming** ceiling bounds a `#[handler(stream)]` response (SSE, chunked, agent
96    // token streaming): connection-bearing but long-lived, so — like the async lane — a large
97    // wall-clock on its own concurrency budget, kept apart from both the fast request pool and
98    // the durable drain.
99    let streaming_limits = boatramp_handlers::Limits {
100        timeout_ms: handlers_cfg
101            .and_then(|h| h.streaming_max_timeout_ms)
102            .unwrap_or(DEFAULT_STREAMING_TIMEOUT_MS),
103        max_concurrency: handlers_cfg
104            .and_then(|h| h.streaming_max_concurrency)
105            .unwrap_or(DEFAULT_STREAMING_CONCURRENCY),
106        fuel: handlers_cfg.and_then(|h| h.streaming_max_fuel),
107        ..sync_limits
108    };
109    let outbound_timeout = handlers_cfg
110        .and_then(|h| h.outbound_timeout_ms)
111        .map(std::time::Duration::from_millis);
112    // Opt-in pooling allocator: faster instantiation, large up-front virtual
113    // reservation — benchmark before enabling.
114    let engine = if handlers_cfg.is_some_and(|h| h.pooling) {
115        boatramp_handlers::HandlerEngine::with_pooling(sync_limits, 64)?
116    } else {
117        boatramp_handlers::HandlerEngine::new(sync_limits, 64)?
118    }
119    .with_async_limits(async_limits)
120    .with_streaming_limits(streaming_limits)
121    .with_outbound_timeout(outbound_timeout)
122    .with_private_egress(allow_guest_private_egress)
123    .with_self_egress(self_egress_addrs);
124    let sql = build_sql_backends(
125        handlers_cfg.and_then(|h| h.bindings.sql.as_ref()),
126        data_dir,
127        deploy,
128        &kv,
129        secrets_envelope.as_ref(),
130    )
131    .await?;
132    // The `wasi:messaging` substrate: single-node `LogMessaging` over the same
133    // blob/KV backends by default, or the cluster coordinator when one is given.
134    let messaging: Arc<dyn boatramp_core::messaging::Messaging> = messaging_override
135        .unwrap_or_else(|| {
136            Arc::new(boatramp_core::messaging::LogMessaging::new(
137                storage.clone(),
138                kv.clone(),
139            ))
140        });
141    // Keep a KV handle for the internal secret store before `kv` is moved into the
142    // runtime below.
143    let kv_for_secrets = kv.clone();
144    let runtime =
145        boatramp_server::HandlerRuntime::new(engine, kv, storage, Some(sql), Some(messaging));
146    // Apply the posture's host-side blob cap + component-size cap.
147    runtime.set_max_blob_bytes(max_blob_bytes);
148    runtime.set_max_component_bytes(max_component_bytes);
149    // Apply the posture's host-env secret-ref gate (fail-closed if never set).
150    runtime.set_allow_env_secret_refs(allow_env_secret_refs);
151    // Wire the project-scoped internal secret store when a `[secrets]` envelope is
152    // configured, so `boatramp:<name>` refs resolve (sealed at rest). Without an
153    // envelope there is no sealed store and such refs stay fail-closed.
154    if let Some(envelope) = secrets_envelope {
155        runtime.set_secret_store(Arc::new(boatramp_core::secret_store::SecretStore::new(
156            kv_for_secrets,
157            envelope,
158        )));
159    }
160    Ok(runtime)
161}
162
163/// Resolve the `[handlers.bindings.sql]` config to the libsql SQL backend.
164/// Single-node by default (an embedded file per site under `<data-dir>`); set
165/// `url` to bind a shared sqld cluster (a namespace per site). Either way sites
166/// get a real database boundary — see `boatramp_core::sql`.
167#[cfg(feature = "handlers")]
168async fn build_sql_backends(
169    cfg: Option<&crate::config::SqlBindingConfig>,
170    data_dir: &Path,
171    deploy: &DeployStore,
172    kv: &Arc<dyn KvStore>,
173    secrets_envelope: Option<&Arc<dyn KeyEnvelope>>,
174) -> Result<Arc<dyn boatramp_core::sql::SqlBackends>> {
175    let resolve_env = |var: &Option<String>| -> Result<Option<String>> {
176        match var {
177            Some(var) => Ok(Some(
178                std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?,
179            )),
180            None => Ok(None),
181        }
182    };
183
184    let backend = match cfg.and_then(|c| c.url.as_ref()) {
185        // Cluster: a sqld namespace per site. Auth tokens come from the
186        // environment, never the config file.
187        Some(url) => {
188            let cfg = cfg.expect("url implies cfg");
189            let admin_url = cfg.admin_url.as_ref().ok_or(Error::SqlAdminUrlRequired)?;
190            let token = resolve_env(&cfg.token_env)?.unwrap_or_default();
191            let admin_token = resolve_env(&cfg.admin_token_env)?;
192            let backends = boatramp_storage::LibsqlSqlBackends::remote(
193                url.clone(),
194                admin_url.clone(),
195                token,
196                admin_token,
197            );
198            // Optional read-replica routing: reads → replica, writes → primary.
199            match &cfg.replica_url {
200                Some(replica_url) => backends.with_read_replica(replica_url.clone()),
201                None => backends,
202            }
203        }
204        // Single-node: an embedded file per site.
205        None => {
206            let dir = cfg
207                .and_then(|c| c.dir.clone())
208                .unwrap_or_else(|| data_dir.join("handlers-sql"));
209            boatramp_storage::LibsqlSqlBackends::local(dir)
210        }
211    };
212    // Preview SQL policy (how preview deployments relate to live data).
213    let preview_mode = match cfg.and_then(|c| c.preview_mode.as_deref()) {
214        None | Some("empty") => boatramp_core::sql::PreviewSqlMode::Empty,
215        Some("branch") => boatramp_core::sql::PreviewSqlMode::Branch,
216        Some("shared") => boatramp_core::sql::PreviewSqlMode::Shared,
217        Some(other) => return Err(Error::UnknownPreviewMode(other.to_string())),
218    };
219    let preview_init = match cfg.and_then(|c| c.preview_init.as_ref()) {
220        Some(path) => {
221            Some(
222                std::fs::read_to_string(path).map_err(|err| Error::PreviewInitRead {
223                    path: path.clone(),
224                    source: err,
225                })?,
226            )
227        }
228        None => None,
229    };
230    let default: Arc<dyn boatramp_core::sql::SqlBackends> =
231        Arc::new(backend.with_preview_policy(preview_mode, preview_init));
232
233    // Overlay any external (bring-your-own) databases on the managed default.
234    // With none configured the default is returned unchanged (and a build
235    // without an external SQL engine never has to link the sqlx path).
236    let databases = cfg.map(|c| &c.databases);
237    if databases.is_none_or(std::collections::BTreeMap::is_empty) {
238        return Ok(default);
239    }
240    let databases = databases.expect("checked non-empty above");
241
242    #[cfg(any(feature = "sql-postgres", feature = "sql-mysql"))]
243    {
244        use boatramp_core::sql::SqlBackend;
245        use boatramp_storage::sql_sqlx::{
246            connect, CompositeSqlBackends, ExternalSqlKind, ExternalSqlOptions,
247        };
248        let timeout = |db: &crate::config::ExternalDatabaseConfig| {
249            db.connect_timeout_secs.map(std::time::Duration::from_secs)
250        };
251        let mut composite = CompositeSqlBackends::new(default);
252        for (name, db) in databases {
253            let kind = ExternalSqlKind::parse(&db.kind).ok_or_else(|| Error::SqlExternalKind {
254                name: name.clone(),
255                kind: db.kind.clone(),
256            })?;
257            if db.compute.as_deref().is_some_and(|c| !c.is_empty()) {
258                // Compute-backed: EVERY such binding is per-tenant (Single or Shared
259                // isolation, Project or Site scope). It resolves, per request
260                // `(project, site)`, to the caller's OWN tenant database as its OWN
261                // role — the isolation perimeter — through the per-tenant seam.
262                //
263                // A brought password (`password_env`) is not per-tenant-managed and
264                // keeps its historical single-shared-endpoint shape.
265                if let Some(var) = db.password_env.as_deref().filter(|v| !v.is_empty()) {
266                    let workload = db.compute.as_deref().expect("compute checked above");
267                    let password =
268                        std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.into()))?;
269                    let resolver = Arc::new(crate::managed_sql::DeployEndpointResolver::new(
270                        deploy.clone(),
271                        boatramp_core::project::DEFAULT_PROJECT,
272                    ));
273                    let external: Arc<dyn SqlBackend> = Arc::new(
274                        boatramp_storage::sql_compute::ComputeResolvedSqlBackend::new(
275                            resolver,
276                            workload,
277                            kind,
278                            db.database.clone().unwrap_or_default(),
279                            db.user.clone().unwrap_or_default(),
280                            password,
281                            db.pool_max,
282                            db.read_only,
283                            timeout(db),
284                        ),
285                    );
286                    composite = composite.with_external(name.clone(), external, db.allow_preview);
287                    continue;
288                }
289                // Managed credential: fail closed without a secrets envelope (we will
290                // not persist a DB password in cleartext).
291                let envelope = secrets_envelope
292                    .cloned()
293                    .ok_or_else(|| Error::SqlManagedNeedsSecrets(name.clone()))?;
294                let resolver = crate::tenant_sql::NodeTenantSqlResolver::new(
295                    deploy.clone(),
296                    kv.clone(),
297                    envelope,
298                    db,
299                )
300                .expect("a compute-backed managed binding builds a per-tenant resolver");
301                let site_scoped = resolver.site_scoped();
302                composite = composite.with_per_tenant(
303                    name.clone(),
304                    Arc::new(resolver),
305                    site_scoped,
306                    db.allow_preview,
307                );
308            } else {
309                // Bring-your-own URL: a single shared endpoint, the connection URL(s)
310                // are secrets, resolved from the environment.
311                if db.url_env.trim().is_empty() {
312                    return Err(Error::SqlExternalUrlEnvMissing(name.clone()));
313                }
314                let url = std::env::var(&db.url_env)
315                    .map_err(|_| Error::SqlEnvUnset(db.url_env.clone()))?;
316                let read_url = match &db.read_url_env {
317                    Some(var) => {
318                        Some(std::env::var(var).map_err(|_| Error::SqlEnvUnset(var.clone()))?)
319                    }
320                    None => None,
321                };
322                let opts = ExternalSqlOptions::new(url)
323                    .with_read_url(read_url)
324                    .with_max_connections(db.pool_max)
325                    .read_only(db.read_only)
326                    .with_connect_timeout(timeout(db));
327                let external: Arc<dyn SqlBackend> =
328                    connect(kind, &opts).map_err(|source| Error::SqlExternalConnect {
329                        name: name.clone(),
330                        source,
331                    })?;
332                composite = composite.with_external(name.clone(), external, db.allow_preview);
333            }
334        }
335        Ok(Arc::new(composite))
336    }
337    #[cfg(not(any(feature = "sql-postgres", feature = "sql-mysql")))]
338    {
339        // The compute-backed arm (the only consumer of these) is compiled out
340        // without a SQL engine; a `databases` entry then can't be served at all.
341        let _ = (deploy, kv, secrets_envelope);
342        let name = databases.keys().next().cloned().unwrap_or_default();
343        Err(Error::SqlExternalUnavailable(name))
344    }
345}
346
347#[cfg(not(feature = "handlers"))]
348#[allow(clippy::too_many_arguments)]
349pub async fn build_handler_runtime(
350    _kv: Arc<dyn KvStore>,
351    _storage: Arc<dyn boatramp_core::Storage>,
352    _data_dir: &Path,
353    _handlers_cfg: Option<&crate::config::HandlersConfig>,
354    _messaging_override: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
355    _max_blob_bytes: u64,
356    _max_component_bytes: u64,
357    _allow_guest_private_egress: bool,
358    _self_egress_addrs: Vec<std::net::SocketAddr>,
359    // Kept in lockstep with the `#[cfg(feature = "handlers")]` signature + the single
360    // node.rs caller (the caller passes the posture value unconditionally); a lean
361    // node has no guest to gate, so it is ignored.
362    _allow_env_secret_refs: bool,
363    _deploy: &DeployStore,
364    _secrets_envelope: Option<Arc<dyn KeyEnvelope>>,
365) -> Result<boatramp_server::HandlerRuntime> {
366    Ok(boatramp_server::HandlerRuntime::disabled())
367}
368
369#[cfg(all(test, any(feature = "sql-postgres", feature = "sql-mysql")))]
370mod tests {
371    use super::*;
372    // `super::*` brings the crate's 1-arg `Result` alias into scope; the trait impls
373    // below need the std 2-arg `Result`, so shadow it back (explicit beats glob).
374    use std::result::Result;
375
376    use async_trait::async_trait;
377    use boatramp_core::envelope::EnvelopeError;
378    use boatramp_core::kv::MemoryKv;
379    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
380
381    /// A reversible test envelope (NOT encryption) — proves sealing round-trips.
382    struct TestEnvelope;
383    #[async_trait]
384    impl KeyEnvelope for TestEnvelope {
385        async fn wrap(&self, p: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
386            Ok(p.iter().rev().copied().collect())
387        }
388        async fn unwrap(&self, w: &[u8]) -> Result<Vec<u8>, EnvelopeError> {
389            Ok(w.iter().rev().copied().collect())
390        }
391    }
392
393    /// A no-op object store, so a `DeployStore` can be built (the endpoint resolver
394    /// only reads KV replica state, which is empty here — the backend is lazy).
395    struct NullStorage;
396    #[async_trait]
397    impl Storage for NullStorage {
398        async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
399            Err(StorageError::NotFound(String::new()))
400        }
401        async fn get_range(
402            &self,
403            _: &str,
404            _: u64,
405            _: Option<u64>,
406        ) -> Result<GetObject, StorageError> {
407            Err(StorageError::NotFound(String::new()))
408        }
409        async fn put(
410            &self,
411            _: &str,
412            _: ByteStream,
413            _: PutMeta,
414        ) -> Result<ObjectMeta, StorageError> {
415            Err(StorageError::unsupported("null"))
416        }
417        async fn head(&self, _: &str) -> Result<ObjectMeta, StorageError> {
418            Err(StorageError::NotFound(String::new()))
419        }
420        async fn delete(&self, _: &str) -> Result<(), StorageError> {
421            Ok(())
422        }
423        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
424            Ok(Vec::new())
425        }
426    }
427
428    /// A `sql` binding with one managed (compute-backed, no `password_env`) database.
429    fn managed_sql_cfg() -> crate::config::SqlBindingConfig {
430        let mut databases = std::collections::BTreeMap::new();
431        databases.insert(
432            "analytics".to_string(),
433            crate::config::ExternalDatabaseConfig {
434                kind: "postgres".into(),
435                compute: Some("pg".into()),
436                database: Some("analytics".into()),
437                user: Some("app".into()),
438                ..Default::default()
439            },
440        );
441        crate::config::SqlBindingConfig {
442            databases,
443            ..Default::default()
444        }
445    }
446
447    #[tokio::test]
448    async fn managed_sql_fails_closed_without_secrets() {
449        let tmp = tempfile::tempdir().unwrap();
450        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
451        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
452        let cfg = managed_sql_cfg();
453        // `Arc<dyn SqlBackends>` isn't `Debug`, so match rather than `unwrap_err`.
454        match build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, None).await {
455            Err(Error::SqlManagedNeedsSecrets(name)) => assert_eq!(name, "analytics"),
456            Ok(_) => panic!("a managed DB without [secrets] must fail closed, got Ok"),
457            Err(other) => panic!("expected SqlManagedNeedsSecrets, got: {other}"),
458        }
459    }
460
461    #[tokio::test]
462    async fn managed_sql_builds_lazily_and_seals_the_credential() {
463        let tmp = tempfile::tempdir().unwrap();
464        let deploy = DeployStore::new(Arc::new(NullStorage), Arc::new(MemoryKv::new()));
465        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
466        let envelope: Arc<dyn KeyEnvelope> = Arc::new(TestEnvelope);
467        let cfg = managed_sql_cfg();
468        // No DB is running: assembly builds the composite without a connection AND
469        // without minting any credential — every compute-backed managed binding is
470        // now **per-tenant**, so a credential is sealed lazily per (tenant, server)
471        // on first `open`, not eagerly at build (a tenant isn't known at build time).
472        let backends = build_sql_backends(Some(&cfg), tmp.path(), &deploy, &kv, Some(&envelope))
473            .await
474            .expect("managed sql builds without a live DB (lazy connect)");
475        assert!(
476            kv.get("managed-sql-cred/default/pg")
477                .await
478                .unwrap()
479                .is_none(),
480            "nothing sealed at build — per-tenant credentials are minted on first open"
481        );
482
483        // Resolving the binding for the default project's site (the single-tenant
484        // install) seals the credential under the plain default-project + workload
485        // key, so the DB's server-init env (same key) and the handler connection agree.
486        let _ = backends
487            .database("default", "blog", "analytics")
488            .await
489            .unwrap();
490        let sealed = kv
491            .get("managed-sql-cred/default/pg")
492            .await
493            .unwrap()
494            .expect("credential sealed on first resolve under the default project");
495        assert_ne!(sealed.len(), 0);
496    }
497}