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, create_deployment, current_deployment, delete_compute,
44    delete_site, get_compute, get_daemon_config, get_deployment, get_site_config, invalidate_cache,
45    list_aliases, list_compute, list_deployments, list_sites, prune_delete, prune_report, put_blob,
46    put_compute, put_daemon_config, put_site_config, remove_alias, rollback_daemon_config,
47    scrub_blobs, set_alias,
48};
49#[cfg(feature = "handlers")]
50pub(crate) use admin_api::{
51    delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
52    list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
53    put_graphql_subgraph, register_graphql_safelist,
54};
55mod auth;
56#[cfg(feature = "console")]
57pub mod console;
58mod content;
59mod control_api;
60#[cfg(feature = "compression")]
61pub(crate) use content::maybe_compress;
62pub(crate) use content::multipart_byteranges;
63pub(crate) use content::{
64    negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
65};
66pub(crate) use control_api::{
67    add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
68    cluster_revoke, cluster_rotate_key, create_join_token, create_token, get_authz_policy,
69    list_root_anchors, list_tokens, put_authz_policy, remove_root_anchor, revoke_token,
70};
71#[cfg(all(test, feature = "handlers"))]
72use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
73mod domain_verify;
74pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
75pub mod envelope;
76#[cfg(feature = "handlers")]
77mod graphql_apq;
78#[cfg(feature = "handlers")]
79mod graphql_cache;
80#[cfg(feature = "handlers")]
81mod graphql_data;
82#[cfg(feature = "handlers")]
83mod graphql_federation;
84#[cfg(feature = "handlers")]
85mod graphql_gateway;
86#[cfg(feature = "handlers")]
87mod graphql_graphiql;
88#[cfg(feature = "handlers")]
89mod graphql_guard;
90#[cfg(feature = "handlers")]
91mod graphql_plan;
92#[cfg(feature = "handlers")]
93mod graphql_registry;
94#[cfg(feature = "handlers")]
95mod graphql_subscription;
96#[cfg(feature = "handlers")]
97mod handler_cache;
98#[cfg(feature = "handlers")]
99mod handler_dispatch;
100#[cfg(feature = "handlers")]
101pub(crate) use handler_dispatch::{
102    build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
103    read_blob_fully,
104};
105#[cfg(all(feature = "handlers", test))]
106use handler_dispatch::{resolve_env, set_forwarded_headers};
107mod function_api;
108pub(crate) use function_api::{
109    alias_function, deploy_function, list_functions, remove_function, rollback_function,
110};
111#[cfg(all(test, feature = "handlers"))]
112use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
113mod gateway;
114mod host;
115pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
116#[cfg(feature = "http3")]
117mod http3;
118mod limits;
119#[cfg(feature = "handlers")]
120mod logs;
121#[cfg(feature = "handlers")]
122mod metrics;
123#[cfg(feature = "oidc")]
124mod oidc;
125mod operator;
126pub(crate) use operator::prometheus_metrics;
127#[cfg(feature = "handlers")]
128pub(crate) use operator::{
129    operator_dlq, operator_handler_stats, operator_logs, operator_logs_stream,
130};
131mod proxy;
132pub use proxy::spawn_compute_reconcile;
133pub(crate) use proxy::{
134    await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
135    proxy, COMPUTE_WAKE_TIMEOUT,
136};
137// Only the `handlers`-gated websocket-upgrade path in the serve pipeline uses it.
138#[cfg(feature = "handlers")]
139pub(crate) use proxy::is_upgrade_request;
140#[cfg(all(test, feature = "handlers"))]
141use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
142mod project_api;
143pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
144mod project_scope;
145pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
146mod ratelimit;
147mod routes;
148pub use routes::{router, router_with};
149#[cfg(feature = "mcp")]
150mod mcp_http;
151#[cfg(feature = "handlers")]
152mod scheduler;
153mod serve_pipeline;
154pub use serve_pipeline::http_redirect_router;
155#[cfg(all(test, feature = "handlers"))]
156use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
157pub(crate) use serve_pipeline::{
158    serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
159    BootstrapAttestation,
160};
161/// External token signer backends: KMS / HSM / Vault-hosted
162/// control-plane root keys behind the [`boatramp_core::cose::Signer`] seam.
163pub mod signer;
164mod srvmetrics;
165#[cfg(all(feature = "handlers", test))]
166use scheduler::run_scheduler_tick;
167#[cfg(feature = "handlers")]
168pub(crate) use scheduler::{
169    acquire_site_permit, effective_limits, handler_error_response, handler_unavailable, CronNow,
170};
171#[cfg(feature = "handlers")]
172use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
173#[cfg(feature = "handlers")]
174mod function_runtime;
175#[cfg(feature = "handlers")]
176pub(crate) use function_runtime::{
177    b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
178    dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
179    get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
180    put_trigger_handler, webhook_ingress,
181};
182#[cfg(feature = "handlers")]
183mod stream;
184#[cfg(feature = "handlers")]
185mod workflow;
186pub use auth::{require_auth, Auth};
187#[cfg(feature = "http3")]
188pub use http3::{
189    advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
190    Http3Error,
191};
192pub use limits::{ServerLimits, UploadGuard};
193#[cfg(feature = "oidc")]
194pub use oidc::{OidcConfig, OidcError, OidcVerifier};
195use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
196#[cfg(feature = "handlers")]
197pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
198#[cfg(feature = "handlers")]
199pub(crate) use workflow::{
200    define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
201    get_workflow_run_handler, list_workflows_handler, start_workflow_run,
202};
203// The process-wide HTTP/lifecycle metrics registry. Re-exported so the CLI's
204// certificate-renewal path can record renewals against the same counters.
205pub use srvmetrics::{server_metrics, ServerMetrics};
206
207/// The WebAssembly handler runtime: the shared engine plus the per-site binding
208/// backends. Cheap to clone (it is an `Arc` inside). Without the `handlers`
209/// feature it is an empty placeholder, so the serving signatures stay uniform —
210/// pass [`HandlerRuntime::disabled`].
211#[derive(Clone, Default)]
212pub struct HandlerRuntime {
213    #[cfg(feature = "handlers")]
214    inner: Option<Arc<HandlerRuntimeInner>>,
215}
216
217#[cfg(feature = "handlers")]
218struct HandlerRuntimeInner {
219    engine: boatramp_handlers::HandlerEngine,
220    kv: Arc<dyn boatramp_core::kv::KvStore>,
221    storage: Arc<dyn boatramp_core::Storage>,
222    /// Per-site SQL database provider (libsql — single-node files by default;
223    /// absent = the `sql` capability is not offered, so handlers requesting it
224    /// are refused at activation).
225    sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
226    /// Internal messaging substrate for the `wasi:messaging` binding (publish;
227    /// consumer dispatch is driven separately). Absent = messaging not offered.
228    messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
229    /// Per-site concurrency semaphores (for sites that set `maxConcurrency`),
230    /// created on first use.
231    site_semaphores:
232        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
233    /// Per-scope SSE connection semaphores (per-site cap),
234    /// created on first use and keyed by binding scope so a preview's streams
235    /// can't exhaust the live site's budget.
236    stream_semaphores:
237        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
238    /// Live SSE connection counts per `(scope, client-ip)`, for the per-IP cap.
239    /// `Arc` so a connection's RAII guard can decrement it on drop.
240    stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
241    /// Per-invocation observability counters, read by the
242    /// operator endpoint + Prometheus exporter.
243    metrics: metrics::Metrics,
244    /// Captured guest stdout/stderr: per-site ring + rate cap.
245    logs: Arc<logs::LogStore>,
246    /// Per-project memoized composed supergraph + query plans (the federation hot path),
247    /// keyed on the registry composition version. Shared by the edge and in-process
248    /// `graphql::run` paths; a registry mutation bumps the version and invalidates it.
249    #[cfg(feature = "handlers")]
250    graphql_cache: graphql_cache::GraphqlCache,
251    /// Optional **cron leader gate**: in cluster mode the
252    /// scheduler fires crons only when this returns `true` (the node is the Raft
253    /// leader), so a cron fires exactly once cluster-wide. `None` (single-node)
254    /// always fires. Consumers are *not* gated — leased dispatch distributes
255    /// them across nodes.
256    cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
257    /// Max bytes a `wasi:blobstore` host read/range/copy may buffer (`0` =
258    /// unlimited), from the security posture. Set once at serve
259    /// startup via [`HandlerRuntime::set_max_blob_bytes`]; unset reads as `0`.
260    max_blob_bytes: std::sync::OnceLock<u64>,
261    /// Max size of a Wasm component blob accepted at activation (`0` = unlimited),
262    /// from the security posture. Checked against the manifest's file
263    /// size *before* the blob is read. Set via
264    /// [`HandlerRuntime::set_max_component_bytes`]; unset reads as `0`.
265    max_component_bytes: std::sync::OnceLock<u64>,
266    /// Per-function locks serializing the metering + rate-limit read-modify-write
267    /// (FA-4), so concurrent invocations of one function can't lose an update.
268    /// Created on first use, keyed by function name.
269    function_meter_locks:
270        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
271    /// Per-function concurrency semaphores (for functions that set a
272    /// `max_concurrent` quota), created on first use.
273    function_semaphores:
274        std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
275    /// Optional cloud **blob-change notification provisioner** (FA-5b2): when set,
276    /// adding a `Blob` trigger provisions the native pipeline (S3→SQS, …) per the
277    /// [`provision_tier`](Self::provision_tier), and removing it retracts. Absent
278    /// on a self-watching backend (fs), which needs no provisioning.
279    watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
280    /// The operator tier governing the [`watch_provider`](Self::watch_provider):
281    /// dry-run (recipe) / provision / verify-only / refuse. Unset reads as the
282    /// fail-closed default (`Refuse`).
283    provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
284    /// The function-to-function invoke resolver (FI): backs the `invoke`
285    /// capability. Set once at startup with the deploy store (a self-referential
286    /// `Weak` back to this runtime), so a granted function can call a sibling
287    /// in-process. Unset ⇒ the `invoke` capability is not offered. Held as the
288    /// concrete type so a binding can derive a **project-scoped** invoker
289    /// ([`FunctionInvoker::scoped`]) resolving the caller's siblings within its
290    /// own tenant project, not `default`.
291    invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
292    /// The supergraph runner backing the `graphql` capability: runs a guest's GraphQL
293    /// operation against the project's composed supergraph in-process (plan + execute over
294    /// the invoke path). Set once at startup alongside [`invoker`](Self::invoker); unset ⇒ the
295    /// `graphql` capability is not offered. Project-scoped per grant, like the invoker.
296    federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
297}
298
299/// Predicate gating cron firing to the cluster leader (see
300/// [`HandlerRuntime::set_cron_leader_gate`]).
301pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
302
303impl HandlerRuntime {
304    /// An empty runtime — handler dispatch disabled (the static path is unchanged).
305    pub fn disabled() -> Self {
306        Self::default()
307    }
308
309    /// Build a runtime over `engine`. The `wasi:keyvalue` / `wasi:blobstore`
310    /// bindings are served from the server's own `kv` / `storage` backends (each
311    /// namespaced per site); `sql`, if a provider is given, serves a per-site
312    /// database (the default `""` database). `sql: None` means the `sql`
313    /// capability is not offered.
314    #[cfg(feature = "handlers")]
315    pub fn new(
316        engine: boatramp_handlers::HandlerEngine,
317        kv: Arc<dyn boatramp_core::kv::KvStore>,
318        storage: Arc<dyn boatramp_core::Storage>,
319        sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
320        messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
321    ) -> Self {
322        Self {
323            inner: Some(Arc::new(HandlerRuntimeInner {
324                engine,
325                kv,
326                storage,
327                sql,
328                messaging,
329                site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
330                stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
331                stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
332                metrics: metrics::Metrics::default(),
333                logs: Arc::new(logs::LogStore::default()),
334                #[cfg(feature = "handlers")]
335                graphql_cache: graphql_cache::GraphqlCache::default(),
336                cron_leader_gate: std::sync::OnceLock::new(),
337                max_blob_bytes: std::sync::OnceLock::new(),
338                max_component_bytes: std::sync::OnceLock::new(),
339                function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
340                function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
341                watch_provider: std::sync::OnceLock::new(),
342                provision_tier: std::sync::OnceLock::new(),
343                invoker: std::sync::OnceLock::new(),
344                federation_runner: std::sync::OnceLock::new(),
345            })),
346        }
347    }
348
349    /// The per-site SQL database provider, if one is configured. Lets the control plane
350    /// introspect a site's database (e.g. to generate a SQL federation subgraph's SDL).
351    #[cfg(feature = "handlers")]
352    pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
353        self.inner.as_ref().and_then(|inner| inner.sql.clone())
354    }
355
356    /// The function invoker, if wired (set at serve startup). Lets the control plane run a
357    /// deployed function in-process — e.g. to introspect a function subgraph's SDL via its
358    /// federation `_service { sdl }` field when registering it.
359    #[cfg(feature = "handlers")]
360    pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
361        self.inner
362            .as_ref()
363            .and_then(|inner| inner.invoker.get().cloned())
364    }
365
366    /// Introspect a **specific component's** federation SDL by running `{ _service { sdl } }`
367    /// against it — targeting a pending (not-yet-active) version so a subgraph redeploy can be
368    /// composed-checked before it goes live. `Unavailable` if this node has no wasm engine.
369    #[cfg(feature = "handlers")]
370    pub(crate) async fn introspect_subgraph_sdl(
371        &self,
372        deploy: &DeployStore,
373        project: boatramp_core::project::ProjectRef<'_>,
374        function: &boatramp_core::function::Function,
375        component: &str,
376    ) -> Result<String, function_runtime::SubgraphSdlError> {
377        match self.inner.as_ref() {
378            Some(inner) => {
379                function_runtime::introspect_service_sdl(
380                    inner, deploy, project, function, component,
381                )
382                .await
383            }
384            None => Err(function_runtime::SubgraphSdlError::Unavailable),
385        }
386    }
387
388    /// Wire the function-to-function invoke resolver (FI). Set once at startup,
389    /// after the deploy store exists: it holds a `Weak` back to this runtime plus
390    /// the deploy store, so a function granted `invoke` can resolve + run a
391    /// sibling in-process. A no-op runtime (no `inner`) leaves it unset, and the
392    /// `invoke` capability is then simply never granted.
393    #[cfg(feature = "handlers")]
394    pub fn set_invoker(&self, deploy: DeployStore) {
395        if let Some(inner) = self.inner.as_ref() {
396            let invoker = Arc::new(function_runtime::FunctionInvoker::new(
397                deploy,
398                Arc::downgrade(inner),
399            ));
400            let _ = inner.invoker.set(invoker);
401            // The supergraph runner shares the same self-referential `Weak`; it reaches the
402            // invoker (set above) to dispatch a guest run's sub-fetches in-process.
403            let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
404                inner,
405            )));
406            let _ = inner.federation_runner.set(runner);
407        }
408    }
409
410    /// The per-site SQL provider, if the `sql` capability is offered. Backs the
411    /// compute sql-shim (PLAN-compute-bindings) so an opaque workload reaches the
412    /// same tenant-scoped database a handler does.
413    #[cfg(feature = "handlers")]
414    pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
415        self.inner.as_ref().and_then(|inner| inner.sql.clone())
416    }
417
418    /// Wire the cloud blob-change notification provisioner (FA-5b2). Set once at
419    /// startup when the storage backend is a cloud object store; a no-op runtime,
420    /// or a self-watching backend (fs), leaves it unset.
421    #[cfg(feature = "handlers")]
422    pub fn set_watch_provider(
423        &self,
424        provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
425    ) {
426        if let Some(inner) = self.inner.as_ref() {
427            let _ = inner.watch_provider.set(provider);
428        }
429    }
430
431    /// Set the operator provisioning tier for the
432    /// [`watch_provider`](Self::set_watch_provider). Set once at startup; unset is
433    /// the fail-closed `Refuse`.
434    #[cfg(feature = "handlers")]
435    pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
436        if let Some(inner) = self.inner.as_ref() {
437            let _ = inner.provision_tier.set(tier);
438        }
439    }
440
441    /// Cap the bytes a `wasi:blobstore` host read/range/copy may buffer (`0` =
442    /// unlimited), from the security posture. Set once at startup; a
443    /// no-op runtime ignores it.
444    #[cfg(feature = "handlers")]
445    pub fn set_max_blob_bytes(&self, max_bytes: u64) {
446        if let Some(inner) = self.inner.as_ref() {
447            let _ = inner.max_blob_bytes.set(max_bytes);
448        }
449    }
450
451    /// Cap the size of a Wasm component blob accepted at activation (`0` =
452    /// unlimited), from the security posture. Set once at startup.
453    #[cfg(feature = "handlers")]
454    pub fn set_max_component_bytes(&self, max_bytes: u64) {
455        if let Some(inner) = self.inner.as_ref() {
456            let _ = inner.max_component_bytes.set(max_bytes);
457        }
458    }
459
460    /// Gate cron firing on a predicate (cluster mode: the node is the Raft
461    /// leader), so a cron fires exactly once cluster-wide.
462    /// Set once at startup; a no-op runtime ignores it. Consumers are never
463    /// gated (leased dispatch already distributes them).
464    #[cfg(feature = "handlers")]
465    pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
466        if let Some(inner) = self.inner.as_ref() {
467            let _ = inner.cron_leader_gate.set(gate);
468        }
469    }
470
471    /// Pre-activation gate: refuse to flip a deployment
472    /// whose handlers can't be satisfied — the site must enable handlers and
473    /// allow each requested import (the resolution rule), and every component
474    /// must compile (so a broken component never goes live; this also pre-warms
475    /// the cache). `Err(reason)` means "do not activate". A no-op for deploys
476    /// with no handlers, or without the `handlers` feature/runtime.
477    #[cfg(feature = "handlers")]
478    async fn precheck_activation(
479        &self,
480        deploy: &DeployStore,
481        manifest: &Manifest,
482        site_config: Option<&SiteConfig>,
483    ) -> Result<(), String> {
484        let Some(inner) = self.inner.as_ref() else {
485            return Ok(());
486        };
487        // Consumer-only deploys must be prechecked too: skip only
488        // when neither handlers nor consumers ship.
489        if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
490            return Ok(());
491        }
492        // A deploy that ships handlers or consumers requires the site to enable them.
493        let site_handlers = site_config
494            .and_then(|c| c.handlers.as_ref())
495            .filter(|h| h.enabled)
496            .ok_or_else(|| {
497                "deployment ships handlers/consumers but the site has them disabled".to_string()
498            })?;
499        let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
500
501        // Same import/size/compile gate for every handler and consumer component.
502        for handler in &manifest.config.handlers {
503            precheck_component(
504                deploy,
505                manifest,
506                site_handlers,
507                inner,
508                max_component,
509                &handler.imports,
510                &handler.component,
511                &format!("handler {:?}", handler.route),
512            )
513            .await?;
514        }
515        for consumer in &manifest.config.consumers {
516            precheck_component(
517                deploy,
518                manifest,
519                site_handlers,
520                inner,
521                max_component,
522                &consumer.imports,
523                &consumer.component,
524                &format!("consumer {:?}", consumer.topic),
525            )
526            .await?;
527        }
528        Ok(())
529    }
530
531    #[cfg(not(feature = "handlers"))]
532    async fn precheck_activation(
533        &self,
534        _deploy: &DeployStore,
535        _manifest: &Manifest,
536        _site_config: Option<&SiteConfig>,
537    ) -> Result<(), String> {
538        Ok(())
539    }
540}
541
542/// Server runtime knobs that aren't part of the core (deploy, auth, handlers)
543/// triple: operational request [`limits`](ServerLimits) and an optional custom
544/// domain-ownership [`DomainProbe`] (defaults to the live network probe).
545///
546/// [`DomainProbe`]: boatramp_core::domain_verify::DomainProbe
547#[derive(Default, Clone)]
548pub struct ServerOptions {
549    /// Operational upload limits (size / idle / concurrency).
550    pub limits: ServerLimits,
551    /// Domain-ownership probe override (tests inject a scripted one); `None`
552    /// uses the live HTTP/DNS probe.
553    pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
554    /// Site to serve for a `Host` that matches no domain, instead of `404`.
555    /// `None` keeps the 404 default.
556    pub default_site: Option<String>,
557    /// Resolve an unmatched `Host` to a site **without** an explicit domain
558    /// registration — by first host label (`<site>.host`), or, when exactly one
559    /// site is served, as the sole site. The effective gate (posture knob OR a
560    /// loopback bind), computed by `serve`. `false` (the default) keeps the
561    /// strict behavior: an unmatched host resolves only to `default_site` or 404.
562    pub implicit_routing: bool,
563    /// Require a valid control-plane token to view a deployment **preview**
564    /// (`/_deploy/<id>/…` and `<id>.deploy.<host>`) — the
565    /// `previews.protect` setting. Off by default (previews are unguessable capability
566    /// URLs).
567    pub protect_previews: bool,
568    /// When set, rate limiting uses a **cluster-wide** KV-backed fixed-window
569    /// counter over this store instead of the per-node in-process buckets.
570    /// Pass the shared/replicated KV (e.g. the cluster `RaftKv`).
571    pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
572    /// The token signer (root private key / KMS / HSM), when this node issues
573    /// tokens (the `/api/tokens` create route and the OIDC→token exchange).
574    /// `None` ⇒ verify-only.
575    pub issuer: Option<Arc<dyn Signer>>,
576    /// An operator-set, single-use **bootstrap secret** enabling the
577    /// `POST /api/tokens/bootstrap` first-token route. `None` ⇒ that route returns
578    /// `501`. Compared by SHA-256, single-use (rotating the secret re-enables it);
579    /// unset once bootstrapped.
580    pub bootstrap_secret: Option<String>,
581    /// A **bootstrap-TLS identity attestation** (base64url `COSE_Sign1`) served at
582    /// `GET /.well-known/boatramp-bootstrap-identity` — the root key vouching for
583    /// this node's `--tls rpk` control-plane TLS public key, so a client pinning
584    /// only the root key can learn + pin the TLS identity. Set by `serve` under
585    /// `--tls rpk` when an issuer is present; `None` ⇒ the route returns `404`.
586    pub bootstrap_attestation: Option<String>,
587    /// The cluster mesh control hook, wired in cluster mode over
588    /// `ClusterNode`. Backs `POST /api/cluster/join` + `/rotate-key`; `None`
589    /// (single-node) ⇒ those routes return `501`.
590    pub mesh_control: Option<Arc<dyn MeshControl>>,
591    /// Origins allowed to call the control-plane `/api/*` routes cross-origin
592    /// (CORS). Empty (the default) ⇒ no `Access-Control-*` headers at all, i.e.
593    /// same-origin only — which is exactly the dogfood console, served from the
594    /// same origin as the API. Set this to host the console (or any browser
595    /// client) on a *different* origin: each entry is an exact
596    /// `scheme://host[:port]` (e.g. `https://console.example.com`), or `*` to
597    /// allow any origin. The API authenticates with a Bearer token (not cookies),
598    /// so credentials are not enabled; the matched origin is echoed back with
599    /// `Vary: Origin`, and a preflight `OPTIONS` is answered before auth runs.
600    pub cors_allowed_origins: Vec<String>,
601    /// The OIDC verifier for `/api/auth/exchange` (validates the IdP JWT before
602    /// minting a token). Only with the `oidc` feature + an issuer key.
603    #[cfg(feature = "oidc")]
604    pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
605    /// The resolved operator security posture (the hardening knobs).
606    /// Carried as an extension so the gateway, proxy, domain-verify, and upload
607    /// paths can consult it. Defaults to the strict `multi-tenant` preset.
608    pub posture: boatramp_core::security::SecurityPosture,
609    /// Whether this server's listener terminates TLS (the connection scheme is
610    /// `https`). Set by `serve` from the TLS mode; used to derive the request
611    /// scheme when `X-Forwarded-Proto` can't be trusted. Default
612    /// `false` (plain HTTP).
613    pub served_over_tls: bool,
614    /// The fleet's **canonical public origin** (e.g. `https://cp.example.com`) that
615    /// a per-request PoP proof must be bound to (`aud`). Set from `[serve]
616    /// pop_origin` in `boatramp.cfg`. Compared against a proof's bound origin —
617    /// **never** derived from a `Host`/`X-Forwarded-*` header. A holder-bound
618    /// (`cnf`) token cannot be used against a server that has not configured this
619    /// (its proof can't be verified, so the request is rejected).
620    pub pop_origin: Option<String>,
621    /// A pre-built dynamic daemon-config runtime. `serve` supplies one (built via
622    /// [`config_baseline`] + [`DaemonRuntime::new`]) so it can wake it on
623    /// SIGHUP / changelog; `None` (tests, embedders) ⇒ the router builds its own.
624    pub daemon_runtime: Option<Arc<DaemonRuntime>>,
625    /// The embedded web-console mount (`[serve.console]`), when the operator
626    /// enabled it and the binary was built with the `console` feature. `None` ⇒
627    /// not served. The static SPA is served unauthenticated at this host+path.
628    #[cfg(feature = "console")]
629    pub console: Option<console::ConsoleMount>,
630}
631
632/// The listener's own connection scheme (`true` = `https`), carried as an
633/// extension so the serving path can derive the scheme without trusting a
634/// forged `X-Forwarded-Proto` from a direct client.
635#[derive(Clone, Copy)]
636struct ServedOverTls(bool);
637
638/// Whether the host fallback may resolve an unmatched `Host` to a site without an
639/// explicit domain registration (first-label `<site>.host`, or the sole served
640/// site). Carried as an extension; the effective gate is resolved by `serve`
641/// (posture knob OR loopback bind). `false` = strict (default_site or 404 only).
642#[derive(Clone, Copy, Default)]
643struct ImplicitRouting(bool);
644
645/// Holds the live, resolved [`EffectiveConfig`] (`file baseline ⊕ dynamic
646/// overrides`) plus the active generation hash. Request handlers read the current
647/// operational values through [`effective`](Self::effective); the daemon-config
648/// API and the SIGHUP handler [`reload`](Self::reload) it from the store, so a
649/// change converges without a restart.
650/// Defensive backstop interval for re-resolving the dynamic daemon config.
651/// Convergence is **fully notification-driven** — a local write applies
652/// immediately; a SIGHUP, a shared-store changelog invalidation of `daemon/*`, or
653/// a Raft apply of a replicated `daemon/*` write each wakes an immediate reload via
654/// [`DaemonRuntime::notify_reload`]. This long tick is only a safety net against a
655/// missed wake; it is not the convergence mechanism.
656const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
657
658pub struct DaemonRuntime {
659    baseline: boatramp_core::daemon_config::ConfigBaseline,
660    state: std::sync::RwLock<DaemonState>,
661    /// Woken (by SIGHUP / changelog / a local write) to trigger an immediate
662    /// reload instead of waiting for the backstop tick.
663    reload: tokio::sync::Notify,
664}
665
666struct DaemonState {
667    effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
668    generation: Option<String>,
669}
670
671/// The daemon-config file baseline derived from [`ServerOptions`] (the resolved
672/// `boatramp.cfg`). `serve` uses this to build a [`DaemonRuntime`] it can wake on
673/// SIGHUP/changelog; the posture's upload cap is the ceiling a dynamic override
674/// may not exceed.
675pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
676    // The static `[serve.console]` mount is the baseline the dynamic
677    // `DaemonConfig.console` override layers over. `Some(mount)` ⇒ enabled at the
678    // file level; without the `console` feature there is nothing to serve.
679    #[cfg(feature = "console")]
680    let (console_enabled, console_host, console_path) = match options.console.as_ref() {
681        Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
682        None => (false, None, None),
683    };
684    #[cfg(not(feature = "console"))]
685    let (console_enabled, console_host, console_path) = (false, None, None);
686    boatramp_core::daemon_config::ConfigBaseline {
687        default_site: options.default_site.clone(),
688        protect_previews: options.protect_previews,
689        max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
690        upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
691        max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
692        cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
693        compute_vcpus: 0,
694        compute_mem_mib: 0,
695        console_enabled,
696        console_host,
697        console_path,
698        max_upload_ceiling: options.posture.max_upload_bytes,
699        max_concurrent_uploads_ceiling: None,
700        posture: options.posture,
701    }
702}
703
704impl DaemonRuntime {
705    /// Build with the file baseline; the effective config starts equal to the
706    /// baseline (no dynamic override) until [`reload`](Self::reload) runs. `serve`
707    /// builds this (via [`config_baseline`]) so it can wake it on SIGHUP/changelog.
708    pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
709        let effective =
710            Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
711        Self {
712            baseline,
713            state: std::sync::RwLock::new(DaemonState {
714                effective,
715                generation: None,
716            }),
717            reload: tokio::sync::Notify::new(),
718        }
719    }
720
721    /// Wake an immediate re-resolve from the store. Called by the SIGHUP handler,
722    /// the shared-store changelog poller (when a `daemon/*` key changed), and after
723    /// a local write — so convergence is push-driven, not poll-driven.
724    pub fn notify_reload(&self) {
725        self.reload.notify_one();
726    }
727
728    /// The current effective operational config.
729    pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
730        self.state
731            .read()
732            .expect("daemon config lock")
733            .effective
734            .clone()
735    }
736
737    /// The active generation hash (the `daemon/current` content address), or
738    /// `None` when running on the pure file baseline.
739    pub fn generation(&self) -> Option<String> {
740        self.state
741            .read()
742            .expect("daemon config lock")
743            .generation
744            .clone()
745    }
746
747    /// The file baseline (+ static ceilings) a write is validated against.
748    pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
749        &self.baseline
750    }
751
752    /// Re-resolve `baseline ⊕ stored dynamic config` and hot-swap the live values.
753    /// Called after a write and on SIGHUP.
754    pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
755        let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
756        let generation = deploy.daemon_config_generation().await?;
757        let effective = Arc::new(cfg.resolve(&self.baseline));
758        *self.state.write().expect("daemon config lock") = DaemonState {
759            effective,
760            generation,
761        };
762        Ok(())
763    }
764}
765
766/// Preview-access policy, carried as an extension so the preview handlers can
767/// require a token when `protect` is set.
768#[derive(Clone, Copy, Default)]
769struct PreviewPolicy {
770    protect: bool,
771}
772
773/// The token issuing signer (root private key / KMS / HSM), carried as an
774/// extension for the token-create and OIDC-exchange handlers. `None` ⇒ this node
775/// verifies tokens but does not issue them (it has only the public key); issuing
776/// routes return `501`.
777#[derive(Clone, Default)]
778struct Issuer(Option<Arc<dyn Signer>>);
779
780/// The first-token bootstrap gate: the SHA-256 hex of the operator-set bootstrap
781/// secret plus an in-process lock that serializes the check-and-spend (the KV has
782/// no compare-and-set; a persisted marker keeps it single-use across restarts).
783/// `None` ⇒ bootstrap disabled (the route returns `501`).
784#[derive(Clone, Default)]
785struct BootstrapGate(Option<Arc<BootstrapInner>>);
786
787struct BootstrapInner {
788    /// SHA-256 hex of the configured secret — used for both the constant-work
789    /// comparison and the single-use marker key.
790    secret_hash: String,
791    /// Serializes the read-marker → mint → write-marker section so two concurrent
792    /// redemptions can't both mint.
793    lock: tokio::sync::Mutex<()>,
794}
795
796impl BootstrapGate {
797    fn new(secret: Option<&str>) -> Self {
798        Self(secret.filter(|s| !s.is_empty()).map(|s| {
799            Arc::new(BootstrapInner {
800                secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
801                lock: tokio::sync::Mutex::new(()),
802            })
803        }))
804    }
805}
806
807/// The cluster mesh control operations exposed to the control-plane API,
808/// implemented by the cluster runtime over `ClusterNode`;
809/// `None` on a non-cluster node (the routes then return `501`).
810#[async_trait::async_trait]
811pub trait MeshControl: Send + Sync {
812    /// Admit a joining node presenting a bearer join token whose single-use handle
813    /// is `jti`: **verify the possession proof** (`possession_proof` over
814    /// `cose::join_challenge(jti, mesh_pubkey_hex, proof_iat)`, fresh at `now`)
815    /// against `mesh_pubkey_hex`, then — if valid and the token isn't spent — trust
816    /// the key cluster-wide, add it to membership (id derived from the key), and
817    /// return the current members as **root-signed** assertions. `Err` is a
818    /// human-readable failure (e.g. this node has no root key to vouch for members).
819    async fn admit(
820        &self,
821        mesh_pubkey_hex: &str,
822        jti: &str,
823        possession_proof: &[u8],
824        proof_iat: u64,
825        now: u64,
826        advertise_addr: Option<&str>,
827    ) -> Result<JoinOutcome, String>;
828
829    /// Rotate **this node's** mesh identity (make-before-break) and return the new
830    /// public key (SPKI hex). Node-local: only the node itself can mint + persist
831    /// its private key, so this rotates the key of the node whose API is hit.
832    async fn rotate_key(&self) -> Result<String, String>;
833
834    /// Revoke `node` from the mesh: delete its trust cluster-wide (so it can no
835    /// longer authenticate) and drop it from the quorum. `Err` is a
836    /// human-readable failure.
837    async fn revoke(&self, node: u64) -> Result<(), String>;
838
839    /// The current Raft membership (voters + learners), for the Kubernetes
840    /// operator's membership reconciler. `caught_up` is meaningful only on the
841    /// leader; hit the leader for a promote decision.
842    async fn members(&self) -> Result<Vec<MeshMember>, String>;
843
844    /// Promote a caught-up learner `node` to a voter (leader-only; a no-op on a
845    /// follower). `Err` is a human-readable failure.
846    async fn promote(&self, node: u64) -> Result<(), String>;
847}
848
849/// The result of a join admission ([`MeshControl::admit`]).
850pub enum JoinOutcome {
851    /// Admitted — carries the current members as root-signed assertions plus the
852    /// advisory `node_id -> mesh URL` routing for them.
853    Admitted {
854        /// Root-signed member assertions the joiner verifies against the anchor.
855        members: Vec<String>,
856        /// Advisory `node_id -> mesh URL` routing (not signed).
857        addrs: std::collections::BTreeMap<u64, String>,
858    },
859    /// The join token was already spent (single-use) → `409`.
860    TokenSpent,
861    /// The possession proof was missing/stale/invalid → `403`.
862    ProofInvalid,
863    /// The presented key is revoked (a durable tombstone bars it, F6) — an
864    /// explicit un-revoke is required before it can rejoin → `403`.
865    Revoked,
866}
867
868/// One node's Raft membership, reported by `GET /api/cluster/members`.
869#[derive(Debug, Clone, Serialize)]
870pub struct MeshMember {
871    /// The node id.
872    pub node: u64,
873    /// `true` ⇒ a voter (counts toward quorum); `false` ⇒ a learner.
874    pub voter: bool,
875    /// Whether a learner has caught up to the leader's log (ready to promote).
876    pub caught_up: bool,
877    /// Whether this node is the current leader.
878    pub leader: bool,
879    /// The node's advisory mesh URL, if this node knows it — the address-primary
880    /// handle `cluster status`/`remove` use (dynamic-join learns addresses at
881    /// admit; a static-genesis node has them from config). `None` ⇒ unknown here.
882    #[serde(default, skip_serializing_if = "Option::is_none")]
883    pub addr: Option<String>,
884}
885
886/// The mesh control hook, carried as an extension for the join/rotate handlers.
887/// `None` ⇒ this node is not a cluster node, so those routes return `501`.
888#[derive(Clone, Default)]
889struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
890
891/// The OIDC verifier for the exchange endpoint, carried as an extension.
892#[cfg(feature = "oidc")]
893#[derive(Clone, Default)]
894struct OidcState(Option<Arc<oidc::OidcVerifier>>);
895
896/// TTL for an OIDC-exchanged token: short, since the holder can re-exchange
897/// against the IdP at any time.
898#[cfg(feature = "oidc")]
899const EXCHANGE_TTL_SECS: u64 = 3600;
900
901use boatramp_core::time::now_unix;
902
903/// The configured CORS allowlist, carried as middleware state for the API.
904#[derive(Clone)]
905struct CorsState(Arc<Vec<String>>);
906
907/// Methods the control-plane API exposes; advertised in a preflight response.
908const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
909/// Request headers a browser client needs (Bearer auth + JSON bodies); the
910/// fallback when a preflight doesn't list `Access-Control-Request-Headers`.
911const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
912/// How long a browser may cache a preflight result (seconds).
913const CORS_MAX_AGE: &str = "600";
914
915/// Whether `origin` is permitted by the configured allowlist. `*` allows any
916/// origin (the specific origin is still echoed back, with `Vary: Origin`);
917/// otherwise the match is an exact `scheme://host[:port]` comparison.
918fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
919    allowed.iter().any(|a| a == "*" || a == origin)
920}
921
922/// Opt-in CORS for the control-plane `/api/*` routes (see
923/// [`ServerOptions::cors_allowed_origins`]). Answers a preflight `OPTIONS`
924/// itself — before the auth layer, since a preflight carries no credentials —
925/// and, for an allowed `Origin`, echoes `Access-Control-Allow-Origin` plus
926/// `Vary: Origin` onto the response. A disallowed/absent origin gets no
927/// `Access-Control-*` headers, so the browser blocks the cross-origin read.
928async fn cors(
929    State(allowed): State<CorsState>,
930    request: Request,
931    next: axum::middleware::Next,
932) -> Response {
933    let origin = request
934        .headers()
935        .get(header::ORIGIN)
936        .and_then(|v| v.to_str().ok())
937        .filter(|o| cors_origin_allowed(&allowed.0, o))
938        .map(str::to_string);
939    // A CORS preflight is an OPTIONS carrying `Access-Control-Request-Method`.
940    let is_preflight = request.method() == Method::OPTIONS
941        && request
942            .headers()
943            .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
944    if is_preflight {
945        // Echo the browser's requested headers when present, else our known set.
946        let allow_headers = request
947            .headers()
948            .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
949            .and_then(|v| v.to_str().ok())
950            .map(str::to_string)
951            .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
952        let mut response = Response::new(Body::empty());
953        *response.status_mut() = StatusCode::NO_CONTENT;
954        if let Some(origin) = origin {
955            let headers = response.headers_mut();
956            set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
957            set_header(headers, header::VARY, "Origin");
958            set_header(
959                headers,
960                header::ACCESS_CONTROL_ALLOW_METHODS,
961                CORS_ALLOW_METHODS,
962            );
963            set_header(
964                headers,
965                header::ACCESS_CONTROL_ALLOW_HEADERS,
966                &allow_headers,
967            );
968            set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
969        }
970        return response;
971    }
972    let mut response = next.run(request).await;
973    if let Some(origin) = origin {
974        let headers = response.headers_mut();
975        set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
976        // `Vary: Origin` so a shared cache can't serve one origin's CORS
977        // response to another; appended so any existing `Vary` is preserved.
978        if let Ok(value) = HeaderValue::from_str("Origin") {
979            headers.append(header::VARY, value);
980        }
981    }
982    response
983}
984
985/// How long the shutdown drain may run before the listener is forced closed.
986/// Generous enough for any in-flight handler invocation to finish (each is
987/// itself bounded by the engine's epoch timeout); it only caps stuck or
988/// abusive connections so a SIGTERM can't hang forever.
989const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
990
991/// A failure starting or running the HTTP server.
992#[derive(Debug, thiserror::Error)]
993pub enum ServeError {
994    /// Binding the listener, or an axum serve I/O error.
995    #[error("server I/O: {0}")]
996    Io(#[from] std::io::Error),
997}
998
999/// Bind `addr` and serve until a shutdown signal (Ctrl-C / SIGTERM), then drain
1000/// in-flight requests under [`DRAIN_DEADLINE`]. Default [`ServerOptions`].
1001pub async fn serve(
1002    addr: SocketAddr,
1003    deploy: DeployStore,
1004    auth: Auth,
1005    handlers: HandlerRuntime,
1006) -> Result<(), ServeError> {
1007    serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1008}
1009
1010/// [`serve`] with explicit [`ServerOptions`] (e.g. operational request limits).
1011pub async fn serve_with(
1012    addr: SocketAddr,
1013    deploy: DeployStore,
1014    auth: Auth,
1015    handlers: HandlerRuntime,
1016    options: ServerOptions,
1017) -> Result<(), ServeError> {
1018    let listener = tokio::net::TcpListener::bind(addr).await?;
1019    tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1020    // Background scheduler: drives consumers/crons for active deployments
1021    // (no-op without the handlers feature/runtime). Aborted after the drain.
1022    #[cfg(feature = "handlers")]
1023    let scheduler = handlers.spawn_scheduler(deploy.clone());
1024    // Background gateway active-health prober: probes the
1025    // backends of upstreams with `active_health` so a dead one leaves rotation
1026    // before client traffic. Idle until a request arms an upstream.
1027    let gateway_prober = gateway::spawn_active_health_prober();
1028    // Connect-info make-service so handlers can see the peer address (for IP
1029    // rules / rate limiting / access logs).
1030    let app = router_with(deploy, auth, handlers, options)
1031        .into_make_service_with_connect_info::<SocketAddr>();
1032
1033    // The graceful drain begins when the OS signal fires; `signalled` flips at
1034    // that instant so the drain deadline is measured from the signal, not from
1035    // server start.
1036    let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1037    let server = axum::serve(listener, app).with_graceful_shutdown(async move {
1038        shutdown_signal().await;
1039        let _ = signalled_tx.send(true);
1040    });
1041    let signalled = {
1042        let mut rx = signalled_rx;
1043        async move {
1044            let _ = rx.wait_for(|fired| *fired).await;
1045        }
1046    };
1047    let result = serve_with_drain_deadline(
1048        async move { server.await.map_err(ServeError::from) },
1049        signalled,
1050        DRAIN_DEADLINE,
1051    )
1052    .await;
1053    // Stop the scheduler once the server has drained.
1054    #[cfg(feature = "handlers")]
1055    if let Some(handle) = scheduler {
1056        handle.abort();
1057    }
1058    gateway_prober.abort();
1059    result
1060}
1061
1062/// Run the graceful-serve future `server`, but if the drain runs longer than
1063/// `deadline` *after* `signalled` resolves, stop waiting and return (dropping
1064/// `server`, which closes any still-open connections). Pulled out of [`serve`]
1065/// so the deadline behaviour is unit-testable without sockets or real signals.
1066async fn serve_with_drain_deadline<Srv, Sig>(
1067    server: Srv,
1068    signalled: Sig,
1069    deadline: Duration,
1070) -> Result<(), ServeError>
1071where
1072    Srv: Future<Output = Result<(), ServeError>>,
1073    Sig: Future<Output = ()>,
1074{
1075    tokio::pin!(server);
1076    let drain_cap = async move {
1077        signalled.await;
1078        tokio::time::sleep(deadline).await;
1079    };
1080    tokio::select! {
1081        result = &mut server => result,
1082        _ = drain_cap => {
1083            tracing::warn!(
1084                deadline_s = deadline.as_secs(),
1085                "drain deadline exceeded; forcing shutdown with requests still in flight"
1086            );
1087            Ok(())
1088        }
1089    }
1090}
1091
1092/// Resolve when the process receives Ctrl-C or SIGTERM, so in-flight requests
1093/// can drain before exit.
1094pub async fn shutdown_signal() {
1095    let ctrl_c = async {
1096        let _ = tokio::signal::ctrl_c().await;
1097    };
1098    #[cfg(unix)]
1099    let terminate = async {
1100        if let Ok(mut sig) =
1101            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1102        {
1103            sig.recv().await;
1104        }
1105    };
1106    #[cfg(not(unix))]
1107    let terminate = std::future::pending::<()>();
1108
1109    tokio::select! {
1110        _ = ctrl_c => {}
1111        _ = terminate => {}
1112    }
1113    tracing::info!("shutdown signal received; draining");
1114}
1115
1116/// Liveness probe. Also reports the active daemon-config **generation** hash so an
1117/// operator can confirm every node in a cluster converged to the same config
1118/// (`ok` alone = running on the pure file baseline).
1119async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1120    match daemon.generation() {
1121        Some(gen) => format!("ok gen={gen}"),
1122        None => "ok".to_string(),
1123    }
1124}
1125
1126/// Readiness probe: `200 ready` when the metadata backend answers, else `503`.
1127async fn readyz(State(deploy): State<DeployStore>) -> Response {
1128    match deploy.ready().await {
1129        Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1130        Err(err) => {
1131            tracing::warn!(error = %err, "readiness probe failed");
1132            (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1133        }
1134    }
1135}
1136
1137/// A per-request correlation id assigned by the access-log layer and readable downstream via
1138/// the request extensions — the handler dispatch tags captured guest logs with it, so a guest
1139/// line correlates with its `boatramp::access` line. Public so an embedder (or a test) can seed
1140/// its own id into the request extensions.
1141#[derive(Clone)]
1142pub struct RequestId(pub String);
1143
1144/// The correlation id for a request: an upstream proxy's `X-Request-Id` when present (sanitized,
1145/// length-capped), else a generated time-ordered, per-process-unique id.
1146fn request_id_for(headers: &HeaderMap) -> String {
1147    if let Some(id) = headers
1148        .get("x-request-id")
1149        .and_then(|v| v.to_str().ok())
1150        .map(str::trim)
1151        .filter(|s| !s.is_empty())
1152    {
1153        return id.chars().filter(|c| !c.is_control()).take(128).collect();
1154    }
1155    use std::sync::atomic::{AtomicU64, Ordering};
1156    static SEQ: AtomicU64 = AtomicU64::new(0);
1157    let n = SEQ.fetch_add(1, Ordering::Relaxed);
1158    format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1159}
1160
1161/// One access-log line, emitted when the response body finishes streaming, so
1162/// `bytes` (response size) and `elapsed_ms` (time-to-last-byte) are accurate for
1163/// fixed-size *and* streamed/proxied responses.
1164struct AccessLog {
1165    request_id: String,
1166    method: Method,
1167    path: String,
1168    host: String,
1169    client: String,
1170    status: u16,
1171    /// Response `Content-Encoding` (`br`/`gzip`/`identity`).
1172    encoding: String,
1173    start: std::time::Instant,
1174    bytes: std::sync::atomic::AtomicU64,
1175}
1176
1177impl Drop for AccessLog {
1178    fn drop(&mut self) {
1179        let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1180        // Aggregate into the process-wide Prometheus counters (status class +
1181        // cache result + bytes) before emitting the per-request line.
1182        srvmetrics::server_metrics().record_request(self.status, bytes);
1183        tracing::info!(
1184            target: "boatramp::access",
1185            request_id = %self.request_id,
1186            method = %self.method,
1187            path = %self.path,
1188            host = %self.host,
1189            client = %self.client,
1190            status = self.status,
1191            bytes = bytes,
1192            encoding = %self.encoding,
1193            cache_result = srvmetrics::cache_result(self.status),
1194            elapsed_ms = self.start.elapsed().as_millis() as u64,
1195            "request"
1196        );
1197    }
1198}
1199
1200/// Structured access-log middleware: method, path, host, client IP, status,
1201/// response bytes, and duration. The line is emitted once the body has fully
1202/// streamed (or the connection drops), counting bytes as they pass through.
1203async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1204    let method = request.method().clone();
1205    let path = request.uri().path().to_string();
1206    // Assign the correlation id and make it readable downstream (handler dispatch tags
1207    // captured guest logs with it) before running the request.
1208    let request_id = request_id_for(request.headers());
1209    request
1210        .extensions_mut()
1211        .insert(RequestId(request_id.clone()));
1212    let host = request
1213        .headers()
1214        .get(header::HOST)
1215        .and_then(|value| value.to_str().ok())
1216        .unwrap_or("-")
1217        .to_string();
1218    let client = request
1219        .extensions()
1220        .get::<axum::extract::ConnectInfo<SocketAddr>>()
1221        .map(|info| info.0.ip().to_string())
1222        .unwrap_or_else(|| "-".to_string());
1223
1224    let start = std::time::Instant::now();
1225    let response = next.run(request).await;
1226    let encoding = response
1227        .headers()
1228        .get(header::CONTENT_ENCODING)
1229        .and_then(|v| v.to_str().ok())
1230        .unwrap_or("identity")
1231        .to_string();
1232    let log = AccessLog {
1233        request_id,
1234        method,
1235        path,
1236        host,
1237        client,
1238        status: response.status().as_u16(),
1239        encoding,
1240        start,
1241        bytes: std::sync::atomic::AtomicU64::new(0),
1242    };
1243
1244    // Wrap the body so bytes are tallied as they stream; `log` is owned by the
1245    // stream closure, so its Drop emits the line when the body finishes (or the
1246    // client disconnects).
1247    let (parts, body) = response.into_parts();
1248    let counted = body.into_data_stream().map(move |chunk| {
1249        if let Ok(bytes) = &chunk {
1250            log.bytes
1251                .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1252        }
1253        chunk
1254    });
1255    Response::from_parts(parts, Body::from_stream(counted))
1256}
1257
1258/// Whether the request's `If-None-Match` matches `etag` (or `*`).
1259fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1260    req_headers
1261        .get(header::IF_NONE_MATCH)
1262        .and_then(|value| value.to_str().ok())
1263        .is_some_and(|value| {
1264            value
1265                .split(',')
1266                .map(str::trim)
1267                .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1268        })
1269}
1270
1271fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1272    if let Ok(value) = HeaderValue::from_str(value) {
1273        headers.insert(name, value);
1274    }
1275}
1276
1277fn not_found() -> Response {
1278    (StatusCode::NOT_FOUND, "not found\n").into_response()
1279}
1280
1281fn redirect(status: u16, location: &str) -> Response {
1282    let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1283    match HeaderValue::from_str(location) {
1284        Ok(location) => {
1285            let mut headers = HeaderMap::new();
1286            headers.insert(header::LOCATION, location);
1287            (status, headers).into_response()
1288        }
1289        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1290    }
1291}
1292
1293/// Map a [`DeployError`] to an HTTP response.
1294fn deploy_error_response(err: DeployError) -> Response {
1295    let status = match &err {
1296        DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1297            StatusCode::NOT_FOUND
1298        }
1299        DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1300        DeployError::Incomplete(_) => StatusCode::CONFLICT,
1301        // A host already claimed by another site — refuse the overwrite.
1302        DeployError::Conflict(_) => StatusCode::CONFLICT,
1303        // An ambiguous preview-id prefix is not a usable capability → not found.
1304        DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1305        _ => StatusCode::INTERNAL_SERVER_ERROR,
1306    };
1307    tracing::warn!(error = %err, "request failed");
1308    (status, format!("{err}\n")).into_response()
1309}
1310
1311/// Reject a resource name (site/function/compute/workflow) that is unsafe at the
1312/// store-key boundary, returning `Some(422)` to short-circuit the handler. The
1313/// name arrives here already percent-decoded by axum's `Path` extractor, so a
1314/// smuggled `%2F` is caught as a literal `/`. `None` = the name is fine.
1315fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1316    boatramp_core::project::validate_resource_name(kind, value)
1317        .err()
1318        .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1319}
1320
1321#[cfg(test)]
1322mod drain_tests {
1323    use super::*;
1324
1325    #[tokio::test]
1326    async fn deadline_forces_shutdown_after_signal() {
1327        // Server never finishes draining; once the signal has fired the
1328        // deadline must end the wait (Ok — we forced shutdown deliberately).
1329        let server = std::future::pending::<Result<(), ServeError>>();
1330        let signalled = async {}; // signal already fired
1331        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1332        assert!(result.is_ok());
1333    }
1334
1335    #[tokio::test]
1336    async fn server_finishing_first_wins() {
1337        // If the server drains before the deadline, its result is returned and
1338        // the deadline never trips (signal never even fires here).
1339        let server = async { Ok(()) };
1340        let signalled = std::future::pending::<()>();
1341        let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1342        assert!(result.is_ok());
1343    }
1344
1345    #[tokio::test]
1346    async fn deadline_does_not_trip_before_signal() {
1347        // The deadline is measured from the signal: with no signal it never
1348        // trips, even past its length. The server completes (here with an
1349        // error) and that result propagates.
1350        let server = async {
1351            tokio::time::sleep(Duration::from_millis(40)).await;
1352            Err(ServeError::Io(std::io::Error::other("server error")))
1353        };
1354        let signalled = std::future::pending::<()>();
1355        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1356        assert!(result.is_err());
1357    }
1358}
1359
1360#[cfg(all(test, feature = "handlers"))]
1361mod tests {
1362    use super::*;
1363    use boatramp_core::cose::{LocalSigner, TokenAlg};
1364    use boatramp_core::project::ProjectRef;
1365
1366    #[test]
1367    fn query_string_parses_and_url_decodes() {
1368        let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1369        assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1370        assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); // %C3%A3 + '+'
1371        assert_eq!(q.get("flag").map(String::as_str), Some("")); // bare key
1372        assert_eq!(q.get("dup").map(String::as_str), Some("1")); // first value wins
1373    }
1374
1375    #[test]
1376    fn cookie_header_parses_pairs() {
1377        let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1378        assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1379        assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1380        assert_eq!(c.get("empty").map(String::as_str), Some(""));
1381    }
1382
1383    #[test]
1384    fn apply_vary_merges_without_duplicates() {
1385        let base = (StatusCode::OK, "x").into_response();
1386        let r = apply_vary(base, &["accept-language".into()]);
1387        assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1388        // Merges into an existing Vary, de-duplicating case-insensitively.
1389        let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1390        let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1391        assert!(v.contains("accept-language") && v.contains("cookie"));
1392        assert_eq!(v.matches("accept-language").count(), 1);
1393        // Empty vary is a no-op.
1394        let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1395        assert!(plain.headers().get(header::VARY).is_none());
1396    }
1397
1398    /// The `/api/cluster/join-token` handler mints a verifiable **bearer** token,
1399    /// and refuses cleanly on a verify-only node (no root key) → 501. Admin-gating
1400    /// is the deny-safe `Right::required` default for `/api/cluster/*`.
1401    #[tokio::test]
1402    async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1403        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1404        let public = keys.public_key();
1405
1406        // Happy path: the returned token verifies + yields a single-use jti.
1407        let resp = create_join_token(
1408            Extension(Issuer(Some(keys.clone()))),
1409            Json(CreateJoinTokenRequest {
1410                ttl_secs: Some(600),
1411            }),
1412        )
1413        .await;
1414        assert_eq!(resp.status(), StatusCode::CREATED);
1415        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1416            .await
1417            .unwrap();
1418        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1419        let token = parsed["token"].as_str().unwrap();
1420        let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1421        assert!(!jti.is_empty());
1422
1423        // A verify-only node (no issuing key) cannot mint → 501.
1424        let no_issuer = create_join_token(
1425            Extension(Issuer(None)),
1426            Json(CreateJoinTokenRequest { ttl_secs: None }),
1427        )
1428        .await;
1429        assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1430    }
1431
1432    /// FA-2: the top-level function **write** path driven through the HTTP handlers —
1433    /// deploy two versions, roll back, alias, remove — plus the two 400/absent-blob
1434    /// guards. The store-layer semantics are the `boatramp-core` oracle; this pins the
1435    /// handler wrapper (status codes, blob gate, JSON echo).
1436    #[tokio::test]
1437    async fn function_write_path_deploy_rollback_alias_remove() {
1438        use boatramp_core::function::Lifecycle;
1439        use boatramp_core::kv::MemoryKv;
1440        use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1441
1442        // A storage whose `head` (hence `has_blob`) is toggleable — enough to drive
1443        // both the blob-present deploy path and the absent-blob 400.
1444        struct FakeStorage {
1445            present: bool,
1446        }
1447        #[async_trait::async_trait]
1448        impl Storage for FakeStorage {
1449            async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1450                Err(StorageError::NotFound(String::new()))
1451            }
1452            async fn get_range(
1453                &self,
1454                _: &str,
1455                _: u64,
1456                _: Option<u64>,
1457            ) -> Result<GetObject, StorageError> {
1458                Err(StorageError::NotFound(String::new()))
1459            }
1460            async fn put(
1461                &self,
1462                _: &str,
1463                _: ByteStream,
1464                _: PutMeta,
1465            ) -> Result<ObjectMeta, StorageError> {
1466                Err(StorageError::unsupported("fake"))
1467            }
1468            async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
1469                if self.present {
1470                    Ok(ObjectMeta {
1471                        key: key.to_string(),
1472                        ..Default::default()
1473                    })
1474                } else {
1475                    Err(StorageError::NotFound(key.to_string()))
1476                }
1477            }
1478            async fn delete(&self, _: &str) -> Result<(), StorageError> {
1479                Ok(())
1480            }
1481            async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
1482                Ok(Vec::new())
1483            }
1484        }
1485
1486        async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
1487            let status = resp.status();
1488            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
1489                .await
1490                .unwrap();
1491            let value = if bytes.is_empty() {
1492                serde_json::Value::Null
1493            } else {
1494                serde_json::from_slice(&bytes).unwrap()
1495            };
1496            (status, value)
1497        }
1498
1499        let deploy = DeployStore::new(
1500            Arc::new(FakeStorage { present: true }),
1501            Arc::new(MemoryKv::new()),
1502        );
1503        let v1 = "a".repeat(64);
1504        let v2 = "b".repeat(64);
1505
1506        // Deploy v1 → created, active = v1.
1507        let (st, body) = body_json(
1508            deploy_function(
1509                State(deploy.clone()),
1510                axum::extract::Extension(crate::ProjectContext::default()),
1511                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1512                axum::extract::Query(DeployFunctionQuery::default()),
1513                Path("greeter".to_string()),
1514                Json(FunctionUpsert {
1515                    component: v1.clone(),
1516                    config: Default::default(),
1517                    lifecycle: Lifecycle::Independent,
1518                }),
1519            )
1520            .await,
1521        )
1522        .await;
1523        assert_eq!(st, StatusCode::OK);
1524        assert_eq!(body["active"], v1);
1525
1526        // Deploy v2 → active advances, two versions retained.
1527        let (_, body) = body_json(
1528            deploy_function(
1529                State(deploy.clone()),
1530                axum::extract::Extension(crate::ProjectContext::default()),
1531                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1532                axum::extract::Query(DeployFunctionQuery::default()),
1533                Path("greeter".to_string()),
1534                Json(FunctionUpsert {
1535                    component: v2.clone(),
1536                    config: Default::default(),
1537                    lifecycle: Lifecycle::Independent,
1538                }),
1539            )
1540            .await,
1541        )
1542        .await;
1543        assert_eq!(body["active"], v2);
1544        assert_eq!(body["versions"].as_array().unwrap().len(), 2);
1545
1546        // Roll back to v1.
1547        let (st, body) = body_json(
1548            rollback_function(
1549                State(deploy.clone()),
1550                axum::extract::Extension(crate::ProjectContext::default()),
1551                Path("greeter".to_string()),
1552                Json(RollbackBody { to: v1.clone() }),
1553            )
1554            .await,
1555        )
1556        .await;
1557        assert_eq!(st, StatusCode::OK);
1558        assert_eq!(body["active"], v1);
1559
1560        // Rolling back to an unknown version is a 400 (plain-text body).
1561        let resp = rollback_function(
1562            State(deploy.clone()),
1563            axum::extract::Extension(crate::ProjectContext::default()),
1564            Path("greeter".to_string()),
1565            Json(RollbackBody { to: "c".repeat(64) }),
1566        )
1567        .await;
1568        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1569
1570        // Alias prod → v2.
1571        let (st, body) = body_json(
1572            alias_function(
1573                State(deploy.clone()),
1574                axum::extract::Extension(crate::ProjectContext::default()),
1575                Path(("greeter".to_string(), "prod".to_string())),
1576                Json(AliasBody {
1577                    version: v2.clone(),
1578                }),
1579            )
1580            .await,
1581        )
1582        .await;
1583        assert_eq!(st, StatusCode::OK);
1584        assert_eq!(body["aliases"]["prod"], v2);
1585
1586        // Remove → 204, and it's gone.
1587        let (st, _) = body_json(
1588            remove_function(
1589                State(deploy.clone()),
1590                axum::extract::Extension(crate::ProjectContext::default()),
1591                Path("greeter".to_string()),
1592            )
1593            .await,
1594        )
1595        .await;
1596        assert_eq!(st, StatusCode::NO_CONTENT);
1597        assert!(deploy
1598            .get_function(ProjectRef::DEFAULT, "greeter")
1599            .await
1600            .unwrap()
1601            .is_none());
1602
1603        // Deploying a component whose blob was never uploaded is a 400.
1604        let empty = DeployStore::new(
1605            Arc::new(FakeStorage { present: false }),
1606            Arc::new(MemoryKv::new()),
1607        );
1608        let resp = deploy_function(
1609            State(empty),
1610            axum::extract::Extension(crate::ProjectContext::default()),
1611            axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
1612            axum::extract::Query(DeployFunctionQuery::default()),
1613            Path("orphan".to_string()),
1614            Json(FunctionUpsert {
1615                component: v1.clone(),
1616                config: Default::default(),
1617                lifecycle: Lifecycle::default(),
1618            }),
1619        )
1620        .await;
1621        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
1622    }
1623
1624    /// A configurable stub: records the `(mesh_pubkey, jti)` it's asked to admit and
1625    /// returns a chosen [`JoinOutcome`] (the real possession-proof + member signing
1626    /// lives in the cluster impl; here we test the handler's dispatch + status map).
1627    struct StubControl {
1628        admits: std::sync::Mutex<Vec<(String, String)>>,
1629        respond: StubJoin,
1630    }
1631    #[derive(Clone, Copy)]
1632    enum StubJoin {
1633        Admit,
1634        Spent,
1635        Invalid,
1636        Revoked,
1637    }
1638
1639    #[async_trait::async_trait]
1640    impl MeshControl for StubControl {
1641        async fn admit(
1642            &self,
1643            mesh_pubkey_hex: &str,
1644            jti: &str,
1645            _proof: &[u8],
1646            _proof_iat: u64,
1647            _now: u64,
1648            _advertise_addr: Option<&str>,
1649        ) -> Result<JoinOutcome, String> {
1650            self.admits
1651                .lock()
1652                .unwrap()
1653                .push((mesh_pubkey_hex.to_string(), jti.to_string()));
1654            Ok(match self.respond {
1655                StubJoin::Admit => JoinOutcome::Admitted {
1656                    members: vec!["signed-member".to_string()],
1657                    addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
1658                },
1659                StubJoin::Spent => JoinOutcome::TokenSpent,
1660                StubJoin::Invalid => JoinOutcome::ProofInvalid,
1661                StubJoin::Revoked => JoinOutcome::Revoked,
1662            })
1663        }
1664        async fn rotate_key(&self) -> Result<String, String> {
1665            Ok("cafe".to_string())
1666        }
1667        async fn revoke(&self, _node: u64) -> Result<(), String> {
1668            Ok(())
1669        }
1670        async fn members(&self) -> Result<Vec<MeshMember>, String> {
1671            Ok(Vec::new())
1672        }
1673        async fn promote(&self, _node: u64) -> Result<(), String> {
1674            Ok(())
1675        }
1676    }
1677
1678    /// `POST /api/cluster/join`: a valid bearer token dispatches to the admitter and
1679    /// maps its outcome (admitted→200+members, spent→409, proof-invalid→403); a bad
1680    /// token → 401, a non-hex proof → 400, and no cluster hook → 501.
1681    #[tokio::test]
1682    async fn cluster_join_dispatches_and_maps_outcomes() {
1683        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1684        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
1685        let auth = Auth::with_key(keys.public_key(), kv);
1686        let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
1687        let req = |proof: &str| JoinRequest {
1688            token: token.clone(),
1689            mesh_pubkey: "302a300506032b6570032100feed".into(),
1690            possession_proof: proof.to_string(),
1691            proof_iat: now_unix(),
1692            advertise_addr: Some("https://joiner:7000".into()),
1693        };
1694
1695        // Admitted → 200 + the signed members, and the admitter saw the jti.
1696        let admitter = Arc::new(StubControl {
1697            admits: std::sync::Mutex::new(Vec::new()),
1698            respond: StubJoin::Admit,
1699        });
1700        let resp = cluster_join(
1701            Extension(auth.clone()),
1702            Extension(MeshControlHandle(Some(admitter.clone()))),
1703            Json(req("aa01")),
1704        )
1705        .await;
1706        assert_eq!(resp.status(), StatusCode::OK);
1707        assert_eq!(admitter.admits.lock().unwrap().len(), 1);
1708
1709        // Spent token → 409; proof-invalid → 403 (the impl's verdicts, mapped).
1710        let spent = Arc::new(StubControl {
1711            admits: std::sync::Mutex::new(Vec::new()),
1712            respond: StubJoin::Spent,
1713        });
1714        assert_eq!(
1715            cluster_join(
1716                Extension(auth.clone()),
1717                Extension(MeshControlHandle(Some(spent))),
1718                Json(req("aa01")),
1719            )
1720            .await
1721            .status(),
1722            StatusCode::CONFLICT
1723        );
1724        let invalid = Arc::new(StubControl {
1725            admits: std::sync::Mutex::new(Vec::new()),
1726            respond: StubJoin::Invalid,
1727        });
1728        assert_eq!(
1729            cluster_join(
1730                Extension(auth.clone()),
1731                Extension(MeshControlHandle(Some(invalid))),
1732                Json(req("aa01")),
1733            )
1734            .await
1735            .status(),
1736            StatusCode::FORBIDDEN
1737        );
1738        // A revoked key → 403 (a tombstone bars re-admission until un-revoked).
1739        let revoked = Arc::new(StubControl {
1740            admits: std::sync::Mutex::new(Vec::new()),
1741            respond: StubJoin::Revoked,
1742        });
1743        assert_eq!(
1744            cluster_join(
1745                Extension(auth.clone()),
1746                Extension(MeshControlHandle(Some(revoked))),
1747                Json(req("aa01")),
1748            )
1749            .await
1750            .status(),
1751            StatusCode::FORBIDDEN
1752        );
1753
1754        // A non-hex possession proof → 400 (before dispatch).
1755        let ok = Arc::new(StubControl {
1756            admits: std::sync::Mutex::new(Vec::new()),
1757            respond: StubJoin::Admit,
1758        });
1759        assert_eq!(
1760            cluster_join(
1761                Extension(auth.clone()),
1762                Extension(MeshControlHandle(Some(ok))),
1763                Json(req("not-hex")),
1764            )
1765            .await
1766            .status(),
1767            StatusCode::BAD_REQUEST
1768        );
1769
1770        // No cluster hook → 501.
1771        let none = cluster_join(
1772            Extension(auth),
1773            Extension(MeshControlHandle(None)),
1774            Json(req("aa01")),
1775        )
1776        .await;
1777        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1778    }
1779
1780    /// `POST /api/tokens/bootstrap`: the right single-use secret mints a verifiable,
1781    /// recorded first token exactly once; a wrong secret is `401`, a reused one
1782    /// `409`, and a node without a bootstrap secret configured is `501`.
1783    #[tokio::test]
1784    async fn bootstrap_mints_the_first_token_once() {
1785        use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
1786        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1787        let public = keys.public_key();
1788        let deploy = DeployStore::new(
1789            Arc::new(MemStorage::default()),
1790            Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
1791        );
1792        let secret = "s3cr3t-bootstrap-value";
1793        let gate = BootstrapGate::new(Some(secret));
1794        let issuer = Issuer(Some(keys.clone()));
1795        let bearer = |s: &str| {
1796            let mut h = HeaderMap::new();
1797            h.insert(
1798                AUTHORIZATION,
1799                HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
1800            );
1801            h
1802        };
1803        let req = || BootstrapRequest {
1804            roles: vec!["admin".to_string()],
1805            ttl_secs: None,
1806        };
1807
1808        // Wrong secret → 401.
1809        let bad = bootstrap_token(
1810            State(deploy.clone()),
1811            Extension(issuer.clone()),
1812            Extension(gate.clone()),
1813            bearer("wrong"),
1814            Json(req()),
1815        )
1816        .await;
1817        assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
1818
1819        // Correct secret → 201, a token the root key verifies as admin, recorded.
1820        let ok = bootstrap_token(
1821            State(deploy.clone()),
1822            Extension(issuer.clone()),
1823            Extension(gate.clone()),
1824            bearer(secret),
1825            Json(req()),
1826        )
1827        .await;
1828        assert_eq!(ok.status(), StatusCode::CREATED);
1829        let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
1830            .await
1831            .unwrap();
1832        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
1833        let token = json["token"].as_str().unwrap();
1834        let id = json["id"].as_str().unwrap();
1835        let verified = cose::verify(token, &public, now_unix()).unwrap();
1836        assert!(verified.roles.iter().any(|r| r.name == "admin"));
1837        assert!(deploy
1838            .list_token_meta()
1839            .await
1840            .unwrap()
1841            .iter()
1842            .any(|m| m.revocation_id == id));
1843
1844        // Reuse of the same secret → 409 (single-use).
1845        let reuse = bootstrap_token(
1846            State(deploy.clone()),
1847            Extension(issuer.clone()),
1848            Extension(gate),
1849            bearer(secret),
1850            Json(req()),
1851        )
1852        .await;
1853        assert_eq!(reuse.status(), StatusCode::CONFLICT);
1854
1855        // No bootstrap secret configured → 501.
1856        let disabled = bootstrap_token(
1857            State(deploy),
1858            Extension(issuer),
1859            Extension(BootstrapGate(None)),
1860            bearer(secret),
1861            Json(req()),
1862        )
1863        .await;
1864        assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
1865    }
1866
1867    /// `POST /api/cluster/rotate-key` rotates via the control hook and returns the
1868    /// new pubkey; `501` on a non-cluster node.
1869    #[tokio::test]
1870    async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
1871        let control = Arc::new(StubControl {
1872            admits: std::sync::Mutex::new(Vec::new()),
1873            respond: StubJoin::Admit,
1874        });
1875        let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
1876        assert_eq!(resp.status(), StatusCode::OK);
1877        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1878            .await
1879            .unwrap();
1880        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1881        assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
1882
1883        let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
1884        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
1885    }
1886
1887    #[test]
1888    fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
1889        use boatramp_core::security::SecurityProfile;
1890        let strict = SecurityProfile::MultiTenant.preset();
1891        let loose = SecurityProfile::SingleTenant.preset(); // allows private upstreams
1892
1893        let public: IpAddr = "93.184.216.34".parse().unwrap(); // example.com
1894        let private: IpAddr = "10.1.2.3".parse().unwrap();
1895        let loopback: IpAddr = "127.0.0.1".parse().unwrap();
1896        let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
1897
1898        // Strict (multi-tenant): only globally-routable addresses are allowed.
1899        assert!(gateway_addr_allowed(public, &strict));
1900        assert!(!gateway_addr_allowed(private, &strict));
1901        assert!(!gateway_addr_allowed(loopback, &strict));
1902        assert!(!gateway_addr_allowed(metadata, &strict));
1903
1904        // Operator opt-in: private/loopback allowed, but cloud-metadata is still
1905        // refused (defense in depth — it is never a legitimate target).
1906        assert!(gateway_addr_allowed(public, &loose));
1907        assert!(gateway_addr_allowed(private, &loose));
1908        assert!(gateway_addr_allowed(loopback, &loose));
1909        assert!(!gateway_addr_allowed(metadata, &loose));
1910    }
1911
1912    #[test]
1913    fn resolve_env_merges_static_and_host_secrets() {
1914        use boatramp_core::config::HandlersSiteConfig;
1915
1916        // A uniquely-named host var holds the real secret value.
1917        std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
1918
1919        let deploy_env = std::collections::BTreeMap::from([
1920            ("GREETING".to_string(), "hi".to_string()),
1921            ("OVERRIDE_ME".to_string(), "static".to_string()),
1922        ]);
1923        let site_handlers = HandlersSiteConfig {
1924            enabled: true,
1925            secrets: std::collections::BTreeMap::from([
1926                // guest var <- host env var holding the value
1927                (
1928                    "SECRET_TOKEN".to_string(),
1929                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1930                ),
1931                (
1932                    "OVERRIDE_ME".to_string(),
1933                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
1934                ),
1935                (
1936                    "MISSING".to_string(),
1937                    "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
1938                ),
1939            ]),
1940            ..Default::default()
1941        };
1942        let env = resolve_env("blog", &deploy_env, &site_handlers);
1943
1944        // Static var present; secret resolved from the host env; a secret
1945        // overrides a static of the same name; a secret whose host var is unset
1946        // is skipped (never injected as empty).
1947        assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
1948        assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
1949        assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
1950        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
1951
1952        std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
1953    }
1954
1955    fn req() -> Request {
1956        Request::builder()
1957            .uri("/")
1958            .header(header::HOST, "example.com")
1959            .body(Body::empty())
1960            .unwrap()
1961    }
1962
1963    #[test]
1964    fn forwarded_headers_set_standard_triple() {
1965        let mut request = req();
1966        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1967        let h = request.headers();
1968        assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
1969        assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
1970        assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
1971    }
1972
1973    #[test]
1974    fn forwarded_for_overwrites_spoofed_value() {
1975        // A client-supplied X-Forwarded-For must not survive: the host stamps
1976        // the single resolved address, not an attacker-controlled chain.
1977        let mut request = Request::builder()
1978            .uri("/")
1979            .header(header::HOST, "example.com")
1980            .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
1981            .body(Body::empty())
1982            .unwrap();
1983        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
1984        let values: Vec<_> = request
1985            .headers()
1986            .get_all("x-forwarded-for")
1987            .iter()
1988            .collect();
1989        assert_eq!(values.len(), 1);
1990        assert_eq!(values[0], "203.0.113.7");
1991    }
1992
1993    #[test]
1994    fn forwarded_proto_preserves_upstream_tls() {
1995        // A TLS-terminating reverse proxy in front already set https; keep it.
1996        let mut request = Request::builder()
1997            .uri("/")
1998            .header(header::HOST, "example.com")
1999            .header("x-forwarded-proto", "https")
2000            .body(Body::empty())
2001            .unwrap();
2002        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2003        assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2004    }
2005
2006    #[test]
2007    fn forwarded_host_absent_when_no_host_header() {
2008        let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2009        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2010        assert!(request.headers().get("x-forwarded-host").is_none());
2011        assert_eq!(
2012            request.headers().get("x-forwarded-for").unwrap(),
2013            "203.0.113.7"
2014        );
2015    }
2016
2017    // ---- consumer dispatcher (#17) -----------------------------------------
2018
2019    use boatramp_core::kv::{KvStore, MemoryKv};
2020    use boatramp_core::messaging::{LogMessaging, Messaging};
2021    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2022
2023    const EVENT_CONSUMER: &[u8] =
2024        include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2025
2026    #[derive(Default)]
2027    struct MemStorage {
2028        objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2029    }
2030
2031    #[async_trait::async_trait]
2032    impl boatramp_core::Storage for MemStorage {
2033        async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2034            let bytes = self
2035                .objects
2036                .lock()
2037                .unwrap()
2038                .get(key)
2039                .cloned()
2040                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2041            let body: ByteStream =
2042                futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2043            Ok(GetObject {
2044                meta: ObjectMeta {
2045                    key: key.to_string(),
2046                    ..Default::default()
2047                },
2048                body,
2049            })
2050        }
2051        async fn get_range(
2052            &self,
2053            key: &str,
2054            _: u64,
2055            _: Option<u64>,
2056        ) -> Result<GetObject, StorageError> {
2057            self.get(key).await
2058        }
2059        async fn put(
2060            &self,
2061            key: &str,
2062            mut body: ByteStream,
2063            _: PutMeta,
2064        ) -> Result<ObjectMeta, StorageError> {
2065            use futures::StreamExt;
2066            let mut buf = Vec::new();
2067            while let Some(chunk) = body.next().await {
2068                buf.extend_from_slice(&chunk?);
2069            }
2070            self.objects.lock().unwrap().insert(key.to_string(), buf);
2071            Ok(ObjectMeta {
2072                key: key.to_string(),
2073                ..Default::default()
2074            })
2075        }
2076        async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2077            self.objects
2078                .lock()
2079                .unwrap()
2080                .get(key)
2081                .map(|_| ObjectMeta {
2082                    key: key.to_string(),
2083                    ..Default::default()
2084                })
2085                .ok_or_else(|| StorageError::NotFound(key.to_string()))
2086        }
2087        async fn delete(&self, key: &str) -> Result<(), StorageError> {
2088            self.objects.lock().unwrap().remove(key);
2089            Ok(())
2090        }
2091        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2092            Ok(Vec::new())
2093        }
2094    }
2095
2096    /// Build an `ObservedInstance` for the wake-from-zero helper tests.
2097    fn observed_state(
2098        workload: &str,
2099        healthy: bool,
2100        phase: boatramp_core::compute::ReplicaPhase,
2101    ) -> boatramp_core::compute::ObservedInstance {
2102        use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2103        boatramp_core::compute::ObservedInstance {
2104            handle: InstanceHandle {
2105                workload: workload.into(),
2106                replica: 0,
2107                backend_ref: "ref-0".into(),
2108            },
2109            node: 1,
2110            backend: "vmm".into(),
2111            endpoint: Endpoint {
2112                scheme: Scheme::Http,
2113                host: "10.0.0.2".into(),
2114                port: 80,
2115            },
2116            region: None,
2117            healthy,
2118            phase,
2119            snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2120                workload: workload.into(),
2121                replica: 0,
2122                data_ref: "snap-0".into(),
2123            }),
2124        }
2125    }
2126
2127    #[tokio::test]
2128    async fn has_parked_replica_detects_a_zeroed_replica() {
2129        use boatramp_core::compute::ReplicaPhase;
2130        let storage = Arc::new(MemStorage::default());
2131        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2132        let deploy = DeployStore::new(storage, kv);
2133
2134        // Nothing → false.
2135        assert!(!has_parked_replica(&deploy, "w").await);
2136        // A running replica → false (it's serving, not parked).
2137        deploy
2138            .set_replica_state(
2139                ProjectRef::DEFAULT,
2140                &observed_state("w", true, ReplicaPhase::Running),
2141            )
2142            .await
2143            .unwrap();
2144        assert!(!has_parked_replica(&deploy, "w").await);
2145        // A parked (Zero) replica → true (wakeable).
2146        deploy
2147            .set_replica_state(
2148                ProjectRef::DEFAULT,
2149                &observed_state("w", false, ReplicaPhase::Zero),
2150            )
2151            .await
2152            .unwrap();
2153        assert!(has_parked_replica(&deploy, "w").await);
2154    }
2155
2156    #[tokio::test]
2157    async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
2158        use boatramp_core::compute::ReplicaPhase;
2159        let storage = Arc::new(MemStorage::default());
2160        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2161        let deploy = DeployStore::new(storage, kv);
2162
2163        // No healthy replica → times out with an empty pool (short timeout).
2164        let empty = await_warm(&deploy, "w", std::time::Duration::from_millis(150)).await;
2165        assert!(empty.is_empty());
2166
2167        // A healthy replica → returned promptly.
2168        deploy
2169            .set_replica_state(
2170                ProjectRef::DEFAULT,
2171                &observed_state("w", true, ReplicaPhase::Running),
2172            )
2173            .await
2174            .unwrap();
2175        let warm = await_warm(&deploy, "w", std::time::Duration::from_secs(5)).await;
2176        assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
2177    }
2178
2179    /// The delivery gate: a consumer receives every published message at-least-once
2180    /// (acked, counted once each), and a message that keeps failing is
2181    /// redelivered and then dead-lettered after `max_attempts`.
2182    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2183    async fn dispatcher_delivers_at_least_once_then_dead_letters() {
2184        use boatramp_handlers::{Bindings, HandlerEngine, Limits};
2185        let storage = Arc::new(MemStorage::default());
2186        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2187        let mq = LogMessaging::new(storage, kv.clone());
2188        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2189        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2190        let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
2191        let topic = "blog/orders/created";
2192
2193        // Three good messages → each delivered + acked exactly once.
2194        for _ in 0..3 {
2195            mq.publish(topic, b"ok").await.unwrap();
2196        }
2197        loop {
2198            let acked = dispatch_consumer_batch(
2199                &engine,
2200                &mq,
2201                &metrics::Metrics::default(),
2202                "blog",
2203                topic,
2204                "blog/",
2205                &hash,
2206                EVENT_CONSUMER,
2207                &bindings,
2208                Limits::default(),
2209                Duration::from_secs(30),
2210                5,
2211                10,
2212            )
2213            .await;
2214            if acked == 0 {
2215                break;
2216            }
2217        }
2218        assert_eq!(
2219            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2220            Some(b"3".to_vec())
2221        );
2222
2223        // A poison message keeps failing → redelivered, then dead-lettered after
2224        // max_attempts (zero lease makes redelivery immediate).
2225        mq.publish(topic, b"fail").await.unwrap();
2226        for _ in 0..5 {
2227            dispatch_consumer_batch(
2228                &engine,
2229                &mq,
2230                &metrics::Metrics::default(),
2231                "blog",
2232                topic,
2233                "blog/",
2234                &hash,
2235                EVENT_CONSUMER,
2236                &bindings,
2237                Limits::default(),
2238                Duration::ZERO,
2239                2,
2240                10,
2241            )
2242            .await;
2243        }
2244        assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
2245        // The good counter is untouched by the poison message.
2246        assert_eq!(
2247            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2248            Some(b"3".to_vec())
2249        );
2250    }
2251
2252    /// The activation policy: the scheduler runs the **current** deployment's
2253    /// consumers (production namespace `{site}`), but never a preview's — a
2254    /// preview-namespaced message is left untouched.
2255    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2256    async fn scheduler_runs_current_consumers_not_previews() {
2257        use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
2258        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2259        use boatramp_handlers::{HandlerEngine, Limits};
2260        use futures::StreamExt;
2261
2262        let storage = Arc::new(MemStorage::default());
2263        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2264        let deploy = DeployStore::new(storage.clone(), kv.clone());
2265        let messaging: Arc<dyn Messaging> =
2266            Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
2267
2268        // Store the consumer component + a deployment that subscribes to it.
2269        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
2270        let stream: ByteStream =
2271            futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
2272                .boxed();
2273        deploy.put_blob(&hash, stream).await.unwrap();
2274        let mut files = std::collections::BTreeMap::new();
2275        files.insert(
2276            "consumer.wasm".to_string(),
2277            FileEntry {
2278                hash: hash.clone(),
2279                size: EVENT_CONSUMER.len() as u64,
2280                content_type: None,
2281                variants: std::collections::BTreeMap::new(),
2282            },
2283        );
2284        let manifest = Manifest {
2285            files,
2286            config: DeployConfig {
2287                consumers: vec![ConsumerConfig {
2288                    topic: "orders/created".into(),
2289                    component: "consumer.wasm".into(),
2290                    imports: vec!["wasi:keyvalue".into()],
2291                }],
2292                ..Default::default()
2293            },
2294            ..Default::default()
2295        };
2296        let id = deploy.put_manifest(&manifest).await.unwrap();
2297        deploy
2298            .activate(ProjectRef::DEFAULT, "blog", &id)
2299            .await
2300            .unwrap();
2301        deploy
2302            .set_site_config(
2303                ProjectRef::DEFAULT,
2304                "blog",
2305                &SiteConfig {
2306                    handlers: Some(HandlersSiteConfig {
2307                        enabled: true,
2308                        allow_imports: vec!["wasi:keyvalue".into()],
2309                        ..Default::default()
2310                    }),
2311                    ..Default::default()
2312                },
2313            )
2314            .await
2315            .unwrap();
2316
2317        // One message in the production namespace, one in a preview namespace.
2318        messaging
2319            .publish("blog/orders/created", b"live")
2320            .await
2321            .unwrap();
2322        messaging
2323            .publish("blog/_preview/abc/orders/created", b"preview")
2324            .await
2325            .unwrap();
2326
2327        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2328        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
2329        let inner = rt.inner.clone().unwrap();
2330        let mut cache = std::collections::HashMap::new();
2331        let mut crons = std::collections::HashMap::new();
2332        let now = CronNow {
2333            minute: 0,
2334            hour: 0,
2335            dom: 1,
2336            month: 1,
2337            dow: 0,
2338            minute_stamp: 0,
2339        };
2340        for _ in 0..3 {
2341            run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, now)
2342                .await
2343                .unwrap();
2344        }
2345
2346        // The production message was delivered + counted.
2347        assert_eq!(
2348            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
2349            Some(b"1".to_vec())
2350        );
2351        // The preview-namespaced message was never claimed (no background work
2352        // for previews) — its counter doesn't exist.
2353        assert_eq!(
2354            kv.get("hkv/blog/_preview/abc/delivered/orders/created")
2355                .await
2356                .unwrap(),
2357            None
2358        );
2359    }
2360
2361    // ---- cron driver (#18) -------------------------------------------------
2362
2363    /// A `wasi:http` handler that increments `hits` per request (`kv-counter`),
2364    /// used here as a cron target so a fire is observable as a counter bump.
2365    const KV_COUNTER: &[u8] =
2366        include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
2367
2368    /// The function-to-function invoke resolver (FI): a resolvable target runs on
2369    /// the real engine and its response is buffered back + metered; an unknown
2370    /// target is `NotFound`. (The caller-side capability gate — allowlist, depth,
2371    /// deny-by-default — is unit-tested in `boatramp_handlers::bindings::invoke`.)
2372    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2373    async fn function_invoker_runs_target_buffers_and_meters() {
2374        use boatramp_core::deploy::DeployStore;
2375        use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
2376        use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
2377        use futures::StreamExt;
2378
2379        // The committed `http-200` fixture is the invoke *target* (a wasi:http
2380        // guest that returns 200); it needs no fixture of its own to be a callee.
2381        const HTTP_200: &[u8] =
2382            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2383
2384        let storage = Arc::new(MemStorage::default());
2385        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2386        let deploy = DeployStore::new(storage.clone(), kv.clone());
2387
2388        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2389        let stream: ByteStream =
2390            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2391        deploy.put_blob(&hash, stream).await.unwrap();
2392        let function = Function {
2393            name: "target".into(),
2394            owner: Owner::Project("default".into()),
2395            versions: vec![FunctionVersion {
2396                id: "v1".into(),
2397                component: hash.clone(),
2398                created: 0,
2399                lifecycle: Lifecycle::Independent,
2400            }],
2401            active: "v1".into(),
2402            aliases: Default::default(),
2403            config: Default::default(),
2404        };
2405        deploy
2406            .put_function(ProjectRef::DEFAULT, &function)
2407            .await
2408            .unwrap();
2409
2410        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2411        let rt = HandlerRuntime::new(engine, kv, storage, None, None);
2412        rt.set_invoker(deploy.clone());
2413        let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
2414
2415        let request = || InvokeRequest {
2416            method: "GET".into(),
2417            path: "/".into(),
2418            headers: vec![],
2419            body: vec![],
2420        };
2421
2422        // A resolvable target runs on the engine and returns its 200.
2423        let response = invoker.invoke("target", request(), 1).await.unwrap();
2424        assert_eq!(response.status, 200);
2425
2426        // The call was metered against the target function.
2427        let metering = deploy
2428            .get_metering(ProjectRef::DEFAULT, "target")
2429            .await
2430            .unwrap()
2431            .unwrap();
2432        assert_eq!(metering.invocations, 1);
2433
2434        // An unknown target is NotFound (never reaches the engine).
2435        let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
2436        assert!(matches!(err, InvokeError::NotFound));
2437    }
2438
2439    /// The supergraph runner backing the `graphql` capability, driven end-to-end through a real
2440    /// runtime: the safelist is the deny-by-default operation floor, and only a safelisted op
2441    /// reaches planning. (The host-side grant + depth cap are unit-tested in
2442    /// `boatramp_handlers::bindings::graphql`; stitching + bearer forwarding + depth dispatch in
2443    /// `graphql_gateway`.)
2444    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2445    async fn federation_runner_enforces_the_safelist_before_planning() {
2446        use boatramp_core::deploy::DeployStore;
2447        use boatramp_core::project::ProjectRef;
2448        use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
2449
2450        let storage = Arc::new(MemStorage::default());
2451        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2452        let deploy = DeployStore::new(storage.clone(), kv.clone());
2453        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2454        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2455        rt.set_invoker(deploy.clone());
2456        let runner = rt
2457            .inner
2458            .as_ref()
2459            .unwrap()
2460            .federation_runner
2461            .get()
2462            .unwrap()
2463            .scoped(ProjectRef::new("default"));
2464
2465        let req = |query: &str| GraphqlRequest {
2466            query: Some(query.to_string()),
2467            persisted_hash: None,
2468            variables: "{}".to_string(),
2469            operation_name: None,
2470            authorization: None,
2471        };
2472
2473        // A query that was never registered is refused (deny-by-default) before any planning.
2474        assert!(matches!(
2475            runner.run(req("{ me { id } }"), 1).await,
2476            Err(SupergraphRunError::NotSafelisted)
2477        ));
2478
2479        // Register it in the safelist (any writer of the APQ store) — now it passes the floor and
2480        // reaches planning; against an empty supergraph the plan fails (proving the gate opened).
2481        let query = "{ me { id } }";
2482        let hash = crate::graphql_apq::sha256_hex(query);
2483        kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
2484            .await
2485            .unwrap();
2486        assert!(matches!(
2487            runner.run(req(query), 1).await,
2488            Err(SupergraphRunError::PlanFailed(_))
2489        ));
2490
2491        // A run-persisted with an unregistered hash is refused the same way.
2492        let persisted = GraphqlRequest {
2493            query: None,
2494            persisted_hash: Some("deadbeef".to_string()),
2495            variables: "{}".to_string(),
2496            operation_name: None,
2497            authorization: None,
2498        };
2499        assert!(matches!(
2500            runner.run(persisted, 1).await,
2501            Err(SupergraphRunError::NotSafelisted)
2502        ));
2503    }
2504
2505    /// Tenant isolation (Step 7a): the background scheduler fans out over every
2506    /// project, so a **non-default** project's queued async invocation is drained
2507    /// and metered **within that project** — never leaking into `default`. Before
2508    /// the fan-out the tick only ever scanned `default`, so an `acme` function's
2509    /// queue would never drain at all.
2510    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2511    async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
2512        use crate::scheduler::{run_scheduler_tick, CronNow};
2513        use boatramp_core::deploy::DeployStore;
2514        use boatramp_core::function::{
2515            Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
2516        };
2517        use boatramp_handlers::{HandlerEngine, Limits};
2518        use futures::StreamExt;
2519
2520        const HTTP_200: &[u8] =
2521            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
2522
2523        let storage = Arc::new(MemStorage::default());
2524        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2525        let deploy = DeployStore::new(storage.clone(), kv.clone());
2526
2527        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
2528        let stream: ByteStream =
2529            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
2530        deploy.put_blob(&hash, stream).await.unwrap();
2531
2532        // A function + a queued async invocation, both under project `acme`.
2533        let acme = ProjectRef::new("acme");
2534        let function = Function {
2535            name: "worker".into(),
2536            owner: Owner::Project("acme".into()),
2537            versions: vec![FunctionVersion {
2538                id: "v1".into(),
2539                component: hash.clone(),
2540                created: 0,
2541                lifecycle: Lifecycle::Independent,
2542            }],
2543            active: "v1".into(),
2544            aliases: Default::default(),
2545            config: Default::default(),
2546        };
2547        deploy.put_function(acme, &function).await.unwrap();
2548        let inv = Invocation {
2549            id: "inv1".into(),
2550            function: "worker".into(),
2551            version: "v1".into(),
2552            mode: InvokeMode::Async,
2553            status: InvocationStatus::Queued,
2554            idempotency_key: None,
2555            attempts: 0,
2556            request_b64: None,
2557            request_content_type: None,
2558            result: None,
2559            created: 0,
2560            updated: 0,
2561        };
2562        deploy.put_invocation(acme, &inv).await.unwrap();
2563
2564        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2565        let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
2566        let inner = rt.inner.as_ref().unwrap();
2567
2568        // One tick: `discover_projects()` yields `["acme"]`, so the drain runs
2569        // under `acme`. A fixed `CronNow` (no cron to match) keeps it deterministic.
2570        let mut wasm_cache = std::collections::HashMap::new();
2571        let mut cron_state = std::collections::HashMap::new();
2572        let now = CronNow {
2573            minute: 0,
2574            hour: 0,
2575            dom: 1,
2576            month: 1,
2577            dow: 0,
2578            minute_stamp: 0,
2579        };
2580        run_scheduler_tick(inner, &deploy, &mut wasm_cache, &mut cron_state, now)
2581            .await
2582            .unwrap();
2583
2584        // The invocation settled Succeeded **in `acme`** …
2585        let settled = deploy
2586            .get_invocation(acme, "worker", "inv1")
2587            .await
2588            .unwrap()
2589            .expect("invocation still present in acme");
2590        assert_eq!(settled.status, InvocationStatus::Succeeded);
2591        // … metered in `acme` …
2592        let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
2593        assert_eq!(metering.invocations, 1);
2594        // … and nothing leaked into `default` (no record, no metering there).
2595        assert!(deploy
2596            .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
2597            .await
2598            .unwrap()
2599            .is_none());
2600        assert!(deploy
2601            .get_metering(ProjectRef::DEFAULT, "worker")
2602            .await
2603            .unwrap()
2604            .is_none());
2605    }
2606
2607    /// BR-TEN-1 (Critical) gate: a same-named **function** in two tenant
2608    /// projects must NOT share one guest kv namespace. Two functions both named
2609    /// `store` — one in `acme`, one in `globex` — each writes to guest kv key
2610    /// `hits` (via the committed `kv-counter` fixture, whose default bucket key
2611    /// is `hits`). We assert the writes land under DISTINCT host kv keys
2612    /// (`hkv/acme/fn/store/hits` vs `hkv/globex/fn/store/hits`) and that neither
2613    /// aliases the bare pre-project key (`hkv/fn/store/hits`). A third `store`
2614    /// under the reserved `default` project is asserted to keep exactly that bare
2615    /// key (back-compat: no data migration for a pre-project store).
2616    ///
2617    /// This is a real end-to-end kv-isolation assertion driven through the live
2618    /// engine (`execute_function`) with the existing `kv-counter` fixture — the
2619    /// preferred form over unit-testing scope construction — because that
2620    /// exercises the actual `build_function_bindings` scope path a guest sees.
2621    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2622    async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
2623        use boatramp_core::deploy::DeployStore;
2624        use boatramp_core::function::{
2625            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
2626        };
2627        use boatramp_handlers::{HandlerEngine, Limits};
2628        use futures::StreamExt;
2629
2630        let storage = Arc::new(MemStorage::default());
2631        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2632        let deploy = DeployStore::new(storage.clone(), kv.clone());
2633
2634        // The `kv-counter` fixture increments a "hits" counter in its default kv
2635        // bucket, so a single invocation writes `<scope>/hits`.
2636        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2637        let stream: ByteStream =
2638            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2639        deploy.put_blob(&hash, stream).await.unwrap();
2640
2641        // A single `store` function definition (imports `wasi:keyvalue`); the
2642        // guest binding scope comes from the `project` passed to
2643        // `execute_function`, not from the function's `owner`, so one definition
2644        // suffices to prove per-tenant scoping.
2645        let store = Function {
2646            name: "store".into(),
2647            owner: Owner::Project("default".into()),
2648            versions: vec![FunctionVersion {
2649                id: "v1".into(),
2650                component: hash.clone(),
2651                created: 0,
2652                lifecycle: Lifecycle::Independent,
2653            }],
2654            active: "v1".into(),
2655            aliases: Default::default(),
2656            config: FunctionConfig {
2657                imports: vec!["wasi:keyvalue".into()],
2658                ..Default::default()
2659            },
2660        };
2661        let acme = ProjectRef::new("acme");
2662        let globex = ProjectRef::new("globex");
2663
2664        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2665        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2666        let inner = rt.inner.as_ref().unwrap();
2667
2668        let request = || {
2669            axum::http::Request::builder()
2670                .method("GET")
2671                .uri("/")
2672                .body(axum::body::Body::empty())
2673                .unwrap()
2674        };
2675
2676        // Invoke `store` in each of the two non-default projects, plus once in
2677        // `default`, all named identically.
2678        let component = store.resolve(&store.active).unwrap().to_owned();
2679        for project in [acme, globex, ProjectRef::DEFAULT] {
2680            let (response, _) =
2681                execute_function(inner, &deploy, project, &store, &component, request(), 0).await;
2682            assert!(response.status().is_success(), "invocation should succeed");
2683        }
2684
2685        // The three writes landed under THREE distinct host kv keys: the two
2686        // tenants are project-qualified, and `default` keeps the bare key.
2687        assert_eq!(
2688            kv.get("hkv/acme/fn/store/hits").await.unwrap(),
2689            Some(b"1".to_vec()),
2690            "acme's write must be tenant-qualified"
2691        );
2692        assert_eq!(
2693            kv.get("hkv/globex/fn/store/hits").await.unwrap(),
2694            Some(b"1".to_vec()),
2695            "globex's write must be tenant-qualified"
2696        );
2697        assert_eq!(
2698            kv.get("hkv/fn/store/hits").await.unwrap(),
2699            Some(b"1".to_vec()),
2700            "the default project must keep the byte-identical pre-project key"
2701        );
2702        // Sanity: had the fix regressed, all three would have collided on the
2703        // bare key and it would read "3", not "1".
2704    }
2705
2706    /// The cron driver: a due cron fires its route (loopback), once per
2707    /// matching minute (dedup), and with `overlap: Skip` a fire is skipped while
2708    /// a previous one is still running.
2709    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2710    async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
2711        use boatramp_core::config::{
2712            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2713        };
2714        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2715        use boatramp_handlers::{HandlerEngine, Limits};
2716        use futures::StreamExt;
2717        use std::sync::atomic::Ordering;
2718
2719        let storage = Arc::new(MemStorage::default());
2720        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2721        let deploy = DeployStore::new(storage.clone(), kv.clone());
2722
2723        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2724        let stream: ByteStream =
2725            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2726        deploy.put_blob(&hash, stream).await.unwrap();
2727        let mut files = std::collections::BTreeMap::new();
2728        files.insert(
2729            "counter.wasm".to_string(),
2730            FileEntry {
2731                hash: hash.clone(),
2732                size: KV_COUNTER.len() as u64,
2733                content_type: None,
2734                variants: std::collections::BTreeMap::new(),
2735            },
2736        );
2737        let manifest = Manifest {
2738            files,
2739            config: DeployConfig {
2740                handlers: vec![HandlerConfig {
2741                    route: "/".into(),
2742                    methods: Vec::new(),
2743                    component: "counter.wasm".into(),
2744                    imports: vec!["wasi:keyvalue".into()],
2745                    limits: None,
2746                    env: std::collections::BTreeMap::new(),
2747                    invoke_targets: Vec::new(),
2748                }],
2749                crons: vec![CronConfig {
2750                    schedule: "* * * * *".into(),
2751                    route: "/".into(),
2752                    overlap: Overlap::Skip,
2753                }],
2754                ..Default::default()
2755            },
2756            ..Default::default()
2757        };
2758        let id = deploy.put_manifest(&manifest).await.unwrap();
2759        deploy
2760            .activate(ProjectRef::DEFAULT, "blog", &id)
2761            .await
2762            .unwrap();
2763        deploy
2764            .set_site_config(
2765                ProjectRef::DEFAULT,
2766                "blog",
2767                &SiteConfig {
2768                    handlers: Some(HandlersSiteConfig {
2769                        enabled: true,
2770                        allow_imports: vec!["wasi:keyvalue".into()],
2771                        ..Default::default()
2772                    }),
2773                    ..Default::default()
2774                },
2775            )
2776            .await
2777            .unwrap();
2778
2779        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2780        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2781        let inner = rt.inner.clone().unwrap();
2782        let mut wasm = std::collections::HashMap::new();
2783        let mut crons = std::collections::HashMap::new();
2784        let at = |stamp| CronNow {
2785            minute: 0,
2786            hour: 0,
2787            dom: 1,
2788            month: 1,
2789            dow: 0,
2790            minute_stamp: stamp,
2791        };
2792
2793        // Fires once for the minute.
2794        let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2795            .await
2796            .unwrap();
2797        for h in handles {
2798            h.await.unwrap();
2799        }
2800        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2801
2802        // Same minute → deduped (no fire).
2803        let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(100))
2804            .await
2805            .unwrap();
2806        assert!(handles.is_empty());
2807        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
2808
2809        // Next minute → fires again.
2810        let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(101))
2811            .await
2812            .unwrap();
2813        for h in handles {
2814            h.await.unwrap();
2815        }
2816        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2817
2818        // overlap=Skip: a previous fire still running → the next minute is skipped.
2819        // The cron dedup key is project-qualified (`default|blog|cron|0`) so a
2820        // same-named site in another project can't dedup this one.
2821        crons
2822            .get("default|blog|cron|0")
2823            .unwrap()
2824            .running
2825            .store(true, Ordering::Release);
2826        let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, at(102))
2827            .await
2828            .unwrap();
2829        assert!(handles.is_empty());
2830        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
2831    }
2832
2833    /// Cluster cron single-firing: with a leader gate that
2834    /// returns `false` (this node is not the leader), the scheduler fires **no**
2835    /// crons — so a cron fires on exactly one node cluster-wide.
2836    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
2837    async fn cron_leader_gate_suppresses_crons_off_leader() {
2838        use boatramp_core::config::{
2839            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
2840        };
2841        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
2842        use boatramp_handlers::{HandlerEngine, Limits};
2843        use futures::StreamExt;
2844
2845        let storage = Arc::new(MemStorage::default());
2846        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2847        let deploy = DeployStore::new(storage.clone(), kv.clone());
2848
2849        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
2850        let stream: ByteStream =
2851            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
2852        deploy.put_blob(&hash, stream).await.unwrap();
2853        let mut files = std::collections::BTreeMap::new();
2854        files.insert(
2855            "counter.wasm".to_string(),
2856            FileEntry {
2857                hash: hash.clone(),
2858                size: KV_COUNTER.len() as u64,
2859                content_type: None,
2860                variants: std::collections::BTreeMap::new(),
2861            },
2862        );
2863        let manifest = Manifest {
2864            files,
2865            config: DeployConfig {
2866                handlers: vec![HandlerConfig {
2867                    route: "/".into(),
2868                    methods: Vec::new(),
2869                    component: "counter.wasm".into(),
2870                    imports: vec!["wasi:keyvalue".into()],
2871                    limits: None,
2872                    env: std::collections::BTreeMap::new(),
2873                    invoke_targets: Vec::new(),
2874                }],
2875                crons: vec![CronConfig {
2876                    schedule: "* * * * *".into(),
2877                    route: "/".into(),
2878                    overlap: Overlap::Skip,
2879                }],
2880                ..Default::default()
2881            },
2882            ..Default::default()
2883        };
2884        let id = deploy.put_manifest(&manifest).await.unwrap();
2885        deploy
2886            .activate(ProjectRef::DEFAULT, "blog", &id)
2887            .await
2888            .unwrap();
2889        deploy
2890            .set_site_config(
2891                ProjectRef::DEFAULT,
2892                "blog",
2893                &SiteConfig {
2894                    handlers: Some(HandlersSiteConfig {
2895                        enabled: true,
2896                        allow_imports: vec!["wasi:keyvalue".into()],
2897                        ..Default::default()
2898                    }),
2899                    ..Default::default()
2900                },
2901            )
2902            .await
2903            .unwrap();
2904
2905        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2906        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
2907        // This node is "not the leader" — gate returns false.
2908        rt.set_cron_leader_gate(Arc::new(|| false));
2909        let inner = rt.inner.clone().unwrap();
2910        let mut wasm = std::collections::HashMap::new();
2911        let mut crons = std::collections::HashMap::new();
2912        let now = CronNow {
2913            minute: 0,
2914            hour: 0,
2915            dom: 1,
2916            month: 1,
2917            dow: 0,
2918            minute_stamp: 100,
2919        };
2920
2921        let (_, handles) = run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, now)
2922            .await
2923            .unwrap();
2924        // No cron fired (a follower); the counter was never written.
2925        assert!(handles.is_empty(), "a non-leader must not fire crons");
2926        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
2927    }
2928
2929    /// Named SQL binding dispatch through the real `build_bindings` + a real (libsql) provider:
2930    /// the granted databases in the resulting `Bindings` are exactly what the per-handler grant
2931    /// grammar allows, with the site as the ceiling. This is the config→dispatch→backends half of
2932    /// the tenant-isolation story (the guest-open half is the binding layer's
2933    /// `two_named_databases_are_independent`; a full guest `open("named")` e2e needs a wasm
2934    /// fixture and is a live-validation follow-up).
2935    #[tokio::test]
2936    async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
2937        use boatramp_core::config::HandlersSiteConfig;
2938        use boatramp_core::project::ProjectRef;
2939        use boatramp_handlers::{HandlerEngine, Limits};
2940
2941        let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
2942        let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
2943        // A real per-site libsql provider (opens a distinct database per name).
2944        let sql_dir =
2945            std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
2946        let _ = std::fs::remove_dir_all(&sql_dir);
2947        let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
2948            Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
2949
2950        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
2951        let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
2952        let inner = rt.inner.as_ref().unwrap();
2953
2954        // The site exposes the default + two named databases — the ceiling.
2955        let site = HandlersSiteConfig {
2956            enabled: true,
2957            allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
2958            ..Default::default()
2959        };
2960        let env = std::collections::BTreeMap::new();
2961        let build = |imports: &[&str]| {
2962            let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
2963            let site = &site;
2964            let env = &env;
2965            async move {
2966                crate::handler_dispatch::build_bindings(
2967                    inner,
2968                    ProjectRef::new("default"),
2969                    "shop",
2970                    "shop",
2971                    None,
2972                    &imports,
2973                    site,
2974                    env,
2975                    &[],
2976                    0,
2977                    None,
2978                )
2979                .await
2980                .sql_database_names()
2981            }
2982        };
2983
2984        // Least-privilege: a handler asking only for the default + product gets exactly those —
2985        // never `privileged`, even though the site exposes it.
2986        assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
2987        // A wildcard handler gets every name the site exposes (default via bare `sql` + all named).
2988        assert_eq!(
2989            build(&["sql", "sql:*"]).await,
2990            vec!["", "privileged", "product"]
2991        );
2992        // Fail-closed: requesting a name the site does not expose grants nothing.
2993        assert!(build(&["sql:secret"]).await.is_empty());
2994        // No bare `sql` → the default `""` database is not granted either.
2995        assert_eq!(build(&["sql:product"]).await, vec!["product"]);
2996
2997        let _ = std::fs::remove_dir_all(&sql_dir);
2998    }
2999}