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