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