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