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