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