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