Skip to main content

boatramp_server/
lib.rs

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