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