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