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