Skip to main content

boatramp_server/
lib.rs

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