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    /// The operator ceiling (seconds) on a guest-minted capability's TTL (R5,
418    /// PLAN-delegable-capabilities). Set at startup **only when** the `allow_guest_mint_capability`
419    /// posture is on and a positive ceiling is configured; **unset ⇒ guest capability minting is not
420    /// offered** (the `capability` binding is never attached). The fleet signer is reused from
421    /// [`session_signer`](Self::session_signer).
422    #[cfg(feature = "capability")]
423    capability_max_ttl_secs: std::sync::OnceLock<u64>,
424    /// Per-project overrides (Gap 4a) of the resolved tenancy/capability knobs, project name →
425    /// resolved knobs (base posture ⊕ the operator's `[security.projects.<p>]` override). Consulted
426    /// at each in-project enforcement point via [`HandlerRuntimeInner::project_tenancy_knobs`];
427    /// **unset / a project not listed ⇒ the node base** (today's behavior). Set once at startup.
428    #[cfg(feature = "handlers")]
429    tenancy_posture_overrides: std::sync::OnceLock<
430        Arc<std::collections::BTreeMap<String, boatramp_core::security::ResolvedProjectTenancy>>,
431    >,
432}
433
434#[cfg(feature = "handlers")]
435impl HandlerRuntimeInner {
436    /// Resolve the tenancy/capability knobs for `project` (Gap 4a): the operator's per-project
437    /// override if one was declared, else the node base. The lookup key is the **host-routed**
438    /// project (never guest input), so it can't be spoofed. Consulted at every in-project
439    /// enforcement point (tenancy-declaration + cross-tenant `all` in `build_bindings` /
440    /// `function_runtime`; the guest capability-mint gate).
441    pub(crate) fn project_tenancy_knobs(
442        &self,
443        project: &str,
444    ) -> boatramp_core::security::ResolvedProjectTenancy {
445        if let Some(map) = self.tenancy_posture_overrides.get() {
446            if let Some(knobs) = map.get(project) {
447                return *knobs;
448            }
449        }
450        boatramp_core::security::ResolvedProjectTenancy {
451            require_tenancy_declaration: self
452                .require_tenancy_declaration
453                .get()
454                .copied()
455                .unwrap_or(true),
456            allow_cross_tenant_db: self.allow_cross_tenant_db.get().copied().unwrap_or(false),
457            #[cfg(feature = "capability")]
458            capability_max_ttl_secs: self.capability_max_ttl_secs.get().copied(),
459            #[cfg(not(feature = "capability"))]
460            capability_max_ttl_secs: None,
461        }
462    }
463}
464
465/// Predicate gating cron firing to the cluster leader (see
466/// [`HandlerRuntime::set_cron_leader_gate`]).
467pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
468
469impl HandlerRuntime {
470    /// An empty runtime — handler dispatch disabled (the static path is unchanged).
471    pub fn disabled() -> Self {
472        Self::default()
473    }
474
475    /// Build a runtime over `engine`. The `wasi:keyvalue` / `wasi:blobstore`
476    /// bindings are served from the server's own `kv` / `storage` backends (each
477    /// namespaced per site); `sql`, if a provider is given, serves a per-site
478    /// database (the default `""` database). `sql: None` means the `sql`
479    /// capability is not offered.
480    #[cfg(feature = "handlers")]
481    pub fn new(
482        engine: boatramp_handlers::HandlerEngine,
483        kv: Arc<dyn boatramp_core::kv::KvStore>,
484        storage: Arc<dyn boatramp_core::Storage>,
485        sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
486        messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
487    ) -> Self {
488        // Size the async drain gate to the engine's async-lane concurrency
489        // (read before `engine` is moved into the runtime).
490        let async_drain_slots = engine.async_max_concurrency().max(1);
491        Self {
492            inner: Some(Arc::new(HandlerRuntimeInner {
493                engine,
494                async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
495                kv,
496                storage,
497                sql,
498                messaging,
499                site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
500                stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
501                stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
502                metrics: metrics::Metrics::default(),
503                logs: Arc::new(logs::LogStore::default()),
504                #[cfg(feature = "handlers")]
505                graphql_cache: graphql_cache::GraphqlCache::default(),
506                cron_leader_gate: std::sync::OnceLock::new(),
507                max_blob_bytes: std::sync::OnceLock::new(),
508                max_component_bytes: std::sync::OnceLock::new(),
509                allow_env_secret_refs: std::sync::OnceLock::new(),
510                require_tenancy_declaration: std::sync::OnceLock::new(),
511                allow_cross_tenant_db: std::sync::OnceLock::new(),
512                secret_store: std::sync::OnceLock::new(),
513                function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
514                function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
515                watch_provider: std::sync::OnceLock::new(),
516                provision_tier: std::sync::OnceLock::new(),
517                invoker: std::sync::OnceLock::new(),
518                federation_runner: std::sync::OnceLock::new(),
519                #[cfg(feature = "email")]
520                email_profile_store: std::sync::OnceLock::new(),
521                #[cfg(feature = "email")]
522                email_spool: std::sync::OnceLock::new(),
523                #[cfg(feature = "admin")]
524                admin_controller: std::sync::OnceLock::new(),
525                #[cfg(feature = "admin")]
526                admin_surfaces: std::sync::OnceLock::new(),
527                #[cfg(feature = "session")]
528                session_store: std::sync::OnceLock::new(),
529                session_signer: std::sync::OnceLock::new(),
530                #[cfg(feature = "capability")]
531                capability_max_ttl_secs: std::sync::OnceLock::new(),
532                #[cfg(feature = "handlers")]
533                tenancy_posture_overrides: std::sync::OnceLock::new(),
534            })),
535        }
536    }
537
538    /// The per-site SQL database provider, if one is configured. Lets the control plane
539    /// introspect a site's database (e.g. to generate a SQL federation subgraph's SDL).
540    #[cfg(feature = "handlers")]
541    pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
542        self.inner.as_ref().and_then(|inner| inner.sql.clone())
543    }
544
545    /// The function invoker, if wired (set at serve startup). Lets the control plane run a
546    /// deployed function in-process — e.g. to introspect a function subgraph's SDL via its
547    /// federation `_service { sdl }` field when registering it.
548    #[cfg(feature = "handlers")]
549    pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
550        self.inner
551            .as_ref()
552            .and_then(|inner| inner.invoker.get().cloned())
553    }
554
555    /// Introspect a **specific component's** federation SDL by running `{ _service { sdl } }`
556    /// against it — targeting a pending (not-yet-active) version so a subgraph redeploy can be
557    /// composed-checked before it goes live. `Unavailable` if this node has no wasm engine.
558    #[cfg(feature = "handlers")]
559    pub(crate) async fn introspect_subgraph_sdl(
560        &self,
561        deploy: &DeployStore,
562        project: boatramp_core::project::ProjectRef<'_>,
563        function: &boatramp_core::function::Function,
564        component: &str,
565    ) -> Result<String, function_runtime::SubgraphSdlError> {
566        match self.inner.as_ref() {
567            Some(inner) => {
568                function_runtime::introspect_service_sdl(
569                    inner, deploy, project, function, component,
570                )
571                .await
572            }
573            None => Err(function_runtime::SubgraphSdlError::Unavailable),
574        }
575    }
576
577    /// Wire the function-to-function invoke resolver (FI). Set once at startup,
578    /// after the deploy store exists: it holds a `Weak` back to this runtime plus
579    /// the deploy store, so a function granted `invoke` can resolve + run a
580    /// sibling in-process. A no-op runtime (no `inner`) leaves it unset, and the
581    /// `invoke` capability is then simply never granted.
582    #[cfg(feature = "handlers")]
583    pub fn set_invoker(&self, deploy: DeployStore) {
584        if let Some(inner) = self.inner.as_ref() {
585            let invoker = Arc::new(function_runtime::FunctionInvoker::new(
586                deploy,
587                Arc::downgrade(inner),
588            ));
589            let _ = inner.invoker.set(invoker);
590            // The supergraph runner shares the same self-referential `Weak`; it reaches the
591            // invoker (set above) to dispatch a guest run's sub-fetches in-process.
592            let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
593                inner,
594            )));
595            let _ = inner.federation_runner.set(runner);
596        }
597    }
598
599    /// The per-site SQL provider, if the `sql` capability is offered. Backs the
600    /// compute sql-shim (PLAN-compute-bindings) so an opaque workload reaches the
601    /// same tenant-scoped database a handler does.
602    #[cfg(feature = "handlers")]
603    pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
604        self.inner.as_ref().and_then(|inner| inner.sql.clone())
605    }
606
607    /// Wire the cloud blob-change notification provisioner (FA-5b2). Set once at
608    /// startup when the storage backend is a cloud object store; a no-op runtime,
609    /// or a self-watching backend (fs), leaves it unset.
610    #[cfg(feature = "handlers")]
611    pub fn set_watch_provider(
612        &self,
613        provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
614    ) {
615        if let Some(inner) = self.inner.as_ref() {
616            let _ = inner.watch_provider.set(provider);
617        }
618    }
619
620    /// Set the operator provisioning tier for the
621    /// [`watch_provider`](Self::set_watch_provider). Set once at startup; unset is
622    /// the fail-closed `Refuse`.
623    #[cfg(feature = "handlers")]
624    pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
625        if let Some(inner) = self.inner.as_ref() {
626            let _ = inner.provision_tier.set(tier);
627        }
628    }
629
630    /// Cap the bytes a `wasi:blobstore` host read/range/copy may buffer (`0` =
631    /// unlimited), from the security posture. Set once at startup; a
632    /// no-op runtime ignores it.
633    #[cfg(feature = "handlers")]
634    pub fn set_max_blob_bytes(&self, max_bytes: u64) {
635        if let Some(inner) = self.inner.as_ref() {
636            let _ = inner.max_blob_bytes.set(max_bytes);
637        }
638    }
639
640    /// Cap the size of a Wasm component blob accepted at activation (`0` =
641    /// unlimited), from the security posture. Set once at startup.
642    #[cfg(feature = "handlers")]
643    pub fn set_max_component_bytes(&self, max_bytes: u64) {
644        if let Some(inner) = self.inner.as_ref() {
645            let _ = inner.max_component_bytes.set(max_bytes);
646        }
647    }
648
649    /// Permit (or forbid) resolving a **bare** / `env:`-scheme secret ref against
650    /// the serve process's own environment, from the security posture's
651    /// `allow_env_secret_refs`. Set once at startup; **unset reads as `false`**
652    /// (fail-closed), so a runtime that never wired the posture refuses host-env
653    /// refs rather than leaking them. Under the multi-tenant posture (`false`) an
654    /// untrusted tenant's `secrets` map can no longer name an arbitrary host env
655    /// var to exfiltrate it into their guest.
656    #[cfg(feature = "handlers")]
657    pub fn set_allow_env_secret_refs(&self, allow: bool) {
658        if let Some(inner) = self.inner.as_ref() {
659            let _ = inner.allow_env_secret_refs.set(allow);
660        }
661    }
662
663    /// Wire the in-site tenancy posture (Stage 0): `require_declaration` (a sql/orm importer must
664    /// declare an explicit tenancy decision) and `allow_cross_tenant` (an `all` grant may reach
665    /// across tenants). Set once at startup; **unset reads fail-closed** (`require = true`,
666    /// `allow_cross_tenant = false`).
667    #[cfg(feature = "handlers")]
668    pub fn set_tenancy_posture(&self, require_declaration: bool, allow_cross_tenant: bool) {
669        if let Some(inner) = self.inner.as_ref() {
670            let _ = inner.require_tenancy_declaration.set(require_declaration);
671            let _ = inner.allow_cross_tenant_db.set(allow_cross_tenant);
672        }
673    }
674
675    /// Wire per-project tenancy/capability posture overrides (Gap 4a): project name → the resolved
676    /// knobs (fleet base ⊕ the operator's `[security.projects.<p>]` override). A project not in the
677    /// map uses the node base ([`set_tenancy_posture`](Self::set_tenancy_posture) +
678    /// [`set_capability_minting`](Self::set_capability_minting)). Set once at startup; a per-project
679    /// override tunes only that project's own in-project isolation + guest capability-mint ceiling
680    /// (cross-project isolation is structural, never a knob). No-op on a plain runtime / empty map.
681    #[cfg(feature = "handlers")]
682    pub fn set_project_tenancy_overrides(
683        &self,
684        overrides: std::collections::BTreeMap<
685            String,
686            boatramp_core::security::ResolvedProjectTenancy,
687        >,
688    ) {
689        if overrides.is_empty() {
690            return;
691        }
692        if let Some(inner) = self.inner.as_ref() {
693            let _ = inner.tenancy_posture_overrides.set(Arc::new(overrides));
694        }
695    }
696
697    /// Wire the fleet [`Signer`] used to mint + verify anonymous session cookies (R3). Pass the
698    /// node's issuing signer (typically the same `issuer` that mints control-plane tokens); its
699    /// public half becomes the session-cookie verify anchor. Set once at startup; **unset ⇒ the
700    /// host issues/verifies no session cookies** (the `Session` scope axis stays dormant — a
701    /// project's `TenantOrSession` reads then carry only the tenant arm). No-op on a plain runtime.
702    #[cfg(feature = "handlers")]
703    pub fn set_session_signer(&self, signer: Arc<dyn Signer>) {
704        if let Some(inner) = self.inner.as_ref() {
705            let _ = inner.session_signer.set(signer);
706        }
707    }
708
709    /// Enable guest capability minting (`boatramp:handlers/capability`, PLAN-delegable-capabilities)
710    /// with an operator TTL ceiling (seconds). Call **only when** the `allow_guest_mint_capability`
711    /// posture is on and `max_ttl_secs > 0`; unset (or `0`) ⇒ the `capability` binding is never
712    /// attached and a guest `mint` returns `access-denied`. Minting reuses the fleet
713    /// [`set_session_signer`](Self::set_session_signer) key (a capability is verified against the same
714    /// anchor), so wire that too. No-op on a plain runtime.
715    #[cfg(feature = "capability")]
716    pub fn set_capability_minting(&self, max_ttl_secs: u64) {
717        if max_ttl_secs == 0 {
718            return;
719        }
720        if let Some(inner) = self.inner.as_ref() {
721            let _ = inner.capability_max_ttl_secs.set(max_ttl_secs);
722        }
723    }
724
725    /// Wire the project-scoped internal secret store (sealed with the `[secrets]`
726    /// envelope) that backs the `boatramp:<name>` secret-ref scheme. Set once at
727    /// startup; if never set, a `boatramp:` ref cannot resolve and
728    /// `resolve_secret_env` errors fail-closed rather than injecting a value.
729    #[cfg(feature = "handlers")]
730    pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
731        if let Some(inner) = self.inner.as_ref() {
732            let _ = inner.secret_store.set(store);
733        }
734    }
735
736    /// Wire the per-project SMTP email-profile store that backs the `email`
737    /// capability's host-side profile resolution (same sealed store the admin API
738    /// exposes redacted). Set once at startup; unset ⇒ no profile resolves.
739    #[cfg(feature = "email")]
740    pub fn set_email_profile_store(
741        &self,
742        store: Arc<boatramp_core::email_config::EmailProfileStore>,
743    ) {
744        if let Some(inner) = self.inner.as_ref() {
745            let _ = inner.email_profile_store.set(store);
746        }
747    }
748
749    /// Wire the shared node email delivery spool backing the `email` capability.
750    /// Called at startup **only when the `allow_guest_email` posture permits guest
751    /// email**, so leaving it unset (posture off, or no spool) makes email
752    /// unavailable and a granted guest's `send` returns `access-denied`.
753    #[cfg(feature = "email")]
754    pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
755        if let Some(inner) = self.inner.as_ref() {
756            let _ = inner.email_spool.set(spool);
757        }
758    }
759
760    /// Wire the guest `admin` capability: the server controller + the operator-enabled surface
761    /// set (the posture-on subset). Called at startup only when at least one
762    /// `allow_guest_admin_*` posture bit is on; unset ⇒ admin is not offered and a granted
763    /// guest's calls return `access-denied`. A guest's grant is intersected with `surfaces`.
764    #[cfg(feature = "admin")]
765    pub fn set_admin(
766        &self,
767        controller: Arc<admin_controller::ServerAdminController>,
768        surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
769    ) {
770        if let Some(inner) = self.inner.as_ref() {
771            let _ = inner.admin_controller.set(controller);
772            let _ = inner.admin_surfaces.set(surfaces);
773        }
774    }
775
776    /// The resolved `allow_env_secret_refs` posture bool (fail-closed `false` if
777    /// unset, or if there is no runtime). Lets the function deploy-admission path
778    /// refuse a forbidden `secrets` map at deploy time — the same gate the
779    /// resolution-time backstop enforces.
780    #[cfg(feature = "handlers")]
781    pub(crate) fn allow_env_secret_refs(&self) -> bool {
782        self.inner
783            .as_ref()
784            .and_then(|inner| inner.allow_env_secret_refs.get().copied())
785            .unwrap_or(false)
786    }
787
788    /// Gate cron firing on a predicate (cluster mode: the node is the Raft
789    /// leader), so a cron fires exactly once cluster-wide.
790    /// Set once at startup; a no-op runtime ignores it. Consumers are never
791    /// gated (leased dispatch already distributes them).
792    #[cfg(feature = "handlers")]
793    pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
794        if let Some(inner) = self.inner.as_ref() {
795            let _ = inner.cron_leader_gate.set(gate);
796        }
797    }
798
799    /// Pre-activation gate: refuse to flip a deployment
800    /// whose handlers can't be satisfied — the site must enable handlers and
801    /// allow each requested import (the resolution rule), and every component
802    /// must compile (so a broken component never goes live; this also pre-warms
803    /// the cache). `Err(reason)` means "do not activate". A no-op for deploys
804    /// with no handlers, or without the `handlers` feature/runtime.
805    #[cfg(feature = "handlers")]
806    async fn precheck_activation(
807        &self,
808        deploy: &DeployStore,
809        manifest: &Manifest,
810        site_config: Option<&SiteConfig>,
811    ) -> Result<(), String> {
812        let Some(inner) = self.inner.as_ref() else {
813            return Ok(());
814        };
815        // Consumer-only deploys must be prechecked too: skip only
816        // when neither handlers nor consumers ship.
817        if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
818            return Ok(());
819        }
820        // A deploy that ships handlers or consumers requires the site to enable them.
821        let site_handlers = site_config
822            .and_then(|c| c.handlers.as_ref())
823            .filter(|h| h.enabled)
824            .ok_or_else(|| {
825                "deployment ships handlers/consumers but the site has them disabled".to_string()
826            })?;
827        let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
828
829        // Fail loud at deploy on a `secrets` map the posture forbids: under the
830        // multi-tenant posture a bare / `env:` ref reads the operator's environment
831        // (cross-tenant host-env exfiltration), so refuse the activation with the
832        // same message the resolution-time backstop would raise — the tenant sees
833        // the failure now, not at first request. Uses the runtime's resolved
834        // `allow_env_secret_refs` (fail-closed if never wired).
835        let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
836        crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
837            .map_err(|err| format!("handler secrets: {err}"))?;
838
839        // Sync-timeout footgun: a handler/site timeout above the sync ceiling is
840        // silently clamped for connection-bearing (sync HTTP) calls, so a legit
841        // long call dies as a mysterious runtime 504. Warn loudly at deploy. The
842        // same value is valid for the async lane (`?mode=async` / triggers), clamped
843        // to the larger async ceiling — so this is a warning, not a refusal.
844        let sync_ceiling = inner.engine.sync_timeout_ms();
845        let async_ceiling = inner.engine.async_timeout_ms();
846        if let Some(ms) = site_handlers.max_timeout_ms {
847            if u64::from(ms) > sync_ceiling {
848                tracing::warn!(
849                    "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
850                     synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
851                     applies only to async calls (?mode=async / triggers), capped at \
852                     async_max_timeout_ms={async_ceiling}"
853                );
854            }
855        }
856
857        // Same import/size/compile gate for every handler and consumer component.
858        for handler in &manifest.config.handlers {
859            if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
860                if u64::from(ms) > sync_ceiling {
861                    let route = &handler.route;
862                    tracing::warn!(
863                        "route {route:?} declares limits.timeout_ms={ms}, above \
864                         sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
865                         are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
866                         (?mode=async / a queue trigger / a #[consumer]), capped at \
867                         async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
868                         that isn't possible — move the work to the async lane"
869                    );
870                }
871            }
872            // A guest that self-declares a streaming handler (`#[handler(stream)]`) but whose
873            // config doesn't mark the route `streaming` would run on the tight sync request lane
874            // and be cut at the sync timeout — a silent footgun for a long-lived SSE/agent stream.
875            // Warn (don't block) so the operator sets `streaming = true` for the dedicated lane.
876            if !handler.streaming {
877                if let Some(entry) = manifest.files.get(&handler.component) {
878                    if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
879                        if crate::function_api::component_declares_streaming_route(
880                            &bytes,
881                            &handler.route,
882                        ) {
883                            let route = &handler.route;
884                            tracing::warn!(
885                                "route {route:?} is a streaming handler (#[handler(stream)]) but \
886                                 its config lacks streaming = true: it will run on the sync request \
887                                 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
888                                 streaming = true so it serves on the dedicated streaming lane (its \
889                                 own concurrency budget + a much larger wall-clock)."
890                            );
891                        }
892                    }
893                }
894            }
895            // Fail loud at deploy if the guest's function-manifest `requires` a capability feature
896            // this host build does not implement — availability lives in metadata, not the linkable
897            // WIT (PLAN v2). A clear message beats an opaque runtime failure later.
898            if let Some(entry) = manifest.files.get(&handler.component) {
899                if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
900                    let unmet = crate::function_api::unmet_requires(&bytes);
901                    if !unmet.is_empty() {
902                        return Err(format!(
903                            "route {:?} [{}] requires capabilities this host does not implement: \
904                             {}. Upgrade boatramp or enable those features — see `boatramp \
905                             capabilities`.",
906                            handler.route,
907                            handler.methods.join(","),
908                            unmet.join(", ")
909                        ));
910                    }
911                }
912            }
913            precheck_component(
914                deploy,
915                manifest,
916                site_handlers,
917                inner,
918                max_component,
919                &handler.imports,
920                &handler.component,
921                // Name route + methods (matching the client-side validator), so with
922                // one component on several routes the operator sees which is at fault.
923                &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
924                false,
925            )
926            .await?;
927        }
928        for consumer in &manifest.config.consumers {
929            precheck_component(
930                deploy,
931                manifest,
932                site_handlers,
933                inner,
934                max_component,
935                &consumer.imports,
936                &consumer.component,
937                &format!("consumer {:?}", consumer.topic),
938                true,
939            )
940            .await?;
941        }
942        Ok(())
943    }
944
945    #[cfg(not(feature = "handlers"))]
946    async fn precheck_activation(
947        &self,
948        _deploy: &DeployStore,
949        _manifest: &Manifest,
950        _site_config: Option<&SiteConfig>,
951    ) -> Result<(), String> {
952        Ok(())
953    }
954}
955
956/// Server runtime knobs that aren't part of the core (deploy, auth, handlers)
957/// triple: operational request [`limits`](ServerLimits) and an optional custom
958/// domain-ownership [`DomainProbe`] (defaults to the live network probe).
959///
960/// [`DomainProbe`]: boatramp_core::domain_verify::DomainProbe
961#[derive(Default, Clone)]
962pub struct ServerOptions {
963    /// Operational upload limits (size / idle / concurrency).
964    pub limits: ServerLimits,
965    /// Domain-ownership probe override (tests inject a scripted one); `None`
966    /// uses the live HTTP/DNS probe.
967    pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
968    /// Site to serve for a `Host` that matches no domain, instead of `404`.
969    /// `None` keeps the 404 default.
970    pub default_site: Option<String>,
971    /// Resolve an unmatched `Host` to a site **without** an explicit domain
972    /// registration — by first host label (`<site>.host`), or, when exactly one
973    /// site is served, as the sole site. The effective gate (posture knob OR a
974    /// loopback bind), computed by `serve`. `false` (the default) keeps the
975    /// strict behavior: an unmatched host resolves only to `default_site` or 404.
976    pub implicit_routing: bool,
977    /// Require a valid control-plane token to view a deployment **preview**
978    /// (`/_deploy/<id>/…` and `<id>.deploy.<host>`) — the
979    /// `previews.protect` setting. Off by default (previews are unguessable capability
980    /// URLs).
981    pub protect_previews: bool,
982    /// When set, rate limiting uses a **cluster-wide** KV-backed fixed-window
983    /// counter over this store instead of the per-node in-process buckets.
984    /// Pass the shared/replicated KV (e.g. the cluster `RaftKv`).
985    pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
986    /// The token signer (root private key / KMS / HSM), when this node issues
987    /// tokens (the `/api/tokens` create route and the OIDC→token exchange).
988    /// `None` ⇒ verify-only.
989    pub issuer: Option<Arc<dyn Signer>>,
990    /// An operator-set, single-use **bootstrap secret** enabling the
991    /// `POST /api/tokens/bootstrap` first-token route. `None` ⇒ that route returns
992    /// `501`. Compared by SHA-256, single-use (rotating the secret re-enables it);
993    /// unset once bootstrapped.
994    pub bootstrap_secret: Option<String>,
995    /// A **bootstrap-TLS identity attestation** (base64url `COSE_Sign1`) served at
996    /// `GET /.well-known/boatramp-bootstrap-identity` — the root key vouching for
997    /// this node's `--tls rpk` control-plane TLS public key, so a client pinning
998    /// only the root key can learn + pin the TLS identity. Set by `serve` under
999    /// `--tls rpk` when an issuer is present; `None` ⇒ the route returns `404`.
1000    pub bootstrap_attestation: Option<String>,
1001    /// The cluster mesh control hook, wired in cluster mode over
1002    /// `ClusterNode`. Backs `POST /api/cluster/join` + `/rotate-key`; `None`
1003    /// (single-node) ⇒ those routes return `501`.
1004    pub mesh_control: Option<Arc<dyn MeshControl>>,
1005    /// Origins allowed to call the control-plane `/api/*` routes cross-origin
1006    /// (CORS). Empty (the default) ⇒ no `Access-Control-*` headers at all, i.e.
1007    /// same-origin only — which is exactly the dogfood console, served from the
1008    /// same origin as the API. Set this to host the console (or any browser
1009    /// client) on a *different* origin: each entry is an exact
1010    /// `scheme://host[:port]` (e.g. `https://console.example.com`), or `*` to
1011    /// allow any origin. The API authenticates with a Bearer token (not cookies),
1012    /// so credentials are not enabled; the matched origin is echoed back with
1013    /// `Vary: Origin`, and a preflight `OPTIONS` is answered before auth runs.
1014    pub cors_allowed_origins: Vec<String>,
1015    /// The OIDC verifier for `/api/auth/exchange` (validates the IdP JWT before
1016    /// minting a token). Only with the `oidc` feature + an issuer key.
1017    #[cfg(feature = "oidc")]
1018    pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
1019    /// The resolved operator security posture (the hardening knobs).
1020    /// Carried as an extension so the gateway, proxy, domain-verify, and upload
1021    /// paths can consult it. Defaults to the strict `multi-tenant` preset.
1022    pub posture: boatramp_core::security::SecurityPosture,
1023    /// Whether this server's listener terminates TLS (the connection scheme is
1024    /// `https`). Set by `serve` from the TLS mode; used to derive the request
1025    /// scheme when `X-Forwarded-Proto` can't be trusted. Default
1026    /// `false` (plain HTTP).
1027    pub served_over_tls: bool,
1028    /// The fleet's **canonical public origin** (e.g. `https://cp.example.com`) that
1029    /// a per-request PoP proof must be bound to (`aud`). Set from `[serve]
1030    /// pop_origin` in `boatramp.cfg`. Compared against a proof's bound origin —
1031    /// **never** derived from a `Host`/`X-Forwarded-*` header. A holder-bound
1032    /// (`cnf`) token cannot be used against a server that has not configured this
1033    /// (its proof can't be verified, so the request is rejected).
1034    pub pop_origin: Option<String>,
1035    /// A pre-built dynamic daemon-config runtime. `serve` supplies one (built via
1036    /// [`config_baseline`] + [`DaemonRuntime::new`]) so it can wake it on
1037    /// SIGHUP / changelog; `None` (tests, embedders) ⇒ the router builds its own.
1038    pub daemon_runtime: Option<Arc<DaemonRuntime>>,
1039    /// Operator SQL capability for managed databases (migrations / queries via the
1040    /// sealed credential, resolved server-side). Backs `POST /api/sql/{db}/{exec,query}`;
1041    /// `None` ⇒ those routes return `501`. Wired by the node when a managed DB exists.
1042    pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
1043    /// Tenant-deprovision capability: drops a deleted tenant's managed databases +
1044    /// roles + sealed credentials on project/site delete. `None` ⇒ delete does no
1045    /// managed-DB teardown. Wired by the node when a compute-backed managed database
1046    /// exists; the delete handlers call it best-effort after the store delete.
1047    pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
1048    /// Operator compute-exec capability (run a command inside a running workload).
1049    /// Backs `POST /api/compute/{name}/exec`; `None` ⇒ `501`. Gated at the handler by
1050    /// the `allow_compute_exec` posture. Wired by the node with the compute backends.
1051    pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
1052    /// Operator volume-reclamation capability (list + remove persistent volumes).
1053    /// Backs `GET /api/compute/volumes` + `DELETE /api/compute/volumes/{name}`;
1054    /// `None` ⇒ `501`. Admin-scoped (the deny-safe `/api/compute/*` default). Wired
1055    /// by the node with the compute backends.
1056    pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
1057    /// Operator reconcile-plane control capability (restart a replica). Backs
1058    /// `POST /api/compute/maintenance/restart`; `None` ⇒ `501`. Admin-scoped
1059    /// (`is_compute_maintenance_path`). Wired by the node with the compute backends.
1060    pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
1061    /// The project-scoped internal secret store (sealed with the `[secrets]`
1062    /// envelope). Backs the admin secrets API (`/api/projects/{proj}/secrets{,/{name}}`,
1063    /// rewritten onto `/api/secrets{,/{name}}`) — set/list/delete of names + metadata,
1064    /// **never** values. `None` ⇒ no `[secrets]` envelope was configured, and every
1065    /// secrets endpoint returns a clear `501` ("no key envelope configured"), never a
1066    /// panic. Not `handlers`-gated: `SecretStore` lives in boatramp-core, so the admin
1067    /// API works on a lean node too. Wired by the node alongside the envelope.
1068    pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
1069    /// The per-project SMTP email-profile store backing the admin API
1070    /// (`/api/email/profiles{,/{name}}`) — set/list/show/delete of a profile's
1071    /// **redacted** config (the password is never returned). `None` ⇒ no
1072    /// `[secrets]` envelope was configured, and the email endpoints fail closed with
1073    /// a clear `501`. Not `handlers`-gated: `EmailProfileStore` lives in
1074    /// boatramp-core, so the admin API works on a lean node. Wired by the node
1075    /// alongside the envelope, like [`secret_store`](Self::secret_store).
1076    pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
1077    /// The embedded web-console mount (`[serve.console]`), when the operator
1078    /// enabled it and the binary was built with the `console` feature. `None` ⇒
1079    /// not served. The static SPA is served unauthenticated at this host+path.
1080    #[cfg(feature = "console")]
1081    pub console: Option<console::ConsoleMount>,
1082}
1083
1084/// The listener's own connection scheme (`true` = `https`), carried as an
1085/// extension so the serving path can derive the scheme without trusting a
1086/// forged `X-Forwarded-Proto` from a direct client.
1087#[derive(Clone, Copy)]
1088struct ServedOverTls(bool);
1089
1090/// Whether the host fallback may resolve an unmatched `Host` to a site without an
1091/// explicit domain registration (first-label `<site>.host`, or the sole served
1092/// site). Carried as an extension; the effective gate is resolved by `serve`
1093/// (posture knob OR loopback bind). `false` = strict (default_site or 404 only).
1094#[derive(Clone, Copy, Default)]
1095struct ImplicitRouting(bool);
1096
1097/// Holds the live, resolved [`EffectiveConfig`] (`file baseline ⊕ dynamic
1098/// overrides`) plus the active generation hash. Request handlers read the current
1099/// operational values through [`effective`](Self::effective); the daemon-config
1100/// API and the SIGHUP handler [`reload`](Self::reload) it from the store, so a
1101/// change converges without a restart.
1102/// Defensive backstop interval for re-resolving the dynamic daemon config.
1103/// Convergence is **fully notification-driven** — a local write applies
1104/// immediately; a SIGHUP, a shared-store changelog invalidation of `daemon/*`, or
1105/// a Raft apply of a replicated `daemon/*` write each wakes an immediate reload via
1106/// [`DaemonRuntime::notify_reload`]. This long tick is only a safety net against a
1107/// missed wake; it is not the convergence mechanism.
1108const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
1109
1110pub struct DaemonRuntime {
1111    baseline: boatramp_core::daemon_config::ConfigBaseline,
1112    state: std::sync::RwLock<DaemonState>,
1113    /// Woken (by SIGHUP / changelog / a local write) to trigger an immediate
1114    /// reload instead of waiting for the backstop tick.
1115    reload: tokio::sync::Notify,
1116}
1117
1118struct DaemonState {
1119    effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
1120    generation: Option<String>,
1121}
1122
1123/// The daemon-config file baseline derived from [`ServerOptions`] (the resolved
1124/// `boatramp.cfg`). `serve` uses this to build a [`DaemonRuntime`] it can wake on
1125/// SIGHUP/changelog; the posture's upload cap is the ceiling a dynamic override
1126/// may not exceed.
1127pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
1128    // The static `[serve.console]` mount is the baseline the dynamic
1129    // `DaemonConfig.console` override layers over. `Some(mount)` ⇒ enabled at the
1130    // file level; without the `console` feature there is nothing to serve.
1131    #[cfg(feature = "console")]
1132    let (console_enabled, console_host, console_path) = match options.console.as_ref() {
1133        Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
1134        None => (false, None, None),
1135    };
1136    #[cfg(not(feature = "console"))]
1137    let (console_enabled, console_host, console_path) = (false, None, None);
1138    boatramp_core::daemon_config::ConfigBaseline {
1139        default_site: options.default_site.clone(),
1140        protect_previews: options.protect_previews,
1141        max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
1142        upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
1143        max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
1144        cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
1145        compute_vcpus: 0,
1146        compute_mem_mib: 0,
1147        console_enabled,
1148        console_host,
1149        console_path,
1150        max_upload_ceiling: options.posture.max_upload_bytes,
1151        max_concurrent_uploads_ceiling: None,
1152        posture: options.posture,
1153    }
1154}
1155
1156impl DaemonRuntime {
1157    /// Build with the file baseline; the effective config starts equal to the
1158    /// baseline (no dynamic override) until [`reload`](Self::reload) runs. `serve`
1159    /// builds this (via [`config_baseline`]) so it can wake it on SIGHUP/changelog.
1160    pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1161        let effective =
1162            Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1163        Self {
1164            baseline,
1165            state: std::sync::RwLock::new(DaemonState {
1166                effective,
1167                generation: None,
1168            }),
1169            reload: tokio::sync::Notify::new(),
1170        }
1171    }
1172
1173    /// Wake an immediate re-resolve from the store. Called by the SIGHUP handler,
1174    /// the shared-store changelog poller (when a `daemon/*` key changed), and after
1175    /// a local write — so convergence is push-driven, not poll-driven.
1176    pub fn notify_reload(&self) {
1177        self.reload.notify_one();
1178    }
1179
1180    /// The current effective operational config.
1181    pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1182        self.state
1183            .read()
1184            .expect("daemon config lock")
1185            .effective
1186            .clone()
1187    }
1188
1189    /// The active generation hash (the `daemon/current` content address), or
1190    /// `None` when running on the pure file baseline.
1191    pub fn generation(&self) -> Option<String> {
1192        self.state
1193            .read()
1194            .expect("daemon config lock")
1195            .generation
1196            .clone()
1197    }
1198
1199    /// The file baseline (+ static ceilings) a write is validated against.
1200    pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1201        &self.baseline
1202    }
1203
1204    /// Re-resolve `baseline ⊕ stored dynamic config` and hot-swap the live values.
1205    /// Called after a write and on SIGHUP.
1206    pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1207        let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1208        let generation = deploy.daemon_config_generation().await?;
1209        let effective = Arc::new(cfg.resolve(&self.baseline));
1210        *self.state.write().expect("daemon config lock") = DaemonState {
1211            effective,
1212            generation,
1213        };
1214        Ok(())
1215    }
1216}
1217
1218/// Preview-access policy, carried as an extension so the preview handlers can
1219/// require a token when `protect` is set.
1220#[derive(Clone, Copy, Default)]
1221struct PreviewPolicy {
1222    protect: bool,
1223}
1224
1225/// The token issuing signer (root private key / KMS / HSM), carried as an
1226/// extension for the token-create and OIDC-exchange handlers. `None` ⇒ this node
1227/// verifies tokens but does not issue them (it has only the public key); issuing
1228/// routes return `501`.
1229#[derive(Clone, Default)]
1230struct Issuer(Option<Arc<dyn Signer>>);
1231
1232/// The first-token bootstrap gate: the SHA-256 hex of the operator-set bootstrap
1233/// secret plus an in-process lock that serializes the check-and-spend (the KV has
1234/// no compare-and-set; a persisted marker keeps it single-use across restarts).
1235/// `None` ⇒ bootstrap disabled (the route returns `501`).
1236#[derive(Clone, Default)]
1237struct BootstrapGate(Option<Arc<BootstrapInner>>);
1238
1239struct BootstrapInner {
1240    /// SHA-256 hex of the configured secret — used for both the constant-work
1241    /// comparison and the single-use marker key.
1242    secret_hash: String,
1243    /// Serializes the read-marker → mint → write-marker section so two concurrent
1244    /// redemptions can't both mint.
1245    lock: tokio::sync::Mutex<()>,
1246}
1247
1248impl BootstrapGate {
1249    fn new(secret: Option<&str>) -> Self {
1250        Self(secret.filter(|s| !s.is_empty()).map(|s| {
1251            Arc::new(BootstrapInner {
1252                secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1253                lock: tokio::sync::Mutex::new(()),
1254            })
1255        }))
1256    }
1257}
1258
1259/// The cluster mesh control operations exposed to the control-plane API,
1260/// implemented by the cluster runtime over `ClusterNode`;
1261/// `None` on a non-cluster node (the routes then return `501`).
1262#[async_trait::async_trait]
1263pub trait MeshControl: Send + Sync {
1264    /// Admit a joining node presenting a bearer join token whose single-use handle
1265    /// is `jti`: **verify the possession proof** (`possession_proof` over
1266    /// `cose::join_challenge(jti, mesh_pubkey_hex, proof_iat)`, fresh at `now`)
1267    /// against `mesh_pubkey_hex`, then — if valid and the token isn't spent — trust
1268    /// the key cluster-wide, add it to membership (id derived from the key), and
1269    /// return the current members as **root-signed** assertions. `Err` is a
1270    /// human-readable failure (e.g. this node has no root key to vouch for members).
1271    async fn admit(
1272        &self,
1273        mesh_pubkey_hex: &str,
1274        jti: &str,
1275        possession_proof: &[u8],
1276        proof_iat: u64,
1277        now: u64,
1278        advertise_addr: Option<&str>,
1279    ) -> Result<JoinOutcome, String>;
1280
1281    /// Rotate **this node's** mesh identity (make-before-break) and return the new
1282    /// public key (SPKI hex). Node-local: only the node itself can mint + persist
1283    /// its private key, so this rotates the key of the node whose API is hit.
1284    async fn rotate_key(&self) -> Result<String, String>;
1285
1286    /// Revoke `node` from the mesh: delete its trust cluster-wide (so it can no
1287    /// longer authenticate) and drop it from the quorum. `Err` is a
1288    /// human-readable failure.
1289    async fn revoke(&self, node: u64) -> Result<(), String>;
1290
1291    /// The current Raft membership (voters + learners), for the Kubernetes
1292    /// operator's membership reconciler. `caught_up` is meaningful only on the
1293    /// leader; hit the leader for a promote decision.
1294    async fn members(&self) -> Result<Vec<MeshMember>, String>;
1295
1296    /// Promote a caught-up learner `node` to a voter (leader-only; a no-op on a
1297    /// follower). `Err` is a human-readable failure.
1298    async fn promote(&self, node: u64) -> Result<(), String>;
1299}
1300
1301/// The result of a join admission ([`MeshControl::admit`]).
1302pub enum JoinOutcome {
1303    /// Admitted — carries the current members as root-signed assertions plus the
1304    /// advisory `node_id -> mesh URL` routing for them.
1305    Admitted {
1306        /// Root-signed member assertions the joiner verifies against the anchor.
1307        members: Vec<String>,
1308        /// Advisory `node_id -> mesh URL` routing (not signed).
1309        addrs: std::collections::BTreeMap<u64, String>,
1310    },
1311    /// The join token was already spent (single-use) → `409`.
1312    TokenSpent,
1313    /// The possession proof was missing/stale/invalid → `403`.
1314    ProofInvalid,
1315    /// The presented key is revoked (a durable tombstone bars it, F6) — an
1316    /// explicit un-revoke is required before it can rejoin → `403`.
1317    Revoked,
1318}
1319
1320/// One node's Raft membership, reported by `GET /api/cluster/members`.
1321#[derive(Debug, Clone, Serialize)]
1322pub struct MeshMember {
1323    /// The node id.
1324    pub node: u64,
1325    /// `true` ⇒ a voter (counts toward quorum); `false` ⇒ a learner.
1326    pub voter: bool,
1327    /// Whether a learner has caught up to the leader's log (ready to promote).
1328    pub caught_up: bool,
1329    /// Whether this node is the current leader.
1330    pub leader: bool,
1331    /// The node's advisory mesh URL, if this node knows it — the address-primary
1332    /// handle `cluster status`/`remove` use (dynamic-join learns addresses at
1333    /// admit; a static-genesis node has them from config). `None` ⇒ unknown here.
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub addr: Option<String>,
1336}
1337
1338/// The mesh control hook, carried as an extension for the join/rotate handlers.
1339/// `None` ⇒ this node is not a cluster node, so those routes return `501`.
1340#[derive(Clone, Default)]
1341struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1342
1343/// The OIDC verifier for the exchange endpoint, carried as an extension.
1344#[cfg(feature = "oidc")]
1345#[derive(Clone, Default)]
1346struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1347
1348/// TTL for an OIDC-exchanged token: short, since the holder can re-exchange
1349/// against the IdP at any time.
1350#[cfg(feature = "oidc")]
1351const EXCHANGE_TTL_SECS: u64 = 3600;
1352
1353use boatramp_core::time::now_unix;
1354
1355/// The configured CORS allowlist, carried as middleware state for the API.
1356#[derive(Clone)]
1357struct CorsState(Arc<Vec<String>>);
1358
1359/// Methods the control-plane API exposes; advertised in a preflight response.
1360const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1361/// Request headers a browser client needs (Bearer auth + JSON bodies); the
1362/// fallback when a preflight doesn't list `Access-Control-Request-Headers`.
1363const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1364/// How long a browser may cache a preflight result (seconds).
1365const CORS_MAX_AGE: &str = "600";
1366
1367/// Whether `origin` is permitted by the configured allowlist. `*` allows any
1368/// origin (the specific origin is still echoed back, with `Vary: Origin`);
1369/// otherwise the match is an exact `scheme://host[:port]` comparison.
1370fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1371    allowed.iter().any(|a| a == "*" || a == origin)
1372}
1373
1374/// Opt-in CORS for the control-plane `/api/*` routes (see
1375/// [`ServerOptions::cors_allowed_origins`]). Answers a preflight `OPTIONS`
1376/// itself — before the auth layer, since a preflight carries no credentials —
1377/// and, for an allowed `Origin`, echoes `Access-Control-Allow-Origin` plus
1378/// `Vary: Origin` onto the response. A disallowed/absent origin gets no
1379/// `Access-Control-*` headers, so the browser blocks the cross-origin read.
1380async fn cors(
1381    State(allowed): State<CorsState>,
1382    request: Request,
1383    next: axum::middleware::Next,
1384) -> Response {
1385    let origin = request
1386        .headers()
1387        .get(header::ORIGIN)
1388        .and_then(|v| v.to_str().ok())
1389        .filter(|o| cors_origin_allowed(&allowed.0, o))
1390        .map(str::to_string);
1391    // A CORS preflight is an OPTIONS carrying `Access-Control-Request-Method`.
1392    let is_preflight = request.method() == Method::OPTIONS
1393        && request
1394            .headers()
1395            .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1396    if is_preflight {
1397        // Echo the browser's requested headers when present, else our known set.
1398        let allow_headers = request
1399            .headers()
1400            .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1401            .and_then(|v| v.to_str().ok())
1402            .map(str::to_string)
1403            .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1404        let mut response = Response::new(Body::empty());
1405        *response.status_mut() = StatusCode::NO_CONTENT;
1406        if let Some(origin) = origin {
1407            let headers = response.headers_mut();
1408            set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1409            set_header(headers, header::VARY, "Origin");
1410            set_header(
1411                headers,
1412                header::ACCESS_CONTROL_ALLOW_METHODS,
1413                CORS_ALLOW_METHODS,
1414            );
1415            set_header(
1416                headers,
1417                header::ACCESS_CONTROL_ALLOW_HEADERS,
1418                &allow_headers,
1419            );
1420            set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1421        }
1422        return response;
1423    }
1424    let mut response = next.run(request).await;
1425    if let Some(origin) = origin {
1426        let headers = response.headers_mut();
1427        set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1428        // `Vary: Origin` so a shared cache can't serve one origin's CORS
1429        // response to another; appended so any existing `Vary` is preserved.
1430        if let Ok(value) = HeaderValue::from_str("Origin") {
1431            headers.append(header::VARY, value);
1432        }
1433    }
1434    response
1435}
1436
1437/// How long the shutdown drain may run before the listener is forced closed.
1438/// Generous enough for any in-flight handler invocation to finish (each is
1439/// itself bounded by the engine's epoch timeout); it only caps stuck or
1440/// abusive connections so a SIGTERM can't hang forever.
1441const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1442
1443/// A failure starting or running the HTTP server.
1444#[derive(Debug, thiserror::Error)]
1445pub enum ServeError {
1446    /// Binding the listener, or an axum serve I/O error.
1447    #[error("server I/O: {0}")]
1448    Io(#[from] std::io::Error),
1449}
1450
1451/// Bind `addr` and serve until a shutdown signal (Ctrl-C / SIGTERM), then drain
1452/// in-flight requests under [`DRAIN_DEADLINE`]. Default [`ServerOptions`].
1453pub async fn serve(
1454    addr: SocketAddr,
1455    deploy: DeployStore,
1456    auth: Auth,
1457    handlers: HandlerRuntime,
1458) -> Result<(), ServeError> {
1459    serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1460}
1461
1462/// Disable Nagle's algorithm on an accepted connection.
1463///
1464/// Without `TCP_NODELAY`, small HTTP responses on **keep-alive** connections stall
1465/// on Nagle's algorithm interacting with the peer's delayed ACK — a fixed ~40 ms
1466/// per request. That is boatramp's hot path in production: on Fly and Cloudflare
1467/// the platform terminates TLS and forwards **plaintext** HTTP to the app over
1468/// persistent connections, so the stall would hit every small response. This runs
1469/// on each accepted stream via [`axum::serve::ListenerExt::tap_io`]; it is
1470/// best-effort — a failure only forfeits the latency win, never the connection.
1471pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1472    if let Err(err) = stream.set_nodelay(true) {
1473        tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1474    }
1475}
1476
1477/// [`serve`] with explicit [`ServerOptions`] (e.g. operational request limits).
1478pub async fn serve_with(
1479    addr: SocketAddr,
1480    deploy: DeployStore,
1481    auth: Auth,
1482    handlers: HandlerRuntime,
1483    options: ServerOptions,
1484) -> Result<(), ServeError> {
1485    let tcp = tokio::net::TcpListener::bind(addr).await?;
1486    tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1487    // Context for the Linux `splice()` reverse-proxy fast-path: the store, the
1488    // resolved posture (SSRF gate), and a live read of the catch-all `default_site`
1489    // (so host resolution matches the serving pipeline). The daemon runtime is the
1490    // one `serve` supplies (shared with the router); absent it, the fast-path just
1491    // falls back for default-site hosts.
1492    let splice_ctx = splice::SpliceCtx {
1493        deploy: deploy.clone(),
1494        posture: options.posture,
1495        daemon: options.daemon_runtime.clone(),
1496    };
1497    // Background scheduler: drives consumers/crons for active deployments
1498    // (no-op without the handlers feature/runtime). Aborted after the drain.
1499    #[cfg(feature = "handlers")]
1500    let scheduler = handlers.spawn_scheduler(deploy.clone());
1501    // Background gateway active-health prober: probes the
1502    // backends of upstreams with `active_health` so a dead one leaves rotation
1503    // before client traffic. Idle until a request arms an upstream.
1504    let gateway_prober = gateway::spawn_active_health_prober();
1505    // Connect-info make-service so handlers can see the peer address (for IP
1506    // rules / rate limiting / access logs).
1507    let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1508
1509    // The graceful drain begins when the OS signal fires; `signalled` flips at
1510    // that instant so the drain deadline is measured from the signal, not from
1511    // server start.
1512    let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1513    // The splice serve loop intercepts eligible plaintext reverse-proxy connections
1514    // (Linux) and serves everything else through `boatramp-http` — an eligible plain
1515    // site GET/HEAD via the `fast` hot-path bypass, the rest through the full `router`.
1516    let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1517        shutdown_signal().await;
1518        let _ = signalled_tx.send(true);
1519    });
1520    let signalled = {
1521        let mut rx = signalled_rx;
1522        async move {
1523            let _ = rx.wait_for(|fired| *fired).await;
1524        }
1525    };
1526    let result = serve_with_drain_deadline(
1527        async move { server.await.map_err(ServeError::from) },
1528        signalled,
1529        DRAIN_DEADLINE,
1530    )
1531    .await;
1532    // Stop the scheduler once the server has drained.
1533    #[cfg(feature = "handlers")]
1534    if let Some(handle) = scheduler {
1535        handle.abort();
1536    }
1537    gateway_prober.abort();
1538    result
1539}
1540
1541/// Run the graceful-serve future `server`, but if the drain runs longer than
1542/// `deadline` *after* `signalled` resolves, stop waiting and return (dropping
1543/// `server`, which closes any still-open connections). Pulled out of [`serve`]
1544/// so the deadline behaviour is unit-testable without sockets or real signals.
1545async fn serve_with_drain_deadline<Srv, Sig>(
1546    server: Srv,
1547    signalled: Sig,
1548    deadline: Duration,
1549) -> Result<(), ServeError>
1550where
1551    Srv: Future<Output = Result<(), ServeError>>,
1552    Sig: Future<Output = ()>,
1553{
1554    tokio::pin!(server);
1555    let drain_cap = async move {
1556        signalled.await;
1557        tokio::time::sleep(deadline).await;
1558    };
1559    tokio::select! {
1560        result = &mut server => result,
1561        _ = drain_cap => {
1562            tracing::warn!(
1563                deadline_s = deadline.as_secs(),
1564                "drain deadline exceeded; forcing shutdown with requests still in flight"
1565            );
1566            Ok(())
1567        }
1568    }
1569}
1570
1571/// Resolve when the process receives Ctrl-C or SIGTERM, so in-flight requests
1572/// can drain before exit.
1573pub async fn shutdown_signal() {
1574    let ctrl_c = async {
1575        let _ = tokio::signal::ctrl_c().await;
1576    };
1577    #[cfg(unix)]
1578    let terminate = async {
1579        if let Ok(mut sig) =
1580            tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1581        {
1582            sig.recv().await;
1583        }
1584    };
1585    #[cfg(not(unix))]
1586    let terminate = std::future::pending::<()>();
1587
1588    tokio::select! {
1589        _ = ctrl_c => {}
1590        _ = terminate => {}
1591    }
1592    tracing::info!("shutdown signal received; draining");
1593}
1594
1595/// Liveness probe. Also reports the active daemon-config **generation** hash so an
1596/// operator can confirm every node in a cluster converged to the same config
1597/// (`ok` alone = running on the pure file baseline).
1598async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1599    match daemon.generation() {
1600        Some(gen) => format!("ok gen={gen}"),
1601        None => "ok".to_string(),
1602    }
1603}
1604
1605/// Readiness probe: `200 ready` when the metadata backend answers, else `503`.
1606async fn readyz(State(deploy): State<DeployStore>) -> Response {
1607    match deploy.ready().await {
1608        Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1609        Err(err) => {
1610            tracing::warn!(error = %err, "readiness probe failed");
1611            (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1612        }
1613    }
1614}
1615
1616/// A per-request correlation id assigned by the access-log layer and readable downstream via
1617/// the request extensions — the handler dispatch tags captured guest logs with it, so a guest
1618/// line correlates with its `boatramp::access` line. Public so an embedder (or a test) can seed
1619/// its own id into the request extensions.
1620#[derive(Clone)]
1621pub struct RequestId(pub String);
1622
1623/// The routed domain's opaque **tenant context tag** ([`boatramp_core::project::DomainOwner`]'s
1624/// `context`), stashed in the request extensions at host routing time so the handler-dispatch path
1625/// can resolve a [`boatramp_core::tenancy::TenantSource::Domain`] scope without re-reading the
1626/// routing index. Present only on the host-routed serving path; absent elsewhere (a domain source
1627/// then fails closed).
1628#[cfg(feature = "handlers")]
1629#[derive(Clone)]
1630pub struct DomainContext(pub String);
1631
1632/// The correlation id for a request: an upstream proxy's `X-Request-Id` when present (sanitized,
1633/// length-capped), else a generated time-ordered, per-process-unique id.
1634fn request_id_for(headers: &HeaderMap) -> String {
1635    if let Some(id) = headers
1636        .get("x-request-id")
1637        .and_then(|v| v.to_str().ok())
1638        .map(str::trim)
1639        .filter(|s| !s.is_empty())
1640    {
1641        return id.chars().filter(|c| !c.is_control()).take(128).collect();
1642    }
1643    use std::sync::atomic::{AtomicU64, Ordering};
1644    static SEQ: AtomicU64 = AtomicU64::new(0);
1645    let n = SEQ.fetch_add(1, Ordering::Relaxed);
1646    format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1647}
1648
1649/// One access-log line, emitted when the response body finishes streaming, so
1650/// `bytes` (response size) and `elapsed_ms` (time-to-last-byte) are accurate for
1651/// fixed-size *and* streamed/proxied responses.
1652struct AccessLog {
1653    request_id: String,
1654    method: Method,
1655    path: String,
1656    host: String,
1657    client: String,
1658    status: u16,
1659    /// Response `Content-Encoding` (`br`/`gzip`/`identity`).
1660    encoding: String,
1661    start: std::time::Instant,
1662    bytes: std::sync::atomic::AtomicU64,
1663}
1664
1665impl Drop for AccessLog {
1666    fn drop(&mut self) {
1667        let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1668        // Aggregate into the process-wide Prometheus counters (status class +
1669        // cache result + bytes) before emitting the per-request line.
1670        srvmetrics::server_metrics().record_request(self.status, bytes);
1671        tracing::info!(
1672            target: "boatramp::access",
1673            request_id = %self.request_id,
1674            method = %self.method,
1675            path = %self.path,
1676            host = %self.host,
1677            client = %self.client,
1678            status = self.status,
1679            bytes = bytes,
1680            encoding = %self.encoding,
1681            cache_result = srvmetrics::cache_result(self.status),
1682            elapsed_ms = self.start.elapsed().as_millis() as u64,
1683            "request"
1684        );
1685    }
1686}
1687
1688/// Assign the request correlation id (from the client's header or freshly minted) and
1689/// make it readable downstream — handler dispatch tags captured guest logs with it.
1690/// Runs for every request regardless of the access-log level; returns the id. Shared by
1691/// the [`access_log`] middleware and any direct serve path so correlation is never
1692/// skipped on a bypass. (Serve hot-path bypass, stage 1.)
1693pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1694    let request_id = request_id_for(request.headers());
1695    request
1696        .extensions_mut()
1697        .insert(RequestId(request_id.clone()));
1698    request_id
1699}
1700
1701/// Request metadata captured *before* the response is produced, for the access-log line
1702/// plus the Prometheus request counters — both emitted from [`AccessLog`]'s `Drop` once
1703/// the body has fully streamed (or the client disconnected). Extracted so the access-log
1704/// middleware and a direct serve path share one implementation.
1705pub(crate) struct AccessLogCtx {
1706    request_id: String,
1707    method: Method,
1708    path: String,
1709    host: String,
1710    client: String,
1711    start: std::time::Instant,
1712}
1713
1714impl AccessLogCtx {
1715    /// Capture the request for logging, or `None` when the `boatramp::access` line is
1716    /// filtered out — in which case logging *and* the per-request metric aggregation are
1717    /// both skipped (~4 string allocations plus a body-stream wrapper avoided, matching
1718    /// how nginx/Envoy run with `access_log off`). The id is assigned separately and
1719    /// unconditionally via [`assign_request_id`].
1720    pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1721        if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1722            return None;
1723        }
1724        Some(Self {
1725            request_id,
1726            method: request.method().clone(),
1727            path: request.uri().path().to_string(),
1728            host: request
1729                .headers()
1730                .get(header::HOST)
1731                .and_then(|value| value.to_str().ok())
1732                .or_else(|| request.uri().host()) // HTTP/2: `:authority` lives in the URI
1733                .unwrap_or("-")
1734                .to_string(),
1735            client: request
1736                .extensions()
1737                .get::<axum::extract::ConnectInfo<SocketAddr>>()
1738                .map(|info| info.0.ip().to_string())
1739                .unwrap_or_else(|| "-".to_string()),
1740            start: std::time::Instant::now(),
1741        })
1742    }
1743
1744    /// Wrap `response` so its bytes are tallied as they stream; the access line + request
1745    /// metrics emit from the counter's `Drop` when the body finishes (or the client
1746    /// disconnects).
1747    pub(crate) fn finish(self, response: Response) -> Response {
1748        let encoding = response
1749            .headers()
1750            .get(header::CONTENT_ENCODING)
1751            .and_then(|v| v.to_str().ok())
1752            .unwrap_or("identity")
1753            .to_string();
1754        let log = AccessLog {
1755            request_id: self.request_id,
1756            method: self.method,
1757            path: self.path,
1758            host: self.host,
1759            client: self.client,
1760            status: response.status().as_u16(),
1761            encoding,
1762            start: self.start,
1763            bytes: std::sync::atomic::AtomicU64::new(0),
1764        };
1765        let (parts, body) = response.into_parts();
1766        let counted = body.into_data_stream().map(move |chunk| {
1767            if let Ok(bytes) = &chunk {
1768                log.bytes
1769                    .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1770            }
1771            chunk
1772        });
1773        Response::from_parts(parts, Body::from_stream(counted))
1774    }
1775}
1776
1777/// Structured access-log middleware: assigns the correlation id, then (when access
1778/// logging is on) records method / path / host / client IP / status / response bytes /
1779/// duration once the body has fully streamed. Assignment and the capture/finish logic
1780/// are shared with any direct serve path via [`assign_request_id`] + [`AccessLogCtx`].
1781async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1782    let request_id = assign_request_id(&mut request);
1783    match AccessLogCtx::capture(&request, request_id) {
1784        None => next.run(request).await,
1785        Some(ctx) => ctx.finish(next.run(request).await),
1786    }
1787}
1788
1789/// Whether the request's `If-None-Match` matches `etag` (or `*`).
1790fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1791    req_headers
1792        .get(header::IF_NONE_MATCH)
1793        .and_then(|value| value.to_str().ok())
1794        .is_some_and(|value| {
1795            value
1796                .split(',')
1797                .map(str::trim)
1798                .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1799        })
1800}
1801
1802fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1803    if let Ok(value) = HeaderValue::from_str(value) {
1804        headers.insert(name, value);
1805    }
1806}
1807
1808fn not_found() -> Response {
1809    (StatusCode::NOT_FOUND, "not found\n").into_response()
1810}
1811
1812fn redirect(status: u16, location: &str) -> Response {
1813    let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1814    match HeaderValue::from_str(location) {
1815        Ok(location) => {
1816            let mut headers = HeaderMap::new();
1817            headers.insert(header::LOCATION, location);
1818            (status, headers).into_response()
1819        }
1820        Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1821    }
1822}
1823
1824/// Map a [`DeployError`] to an HTTP response.
1825fn deploy_error_response(err: DeployError) -> Response {
1826    let status = match &err {
1827        DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1828            StatusCode::NOT_FOUND
1829        }
1830        DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1831        DeployError::Incomplete(_) => StatusCode::CONFLICT,
1832        // A host already claimed by another site — refuse the overwrite.
1833        DeployError::Conflict(_) => StatusCode::CONFLICT,
1834        // An ambiguous preview-id prefix is not a usable capability → not found.
1835        DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1836        // A submitted config failed content validation (e.g. an empty target public predicate).
1837        DeployError::Invalid(_) => StatusCode::BAD_REQUEST,
1838        _ => StatusCode::INTERNAL_SERVER_ERROR,
1839    };
1840    tracing::warn!(error = %err, "request failed");
1841    (status, format!("{err}\n")).into_response()
1842}
1843
1844/// Reject a resource name (site/function/compute/workflow) that is unsafe at the
1845/// store-key boundary, returning `Some(422)` to short-circuit the handler. The
1846/// name arrives here already percent-decoded by axum's `Path` extractor, so a
1847/// smuggled `%2F` is caught as a literal `/`. `None` = the name is fine.
1848fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1849    boatramp_core::project::validate_resource_name(kind, value)
1850        .err()
1851        .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1852}
1853
1854#[cfg(test)]
1855mod drain_tests {
1856    use super::*;
1857
1858    #[tokio::test]
1859    async fn deadline_forces_shutdown_after_signal() {
1860        // Server never finishes draining; once the signal has fired the
1861        // deadline must end the wait (Ok — we forced shutdown deliberately).
1862        let server = std::future::pending::<Result<(), ServeError>>();
1863        let signalled = async {}; // signal already fired
1864        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1865        assert!(result.is_ok());
1866    }
1867
1868    #[tokio::test]
1869    async fn server_finishing_first_wins() {
1870        // If the server drains before the deadline, its result is returned and
1871        // the deadline never trips (signal never even fires here).
1872        let server = async { Ok(()) };
1873        let signalled = std::future::pending::<()>();
1874        let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1875        assert!(result.is_ok());
1876    }
1877
1878    #[tokio::test]
1879    async fn deadline_does_not_trip_before_signal() {
1880        // The deadline is measured from the signal: with no signal it never
1881        // trips, even past its length. The server completes (here with an
1882        // error) and that result propagates.
1883        let server = async {
1884            tokio::time::sleep(Duration::from_millis(40)).await;
1885            Err(ServeError::Io(std::io::Error::other("server error")))
1886        };
1887        let signalled = std::future::pending::<()>();
1888        let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1889        assert!(result.is_err());
1890    }
1891}
1892
1893#[cfg(all(test, feature = "handlers"))]
1894mod tests {
1895    use super::*;
1896    use boatramp_core::cose::{LocalSigner, TokenAlg};
1897    use boatramp_core::project::ProjectRef;
1898
1899    #[test]
1900    fn query_string_parses_and_url_decodes() {
1901        let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1902        assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1903        assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); // %C3%A3 + '+'
1904        assert_eq!(q.get("flag").map(String::as_str), Some("")); // bare key
1905        assert_eq!(q.get("dup").map(String::as_str), Some("1")); // first value wins
1906    }
1907
1908    #[test]
1909    fn cookie_header_parses_pairs() {
1910        let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1911        assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1912        assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1913        assert_eq!(c.get("empty").map(String::as_str), Some(""));
1914    }
1915
1916    #[test]
1917    fn apply_vary_merges_without_duplicates() {
1918        let base = (StatusCode::OK, "x").into_response();
1919        let r = apply_vary(base, &["accept-language".into()]);
1920        assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1921        // Merges into an existing Vary, de-duplicating case-insensitively.
1922        let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1923        let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1924        assert!(v.contains("accept-language") && v.contains("cookie"));
1925        assert_eq!(v.matches("accept-language").count(), 1);
1926        // Empty vary is a no-op.
1927        let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1928        assert!(plain.headers().get(header::VARY).is_none());
1929    }
1930
1931    /// The `/api/cluster/join-token` handler mints a verifiable **bearer** token,
1932    /// and refuses cleanly on a verify-only node (no root key) → 501. Admin-gating
1933    /// is the deny-safe `Right::required` default for `/api/cluster/*`.
1934    #[tokio::test]
1935    async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1936        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1937        let public = keys.public_key();
1938
1939        // Happy path: the returned token verifies + yields a single-use jti.
1940        let resp = create_join_token(
1941            Extension(Issuer(Some(keys.clone()))),
1942            Json(CreateJoinTokenRequest {
1943                ttl_secs: Some(600),
1944            }),
1945        )
1946        .await;
1947        assert_eq!(resp.status(), StatusCode::CREATED);
1948        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1949            .await
1950            .unwrap();
1951        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1952        let token = parsed["token"].as_str().unwrap();
1953        let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1954        assert!(!jti.is_empty());
1955
1956        // A verify-only node (no issuing key) cannot mint → 501.
1957        let no_issuer = create_join_token(
1958            Extension(Issuer(None)),
1959            Json(CreateJoinTokenRequest { ttl_secs: None }),
1960        )
1961        .await;
1962        assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1963    }
1964
1965    /// FA-2: the top-level function **write** path driven through the HTTP handlers —
1966    /// deploy two versions, roll back, alias, remove — plus the two 400/absent-blob
1967    /// guards. The store-layer semantics are the `boatramp-core` oracle; this pins the
1968    /// handler wrapper (status codes, blob gate, JSON echo).
1969    #[tokio::test]
1970    async fn function_write_path_deploy_rollback_alias_remove() {
1971        use boatramp_core::function::Lifecycle;
1972        use boatramp_core::kv::MemoryKv;
1973        use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1974
1975        // A storage whose `head` (hence `has_blob`) is toggleable — enough to drive
1976        // both the blob-present deploy path and the absent-blob 400.
1977        struct FakeStorage {
1978            present: bool,
1979        }
1980        #[async_trait::async_trait]
1981        impl Storage for FakeStorage {
1982            async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1983                Err(StorageError::NotFound(String::new()))
1984            }
1985            async fn get_range(
1986                &self,
1987                _: &str,
1988                _: u64,
1989                _: Option<u64>,
1990            ) -> Result<GetObject, StorageError> {
1991                Err(StorageError::NotFound(String::new()))
1992            }
1993            async fn put(
1994                &self,
1995                _: &str,
1996                _: ByteStream,
1997                _: PutMeta,
1998            ) -> Result<ObjectMeta, StorageError> {
1999                Err(StorageError::unsupported("fake"))
2000            }
2001            async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2002                if self.present {
2003                    Ok(ObjectMeta {
2004                        key: key.to_string(),
2005                        ..Default::default()
2006                    })
2007                } else {
2008                    Err(StorageError::NotFound(key.to_string()))
2009                }
2010            }
2011            async fn delete(&self, _: &str) -> Result<(), StorageError> {
2012                Ok(())
2013            }
2014            async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2015                Ok(Vec::new())
2016            }
2017        }
2018
2019        async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
2020            let status = resp.status();
2021            let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2022                .await
2023                .unwrap();
2024            let value = if bytes.is_empty() {
2025                serde_json::Value::Null
2026            } else {
2027                serde_json::from_slice(&bytes).unwrap()
2028            };
2029            (status, value)
2030        }
2031
2032        let deploy = DeployStore::new(
2033            Arc::new(FakeStorage { present: true }),
2034            Arc::new(MemoryKv::new()),
2035        );
2036        let v1 = "a".repeat(64);
2037        let v2 = "b".repeat(64);
2038
2039        // Deploy v1 → created, active = v1.
2040        let (st, body) = body_json(
2041            deploy_function(
2042                State(deploy.clone()),
2043                axum::extract::Extension(crate::ProjectContext::default()),
2044                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2045                axum::extract::Query(DeployFunctionQuery::default()),
2046                Path("greeter".to_string()),
2047                Json(FunctionUpsert {
2048                    component: v1.clone(),
2049                    config: Default::default(),
2050                    lifecycle: Lifecycle::Independent,
2051                }),
2052            )
2053            .await,
2054        )
2055        .await;
2056        assert_eq!(st, StatusCode::OK);
2057        assert_eq!(body["active"], v1);
2058
2059        // Deploy v2 → active advances, two versions retained.
2060        let (_, body) = body_json(
2061            deploy_function(
2062                State(deploy.clone()),
2063                axum::extract::Extension(crate::ProjectContext::default()),
2064                axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2065                axum::extract::Query(DeployFunctionQuery::default()),
2066                Path("greeter".to_string()),
2067                Json(FunctionUpsert {
2068                    component: v2.clone(),
2069                    config: Default::default(),
2070                    lifecycle: Lifecycle::Independent,
2071                }),
2072            )
2073            .await,
2074        )
2075        .await;
2076        assert_eq!(body["active"], v2);
2077        assert_eq!(body["versions"].as_array().unwrap().len(), 2);
2078
2079        // Roll back to v1.
2080        let (st, body) = body_json(
2081            rollback_function(
2082                State(deploy.clone()),
2083                axum::extract::Extension(crate::ProjectContext::default()),
2084                Path("greeter".to_string()),
2085                Json(RollbackBody { to: v1.clone() }),
2086            )
2087            .await,
2088        )
2089        .await;
2090        assert_eq!(st, StatusCode::OK);
2091        assert_eq!(body["active"], v1);
2092
2093        // Rolling back to an unknown version is a 400 (plain-text body).
2094        let resp = rollback_function(
2095            State(deploy.clone()),
2096            axum::extract::Extension(crate::ProjectContext::default()),
2097            Path("greeter".to_string()),
2098            Json(RollbackBody { to: "c".repeat(64) }),
2099        )
2100        .await;
2101        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2102
2103        // Alias prod → v2.
2104        let (st, body) = body_json(
2105            alias_function(
2106                State(deploy.clone()),
2107                axum::extract::Extension(crate::ProjectContext::default()),
2108                Path(("greeter".to_string(), "prod".to_string())),
2109                Json(AliasBody {
2110                    version: v2.clone(),
2111                }),
2112            )
2113            .await,
2114        )
2115        .await;
2116        assert_eq!(st, StatusCode::OK);
2117        assert_eq!(body["aliases"]["prod"], v2);
2118
2119        // Remove → 204, and it's gone.
2120        let (st, _) = body_json(
2121            remove_function(
2122                State(deploy.clone()),
2123                axum::extract::Extension(crate::ProjectContext::default()),
2124                Path("greeter".to_string()),
2125            )
2126            .await,
2127        )
2128        .await;
2129        assert_eq!(st, StatusCode::NO_CONTENT);
2130        assert!(deploy
2131            .get_function(ProjectRef::DEFAULT, "greeter")
2132            .await
2133            .unwrap()
2134            .is_none());
2135
2136        // Deploying a component whose blob was never uploaded is a 400.
2137        let empty = DeployStore::new(
2138            Arc::new(FakeStorage { present: false }),
2139            Arc::new(MemoryKv::new()),
2140        );
2141        let resp = deploy_function(
2142            State(empty),
2143            axum::extract::Extension(crate::ProjectContext::default()),
2144            axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2145            axum::extract::Query(DeployFunctionQuery::default()),
2146            Path("orphan".to_string()),
2147            Json(FunctionUpsert {
2148                component: v1.clone(),
2149                config: Default::default(),
2150                lifecycle: Lifecycle::default(),
2151            }),
2152        )
2153        .await;
2154        assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2155    }
2156
2157    /// A configurable stub: records the `(mesh_pubkey, jti)` it's asked to admit and
2158    /// returns a chosen [`JoinOutcome`] (the real possession-proof + member signing
2159    /// lives in the cluster impl; here we test the handler's dispatch + status map).
2160    struct StubControl {
2161        admits: std::sync::Mutex<Vec<(String, String)>>,
2162        respond: StubJoin,
2163    }
2164    #[derive(Clone, Copy)]
2165    enum StubJoin {
2166        Admit,
2167        Spent,
2168        Invalid,
2169        Revoked,
2170    }
2171
2172    #[async_trait::async_trait]
2173    impl MeshControl for StubControl {
2174        async fn admit(
2175            &self,
2176            mesh_pubkey_hex: &str,
2177            jti: &str,
2178            _proof: &[u8],
2179            _proof_iat: u64,
2180            _now: u64,
2181            _advertise_addr: Option<&str>,
2182        ) -> Result<JoinOutcome, String> {
2183            self.admits
2184                .lock()
2185                .unwrap()
2186                .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2187            Ok(match self.respond {
2188                StubJoin::Admit => JoinOutcome::Admitted {
2189                    members: vec!["signed-member".to_string()],
2190                    addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2191                },
2192                StubJoin::Spent => JoinOutcome::TokenSpent,
2193                StubJoin::Invalid => JoinOutcome::ProofInvalid,
2194                StubJoin::Revoked => JoinOutcome::Revoked,
2195            })
2196        }
2197        async fn rotate_key(&self) -> Result<String, String> {
2198            Ok("cafe".to_string())
2199        }
2200        async fn revoke(&self, _node: u64) -> Result<(), String> {
2201            Ok(())
2202        }
2203        async fn members(&self) -> Result<Vec<MeshMember>, String> {
2204            Ok(Vec::new())
2205        }
2206        async fn promote(&self, _node: u64) -> Result<(), String> {
2207            Ok(())
2208        }
2209    }
2210
2211    /// `POST /api/cluster/join`: a valid bearer token dispatches to the admitter and
2212    /// maps its outcome (admitted→200+members, spent→409, proof-invalid→403); a bad
2213    /// token → 401, a non-hex proof → 400, and no cluster hook → 501.
2214    #[tokio::test]
2215    async fn cluster_join_dispatches_and_maps_outcomes() {
2216        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2217        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2218        let auth = Auth::with_key(keys.public_key(), kv);
2219        let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2220        let req = |proof: &str| JoinRequest {
2221            token: token.clone(),
2222            mesh_pubkey: "302a300506032b6570032100feed".into(),
2223            possession_proof: proof.to_string(),
2224            proof_iat: now_unix(),
2225            advertise_addr: Some("https://joiner:7000".into()),
2226        };
2227
2228        // Admitted → 200 + the signed members, and the admitter saw the jti.
2229        let admitter = Arc::new(StubControl {
2230            admits: std::sync::Mutex::new(Vec::new()),
2231            respond: StubJoin::Admit,
2232        });
2233        let resp = cluster_join(
2234            Extension(auth.clone()),
2235            Extension(MeshControlHandle(Some(admitter.clone()))),
2236            Json(req("aa01")),
2237        )
2238        .await;
2239        assert_eq!(resp.status(), StatusCode::OK);
2240        assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2241
2242        // Spent token → 409; proof-invalid → 403 (the impl's verdicts, mapped).
2243        let spent = Arc::new(StubControl {
2244            admits: std::sync::Mutex::new(Vec::new()),
2245            respond: StubJoin::Spent,
2246        });
2247        assert_eq!(
2248            cluster_join(
2249                Extension(auth.clone()),
2250                Extension(MeshControlHandle(Some(spent))),
2251                Json(req("aa01")),
2252            )
2253            .await
2254            .status(),
2255            StatusCode::CONFLICT
2256        );
2257        let invalid = Arc::new(StubControl {
2258            admits: std::sync::Mutex::new(Vec::new()),
2259            respond: StubJoin::Invalid,
2260        });
2261        assert_eq!(
2262            cluster_join(
2263                Extension(auth.clone()),
2264                Extension(MeshControlHandle(Some(invalid))),
2265                Json(req("aa01")),
2266            )
2267            .await
2268            .status(),
2269            StatusCode::FORBIDDEN
2270        );
2271        // A revoked key → 403 (a tombstone bars re-admission until un-revoked).
2272        let revoked = Arc::new(StubControl {
2273            admits: std::sync::Mutex::new(Vec::new()),
2274            respond: StubJoin::Revoked,
2275        });
2276        assert_eq!(
2277            cluster_join(
2278                Extension(auth.clone()),
2279                Extension(MeshControlHandle(Some(revoked))),
2280                Json(req("aa01")),
2281            )
2282            .await
2283            .status(),
2284            StatusCode::FORBIDDEN
2285        );
2286
2287        // A non-hex possession proof → 400 (before dispatch).
2288        let ok = Arc::new(StubControl {
2289            admits: std::sync::Mutex::new(Vec::new()),
2290            respond: StubJoin::Admit,
2291        });
2292        assert_eq!(
2293            cluster_join(
2294                Extension(auth.clone()),
2295                Extension(MeshControlHandle(Some(ok))),
2296                Json(req("not-hex")),
2297            )
2298            .await
2299            .status(),
2300            StatusCode::BAD_REQUEST
2301        );
2302
2303        // No cluster hook → 501.
2304        let none = cluster_join(
2305            Extension(auth),
2306            Extension(MeshControlHandle(None)),
2307            Json(req("aa01")),
2308        )
2309        .await;
2310        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2311    }
2312
2313    /// `POST /api/tokens/bootstrap`: the right single-use secret mints a verifiable,
2314    /// recorded first token exactly once; a wrong secret is `401`, a reused one
2315    /// `409`, and a node without a bootstrap secret configured is `501`.
2316    #[tokio::test]
2317    async fn bootstrap_mints_the_first_token_once() {
2318        use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2319        let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2320        let public = keys.public_key();
2321        let deploy = DeployStore::new(
2322            Arc::new(MemStorage::default()),
2323            Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2324        );
2325        let secret = "s3cr3t-bootstrap-value";
2326        let gate = BootstrapGate::new(Some(secret));
2327        let issuer = Issuer(Some(keys.clone()));
2328        let bearer = |s: &str| {
2329            let mut h = HeaderMap::new();
2330            h.insert(
2331                AUTHORIZATION,
2332                HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2333            );
2334            h
2335        };
2336        let req = || BootstrapRequest {
2337            roles: vec!["admin".to_string()],
2338            ttl_secs: None,
2339        };
2340
2341        // Wrong secret → 401.
2342        let bad = bootstrap_token(
2343            State(deploy.clone()),
2344            Extension(issuer.clone()),
2345            Extension(gate.clone()),
2346            bearer("wrong"),
2347            Json(req()),
2348        )
2349        .await;
2350        assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2351
2352        // Correct secret → 201, a token the root key verifies as admin, recorded.
2353        let ok = bootstrap_token(
2354            State(deploy.clone()),
2355            Extension(issuer.clone()),
2356            Extension(gate.clone()),
2357            bearer(secret),
2358            Json(req()),
2359        )
2360        .await;
2361        assert_eq!(ok.status(), StatusCode::CREATED);
2362        let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2363            .await
2364            .unwrap();
2365        let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2366        let token = json["token"].as_str().unwrap();
2367        let id = json["id"].as_str().unwrap();
2368        let verified = cose::verify(token, &public, now_unix()).unwrap();
2369        assert!(verified.roles.iter().any(|r| r.name == "admin"));
2370        assert!(deploy
2371            .list_token_meta()
2372            .await
2373            .unwrap()
2374            .iter()
2375            .any(|m| m.revocation_id == id));
2376
2377        // Reuse of the same secret → 409 (single-use).
2378        let reuse = bootstrap_token(
2379            State(deploy.clone()),
2380            Extension(issuer.clone()),
2381            Extension(gate),
2382            bearer(secret),
2383            Json(req()),
2384        )
2385        .await;
2386        assert_eq!(reuse.status(), StatusCode::CONFLICT);
2387
2388        // No bootstrap secret configured → 501.
2389        let disabled = bootstrap_token(
2390            State(deploy),
2391            Extension(issuer),
2392            Extension(BootstrapGate(None)),
2393            bearer(secret),
2394            Json(req()),
2395        )
2396        .await;
2397        assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2398    }
2399
2400    /// `POST /api/cluster/rotate-key` rotates via the control hook and returns the
2401    /// new pubkey; `501` on a non-cluster node.
2402    #[tokio::test]
2403    async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2404        let control = Arc::new(StubControl {
2405            admits: std::sync::Mutex::new(Vec::new()),
2406            respond: StubJoin::Admit,
2407        });
2408        let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2409        assert_eq!(resp.status(), StatusCode::OK);
2410        let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2411            .await
2412            .unwrap();
2413        let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2414        assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2415
2416        let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2417        assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2418    }
2419
2420    #[test]
2421    fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2422        use boatramp_core::security::SecurityProfile;
2423        let strict = SecurityProfile::MultiTenant.preset();
2424        let loose = SecurityProfile::SingleTenant.preset(); // allows private upstreams
2425
2426        let public: IpAddr = "93.184.216.34".parse().unwrap(); // example.com
2427        let private: IpAddr = "10.1.2.3".parse().unwrap();
2428        let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2429        let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2430
2431        // Strict (multi-tenant): only globally-routable addresses are allowed.
2432        assert!(gateway_addr_allowed(public, &strict));
2433        assert!(!gateway_addr_allowed(private, &strict));
2434        assert!(!gateway_addr_allowed(loopback, &strict));
2435        assert!(!gateway_addr_allowed(metadata, &strict));
2436
2437        // Operator opt-in: private/loopback allowed, but cloud-metadata is still
2438        // refused (defense in depth — it is never a legitimate target).
2439        assert!(gateway_addr_allowed(public, &loose));
2440        assert!(gateway_addr_allowed(private, &loose));
2441        assert!(gateway_addr_allowed(loopback, &loose));
2442        assert!(!gateway_addr_allowed(metadata, &loose));
2443    }
2444
2445    #[tokio::test]
2446    async fn resolve_env_merges_static_and_host_secrets() {
2447        use boatramp_core::config::HandlersSiteConfig;
2448
2449        // A uniquely-named host var holds the real secret value.
2450        std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2451
2452        let deploy_env = std::collections::BTreeMap::from([
2453            ("GREETING".to_string(), "hi".to_string()),
2454            ("OVERRIDE_ME".to_string(), "static".to_string()),
2455        ]);
2456        let site_handlers = HandlersSiteConfig {
2457            enabled: true,
2458            secrets: std::collections::BTreeMap::from([
2459                // guest var <- host env var holding the value
2460                (
2461                    "SECRET_TOKEN".to_string(),
2462                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2463                ),
2464                (
2465                    "OVERRIDE_ME".to_string(),
2466                    "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2467                ),
2468                (
2469                    "MISSING".to_string(),
2470                    "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2471                ),
2472            ]),
2473            ..Default::default()
2474        };
2475        // Single-tenant / dev: host-env secret refs are permitted (the operator
2476        // authors the site config), so this resolves exactly as before.
2477        let env = resolve_env(
2478            "blog",
2479            boatramp_core::project::ProjectRef::DEFAULT,
2480            &deploy_env,
2481            &site_handlers,
2482            true,
2483            None,
2484        )
2485        .await
2486        .expect("resolves");
2487
2488        // Static var present; secret resolved from the host env; a secret
2489        // overrides a static of the same name; a secret whose host var is unset
2490        // is skipped (never injected as empty).
2491        assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2492        assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2493        assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2494        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2495
2496        std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2497    }
2498
2499    #[tokio::test]
2500    async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2501        use boatramp_core::config::HandlersSiteConfig;
2502
2503        // The exfiltration vector: an untrusted tenant names another tenant's / the
2504        // operator's host env var (bare or `env:`) in its site `secrets` map. Under
2505        // the multi-tenant posture (`allow_env_secret_refs = false`) the resolver
2506        // must REFUSE — never read the host env — and name the offending guest var.
2507        std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2508        let deploy_env = std::collections::BTreeMap::new();
2509        let bare = HandlersSiteConfig {
2510            enabled: true,
2511            secrets: std::collections::BTreeMap::from([(
2512                "STOLEN".to_string(),
2513                "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2514            )]),
2515            ..Default::default()
2516        };
2517        let err = resolve_env(
2518            "evil",
2519            boatramp_core::project::ProjectRef::DEFAULT,
2520            &deploy_env,
2521            &bare,
2522            false,
2523            None,
2524        )
2525        .await
2526        .expect_err("multi-tenant must refuse a bare host-env ref");
2527        assert!(
2528            err.contains("STOLEN"),
2529            "error names the offending guest var: {err}"
2530        );
2531        assert!(
2532            err.contains("multi-tenant"),
2533            "error steers the tenant: {err}"
2534        );
2535        assert!(
2536            !err.contains("leak-me"),
2537            "the host value must never appear (never read): {err}"
2538        );
2539
2540        // The explicit `env:` scheme is refused identically.
2541        let explicit = HandlersSiteConfig {
2542            enabled: true,
2543            secrets: std::collections::BTreeMap::from([(
2544                "STOLEN".to_string(),
2545                "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2546            )]),
2547            ..Default::default()
2548        };
2549        assert!(resolve_env(
2550            "evil",
2551            boatramp_core::project::ProjectRef::DEFAULT,
2552            &deploy_env,
2553            &explicit,
2554            false,
2555            None,
2556        )
2557        .await
2558        .is_err());
2559
2560        // A reserved-but-unimplemented scheme is also refused (no silent fall-through).
2561        let reserved = HandlersSiteConfig {
2562            enabled: true,
2563            secrets: std::collections::BTreeMap::from([(
2564                "TOKEN".to_string(),
2565                "vault:kv/data/app#token".to_string(),
2566            )]),
2567            ..Default::default()
2568        };
2569        let err = resolve_env(
2570            "evil",
2571            boatramp_core::project::ProjectRef::DEFAULT,
2572            &deploy_env,
2573            &reserved,
2574            true,
2575            None,
2576        )
2577        .await
2578        .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2579        assert!(err.contains("not yet supported"), "{err}");
2580
2581        // The rule is provider-neutral: ANY value with a colon is a scheme, so an
2582        // un-enumerated one (a cloud secret manager) is refused too — never misread
2583        // as a bare host var literally named "aws:sm/prod/apikey".
2584        let arbitrary = HandlersSiteConfig {
2585            enabled: true,
2586            secrets: std::collections::BTreeMap::from([(
2587                "KEY".to_string(),
2588                "aws:sm/prod/apikey".to_string(),
2589            )]),
2590            ..Default::default()
2591        };
2592        let err = resolve_env(
2593            "evil",
2594            boatramp_core::project::ProjectRef::DEFAULT,
2595            &deploy_env,
2596            &arbitrary,
2597            true,
2598            None,
2599        )
2600        .await
2601        .expect_err("any unknown scheme is reserved, even under single-tenant");
2602        assert!(
2603            err.contains("not yet supported") && err.contains("aws"),
2604            "provider-neutral reservation names the scheme: {err}"
2605        );
2606
2607        std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2608    }
2609
2610    #[tokio::test]
2611    async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2612        // A top-level function resolves its `secrets` map exactly like a site
2613        // handler: `resolve_secret_env` reads the host env var named by the map's
2614        // value and injects it under the map's key. This is the SAME helper the
2615        // handler path uses, so the semantics are identical by construction.
2616        std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2617
2618        let static_env = std::collections::BTreeMap::from([
2619            ("STAGE".to_string(), "prod".to_string()),
2620            ("OVERRIDE_ME".to_string(), "static".to_string()),
2621        ]);
2622        let secrets = std::collections::BTreeMap::from([
2623            // guest ENV_VAR <- host env var holding the value
2624            ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2625            // a secret overrides a static of the same name
2626            (
2627                "OVERRIDE_ME".to_string(),
2628                "BOATRAMP_TEST_FN_SECRET".to_string(),
2629            ),
2630            // an unset host referent is skipped, never injected empty
2631            (
2632                "MISSING".to_string(),
2633                "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2634            ),
2635        ]);
2636        // Single-tenant / dev: host-env refs permitted, so this resolves as before.
2637        let env = resolve_secret_env(
2638            "fn/api",
2639            boatramp_core::project::ProjectRef::DEFAULT,
2640            &static_env,
2641            &secrets,
2642            true,
2643            None,
2644        )
2645        .await
2646        .expect("resolves");
2647
2648        assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2649        // The secret is injected under its target ENV_VAR, read from the host env.
2650        assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2651        // A secret overrides a static of the same name.
2652        assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2653        // Absent host var → skipped (matches the handler's missing-var behavior).
2654        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2655
2656        std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2657    }
2658
2659    #[tokio::test]
2660    async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2661        // The function analog of the handler exfiltration vector: a function's
2662        // `secrets` map naming a host env var must be REFUSED under the multi-tenant
2663        // posture (never read), using the SAME helper the handler path uses — so the
2664        // fail-closed semantics are identical by construction.
2665        std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2666        let static_env = std::collections::BTreeMap::new();
2667        let secrets = std::collections::BTreeMap::from([(
2668            "DB_URL".to_string(),
2669            "BOATRAMP_TEST_FN_LEAK".to_string(),
2670        )]);
2671
2672        // Multi-tenant: refused, names the offending guest var, host value never read.
2673        let err = resolve_secret_env(
2674            "fn/api",
2675            boatramp_core::project::ProjectRef::DEFAULT,
2676            &static_env,
2677            &secrets,
2678            false,
2679            None,
2680        )
2681        .await
2682        .expect_err("multi-tenant must refuse a function host-env ref");
2683        assert!(
2684            err.contains("DB_URL"),
2685            "error names the offending guest var: {err}"
2686        );
2687        assert!(
2688            !err.contains("leak-me"),
2689            "host value must never appear: {err}"
2690        );
2691
2692        // Single-tenant / dev: the same ref resolves + injects (operator owns config).
2693        let env = resolve_secret_env(
2694            "fn/api",
2695            boatramp_core::project::ProjectRef::DEFAULT,
2696            &static_env,
2697            &secrets,
2698            true,
2699            None,
2700        )
2701        .await
2702        .expect("resolves");
2703        assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2704
2705        std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2706    }
2707
2708    #[tokio::test]
2709    async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2710        use boatramp_core::project::ProjectRef;
2711        use boatramp_core::secret_store::SecretStore;
2712        use std::sync::Arc;
2713
2714        // A reversible test envelope (XOR) so `set` seals and `get` unseals.
2715        struct XorEnvelope;
2716        #[async_trait::async_trait]
2717        impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2718            async fn wrap(
2719                &self,
2720                p: &[u8],
2721            ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2722                Ok(p.iter().map(|b| b ^ 0x5a).collect())
2723            }
2724            async fn unwrap(
2725                &self,
2726                c: &[u8],
2727            ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2728                Ok(c.iter().map(|b| b ^ 0x5a).collect())
2729            }
2730        }
2731
2732        let store = SecretStore::new(
2733            Arc::new(boatramp_core::kv::MemoryKv::new()),
2734            Arc::new(XorEnvelope),
2735        );
2736        store
2737            .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2738            .await
2739            .unwrap();
2740
2741        let static_env = std::collections::BTreeMap::new();
2742        let secrets = std::collections::BTreeMap::from([
2743            ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2744            ("MISSING".to_string(), "boatramp:not-set".to_string()),
2745        ]);
2746
2747        // Resolves under the MULTI-TENANT posture (allow_env_secret_refs = false):
2748        // the project-scoped store is the multi-tenant-safe path, not gated on it.
2749        let env = resolve_secret_env(
2750            "site",
2751            ProjectRef::new("acme"),
2752            &static_env,
2753            &secrets,
2754            false,
2755            Some(&store),
2756        )
2757        .await
2758        .expect("boatramp refs resolve without the host-env gate");
2759        assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2760        // A missing boatramp secret is skipped, never injected empty (like a missing env var).
2761        assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2762
2763        // Project isolation: the same ref under a different project does not see acme's secret.
2764        let other_secrets = std::collections::BTreeMap::from([(
2765            "API_KEY".to_string(),
2766            "boatramp:api-key".to_string(),
2767        )]);
2768        let other = resolve_secret_env(
2769            "site",
2770            ProjectRef::new("globex"),
2771            &static_env,
2772            &other_secrets,
2773            false,
2774            Some(&store),
2775        )
2776        .await
2777        .expect("resolves (a foreign project's secret is simply absent → skipped)");
2778        assert!(
2779            !other.iter().any(|(k, _)| k == "API_KEY"),
2780            "a tenant must not read another project's secret"
2781        );
2782
2783        // Fail-closed: a boatramp: ref with no store configured errors (does not silently skip).
2784        let err = resolve_secret_env(
2785            "site",
2786            ProjectRef::new("acme"),
2787            &static_env,
2788            &other_secrets,
2789            false,
2790            None,
2791        )
2792        .await
2793        .expect_err("no store configured must fail closed");
2794        assert!(err.contains("no internal secret store"), "{err}");
2795    }
2796
2797    fn req() -> Request {
2798        Request::builder()
2799            .uri("/")
2800            .header(header::HOST, "example.com")
2801            .body(Body::empty())
2802            .unwrap()
2803    }
2804
2805    #[test]
2806    fn forwarded_headers_set_standard_triple() {
2807        let mut request = req();
2808        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2809        let h = request.headers();
2810        assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2811        assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2812        assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2813    }
2814
2815    #[test]
2816    fn forwarded_for_overwrites_spoofed_value() {
2817        // A client-supplied X-Forwarded-For must not survive: the host stamps
2818        // the single resolved address, not an attacker-controlled chain.
2819        let mut request = Request::builder()
2820            .uri("/")
2821            .header(header::HOST, "example.com")
2822            .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2823            .body(Body::empty())
2824            .unwrap();
2825        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2826        let values: Vec<_> = request
2827            .headers()
2828            .get_all("x-forwarded-for")
2829            .iter()
2830            .collect();
2831        assert_eq!(values.len(), 1);
2832        assert_eq!(values[0], "203.0.113.7");
2833    }
2834
2835    #[test]
2836    fn forwarded_proto_preserves_upstream_tls() {
2837        // A TLS-terminating reverse proxy in front already set https; keep it.
2838        let mut request = Request::builder()
2839            .uri("/")
2840            .header(header::HOST, "example.com")
2841            .header("x-forwarded-proto", "https")
2842            .body(Body::empty())
2843            .unwrap();
2844        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2845        assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2846    }
2847
2848    #[test]
2849    fn forwarded_host_absent_when_no_host_header() {
2850        let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2851        set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2852        assert!(request.headers().get("x-forwarded-host").is_none());
2853        assert_eq!(
2854            request.headers().get("x-forwarded-for").unwrap(),
2855            "203.0.113.7"
2856        );
2857    }
2858
2859    // ---- consumer dispatcher (#17) -----------------------------------------
2860
2861    use boatramp_core::kv::{KvStore, MemoryKv};
2862    use boatramp_core::messaging::{LogMessaging, Messaging};
2863    use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2864
2865    const EVENT_CONSUMER: &[u8] =
2866        include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2867
2868    #[derive(Default)]
2869    struct MemStorage {
2870        objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2871    }
2872
2873    #[async_trait::async_trait]
2874    impl boatramp_core::Storage for MemStorage {
2875        async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2876            let bytes = self
2877                .objects
2878                .lock()
2879                .unwrap()
2880                .get(key)
2881                .cloned()
2882                .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2883            let body: ByteStream =
2884                futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2885            Ok(GetObject {
2886                meta: ObjectMeta {
2887                    key: key.to_string(),
2888                    ..Default::default()
2889                },
2890                body,
2891            })
2892        }
2893        async fn get_range(
2894            &self,
2895            key: &str,
2896            _: u64,
2897            _: Option<u64>,
2898        ) -> Result<GetObject, StorageError> {
2899            self.get(key).await
2900        }
2901        async fn put(
2902            &self,
2903            key: &str,
2904            mut body: ByteStream,
2905            _: PutMeta,
2906        ) -> Result<ObjectMeta, StorageError> {
2907            use futures::StreamExt;
2908            let mut buf = Vec::new();
2909            while let Some(chunk) = body.next().await {
2910                buf.extend_from_slice(&chunk?);
2911            }
2912            self.objects.lock().unwrap().insert(key.to_string(), buf);
2913            Ok(ObjectMeta {
2914                key: key.to_string(),
2915                ..Default::default()
2916            })
2917        }
2918        async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2919            self.objects
2920                .lock()
2921                .unwrap()
2922                .get(key)
2923                .map(|_| ObjectMeta {
2924                    key: key.to_string(),
2925                    ..Default::default()
2926                })
2927                .ok_or_else(|| StorageError::NotFound(key.to_string()))
2928        }
2929        async fn delete(&self, key: &str) -> Result<(), StorageError> {
2930            self.objects.lock().unwrap().remove(key);
2931            Ok(())
2932        }
2933        async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2934            Ok(Vec::new())
2935        }
2936    }
2937
2938    /// Build an `ObservedInstance` for the wake-from-zero helper tests, owned by
2939    /// `project` (so the project-scoped resolution can be exercised).
2940    fn observed_state_in(
2941        project: &str,
2942        workload: &str,
2943        host: &str,
2944        healthy: bool,
2945        phase: boatramp_core::compute::ReplicaPhase,
2946    ) -> boatramp_core::compute::ObservedInstance {
2947        use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2948        boatramp_core::compute::ObservedInstance {
2949            handle: InstanceHandle {
2950                project: project.into(),
2951                workload: workload.into(),
2952                replica: 0,
2953                backend_ref: "ref-0".into(),
2954            },
2955            node: 1,
2956            backend: "vmm".into(),
2957            endpoint: Endpoint {
2958                scheme: Scheme::Http,
2959                host: host.into(),
2960                port: 80,
2961            },
2962            region: None,
2963            healthy,
2964            started_at: None,
2965            phase,
2966            snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2967                project: project.into(),
2968                workload: workload.into(),
2969                replica: 0,
2970                data_ref: "snap-0".into(),
2971            }),
2972        }
2973    }
2974
2975    /// The default-project helper the wake-from-zero tests use.
2976    fn observed_state(
2977        workload: &str,
2978        healthy: bool,
2979        phase: boatramp_core::compute::ReplicaPhase,
2980    ) -> boatramp_core::compute::ObservedInstance {
2981        observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2982    }
2983
2984    #[tokio::test]
2985    async fn has_parked_replica_detects_a_zeroed_replica() {
2986        use boatramp_core::compute::ReplicaPhase;
2987        let storage = Arc::new(MemStorage::default());
2988        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2989        let deploy = DeployStore::new(storage, kv);
2990
2991        // Nothing → false.
2992        assert!(!has_parked_replica(&deploy, "default", "w").await);
2993        // A running replica → false (it's serving, not parked).
2994        deploy
2995            .set_replica_state(
2996                ProjectRef::DEFAULT,
2997                &observed_state("w", true, ReplicaPhase::Running),
2998            )
2999            .await
3000            .unwrap();
3001        assert!(!has_parked_replica(&deploy, "default", "w").await);
3002        // A parked (Zero) replica → true (wakeable).
3003        deploy
3004            .set_replica_state(
3005                ProjectRef::DEFAULT,
3006                &observed_state("w", false, ReplicaPhase::Zero),
3007            )
3008            .await
3009            .unwrap();
3010        assert!(has_parked_replica(&deploy, "default", "w").await);
3011    }
3012
3013    #[tokio::test]
3014    async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
3015        use boatramp_core::compute::ReplicaPhase;
3016        let storage = Arc::new(MemStorage::default());
3017        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3018        let deploy = DeployStore::new(storage, kv);
3019
3020        // No healthy replica → times out with an empty pool (short timeout).
3021        let empty = await_warm(
3022            &deploy,
3023            "default",
3024            "w",
3025            std::time::Duration::from_millis(150),
3026        )
3027        .await;
3028        assert!(empty.is_empty());
3029
3030        // A healthy replica → returned promptly.
3031        deploy
3032            .set_replica_state(
3033                ProjectRef::DEFAULT,
3034                &observed_state("w", true, ReplicaPhase::Running),
3035            )
3036            .await
3037            .unwrap();
3038        let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
3039        assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
3040    }
3041
3042    /// The project-scoped compute upstream resolution (v0.3.12): a workload named
3043    /// `web` exists in BOTH the `acme` project and `default`, on different endpoints.
3044    /// `compute_endpoints`/`has_parked_replica` must resolve against the project they
3045    /// are asked for — a non-default tenant no longer resolves against `default` (the
3046    /// project-blind bug that 502'd it / never woke it).
3047    #[tokio::test]
3048    async fn compute_endpoints_are_project_scoped() {
3049        use boatramp_core::compute::ReplicaPhase;
3050        let storage = Arc::new(MemStorage::default());
3051        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3052        let deploy = DeployStore::new(storage, kv);
3053
3054        // Same workload name `web`, one per project, distinct endpoints.
3055        deploy
3056            .set_replica_state(
3057                ProjectRef::new("acme"),
3058                &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
3059            )
3060            .await
3061            .unwrap();
3062        deploy
3063            .set_replica_state(
3064                ProjectRef::DEFAULT,
3065                &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
3066            )
3067            .await
3068            .unwrap();
3069
3070        // Asking for `acme` yields acme's endpoint — NOT default's.
3071        assert_eq!(
3072            compute_endpoints(&deploy, "acme", "web").await,
3073            vec!["http://10.0.0.5:80".to_string()],
3074            "acme's web resolves against acme, not default"
3075        );
3076        // Asking for `default` yields default's endpoint.
3077        assert_eq!(
3078            compute_endpoints(&deploy, "default", "web").await,
3079            vec!["http://10.0.0.9:80".to_string()]
3080        );
3081        // A project with no such workload resolves empty (→ 502), not another
3082        // project's replica.
3083        assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
3084    }
3085
3086    /// The delivery gate: a consumer receives every published message at-least-once
3087    /// (acked, counted once each), and a message that keeps failing is
3088    /// redelivered and then dead-lettered after `max_attempts`.
3089    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3090    async fn dispatcher_delivers_at_least_once_then_dead_letters() {
3091        use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3092        let storage = Arc::new(MemStorage::default());
3093        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3094        let mq = LogMessaging::new(storage, kv.clone());
3095        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3096        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3097        let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3098        let topic = "blog/orders/created";
3099
3100        // Three good messages → each delivered + acked exactly once.
3101        for _ in 0..3 {
3102            mq.publish(topic, b"ok").await.unwrap();
3103        }
3104        loop {
3105            let acked = dispatch_consumer_batch(
3106                &engine,
3107                &mq,
3108                &metrics::Metrics::default(),
3109                "blog",
3110                topic,
3111                "blog/",
3112                "",
3113                boatramp_core::messaging::StartPosition::Latest,
3114                &hash,
3115                EVENT_CONSUMER,
3116                &bindings,
3117                Limits::default(),
3118                Duration::from_secs(30),
3119                5,
3120                10,
3121            )
3122            .await;
3123            if acked == 0 {
3124                break;
3125            }
3126        }
3127        assert_eq!(
3128            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3129            Some(b"3".to_vec())
3130        );
3131
3132        // A poison message keeps failing → redelivered, then dead-lettered after
3133        // max_attempts (zero lease makes redelivery immediate).
3134        mq.publish(topic, b"fail").await.unwrap();
3135        for _ in 0..5 {
3136            dispatch_consumer_batch(
3137                &engine,
3138                &mq,
3139                &metrics::Metrics::default(),
3140                "blog",
3141                topic,
3142                "blog/",
3143                "",
3144                boatramp_core::messaging::StartPosition::Latest,
3145                &hash,
3146                EVENT_CONSUMER,
3147                &bindings,
3148                Limits::default(),
3149                Duration::ZERO,
3150                2,
3151                10,
3152            )
3153            .await;
3154        }
3155        assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
3156        // The good counter is untouched by the poison message.
3157        assert_eq!(
3158            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3159            Some(b"3".to_vec())
3160        );
3161    }
3162
3163    /// Config-driven fan-out through the dispatcher: two consumers with different
3164    /// **groups** on one topic each receive every message (not one-of-N), each
3165    /// with its own cursor + ack. The one delivered message increments the
3166    /// consumer's counter once *per group*.
3167    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3168    async fn consumer_groups_fan_out_through_the_dispatcher() {
3169        use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3170        let storage = Arc::new(MemStorage::default());
3171        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3172        let mq = LogMessaging::new(storage, kv.clone());
3173        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3174        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3175        let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3176        let topic = "blog/orders/created";
3177        let start = boatramp_core::messaging::StartPosition::Latest;
3178
3179        // Both groups subscribe first (registering them turns on retention), then
3180        // one event is published — the fabric shape (workers deployed, then events).
3181        for g in ["billing", "audit"] {
3182            let n = dispatch_consumer_batch(
3183                &engine,
3184                &mq,
3185                &metrics::Metrics::default(),
3186                "blog",
3187                topic,
3188                "blog/",
3189                g,
3190                start,
3191                &hash,
3192                EVENT_CONSUMER,
3193                &bindings,
3194                Limits::default(),
3195                Duration::from_secs(30),
3196                5,
3197                10,
3198            )
3199            .await;
3200            assert_eq!(n, 0, "no events yet for group {g}");
3201        }
3202        mq.publish(topic, b"ok").await.unwrap();
3203
3204        // Each group independently delivers the one message.
3205        for g in ["billing", "audit"] {
3206            let n = dispatch_consumer_batch(
3207                &engine,
3208                &mq,
3209                &metrics::Metrics::default(),
3210                "blog",
3211                topic,
3212                "blog/",
3213                g,
3214                start,
3215                &hash,
3216                EVENT_CONSUMER,
3217                &bindings,
3218                Limits::default(),
3219                Duration::from_secs(30),
3220                5,
3221                10,
3222            )
3223            .await;
3224            assert_eq!(n, 1, "group {g} should receive the message");
3225        }
3226        // Delivered once per group ⇒ counted twice (fan-out), not once.
3227        assert_eq!(
3228            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3229            Some(b"2".to_vec())
3230        );
3231    }
3232
3233    /// The activation policy: the scheduler runs the **current** deployment's
3234    /// consumers (production namespace `{site}`), but never a preview's — a
3235    /// preview-namespaced message is left untouched.
3236    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3237    async fn scheduler_runs_current_consumers_not_previews() {
3238        use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3239        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3240        use boatramp_handlers::{HandlerEngine, Limits};
3241        use futures::StreamExt;
3242
3243        let storage = Arc::new(MemStorage::default());
3244        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3245        let deploy = DeployStore::new(storage.clone(), kv.clone());
3246        let messaging: Arc<dyn Messaging> =
3247            Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3248
3249        // Store the consumer component + a deployment that subscribes to it.
3250        let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3251        let stream: ByteStream =
3252            futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3253                .boxed();
3254        deploy.put_blob(&hash, stream).await.unwrap();
3255        let mut files = std::collections::BTreeMap::new();
3256        files.insert(
3257            "consumer.wasm".to_string(),
3258            FileEntry {
3259                hash: hash.clone(),
3260                size: EVENT_CONSUMER.len() as u64,
3261                content_type: None,
3262                variants: std::collections::BTreeMap::new(),
3263            },
3264        );
3265        let manifest = Manifest {
3266            files,
3267            config: DeployConfig {
3268                consumers: vec![ConsumerConfig {
3269                    tenancy: None,
3270                    token_claims: None,
3271                    topic: "orders/created".into(),
3272                    component: "consumer.wasm".into(),
3273                    imports: vec!["wasi:keyvalue".into()],
3274                    group: String::new(),
3275                    start: Default::default(),
3276                }],
3277                ..Default::default()
3278            },
3279            ..Default::default()
3280        };
3281        let id = deploy.put_manifest(&manifest).await.unwrap();
3282        deploy
3283            .activate(ProjectRef::DEFAULT, "blog", &id)
3284            .await
3285            .unwrap();
3286        deploy
3287            .set_site_config(
3288                ProjectRef::DEFAULT,
3289                "blog",
3290                &SiteConfig {
3291                    handlers: Some(HandlersSiteConfig {
3292                        enabled: true,
3293                        allow_imports: vec!["wasi:keyvalue".into()],
3294                        ..Default::default()
3295                    }),
3296                    ..Default::default()
3297                },
3298            )
3299            .await
3300            .unwrap();
3301
3302        // One message in the production namespace, one in a preview namespace.
3303        messaging
3304            .publish("blog/orders/created", b"live")
3305            .await
3306            .unwrap();
3307        messaging
3308            .publish("blog/_preview/abc/orders/created", b"preview")
3309            .await
3310            .unwrap();
3311
3312        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3313        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3314        let inner = rt.inner.clone().unwrap();
3315        let mut cache = std::collections::HashMap::new();
3316        let mut crons = std::collections::HashMap::new();
3317        let mut sweep = std::collections::HashMap::new();
3318        let now = CronNow {
3319            minute: 0,
3320            hour: 0,
3321            dom: 1,
3322            month: 1,
3323            dow: 0,
3324            minute_stamp: 0,
3325        };
3326        for _ in 0..3 {
3327            run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3328                .await
3329                .unwrap();
3330        }
3331
3332        // The production message was delivered + counted.
3333        assert_eq!(
3334            kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3335            Some(b"1".to_vec())
3336        );
3337        // The preview-namespaced message was never claimed (no background work
3338        // for previews) — its counter doesn't exist.
3339        assert_eq!(
3340            kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3341                .await
3342                .unwrap(),
3343            None
3344        );
3345    }
3346
3347    // ---- cron driver (#18) -------------------------------------------------
3348
3349    /// A `wasi:http` handler that increments `hits` per request (`kv-counter`),
3350    /// used here as a cron target so a fire is observable as a counter bump.
3351    const KV_COUNTER: &[u8] =
3352        include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3353
3354    /// The function-to-function invoke resolver (FI): a resolvable target runs on
3355    /// the real engine and its response is buffered back + metered; an unknown
3356    /// target is `NotFound`. (The caller-side capability gate — allowlist, depth,
3357    /// deny-by-default — is unit-tested in `boatramp_handlers::bindings::invoke`.)
3358    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3359    async fn function_invoker_runs_target_buffers_and_meters() {
3360        use boatramp_core::deploy::DeployStore;
3361        use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3362        use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3363        use futures::StreamExt;
3364
3365        // The committed `http-200` fixture is the invoke *target* (a wasi:http
3366        // guest that returns 200); it needs no fixture of its own to be a callee.
3367        const HTTP_200: &[u8] =
3368            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3369
3370        let storage = Arc::new(MemStorage::default());
3371        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3372        let deploy = DeployStore::new(storage.clone(), kv.clone());
3373
3374        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3375        let stream: ByteStream =
3376            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3377        deploy.put_blob(&hash, stream).await.unwrap();
3378        let function = Function {
3379            name: "target".into(),
3380            owner: Owner::Project("default".into()),
3381            versions: vec![FunctionVersion {
3382                id: "v1".into(),
3383                component: hash.clone(),
3384                created: 0,
3385                lifecycle: Lifecycle::Independent,
3386            }],
3387            active: "v1".into(),
3388            aliases: Default::default(),
3389            config: Default::default(),
3390        };
3391        deploy
3392            .put_function(ProjectRef::DEFAULT, &function)
3393            .await
3394            .unwrap();
3395
3396        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3397        let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3398        rt.set_invoker(deploy.clone());
3399        let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3400
3401        let request = || InvokeRequest {
3402            method: "GET".into(),
3403            path: "/".into(),
3404            headers: vec![],
3405            body: vec![],
3406        };
3407
3408        // A resolvable target runs on the engine and returns its 200.
3409        let response = invoker.invoke("target", request(), 1).await.unwrap();
3410        assert_eq!(response.status, 200);
3411
3412        // The call was metered against the target function.
3413        let metering = deploy
3414            .get_metering(ProjectRef::DEFAULT, "target")
3415            .await
3416            .unwrap()
3417            .unwrap();
3418        assert_eq!(metering.invocations, 1);
3419
3420        // An unknown target is NotFound (never reaches the engine).
3421        let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3422        assert!(matches!(err, InvokeError::NotFound));
3423    }
3424
3425    /// The supergraph runner backing the `graphql` capability, driven end-to-end through a real
3426    /// runtime: the safelist is the deny-by-default operation floor, and only a safelisted op
3427    /// reaches planning. (The host-side grant + depth cap are unit-tested in
3428    /// `boatramp_handlers::bindings::graphql`; stitching + bearer forwarding + depth dispatch in
3429    /// `graphql_gateway`.)
3430    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3431    async fn federation_runner_enforces_the_safelist_before_planning() {
3432        use boatramp_core::deploy::DeployStore;
3433        use boatramp_core::project::ProjectRef;
3434        use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3435
3436        let storage = Arc::new(MemStorage::default());
3437        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3438        let deploy = DeployStore::new(storage.clone(), kv.clone());
3439        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3440        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3441        rt.set_invoker(deploy.clone());
3442        let runner = rt
3443            .inner
3444            .as_ref()
3445            .unwrap()
3446            .federation_runner
3447            .get()
3448            .unwrap()
3449            .scoped(ProjectRef::new("default"), Vec::new());
3450
3451        let req = |query: &str| GraphqlRequest {
3452            query: Some(query.to_string()),
3453            persisted_hash: None,
3454            variables: "{}".to_string(),
3455            operation_name: None,
3456            authorization: None,
3457        };
3458
3459        // A query that was never registered is refused (deny-by-default) before any planning.
3460        assert!(matches!(
3461            runner.run(req("{ me { id } }"), 1).await,
3462            Err(SupergraphRunError::NotSafelisted)
3463        ));
3464
3465        // Register it in the safelist (any writer of the APQ store) — now it passes the floor and
3466        // reaches planning; against an empty supergraph the plan fails (proving the gate opened).
3467        let query = "{ me { id } }";
3468        let hash = crate::graphql_apq::sha256_hex(query);
3469        kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3470            .await
3471            .unwrap();
3472        assert!(matches!(
3473            runner.run(req(query), 1).await,
3474            Err(SupergraphRunError::PlanFailed(_))
3475        ));
3476
3477        // A run-persisted with an unregistered hash is refused the same way.
3478        let persisted = GraphqlRequest {
3479            query: None,
3480            persisted_hash: Some("deadbeef".to_string()),
3481            variables: "{}".to_string(),
3482            operation_name: None,
3483            authorization: None,
3484        };
3485        assert!(matches!(
3486            runner.run(persisted, 1).await,
3487            Err(SupergraphRunError::NotSafelisted)
3488        ));
3489    }
3490
3491    /// **Live gate (v0.4.6, PLAN-async-lane-propagation):** `graphql::run` propagates the caller's
3492    /// resolved **principal** to a federated sub-fetch, so a subgraph resolver reached over the
3493    /// supergraph with NO request bearer still resolves its own-tenancy from the inherited principal
3494    /// (symmetric to `emit::invoke`). Drives the REAL `FederationRunner` → invoke → engine over a REAL
3495    /// libsql-backed subgraph function (`graphql-scope-probe`, whose `items` field is host-scoped via
3496    /// the `{scope}` marker): running as tenant B returns ONLY B's rows; running with no principal
3497    /// fails closed (the pre-v0.4.6 behavior — never a leak). `#[ignore]`d (static-musl libsql
3498    /// segfault); the CI job runs it on the host toolchain + greps the marker.
3499    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3500    #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI graphql-propagation gate"]
3501    async fn graphql_run_propagates_caller_principal_to_a_scoped_subfetch() {
3502        use boatramp_core::deploy::{sha256_hex, DeployStore};
3503        use boatramp_core::function::{
3504            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3505        };
3506        use boatramp_core::project::ProjectRef;
3507        use boatramp_core::sql::{SqlBackends, SqlValue};
3508        use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3509        use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, ScopeFact};
3510
3511        // The compiled subgraph probe: its one root field `items` is host-tenancy-scoped
3512        // (`SELECT id FROM items WHERE {scope}`), so its response reveals which tenant the host
3513        // resolved for it, and it fails closed with no principal.
3514        const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3515
3516        let storage = Arc::new(MemStorage::default());
3517        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3518        let deploy = DeployStore::new(storage.clone(), kv.clone());
3519
3520        // A real per-site libsql backend; seed tenant A + B rows in the probe's OWN function DB
3521        // (`fn/<name>` — the exact identity `build_function_bindings` opens for `sql_query::open("")`).
3522        let sql_dir = std::env::temp_dir().join(format!("br-gqlprop-{}", std::process::id()));
3523        let _ = std::fs::remove_dir_all(&sql_dir);
3524        let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3525        let db = backends
3526            .database("default", "fn/scopeprobe", "")
3527            .await
3528            .unwrap();
3529        {
3530            let mut tx = db.begin().await.unwrap();
3531            tx.execute(
3532                "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3533                &[],
3534            )
3535            .await
3536            .unwrap();
3537            for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3538                tx.execute(
3539                    "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3540                    &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3541                )
3542                .await
3543                .unwrap();
3544            }
3545            tx.commit().await.unwrap();
3546        }
3547        let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3548
3549        // Deploy the probe as an invocable subgraph function, scoped on `tenant_id` (read own). The
3550        // declared source is irrelevant on the INHERITED path — the principal comes from the caller.
3551        let hash = sha256_hex(PROBE);
3552        let stream: ByteStream =
3553            futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3554        deploy.put_blob(&hash, stream).await.unwrap();
3555        let function = Function {
3556            name: "scopeprobe".into(),
3557            owner: Owner::Project("default".into()),
3558            versions: vec![FunctionVersion {
3559                id: "v1".into(),
3560                component: hash.clone(),
3561                created: 0,
3562                lifecycle: Lifecycle::Independent,
3563            }],
3564            active: "v1".into(),
3565            aliases: Default::default(),
3566            config: FunctionConfig {
3567                imports: vec!["sql".into()],
3568                tenancy: Some(Tenancy::Scoped {
3569                    column: "tenant_id".into(),
3570                    sources: vec![TenantSource::None],
3571                    read: AccessMode::Own,
3572                    write: AccessMode::None,
3573                }),
3574                ..Default::default()
3575            },
3576        };
3577        deploy
3578            .put_function(ProjectRef::DEFAULT, &function)
3579            .await
3580            .unwrap();
3581
3582        // Register it as a subgraph (default Function backend) + safelist the op.
3583        crate::graphql_registry::publish(
3584            kv.as_ref(),
3585            "default",
3586            "scopeprobe",
3587            "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }",
3588        )
3589        .await
3590        .unwrap();
3591        let query = "{ items { id } }";
3592        let op_hash = crate::graphql_apq::sha256_hex(query);
3593        kv.put(
3594            &format!("hapq/default/{op_hash}"),
3595            query.as_bytes().to_vec(),
3596        )
3597        .await
3598        .unwrap();
3599
3600        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3601        let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3602        rt.set_invoker(deploy.clone());
3603        let fed = rt
3604            .inner
3605            .as_ref()
3606            .unwrap()
3607            .federation_runner
3608            .get()
3609            .unwrap()
3610            .clone();
3611        let req = || GraphqlRequest {
3612            query: Some(query.to_string()),
3613            persisted_hash: None,
3614            variables: "{}".to_string(),
3615            operation_name: None,
3616            authorization: None,
3617        };
3618
3619        // (1) Propagation: run as tenant B → the subgraph inherits B → returns ONLY B's rows.
3620        let runner_b = fed.scoped(
3621            ProjectRef::new("default"),
3622            vec![ScopeFact {
3623                axis: ScopeAxis::Tenant,
3624                value: SqlValue::Text("tenant_B".into()),
3625            }],
3626        );
3627        let body_b = String::from_utf8_lossy(&runner_b.run(req(), 0).await.unwrap()).into_owned();
3628        assert!(
3629            body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
3630            "graphql::run propagated principal B → the subgraph read ONLY tenant B's rows: {body_b}"
3631        );
3632
3633        // (2) Control: run with NO principal → the subgraph's own read fails closed → no rows leak
3634        // (the pre-v0.4.6 behavior for the empty-caller_tenant path).
3635        let runner_empty = fed.scoped(ProjectRef::new("default"), Vec::new());
3636        let body_none =
3637            String::from_utf8_lossy(&runner_empty.run(req(), 0).await.unwrap()).into_owned();
3638        assert!(
3639            !body_none.contains("\"a1\"")
3640                && !body_none.contains("\"b1\"")
3641                && !body_none.contains("\"b2\""),
3642            "with NO propagated principal the subgraph fails closed — no rows leak: {body_none}"
3643        );
3644
3645        let _ = std::fs::remove_dir_all(&sql_dir);
3646        println!(
3647            "GRAPHQL-RUN PRINCIPAL PROPAGATION OK: graphql::run carried the caller's resolved principal \
3648             (tenant B) to a federated subgraph sub-fetch, which resolved its own-tenancy from the \
3649             inherited principal and returned ONLY tenant B's rows over a real libsql engine; with no \
3650             principal the same sub-fetch failed closed (no rows) — the async lane can drive a \
3651             tenant-scoped supergraph read/write with no request bearer, symmetric to emit::invoke"
3652        );
3653    }
3654
3655    /// Gap 1 live gate (v0.4.7): the external `/graphql` federation gateway serves a **target-tenant**
3656    /// field on a **WASM subgraph** — the gateway resolves `B` per fetch (domain source) and FORCES a
3657    /// `HostTenancy::target` binding onto the subgraph FUNCTION invocation via `invoke_target`, so the
3658    /// guest's own `sql` is confined to `tenant = B AND <public subset>`. Drives the REAL
3659    /// `BackendRouter` → `invoke_target` → engine over the REAL libsql-backed probe: a request whose
3660    /// routed domain resolves tenant B returns ONLY B's PUBLIC rows (never A's, never B's PRIVATE
3661    /// rows); with no resolved target it fails closed. `#[ignore]`d (static-musl libsql segfault); the
3662    /// CI job runs it on the host toolchain + greps the marker.
3663    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3664    #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
3665    async fn gateway_forces_target_confinement_onto_a_wasm_subgraph() {
3666        use boatramp_core::deploy::{sha256_hex, DeployStore};
3667        use boatramp_core::function::{
3668            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3669        };
3670        use boatramp_core::project::ProjectRef;
3671        use boatramp_core::sql::{SqlBackends, SqlValue};
3672        use boatramp_core::tenancy::{
3673            AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
3674            TenancySchema, TenantSource,
3675        };
3676        use boatramp_handlers::{HandlerEngine, Limits};
3677
3678        // The SAME compiled probe as the propagation gate — its `items` field runs
3679        // `SELECT id FROM items WHERE {scope}`. Under a FORCED TARGET binding the host neutralises
3680        // `{scope}` to `1=1` and AST-rewrites the statement to confine `items` to `tenant = B AND
3681        // <public predicate>` — so the probe's own declared tenancy is irrelevant (the gateway forces
3682        // the target); the fixture needs no change.
3683        const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3684
3685        let storage = Arc::new(MemStorage::default());
3686        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3687        let deploy = DeployStore::new(storage.clone(), kv.clone());
3688
3689        // Seed the probe's OWN function DB with A's row + B's PUBLIC and PRIVATE rows, so the gate
3690        // proves BOTH the tenant confinement (no A) AND the public-subset confinement (no B-private).
3691        let sql_dir = std::env::temp_dir().join(format!("br-gwtarget-{}", std::process::id()));
3692        let _ = std::fs::remove_dir_all(&sql_dir);
3693        let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3694        let db = backends
3695            .database("default", "fn/scopeprobe", "")
3696            .await
3697            .unwrap();
3698        {
3699            let mut tx = db.begin().await.unwrap();
3700            tx.execute(
3701                "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
3702                &[],
3703            )
3704            .await
3705            .unwrap();
3706            for (id, tenant, published) in [
3707                ("a_pub", "tenant_A", 1),
3708                ("b_pub", "tenant_B", 1),
3709                ("b_priv", "tenant_B", 0),
3710            ] {
3711                tx.execute(
3712                    "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
3713                    &[
3714                        SqlValue::Text(id.into()),
3715                        SqlValue::Text(tenant.into()),
3716                        SqlValue::Integer(published),
3717                    ],
3718                )
3719                .await
3720                .unwrap();
3721            }
3722            tx.commit().await.unwrap();
3723        }
3724        let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3725
3726        // Deploy the probe as a subgraph FUNCTION (its Scoped-own config is bypassed under a forced
3727        // target — the composed SDL field's target class is the authority).
3728        let hash = sha256_hex(PROBE);
3729        let stream: ByteStream =
3730            futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3731        deploy.put_blob(&hash, stream).await.unwrap();
3732        let function = Function {
3733            name: "scopeprobe".into(),
3734            owner: Owner::Project("default".into()),
3735            versions: vec![FunctionVersion {
3736                id: "v1".into(),
3737                component: hash.clone(),
3738                created: 0,
3739                lifecycle: Lifecycle::Independent,
3740            }],
3741            active: "v1".into(),
3742            aliases: Default::default(),
3743            config: FunctionConfig {
3744                imports: vec!["sql".into()],
3745                tenancy: Some(Tenancy::Scoped {
3746                    column: "tenant_id".into(),
3747                    sources: vec![TenantSource::None],
3748                    read: AccessMode::Own,
3749                    write: AccessMode::None,
3750                }),
3751                ..Default::default()
3752            },
3753        };
3754        deploy
3755            .put_function(ProjectRef::DEFAULT, &function)
3756            .await
3757            .unwrap();
3758
3759        // Compose a supergraph whose `items` field is a TARGET field (via the routed domain), plan
3760        // the op, and build a router with the host-trusted target inputs resolving B = "tenant_B".
3761        let sdl = "type Query { items: [Item!]! @tenant(scope: target, via: [domain], public: \"items\") }\n\
3762                   type Item @key(fields: \"id\") { id: ID! }";
3763        let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3764        let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3765
3766        let mut schema = TenancySchema {
3767            default_tenant_key: "tenant_id".into(),
3768            tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
3769            ..Default::default()
3770        };
3771        schema.public_subsets.insert(
3772            "items".into(),
3773            PublicSubset {
3774                predicate: PublicPredicate {
3775                    terms: vec![PublicTerm::Cmp {
3776                        column: "published".into(),
3777                        op: boatramp_core::tenancy::PublicCmp::Eq,
3778                        value: boatramp_core::tenancy::PublicLiteral::Int(1),
3779                    }],
3780                },
3781                world_public: true,
3782                listable: true,
3783            },
3784        );
3785
3786        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3787        let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3788        rt.set_invoker(deploy.clone());
3789        let inner = rt.inner.as_ref().unwrap();
3790        let invoker = inner.invoker.get().unwrap().clone();
3791
3792        let make_router = |domain: Option<&str>| {
3793            crate::graphql_gateway::BackendRouter::new(
3794                invoker.scoped(ProjectRef::new("default"), Vec::new()),
3795                "default".to_string(),
3796                inner.sql.clone(),
3797                std::collections::BTreeMap::new(),
3798                None,
3799            )
3800            .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
3801                schema: Arc::new(schema.clone()),
3802                domain_context: domain.map(str::to_string),
3803                target_handle: None,
3804                capability_anchor: None,
3805            }))
3806        };
3807
3808        // (1) Domain resolves B → the forced target confines the wasm subgraph to B's PUBLIC rows.
3809        let router_b = make_router(Some("tenant_B"));
3810        let out_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({})).await;
3811        let body_b = out_b.to_string();
3812        assert!(
3813            body_b.contains("\"b_pub\"")
3814                && !body_b.contains("\"b_priv\"")
3815                && !body_b.contains("\"a_pub\""),
3816            "gateway forced target B onto the wasm subgraph → ONLY B's PUBLIC row (b_pub), never B's \
3817             private row nor A's: {body_b}"
3818        );
3819
3820        // (2) No resolved target (no domain, no capability, no handle) → fail closed, no rows.
3821        let router_none = make_router(None);
3822        let out_none =
3823            crate::graphql_gateway::execute(&plan, &router_none, &serde_json::json!({})).await;
3824        let body_none = out_none.to_string();
3825        assert!(
3826            !body_none.contains("\"a_pub\"")
3827                && !body_none.contains("\"b_pub\"")
3828                && !body_none.contains("\"b_priv\""),
3829            "with no resolved target the wasm-subgraph target fetch fails closed — no rows: {body_none}"
3830        );
3831
3832        let _ = std::fs::remove_dir_all(&sql_dir);
3833        println!(
3834            "GATEWAY WASM-TARGET OK: the /graphql gateway resolved target tenant B from the routed \
3835             domain and FORCED a HostTenancy::target binding onto the wasm subgraph invocation \
3836             (invoke_target), confining the guest's own SQL to tenant=B AND published=1 over a real \
3837             libsql engine — returned ONLY B's public row (b_pub), never B's private row (b_priv) nor \
3838             tenant A's (a_pub); with no resolved target the fetch failed closed"
3839        );
3840    }
3841
3842    /// **Live gate (v0.4.11):** the EXTERNAL `/graphql` federation gateway propagates the caller's
3843    /// resolved OWN principal to a WASM subgraph fetch, so a post-P48 host-forced `own` read reached
3844    /// through the gateway returns the CALLER's tenant rows instead of failing closed (the
3845    /// production topology: an authed console query fans across `own`-scoped wasm subgraphs). Drives
3846    /// the REAL `BackendRouter` → invoke → engine over the REAL libsql-backed probe (its `items`
3847    /// field is `own`-scoped via `{scope}`): caller principal = tenant B ⇒ ONLY B's rows; an EMPTY
3848    /// principal (anon) ⇒ fail closed (the pre-v0.4.11 bug — every federated `own` read returned
3849    /// nothing). `#[ignore]`d (static-musl libsql segfault); the CI job runs it on the host toolchain
3850    /// + greps the marker.
3851    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3852    #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-own-propagation gate"]
3853    async fn gateway_propagates_caller_own_principal_to_a_wasm_subgraph() {
3854        use boatramp_core::deploy::{sha256_hex, DeployStore};
3855        use boatramp_core::function::{
3856            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3857        };
3858        use boatramp_core::project::ProjectRef;
3859        use boatramp_core::sql::{SqlBackends, SqlValue};
3860        use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3861        use boatramp_handlers::{HandlerEngine, Limits, ScopeFact};
3862
3863        // The SAME compiled probe — its `items` field runs `SELECT id FROM items WHERE {scope}`. On
3864        // the INHERITED (own) path the host injects the caller's principal into `{scope}`, so the
3865        // response reveals which tenant the gateway propagated (and fails closed with none).
3866        const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3867
3868        let storage = Arc::new(MemStorage::default());
3869        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3870        let deploy = DeployStore::new(storage.clone(), kv.clone());
3871
3872        let sql_dir = std::env::temp_dir().join(format!("br-gwown-{}", std::process::id()));
3873        let _ = std::fs::remove_dir_all(&sql_dir);
3874        let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3875        let db = backends
3876            .database("default", "fn/scopeprobe", "")
3877            .await
3878            .unwrap();
3879        {
3880            let mut tx = db.begin().await.unwrap();
3881            tx.execute(
3882                "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3883                &[],
3884            )
3885            .await
3886            .unwrap();
3887            for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3888                tx.execute(
3889                    "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3890                    &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3891                )
3892                .await
3893                .unwrap();
3894            }
3895            tx.commit().await.unwrap();
3896        }
3897        let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3898
3899        // Deploy the probe as an `own`-scoped subgraph FUNCTION (source `None` — on the inherited
3900        // path the principal comes from the caller, not a declared source).
3901        let hash = sha256_hex(PROBE);
3902        let stream: ByteStream =
3903            futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3904        deploy.put_blob(&hash, stream).await.unwrap();
3905        let function = Function {
3906            name: "scopeprobe".into(),
3907            owner: Owner::Project("default".into()),
3908            versions: vec![FunctionVersion {
3909                id: "v1".into(),
3910                component: hash.clone(),
3911                created: 0,
3912                lifecycle: Lifecycle::Independent,
3913            }],
3914            active: "v1".into(),
3915            aliases: Default::default(),
3916            config: FunctionConfig {
3917                imports: vec!["sql".into()],
3918                tenancy: Some(Tenancy::Scoped {
3919                    column: "tenant_id".into(),
3920                    sources: vec![TenantSource::None],
3921                    read: AccessMode::Own,
3922                    write: AccessMode::None,
3923                }),
3924                ..Default::default()
3925            },
3926        };
3927        deploy
3928            .put_function(ProjectRef::DEFAULT, &function)
3929            .await
3930            .unwrap();
3931
3932        // A PLAIN (own) supergraph — no `@tenant(scope: target)` — so the fetch takes the ordinary
3933        // inherited-principal `invoke` path (not `invoke_target`).
3934        let sdl = "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }";
3935        let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3936        let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3937
3938        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3939        let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3940        rt.set_invoker(deploy.clone());
3941        let inner = rt.inner.as_ref().unwrap();
3942        let invoker = inner.invoker.get().unwrap().clone();
3943
3944        // The exact construction `federation_gateway` performs: a BackendRouter over an invoker
3945        // scoped to the caller's OWN principal (v0.4.11 — previously `Vec::new()`).
3946        let make_router = |facts: Vec<ScopeFact>| {
3947            crate::graphql_gateway::BackendRouter::new(
3948                invoker.scoped(ProjectRef::new("default"), facts),
3949                "default".to_string(),
3950                inner.sql.clone(),
3951                std::collections::BTreeMap::new(),
3952                None,
3953            )
3954        };
3955
3956        // (1) Caller principal = tenant B → the wasm subgraph inherits B → returns ONLY B's rows.
3957        let router_b = make_router(vec![ScopeFact {
3958            axis: ScopeAxis::Tenant,
3959            value: SqlValue::Text("tenant_B".into()),
3960        }]);
3961        let body_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({}))
3962            .await
3963            .to_string();
3964        assert!(
3965            body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
3966            "gateway propagated principal B → the wasm subgraph read ONLY tenant B's rows: {body_b}"
3967        );
3968
3969        // (2) Empty principal (anonymous) → the own fetch fails closed — no rows, anon NOT widened.
3970        let router_anon = make_router(Vec::new());
3971        let body_anon =
3972            crate::graphql_gateway::execute(&plan, &router_anon, &serde_json::json!({}))
3973                .await
3974                .to_string();
3975        assert!(
3976            !body_anon.contains("\"a1\"")
3977                && !body_anon.contains("\"b1\"")
3978                && !body_anon.contains("\"b2\""),
3979            "with an empty principal the wasm-subgraph own fetch fails closed — no rows: {body_anon}"
3980        );
3981
3982        let _ = std::fs::remove_dir_all(&sql_dir);
3983        println!(
3984            "GATEWAY OWN-PROPAGATION OK: the /graphql gateway propagated the caller's resolved OWN \
3985             principal (tenant B) to a wasm subgraph fetch, which scoped its own read to the \
3986             inherited principal and returned ONLY tenant B's rows over a real libsql engine; with \
3987             an empty principal the same own fetch failed closed (no rows) — anon is never widened"
3988        );
3989    }
3990
3991    /// v0.4.8 live gate: `scope: target_or_null` on a WASM subgraph reads `B` ⊕ the shared
3992    /// `NULL`-tenant **base** rows (the funnel inheritance floor), still confined to the public
3993    /// subset on BOTH — a base-only tenant sees the base floor, never another tenant's rows nor any
3994    /// private (non-public) row. Drives the REAL gateway → invoke_target → libsql over the probe.
3995    /// `#[ignore]`d (static-musl libsql segfault); the CI job runs it on the host toolchain.
3996    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3997    #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
3998    async fn gateway_target_or_null_includes_the_shared_base() {
3999        use boatramp_core::deploy::{sha256_hex, DeployStore};
4000        use boatramp_core::function::{
4001            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4002        };
4003        use boatramp_core::project::ProjectRef;
4004        use boatramp_core::sql::{SqlBackends, SqlValue};
4005        use boatramp_core::tenancy::{
4006            AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
4007            TenancySchema, TenantSource,
4008        };
4009        use boatramp_handlers::{HandlerEngine, Limits};
4010
4011        const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
4012
4013        let storage = Arc::new(MemStorage::default());
4014        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4015        let deploy = DeployStore::new(storage.clone(), kv.clone());
4016
4017        let sql_dir = std::env::temp_dir().join(format!("br-gwton-{}", std::process::id()));
4018        let _ = std::fs::remove_dir_all(&sql_dir);
4019        let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
4020        let db = backends
4021            .database("default", "fn/scopeprobe", "")
4022            .await
4023            .unwrap();
4024        {
4025            let mut tx = db.begin().await.unwrap();
4026            tx.execute(
4027                "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
4028                &[],
4029            )
4030            .await
4031            .unwrap();
4032            // base_pub: shared floor (NULL tenant, public) → visible. base_priv: NULL but NOT public
4033            // → excluded (the subset conjoins the base too). b_pub: B public → visible. b_priv: B
4034            // private → excluded. a_pub: another tenant → excluded (never A's rows).
4035            for (id, tenant, published) in [
4036                ("base_pub", None, 1),
4037                ("base_priv", None, 0),
4038                ("b_pub", Some("tenant_B"), 1),
4039                ("b_priv", Some("tenant_B"), 0),
4040                ("a_pub", Some("tenant_A"), 1),
4041            ] {
4042                tx.execute(
4043                    "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
4044                    &[
4045                        SqlValue::Text(id.into()),
4046                        tenant
4047                            .map(|t| SqlValue::Text(t.into()))
4048                            .unwrap_or(SqlValue::Null),
4049                        SqlValue::Integer(published),
4050                    ],
4051                )
4052                .await
4053                .unwrap();
4054            }
4055            tx.commit().await.unwrap();
4056        }
4057        let sql: Arc<dyn SqlBackends> = Arc::new(backends);
4058
4059        let hash = sha256_hex(PROBE);
4060        let stream: ByteStream =
4061            futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
4062        deploy.put_blob(&hash, stream).await.unwrap();
4063        let function = Function {
4064            name: "scopeprobe".into(),
4065            owner: Owner::Project("default".into()),
4066            versions: vec![FunctionVersion {
4067                id: "v1".into(),
4068                component: hash.clone(),
4069                created: 0,
4070                lifecycle: Lifecycle::Independent,
4071            }],
4072            active: "v1".into(),
4073            aliases: Default::default(),
4074            config: FunctionConfig {
4075                imports: vec!["sql".into()],
4076                tenancy: Some(Tenancy::Scoped {
4077                    column: "tenant_id".into(),
4078                    sources: vec![TenantSource::None],
4079                    read: AccessMode::Own,
4080                    write: AccessMode::None,
4081                }),
4082                ..Default::default()
4083            },
4084        };
4085        deploy
4086            .put_function(ProjectRef::DEFAULT, &function)
4087            .await
4088            .unwrap();
4089
4090        // The funnel field: `scope: target_or_null` — base⊕B.
4091        let sdl = "type Query { items: [Item!]! @tenant(scope: target_or_null, via: [domain], public: \"items\") }\n\
4092                   type Item @key(fields: \"id\") { id: ID! }";
4093        let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
4094        let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
4095
4096        let mut schema = TenancySchema {
4097            default_tenant_key: "tenant_id".into(),
4098            tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
4099            ..Default::default()
4100        };
4101        schema.public_subsets.insert(
4102            "items".into(),
4103            PublicSubset {
4104                predicate: PublicPredicate {
4105                    terms: vec![PublicTerm::Cmp {
4106                        column: "published".into(),
4107                        op: boatramp_core::tenancy::PublicCmp::Eq,
4108                        value: boatramp_core::tenancy::PublicLiteral::Int(1),
4109                    }],
4110                },
4111                world_public: true,
4112                listable: true,
4113            },
4114        );
4115
4116        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4117        let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
4118        rt.set_invoker(deploy.clone());
4119        let inner = rt.inner.as_ref().unwrap();
4120        let router = crate::graphql_gateway::BackendRouter::new(
4121            inner
4122                .invoker
4123                .get()
4124                .unwrap()
4125                .clone()
4126                .scoped(ProjectRef::new("default"), Vec::new()),
4127            "default".to_string(),
4128            inner.sql.clone(),
4129            std::collections::BTreeMap::new(),
4130            None,
4131        )
4132        .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
4133            schema: Arc::new(schema),
4134            domain_context: Some("tenant_B".into()),
4135            target_handle: None,
4136            capability_anchor: None,
4137        }));
4138
4139        let out = crate::graphql_gateway::execute(&plan, &router, &serde_json::json!({})).await;
4140        let body = out.to_string();
4141        assert!(
4142            body.contains("\"base_pub\"") && body.contains("\"b_pub\""),
4143            "target_or_null returned B's public row AND the shared base floor: {body}"
4144        );
4145        assert!(
4146            !body.contains("\"base_priv\"")
4147                && !body.contains("\"b_priv\"")
4148                && !body.contains("\"a_pub\""),
4149            "the public subset confines BOTH B and base (no base_priv, no b_priv), and no other \
4150             tenant's rows (no a_pub): {body}"
4151        );
4152
4153        let _ = std::fs::remove_dir_all(&sql_dir);
4154        println!(
4155            "GATEWAY TARGET-OR-NULL OK: scope:target_or_null on a wasm subgraph read tenant B's \
4156             public row (b_pub) PLUS the shared NULL-tenant base floor (base_pub), each confined to \
4157             published=1 — never B's private row (b_priv), never a non-public base row (base_priv), \
4158             never another tenant's row (a_pub); the base-only funnel keeps its inheritance floor"
4159        );
4160    }
4161
4162    /// Tenant isolation (Step 7a): the background scheduler fans out over every
4163    /// project, so a **non-default** project's queued async invocation is drained
4164    /// and metered **within that project** — never leaking into `default`. Before
4165    /// the fan-out the tick only ever scanned `default`, so an `acme` function's
4166    /// queue would never drain at all.
4167    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4168    async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
4169        use crate::scheduler::{run_scheduler_tick, CronNow};
4170        use boatramp_core::deploy::DeployStore;
4171        use boatramp_core::function::{
4172            Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4173        };
4174        use boatramp_handlers::{HandlerEngine, Limits};
4175        use futures::StreamExt;
4176
4177        const HTTP_200: &[u8] =
4178            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4179
4180        let storage = Arc::new(MemStorage::default());
4181        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4182        let deploy = DeployStore::new(storage.clone(), kv.clone());
4183
4184        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4185        let stream: ByteStream =
4186            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4187        deploy.put_blob(&hash, stream).await.unwrap();
4188
4189        // A function + a queued async invocation, both under project `acme`.
4190        let acme = ProjectRef::new("acme");
4191        let function = Function {
4192            name: "worker".into(),
4193            owner: Owner::Project("acme".into()),
4194            versions: vec![FunctionVersion {
4195                id: "v1".into(),
4196                component: hash.clone(),
4197                created: 0,
4198                lifecycle: Lifecycle::Independent,
4199            }],
4200            active: "v1".into(),
4201            aliases: Default::default(),
4202            config: Default::default(),
4203        };
4204        deploy.put_function(acme, &function).await.unwrap();
4205        let inv = Invocation {
4206            id: "inv1".into(),
4207            function: "worker".into(),
4208            version: "v1".into(),
4209            mode: InvokeMode::Async,
4210            status: InvocationStatus::Queued,
4211            idempotency_key: None,
4212            attempts: 0,
4213            lease_expires: None,
4214            request_b64: None,
4215            request_content_type: None,
4216            result: None,
4217            created: 0,
4218            updated: 0,
4219        };
4220        deploy.put_invocation(acme, &inv).await.unwrap();
4221
4222        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4223        let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4224        let inner = rt.inner.as_ref().unwrap();
4225
4226        // One tick: `discover_projects()` yields `["acme"]`, so the drain runs
4227        // under `acme`. A fixed `CronNow` (no cron to match) keeps it deterministic.
4228        let mut wasm_cache = std::collections::HashMap::new();
4229        let mut cron_state = std::collections::HashMap::new();
4230        let mut sweep = std::collections::HashMap::new();
4231        let now = CronNow {
4232            minute: 0,
4233            hour: 0,
4234            dom: 1,
4235            month: 1,
4236            dow: 0,
4237            minute_stamp: 0,
4238        };
4239        run_scheduler_tick(
4240            inner,
4241            &deploy,
4242            &mut wasm_cache,
4243            &mut cron_state,
4244            &mut sweep,
4245            now,
4246        )
4247        .await
4248        .unwrap();
4249
4250        // The drain claims + spawns the run off the tick, so poll for the
4251        // terminal transition rather than assuming synchronous settlement.
4252        let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
4253        // The invocation settled Succeeded **in `acme`** …
4254        assert_eq!(settled.status, InvocationStatus::Succeeded);
4255        // … metered in `acme` …
4256        let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
4257        assert_eq!(metering.invocations, 1);
4258        // … and nothing leaked into `default` (no record, no metering there).
4259        assert!(deploy
4260            .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
4261            .await
4262            .unwrap()
4263            .is_none());
4264        assert!(deploy
4265            .get_metering(ProjectRef::DEFAULT, "worker")
4266            .await
4267            .unwrap()
4268            .is_none());
4269    }
4270
4271    /// Poll a durable invocation until it leaves the in-flight states — the drain
4272    /// spawns the run off the tick, so settlement is asynchronous. Panics on
4273    /// timeout so a stuck run fails the test rather than hanging it.
4274    #[cfg(feature = "handlers")]
4275    async fn poll_invocation_settled(
4276        deploy: &boatramp_core::deploy::DeployStore,
4277        project: ProjectRef<'_>,
4278        function: &str,
4279        id: &str,
4280    ) -> boatramp_core::function::Invocation {
4281        use boatramp_core::function::InvocationStatus;
4282        for _ in 0..200 {
4283            if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
4284                if matches!(
4285                    inv.status,
4286                    InvocationStatus::Succeeded | InvocationStatus::Failed
4287                ) {
4288                    return inv;
4289                }
4290            }
4291            tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4292        }
4293        panic!("invocation {function}/{id} never settled");
4294    }
4295
4296    /// A `Running` invocation whose **lease has elapsed** (the node holding it
4297    /// crashed mid-run) is reclaimed by a later drain and runs to completion; one
4298    /// whose lease is still in the future is left untouched (no double-run). This
4299    /// is the crash-recovery guarantee that makes a large async ceiling safe.
4300    #[cfg(feature = "handlers")]
4301    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4302    async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
4303        use crate::scheduler::{run_scheduler_tick, CronNow};
4304        use boatramp_core::deploy::DeployStore;
4305        use boatramp_core::function::{
4306            Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4307        };
4308        use boatramp_handlers::{HandlerEngine, Limits};
4309        use futures::StreamExt;
4310
4311        const HTTP_200: &[u8] =
4312            include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4313
4314        let storage = Arc::new(MemStorage::default());
4315        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4316        let deploy = DeployStore::new(storage.clone(), kv.clone());
4317        let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4318        let stream: ByteStream =
4319            futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4320        deploy.put_blob(&hash, stream).await.unwrap();
4321
4322        let function = Function {
4323            name: "worker".into(),
4324            owner: Owner::Project("default".into()),
4325            versions: vec![FunctionVersion {
4326                id: "v1".into(),
4327                component: hash.clone(),
4328                created: 0,
4329                lifecycle: Lifecycle::Independent,
4330            }],
4331            active: "v1".into(),
4332            aliases: Default::default(),
4333            config: Default::default(),
4334        };
4335        deploy
4336            .put_function(ProjectRef::DEFAULT, &function)
4337            .await
4338            .unwrap();
4339
4340        // Two `Running` records: one already claimed by a now-dead node (lease in
4341        // the past), one held by a live node (lease far in the future).
4342        let base = Invocation {
4343            id: String::new(),
4344            function: "worker".into(),
4345            version: "v1".into(),
4346            mode: InvokeMode::Async,
4347            status: InvocationStatus::Running,
4348            idempotency_key: None,
4349            attempts: 1,
4350            lease_expires: None,
4351            request_b64: None,
4352            request_content_type: None,
4353            result: None,
4354            created: 0,
4355            updated: 0,
4356        };
4357        let orphan = Invocation {
4358            id: "orphan".into(),
4359            lease_expires: Some(1),
4360            ..base.clone()
4361        };
4362        deploy
4363            .put_invocation(ProjectRef::DEFAULT, &orphan)
4364            .await
4365            .unwrap();
4366        let live = Invocation {
4367            id: "live".into(),
4368            lease_expires: Some(u64::MAX),
4369            ..base.clone()
4370        };
4371        deploy
4372            .put_invocation(ProjectRef::DEFAULT, &live)
4373            .await
4374            .unwrap();
4375
4376        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4377        let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4378        let inner = rt.inner.as_ref().unwrap();
4379
4380        let now = CronNow {
4381            minute: 0,
4382            hour: 0,
4383            dom: 1,
4384            month: 1,
4385            dow: 0,
4386            minute_stamp: 0,
4387        };
4388        let mut wasm_cache = std::collections::HashMap::new();
4389        let mut cron_state = std::collections::HashMap::new();
4390        let mut sweep = std::collections::HashMap::new();
4391        run_scheduler_tick(
4392            inner,
4393            &deploy,
4394            &mut wasm_cache,
4395            &mut cron_state,
4396            &mut sweep,
4397            now,
4398        )
4399        .await
4400        .unwrap();
4401
4402        // The orphan was reclaimed and ran to completion, its attempt advanced …
4403        let settled =
4404            poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
4405        assert_eq!(settled.status, InvocationStatus::Succeeded);
4406        assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
4407        assert_eq!(
4408            settled.lease_expires, None,
4409            "a settled invocation drops its lease"
4410        );
4411        // … while the live-lease invocation was left exactly as it was.
4412        let live_after = deploy
4413            .get_invocation(ProjectRef::DEFAULT, "worker", "live")
4414            .await
4415            .unwrap()
4416            .unwrap();
4417        assert_eq!(live_after.status, InvocationStatus::Running);
4418        assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
4419        assert_eq!(live_after.lease_expires, Some(u64::MAX));
4420    }
4421
4422    /// BR-TEN-1 (Critical) gate: a same-named **function** in two tenant
4423    /// projects must NOT share one guest kv namespace. Two functions both named
4424    /// `store` — one in `acme`, one in `globex` — each writes to guest kv key
4425    /// `hits` (via the committed `kv-counter` fixture, whose default bucket key
4426    /// is `hits`). We assert the writes land under DISTINCT host kv keys
4427    /// (`hkv/acme/fn/store/hits` vs `hkv/globex/fn/store/hits`) and that neither
4428    /// aliases the bare pre-project key (`hkv/fn/store/hits`). A third `store`
4429    /// under the reserved `default` project is asserted to keep exactly that bare
4430    /// key (back-compat: no data migration for a pre-project store).
4431    ///
4432    /// This is a real end-to-end kv-isolation assertion driven through the live
4433    /// engine (`execute_function`) with the existing `kv-counter` fixture — the
4434    /// preferred form over unit-testing scope construction — because that
4435    /// exercises the actual `build_function_bindings` scope path a guest sees.
4436    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4437    async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
4438        use boatramp_core::deploy::DeployStore;
4439        use boatramp_core::function::{
4440            Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4441        };
4442        use boatramp_handlers::{HandlerEngine, Limits};
4443        use futures::StreamExt;
4444
4445        let storage = Arc::new(MemStorage::default());
4446        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4447        let deploy = DeployStore::new(storage.clone(), kv.clone());
4448
4449        // The `kv-counter` fixture increments a "hits" counter in its default kv
4450        // bucket, so a single invocation writes `<scope>/hits`.
4451        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4452        let stream: ByteStream =
4453            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4454        deploy.put_blob(&hash, stream).await.unwrap();
4455
4456        // A single `store` function definition (imports `wasi:keyvalue`); the
4457        // guest binding scope comes from the `project` passed to
4458        // `execute_function`, not from the function's `owner`, so one definition
4459        // suffices to prove per-tenant scoping.
4460        let store = Function {
4461            name: "store".into(),
4462            owner: Owner::Project("default".into()),
4463            versions: vec![FunctionVersion {
4464                id: "v1".into(),
4465                component: hash.clone(),
4466                created: 0,
4467                lifecycle: Lifecycle::Independent,
4468            }],
4469            active: "v1".into(),
4470            aliases: Default::default(),
4471            config: FunctionConfig {
4472                imports: vec!["wasi:keyvalue".into()],
4473                ..Default::default()
4474            },
4475        };
4476        let acme = ProjectRef::new("acme");
4477        let globex = ProjectRef::new("globex");
4478
4479        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4480        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4481        let inner = rt.inner.as_ref().unwrap();
4482
4483        let request = || {
4484            axum::http::Request::builder()
4485                .method("GET")
4486                .uri("/")
4487                .body(axum::body::Body::empty())
4488                .unwrap()
4489        };
4490
4491        // Invoke `store` in each of the two non-default projects, plus once in
4492        // `default`, all named identically.
4493        let component = store.resolve(&store.active).unwrap().to_owned();
4494        for project in [acme, globex, ProjectRef::DEFAULT] {
4495            let (response, _) = execute_function(
4496                inner,
4497                &deploy,
4498                project,
4499                &store,
4500                &component,
4501                request(),
4502                0,
4503                boatramp_handlers::Lane::Sync,
4504                crate::function_runtime::FnTenant::Request,
4505            )
4506            .await;
4507            assert!(response.status().is_success(), "invocation should succeed");
4508        }
4509
4510        // The three writes landed under THREE distinct host kv keys: the two
4511        // tenants are project-qualified, and `default` keeps the bare key.
4512        assert_eq!(
4513            kv.get("hkv/acme/fn/store/hits").await.unwrap(),
4514            Some(b"1".to_vec()),
4515            "acme's write must be tenant-qualified"
4516        );
4517        assert_eq!(
4518            kv.get("hkv/globex/fn/store/hits").await.unwrap(),
4519            Some(b"1".to_vec()),
4520            "globex's write must be tenant-qualified"
4521        );
4522        assert_eq!(
4523            kv.get("hkv/fn/store/hits").await.unwrap(),
4524            Some(b"1".to_vec()),
4525            "the default project must keep the byte-identical pre-project key"
4526        );
4527        // Sanity: had the fix regressed, all three would have collided on the
4528        // bare key and it would read "3", not "1".
4529    }
4530
4531    /// The cron driver: a due cron fires its route (loopback), once per
4532    /// matching minute (dedup), and with `overlap: Skip` a fire is skipped while
4533    /// a previous one is still running.
4534    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4535    async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
4536        use boatramp_core::config::{
4537            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
4538        };
4539        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
4540        use boatramp_handlers::{HandlerEngine, Limits};
4541        use futures::StreamExt;
4542        use std::sync::atomic::Ordering;
4543
4544        let storage = Arc::new(MemStorage::default());
4545        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4546        let deploy = DeployStore::new(storage.clone(), kv.clone());
4547
4548        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4549        let stream: ByteStream =
4550            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4551        deploy.put_blob(&hash, stream).await.unwrap();
4552        let mut files = std::collections::BTreeMap::new();
4553        files.insert(
4554            "counter.wasm".to_string(),
4555            FileEntry {
4556                hash: hash.clone(),
4557                size: KV_COUNTER.len() as u64,
4558                content_type: None,
4559                variants: std::collections::BTreeMap::new(),
4560            },
4561        );
4562        let manifest = Manifest {
4563            files,
4564            config: DeployConfig {
4565                handlers: vec![HandlerConfig {
4566                    tenancy: None,
4567                    token_claims: None,
4568                    route: "/".into(),
4569                    methods: Vec::new(),
4570                    component: "counter.wasm".into(),
4571                    imports: vec!["wasi:keyvalue".into()],
4572                    streaming: false,
4573                    limits: None,
4574                    env: std::collections::BTreeMap::new(),
4575                    invoke_targets: Vec::new(),
4576                }],
4577                crons: vec![CronConfig {
4578                    schedule: "* * * * *".into(),
4579                    route: "/".into(),
4580                    overlap: Overlap::Skip,
4581                }],
4582                ..Default::default()
4583            },
4584            ..Default::default()
4585        };
4586        let id = deploy.put_manifest(&manifest).await.unwrap();
4587        deploy
4588            .activate(ProjectRef::DEFAULT, "blog", &id)
4589            .await
4590            .unwrap();
4591        deploy
4592            .set_site_config(
4593                ProjectRef::DEFAULT,
4594                "blog",
4595                &SiteConfig {
4596                    handlers: Some(HandlersSiteConfig {
4597                        enabled: true,
4598                        allow_imports: vec!["wasi:keyvalue".into()],
4599                        ..Default::default()
4600                    }),
4601                    ..Default::default()
4602                },
4603            )
4604            .await
4605            .unwrap();
4606
4607        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4608        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4609        let inner = rt.inner.clone().unwrap();
4610        let mut wasm = std::collections::HashMap::new();
4611        let mut crons = std::collections::HashMap::new();
4612        let mut sweep = std::collections::HashMap::new();
4613        let at = |stamp| CronNow {
4614            minute: 0,
4615            hour: 0,
4616            dom: 1,
4617            month: 1,
4618            dow: 0,
4619            minute_stamp: stamp,
4620        };
4621
4622        // Fires once for the minute.
4623        let (_, handles) =
4624            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
4625                .await
4626                .unwrap();
4627        for h in handles {
4628            h.await.unwrap();
4629        }
4630        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
4631
4632        // Same minute → deduped (no fire).
4633        let (_, handles) =
4634            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
4635                .await
4636                .unwrap();
4637        assert!(handles.is_empty());
4638        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
4639
4640        // Next minute → fires again.
4641        let (_, handles) =
4642            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
4643                .await
4644                .unwrap();
4645        for h in handles {
4646            h.await.unwrap();
4647        }
4648        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
4649
4650        // overlap=Skip: a previous fire still running → the next minute is skipped.
4651        // The cron dedup key is project-qualified (`default|blog|cron|0`) so a
4652        // same-named site in another project can't dedup this one.
4653        crons
4654            .get("default|blog|cron|0")
4655            .unwrap()
4656            .running
4657            .store(true, Ordering::Release);
4658        let (_, handles) =
4659            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
4660                .await
4661                .unwrap();
4662        assert!(handles.is_empty());
4663        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
4664    }
4665
4666    /// Cluster cron single-firing: with a leader gate that
4667    /// returns `false` (this node is not the leader), the scheduler fires **no**
4668    /// crons — so a cron fires on exactly one node cluster-wide.
4669    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4670    async fn cron_leader_gate_suppresses_crons_off_leader() {
4671        use boatramp_core::config::{
4672            CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
4673        };
4674        use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
4675        use boatramp_handlers::{HandlerEngine, Limits};
4676        use futures::StreamExt;
4677
4678        let storage = Arc::new(MemStorage::default());
4679        let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4680        let deploy = DeployStore::new(storage.clone(), kv.clone());
4681
4682        let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4683        let stream: ByteStream =
4684            futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4685        deploy.put_blob(&hash, stream).await.unwrap();
4686        let mut files = std::collections::BTreeMap::new();
4687        files.insert(
4688            "counter.wasm".to_string(),
4689            FileEntry {
4690                hash: hash.clone(),
4691                size: KV_COUNTER.len() as u64,
4692                content_type: None,
4693                variants: std::collections::BTreeMap::new(),
4694            },
4695        );
4696        let manifest = Manifest {
4697            files,
4698            config: DeployConfig {
4699                handlers: vec![HandlerConfig {
4700                    tenancy: None,
4701                    token_claims: None,
4702                    route: "/".into(),
4703                    methods: Vec::new(),
4704                    component: "counter.wasm".into(),
4705                    imports: vec!["wasi:keyvalue".into()],
4706                    streaming: false,
4707                    limits: None,
4708                    env: std::collections::BTreeMap::new(),
4709                    invoke_targets: Vec::new(),
4710                }],
4711                crons: vec![CronConfig {
4712                    schedule: "* * * * *".into(),
4713                    route: "/".into(),
4714                    overlap: Overlap::Skip,
4715                }],
4716                ..Default::default()
4717            },
4718            ..Default::default()
4719        };
4720        let id = deploy.put_manifest(&manifest).await.unwrap();
4721        deploy
4722            .activate(ProjectRef::DEFAULT, "blog", &id)
4723            .await
4724            .unwrap();
4725        deploy
4726            .set_site_config(
4727                ProjectRef::DEFAULT,
4728                "blog",
4729                &SiteConfig {
4730                    handlers: Some(HandlersSiteConfig {
4731                        enabled: true,
4732                        allow_imports: vec!["wasi:keyvalue".into()],
4733                        ..Default::default()
4734                    }),
4735                    ..Default::default()
4736                },
4737            )
4738            .await
4739            .unwrap();
4740
4741        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4742        let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4743        // This node is "not the leader" — gate returns false.
4744        rt.set_cron_leader_gate(Arc::new(|| false));
4745        let inner = rt.inner.clone().unwrap();
4746        let mut wasm = std::collections::HashMap::new();
4747        let mut crons = std::collections::HashMap::new();
4748        let mut sweep = std::collections::HashMap::new();
4749        let now = CronNow {
4750            minute: 0,
4751            hour: 0,
4752            dom: 1,
4753            month: 1,
4754            dow: 0,
4755            minute_stamp: 100,
4756        };
4757
4758        let (_, handles) =
4759            run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
4760                .await
4761                .unwrap();
4762        // No cron fired (a follower); the counter was never written.
4763        assert!(handles.is_empty(), "a non-leader must not fire crons");
4764        assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
4765    }
4766
4767    /// Gap 4a: `project_tenancy_knobs` returns the operator's per-project override for a listed
4768    /// project and falls back to the node base for any unlisted project — the runtime half of
4769    /// per-project posture (the resolution half is `security::per_project_override_*`).
4770    #[tokio::test]
4771    async fn project_tenancy_knobs_override_wins_else_node_base() {
4772        use boatramp_core::security::ResolvedProjectTenancy;
4773        use boatramp_handlers::{HandlerEngine, Limits};
4774
4775        let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
4776        let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
4777        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4778        let rt = HandlerRuntime::new(engine, kv, storage, None, None);
4779        // Node base: strict multi-tenant (declaration required, no cross-tenant `all`).
4780        rt.set_tenancy_posture(true, false);
4781        // One project relaxes cross-tenant (its `all` twins) while KEEPING strict declaration.
4782        let mut overrides = std::collections::BTreeMap::new();
4783        overrides.insert(
4784            "preview".to_string(),
4785            ResolvedProjectTenancy {
4786                require_tenancy_declaration: true,
4787                allow_cross_tenant_db: true,
4788                capability_max_ttl_secs: Some(1800),
4789            },
4790        );
4791        rt.set_project_tenancy_overrides(overrides);
4792        let inner = rt.inner.as_ref().unwrap();
4793
4794        // Listed project → the override.
4795        let p = inner.project_tenancy_knobs("preview");
4796        assert!(p.require_tenancy_declaration);
4797        assert!(p.allow_cross_tenant_db);
4798        assert_eq!(p.capability_max_ttl_secs, Some(1800));
4799        // Unlisted project → the node base (strict, no cross-tenant, no minting wired).
4800        let b = inner.project_tenancy_knobs("prod");
4801        assert!(b.require_tenancy_declaration);
4802        assert!(!b.allow_cross_tenant_db);
4803        assert_eq!(b.capability_max_ttl_secs, None);
4804    }
4805
4806    /// Named SQL binding dispatch through the real `build_bindings` + a real (libsql) provider:
4807    /// the granted databases in the resulting `Bindings` are exactly what the per-handler grant
4808    /// grammar allows, with the site as the ceiling. This is the config→dispatch→backends half of
4809    /// the tenant-isolation story (the guest-open half is the binding layer's
4810    /// `two_named_databases_are_independent`; a full guest `open("named")` e2e needs a wasm
4811    /// fixture and is a live-validation follow-up).
4812    #[tokio::test]
4813    async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
4814        use boatramp_core::config::HandlersSiteConfig;
4815        use boatramp_core::project::ProjectRef;
4816        use boatramp_handlers::{HandlerEngine, Limits};
4817
4818        let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
4819        let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
4820        // A real per-site libsql provider (opens a distinct database per name).
4821        let sql_dir =
4822            std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
4823        let _ = std::fs::remove_dir_all(&sql_dir);
4824        let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
4825            Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
4826
4827        let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4828        let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
4829        let inner = rt.inner.as_ref().unwrap();
4830
4831        // The site exposes the default + two named databases — the ceiling. It declares tenancy
4832        // Disabled (this test is about named-sql dispatch, not tenancy) so the fail-closed
4833        // default posture (require a declaration) admits it.
4834        let site = HandlersSiteConfig {
4835            enabled: true,
4836            allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
4837            tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
4838            ..Default::default()
4839        };
4840        let env = std::collections::BTreeMap::new();
4841        let build = |imports: &[&str]| {
4842            let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
4843            let site = &site;
4844            let env = &env;
4845            async move {
4846                crate::handler_dispatch::build_bindings(
4847                    inner,
4848                    ProjectRef::new("default"),
4849                    "shop",
4850                    "shop",
4851                    None,
4852                    &imports,
4853                    site,
4854                    env,
4855                    &[],
4856                    0,
4857                    None,
4858                    None,
4859                    None,
4860                    None,
4861                    None,
4862                    None,
4863                    None,
4864                )
4865                .await
4866                .expect("no secrets → resolves")
4867                .sql_database_names()
4868            }
4869        };
4870
4871        // Least-privilege: a handler asking only for the default + product gets exactly those —
4872        // never `privileged`, even though the site exposes it.
4873        assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
4874        // A wildcard handler gets every name the site exposes (default via bare `sql` + all named).
4875        assert_eq!(
4876            build(&["sql", "sql:*"]).await,
4877            vec!["", "privileged", "product"]
4878        );
4879        // Fail-closed: requesting a name the site does not expose grants nothing.
4880        assert!(build(&["sql:secret"]).await.is_empty());
4881        // No bare `sql` → the default `""` database is not granted either.
4882        assert_eq!(build(&["sql:product"]).await, vec!["product"]);
4883
4884        let _ = std::fs::remove_dir_all(&sql_dir);
4885    }
4886}