Skip to main content

boatramp_server/
lib.rs

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