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