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