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