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