Skip to main content

boatramp_server/
lib.rs

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