Skip to main content

boatramp_server/
lib.rs

1//! boatramp HTTP server + publishing API.
2//!
3//! The server is backend-agnostic: it is handed a [`DeployStore`] (blobs in any
4//! [`boatramp_core::Storage`], metadata in any [`boatramp_core::kv::KvStore`])
5//! and exposes:
6//!
7//! - a **publishing API** used by `boatramp sync` — negotiate a manifest,
8//!   upload missing blobs (streamed), then atomically activate;
9//! - **public serving** of the currently-active deployment for each site.
10//!
11//! Every byte path streams: uploads flow request→backend, downloads flow
12//! backend→response, and only small manifests are ever held in memory.
13
14use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30    DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43    activate_deployment, cert_status, compute_dns, compute_dns_resolve, compute_exec, compute_ipam,
44    compute_netdiag, compute_reconcile, compute_restart, compute_set_health, compute_status,
45    create_deployment, current_deployment, delete_compute, delete_compute_volume, delete_site,
46    get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
47    list_aliases, list_compute, list_compute_volumes, list_deployments, list_sites, prune_delete,
48    prune_report, put_blob, put_compute, put_daemon_config, put_site_config, remove_alias,
49    rollback_daemon_config, scrub_blobs, set_alias, sql_exec, sql_ping, sql_query,
50};
51#[cfg(feature = "handlers")]
52pub(crate) use admin_api::{
53    delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
54    list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
55    put_graphql_subgraph, register_graphql_safelist,
56};
57/// The server-side controller backing the guest `admin` capability (project self-config).
58#[cfg(feature = "admin")]
59mod admin_controller;
60mod auth;
61#[cfg(feature = "console")]
62pub mod console;
63mod content;
64mod control_api;
65/// The node-side email delivery spool (best-effort + durable) backing the `email`
66/// guest capability.
67#[cfg(feature = "email")]
68mod email_spool;
69#[cfg(feature = "admin")]
70pub use admin_controller::ServerAdminController;
71#[cfg(feature = "compression")]
72pub(crate) use content::maybe_compress;
73pub(crate) use content::multipart_byteranges;
74pub(crate) use content::{
75    negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
76};
77pub(crate) use control_api::{
78    add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
79    cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_email_profile,
80    delete_secret, get_authz_policy, list_email_profiles, list_root_anchors, list_secrets,
81    list_tokens, put_authz_policy, remove_root_anchor, revoke_token, set_email_profile, set_secret,
82    show_email_profile,
83};
84#[cfg(all(test, feature = "handlers"))]
85use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
86#[cfg(feature = "email")]
87pub use email_spool::NodeEmailSpool;
88mod domain_verify;
89pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
90pub mod envelope;
91#[cfg(feature = "handlers")]
92mod graphql_apq;
93#[cfg(feature = "handlers")]
94mod graphql_cache;
95#[cfg(feature = "handlers")]
96mod graphql_data;
97#[cfg(feature = "handlers")]
98mod graphql_federation;
99#[cfg(feature = "handlers")]
100mod graphql_gateway;
101#[cfg(feature = "handlers")]
102mod graphql_graphiql;
103#[cfg(feature = "handlers")]
104mod graphql_guard;
105#[cfg(feature = "handlers")]
106mod graphql_plan;
107#[cfg(feature = "handlers")]
108mod graphql_registry;
109#[cfg(feature = "handlers")]
110mod graphql_subscription;
111#[cfg(feature = "handlers")]
112mod handler_cache;
113#[cfg(feature = "handlers")]
114mod handler_dispatch;
115#[cfg(feature = "handlers")]
116pub(crate) use handler_dispatch::{
117    build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
118    read_blob_fully, resolve_secret_env,
119};
120#[cfg(all(feature = "handlers", test))]
121use handler_dispatch::{resolve_env, set_forwarded_headers};
122mod function_api;
123pub(crate) use function_api::{
124    alias_function, deploy_function, list_functions, remove_function, rollback_function,
125};
126/// The capability **features** this host build implements — the registry a guest's manifest
127/// `requires` is admission-checked against, re-exported so `boatramp capabilities` reports the
128/// exact same set the deploy gate enforces (PLAN v2). `*_detailed` pairs each with its lifecycle;
129/// `component_requires`/`unmet_requires` back the shift-left `capabilities check`.
130#[cfg(feature = "handlers")]
131pub use function_api::{
132    component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
133};
134#[cfg(all(test, feature = "handlers"))]
135use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
136/// Capability-surface types (`boatramp capabilities` / `/api/capabilities`). Ungated — a build
137/// without `handlers` still names the vocabulary, it just implements nothing.
138pub use function_api::{CapabilityFeature, Lifecycle};
139mod gateway;
140mod host;
141pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
142#[cfg(feature = "http3")]
143mod http3;
144mod limits;
145#[cfg(feature = "handlers")]
146mod logs;
147#[cfg(feature = "handlers")]
148mod metrics;
149#[cfg(feature = "oidc")]
150mod oidc;
151mod operator;
152pub(crate) use operator::prometheus_metrics;
153#[cfg(feature = "handlers")]
154pub(crate) use operator::{
155    operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
156    operator_logs, operator_logs_stream,
157};
158mod proxy;
159pub use proxy::spawn_compute_reconcile;
160pub(crate) use proxy::{
161    await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
162    proxy, COMPUTE_WAKE_TIMEOUT,
163};
164mod splice;
165// The unified serving front door: TLS + plaintext accept loops that drive every
166// connection through boatramp-http's own h1+h2 stack (replaced hyper/axum_server).
167mod http_serve;
168pub use http_serve::{
169    alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
170    serve_tls_listener, ReloadableTls, ServeInput,
171};
172// Only the `handlers`-gated websocket-upgrade path in the serve pipeline uses it.
173#[cfg(feature = "handlers")]
174pub(crate) use proxy::is_upgrade_request;
175#[cfg(all(test, feature = "handlers"))]
176use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
177mod project_api;
178pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
179mod project_scope;
180pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
181mod ratelimit;
182mod routes;
183pub use routes::{router, router_with, router_with_fast};
184#[cfg(feature = "mcp")]
185mod mcp_http;
186#[cfg(feature = "handlers")]
187mod scheduler;
188mod serve_pipeline;
189pub use serve_pipeline::{http_redirect_router, FastServe};
190#[cfg(test)]
191mod hotpath_test;
192#[cfg(all(test, feature = "handlers"))]
193use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
194pub(crate) use serve_pipeline::{
195    serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
196    BootstrapAttestation,
197};
198/// External token signer backends: KMS / HSM / Vault-hosted
199/// control-plane root keys behind the [`boatramp_core::cose::Signer`] seam.
200pub mod signer;
201mod srvmetrics;
202#[cfg(all(feature = "handlers", test))]
203use scheduler::run_scheduler_tick;
204#[cfg(feature = "handlers")]
205pub(crate) use scheduler::{
206    acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
207};
208#[cfg(feature = "handlers")]
209use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
210#[cfg(feature = "handlers")]
211mod function_runtime;
212#[cfg(feature = "handlers")]
213pub(crate) use function_runtime::{
214    b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
215    dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
216    get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
217    put_trigger_handler, webhook_ingress,
218};
219#[cfg(feature = "handlers")]
220mod stream;
221#[cfg(feature = "handlers")]
222mod workflow;
223pub use auth::{require_auth, Auth};
224#[cfg(feature = "http3")]
225pub use http3::{
226    advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
227    Http3Error,
228};
229pub use limits::{ServerLimits, UploadGuard};
230#[cfg(feature = "oidc")]
231pub use oidc::{OidcConfig, OidcError, OidcVerifier};
232use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
233#[cfg(feature = "handlers")]
234pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
235#[cfg(feature = "handlers")]
236pub(crate) use workflow::{
237    define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
238    get_workflow_run_handler, list_workflows_handler, start_workflow_run,
239};
240// The process-wide HTTP/lifecycle metrics registry. Re-exported so the CLI's
241// certificate-renewal path can record renewals against the same counters.
242pub use srvmetrics::{server_metrics, ServerMetrics};
243
244/// The WebAssembly handler runtime: the shared engine plus the per-site binding
245/// backends. Cheap to clone (it is an `Arc` inside). Without the `handlers`
246/// feature it is an empty placeholder, so the serving signatures stay uniform —
247/// pass [`HandlerRuntime::disabled`].
248#[derive(Clone, Default)]
249pub struct HandlerRuntime {
250    #[cfg(feature = "handlers")]
251    inner: Option<Arc<HandlerRuntimeInner>>,
252}
253
254#[cfg(feature = "handlers")]
255struct HandlerRuntimeInner {
256    engine: boatramp_handlers::HandlerEngine,
257    /// Claim gate for the durable async drain, sized to the engine's async-lane
258    /// concurrency. The drain acquires an owned permit before claiming +
259    /// spawning an invocation and holds it for the whole run, so a backlog can
260    /// never spawn more background jobs than the async lane can run (bounded
261    /// fan-out, no attempt-burning overload storm).
262    async_drain_gate: Arc<tokio::sync::Semaphore>,
263    kv: Arc<dyn boatramp_core::kv::KvStore>,
264    storage: Arc<dyn boatramp_core::Storage>,
265    /// Per-site SQL database provider (libsql — single-node files by default;
266    /// absent = the `sql` capability is not offered, so handlers requesting it
267    /// are refused at activation).
268    sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
269    /// Internal messaging substrate for the `wasi:messaging` binding (publish;
270    /// consumer dispatch is driven separately). Absent = messaging not offered.
271    messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
272    /// Per-site concurrency semaphores (for sites that set `maxConcurrency`),
273    /// created on first use.
274    site_semaphores:
275        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
276    /// Per-scope SSE connection semaphores (per-site cap),
277    /// created on first use and keyed by binding scope so a preview's streams
278    /// can't exhaust the live site's budget.
279    stream_semaphores:
280        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
281    /// Live SSE connection counts per `(scope, client-ip)`, for the per-IP cap.
282    /// `Arc` so a connection's RAII guard can decrement it on drop.
283    stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
284    /// Per-invocation observability counters, read by the
285    /// operator endpoint + Prometheus exporter.
286    metrics: metrics::Metrics,
287    /// Captured guest stdout/stderr: per-site ring + rate cap.
288    logs: Arc<logs::LogStore>,
289    /// Per-project memoized composed supergraph + query plans (the federation hot path),
290    /// keyed on the registry composition version. Shared by the edge and in-process
291    /// `graphql::run` paths; a registry mutation bumps the version and invalidates it.
292    #[cfg(feature = "handlers")]
293    graphql_cache: graphql_cache::GraphqlCache,
294    /// Optional **cron leader gate**: in cluster mode the
295    /// scheduler fires crons only when this returns `true` (the node is the Raft
296    /// leader), so a cron fires exactly once cluster-wide. `None` (single-node)
297    /// always fires. Consumers are *not* gated — leased dispatch distributes
298    /// them across nodes.
299    cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
300    /// Max bytes a `wasi:blobstore` host read/range/copy may buffer (`0` =
301    /// unlimited), from the security posture. Set once at serve
302    /// startup via [`HandlerRuntime::set_max_blob_bytes`]; unset reads as `0`.
303    max_blob_bytes: std::sync::OnceLock<u64>,
304    /// Max size of a Wasm component blob accepted at activation (`0` = unlimited),
305    /// from the security posture. Checked against the manifest's file
306    /// size *before* the blob is read. Set via
307    /// [`HandlerRuntime::set_max_component_bytes`]; unset reads as `0`.
308    max_component_bytes: std::sync::OnceLock<u64>,
309    /// Whether a site handler's / function's `secrets` map may resolve a **bare**
310    /// or `env:`-scheme reference against the serve process's own (operator)
311    /// environment, from the security posture's `allow_env_secret_refs`. Set once
312    /// at startup via [`HandlerRuntime::set_allow_env_secret_refs`]; **unset reads
313    /// as `false`** (fail-closed — a runtime that never wired the posture refuses
314    /// host-env refs rather than leaking them). When `false`, `resolve_secret_env`
315    /// refuses such a ref instead of injecting the host value, closing the
316    /// cross-tenant host-env exfiltration path under the multi-tenant posture.
317    allow_env_secret_refs: std::sync::OnceLock<bool>,
318    /// The project-scoped internal secret store (sealed with the `[secrets]`
319    /// envelope). Backs the `boatramp:<name>` secret-ref scheme — the
320    /// multi-tenant-safe alternative to a host-env ref. Set once at startup via
321    /// [`HandlerRuntime::set_secret_store`]; **unset means no `boatramp:` ref can
322    /// resolve** (fail-closed — `resolve_secret_env` errors rather than injecting).
323    secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
324    /// Per-function locks serializing the metering + rate-limit read-modify-write
325    /// (FA-4), so concurrent invocations of one function can't lose an update.
326    /// Created on first use, keyed by function name.
327    function_meter_locks:
328        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
329    /// Per-function concurrency semaphores (for functions that set a
330    /// `max_concurrent` quota), created on first use.
331    function_semaphores:
332        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
333    /// Optional cloud **blob-change notification provisioner** (FA-5b2): when set,
334    /// adding a `Blob` trigger provisions the native pipeline (S3→SQS, …) per the
335    /// [`provision_tier`](Self::provision_tier), and removing it retracts. Absent
336    /// on a self-watching backend (fs), which needs no provisioning.
337    watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
338    /// The operator tier governing the [`watch_provider`](Self::watch_provider):
339    /// dry-run (recipe) / provision / verify-only / refuse. Unset reads as the
340    /// fail-closed default (`Refuse`).
341    provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
342    /// The function-to-function invoke resolver (FI): backs the `invoke`
343    /// capability. Set once at startup with the deploy store (a self-referential
344    /// `Weak` back to this runtime), so a granted function can call a sibling
345    /// in-process. Unset ⇒ the `invoke` capability is not offered. Held as the
346    /// concrete type so a binding can derive a **project-scoped** invoker
347    /// ([`FunctionInvoker::scoped`]) resolving the caller's siblings within its
348    /// own tenant project, not `default`.
349    invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
350    /// The supergraph runner backing the `graphql` capability: runs a guest's GraphQL
351    /// operation against the project's composed supergraph in-process (plan + execute over
352    /// the invoke path). Set once at startup alongside [`invoker`](Self::invoker); unset ⇒ the
353    /// `graphql` capability is not offered. Project-scoped per grant, like the invoker.
354    federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
355    /// The per-project SMTP email-profile store backing the `email` capability's
356    /// host-side profile resolution (sealed with the `[secrets]` envelope, like
357    /// [`secret_store`](Self::secret_store)). Set via
358    /// [`HandlerRuntime::set_email_profile_store`]; unset ⇒ no profile resolves.
359    #[cfg(feature = "email")]
360    email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
361    /// The shared node email delivery spool backing the `email` capability. Set via
362    /// [`HandlerRuntime::set_email_spool`] at startup **only when the
363    /// `allow_guest_email` posture permits guest email**; unset ⇒ email is not
364    /// offered (a guest granted `email` gets `access-denied`).
365    #[cfg(feature = "email")]
366    email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
367    /// The server-side controller backing the guest `admin` capability. Set at startup via
368    /// [`HandlerRuntime::set_admin`]; unset ⇒ admin is not offered (a granted guest gets
369    /// `access-denied`). `.scoped(project)` per grant.
370    #[cfg(feature = "admin")]
371    admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
372    /// The config surfaces the operator posture enables for guest admin (the posture-on subset
373    /// of {domains,email,site,secrets}); a guest's grant is intersected with this. Empty/unset ⇒
374    /// no surface is offered.
375    #[cfg(feature = "admin")]
376    admin_surfaces:
377        std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
378}
379
380/// Predicate gating cron firing to the cluster leader (see
381/// [`HandlerRuntime::set_cron_leader_gate`]).
382pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
383
384impl HandlerRuntime {
385    /// An empty runtime — handler dispatch disabled (the static path is unchanged).
386    pub fn disabled() -> Self {
387        Self::default()
388    }
389
390    /// Build a runtime over `engine`. The `wasi:keyvalue` / `wasi:blobstore`
391    /// bindings are served from the server's own `kv` / `storage` backends (each
392    /// namespaced per site); `sql`, if a provider is given, serves a per-site
393    /// database (the default `""` database). `sql: None` means the `sql`
394    /// capability is not offered.
395    #[cfg(feature = "handlers")]
396    pub fn new(
397        engine: boatramp_handlers::HandlerEngine,
398        kv: Arc<dyn boatramp_core::kv::KvStore>,
399        storage: Arc<dyn boatramp_core::Storage>,
400        sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
401        messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
402    ) -> Self {
403        // Size the async drain gate to the engine's async-lane concurrency
404        // (read before `engine` is moved into the runtime).
405        let async_drain_slots = engine.async_max_concurrency().max(1);
406        Self {
407            inner: Some(Arc::new(HandlerRuntimeInner {
408                engine,
409                async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
410                kv,
411                storage,
412                sql,
413                messaging,
414                site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
415                stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
416                stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
417                metrics: metrics::Metrics::default(),
418                logs: Arc::new(logs::LogStore::default()),
419                #[cfg(feature = "handlers")]
420                graphql_cache: graphql_cache::GraphqlCache::default(),
421                cron_leader_gate: std::sync::OnceLock::new(),
422                max_blob_bytes: std::sync::OnceLock::new(),
423                max_component_bytes: std::sync::OnceLock::new(),
424                allow_env_secret_refs: std::sync::OnceLock::new(),
425                secret_store: std::sync::OnceLock::new(),
426                function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
427                function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
428                watch_provider: std::sync::OnceLock::new(),
429                provision_tier: std::sync::OnceLock::new(),
430                invoker: std::sync::OnceLock::new(),
431                federation_runner: std::sync::OnceLock::new(),
432                #[cfg(feature = "email")]
433                email_profile_store: std::sync::OnceLock::new(),
434                #[cfg(feature = "email")]
435                email_spool: std::sync::OnceLock::new(),
436                #[cfg(feature = "admin")]
437                admin_controller: std::sync::OnceLock::new(),
438                #[cfg(feature = "admin")]
439                admin_surfaces: std::sync::OnceLock::new(),
440            })),
441        }
442    }
443
444    /// The per-site SQL database provider, if one is configured. Lets the control plane
445    /// introspect a site's database (e.g. to generate a SQL federation subgraph's SDL).
446    #[cfg(feature = "handlers")]
447    pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
448        self.inner.as_ref().and_then(|inner| inner.sql.clone())
449    }
450
451    /// The function invoker, if wired (set at serve startup). Lets the control plane run a
452    /// deployed function in-process — e.g. to introspect a function subgraph's SDL via its
453    /// federation `_service { sdl }` field when registering it.
454    #[cfg(feature = "handlers")]
455    pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
456        self.inner
457            .as_ref()
458            .and_then(|inner| inner.invoker.get().cloned())
459    }
460
461    /// Introspect a **specific component's** federation SDL by running `{ _service { sdl } }`
462    /// against it — targeting a pending (not-yet-active) version so a subgraph redeploy can be
463    /// composed-checked before it goes live. `Unavailable` if this node has no wasm engine.
464    #[cfg(feature = "handlers")]
465    pub(crate) async fn introspect_subgraph_sdl(
466        &self,
467        deploy: &DeployStore,
468        project: boatramp_core::project::ProjectRef<'_>,
469        function: &boatramp_core::function::Function,
470        component: &str,
471    ) -> Result<String, function_runtime::SubgraphSdlError> {
472        match self.inner.as_ref() {
473            Some(inner) => {
474                function_runtime::introspect_service_sdl(
475                    inner, deploy, project, function, component,
476                )
477                .await
478            }
479            None => Err(function_runtime::SubgraphSdlError::Unavailable),
480        }
481    }
482
483    /// Wire the function-to-function invoke resolver (FI). Set once at startup,
484    /// after the deploy store exists: it holds a `Weak` back to this runtime plus
485    /// the deploy store, so a function granted `invoke` can resolve + run a
486    /// sibling in-process. A no-op runtime (no `inner`) leaves it unset, and the
487    /// `invoke` capability is then simply never granted.
488    #[cfg(feature = "handlers")]
489    pub fn set_invoker(&self, deploy: DeployStore) {
490        if let Some(inner) = self.inner.as_ref() {
491            let invoker = Arc::new(function_runtime::FunctionInvoker::new(
492                deploy,
493                Arc::downgrade(inner),
494            ));
495            let _ = inner.invoker.set(invoker);
496            // The supergraph runner shares the same self-referential `Weak`; it reaches the
497            // invoker (set above) to dispatch a guest run's sub-fetches in-process.
498            let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
499                inner,
500            )));
501            let _ = inner.federation_runner.set(runner);
502        }
503    }
504
505    /// The per-site SQL provider, if the `sql` capability is offered. Backs the
506    /// compute sql-shim (PLAN-compute-bindings) so an opaque workload reaches the
507    /// same tenant-scoped database a handler does.
508    #[cfg(feature = "handlers")]
509    pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
510        self.inner.as_ref().and_then(|inner| inner.sql.clone())
511    }
512
513    /// Wire the cloud blob-change notification provisioner (FA-5b2). Set once at
514    /// startup when the storage backend is a cloud object store; a no-op runtime,
515    /// or a self-watching backend (fs), leaves it unset.
516    #[cfg(feature = "handlers")]
517    pub fn set_watch_provider(
518        &self,
519        provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
520    ) {
521        if let Some(inner) = self.inner.as_ref() {
522            let _ = inner.watch_provider.set(provider);
523        }
524    }
525
526    /// Set the operator provisioning tier for the
527    /// [`watch_provider`](Self::set_watch_provider). Set once at startup; unset is
528    /// the fail-closed `Refuse`.
529    #[cfg(feature = "handlers")]
530    pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
531        if let Some(inner) = self.inner.as_ref() {
532            let _ = inner.provision_tier.set(tier);
533        }
534    }
535
536    /// Cap the bytes a `wasi:blobstore` host read/range/copy may buffer (`0` =
537    /// unlimited), from the security posture. Set once at startup; a
538    /// no-op runtime ignores it.
539    #[cfg(feature = "handlers")]
540    pub fn set_max_blob_bytes(&self, max_bytes: u64) {
541        if let Some(inner) = self.inner.as_ref() {
542            let _ = inner.max_blob_bytes.set(max_bytes);
543        }
544    }
545
546    /// Cap the size of a Wasm component blob accepted at activation (`0` =
547    /// unlimited), from the security posture. Set once at startup.
548    #[cfg(feature = "handlers")]
549    pub fn set_max_component_bytes(&self, max_bytes: u64) {
550        if let Some(inner) = self.inner.as_ref() {
551            let _ = inner.max_component_bytes.set(max_bytes);
552        }
553    }
554
555    /// Permit (or forbid) resolving a **bare** / `env:`-scheme secret ref against
556    /// the serve process's own environment, from the security posture's
557    /// `allow_env_secret_refs`. Set once at startup; **unset reads as `false`**
558    /// (fail-closed), so a runtime that never wired the posture refuses host-env
559    /// refs rather than leaking them. Under the multi-tenant posture (`false`) an
560    /// untrusted tenant's `secrets` map can no longer name an arbitrary host env
561    /// var to exfiltrate it into their guest.
562    #[cfg(feature = "handlers")]
563    pub fn set_allow_env_secret_refs(&self, allow: bool) {
564        if let Some(inner) = self.inner.as_ref() {
565            let _ = inner.allow_env_secret_refs.set(allow);
566        }
567    }
568
569    /// Wire the project-scoped internal secret store (sealed with the `[secrets]`
570    /// envelope) that backs the `boatramp:<name>` secret-ref scheme. Set once at
571    /// startup; if never set, a `boatramp:` ref cannot resolve and
572    /// `resolve_secret_env` errors fail-closed rather than injecting a value.
573    #[cfg(feature = "handlers")]
574    pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
575        if let Some(inner) = self.inner.as_ref() {
576            let _ = inner.secret_store.set(store);
577        }
578    }
579
580    /// Wire the per-project SMTP email-profile store that backs the `email`
581    /// capability's host-side profile resolution (same sealed store the admin API
582    /// exposes redacted). Set once at startup; unset ⇒ no profile resolves.
583    #[cfg(feature = "email")]
584    pub fn set_email_profile_store(
585        &self,
586        store: Arc<boatramp_core::email_config::EmailProfileStore>,
587    ) {
588        if let Some(inner) = self.inner.as_ref() {
589            let _ = inner.email_profile_store.set(store);
590        }
591    }
592
593    /// Wire the shared node email delivery spool backing the `email` capability.
594    /// Called at startup **only when the `allow_guest_email` posture permits guest
595    /// email**, so leaving it unset (posture off, or no spool) makes email
596    /// unavailable and a granted guest's `send` returns `access-denied`.
597    #[cfg(feature = "email")]
598    pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
599        if let Some(inner) = self.inner.as_ref() {
600            let _ = inner.email_spool.set(spool);
601        }
602    }
603
604    /// Wire the guest `admin` capability: the server controller + the operator-enabled surface
605    /// set (the posture-on subset). Called at startup only when at least one
606    /// `allow_guest_admin_*` posture bit is on; unset ⇒ admin is not offered and a granted
607    /// guest's calls return `access-denied`. A guest's grant is intersected with `surfaces`.
608    #[cfg(feature = "admin")]
609    pub fn set_admin(
610        &self,
611        controller: Arc<admin_controller::ServerAdminController>,
612        surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
613    ) {
614        if let Some(inner) = self.inner.as_ref() {
615            let _ = inner.admin_controller.set(controller);
616            let _ = inner.admin_surfaces.set(surfaces);
617        }
618    }
619
620    /// The resolved `allow_env_secret_refs` posture bool (fail-closed `false` if
621    /// unset, or if there is no runtime). Lets the function deploy-admission path
622    /// refuse a forbidden `secrets` map at deploy time — the same gate the
623    /// resolution-time backstop enforces.
624    #[cfg(feature = "handlers")]
625    pub(crate) fn allow_env_secret_refs(&self) -> bool {
626        self.inner
627            .as_ref()
628            .and_then(|inner| inner.allow_env_secret_refs.get().copied())
629            .unwrap_or(false)
630    }
631
632    /// Gate cron firing on a predicate (cluster mode: the node is the Raft
633    /// leader), so a cron fires exactly once cluster-wide.
634    /// Set once at startup; a no-op runtime ignores it. Consumers are never
635    /// gated (leased dispatch already distributes them).
636    #[cfg(feature = "handlers")]
637    pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
638        if let Some(inner) = self.inner.as_ref() {
639            let _ = inner.cron_leader_gate.set(gate);
640        }
641    }
642
643    /// Pre-activation gate: refuse to flip a deployment
644    /// whose handlers can't be satisfied — the site must enable handlers and
645    /// allow each requested import (the resolution rule), and every component
646    /// must compile (so a broken component never goes live; this also pre-warms
647    /// the cache). `Err(reason)` means "do not activate". A no-op for deploys
648    /// with no handlers, or without the `handlers` feature/runtime.
649    #[cfg(feature = "handlers")]
650    async fn precheck_activation(
651        &self,
652        deploy: &DeployStore,
653        manifest: &Manifest,
654        site_config: Option<&SiteConfig>,
655    ) -> Result<(), String> {
656        let Some(inner) = self.inner.as_ref() else {
657            return Ok(());
658        };
659        // Consumer-only deploys must be prechecked too: skip only
660        // when neither handlers nor consumers ship.
661        if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
662            return Ok(());
663        }
664        // A deploy that ships handlers or consumers requires the site to enable them.
665        let site_handlers = site_config
666            .and_then(|c| c.handlers.as_ref())
667            .filter(|h| h.enabled)
668            .ok_or_else(|| {
669                "deployment ships handlers/consumers but the site has them disabled".to_string()
670            })?;
671        let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
672
673        // Fail loud at deploy on a `secrets` map the posture forbids: under the
674        // multi-tenant posture a bare / `env:` ref reads the operator's environment
675        // (cross-tenant host-env exfiltration), so refuse the activation with the
676        // same message the resolution-time backstop would raise — the tenant sees
677        // the failure now, not at first request. Uses the runtime's resolved
678        // `allow_env_secret_refs` (fail-closed if never wired).
679        let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
680        crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
681            .map_err(|err| format!("handler secrets: {err}"))?;
682
683        // Sync-timeout footgun: a handler/site timeout above the sync ceiling is
684        // silently clamped for connection-bearing (sync HTTP) calls, so a legit
685        // long call dies as a mysterious runtime 504. Warn loudly at deploy. The
686        // same value is valid for the async lane (`?mode=async` / triggers), clamped
687        // to the larger async ceiling — so this is a warning, not a refusal.
688        let sync_ceiling = inner.engine.sync_timeout_ms();
689        let async_ceiling = inner.engine.async_timeout_ms();
690        if let Some(ms) = site_handlers.max_timeout_ms {
691            if u64::from(ms) > sync_ceiling {
692                tracing::warn!(
693                    "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
694                     synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
695                     applies only to async calls (?mode=async / triggers), capped at \
696                     async_max_timeout_ms={async_ceiling}"
697                );
698            }
699        }
700
701        // Same import/size/compile gate for every handler and consumer component.
702        for handler in &manifest.config.handlers {
703            if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
704                if u64::from(ms) > sync_ceiling {
705                    let route = &handler.route;
706                    tracing::warn!(
707                        "route {route:?} declares limits.timeout_ms={ms}, above \
708                         sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
709                         are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
710                         (?mode=async / a queue trigger / a #[consumer]), capped at \
711                         async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
712                         that isn't possible — move the work to the async lane"
713                    );
714                }
715            }
716            // A guest that self-declares a streaming handler (`#[handler(stream)]`) but whose
717            // config doesn't mark the route `streaming` would run on the tight sync request lane
718            // and be cut at the sync timeout — a silent footgun for a long-lived SSE/agent stream.
719            // Warn (don't block) so the operator sets `streaming = true` for the dedicated lane.
720            if !handler.streaming {
721                if let Some(entry) = manifest.files.get(&handler.component) {
722                    if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
723                        if crate::function_api::component_declares_streaming_route(
724                            &bytes,
725                            &handler.route,
726                        ) {
727                            let route = &handler.route;
728                            tracing::warn!(
729                                "route {route:?} is a streaming handler (#[handler(stream)]) but \
730                                 its config lacks streaming = true: it will run on the sync request \
731                                 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
732                                 streaming = true so it serves on the dedicated streaming lane (its \
733                                 own concurrency budget + a much larger wall-clock)."
734                            );
735                        }
736                    }
737                }
738            }
739            // Fail loud at deploy if the guest's function-manifest `requires` a capability feature
740            // this host build does not implement — availability lives in metadata, not the linkable
741            // WIT (PLAN v2). A clear message beats an opaque runtime failure later.
742            if let Some(entry) = manifest.files.get(&handler.component) {
743                if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
744                    let unmet = crate::function_api::unmet_requires(&bytes);
745                    if !unmet.is_empty() {
746                        return Err(format!(
747                            "route {:?} [{}] requires capabilities this host does not implement: \
748                             {}. Upgrade boatramp or enable those features — see `boatramp \
749                             capabilities`.",
750                            handler.route,
751                            handler.methods.join(","),
752                            unmet.join(", ")
753                        ));
754                    }
755                }
756            }
757            precheck_component(
758                deploy,
759                manifest,
760                site_handlers,
761                inner,
762                max_component,
763                &handler.imports,
764                &handler.component,
765                // Name route + methods (matching the client-side validator), so with
766                // one component on several routes the operator sees which is at fault.
767                &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
768                false,
769            )
770            .await?;
771        }
772        for consumer in &manifest.config.consumers {
773            precheck_component(
774                deploy,
775                manifest,
776                site_handlers,
777                inner,
778                max_component,
779                &consumer.imports,
780                &consumer.component,
781                &format!("consumer {:?}", consumer.topic),
782                true,
783            )
784            .await?;
785        }
786        Ok(())
787    }
788
789    #[cfg(not(feature = "handlers"))]
790    async fn precheck_activation(
791        &self,
792        _deploy: &DeployStore,
793        _manifest: &Manifest,
794        _site_config: Option<&SiteConfig>,
795    ) -> Result<(), String> {
796        Ok(())
797    }
798}
799
800/// Server runtime knobs that aren't part of the core (deploy, auth, handlers)
801/// triple: operational request [`limits`](ServerLimits) and an optional custom
802/// domain-ownership [`DomainProbe`] (defaults to the live network probe).
803///
804/// [`DomainProbe`]: boatramp_core::domain_verify::DomainProbe
805#[derive(Default, Clone)]
806pub struct ServerOptions {
807    /// Operational upload limits (size / idle / concurrency).
808    pub limits: ServerLimits,
809    /// Domain-ownership probe override (tests inject a scripted one); `None`
810    /// uses the live HTTP/DNS probe.
811    pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
812    /// Site to serve for a `Host` that matches no domain, instead of `404`.
813    /// `None` keeps the 404 default.
814    pub default_site: Option<String>,
815    /// Resolve an unmatched `Host` to a site **without** an explicit domain
816    /// registration — by first host label (`<site>.host`), or, when exactly one
817    /// site is served, as the sole site. The effective gate (posture knob OR a
818    /// loopback bind), computed by `serve`. `false` (the default) keeps the
819    /// strict behavior: an unmatched host resolves only to `default_site` or 404.
820    pub implicit_routing: bool,
821    /// Require a valid control-plane token to view a deployment **preview**
822    /// (`/_deploy/<id>/…` and `<id>.deploy.<host>`) — the
823    /// `previews.protect` setting. Off by default (previews are unguessable capability
824    /// URLs).
825    pub protect_previews: bool,
826    /// When set, rate limiting uses a **cluster-wide** KV-backed fixed-window
827    /// counter over this store instead of the per-node in-process buckets.
828    /// Pass the shared/replicated KV (e.g. the cluster `RaftKv`).
829    pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
830    /// The token signer (root private key / KMS / HSM), when this node issues
831    /// tokens (the `/api/tokens` create route and the OIDC→token exchange).
832    /// `None` ⇒ verify-only.
833    pub issuer: Option<Arc<dyn Signer>>,
834    /// An operator-set, single-use **bootstrap secret** enabling the
835    /// `POST /api/tokens/bootstrap` first-token route. `None` ⇒ that route returns
836    /// `501`. Compared by SHA-256, single-use (rotating the secret re-enables it);
837    /// unset once bootstrapped.
838    pub bootstrap_secret: Option<String>,
839    /// A **bootstrap-TLS identity attestation** (base64url `COSE_Sign1`) served at
840    /// `GET /.well-known/boatramp-bootstrap-identity` — the root key vouching for
841    /// this node's `--tls rpk` control-plane TLS public key, so a client pinning
842    /// only the root key can learn + pin the TLS identity. Set by `serve` under
843    /// `--tls rpk` when an issuer is present; `None` ⇒ the route returns `404`.
844    pub bootstrap_attestation: Option<String>,
845    /// The cluster mesh control hook, wired in cluster mode over
846    /// `ClusterNode`. Backs `POST /api/cluster/join` + `/rotate-key`; `None`
847    /// (single-node) ⇒ those routes return `501`.
848    pub mesh_control: Option<Arc<dyn MeshControl>>,
849    /// Origins allowed to call the control-plane `/api/*` routes cross-origin
850    /// (CORS). Empty (the default) ⇒ no `Access-Control-*` headers at all, i.e.
851    /// same-origin only — which is exactly the dogfood console, served from the
852    /// same origin as the API. Set this to host the console (or any browser
853    /// client) on a *different* origin: each entry is an exact
854    /// `scheme://host[:port]` (e.g. `https://console.example.com`), or `*` to
855    /// allow any origin. The API authenticates with a Bearer token (not cookies),
856    /// so credentials are not enabled; the matched origin is echoed back with
857    /// `Vary: Origin`, and a preflight `OPTIONS` is answered before auth runs.
858    pub cors_allowed_origins: Vec<String>,
859    /// The OIDC verifier for `/api/auth/exchange` (validates the IdP JWT before
860    /// minting a token). Only with the `oidc` feature + an issuer key.
861    #[cfg(feature = "oidc")]
862    pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
863    /// The resolved operator security posture (the hardening knobs).
864    /// Carried as an extension so the gateway, proxy, domain-verify, and upload
865    /// paths can consult it. Defaults to the strict `multi-tenant` preset.
866    pub posture: boatramp_core::security::SecurityPosture,
867    /// Whether this server's listener terminates TLS (the connection scheme is
868    /// `https`). Set by `serve` from the TLS mode; used to derive the request
869    /// scheme when `X-Forwarded-Proto` can't be trusted. Default
870    /// `false` (plain HTTP).
871    pub served_over_tls: bool,
872    /// The fleet's **canonical public origin** (e.g. `https://cp.example.com`) that
873    /// a per-request PoP proof must be bound to (`aud`). Set from `[serve]
874    /// pop_origin` in `boatramp.cfg`. Compared against a proof's bound origin —
875    /// **never** derived from a `Host`/`X-Forwarded-*` header. A holder-bound
876    /// (`cnf`) token cannot be used against a server that has not configured this
877    /// (its proof can't be verified, so the request is rejected).
878    pub pop_origin: Option<String>,
879    /// A pre-built dynamic daemon-config runtime. `serve` supplies one (built via
880    /// [`config_baseline`] + [`DaemonRuntime::new`]) so it can wake it on
881    /// SIGHUP / changelog; `None` (tests, embedders) ⇒ the router builds its own.
882    pub daemon_runtime: Option<Arc<DaemonRuntime>>,
883    /// Operator SQL capability for managed databases (migrations / queries via the
884    /// sealed credential, resolved server-side). Backs `POST /api/sql/{db}/{exec,query}`;
885    /// `None` ⇒ those routes return `501`. Wired by the node when a managed DB exists.
886    pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
887    /// Tenant-deprovision capability: drops a deleted tenant's managed databases +
888    /// roles + sealed credentials on project/site delete. `None` ⇒ delete does no
889    /// managed-DB teardown. Wired by the node when a compute-backed managed database
890    /// exists; the delete handlers call it best-effort after the store delete.
891    pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
892    /// Operator compute-exec capability (run a command inside a running workload).
893    /// Backs `POST /api/compute/{name}/exec`; `None` ⇒ `501`. Gated at the handler by
894    /// the `allow_compute_exec` posture. Wired by the node with the compute backends.
895    pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
896    /// Operator volume-reclamation capability (list + remove persistent volumes).
897    /// Backs `GET /api/compute/volumes` + `DELETE /api/compute/volumes/{name}`;
898    /// `None` ⇒ `501`. Admin-scoped (the deny-safe `/api/compute/*` default). Wired
899    /// by the node with the compute backends.
900    pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
901    /// Operator reconcile-plane control capability (restart a replica). Backs
902    /// `POST /api/compute/maintenance/restart`; `None` ⇒ `501`. Admin-scoped
903    /// (`is_compute_maintenance_path`). Wired by the node with the compute backends.
904    pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
905    /// The project-scoped internal secret store (sealed with the `[secrets]`
906    /// envelope). Backs the admin secrets API (`/api/projects/{proj}/secrets{,/{name}}`,
907    /// rewritten onto `/api/secrets{,/{name}}`) — set/list/delete of names + metadata,
908    /// **never** values. `None` ⇒ no `[secrets]` envelope was configured, and every
909    /// secrets endpoint returns a clear `501` ("no key envelope configured"), never a
910    /// panic. Not `handlers`-gated: `SecretStore` lives in boatramp-core, so the admin
911    /// API works on a lean node too. Wired by the node alongside the envelope.
912    pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
913    /// The per-project SMTP email-profile store backing the admin API
914    /// (`/api/email/profiles{,/{name}}`) — set/list/show/delete of a profile's
915    /// **redacted** config (the password is never returned). `None` ⇒ no
916    /// `[secrets]` envelope was configured, and the email endpoints fail closed with
917    /// a clear `501`. Not `handlers`-gated: `EmailProfileStore` lives in
918    /// boatramp-core, so the admin API works on a lean node. Wired by the node
919    /// alongside the envelope, like [`secret_store`](Self::secret_store).
920    pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
921    /// The embedded web-console mount (`[serve.console]`), when the operator
922    /// enabled it and the binary was built with the `console` feature. `None` ⇒
923    /// not served. The static SPA is served unauthenticated at this host+path.
924    #[cfg(feature = "console")]
925    pub console: Option<console::ConsoleMount>,
926}
927
928/// The listener's own connection scheme (`true` = `https`), carried as an
929/// extension so the serving path can derive the scheme without trusting a
930/// forged `X-Forwarded-Proto` from a direct client.
931#[derive(Clone, Copy)]
932struct ServedOverTls(bool);
933
934/// Whether the host fallback may resolve an unmatched `Host` to a site without an
935/// explicit domain registration (first-label `<site>.host`, or the sole served
936/// site). Carried as an extension; the effective gate is resolved by `serve`
937/// (posture knob OR loopback bind). `false` = strict (default_site or 404 only).
938#[derive(Clone, Copy, Default)]
939struct ImplicitRouting(bool);
940
941/// Holds the live, resolved [`EffectiveConfig`] (`file baseline ⊕ dynamic
942/// overrides`) plus the active generation hash. Request handlers read the current
943/// operational values through [`effective`](Self::effective); the daemon-config
944/// API and the SIGHUP handler [`reload`](Self::reload) it from the store, so a
945/// change converges without a restart.
946/// Defensive backstop interval for re-resolving the dynamic daemon config.
947/// Convergence is **fully notification-driven** — a local write applies
948/// immediately; a SIGHUP, a shared-store changelog invalidation of `daemon/*`, or
949/// a Raft apply of a replicated `daemon/*` write each wakes an immediate reload via
950/// [`DaemonRuntime::notify_reload`]. This long tick is only a safety net against a
951/// missed wake; it is not the convergence mechanism.
952const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
953
954pub struct DaemonRuntime {
955    baseline: boatramp_core::daemon_config::ConfigBaseline,
956    state: std::sync::RwLock<DaemonState>,
957    /// Woken (by SIGHUP / changelog / a local write) to trigger an immediate
958    /// reload instead of waiting for the backstop tick.
959    reload: tokio::sync::Notify,
960}
961
962struct DaemonState {
963    effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
964    generation: Option<String>,
965}
966
967/// The daemon-config file baseline derived from [`ServerOptions`] (the resolved
968/// `boatramp.cfg`). `serve` uses this to build a [`DaemonRuntime`] it can wake on
969/// SIGHUP/changelog; the posture's upload cap is the ceiling a dynamic override
970/// may not exceed.
971pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
972    // The static `[serve.console]` mount is the baseline the dynamic
973    // `DaemonConfig.console` override layers over. `Some(mount)` ⇒ enabled at the
974    // file level; without the `console` feature there is nothing to serve.
975    #[cfg(feature = "console")]
976    let (console_enabled, console_host, console_path) = match options.console.as_ref() {
977        Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
978        None => (false, None, None),
979    };
980    #[cfg(not(feature = "console"))]
981    let (console_enabled, console_host, console_path) = (false, None, None);
982    boatramp_core::daemon_config::ConfigBaseline {
983        default_site: options.default_site.clone(),
984        protect_previews: options.protect_previews,
985        max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
986        upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
987        max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
988        cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
989        compute_vcpus: 0,
990        compute_mem_mib: 0,
991        console_enabled,
992        console_host,
993        console_path,
994        max_upload_ceiling: options.posture.max_upload_bytes,
995        max_concurrent_uploads_ceiling: None,
996        posture: options.posture,
997    }
998}
999
1000impl DaemonRuntime {
1001    /// Build with the file baseline; the effective config starts equal to the
1002    /// baseline (no dynamic override) until [`reload`](Self::reload) runs. `serve`
1003    /// builds this (via [`config_baseline`]) so it can wake it on SIGHUP/changelog.
1004    pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1005        let effective =
1006            Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1007        Self {
1008            baseline,
1009            state: std::sync::RwLock::new(DaemonState {
1010                effective,
1011                generation: None,
1012            }),
1013            reload: tokio::sync::Notify::new(),
1014        }
1015    }
1016
1017    /// Wake an immediate re-resolve from the store. Called by the SIGHUP handler,
1018    /// the shared-store changelog poller (when a `daemon/*` key changed), and after
1019    /// a local write — so convergence is push-driven, not poll-driven.
1020    pub fn notify_reload(&self) {
1021        self.reload.notify_one();
1022    }
1023
1024    /// The current effective operational config.
1025    pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1026        self.state
1027            .read()
1028            .expect("daemon config lock")
1029            .effective
1030            .clone()
1031    }
1032
1033    /// The active generation hash (the `daemon/current` content address), or
1034    /// `None` when running on the pure file baseline.
1035    pub fn generation(&self) -> Option<String> {
1036        self.state
1037            .read()
1038            .expect("daemon config lock")
1039            .generation
1040            .clone()
1041    }
1042
1043    /// The file baseline (+ static ceilings) a write is validated against.
1044    pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1045        &self.baseline
1046    }
1047
1048    /// Re-resolve `baseline ⊕ stored dynamic config` and hot-swap the live values.
1049    /// Called after a write and on SIGHUP.
1050    pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1051        let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1052        let generation = deploy.daemon_config_generation().await?;
1053        let effective = Arc::new(cfg.resolve(&self.baseline));
1054        *self.state.write().expect("daemon config lock") = DaemonState {
1055            effective,
1056            generation,
1057        };
1058        Ok(())
1059    }
1060}
1061
1062/// Preview-access policy, carried as an extension so the preview handlers can
1063/// require a token when `protect` is set.
1064#[derive(Clone, Copy, Default)]
1065struct PreviewPolicy {
1066    protect: bool,
1067}
1068
1069/// The token issuing signer (root private key / KMS / HSM), carried as an
1070/// extension for the token-create and OIDC-exchange handlers. `None` ⇒ this node
1071/// verifies tokens but does not issue them (it has only the public key); issuing
1072/// routes return `501`.
1073#[derive(Clone, Default)]
1074struct Issuer(Option<Arc<dyn Signer>>);
1075
1076/// The first-token bootstrap gate: the SHA-256 hex of the operator-set bootstrap
1077/// secret plus an in-process lock that serializes the check-and-spend (the KV has
1078/// no compare-and-set; a persisted marker keeps it single-use across restarts).
1079/// `None` ⇒ bootstrap disabled (the route returns `501`).
1080#[derive(Clone, Default)]
1081struct BootstrapGate(Option<Arc<BootstrapInner>>);
1082
1083struct BootstrapInner {
1084    /// SHA-256 hex of the configured secret — used for both the constant-work
1085    /// comparison and the single-use marker key.
1086    secret_hash: String,
1087    /// Serializes the read-marker → mint → write-marker section so two concurrent
1088    /// redemptions can't both mint.
1089    lock: tokio::sync::Mutex<()>,
1090}
1091
1092impl BootstrapGate {
1093    fn new(secret: Option<&str>) -> Self {
1094        Self(secret.filter(|s| !s.is_empty()).map(|s| {
1095            Arc::new(BootstrapInner {
1096                secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1097                lock: tokio::sync::Mutex::new(()),
1098            })
1099        }))
1100    }
1101}
1102
1103/// The cluster mesh control operations exposed to the control-plane API,
1104/// implemented by the cluster runtime over `ClusterNode`;
1105/// `None` on a non-cluster node (the routes then return `501`).
1106#[async_trait::async_trait]
1107pub trait MeshControl: Send + Sync {
1108    /// Admit a joining node presenting a bearer join token whose single-use handle
1109    /// is `jti`: **verify the possession proof** (`possession_proof` over
1110    /// `cose::join_challenge(jti, mesh_pubkey_hex, proof_iat)`, fresh at `now`)
1111    /// against `mesh_pubkey_hex`, then — if valid and the token isn't spent — trust
1112    /// the key cluster-wide, add it to membership (id derived from the key), and
1113    /// return the current members as **root-signed** assertions. `Err` is a
1114    /// human-readable failure (e.g. this node has no root key to vouch for members).
1115    async fn admit(
1116        &self,
1117        mesh_pubkey_hex: &str,
1118        jti: &str,
1119        possession_proof: &[u8],
1120        proof_iat: u64,
1121        now: u64,
1122        advertise_addr: Option<&str>,
1123    ) -> Result<JoinOutcome, String>;
1124
1125    /// Rotate **this node's** mesh identity (make-before-break) and return the new
1126    /// public key (SPKI hex). Node-local: only the node itself can mint + persist
1127    /// its private key, so this rotates the key of the node whose API is hit.
1128    async fn rotate_key(&self) -> Result<String, String>;
1129
1130    /// Revoke `node` from the mesh: delete its trust cluster-wide (so it can no
1131    /// longer authenticate) and drop it from the quorum. `Err` is a
1132    /// human-readable failure.
1133    async fn revoke(&self, node: u64) -> Result<(), String>;
1134
1135    /// The current Raft membership (voters + learners), for the Kubernetes
1136    /// operator's membership reconciler. `caught_up` is meaningful only on the
1137    /// leader; hit the leader for a promote decision.
1138    async fn members(&self) -> Result<Vec<MeshMember>, String>;
1139
1140    /// Promote a caught-up learner `node` to a voter (leader-only; a no-op on a
1141    /// follower). `Err` is a human-readable failure.
1142    async fn promote(&self, node: u64) -> Result<(), String>;
1143}
1144
1145/// The result of a join admission ([`MeshControl::admit`]).
1146pub enum JoinOutcome {
1147    /// Admitted — carries the current members as root-signed assertions plus the
1148    /// advisory `node_id -> mesh URL` routing for them.
1149    Admitted {
1150        /// Root-signed member assertions the joiner verifies against the anchor.
1151        members: Vec<String>,
1152        /// Advisory `node_id -> mesh URL` routing (not signed).
1153        addrs: std::collections::BTreeMap<u64, String>,
1154    },
1155    /// The join token was already spent (single-use) → `409`.
1156    TokenSpent,
1157    /// The possession proof was missing/stale/invalid → `403`.
1158    ProofInvalid,
1159    /// The presented key is revoked (a durable tombstone bars it, F6) — an
1160    /// explicit un-revoke is required before it can rejoin → `403`.
1161    Revoked,
1162}
1163
1164/// One node's Raft membership, reported by `GET /api/cluster/members`.
1165#[derive(Debug, Clone, Serialize)]
1166pub struct MeshMember {
1167    /// The node id.
1168    pub node: u64,
1169    /// `true` ⇒ a voter (counts toward quorum); `false` ⇒ a learner.
1170    pub voter: bool,
1171    /// Whether a learner has caught up to the leader's log (ready to promote).
1172    pub caught_up: bool,
1173    /// Whether this node is the current leader.
1174    pub leader: bool,
1175    /// The node's advisory mesh URL, if this node knows it — the address-primary
1176    /// handle `cluster status`/`remove` use (dynamic-join learns addresses at
1177    /// admit; a static-genesis node has them from config). `None` ⇒ unknown here.
1178    #[serde(default, skip_serializing_if = "Option::is_none")]
1179    pub addr: Option<String>,
1180}
1181
1182/// The mesh control hook, carried as an extension for the join/rotate handlers.
1183/// `None` ⇒ this node is not a cluster node, so those routes return `501`.
1184#[derive(Clone, Default)]
1185struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1186
1187/// The OIDC verifier for the exchange endpoint, carried as an extension.
1188#[cfg(feature = "oidc")]
1189#[derive(Clone, Default)]
1190struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1191
1192/// TTL for an OIDC-exchanged token: short, since the holder can re-exchange
1193/// against the IdP at any time.
1194#[cfg(feature = "oidc")]
1195const EXCHANGE_TTL_SECS: u64 = 3600;
1196
1197use boatramp_core::time::now_unix;
1198
1199/// The configured CORS allowlist, carried as middleware state for the API.
1200#[derive(Clone)]
1201struct CorsState(Arc<Vec<String>>);
1202
1203/// Methods the control-plane API exposes; advertised in a preflight response.
1204const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1205/// Request headers a browser client needs (Bearer auth + JSON bodies); the
1206/// fallback when a preflight doesn't list `Access-Control-Request-Headers`.
1207const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1208/// How long a browser may cache a preflight result (seconds).
1209const CORS_MAX_AGE: &str = "600";
1210
1211/// Whether `origin` is permitted by the configured allowlist. `*` allows any
1212/// origin (the specific origin is still echoed back, with `Vary: Origin`);
1213/// otherwise the match is an exact `scheme://host[:port]` comparison.
1214fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1215    allowed.iter().any(|a| a == "*" || a == origin)
1216}
1217
1218/// Opt-in CORS for the control-plane `/api/*` routes (see
1219/// [`ServerOptions::cors_allowed_origins`]). Answers a preflight `OPTIONS`
1220/// itself — before the auth layer, since a preflight carries no credentials —
1221/// and, for an allowed `Origin`, echoes `Access-Control-Allow-Origin` plus
1222/// `Vary: Origin` onto the response. A disallowed/absent origin gets no
1223/// `Access-Control-*` headers, so the browser blocks the cross-origin read.
1224async fn cors(
1225    State(allowed): State<CorsState>,
1226    request: Request,
1227    next: axum::middleware::Next,
1228) -> Response {
1229    let origin = request
1230        .headers()
1231        .get(header::ORIGIN)
1232        .and_then(|v| v.to_str().ok())
1233        .filter(|o| cors_origin_allowed(&allowed.0, o))
1234        .map(str::to_string);
1235    // A CORS preflight is an OPTIONS carrying `Access-Control-Request-Method`.
1236    let is_preflight = request.method() == Method::OPTIONS
1237        && request
1238            .headers()
1239            .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1240    if is_preflight {
1241        // Echo the browser's requested headers when present, else our known set.
1242        let allow_headers = request
1243            .headers()
1244            .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1245            .and_then(|v| v.to_str().ok())
1246            .map(str::to_string)
1247            .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1248        let mut response = Response::new(Body::empty());
1249        *response.status_mut() = StatusCode::NO_CONTENT;
1250        if let Some(origin) = origin {
1251            let headers = response.headers_mut();
1252            set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1253            set_header(headers, header::VARY, "Origin");
1254            set_header(
1255                headers,
1256                header::ACCESS_CONTROL_ALLOW_METHODS,
1257                CORS_ALLOW_METHODS,
1258            );
1259            set_header(
1260                headers,
1261                header::ACCESS_CONTROL_ALLOW_HEADERS,
1262                &allow_headers,
1263            );
1264            set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1265        }
1266        return response;
1267    }
1268    let mut response = next.run(request).await;
1269    if let Some(origin) = origin {
1270        let headers = response.headers_mut();
1271        set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1272        // `Vary: Origin` so a shared cache can't serve one origin's CORS
1273        // response to another; appended so any existing `Vary` is preserved.
1274        if let Ok(value) = HeaderValue::from_str("Origin") {
1275            headers.append(header::VARY, value);
1276        }
1277    }
1278    response
1279}
1280
1281/// How long the shutdown drain may run before the listener is forced closed.
1282/// Generous enough for any in-flight handler invocation to finish (each is
1283/// itself bounded by the engine's epoch timeout); it only caps stuck or
1284/// abusive connections so a SIGTERM can't hang forever.
1285const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1286
1287/// A failure starting or running the HTTP server.
1288#[derive(Debug, thiserror::Error)]
1289pub enum ServeError {
1290    /// Binding the listener, or an axum serve I/O error.
1291    #[error("server I/O: {0}")]
1292    Io(#[from] std::io::Error),
1293}
1294
1295/// Bind `addr` and serve until a shutdown signal (Ctrl-C / SIGTERM), then drain
1296/// in-flight requests under [`DRAIN_DEADLINE`]. Default [`ServerOptions`].
1297pub async fn serve(
1298    addr: SocketAddr,
1299    deploy: DeployStore,
1300    auth: Auth,
1301    handlers: HandlerRuntime,
1302) -> Result<(), ServeError> {
1303    serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1304}
1305
1306/// Disable Nagle's algorithm on an accepted connection.
1307///
1308/// Without `TCP_NODELAY`, small HTTP responses on **keep-alive** connections stall
1309/// on Nagle's algorithm interacting with the peer's delayed ACK — a fixed ~40 ms
1310/// per request. That is boatramp's hot path in production: on Fly and Cloudflare
1311/// the platform terminates TLS and forwards **plaintext** HTTP to the app over
1312/// persistent connections, so the stall would hit every small response. This runs
1313/// on each accepted stream via [`axum::serve::ListenerExt::tap_io`]; it is
1314/// best-effort — a failure only forfeits the latency win, never the connection.
1315pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1316    if let Err(err) = stream.set_nodelay(true) {
1317        tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1318    }
1319}
1320
1321/// [`serve`] with explicit [`ServerOptions`] (e.g. operational request limits).
1322pub async fn serve_with(
1323    addr: SocketAddr,
1324    deploy: DeployStore,
1325    auth: Auth,
1326    handlers: HandlerRuntime,
1327    options: ServerOptions,
1328) -> Result<(), ServeError> {
1329    let tcp = tokio::net::TcpListener::bind(addr).await?;
1330    tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1331    // Context for the Linux `splice()` reverse-proxy fast-path: the store, the
1332    // resolved posture (SSRF gate), and a live read of the catch-all `default_site`
1333    // (so host resolution matches the serving pipeline). The daemon runtime is the
1334    // one `serve` supplies (shared with the router); absent it, the fast-path just
1335    // falls back for default-site hosts.
1336    let splice_ctx = splice::SpliceCtx {
1337        deploy: deploy.clone(),
1338        posture: options.posture,
1339        daemon: options.daemon_runtime.clone(),
1340    };
1341    // Background scheduler: drives consumers/crons for active deployments
1342    // (no-op without the handlers feature/runtime). Aborted after the drain.
1343    #[cfg(feature = "handlers")]
1344    let scheduler = handlers.spawn_scheduler(deploy.clone());
1345    // Background gateway active-health prober: probes the
1346    // backends of upstreams with `active_health` so a dead one leaves rotation
1347    // before client traffic. Idle until a request arms an upstream.
1348    let gateway_prober = gateway::spawn_active_health_prober();
1349    // Connect-info make-service so handlers can see the peer address (for IP
1350    // rules / rate limiting / access logs).
1351    let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1352
1353    // The graceful drain begins when the OS signal fires; `signalled` flips at
1354    // that instant so the drain deadline is measured from the signal, not from
1355    // server start.
1356    let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1357    // The splice serve loop intercepts eligible plaintext reverse-proxy connections
1358    // (Linux) and serves everything else through `boatramp-http` — an eligible plain
1359    // site GET/HEAD via the `fast` hot-path bypass, the rest through the full `router`.
1360    let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1361        shutdown_signal().await;
1362        let _ = signalled_tx.send(true);
1363    });
1364    let signalled = {
1365        let mut rx = signalled_rx;
1366        async move {
1367            let _ = rx.wait_for(|fired| *fired).await;
1368        }
1369    };
1370    let result = serve_with_drain_deadline(
1371        async move { server.await.map_err(ServeError::from) },
1372        signalled,
1373        DRAIN_DEADLINE,
1374    )
1375    .await;
1376    // Stop the scheduler once the server has drained.
1377    #[cfg(feature = "handlers")]
1378    if let Some(handle) = scheduler {
1379        handle.abort();
1380    }
1381    gateway_prober.abort();
1382    result
1383}
1384
1385/// Run the graceful-serve future `server`, but if the drain runs longer than
1386/// `deadline` *after* `signalled` resolves, stop waiting and return (dropping
1387/// `server`, which closes any still-open connections). Pulled out of [`serve`]
1388/// so the deadline behaviour is unit-testable without sockets or real signals.
1389async fn serve_with_drain_deadline<Srv, Sig>(
1390    server: Srv,
1391    signalled: Sig,
1392    deadline: Duration,
1393) -> Result<(), ServeError>
1394where
1395    Srv: Future<Output = Result<(), ServeError>>,
1396    Sig: Future<Output = ()>,
1397{
1398    tokio::pin!(server);
1399    let drain_cap = async move {
1400        signalled.await;
1401        tokio::time::sleep(deadline).await;
1402    };
1403    tokio::select! {
1404        result = &mut server => result,
1405        _ = drain_cap => {
1406            tracing::warn!(
1407                deadline_s = deadline.as_secs(),
1408                "drain deadline exceeded; forcing shutdown with requests still in flight"
1409            );
1410            Ok(())
1411        }
1412    }
1413}
1414
1415/// Resolve when the process receives Ctrl-C or SIGTERM, so in-flight requests
1416/// can drain before exit.
1417pub async fn shutdown_signal() {
1418    let ctrl_c = async {
1419        let _ = tokio::signal::ctrl_c().await;
1420    };
1421    #[cfg(unix)]
1422    let terminate = async {
1423        if let Ok(mut sig) =
1424            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1425        {
1426            sig.recv().await;
1427        }
1428    };
1429    #[cfg(not(unix))]
1430    let terminate = std::future::pending::<()>();
1431
1432    tokio::select! {
1433        _ = ctrl_c => {}
1434        _ = terminate => {}
1435    }
1436    tracing::info!("shutdown signal received; draining");
1437}
1438
1439/// Liveness probe. Also reports the active daemon-config **generation** hash so an
1440/// operator can confirm every node in a cluster converged to the same config
1441/// (`ok` alone = running on the pure file baseline).
1442async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1443    match daemon.generation() {
1444        Some(gen) => format!("ok gen={gen}"),
1445        None => "ok".to_string(),
1446    }
1447}
1448
1449/// Readiness probe: `200 ready` when the metadata backend answers, else `503`.
1450async fn readyz(State(deploy): State<DeployStore>) -> Response {
1451    match deploy.ready().await {
1452        Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1453        Err(err) => {
1454            tracing::warn!(error = %err, "readiness probe failed");
1455            (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1456        }
1457    }
1458}
1459
1460/// A per-request correlation id assigned by the access-log layer and readable downstream via
1461/// the request extensions — the handler dispatch tags captured guest logs with it, so a guest
1462/// line correlates with its `boatramp::access` line. Public so an embedder (or a test) can seed
1463/// its own id into the request extensions.
1464#[derive(Clone)]
1465pub struct RequestId(pub String);
1466
1467/// The correlation id for a request: an upstream proxy's `X-Request-Id` when present (sanitized,
1468/// length-capped), else a generated time-ordered, per-process-unique id.
1469fn request_id_for(headers: &HeaderMap) -> String {
1470    if let Some(id) = headers
1471        .get("x-request-id")
1472        .and_then(|v| v.to_str().ok())
1473        .map(str::trim)
1474        .filter(|s| !s.is_empty())
1475    {
1476        return id.chars().filter(|c| !c.is_control()).take(128).collect();
1477    }
1478    use std::sync::atomic::{AtomicU64, Ordering};
1479    static SEQ: AtomicU64 = AtomicU64::new(0);
1480    let n = SEQ.fetch_add(1, Ordering::Relaxed);
1481    format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1482}
1483
1484/// One access-log line, emitted when the response body finishes streaming, so
1485/// `bytes` (response size) and `elapsed_ms` (time-to-last-byte) are accurate for
1486/// fixed-size *and* streamed/proxied responses.
1487struct AccessLog {
1488    request_id: String,
1489    method: Method,
1490    path: String,
1491    host: String,
1492    client: String,
1493    status: u16,
1494    /// Response `Content-Encoding` (`br`/`gzip`/`identity`).
1495    encoding: String,
1496    start: std::time::Instant,
1497    bytes: std::sync::atomic::AtomicU64,
1498}
1499
1500impl Drop for AccessLog {
1501    fn drop(&mut self) {
1502        let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1503        // Aggregate into the process-wide Prometheus counters (status class +
1504        // cache result + bytes) before emitting the per-request line.
1505        srvmetrics::server_metrics().record_request(self.status, bytes);
1506        tracing::info!(
1507            target: "boatramp::access",
1508            request_id = %self.request_id,
1509            method = %self.method,
1510            path = %self.path,
1511            host = %self.host,
1512            client = %self.client,
1513            status = self.status,
1514            bytes = bytes,
1515            encoding = %self.encoding,
1516            cache_result = srvmetrics::cache_result(self.status),
1517            elapsed_ms = self.start.elapsed().as_millis() as u64,
1518            "request"
1519        );
1520    }
1521}
1522
1523/// Assign the request correlation id (from the client's header or freshly minted) and
1524/// make it readable downstream — handler dispatch tags captured guest logs with it.
1525/// Runs for every request regardless of the access-log level; returns the id. Shared by
1526/// the [`access_log`] middleware and any direct serve path so correlation is never
1527/// skipped on a bypass. (Serve hot-path bypass, stage 1.)
1528pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1529    let request_id = request_id_for(request.headers());
1530    request
1531        .extensions_mut()
1532        .insert(RequestId(request_id.clone()));
1533    request_id
1534}
1535
1536/// Request metadata captured *before* the response is produced, for the access-log line
1537/// plus the Prometheus request counters — both emitted from [`AccessLog`]'s `Drop` once
1538/// the body has fully streamed (or the client disconnected). Extracted so the access-log
1539/// middleware and a direct serve path share one implementation.
1540pub(crate) struct AccessLogCtx {
1541    request_id: String,
1542    method: Method,
1543    path: String,
1544    host: String,
1545    client: String,
1546    start: std::time::Instant,
1547}
1548
1549impl AccessLogCtx {
1550    /// Capture the request for logging, or `None` when the `boatramp::access` line is
1551    /// filtered out — in which case logging *and* the per-request metric aggregation are
1552    /// both skipped (~4 string allocations plus a body-stream wrapper avoided, matching
1553    /// how nginx/Envoy run with `access_log off`). The id is assigned separately and
1554    /// unconditionally via [`assign_request_id`].
1555    pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1556        if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1557            return None;
1558        }
1559        Some(Self {
1560            request_id,
1561            method: request.method().clone(),
1562            path: request.uri().path().to_string(),
1563            host: request
1564                .headers()
1565                .get(header::HOST)
1566                .and_then(|value| value.to_str().ok())
1567                .or_else(|| request.uri().host()) // HTTP/2: `:authority` lives in the URI
1568                .unwrap_or("-")
1569                .to_string(),
1570            client: request
1571                .extensions()
1572                .get::<axum::extract::ConnectInfo<SocketAddr>>()
1573                .map(|info| info.0.ip().to_string())
1574                .unwrap_or_else(|| "-".to_string()),
1575            start: std::time::Instant::now(),
1576        })
1577    }
1578
1579    /// Wrap `response` so its bytes are tallied as they stream; the access line + request
1580    /// metrics emit from the counter's `Drop` when the body finishes (or the client
1581    /// disconnects).
1582    pub(crate) fn finish(self, response: Response) -> Response {
1583        let encoding = response
1584            .headers()
1585            .get(header::CONTENT_ENCODING)
1586            .and_then(|v| v.to_str().ok())
1587            .unwrap_or("identity")
1588            .to_string();
1589        let log = AccessLog {
1590            request_id: self.request_id,
1591            method: self.method,
1592            path: self.path,
1593            host: self.host,
1594            client: self.client,
1595            status: response.status().as_u16(),
1596            encoding,
1597            start: self.start,
1598            bytes: std::sync::atomic::AtomicU64::new(0),
1599        };
1600        let (parts, body) = response.into_parts();
1601        let counted = body.into_data_stream().map(move |chunk| {
1602            if let Ok(bytes) = &chunk {
1603                log.bytes
1604                    .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1605            }
1606            chunk
1607        });
1608        Response::from_parts(parts, Body::from_stream(counted))
1609    }
1610}
1611
1612/// Structured access-log middleware: assigns the correlation id, then (when access
1613/// logging is on) records method / path / host / client IP / status / response bytes /
1614/// duration once the body has fully streamed. Assignment and the capture/finish logic
1615/// are shared with any direct serve path via [`assign_request_id`] + [`AccessLogCtx`].
1616async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1617    let request_id = assign_request_id(&mut request);
1618    match AccessLogCtx::capture(&request, request_id) {
1619        None => next.run(request).await,
1620        Some(ctx) => ctx.finish(next.run(request).await),
1621    }
1622}
1623
1624/// Whether the request's `If-None-Match` matches `etag` (or `*`).
1625fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1626    req_headers
1627        .get(header::IF_NONE_MATCH)
1628        .and_then(|value| value.to_str().ok())
1629        .is_some_and(|value| {
1630            value
1631                .split(',')
1632                .map(str::trim)
1633                .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1634        })
1635}
1636
1637fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1638    if let Ok(value) = HeaderValue::from_str(value) {
1639        headers.insert(name, value);
1640    }
1641}
1642
1643fn not_found() -> Response {
1644    (StatusCode::NOT_FOUND, "not found\n").into_response()
1645}
1646
1647fn redirect(status: u16, location: &str) -> Response {
1648    let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1649    match HeaderValue::from_str(location) {
1650        Ok(location) => {
1651            let mut headers = HeaderMap::new();
1652            headers.insert(header::LOCATION, location);
1653            (status, headers).into_response()
1654        }
1655        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1656    }
1657}
1658
1659/// Map a [`DeployError`] to an HTTP response.
1660fn deploy_error_response(err: DeployError) -> Response {
1661    let status = match &err {
1662        DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1663            StatusCode::NOT_FOUND
1664        }
1665        DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1666        DeployError::Incomplete(_) => StatusCode::CONFLICT,
1667        // A host already claimed by another site — refuse the overwrite.
1668        DeployError::Conflict(_) => StatusCode::CONFLICT,
1669        // An ambiguous preview-id prefix is not a usable capability → not found.
1670        DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1671        _ => StatusCode::INTERNAL_SERVER_ERROR,
1672    };
1673    tracing::warn!(error = %err, "request failed");
1674    (status, format!("{err}\n")).into_response()
1675}
1676
1677/// Reject a resource name (site/function/compute/workflow) that is unsafe at the
1678/// store-key boundary, returning `Some(422)` to short-circuit the handler. The
1679/// name arrives here already percent-decoded by axum's `Path` extractor, so a
1680/// smuggled `%2F` is caught as a literal `/`. `None` = the name is fine.
1681fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1682    boatramp_core::project::validate_resource_name(kind, value)
1683        .err()
1684        .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1685}
1686
1687#[cfg(test)]
1688mod drain_tests {
1689    use super::*;
1690
1691    #[tokio::test]
1692    async fn deadline_forces_shutdown_after_signal() {
1693        // Server never finishes draining; once the signal has fired the
1694        // deadline must end the wait (Ok — we forced shutdown deliberately).
1695        let server = std::future::pending::<Result<(), ServeError>>();
1696        let signalled = async {}; // signal already fired
1697        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1698        assert!(result.is_ok());
1699    }
1700
1701    #[tokio::test]
1702    async fn server_finishing_first_wins() {
1703        // If the server drains before the deadline, its result is returned and
1704        // the deadline never trips (signal never even fires here).
1705        let server = async { Ok(()) };
1706        let signalled = std::future::pending::<()>();
1707        let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1708        assert!(result.is_ok());
1709    }
1710
1711    #[tokio::test]
1712    async fn deadline_does_not_trip_before_signal() {
1713        // The deadline is measured from the signal: with no signal it never
1714        // trips, even past its length. The server completes (here with an
1715        // error) and that result propagates.
1716        let server = async {
1717            tokio::time::sleep(Duration::from_millis(40)).await;
1718            Err(ServeError::Io(std::io::Error::other("server error")))
1719        };
1720        let signalled = std::future::pending::<()>();
1721        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1722        assert!(result.is_err());
1723    }
1724}
1725
1726#[cfg(all(test, feature = "handlers"))]
1727mod tests {
1728    use super::*;
1729    use boatramp_core::cose::{LocalSigner, TokenAlg};
1730    use boatramp_core::project::ProjectRef;
1731
1732    #[test]
1733    fn query_string_parses_and_url_decodes() {
1734        let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1735        assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1736        assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); // %C3%A3 + '+'
1737        assert_eq!(q.get("flag").map(String::as_str), Some("")); // bare key
1738        assert_eq!(q.get("dup").map(String::as_str), Some("1")); // first value wins
1739    }
1740
1741    #[test]
1742    fn cookie_header_parses_pairs() {
1743        let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1744        assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1745        assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1746        assert_eq!(c.get("empty").map(String::as_str), Some(""));
1747    }
1748
1749    #[test]
1750    fn apply_vary_merges_without_duplicates() {
1751        let base = (StatusCode::OK, "x").into_response();
1752        let r = apply_vary(base, &["accept-language".into()]);
1753        assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1754        // Merges into an existing Vary, de-duplicating case-insensitively.
1755        let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1756        let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1757        assert!(v.contains("accept-language") && v.contains("cookie"));
1758        assert_eq!(v.matches("accept-language").count(), 1);
1759        // Empty vary is a no-op.
1760        let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1761        assert!(plain.headers().get(header::VARY).is_none());
1762    }
1763
1764    /// The `/api/cluster/join-token` handler mints a verifiable **bearer** token,
1765    /// and refuses cleanly on a verify-only node (no root key) → 501. Admin-gating
1766    /// is the deny-safe `Right::required` default for `/api/cluster/*`.
1767    #[tokio::test]
1768    async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1769        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1770        let public = keys.public_key();
1771
1772        // Happy path: the returned token verifies + yields a single-use jti.
1773        let resp = create_join_token(
1774            Extension(Issuer(Some(keys.clone()))),
1775            Json(CreateJoinTokenRequest {
1776                ttl_secs: Some(600),
1777            }),
1778        )
1779        .await;
1780        assert_eq!(resp.status(), StatusCode::CREATED);
1781        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1782            .await
1783            .unwrap();
1784        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1785        let token = parsed["token"].as_str().unwrap();
1786        let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1787        assert!(!jti.is_empty());
1788
1789        // A verify-only node (no issuing key) cannot mint → 501.
1790        let no_issuer = create_join_token(
1791            Extension(Issuer(None)),
1792            Json(CreateJoinTokenRequest { ttl_secs: None }),
1793        )
1794        .await;
1795        assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1796    }
1797
1798    /// FA-2: the top-level function **write** path driven through the HTTP handlers —
1799    /// deploy two versions, roll back, alias, remove — plus the two 400/absent-blob
1800    /// guards. The store-layer semantics are the `boatramp-core` oracle; this pins the
1801    /// handler wrapper (status codes, blob gate, JSON echo).
1802    #[tokio::test]
1803    async fn function_write_path_deploy_rollback_alias_remove() {
1804        use boatramp_core::function::Lifecycle;
1805        use boatramp_core::kv::MemoryKv;
1806        use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1807
1808        // A storage whose `head` (hence `has_blob`) is toggleable — enough to drive
1809        // both the blob-present deploy path and the absent-blob 400.
1810        struct FakeStorage {
1811            present: bool,
1812        }
1813        #[async_trait::async_trait]
1814        impl Storage for FakeStorage {
1815            async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1816                Err(StorageError::NotFound(String::new()))
1817            }
1818            async fn get_range(
1819                &self,
1820                _: &str,
1821                _: u64,
1822                _: Option<u64>,
1823            ) -> Result<GetObject, StorageError> {
1824                Err(StorageError::NotFound(String::new()))
1825            }
1826            async fn put(
1827                &self,
1828                _: &str,
1829                _: ByteStream,
1830                _: PutMeta,
1831            ) -> Result<ObjectMeta, StorageError> {
1832                Err(StorageError::unsupported("fake"))
1833            }
1834            async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1835                if self.present {
1836                    Ok(ObjectMeta {
1837                        key: key.to_string(),
1838                        ..Default::default()
1839                    })
1840                } else {
1841                    Err(StorageError::NotFound(key.to_string()))
1842                }
1843            }
1844            async fn delete(&self, _: &str) -> Result<(), StorageError> {
1845                Ok(())
1846            }
1847            async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1848                Ok(Vec::new())
1849            }
1850        }
1851
1852        async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1853            let status = resp.status();
1854            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1855                .await
1856                .unwrap();
1857            let value = if bytes.is_empty() {
1858                serde_json::Value::Null
1859            } else {
1860                serde_json::from_slice(&bytes).unwrap()
1861            };
1862            (status, value)
1863        }
1864
1865        let deploy = DeployStore::new(
1866            Arc::new(FakeStorage { present: true }),
1867            Arc::new(MemoryKv::new()),
1868        );
1869        let v1 = "a".repeat(64);
1870        let v2 = "b".repeat(64);
1871
1872        // Deploy v1 → created, active = v1.
1873        let (st, body) = body_json(
1874            deploy_function(
1875                State(deploy.clone()),
1876                axum::extract::Extension(crate::ProjectContext::default()),
1877                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1878                axum::extract::Query(DeployFunctionQuery::default()),
1879                Path("greeter".to_string()),
1880                Json(FunctionUpsert {
1881                    component: v1.clone(),
1882                    config: Default::default(),
1883                    lifecycle: Lifecycle::Independent,
1884                }),
1885            )
1886            .await,
1887        )
1888        .await;
1889        assert_eq!(st, StatusCode::OK);
1890        assert_eq!(body["active"], v1);
1891
1892        // Deploy v2 → active advances, two versions retained.
1893        let (_, body) = body_json(
1894            deploy_function(
1895                State(deploy.clone()),
1896                axum::extract::Extension(crate::ProjectContext::default()),
1897                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1898                axum::extract::Query(DeployFunctionQuery::default()),
1899                Path("greeter".to_string()),
1900                Json(FunctionUpsert {
1901                    component: v2.clone(),
1902                    config: Default::default(),
1903                    lifecycle: Lifecycle::Independent,
1904                }),
1905            )
1906            .await,
1907        )
1908        .await;
1909        assert_eq!(body["active"], v2);
1910        assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1911
1912        // Roll back to v1.
1913        let (st, body) = body_json(
1914            rollback_function(
1915                State(deploy.clone()),
1916                axum::extract::Extension(crate::ProjectContext::default()),
1917                Path("greeter".to_string()),
1918                Json(RollbackBody { to: v1.clone() }),
1919            )
1920            .await,
1921        )
1922        .await;
1923        assert_eq!(st, StatusCode::OK);
1924        assert_eq!(body["active"], v1);
1925
1926        // Rolling back to an unknown version is a 400 (plain-text body).
1927        let resp = rollback_function(
1928            State(deploy.clone()),
1929            axum::extract::Extension(crate::ProjectContext::default()),
1930            Path("greeter".to_string()),
1931            Json(RollbackBody { to: "c".repeat(64) }),
1932        )
1933        .await;
1934        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1935
1936        // Alias prod → v2.
1937        let (st, body) = body_json(
1938            alias_function(
1939                State(deploy.clone()),
1940                axum::extract::Extension(crate::ProjectContext::default()),
1941                Path(("greeter".to_string(), "prod".to_string())),
1942                Json(AliasBody {
1943                    version: v2.clone(),
1944                }),
1945            )
1946            .await,
1947        )
1948        .await;
1949        assert_eq!(st, StatusCode::OK);
1950        assert_eq!(body["aliases"]["prod"], v2);
1951
1952        // Remove → 204, and it's gone.
1953        let (st, _) = body_json(
1954            remove_function(
1955                State(deploy.clone()),
1956                axum::extract::Extension(crate::ProjectContext::default()),
1957                Path("greeter".to_string()),
1958            )
1959            .await,
1960        )
1961        .await;
1962        assert_eq!(st, StatusCode::NO_CONTENT);
1963        assert!(deploy
1964            .get_function(ProjectRef::DEFAULT, "greeter")
1965            .await
1966            .unwrap()
1967            .is_none());
1968
1969        // Deploying a component whose blob was never uploaded is a 400.
1970        let empty = DeployStore::new(
1971            Arc::new(FakeStorage { present: false }),
1972            Arc::new(MemoryKv::new()),
1973        );
1974        let resp = deploy_function(
1975            State(empty),
1976            axum::extract::Extension(crate::ProjectContext::default()),
1977            axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1978            axum::extract::Query(DeployFunctionQuery::default()),
1979            Path("orphan".to_string()),
1980            Json(FunctionUpsert {
1981                component: v1.clone(),
1982                config: Default::default(),
1983                lifecycle: Lifecycle::default(),
1984            }),
1985        )
1986        .await;
1987        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1988    }
1989
1990    /// A configurable stub: records the `(mesh_pubkey, jti)` it's asked to admit and
1991    /// returns a chosen [`JoinOutcome`] (the real possession-proof + member signing
1992    /// lives in the cluster impl; here we test the handler's dispatch + status map).
1993    struct StubControl {
1994        admits: std::sync::Mutex<Vec<(String, String)>>,
1995        respond: StubJoin,
1996    }
1997    #[derive(Clone, Copy)]
1998    enum StubJoin {
1999        Admit,
2000        Spent,
2001        Invalid,
2002        Revoked,
2003    }
2004
2005    #[async_trait::async_trait]
2006    impl MeshControl for StubControl {
2007        async fn admit(
2008            &self,
2009            mesh_pubkey_hex: &str,
2010            jti: &str,
2011            _proof: &[u8],
2012            _proof_iat: u64,
2013            _now: u64,
2014            _advertise_addr: Option<&str>,
2015        ) -> Result<JoinOutcome, String> {
2016            self.admits
2017                .lock()
2018                .unwrap()
2019                .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2020            Ok(match self.respond {
2021                StubJoin::Admit => JoinOutcome::Admitted {
2022                    members: vec!["signed-member".to_string()],
2023                    addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2024                },
2025                StubJoin::Spent => JoinOutcome::TokenSpent,
2026                StubJoin::Invalid => JoinOutcome::ProofInvalid,
2027                StubJoin::Revoked => JoinOutcome::Revoked,
2028            })
2029        }
2030        async fn rotate_key(&self) -> Result<String, String> {
2031            Ok("cafe".to_string())
2032        }
2033        async fn revoke(&self, _node: u64) -> Result<(), String> {
2034            Ok(())
2035        }
2036        async fn members(&self) -> Result<Vec<MeshMember>, String> {
2037            Ok(Vec::new())
2038        }
2039        async fn promote(&self, _node: u64) -> Result<(), String> {
2040            Ok(())
2041        }
2042    }
2043
2044    /// `POST /api/cluster/join`: a valid bearer token dispatches to the admitter and
2045    /// maps its outcome (admitted→200+members, spent→409, proof-invalid→403); a bad
2046    /// token → 401, a non-hex proof → 400, and no cluster hook → 501.
2047    #[tokio::test]
2048    async fn cluster_join_dispatches_and_maps_outcomes() {
2049        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2050        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2051        let auth = Auth::with_key(keys.public_key(), kv);
2052        let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2053        let req = |proof: &str| JoinRequest {
2054            token: token.clone(),
2055            mesh_pubkey: "302a300506032b6570032100feed".into(),
2056            possession_proof: proof.to_string(),
2057            proof_iat: now_unix(),
2058            advertise_addr: Some("https://joiner:7000".into()),
2059        };
2060
2061        // Admitted → 200 + the signed members, and the admitter saw the jti.
2062        let admitter = Arc::new(StubControl {
2063            admits: std::sync::Mutex::new(Vec::new()),
2064            respond: StubJoin::Admit,
2065        });
2066        let resp = cluster_join(
2067            Extension(auth.clone()),
2068            Extension(MeshControlHandle(Some(admitter.clone()))),
2069            Json(req("aa01")),
2070        )
2071        .await;
2072        assert_eq!(resp.status(), StatusCode::OK);
2073        assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2074
2075        // Spent token → 409; proof-invalid → 403 (the impl's verdicts, mapped).
2076        let spent = Arc::new(StubControl {
2077            admits: std::sync::Mutex::new(Vec::new()),
2078            respond: StubJoin::Spent,
2079        });
2080        assert_eq!(
2081            cluster_join(
2082                Extension(auth.clone()),
2083                Extension(MeshControlHandle(Some(spent))),
2084                Json(req("aa01")),
2085            )
2086            .await
2087            .status(),
2088            StatusCode::CONFLICT
2089        );
2090        let invalid = Arc::new(StubControl {
2091            admits: std::sync::Mutex::new(Vec::new()),
2092            respond: StubJoin::Invalid,
2093        });
2094        assert_eq!(
2095            cluster_join(
2096                Extension(auth.clone()),
2097                Extension(MeshControlHandle(Some(invalid))),
2098                Json(req("aa01")),
2099            )
2100            .await
2101            .status(),
2102            StatusCode::FORBIDDEN
2103        );
2104        // A revoked key → 403 (a tombstone bars re-admission until un-revoked).
2105        let revoked = Arc::new(StubControl {
2106            admits: std::sync::Mutex::new(Vec::new()),
2107            respond: StubJoin::Revoked,
2108        });
2109        assert_eq!(
2110            cluster_join(
2111                Extension(auth.clone()),
2112                Extension(MeshControlHandle(Some(revoked))),
2113                Json(req("aa01")),
2114            )
2115            .await
2116            .status(),
2117            StatusCode::FORBIDDEN
2118        );
2119
2120        // A non-hex possession proof → 400 (before dispatch).
2121        let ok = Arc::new(StubControl {
2122            admits: std::sync::Mutex::new(Vec::new()),
2123            respond: StubJoin::Admit,
2124        });
2125        assert_eq!(
2126            cluster_join(
2127                Extension(auth.clone()),
2128                Extension(MeshControlHandle(Some(ok))),
2129                Json(req("not-hex")),
2130            )
2131            .await
2132            .status(),
2133            StatusCode::BAD_REQUEST
2134        );
2135
2136        // No cluster hook → 501.
2137        let none = cluster_join(
2138            Extension(auth),
2139            Extension(MeshControlHandle(None)),
2140            Json(req("aa01")),
2141        )
2142        .await;
2143        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2144    }
2145
2146    /// `POST /api/tokens/bootstrap`: the right single-use secret mints a verifiable,
2147    /// recorded first token exactly once; a wrong secret is `401`, a reused one
2148    /// `409`, and a node without a bootstrap secret configured is `501`.
2149    #[tokio::test]
2150    async fn bootstrap_mints_the_first_token_once() {
2151        use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2152        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2153        let public = keys.public_key();
2154        let deploy = DeployStore::new(
2155            Arc::new(MemStorage::default()),
2156            Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2157        );
2158        let secret = "s3cr3t-bootstrap-value";
2159        let gate = BootstrapGate::new(Some(secret));
2160        let issuer = Issuer(Some(keys.clone()));
2161        let bearer = |s: &str| {
2162            let mut h = HeaderMap::new();
2163            h.insert(
2164                AUTHORIZATION,
2165                HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2166            );
2167            h
2168        };
2169        let req = || BootstrapRequest {
2170            roles: vec!["admin".to_string()],
2171            ttl_secs: None,
2172        };
2173
2174        // Wrong secret → 401.
2175        let bad = bootstrap_token(
2176            State(deploy.clone()),
2177            Extension(issuer.clone()),
2178            Extension(gate.clone()),
2179            bearer("wrong"),
2180            Json(req()),
2181        )
2182        .await;
2183        assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2184
2185        // Correct secret → 201, a token the root key verifies as admin, recorded.
2186        let ok = bootstrap_token(
2187            State(deploy.clone()),
2188            Extension(issuer.clone()),
2189            Extension(gate.clone()),
2190            bearer(secret),
2191            Json(req()),
2192        )
2193        .await;
2194        assert_eq!(ok.status(), StatusCode::CREATED);
2195        let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2196            .await
2197            .unwrap();
2198        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2199        let token = json["token"].as_str().unwrap();
2200        let id = json["id"].as_str().unwrap();
2201        let verified = cose::verify(token, &public, now_unix()).unwrap();
2202        assert!(verified.roles.iter().any(|r| r.name == "admin"));
2203        assert!(deploy
2204            .list_token_meta()
2205            .await
2206            .unwrap()
2207            .iter()
2208            .any(|m| m.revocation_id == id));
2209
2210        // Reuse of the same secret → 409 (single-use).
2211        let reuse = bootstrap_token(
2212            State(deploy.clone()),
2213            Extension(issuer.clone()),
2214            Extension(gate),
2215            bearer(secret),
2216            Json(req()),
2217        )
2218        .await;
2219        assert_eq!(reuse.status(), StatusCode::CONFLICT);
2220
2221        // No bootstrap secret configured → 501.
2222        let disabled = bootstrap_token(
2223            State(deploy),
2224            Extension(issuer),
2225            Extension(BootstrapGate(None)),
2226            bearer(secret),
2227            Json(req()),
2228        )
2229        .await;
2230        assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2231    }
2232
2233    /// `POST /api/cluster/rotate-key` rotates via the control hook and returns the
2234    /// new pubkey; `501` on a non-cluster node.
2235    #[tokio::test]
2236    async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2237        let control = Arc::new(StubControl {
2238            admits: std::sync::Mutex::new(Vec::new()),
2239            respond: StubJoin::Admit,
2240        });
2241        let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2242        assert_eq!(resp.status(), StatusCode::OK);
2243        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2244            .await
2245            .unwrap();
2246        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2247        assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2248
2249        let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2250        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2251    }
2252
2253    #[test]
2254    fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2255        use boatramp_core::security::SecurityProfile;
2256        let strict = SecurityProfile::MultiTenant.preset();
2257        let loose = SecurityProfile::SingleTenant.preset(); // allows private upstreams
2258
2259        let public: IpAddr = "93.184.216.34".parse().unwrap(); // example.com
2260        let private: IpAddr = "10.1.2.3".parse().unwrap();
2261        let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2262        let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2263
2264        // Strict (multi-tenant): only globally-routable addresses are allowed.
2265        assert!(gateway_addr_allowed(public, &strict));
2266        assert!(!gateway_addr_allowed(private, &strict));
2267        assert!(!gateway_addr_allowed(loopback, &strict));
2268        assert!(!gateway_addr_allowed(metadata, &strict));
2269
2270        // Operator opt-in: private/loopback allowed, but cloud-metadata is still
2271        // refused (defense in depth — it is never a legitimate target).
2272        assert!(gateway_addr_allowed(public, &loose));
2273        assert!(gateway_addr_allowed(private, &loose));
2274        assert!(gateway_addr_allowed(loopback, &loose));
2275        assert!(!gateway_addr_allowed(metadata, &loose));
2276    }
2277
2278    #[tokio::test]
2279    async fn resolve_env_merges_static_and_host_secrets() {
2280        use boatramp_core::config::HandlersSiteConfig;
2281
2282        // A uniquely-named host var holds the real secret value.
2283        std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2284
2285        let deploy_env = std::collections::BTreeMap::from([
2286            ("GREETING".to_string(), "hi".to_string()),
2287            ("OVERRIDE_ME".to_string(), "static".to_string()),
2288        ]);
2289        let site_handlers = HandlersSiteConfig {
2290            enabled: true,
2291            secrets: std::collections::BTreeMap::from([
2292                // guest var <- host env var holding the value
2293                (
2294                    "SECRET_TOKEN".to_string(),
2295                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2296                ),
2297                (
2298                    "OVERRIDE_ME".to_string(),
2299                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2300                ),
2301                (
2302                    "MISSING".to_string(),
2303                    "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2304                ),
2305            ]),
2306            ..Default::default()
2307        };
2308        // Single-tenant / dev: host-env secret refs are permitted (the operator
2309        // authors the site config), so this resolves exactly as before.
2310        let env = resolve_env(
2311            "blog",
2312            boatramp_core::project::ProjectRef::DEFAULT,
2313            &deploy_env,
2314            &site_handlers,
2315            true,
2316            None,
2317        )
2318        .await
2319        .expect("resolves");
2320
2321        // Static var present; secret resolved from the host env; a secret
2322        // overrides a static of the same name; a secret whose host var is unset
2323        // is skipped (never injected as empty).
2324        assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2325        assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2326        assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2327        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2328
2329        std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2330    }
2331
2332    #[tokio::test]
2333    async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2334        use boatramp_core::config::HandlersSiteConfig;
2335
2336        // The exfiltration vector: an untrusted tenant names another tenant's / the
2337        // operator's host env var (bare or `env:`) in its site `secrets` map. Under
2338        // the multi-tenant posture (`allow_env_secret_refs = false`) the resolver
2339        // must REFUSE — never read the host env — and name the offending guest var.
2340        std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2341        let deploy_env = std::collections::BTreeMap::new();
2342        let bare = HandlersSiteConfig {
2343            enabled: true,
2344            secrets: std::collections::BTreeMap::from([(
2345                "STOLEN".to_string(),
2346                "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2347            )]),
2348            ..Default::default()
2349        };
2350        let err = resolve_env(
2351            "evil",
2352            boatramp_core::project::ProjectRef::DEFAULT,
2353            &deploy_env,
2354            &bare,
2355            false,
2356            None,
2357        )
2358        .await
2359        .expect_err("multi-tenant must refuse a bare host-env ref");
2360        assert!(
2361            err.contains("STOLEN"),
2362            "error names the offending guest var: {err}"
2363        );
2364        assert!(
2365            err.contains("multi-tenant"),
2366            "error steers the tenant: {err}"
2367        );
2368        assert!(
2369            !err.contains("leak-me"),
2370            "the host value must never appear (never read): {err}"
2371        );
2372
2373        // The explicit `env:` scheme is refused identically.
2374        let explicit = HandlersSiteConfig {
2375            enabled: true,
2376            secrets: std::collections::BTreeMap::from([(
2377                "STOLEN".to_string(),
2378                "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2379            )]),
2380            ..Default::default()
2381        };
2382        assert!(resolve_env(
2383            "evil",
2384            boatramp_core::project::ProjectRef::DEFAULT,
2385            &deploy_env,
2386            &explicit,
2387            false,
2388            None,
2389        )
2390        .await
2391        .is_err());
2392
2393        // A reserved-but-unimplemented scheme is also refused (no silent fall-through).
2394        let reserved = HandlersSiteConfig {
2395            enabled: true,
2396            secrets: std::collections::BTreeMap::from([(
2397                "TOKEN".to_string(),
2398                "vault:kv/data/app#token".to_string(),
2399            )]),
2400            ..Default::default()
2401        };
2402        let err = resolve_env(
2403            "evil",
2404            boatramp_core::project::ProjectRef::DEFAULT,
2405            &deploy_env,
2406            &reserved,
2407            true,
2408            None,
2409        )
2410        .await
2411        .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2412        assert!(err.contains("not yet supported"), "{err}");
2413
2414        // The rule is provider-neutral: ANY value with a colon is a scheme, so an
2415        // un-enumerated one (a cloud secret manager) is refused too — never misread
2416        // as a bare host var literally named "aws:sm/prod/apikey".
2417        let arbitrary = HandlersSiteConfig {
2418            enabled: true,
2419            secrets: std::collections::BTreeMap::from([(
2420                "KEY".to_string(),
2421                "aws:sm/prod/apikey".to_string(),
2422            )]),
2423            ..Default::default()
2424        };
2425        let err = resolve_env(
2426            "evil",
2427            boatramp_core::project::ProjectRef::DEFAULT,
2428            &deploy_env,
2429            &arbitrary,
2430            true,
2431            None,
2432        )
2433        .await
2434        .expect_err("any unknown scheme is reserved, even under single-tenant");
2435        assert!(
2436            err.contains("not yet supported") && err.contains("aws"),
2437            "provider-neutral reservation names the scheme: {err}"
2438        );
2439
2440        std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2441    }
2442
2443    #[tokio::test]
2444    async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2445        // A top-level function resolves its `secrets` map exactly like a site
2446        // handler: `resolve_secret_env` reads the host env var named by the map's
2447        // value and injects it under the map's key. This is the SAME helper the
2448        // handler path uses, so the semantics are identical by construction.
2449        std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2450
2451        let static_env = std::collections::BTreeMap::from([
2452            ("STAGE".to_string(), "prod".to_string()),
2453            ("OVERRIDE_ME".to_string(), "static".to_string()),
2454        ]);
2455        let secrets = std::collections::BTreeMap::from([
2456            // guest ENV_VAR <- host env var holding the value
2457            ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2458            // a secret overrides a static of the same name
2459            (
2460                "OVERRIDE_ME".to_string(),
2461                "BOATRAMP_TEST_FN_SECRET".to_string(),
2462            ),
2463            // an unset host referent is skipped, never injected empty
2464            (
2465                "MISSING".to_string(),
2466                "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2467            ),
2468        ]);
2469        // Single-tenant / dev: host-env refs permitted, so this resolves as before.
2470        let env = resolve_secret_env(
2471            "fn/api",
2472            boatramp_core::project::ProjectRef::DEFAULT,
2473            &static_env,
2474            &secrets,
2475            true,
2476            None,
2477        )
2478        .await
2479        .expect("resolves");
2480
2481        assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2482        // The secret is injected under its target ENV_VAR, read from the host env.
2483        assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2484        // A secret overrides a static of the same name.
2485        assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2486        // Absent host var → skipped (matches the handler's missing-var behavior).
2487        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2488
2489        std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2490    }
2491
2492    #[tokio::test]
2493    async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2494        // The function analog of the handler exfiltration vector: a function's
2495        // `secrets` map naming a host env var must be REFUSED under the multi-tenant
2496        // posture (never read), using the SAME helper the handler path uses — so the
2497        // fail-closed semantics are identical by construction.
2498        std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2499        let static_env = std::collections::BTreeMap::new();
2500        let secrets = std::collections::BTreeMap::from([(
2501            "DB_URL".to_string(),
2502            "BOATRAMP_TEST_FN_LEAK".to_string(),
2503        )]);
2504
2505        // Multi-tenant: refused, names the offending guest var, host value never read.
2506        let err = resolve_secret_env(
2507            "fn/api",
2508            boatramp_core::project::ProjectRef::DEFAULT,
2509            &static_env,
2510            &secrets,
2511            false,
2512            None,
2513        )
2514        .await
2515        .expect_err("multi-tenant must refuse a function host-env ref");
2516        assert!(
2517            err.contains("DB_URL"),
2518            "error names the offending guest var: {err}"
2519        );
2520        assert!(
2521            !err.contains("leak-me"),
2522            "host value must never appear: {err}"
2523        );
2524
2525        // Single-tenant / dev: the same ref resolves + injects (operator owns config).
2526        let env = resolve_secret_env(
2527            "fn/api",
2528            boatramp_core::project::ProjectRef::DEFAULT,
2529            &static_env,
2530            &secrets,
2531            true,
2532            None,
2533        )
2534        .await
2535        .expect("resolves");
2536        assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2537
2538        std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2539    }
2540
2541    #[tokio::test]
2542    async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2543        use boatramp_core::project::ProjectRef;
2544        use boatramp_core::secret_store::SecretStore;
2545        use std::sync::Arc;
2546
2547        // A reversible test envelope (XOR) so `set` seals and `get` unseals.
2548        struct XorEnvelope;
2549        #[async_trait::async_trait]
2550        impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2551            async fn wrap(
2552                &self,
2553                p: &[u8],
2554            ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2555                Ok(p.iter().map(|b| b ^ 0x5a).collect())
2556            }
2557            async fn unwrap(
2558                &self,
2559                c: &[u8],
2560            ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2561                Ok(c.iter().map(|b| b ^ 0x5a).collect())
2562            }
2563        }
2564
2565        let store = SecretStore::new(
2566            Arc::new(boatramp_core::kv::MemoryKv::new()),
2567            Arc::new(XorEnvelope),
2568        );
2569        store
2570            .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2571            .await
2572            .unwrap();
2573
2574        let static_env = std::collections::BTreeMap::new();
2575        let secrets = std::collections::BTreeMap::from([
2576            ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2577            ("MISSING".to_string(), "boatramp:not-set".to_string()),
2578        ]);
2579
2580        // Resolves under the MULTI-TENANT posture (allow_env_secret_refs = false):
2581        // the project-scoped store is the multi-tenant-safe path, not gated on it.
2582        let env = resolve_secret_env(
2583            "site",
2584            ProjectRef::new("acme"),
2585            &static_env,
2586            &secrets,
2587            false,
2588            Some(&store),
2589        )
2590        .await
2591        .expect("boatramp refs resolve without the host-env gate");
2592        assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2593        // A missing boatramp secret is skipped, never injected empty (like a missing env var).
2594        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2595
2596        // Project isolation: the same ref under a different project does not see acme's secret.
2597        let other_secrets = std::collections::BTreeMap::from([(
2598            "API_KEY".to_string(),
2599            "boatramp:api-key".to_string(),
2600        )]);
2601        let other = resolve_secret_env(
2602            "site",
2603            ProjectRef::new("globex"),
2604            &static_env,
2605            &other_secrets,
2606            false,
2607            Some(&store),
2608        )
2609        .await
2610        .expect("resolves (a foreign project's secret is simply absent → skipped)");
2611        assert!(
2612            !other.iter().any(|(k, _)| k == "API_KEY"),
2613            "a tenant must not read another project's secret"
2614        );
2615
2616        // Fail-closed: a boatramp: ref with no store configured errors (does not silently skip).
2617        let err = resolve_secret_env(
2618            "site",
2619            ProjectRef::new("acme"),
2620            &static_env,
2621            &other_secrets,
2622            false,
2623            None,
2624        )
2625        .await
2626        .expect_err("no store configured must fail closed");
2627        assert!(err.contains("no internal secret store"), "{err}");
2628    }
2629
2630    fn req() -> Request {
2631        Request::builder()
2632            .uri("/")
2633            .header(header::HOST, "example.com")
2634            .body(Body::empty())
2635            .unwrap()
2636    }
2637
2638    #[test]
2639    fn forwarded_headers_set_standard_triple() {
2640        let mut request = req();
2641        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2642        let h = request.headers();
2643        assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2644        assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2645        assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2646    }
2647
2648    #[test]
2649    fn forwarded_for_overwrites_spoofed_value() {
2650        // A client-supplied X-Forwarded-For must not survive: the host stamps
2651        // the single resolved address, not an attacker-controlled chain.
2652        let mut request = Request::builder()
2653            .uri("/")
2654            .header(header::HOST, "example.com")
2655            .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2656            .body(Body::empty())
2657            .unwrap();
2658        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2659        let values: Vec<_> = request
2660            .headers()
2661            .get_all("x-forwarded-for")
2662            .iter()
2663            .collect();
2664        assert_eq!(values.len(), 1);
2665        assert_eq!(values[0], "203.0.113.7");
2666    }
2667
2668    #[test]
2669    fn forwarded_proto_preserves_upstream_tls() {
2670        // A TLS-terminating reverse proxy in front already set https; keep it.
2671        let mut request = Request::builder()
2672            .uri("/")
2673            .header(header::HOST, "example.com")
2674            .header("x-forwarded-proto", "https")
2675            .body(Body::empty())
2676            .unwrap();
2677        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2678        assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2679    }
2680
2681    #[test]
2682    fn forwarded_host_absent_when_no_host_header() {
2683        let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2684        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2685        assert!(request.headers().get("x-forwarded-host").is_none());
2686        assert_eq!(
2687            request.headers().get("x-forwarded-for").unwrap(),
2688            "203.0.113.7"
2689        );
2690    }
2691
2692    // ---- consumer dispatcher (#17) -----------------------------------------
2693
2694    use boatramp_core::kv::{KvStore, MemoryKv};
2695    use boatramp_core::messaging::{LogMessaging, Messaging};
2696    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2697
2698    const EVENT_CONSUMER: &[u8] =
2699        include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2700
2701    #[derive(Default)]
2702    struct MemStorage {
2703        objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2704    }
2705
2706    #[async_trait::async_trait]
2707    impl boatramp_core::Storage for MemStorage {
2708        async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2709            let bytes = self
2710                .objects
2711                .lock()
2712                .unwrap()
2713                .get(key)
2714                .cloned()
2715                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2716            let body: ByteStream =
2717                futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2718            Ok(GetObject {
2719                meta: ObjectMeta {
2720                    key: key.to_string(),
2721                    ..Default::default()
2722                },
2723                body,
2724            })
2725        }
2726        async fn get_range(
2727            &self,
2728            key: &str,
2729            _: u64,
2730            _: Option<u64>,
2731        ) -> Result<GetObject, StorageError> {
2732            self.get(key).await
2733        }
2734        async fn put(
2735            &self,
2736            key: &str,
2737            mut body: ByteStream,
2738            _: PutMeta,
2739        ) -> Result<ObjectMeta, StorageError> {
2740            use futures::StreamExt;
2741            let mut buf = Vec::new();
2742            while let Some(chunk) = body.next().await {
2743                buf.extend_from_slice(&chunk?);
2744            }
2745            self.objects.lock().unwrap().insert(key.to_string(), buf);
2746            Ok(ObjectMeta {
2747                key: key.to_string(),
2748                ..Default::default()
2749            })
2750        }
2751        async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2752            self.objects
2753                .lock()
2754                .unwrap()
2755                .get(key)
2756                .map(|_| ObjectMeta {
2757                    key: key.to_string(),
2758                    ..Default::default()
2759                })
2760                .ok_or_else(|| StorageError::NotFound(key.to_string()))
2761        }
2762        async fn delete(&self, key: &str) -> Result<(), StorageError> {
2763            self.objects.lock().unwrap().remove(key);
2764            Ok(())
2765        }
2766        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2767            Ok(Vec::new())
2768        }
2769    }
2770
2771    /// Build an `ObservedInstance` for the wake-from-zero helper tests, owned by
2772    /// `project` (so the project-scoped resolution can be exercised).
2773    fn observed_state_in(
2774        project: &str,
2775        workload: &str,
2776        host: &str,
2777        healthy: bool,
2778        phase: boatramp_core::compute::ReplicaPhase,
2779    ) -> boatramp_core::compute::ObservedInstance {
2780        use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2781        boatramp_core::compute::ObservedInstance {
2782            handle: InstanceHandle {
2783                project: project.into(),
2784                workload: workload.into(),
2785                replica: 0,
2786                backend_ref: "ref-0".into(),
2787            },
2788            node: 1,
2789            backend: "vmm".into(),
2790            endpoint: Endpoint {
2791                scheme: Scheme::Http,
2792                host: host.into(),
2793                port: 80,
2794            },
2795            region: None,
2796            healthy,
2797            started_at: None,
2798            phase,
2799            snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2800                project: project.into(),
2801                workload: workload.into(),
2802                replica: 0,
2803                data_ref: "snap-0".into(),
2804            }),
2805        }
2806    }
2807
2808    /// The default-project helper the wake-from-zero tests use.
2809    fn observed_state(
2810        workload: &str,
2811        healthy: bool,
2812        phase: boatramp_core::compute::ReplicaPhase,
2813    ) -> boatramp_core::compute::ObservedInstance {
2814        observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2815    }
2816
2817    #[tokio::test]
2818    async fn has_parked_replica_detects_a_zeroed_replica() {
2819        use boatramp_core::compute::ReplicaPhase;
2820        let storage = Arc::new(MemStorage::default());
2821        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2822        let deploy = DeployStore::new(storage, kv);
2823
2824        // Nothing → false.
2825        assert!(!has_parked_replica(&deploy, "default", "w").await);
2826        // A running replica → false (it's serving, not parked).
2827        deploy
2828            .set_replica_state(
2829                ProjectRef::DEFAULT,
2830                &observed_state("w", true, ReplicaPhase::Running),
2831            )
2832            .await
2833            .unwrap();
2834        assert!(!has_parked_replica(&deploy, "default", "w").await);
2835        // A parked (Zero) replica → true (wakeable).
2836        deploy
2837            .set_replica_state(
2838                ProjectRef::DEFAULT,
2839                &observed_state("w", false, ReplicaPhase::Zero),
2840            )
2841            .await
2842            .unwrap();
2843        assert!(has_parked_replica(&deploy, "default", "w").await);
2844    }
2845
2846    #[tokio::test]
2847    async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2848        use boatramp_core::compute::ReplicaPhase;
2849        let storage = Arc::new(MemStorage::default());
2850        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2851        let deploy = DeployStore::new(storage, kv);
2852
2853        // No healthy replica → times out with an empty pool (short timeout).
2854        let empty = await_warm(
2855            &deploy,
2856            "default",
2857            "w",
2858            std::time::Duration::from_millis(150),
2859        )
2860        .await;
2861        assert!(empty.is_empty());
2862
2863        // A healthy replica → returned promptly.
2864        deploy
2865            .set_replica_state(
2866                ProjectRef::DEFAULT,
2867                &observed_state("w", true, ReplicaPhase::Running),
2868            )
2869            .await
2870            .unwrap();
2871        let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
2872        assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2873    }
2874
2875    /// The project-scoped compute upstream resolution (v0.3.12): a workload named
2876    /// `web` exists in BOTH the `acme` project and `default`, on different endpoints.
2877    /// `compute_endpoints`/`has_parked_replica` must resolve against the project they
2878    /// are asked for — a non-default tenant no longer resolves against `default` (the
2879    /// project-blind bug that 502'd it / never woke it).
2880    #[tokio::test]
2881    async fn compute_endpoints_are_project_scoped() {
2882        use boatramp_core::compute::ReplicaPhase;
2883        let storage = Arc::new(MemStorage::default());
2884        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2885        let deploy = DeployStore::new(storage, kv);
2886
2887        // Same workload name `web`, one per project, distinct endpoints.
2888        deploy
2889            .set_replica_state(
2890                ProjectRef::new("acme"),
2891                &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
2892            )
2893            .await
2894            .unwrap();
2895        deploy
2896            .set_replica_state(
2897                ProjectRef::DEFAULT,
2898                &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
2899            )
2900            .await
2901            .unwrap();
2902
2903        // Asking for `acme` yields acme's endpoint — NOT default's.
2904        assert_eq!(
2905            compute_endpoints(&deploy, "acme", "web").await,
2906            vec!["http://10.0.0.5:80".to_string()],
2907            "acme's web resolves against acme, not default"
2908        );
2909        // Asking for `default` yields default's endpoint.
2910        assert_eq!(
2911            compute_endpoints(&deploy, "default", "web").await,
2912            vec!["http://10.0.0.9:80".to_string()]
2913        );
2914        // A project with no such workload resolves empty (→ 502), not another
2915        // project's replica.
2916        assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
2917    }
2918
2919    /// The delivery gate: a consumer receives every published message at-least-once
2920    /// (acked, counted once each), and a message that keeps failing is
2921    /// redelivered and then dead-lettered after `max_attempts`.
2922    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2923    async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2924        use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2925        let storage = Arc::new(MemStorage::default());
2926        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2927        let mq = LogMessaging::new(storage, kv.clone());
2928        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2929        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2930        let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2931        let topic = "blog/orders/created";
2932
2933        // Three good messages → each delivered + acked exactly once.
2934        for _ in 0..3 {
2935            mq.publish(topic, b"ok").await.unwrap();
2936        }
2937        loop {
2938            let acked = dispatch_consumer_batch(
2939                &engine,
2940                &mq,
2941                &metrics::Metrics::default(),
2942                "blog",
2943                topic,
2944                "blog/",
2945                "",
2946                boatramp_core::messaging::StartPosition::Latest,
2947                &hash,
2948                EVENT_CONSUMER,
2949                &bindings,
2950                Limits::default(),
2951                Duration::from_secs(30),
2952                5,
2953                10,
2954            )
2955            .await;
2956            if acked == 0 {
2957                break;
2958            }
2959        }
2960        assert_eq!(
2961            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2962            Some(b"3".to_vec())
2963        );
2964
2965        // A poison message keeps failing → redelivered, then dead-lettered after
2966        // max_attempts (zero lease makes redelivery immediate).
2967        mq.publish(topic, b"fail").await.unwrap();
2968        for _ in 0..5 {
2969            dispatch_consumer_batch(
2970                &engine,
2971                &mq,
2972                &metrics::Metrics::default(),
2973                "blog",
2974                topic,
2975                "blog/",
2976                "",
2977                boatramp_core::messaging::StartPosition::Latest,
2978                &hash,
2979                EVENT_CONSUMER,
2980                &bindings,
2981                Limits::default(),
2982                Duration::ZERO,
2983                2,
2984                10,
2985            )
2986            .await;
2987        }
2988        assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2989        // The good counter is untouched by the poison message.
2990        assert_eq!(
2991            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2992            Some(b"3".to_vec())
2993        );
2994    }
2995
2996    /// Config-driven fan-out through the dispatcher: two consumers with different
2997    /// **groups** on one topic each receive every message (not one-of-N), each
2998    /// with its own cursor + ack. The one delivered message increments the
2999    /// consumer's counter once *per group*.
3000    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3001    async fn consumer_groups_fan_out_through_the_dispatcher() {
3002        use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3003        let storage = Arc::new(MemStorage::default());
3004        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3005        let mq = LogMessaging::new(storage, kv.clone());
3006        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3007        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3008        let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3009        let topic = "blog/orders/created";
3010        let start = boatramp_core::messaging::StartPosition::Latest;
3011
3012        // Both groups subscribe first (registering them turns on retention), then
3013        // one event is published — the fabric shape (workers deployed, then events).
3014        for g in ["billing", "audit"] {
3015            let n = dispatch_consumer_batch(
3016                &engine,
3017                &mq,
3018                &metrics::Metrics::default(),
3019                "blog",
3020                topic,
3021                "blog/",
3022                g,
3023                start,
3024                &hash,
3025                EVENT_CONSUMER,
3026                &bindings,
3027                Limits::default(),
3028                Duration::from_secs(30),
3029                5,
3030                10,
3031            )
3032            .await;
3033            assert_eq!(n, 0, "no events yet for group {g}");
3034        }
3035        mq.publish(topic, b"ok").await.unwrap();
3036
3037        // Each group independently delivers the one message.
3038        for g in ["billing", "audit"] {
3039            let n = dispatch_consumer_batch(
3040                &engine,
3041                &mq,
3042                &metrics::Metrics::default(),
3043                "blog",
3044                topic,
3045                "blog/",
3046                g,
3047                start,
3048                &hash,
3049                EVENT_CONSUMER,
3050                &bindings,
3051                Limits::default(),
3052                Duration::from_secs(30),
3053                5,
3054                10,
3055            )
3056            .await;
3057            assert_eq!(n, 1, "group {g} should receive the message");
3058        }
3059        // Delivered once per group ⇒ counted twice (fan-out), not once.
3060        assert_eq!(
3061            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3062            Some(b"2".to_vec())
3063        );
3064    }
3065
3066    /// The activation policy: the scheduler runs the **current** deployment's
3067    /// consumers (production namespace `{site}`), but never a preview's — a
3068    /// preview-namespaced message is left untouched.
3069    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3070    async fn scheduler_runs_current_consumers_not_previews() {
3071        use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3072        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3073        use boatramp_handlers::{HandlerEngine, Limits};
3074        use futures::StreamExt;
3075
3076        let storage = Arc::new(MemStorage::default());
3077        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3078        let deploy = DeployStore::new(storage.clone(), kv.clone());
3079        let messaging: Arc<dyn Messaging> =
3080            Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3081
3082        // Store the consumer component + a deployment that subscribes to it.
3083        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3084        let stream: ByteStream =
3085            futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3086                .boxed();
3087        deploy.put_blob(&hash, stream).await.unwrap();
3088        let mut files = std::collections::BTreeMap::new();
3089        files.insert(
3090            "consumer.wasm".to_string(),
3091            FileEntry {
3092                hash: hash.clone(),
3093                size: EVENT_CONSUMER.len() as u64,
3094                content_type: None,
3095                variants: std::collections::BTreeMap::new(),
3096            },
3097        );
3098        let manifest = Manifest {
3099            files,
3100            config: DeployConfig {
3101                consumers: vec![ConsumerConfig {
3102                    topic: "orders/created".into(),
3103                    component: "consumer.wasm".into(),
3104                    imports: vec!["wasi:keyvalue".into()],
3105                    group: String::new(),
3106                    start: Default::default(),
3107                }],
3108                ..Default::default()
3109            },
3110            ..Default::default()
3111        };
3112        let id = deploy.put_manifest(&manifest).await.unwrap();
3113        deploy
3114            .activate(ProjectRef::DEFAULT, "blog", &id)
3115            .await
3116            .unwrap();
3117        deploy
3118            .set_site_config(
3119                ProjectRef::DEFAULT,
3120                "blog",
3121                &SiteConfig {
3122                    handlers: Some(HandlersSiteConfig {
3123                        enabled: true,
3124                        allow_imports: vec!["wasi:keyvalue".into()],
3125                        ..Default::default()
3126                    }),
3127                    ..Default::default()
3128                },
3129            )
3130            .await
3131            .unwrap();
3132
3133        // One message in the production namespace, one in a preview namespace.
3134        messaging
3135            .publish("blog/orders/created", b"live")
3136            .await
3137            .unwrap();
3138        messaging
3139            .publish("blog/_preview/abc/orders/created", b"preview")
3140            .await
3141            .unwrap();
3142
3143        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3144        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3145        let inner = rt.inner.clone().unwrap();
3146        let mut cache = std::collections::HashMap::new();
3147        let mut crons = std::collections::HashMap::new();
3148        let mut sweep = std::collections::HashMap::new();
3149        let now = CronNow {
3150            minute: 0,
3151            hour: 0,
3152            dom: 1,
3153            month: 1,
3154            dow: 0,
3155            minute_stamp: 0,
3156        };
3157        for _ in 0..3 {
3158            run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3159                .await
3160                .unwrap();
3161        }
3162
3163        // The production message was delivered + counted.
3164        assert_eq!(
3165            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3166            Some(b"1".to_vec())
3167        );
3168        // The preview-namespaced message was never claimed (no background work
3169        // for previews) — its counter doesn't exist.
3170        assert_eq!(
3171            kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3172                .await
3173                .unwrap(),
3174            None
3175        );
3176    }
3177
3178    // ---- cron driver (#18) -------------------------------------------------
3179
3180    /// A `wasi:http` handler that increments `hits` per request (`kv-counter`),
3181    /// used here as a cron target so a fire is observable as a counter bump.
3182    const KV_COUNTER: &[u8] =
3183        include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3184
3185    /// The function-to-function invoke resolver (FI): a resolvable target runs on
3186    /// the real engine and its response is buffered back + metered; an unknown
3187    /// target is `NotFound`. (The caller-side capability gate — allowlist, depth,
3188    /// deny-by-default — is unit-tested in `boatramp_handlers::bindings::invoke`.)
3189    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3190    async fn function_invoker_runs_target_buffers_and_meters() {
3191        use boatramp_core::deploy::DeployStore;
3192        use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3193        use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3194        use futures::StreamExt;
3195
3196        // The committed `http-200` fixture is the invoke *target* (a wasi:http
3197        // guest that returns 200); it needs no fixture of its own to be a callee.
3198        const HTTP_200: &[u8] =
3199            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3200
3201        let storage = Arc::new(MemStorage::default());
3202        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3203        let deploy = DeployStore::new(storage.clone(), kv.clone());
3204
3205        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3206        let stream: ByteStream =
3207            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3208        deploy.put_blob(&hash, stream).await.unwrap();
3209        let function = Function {
3210            name: "target".into(),
3211            owner: Owner::Project("default".into()),
3212            versions: vec![FunctionVersion {
3213                id: "v1".into(),
3214                component: hash.clone(),
3215                created: 0,
3216                lifecycle: Lifecycle::Independent,
3217            }],
3218            active: "v1".into(),
3219            aliases: Default::default(),
3220            config: Default::default(),
3221        };
3222        deploy
3223            .put_function(ProjectRef::DEFAULT, &function)
3224            .await
3225            .unwrap();
3226
3227        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3228        let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3229        rt.set_invoker(deploy.clone());
3230        let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3231
3232        let request = || InvokeRequest {
3233            method: "GET".into(),
3234            path: "/".into(),
3235            headers: vec![],
3236            body: vec![],
3237        };
3238
3239        // A resolvable target runs on the engine and returns its 200.
3240        let response = invoker.invoke("target", request(), 1).await.unwrap();
3241        assert_eq!(response.status, 200);
3242
3243        // The call was metered against the target function.
3244        let metering = deploy
3245            .get_metering(ProjectRef::DEFAULT, "target")
3246            .await
3247            .unwrap()
3248            .unwrap();
3249        assert_eq!(metering.invocations, 1);
3250
3251        // An unknown target is NotFound (never reaches the engine).
3252        let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3253        assert!(matches!(err, InvokeError::NotFound));
3254    }
3255
3256    /// The supergraph runner backing the `graphql` capability, driven end-to-end through a real
3257    /// runtime: the safelist is the deny-by-default operation floor, and only a safelisted op
3258    /// reaches planning. (The host-side grant + depth cap are unit-tested in
3259    /// `boatramp_handlers::bindings::graphql`; stitching + bearer forwarding + depth dispatch in
3260    /// `graphql_gateway`.)
3261    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3262    async fn federation_runner_enforces_the_safelist_before_planning() {
3263        use boatramp_core::deploy::DeployStore;
3264        use boatramp_core::project::ProjectRef;
3265        use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3266
3267        let storage = Arc::new(MemStorage::default());
3268        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3269        let deploy = DeployStore::new(storage.clone(), kv.clone());
3270        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3271        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3272        rt.set_invoker(deploy.clone());
3273        let runner = rt
3274            .inner
3275            .as_ref()
3276            .unwrap()
3277            .federation_runner
3278            .get()
3279            .unwrap()
3280            .scoped(ProjectRef::new("default"));
3281
3282        let req = |query: &str| GraphqlRequest {
3283            query: Some(query.to_string()),
3284            persisted_hash: None,
3285            variables: "{}".to_string(),
3286            operation_name: None,
3287            authorization: None,
3288        };
3289
3290        // A query that was never registered is refused (deny-by-default) before any planning.
3291        assert!(matches!(
3292            runner.run(req("{ me { id } }"), 1).await,
3293            Err(SupergraphRunError::NotSafelisted)
3294        ));
3295
3296        // Register it in the safelist (any writer of the APQ store) — now it passes the floor and
3297        // reaches planning; against an empty supergraph the plan fails (proving the gate opened).
3298        let query = "{ me { id } }";
3299        let hash = crate::graphql_apq::sha256_hex(query);
3300        kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3301            .await
3302            .unwrap();
3303        assert!(matches!(
3304            runner.run(req(query), 1).await,
3305            Err(SupergraphRunError::PlanFailed(_))
3306        ));
3307
3308        // A run-persisted with an unregistered hash is refused the same way.
3309        let persisted = GraphqlRequest {
3310            query: None,
3311            persisted_hash: Some("deadbeef".to_string()),
3312            variables: "{}".to_string(),
3313            operation_name: None,
3314            authorization: None,
3315        };
3316        assert!(matches!(
3317            runner.run(persisted, 1).await,
3318            Err(SupergraphRunError::NotSafelisted)
3319        ));
3320    }
3321
3322    /// Tenant isolation (Step 7a): the background scheduler fans out over every
3323    /// project, so a **non-default** project's queued async invocation is drained
3324    /// and metered **within that project** — never leaking into `default`. Before
3325    /// the fan-out the tick only ever scanned `default`, so an `acme` function's
3326    /// queue would never drain at all.
3327    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3328    async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
3329        use crate::scheduler::{run_scheduler_tick, CronNow};
3330        use boatramp_core::deploy::DeployStore;
3331        use boatramp_core::function::{
3332            Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3333        };
3334        use boatramp_handlers::{HandlerEngine, Limits};
3335        use futures::StreamExt;
3336
3337        const HTTP_200: &[u8] =
3338            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3339
3340        let storage = Arc::new(MemStorage::default());
3341        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3342        let deploy = DeployStore::new(storage.clone(), kv.clone());
3343
3344        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3345        let stream: ByteStream =
3346            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3347        deploy.put_blob(&hash, stream).await.unwrap();
3348
3349        // A function + a queued async invocation, both under project `acme`.
3350        let acme = ProjectRef::new("acme");
3351        let function = Function {
3352            name: "worker".into(),
3353            owner: Owner::Project("acme".into()),
3354            versions: vec![FunctionVersion {
3355                id: "v1".into(),
3356                component: hash.clone(),
3357                created: 0,
3358                lifecycle: Lifecycle::Independent,
3359            }],
3360            active: "v1".into(),
3361            aliases: Default::default(),
3362            config: Default::default(),
3363        };
3364        deploy.put_function(acme, &function).await.unwrap();
3365        let inv = Invocation {
3366            id: "inv1".into(),
3367            function: "worker".into(),
3368            version: "v1".into(),
3369            mode: InvokeMode::Async,
3370            status: InvocationStatus::Queued,
3371            idempotency_key: None,
3372            attempts: 0,
3373            lease_expires: None,
3374            request_b64: None,
3375            request_content_type: None,
3376            result: None,
3377            created: 0,
3378            updated: 0,
3379        };
3380        deploy.put_invocation(acme, &inv).await.unwrap();
3381
3382        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3383        let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3384        let inner = rt.inner.as_ref().unwrap();
3385
3386        // One tick: `discover_projects()` yields `["acme"]`, so the drain runs
3387        // under `acme`. A fixed `CronNow` (no cron to match) keeps it deterministic.
3388        let mut wasm_cache = std::collections::HashMap::new();
3389        let mut cron_state = std::collections::HashMap::new();
3390        let mut sweep = std::collections::HashMap::new();
3391        let now = CronNow {
3392            minute: 0,
3393            hour: 0,
3394            dom: 1,
3395            month: 1,
3396            dow: 0,
3397            minute_stamp: 0,
3398        };
3399        run_scheduler_tick(
3400            inner,
3401            &deploy,
3402            &mut wasm_cache,
3403            &mut cron_state,
3404            &mut sweep,
3405            now,
3406        )
3407        .await
3408        .unwrap();
3409
3410        // The drain claims + spawns the run off the tick, so poll for the
3411        // terminal transition rather than assuming synchronous settlement.
3412        let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
3413        // The invocation settled Succeeded **in `acme`** …
3414        assert_eq!(settled.status, InvocationStatus::Succeeded);
3415        // … metered in `acme` …
3416        let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
3417        assert_eq!(metering.invocations, 1);
3418        // … and nothing leaked into `default` (no record, no metering there).
3419        assert!(deploy
3420            .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
3421            .await
3422            .unwrap()
3423            .is_none());
3424        assert!(deploy
3425            .get_metering(ProjectRef::DEFAULT, "worker")
3426            .await
3427            .unwrap()
3428            .is_none());
3429    }
3430
3431    /// Poll a durable invocation until it leaves the in-flight states — the drain
3432    /// spawns the run off the tick, so settlement is asynchronous. Panics on
3433    /// timeout so a stuck run fails the test rather than hanging it.
3434    #[cfg(feature = "handlers")]
3435    async fn poll_invocation_settled(
3436        deploy: &boatramp_core::deploy::DeployStore,
3437        project: ProjectRef<'_>,
3438        function: &str,
3439        id: &str,
3440    ) -> boatramp_core::function::Invocation {
3441        use boatramp_core::function::InvocationStatus;
3442        for _ in 0..200 {
3443            if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
3444                if matches!(
3445                    inv.status,
3446                    InvocationStatus::Succeeded | InvocationStatus::Failed
3447                ) {
3448                    return inv;
3449                }
3450            }
3451            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
3452        }
3453        panic!("invocation {function}/{id} never settled");
3454    }
3455
3456    /// A `Running` invocation whose **lease has elapsed** (the node holding it
3457    /// crashed mid-run) is reclaimed by a later drain and runs to completion; one
3458    /// whose lease is still in the future is left untouched (no double-run). This
3459    /// is the crash-recovery guarantee that makes a large async ceiling safe.
3460    #[cfg(feature = "handlers")]
3461    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3462    async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
3463        use crate::scheduler::{run_scheduler_tick, CronNow};
3464        use boatramp_core::deploy::DeployStore;
3465        use boatramp_core::function::{
3466            Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
3467        };
3468        use boatramp_handlers::{HandlerEngine, Limits};
3469        use futures::StreamExt;
3470
3471        const HTTP_200: &[u8] =
3472            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3473
3474        let storage = Arc::new(MemStorage::default());
3475        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3476        let deploy = DeployStore::new(storage.clone(), kv.clone());
3477        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3478        let stream: ByteStream =
3479            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3480        deploy.put_blob(&hash, stream).await.unwrap();
3481
3482        let function = Function {
3483            name: "worker".into(),
3484            owner: Owner::Project("default".into()),
3485            versions: vec![FunctionVersion {
3486                id: "v1".into(),
3487                component: hash.clone(),
3488                created: 0,
3489                lifecycle: Lifecycle::Independent,
3490            }],
3491            active: "v1".into(),
3492            aliases: Default::default(),
3493            config: Default::default(),
3494        };
3495        deploy
3496            .put_function(ProjectRef::DEFAULT, &function)
3497            .await
3498            .unwrap();
3499
3500        // Two `Running` records: one already claimed by a now-dead node (lease in
3501        // the past), one held by a live node (lease far in the future).
3502        let base = Invocation {
3503            id: String::new(),
3504            function: "worker".into(),
3505            version: "v1".into(),
3506            mode: InvokeMode::Async,
3507            status: InvocationStatus::Running,
3508            idempotency_key: None,
3509            attempts: 1,
3510            lease_expires: None,
3511            request_b64: None,
3512            request_content_type: None,
3513            result: None,
3514            created: 0,
3515            updated: 0,
3516        };
3517        let orphan = Invocation {
3518            id: "orphan".into(),
3519            lease_expires: Some(1),
3520            ..base.clone()
3521        };
3522        deploy
3523            .put_invocation(ProjectRef::DEFAULT, &orphan)
3524            .await
3525            .unwrap();
3526        let live = Invocation {
3527            id: "live".into(),
3528            lease_expires: Some(u64::MAX),
3529            ..base.clone()
3530        };
3531        deploy
3532            .put_invocation(ProjectRef::DEFAULT, &live)
3533            .await
3534            .unwrap();
3535
3536        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3537        let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
3538        let inner = rt.inner.as_ref().unwrap();
3539
3540        let now = CronNow {
3541            minute: 0,
3542            hour: 0,
3543            dom: 1,
3544            month: 1,
3545            dow: 0,
3546            minute_stamp: 0,
3547        };
3548        let mut wasm_cache = std::collections::HashMap::new();
3549        let mut cron_state = std::collections::HashMap::new();
3550        let mut sweep = std::collections::HashMap::new();
3551        run_scheduler_tick(
3552            inner,
3553            &deploy,
3554            &mut wasm_cache,
3555            &mut cron_state,
3556            &mut sweep,
3557            now,
3558        )
3559        .await
3560        .unwrap();
3561
3562        // The orphan was reclaimed and ran to completion, its attempt advanced …
3563        let settled =
3564            poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
3565        assert_eq!(settled.status, InvocationStatus::Succeeded);
3566        assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
3567        assert_eq!(
3568            settled.lease_expires, None,
3569            "a settled invocation drops its lease"
3570        );
3571        // … while the live-lease invocation was left exactly as it was.
3572        let live_after = deploy
3573            .get_invocation(ProjectRef::DEFAULT, "worker", "live")
3574            .await
3575            .unwrap()
3576            .unwrap();
3577        assert_eq!(live_after.status, InvocationStatus::Running);
3578        assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
3579        assert_eq!(live_after.lease_expires, Some(u64::MAX));
3580    }
3581
3582    /// BR-TEN-1 (Critical) gate: a same-named **function** in two tenant
3583    /// projects must NOT share one guest kv namespace. Two functions both named
3584    /// `store` — one in `acme`, one in `globex` — each writes to guest kv key
3585    /// `hits` (via the committed `kv-counter` fixture, whose default bucket key
3586    /// is `hits`). We assert the writes land under DISTINCT host kv keys
3587    /// (`hkv/acme/fn/store/hits` vs `hkv/globex/fn/store/hits`) and that neither
3588    /// aliases the bare pre-project key (`hkv/fn/store/hits`). A third `store`
3589    /// under the reserved `default` project is asserted to keep exactly that bare
3590    /// key (back-compat: no data migration for a pre-project store).
3591    ///
3592    /// This is a real end-to-end kv-isolation assertion driven through the live
3593    /// engine (`execute_function`) with the existing `kv-counter` fixture — the
3594    /// preferred form over unit-testing scope construction — because that
3595    /// exercises the actual `build_function_bindings` scope path a guest sees.
3596    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3597    async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
3598        use boatramp_core::deploy::DeployStore;
3599        use boatramp_core::function::{
3600            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3601        };
3602        use boatramp_handlers::{HandlerEngine, Limits};
3603        use futures::StreamExt;
3604
3605        let storage = Arc::new(MemStorage::default());
3606        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3607        let deploy = DeployStore::new(storage.clone(), kv.clone());
3608
3609        // The `kv-counter` fixture increments a "hits" counter in its default kv
3610        // bucket, so a single invocation writes `<scope>/hits`.
3611        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3612        let stream: ByteStream =
3613            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3614        deploy.put_blob(&hash, stream).await.unwrap();
3615
3616        // A single `store` function definition (imports `wasi:keyvalue`); the
3617        // guest binding scope comes from the `project` passed to
3618        // `execute_function`, not from the function's `owner`, so one definition
3619        // suffices to prove per-tenant scoping.
3620        let store = Function {
3621            name: "store".into(),
3622            owner: Owner::Project("default".into()),
3623            versions: vec![FunctionVersion {
3624                id: "v1".into(),
3625                component: hash.clone(),
3626                created: 0,
3627                lifecycle: Lifecycle::Independent,
3628            }],
3629            active: "v1".into(),
3630            aliases: Default::default(),
3631            config: FunctionConfig {
3632                imports: vec!["wasi:keyvalue".into()],
3633                ..Default::default()
3634            },
3635        };
3636        let acme = ProjectRef::new("acme");
3637        let globex = ProjectRef::new("globex");
3638
3639        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3640        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3641        let inner = rt.inner.as_ref().unwrap();
3642
3643        let request = || {
3644            axum::http::Request::builder()
3645                .method("GET")
3646                .uri("/")
3647                .body(axum::body::Body::empty())
3648                .unwrap()
3649        };
3650
3651        // Invoke `store` in each of the two non-default projects, plus once in
3652        // `default`, all named identically.
3653        let component = store.resolve(&store.active).unwrap().to_owned();
3654        for project in [acme, globex, ProjectRef::DEFAULT] {
3655            let (response, _) = execute_function(
3656                inner,
3657                &deploy,
3658                project,
3659                &store,
3660                &component,
3661                request(),
3662                0,
3663                boatramp_handlers::Lane::Sync,
3664            )
3665            .await;
3666            assert!(response.status().is_success(), "invocation should succeed");
3667        }
3668
3669        // The three writes landed under THREE distinct host kv keys: the two
3670        // tenants are project-qualified, and `default` keeps the bare key.
3671        assert_eq!(
3672            kv.get("hkv/acme/fn/store/hits").await.unwrap(),
3673            Some(b"1".to_vec()),
3674            "acme's write must be tenant-qualified"
3675        );
3676        assert_eq!(
3677            kv.get("hkv/globex/fn/store/hits").await.unwrap(),
3678            Some(b"1".to_vec()),
3679            "globex's write must be tenant-qualified"
3680        );
3681        assert_eq!(
3682            kv.get("hkv/fn/store/hits").await.unwrap(),
3683            Some(b"1".to_vec()),
3684            "the default project must keep the byte-identical pre-project key"
3685        );
3686        // Sanity: had the fix regressed, all three would have collided on the
3687        // bare key and it would read "3", not "1".
3688    }
3689
3690    /// The cron driver: a due cron fires its route (loopback), once per
3691    /// matching minute (dedup), and with `overlap: Skip` a fire is skipped while
3692    /// a previous one is still running.
3693    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3694    async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
3695        use boatramp_core::config::{
3696            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3697        };
3698        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3699        use boatramp_handlers::{HandlerEngine, Limits};
3700        use futures::StreamExt;
3701        use std::sync::atomic::Ordering;
3702
3703        let storage = Arc::new(MemStorage::default());
3704        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3705        let deploy = DeployStore::new(storage.clone(), kv.clone());
3706
3707        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3708        let stream: ByteStream =
3709            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3710        deploy.put_blob(&hash, stream).await.unwrap();
3711        let mut files = std::collections::BTreeMap::new();
3712        files.insert(
3713            "counter.wasm".to_string(),
3714            FileEntry {
3715                hash: hash.clone(),
3716                size: KV_COUNTER.len() as u64,
3717                content_type: None,
3718                variants: std::collections::BTreeMap::new(),
3719            },
3720        );
3721        let manifest = Manifest {
3722            files,
3723            config: DeployConfig {
3724                handlers: vec![HandlerConfig {
3725                    route: "/".into(),
3726                    methods: Vec::new(),
3727                    component: "counter.wasm".into(),
3728                    imports: vec!["wasi:keyvalue".into()],
3729                    streaming: false,
3730                    limits: None,
3731                    env: std::collections::BTreeMap::new(),
3732                    invoke_targets: Vec::new(),
3733                }],
3734                crons: vec![CronConfig {
3735                    schedule: "* * * * *".into(),
3736                    route: "/".into(),
3737                    overlap: Overlap::Skip,
3738                }],
3739                ..Default::default()
3740            },
3741            ..Default::default()
3742        };
3743        let id = deploy.put_manifest(&manifest).await.unwrap();
3744        deploy
3745            .activate(ProjectRef::DEFAULT, "blog", &id)
3746            .await
3747            .unwrap();
3748        deploy
3749            .set_site_config(
3750                ProjectRef::DEFAULT,
3751                "blog",
3752                &SiteConfig {
3753                    handlers: Some(HandlersSiteConfig {
3754                        enabled: true,
3755                        allow_imports: vec!["wasi:keyvalue".into()],
3756                        ..Default::default()
3757                    }),
3758                    ..Default::default()
3759                },
3760            )
3761            .await
3762            .unwrap();
3763
3764        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3765        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3766        let inner = rt.inner.clone().unwrap();
3767        let mut wasm = std::collections::HashMap::new();
3768        let mut crons = std::collections::HashMap::new();
3769        let mut sweep = std::collections::HashMap::new();
3770        let at = |stamp| CronNow {
3771            minute: 0,
3772            hour: 0,
3773            dom: 1,
3774            month: 1,
3775            dow: 0,
3776            minute_stamp: stamp,
3777        };
3778
3779        // Fires once for the minute.
3780        let (_, handles) =
3781            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3782                .await
3783                .unwrap();
3784        for h in handles {
3785            h.await.unwrap();
3786        }
3787        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3788
3789        // Same minute → deduped (no fire).
3790        let (_, handles) =
3791            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
3792                .await
3793                .unwrap();
3794        assert!(handles.is_empty());
3795        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
3796
3797        // Next minute → fires again.
3798        let (_, handles) =
3799            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
3800                .await
3801                .unwrap();
3802        for h in handles {
3803            h.await.unwrap();
3804        }
3805        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3806
3807        // overlap=Skip: a previous fire still running → the next minute is skipped.
3808        // The cron dedup key is project-qualified (`default|blog|cron|0`) so a
3809        // same-named site in another project can't dedup this one.
3810        crons
3811            .get("default|blog|cron|0")
3812            .unwrap()
3813            .running
3814            .store(true, Ordering::Release);
3815        let (_, handles) =
3816            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
3817                .await
3818                .unwrap();
3819        assert!(handles.is_empty());
3820        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
3821    }
3822
3823    /// Cluster cron single-firing: with a leader gate that
3824    /// returns `false` (this node is not the leader), the scheduler fires **no**
3825    /// crons — so a cron fires on exactly one node cluster-wide.
3826    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3827    async fn cron_leader_gate_suppresses_crons_off_leader() {
3828        use boatramp_core::config::{
3829            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
3830        };
3831        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3832        use boatramp_handlers::{HandlerEngine, Limits};
3833        use futures::StreamExt;
3834
3835        let storage = Arc::new(MemStorage::default());
3836        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3837        let deploy = DeployStore::new(storage.clone(), kv.clone());
3838
3839        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
3840        let stream: ByteStream =
3841            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
3842        deploy.put_blob(&hash, stream).await.unwrap();
3843        let mut files = std::collections::BTreeMap::new();
3844        files.insert(
3845            "counter.wasm".to_string(),
3846            FileEntry {
3847                hash: hash.clone(),
3848                size: KV_COUNTER.len() as u64,
3849                content_type: None,
3850                variants: std::collections::BTreeMap::new(),
3851            },
3852        );
3853        let manifest = Manifest {
3854            files,
3855            config: DeployConfig {
3856                handlers: vec![HandlerConfig {
3857                    route: "/".into(),
3858                    methods: Vec::new(),
3859                    component: "counter.wasm".into(),
3860                    imports: vec!["wasi:keyvalue".into()],
3861                    streaming: false,
3862                    limits: None,
3863                    env: std::collections::BTreeMap::new(),
3864                    invoke_targets: Vec::new(),
3865                }],
3866                crons: vec![CronConfig {
3867                    schedule: "* * * * *".into(),
3868                    route: "/".into(),
3869                    overlap: Overlap::Skip,
3870                }],
3871                ..Default::default()
3872            },
3873            ..Default::default()
3874        };
3875        let id = deploy.put_manifest(&manifest).await.unwrap();
3876        deploy
3877            .activate(ProjectRef::DEFAULT, "blog", &id)
3878            .await
3879            .unwrap();
3880        deploy
3881            .set_site_config(
3882                ProjectRef::DEFAULT,
3883                "blog",
3884                &SiteConfig {
3885                    handlers: Some(HandlersSiteConfig {
3886                        enabled: true,
3887                        allow_imports: vec!["wasi:keyvalue".into()],
3888                        ..Default::default()
3889                    }),
3890                    ..Default::default()
3891                },
3892            )
3893            .await
3894            .unwrap();
3895
3896        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3897        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3898        // This node is "not the leader" — gate returns false.
3899        rt.set_cron_leader_gate(Arc::new(|| false));
3900        let inner = rt.inner.clone().unwrap();
3901        let mut wasm = std::collections::HashMap::new();
3902        let mut crons = std::collections::HashMap::new();
3903        let mut sweep = std::collections::HashMap::new();
3904        let now = CronNow {
3905            minute: 0,
3906            hour: 0,
3907            dom: 1,
3908            month: 1,
3909            dow: 0,
3910            minute_stamp: 100,
3911        };
3912
3913        let (_, handles) =
3914            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
3915                .await
3916                .unwrap();
3917        // No cron fired (a follower); the counter was never written.
3918        assert!(handles.is_empty(), "a non-leader must not fire crons");
3919        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
3920    }
3921
3922    /// Named SQL binding dispatch through the real `build_bindings` + a real (libsql) provider:
3923    /// the granted databases in the resulting `Bindings` are exactly what the per-handler grant
3924    /// grammar allows, with the site as the ceiling. This is the config→dispatch→backends half of
3925    /// the tenant-isolation story (the guest-open half is the binding layer's
3926    /// `two_named_databases_are_independent`; a full guest `open("named")` e2e needs a wasm
3927    /// fixture and is a live-validation follow-up).
3928    #[tokio::test]
3929    async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
3930        use boatramp_core::config::HandlersSiteConfig;
3931        use boatramp_core::project::ProjectRef;
3932        use boatramp_handlers::{HandlerEngine, Limits};
3933
3934        let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
3935        let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
3936        // A real per-site libsql provider (opens a distinct database per name).
3937        let sql_dir =
3938            std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
3939        let _ = std::fs::remove_dir_all(&sql_dir);
3940        let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
3941            Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
3942
3943        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3944        let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
3945        let inner = rt.inner.as_ref().unwrap();
3946
3947        // The site exposes the default + two named databases — the ceiling.
3948        let site = HandlersSiteConfig {
3949            enabled: true,
3950            allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
3951            ..Default::default()
3952        };
3953        let env = std::collections::BTreeMap::new();
3954        let build = |imports: &[&str]| {
3955            let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
3956            let site = &site;
3957            let env = &env;
3958            async move {
3959                crate::handler_dispatch::build_bindings(
3960                    inner,
3961                    ProjectRef::new("default"),
3962                    "shop",
3963                    "shop",
3964                    None,
3965                    &imports,
3966                    site,
3967                    env,
3968                    &[],
3969                    0,
3970                    None,
3971                )
3972                .await
3973                .expect("no secrets → resolves")
3974                .sql_database_names()
3975            }
3976        };
3977
3978        // Least-privilege: a handler asking only for the default + product gets exactly those —
3979        // never `privileged`, even though the site exposes it.
3980        assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
3981        // A wildcard handler gets every name the site exposes (default via bare `sql` + all named).
3982        assert_eq!(
3983            build(&["sql", "sql:*"]).await,
3984            vec!["", "privileged", "product"]
3985        );
3986        // Fail-closed: requesting a name the site does not expose grants nothing.
3987        assert!(build(&["sql:secret"]).await.is_empty());
3988        // No bare `sql` → the default `""` database is not granted either.
3989        assert_eq!(build(&["sql:product"]).await, vec!["product"]);
3990
3991        let _ = std::fs::remove_dir_all(&sql_dir);
3992    }
3993}