Skip to main content

boatramp_server/
lib.rs

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