1use std::future::Future;
15use std::net::{IpAddr, SocketAddr};
16use std::sync::Arc;
17use std::time::Duration;
18
19use axum::body::Body;
20use axum::extract::{ConnectInfo, Path, Query, Request, State};
21use axum::http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode};
22use axum::response::{IntoResponse, Response};
23use axum::routing::{any, get, post, put};
24use axum::{Extension, Json, Router};
25use boatramp_core::access::{AccessConfig, BasicAuth};
26use boatramp_core::authz::{GrantedRole, TokenMeta};
27use boatramp_core::config::{DeployConfig, SiteConfig};
28use boatramp_core::cose::{self, Claims, Signer};
29use boatramp_core::deploy::{
30 DeployMetaInput, DeployStore, FileEntry, GcOptions, GcReport, Manifest,
31};
32use boatramp_core::matcher::Pattern;
33use boatramp_core::route::{self, Outcome};
34use boatramp_core::{DeployError, StorageError};
35use futures::StreamExt;
36use serde::{Deserialize, Serialize};
37
38mod admin_api;
39pub mod sql_shim;
40#[cfg(feature = "oidc")]
41pub(crate) use admin_api::auth_exchange;
42pub(crate) use admin_api::{
43 activate_deployment, cert_status, compute_dns, compute_dns_resolve, compute_exec, compute_ipam,
44 compute_netdiag, compute_reconcile, compute_restart, compute_set_health, compute_status,
45 create_deployment, current_deployment, delete_compute, delete_compute_volume,
46 delete_project_tenancy, delete_site, get_compute, get_daemon_config, get_deployment,
47 get_project_tenancy, get_site_config, invalidate_cache, list_aliases, list_compute,
48 list_compute_volumes, list_deployments, list_sites, prune_delete, prune_report, put_blob,
49 put_compute, put_daemon_config, put_project_tenancy, put_site_config, remove_alias,
50 rollback_daemon_config, scrub_blobs, set_alias, sql_exec, sql_ping, sql_query,
51};
52#[cfg(feature = "handlers")]
53pub(crate) use admin_api::{
54 delete_graphql_safelist, delete_graphql_subgraph, get_graphql_supergraph,
55 list_graphql_safelist, put_graphql_function_subgraph, put_graphql_sql_subgraph,
56 put_graphql_subgraph, register_graphql_safelist,
57};
58#[cfg(feature = "admin")]
60mod admin_controller;
61mod auth;
62#[cfg(feature = "console")]
63pub mod console;
64mod content;
65mod control_api;
66#[cfg(feature = "email")]
69mod email_spool;
70#[cfg(feature = "session")]
72mod session_driver;
73#[cfg(feature = "session")]
76mod session_store;
77#[cfg(feature = "admin")]
78pub use admin_controller::ServerAdminController;
79#[cfg(feature = "compression")]
80pub(crate) use content::maybe_compress;
81pub(crate) use content::multipart_byteranges;
82pub(crate) use content::{
83 negotiate_encoding, parse_ranges, response_headers, set_content_encoding, MAX_RANGES,
84};
85pub(crate) use control_api::{
86 add_root_anchor, auth_whoami, bootstrap_token, cluster_join, cluster_members, cluster_promote,
87 cluster_revoke, cluster_rotate_key, create_join_token, create_token, delete_email_profile,
88 delete_secret, get_authz_policy, list_email_profiles, list_root_anchors, list_secrets,
89 list_tokens, put_authz_policy, remove_root_anchor, revoke_token, set_email_profile, set_secret,
90 show_email_profile,
91};
92#[cfg(all(test, feature = "handlers"))]
93use control_api::{BootstrapRequest, CreateJoinTokenRequest, JoinRequest};
94#[cfg(feature = "email")]
95pub use email_spool::NodeEmailSpool;
96mod domain_verify;
97pub use domain_verify::{spawn_domain_verify_reconcile, verification_pending_page};
98pub mod envelope;
99#[cfg(feature = "handlers")]
100mod graphql_apq;
101#[cfg(feature = "handlers")]
102mod graphql_cache;
103#[cfg(feature = "handlers")]
104mod graphql_data;
105#[cfg(feature = "handlers")]
106mod graphql_federation;
107#[cfg(feature = "handlers")]
108mod graphql_gateway;
109#[cfg(feature = "handlers")]
110mod graphql_graphiql;
111#[cfg(feature = "handlers")]
112mod graphql_guard;
113#[cfg(feature = "handlers")]
114mod graphql_plan;
115#[cfg(feature = "handlers")]
116mod graphql_registry;
117#[cfg(feature = "handlers")]
118mod graphql_subscription;
119#[cfg(feature = "handlers")]
120mod handler_cache;
121#[cfg(feature = "handlers")]
122mod handler_dispatch;
123#[cfg(feature = "handlers")]
124pub(crate) use handler_dispatch::{
125 build_bindings, dispatch_consumer_batch, dispatch_handler, precheck_component, read_blob_bytes,
126 read_blob_fully, resolve_secret_env,
127};
128#[cfg(all(feature = "handlers", test))]
129use handler_dispatch::{resolve_env, set_forwarded_headers};
130mod function_api;
131pub(crate) use function_api::{
132 alias_function, deploy_function, list_functions, remove_function, rollback_function,
133};
134#[cfg(feature = "handlers")]
139pub use function_api::{
140 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
141};
142#[cfg(all(test, feature = "handlers"))]
143use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
144pub use function_api::{CapabilityFeature, Lifecycle};
147mod gateway;
148mod host;
149pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
150#[cfg(feature = "http3")]
151mod http3;
152mod limits;
153#[cfg(feature = "handlers")]
154mod logs;
155#[cfg(feature = "handlers")]
156mod metrics;
157#[cfg(feature = "oidc")]
158mod oidc;
159mod operator;
160pub(crate) use operator::prometheus_metrics;
161#[cfg(feature = "handlers")]
162pub(crate) use operator::{
163 operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
164 operator_logs, operator_logs_stream,
165};
166mod proxy;
167pub use proxy::spawn_compute_reconcile;
168pub(crate) use proxy::{
169 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
170 proxy, COMPUTE_WAKE_TIMEOUT,
171};
172mod splice;
173mod http_serve;
176pub use http_serve::{
177 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
178 serve_tls_listener, ReloadableTls, ServeInput,
179};
180#[cfg(feature = "handlers")]
182pub(crate) use proxy::is_upgrade_request;
183#[cfg(all(test, feature = "handlers"))]
184use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
185mod project_api;
186pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
187mod project_scope;
188pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
189mod ratelimit;
190mod routes;
191pub use routes::{router, router_with, router_with_fast};
192#[cfg(feature = "mcp")]
193mod mcp_http;
194#[cfg(feature = "handlers")]
195mod scheduler;
196mod serve_pipeline;
197#[cfg(feature = "handlers")]
198mod tenant_resolve;
199pub use serve_pipeline::{http_redirect_router, FastServe};
200#[cfg(test)]
201mod hotpath_test;
202#[cfg(all(test, feature = "handlers"))]
203use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
204pub(crate) use serve_pipeline::{
205 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
206 BootstrapAttestation,
207};
208pub mod signer;
211mod srvmetrics;
212#[cfg(all(feature = "handlers", test))]
213use scheduler::run_scheduler_tick;
214#[cfg(feature = "handlers")]
215pub(crate) use scheduler::{
216 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable,
217 sql_starting_response, CronNow,
218};
219#[cfg(feature = "handlers")]
220use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
221#[cfg(feature = "handlers")]
222mod function_runtime;
223#[cfg(feature = "handlers")]
224pub(crate) use function_runtime::{
225 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
226 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
227 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
228 put_trigger_handler, webhook_ingress,
229};
230#[cfg(feature = "session")]
234mod session_serve;
235#[cfg(feature = "handlers")]
236mod stream;
237#[cfg(feature = "handlers")]
238mod workflow;
239pub use auth::{require_auth, Auth};
240#[cfg(feature = "http3")]
241pub use http3::{
242 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
243 Http3Error,
244};
245pub use limits::{ServerLimits, UploadGuard};
246#[cfg(feature = "oidc")]
247pub use oidc::{OidcConfig, OidcError, OidcVerifier};
248use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
249#[cfg(feature = "handlers")]
250pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
251#[cfg(feature = "handlers")]
252pub(crate) use workflow::{
253 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
254 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
255};
256pub use srvmetrics::{server_metrics, ServerMetrics};
259
260#[derive(Clone, Default)]
265pub struct HandlerRuntime {
266 #[cfg(feature = "handlers")]
267 inner: Option<Arc<HandlerRuntimeInner>>,
268}
269
270#[cfg(feature = "handlers")]
271struct HandlerRuntimeInner {
272 engine: boatramp_handlers::HandlerEngine,
273 async_drain_gate: Arc<tokio::sync::Semaphore>,
279 kv: Arc<dyn boatramp_core::kv::KvStore>,
280 storage: Arc<dyn boatramp_core::Storage>,
281 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
285 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
288 site_semaphores:
291 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
292 stream_semaphores:
296 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
297 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
300 metrics: metrics::Metrics,
303 logs: Arc<logs::LogStore>,
305 #[cfg(feature = "handlers")]
309 graphql_cache: graphql_cache::GraphqlCache,
310 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
316 max_blob_bytes: std::sync::OnceLock<u64>,
320 max_component_bytes: std::sync::OnceLock<u64>,
325 allow_env_secret_refs: std::sync::OnceLock<bool>,
334 require_tenancy_declaration: std::sync::OnceLock<bool>,
340 allow_cross_tenant_db: std::sync::OnceLock<bool>,
345 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
351 function_meter_locks:
355 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
356 function_semaphores:
359 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
360 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
365 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
369 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
377 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
382 #[cfg(feature = "email")]
387 email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
388 #[cfg(feature = "email")]
393 email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
394 #[cfg(feature = "admin")]
398 admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
399 #[cfg(feature = "admin")]
403 admin_surfaces:
404 std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
405 #[cfg(feature = "session")]
412 session_store: std::sync::OnceLock<session_store::SessionStore>,
413 session_signer: std::sync::OnceLock<Arc<dyn Signer>>,
418 #[cfg(feature = "capability")]
424 capability_max_ttl_secs: std::sync::OnceLock<u64>,
425 #[cfg(feature = "handlers")]
430 tenancy_posture_overrides: std::sync::OnceLock<
431 Arc<std::collections::BTreeMap<String, boatramp_core::security::ResolvedProjectTenancy>>,
432 >,
433}
434
435#[cfg(feature = "handlers")]
436impl HandlerRuntimeInner {
437 pub(crate) fn project_tenancy_knobs(
443 &self,
444 project: &str,
445 ) -> boatramp_core::security::ResolvedProjectTenancy {
446 if let Some(map) = self.tenancy_posture_overrides.get() {
447 if let Some(knobs) = map.get(project) {
448 return *knobs;
449 }
450 }
451 boatramp_core::security::ResolvedProjectTenancy {
452 require_tenancy_declaration: self
453 .require_tenancy_declaration
454 .get()
455 .copied()
456 .unwrap_or(true),
457 allow_cross_tenant_db: self.allow_cross_tenant_db.get().copied().unwrap_or(false),
458 #[cfg(feature = "capability")]
459 capability_max_ttl_secs: self.capability_max_ttl_secs.get().copied(),
460 #[cfg(not(feature = "capability"))]
461 capability_max_ttl_secs: None,
462 }
463 }
464}
465
466pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
469
470impl HandlerRuntime {
471 pub fn disabled() -> Self {
473 Self::default()
474 }
475
476 #[cfg(feature = "handlers")]
482 pub fn new(
483 engine: boatramp_handlers::HandlerEngine,
484 kv: Arc<dyn boatramp_core::kv::KvStore>,
485 storage: Arc<dyn boatramp_core::Storage>,
486 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
487 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
488 ) -> Self {
489 let async_drain_slots = engine.async_max_concurrency().max(1);
492 Self {
493 inner: Some(Arc::new(HandlerRuntimeInner {
494 engine,
495 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
496 kv,
497 storage,
498 sql,
499 messaging,
500 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
501 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
502 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
503 metrics: metrics::Metrics::default(),
504 logs: Arc::new(logs::LogStore::default()),
505 #[cfg(feature = "handlers")]
506 graphql_cache: graphql_cache::GraphqlCache::default(),
507 cron_leader_gate: std::sync::OnceLock::new(),
508 max_blob_bytes: std::sync::OnceLock::new(),
509 max_component_bytes: std::sync::OnceLock::new(),
510 allow_env_secret_refs: std::sync::OnceLock::new(),
511 require_tenancy_declaration: std::sync::OnceLock::new(),
512 allow_cross_tenant_db: std::sync::OnceLock::new(),
513 secret_store: std::sync::OnceLock::new(),
514 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
515 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
516 watch_provider: std::sync::OnceLock::new(),
517 provision_tier: std::sync::OnceLock::new(),
518 invoker: std::sync::OnceLock::new(),
519 federation_runner: std::sync::OnceLock::new(),
520 #[cfg(feature = "email")]
521 email_profile_store: std::sync::OnceLock::new(),
522 #[cfg(feature = "email")]
523 email_spool: std::sync::OnceLock::new(),
524 #[cfg(feature = "admin")]
525 admin_controller: std::sync::OnceLock::new(),
526 #[cfg(feature = "admin")]
527 admin_surfaces: std::sync::OnceLock::new(),
528 #[cfg(feature = "session")]
529 session_store: std::sync::OnceLock::new(),
530 session_signer: std::sync::OnceLock::new(),
531 #[cfg(feature = "capability")]
532 capability_max_ttl_secs: std::sync::OnceLock::new(),
533 #[cfg(feature = "handlers")]
534 tenancy_posture_overrides: std::sync::OnceLock::new(),
535 })),
536 }
537 }
538
539 #[cfg(feature = "handlers")]
542 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
543 self.inner.as_ref().and_then(|inner| inner.sql.clone())
544 }
545
546 #[cfg(feature = "handlers")]
550 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
551 self.inner
552 .as_ref()
553 .and_then(|inner| inner.invoker.get().cloned())
554 }
555
556 #[cfg(feature = "handlers")]
560 pub(crate) async fn introspect_subgraph_sdl(
561 &self,
562 deploy: &DeployStore,
563 project: boatramp_core::project::ProjectRef<'_>,
564 function: &boatramp_core::function::Function,
565 component: &str,
566 ) -> Result<String, function_runtime::SubgraphSdlError> {
567 match self.inner.as_ref() {
568 Some(inner) => {
569 function_runtime::introspect_service_sdl(
570 inner, deploy, project, function, component,
571 )
572 .await
573 }
574 None => Err(function_runtime::SubgraphSdlError::Unavailable),
575 }
576 }
577
578 #[cfg(feature = "handlers")]
584 pub fn set_invoker(&self, deploy: DeployStore) {
585 if let Some(inner) = self.inner.as_ref() {
586 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
587 deploy,
588 Arc::downgrade(inner),
589 ));
590 let _ = inner.invoker.set(invoker);
591 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
594 inner,
595 )));
596 let _ = inner.federation_runner.set(runner);
597 }
598 }
599
600 #[cfg(feature = "handlers")]
604 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
605 self.inner.as_ref().and_then(|inner| inner.sql.clone())
606 }
607
608 #[cfg(feature = "handlers")]
612 pub fn set_watch_provider(
613 &self,
614 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
615 ) {
616 if let Some(inner) = self.inner.as_ref() {
617 let _ = inner.watch_provider.set(provider);
618 }
619 }
620
621 #[cfg(feature = "handlers")]
625 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
626 if let Some(inner) = self.inner.as_ref() {
627 let _ = inner.provision_tier.set(tier);
628 }
629 }
630
631 #[cfg(feature = "handlers")]
635 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
636 if let Some(inner) = self.inner.as_ref() {
637 let _ = inner.max_blob_bytes.set(max_bytes);
638 }
639 }
640
641 #[cfg(feature = "handlers")]
644 pub fn set_max_component_bytes(&self, max_bytes: u64) {
645 if let Some(inner) = self.inner.as_ref() {
646 let _ = inner.max_component_bytes.set(max_bytes);
647 }
648 }
649
650 #[cfg(feature = "handlers")]
658 pub fn set_allow_env_secret_refs(&self, allow: bool) {
659 if let Some(inner) = self.inner.as_ref() {
660 let _ = inner.allow_env_secret_refs.set(allow);
661 }
662 }
663
664 #[cfg(feature = "handlers")]
669 pub fn set_tenancy_posture(&self, require_declaration: bool, allow_cross_tenant: bool) {
670 if let Some(inner) = self.inner.as_ref() {
671 let _ = inner.require_tenancy_declaration.set(require_declaration);
672 let _ = inner.allow_cross_tenant_db.set(allow_cross_tenant);
673 }
674 }
675
676 #[cfg(feature = "handlers")]
683 pub fn set_project_tenancy_overrides(
684 &self,
685 overrides: std::collections::BTreeMap<
686 String,
687 boatramp_core::security::ResolvedProjectTenancy,
688 >,
689 ) {
690 if overrides.is_empty() {
691 return;
692 }
693 if let Some(inner) = self.inner.as_ref() {
694 let _ = inner.tenancy_posture_overrides.set(Arc::new(overrides));
695 }
696 }
697
698 #[cfg(feature = "handlers")]
704 pub fn set_session_signer(&self, signer: Arc<dyn Signer>) {
705 if let Some(inner) = self.inner.as_ref() {
706 let _ = inner.session_signer.set(signer);
707 }
708 }
709
710 #[cfg(feature = "capability")]
717 pub fn set_capability_minting(&self, max_ttl_secs: u64) {
718 if max_ttl_secs == 0 {
719 return;
720 }
721 if let Some(inner) = self.inner.as_ref() {
722 let _ = inner.capability_max_ttl_secs.set(max_ttl_secs);
723 }
724 }
725
726 #[cfg(feature = "handlers")]
731 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
732 if let Some(inner) = self.inner.as_ref() {
733 let _ = inner.secret_store.set(store);
734 }
735 }
736
737 #[cfg(feature = "email")]
741 pub fn set_email_profile_store(
742 &self,
743 store: Arc<boatramp_core::email_config::EmailProfileStore>,
744 ) {
745 if let Some(inner) = self.inner.as_ref() {
746 let _ = inner.email_profile_store.set(store);
747 }
748 }
749
750 #[cfg(feature = "email")]
755 pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
756 if let Some(inner) = self.inner.as_ref() {
757 let _ = inner.email_spool.set(spool);
758 }
759 }
760
761 #[cfg(feature = "admin")]
766 pub fn set_admin(
767 &self,
768 controller: Arc<admin_controller::ServerAdminController>,
769 surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
770 ) {
771 if let Some(inner) = self.inner.as_ref() {
772 let _ = inner.admin_controller.set(controller);
773 let _ = inner.admin_surfaces.set(surfaces);
774 }
775 }
776
777 #[cfg(feature = "handlers")]
782 pub(crate) fn allow_env_secret_refs(&self) -> bool {
783 self.inner
784 .as_ref()
785 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
786 .unwrap_or(false)
787 }
788
789 #[cfg(feature = "handlers")]
794 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
795 if let Some(inner) = self.inner.as_ref() {
796 let _ = inner.cron_leader_gate.set(gate);
797 }
798 }
799
800 #[cfg(feature = "handlers")]
807 async fn precheck_activation(
808 &self,
809 deploy: &DeployStore,
810 manifest: &Manifest,
811 site_config: Option<&SiteConfig>,
812 ) -> Result<(), String> {
813 let Some(inner) = self.inner.as_ref() else {
814 return Ok(());
815 };
816 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
819 return Ok(());
820 }
821 let site_handlers = site_config
823 .and_then(|c| c.handlers.as_ref())
824 .filter(|h| h.enabled)
825 .ok_or_else(|| {
826 "deployment ships handlers/consumers but the site has them disabled".to_string()
827 })?;
828 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
829
830 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
837 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
838 .map_err(|err| format!("handler secrets: {err}"))?;
839
840 let sync_ceiling = inner.engine.sync_timeout_ms();
846 let async_ceiling = inner.engine.async_timeout_ms();
847 if let Some(ms) = site_handlers.max_timeout_ms {
848 if u64::from(ms) > sync_ceiling {
849 tracing::warn!(
850 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
851 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
852 applies only to async calls (?mode=async / triggers), capped at \
853 async_max_timeout_ms={async_ceiling}"
854 );
855 }
856 }
857
858 for handler in &manifest.config.handlers {
860 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
861 if u64::from(ms) > sync_ceiling {
862 let route = &handler.route;
863 tracing::warn!(
864 "route {route:?} declares limits.timeout_ms={ms}, above \
865 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
866 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
867 (?mode=async / a queue trigger / a #[consumer]), capped at \
868 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
869 that isn't possible — move the work to the async lane"
870 );
871 }
872 }
873 if !handler.streaming {
878 if let Some(entry) = manifest.files.get(&handler.component) {
879 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
880 if crate::function_api::component_declares_streaming_route(
881 &bytes,
882 &handler.route,
883 ) {
884 let route = &handler.route;
885 tracing::warn!(
886 "route {route:?} is a streaming handler (#[handler(stream)]) but \
887 its config lacks streaming = true: it will run on the sync request \
888 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
889 streaming = true so it serves on the dedicated streaming lane (its \
890 own concurrency budget + a much larger wall-clock)."
891 );
892 }
893 }
894 }
895 }
896 if let Some(entry) = manifest.files.get(&handler.component) {
900 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
901 let unmet = crate::function_api::unmet_requires(&bytes);
902 if !unmet.is_empty() {
903 return Err(format!(
904 "route {:?} [{}] requires capabilities this host does not implement: \
905 {}. Upgrade boatramp or enable those features — see `boatramp \
906 capabilities`.",
907 handler.route,
908 handler.methods.join(","),
909 unmet.join(", ")
910 ));
911 }
912 }
913 }
914 precheck_component(
915 deploy,
916 manifest,
917 site_handlers,
918 inner,
919 max_component,
920 &handler.imports,
921 &handler.component,
922 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
925 false,
926 )
927 .await?;
928 }
929 for consumer in &manifest.config.consumers {
930 precheck_component(
931 deploy,
932 manifest,
933 site_handlers,
934 inner,
935 max_component,
936 &consumer.imports,
937 &consumer.component,
938 &format!("consumer {:?}", consumer.topic),
939 true,
940 )
941 .await?;
942 }
943 Ok(())
944 }
945
946 #[cfg(not(feature = "handlers"))]
947 async fn precheck_activation(
948 &self,
949 _deploy: &DeployStore,
950 _manifest: &Manifest,
951 _site_config: Option<&SiteConfig>,
952 ) -> Result<(), String> {
953 Ok(())
954 }
955}
956
957#[derive(Default, Clone)]
963pub struct ServerOptions {
964 pub limits: ServerLimits,
966 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
969 pub default_site: Option<String>,
972 pub implicit_routing: bool,
978 pub protect_previews: bool,
983 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
987 pub issuer: Option<Arc<dyn Signer>>,
991 pub bootstrap_secret: Option<String>,
996 pub bootstrap_attestation: Option<String>,
1002 pub mesh_control: Option<Arc<dyn MeshControl>>,
1006 pub cors_allowed_origins: Vec<String>,
1016 #[cfg(feature = "oidc")]
1019 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
1020 pub posture: boatramp_core::security::SecurityPosture,
1024 pub served_over_tls: bool,
1029 pub pop_origin: Option<String>,
1036 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
1040 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
1044 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
1049 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
1053 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
1058 pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
1062 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
1070 pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
1078 #[cfg(feature = "console")]
1082 pub console: Option<console::ConsoleMount>,
1083}
1084
1085#[derive(Clone, Copy)]
1089struct ServedOverTls(bool);
1090
1091#[derive(Clone, Copy, Default)]
1096struct ImplicitRouting(bool);
1097
1098const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
1110
1111pub struct DaemonRuntime {
1112 baseline: boatramp_core::daemon_config::ConfigBaseline,
1113 state: std::sync::RwLock<DaemonState>,
1114 reload: tokio::sync::Notify,
1117}
1118
1119struct DaemonState {
1120 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
1121 generation: Option<String>,
1122}
1123
1124pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
1129 #[cfg(feature = "console")]
1133 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
1134 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
1135 None => (false, None, None),
1136 };
1137 #[cfg(not(feature = "console"))]
1138 let (console_enabled, console_host, console_path) = (false, None, None);
1139 boatramp_core::daemon_config::ConfigBaseline {
1140 default_site: options.default_site.clone(),
1141 protect_previews: options.protect_previews,
1142 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
1143 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
1144 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
1145 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
1146 compute_vcpus: 0,
1147 compute_mem_mib: 0,
1148 console_enabled,
1149 console_host,
1150 console_path,
1151 max_upload_ceiling: options.posture.max_upload_bytes,
1152 max_concurrent_uploads_ceiling: None,
1153 posture: options.posture,
1154 }
1155}
1156
1157impl DaemonRuntime {
1158 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1162 let effective =
1163 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1164 Self {
1165 baseline,
1166 state: std::sync::RwLock::new(DaemonState {
1167 effective,
1168 generation: None,
1169 }),
1170 reload: tokio::sync::Notify::new(),
1171 }
1172 }
1173
1174 pub fn notify_reload(&self) {
1178 self.reload.notify_one();
1179 }
1180
1181 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1183 self.state
1184 .read()
1185 .expect("daemon config lock")
1186 .effective
1187 .clone()
1188 }
1189
1190 pub fn generation(&self) -> Option<String> {
1193 self.state
1194 .read()
1195 .expect("daemon config lock")
1196 .generation
1197 .clone()
1198 }
1199
1200 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1202 &self.baseline
1203 }
1204
1205 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1208 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1209 let generation = deploy.daemon_config_generation().await?;
1210 let effective = Arc::new(cfg.resolve(&self.baseline));
1211 *self.state.write().expect("daemon config lock") = DaemonState {
1212 effective,
1213 generation,
1214 };
1215 Ok(())
1216 }
1217}
1218
1219#[derive(Clone, Copy, Default)]
1222struct PreviewPolicy {
1223 protect: bool,
1224}
1225
1226#[derive(Clone, Default)]
1231struct Issuer(Option<Arc<dyn Signer>>);
1232
1233#[derive(Clone, Default)]
1238struct BootstrapGate(Option<Arc<BootstrapInner>>);
1239
1240struct BootstrapInner {
1241 secret_hash: String,
1244 lock: tokio::sync::Mutex<()>,
1247}
1248
1249impl BootstrapGate {
1250 fn new(secret: Option<&str>) -> Self {
1251 Self(secret.filter(|s| !s.is_empty()).map(|s| {
1252 Arc::new(BootstrapInner {
1253 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1254 lock: tokio::sync::Mutex::new(()),
1255 })
1256 }))
1257 }
1258}
1259
1260#[async_trait::async_trait]
1264pub trait MeshControl: Send + Sync {
1265 async fn admit(
1273 &self,
1274 mesh_pubkey_hex: &str,
1275 jti: &str,
1276 possession_proof: &[u8],
1277 proof_iat: u64,
1278 now: u64,
1279 advertise_addr: Option<&str>,
1280 ) -> Result<JoinOutcome, String>;
1281
1282 async fn rotate_key(&self) -> Result<String, String>;
1286
1287 async fn revoke(&self, node: u64) -> Result<(), String>;
1291
1292 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1296
1297 async fn promote(&self, node: u64) -> Result<(), String>;
1300}
1301
1302pub enum JoinOutcome {
1304 Admitted {
1307 members: Vec<String>,
1309 addrs: std::collections::BTreeMap<u64, String>,
1311 },
1312 TokenSpent,
1314 ProofInvalid,
1316 Revoked,
1319}
1320
1321#[derive(Debug, Clone, Serialize)]
1323pub struct MeshMember {
1324 pub node: u64,
1326 pub voter: bool,
1328 pub caught_up: bool,
1330 pub leader: bool,
1332 #[serde(default, skip_serializing_if = "Option::is_none")]
1336 pub addr: Option<String>,
1337}
1338
1339#[derive(Clone, Default)]
1342struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1343
1344#[cfg(feature = "oidc")]
1346#[derive(Clone, Default)]
1347struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1348
1349#[cfg(feature = "oidc")]
1352const EXCHANGE_TTL_SECS: u64 = 3600;
1353
1354use boatramp_core::time::now_unix;
1355
1356#[derive(Clone)]
1358struct CorsState(Arc<Vec<String>>);
1359
1360const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1362const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1365const CORS_MAX_AGE: &str = "600";
1367
1368fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1372 allowed.iter().any(|a| a == "*" || a == origin)
1373}
1374
1375async fn cors(
1382 State(allowed): State<CorsState>,
1383 request: Request,
1384 next: axum::middleware::Next,
1385) -> Response {
1386 let origin = request
1387 .headers()
1388 .get(header::ORIGIN)
1389 .and_then(|v| v.to_str().ok())
1390 .filter(|o| cors_origin_allowed(&allowed.0, o))
1391 .map(str::to_string);
1392 let is_preflight = request.method() == Method::OPTIONS
1394 && request
1395 .headers()
1396 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1397 if is_preflight {
1398 let allow_headers = request
1400 .headers()
1401 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1402 .and_then(|v| v.to_str().ok())
1403 .map(str::to_string)
1404 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1405 let mut response = Response::new(Body::empty());
1406 *response.status_mut() = StatusCode::NO_CONTENT;
1407 if let Some(origin) = origin {
1408 let headers = response.headers_mut();
1409 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1410 set_header(headers, header::VARY, "Origin");
1411 set_header(
1412 headers,
1413 header::ACCESS_CONTROL_ALLOW_METHODS,
1414 CORS_ALLOW_METHODS,
1415 );
1416 set_header(
1417 headers,
1418 header::ACCESS_CONTROL_ALLOW_HEADERS,
1419 &allow_headers,
1420 );
1421 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1422 }
1423 return response;
1424 }
1425 let mut response = next.run(request).await;
1426 if let Some(origin) = origin {
1427 let headers = response.headers_mut();
1428 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1429 if let Ok(value) = HeaderValue::from_str("Origin") {
1432 headers.append(header::VARY, value);
1433 }
1434 }
1435 response
1436}
1437
1438const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1443
1444#[derive(Debug, thiserror::Error)]
1446pub enum ServeError {
1447 #[error("server I/O: {0}")]
1449 Io(#[from] std::io::Error),
1450}
1451
1452pub async fn serve(
1455 addr: SocketAddr,
1456 deploy: DeployStore,
1457 auth: Auth,
1458 handlers: HandlerRuntime,
1459) -> Result<(), ServeError> {
1460 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1461}
1462
1463pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1473 if let Err(err) = stream.set_nodelay(true) {
1474 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1475 }
1476}
1477
1478pub async fn serve_with(
1480 addr: SocketAddr,
1481 deploy: DeployStore,
1482 auth: Auth,
1483 handlers: HandlerRuntime,
1484 options: ServerOptions,
1485) -> Result<(), ServeError> {
1486 let tcp = tokio::net::TcpListener::bind(addr).await?;
1487 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1488 let splice_ctx = splice::SpliceCtx {
1494 deploy: deploy.clone(),
1495 posture: options.posture,
1496 daemon: options.daemon_runtime.clone(),
1497 };
1498 #[cfg(feature = "handlers")]
1501 let scheduler = handlers.spawn_scheduler(deploy.clone());
1502 let gateway_prober = gateway::spawn_active_health_prober();
1506 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1509
1510 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1514 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1518 shutdown_signal().await;
1519 let _ = signalled_tx.send(true);
1520 });
1521 let signalled = {
1522 let mut rx = signalled_rx;
1523 async move {
1524 let _ = rx.wait_for(|fired| *fired).await;
1525 }
1526 };
1527 let result = serve_with_drain_deadline(
1528 async move { server.await.map_err(ServeError::from) },
1529 signalled,
1530 DRAIN_DEADLINE,
1531 )
1532 .await;
1533 #[cfg(feature = "handlers")]
1535 if let Some(handle) = scheduler {
1536 handle.abort();
1537 }
1538 gateway_prober.abort();
1539 result
1540}
1541
1542async fn serve_with_drain_deadline<Srv, Sig>(
1547 server: Srv,
1548 signalled: Sig,
1549 deadline: Duration,
1550) -> Result<(), ServeError>
1551where
1552 Srv: Future<Output = Result<(), ServeError>>,
1553 Sig: Future<Output = ()>,
1554{
1555 tokio::pin!(server);
1556 let drain_cap = async move {
1557 signalled.await;
1558 tokio::time::sleep(deadline).await;
1559 };
1560 tokio::select! {
1561 result = &mut server => result,
1562 _ = drain_cap => {
1563 tracing::warn!(
1564 deadline_s = deadline.as_secs(),
1565 "drain deadline exceeded; forcing shutdown with requests still in flight"
1566 );
1567 Ok(())
1568 }
1569 }
1570}
1571
1572pub async fn shutdown_signal() {
1575 let ctrl_c = async {
1576 let _ = tokio::signal::ctrl_c().await;
1577 };
1578 #[cfg(unix)]
1579 let terminate = async {
1580 if let Ok(mut sig) =
1581 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1582 {
1583 sig.recv().await;
1584 }
1585 };
1586 #[cfg(not(unix))]
1587 let terminate = std::future::pending::<()>();
1588
1589 tokio::select! {
1590 _ = ctrl_c => {}
1591 _ = terminate => {}
1592 }
1593 tracing::info!("shutdown signal received; draining");
1594}
1595
1596async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1600 match daemon.generation() {
1601 Some(gen) => format!("ok gen={gen}"),
1602 None => "ok".to_string(),
1603 }
1604}
1605
1606async fn readyz(State(deploy): State<DeployStore>) -> Response {
1608 match deploy.ready().await {
1609 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1610 Err(err) => {
1611 tracing::warn!(error = %err, "readiness probe failed");
1612 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1613 }
1614 }
1615}
1616
1617#[derive(Clone)]
1622pub struct RequestId(pub String);
1623
1624#[cfg(feature = "handlers")]
1630#[derive(Clone)]
1631pub struct DomainContext(pub String);
1632
1633fn request_id_for(headers: &HeaderMap) -> String {
1636 if let Some(id) = headers
1637 .get("x-request-id")
1638 .and_then(|v| v.to_str().ok())
1639 .map(str::trim)
1640 .filter(|s| !s.is_empty())
1641 {
1642 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1643 }
1644 use std::sync::atomic::{AtomicU64, Ordering};
1645 static SEQ: AtomicU64 = AtomicU64::new(0);
1646 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1647 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1648}
1649
1650struct AccessLog {
1654 request_id: String,
1655 method: Method,
1656 path: String,
1657 host: String,
1658 client: String,
1659 status: u16,
1660 encoding: String,
1662 start: std::time::Instant,
1663 bytes: std::sync::atomic::AtomicU64,
1664}
1665
1666impl Drop for AccessLog {
1667 fn drop(&mut self) {
1668 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1669 srvmetrics::server_metrics().record_request(self.status, bytes);
1672 tracing::info!(
1673 target: "boatramp::access",
1674 request_id = %self.request_id,
1675 method = %self.method,
1676 path = %self.path,
1677 host = %self.host,
1678 client = %self.client,
1679 status = self.status,
1680 bytes = bytes,
1681 encoding = %self.encoding,
1682 cache_result = srvmetrics::cache_result(self.status),
1683 elapsed_ms = self.start.elapsed().as_millis() as u64,
1684 "request"
1685 );
1686 }
1687}
1688
1689pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1695 let request_id = request_id_for(request.headers());
1696 request
1697 .extensions_mut()
1698 .insert(RequestId(request_id.clone()));
1699 request_id
1700}
1701
1702pub(crate) struct AccessLogCtx {
1707 request_id: String,
1708 method: Method,
1709 path: String,
1710 host: String,
1711 client: String,
1712 start: std::time::Instant,
1713}
1714
1715impl AccessLogCtx {
1716 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1722 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1723 return None;
1724 }
1725 Some(Self {
1726 request_id,
1727 method: request.method().clone(),
1728 path: request.uri().path().to_string(),
1729 host: request
1730 .headers()
1731 .get(header::HOST)
1732 .and_then(|value| value.to_str().ok())
1733 .or_else(|| request.uri().host()) .unwrap_or("-")
1735 .to_string(),
1736 client: request
1737 .extensions()
1738 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1739 .map(|info| info.0.ip().to_string())
1740 .unwrap_or_else(|| "-".to_string()),
1741 start: std::time::Instant::now(),
1742 })
1743 }
1744
1745 pub(crate) fn finish(self, response: Response) -> Response {
1749 let encoding = response
1750 .headers()
1751 .get(header::CONTENT_ENCODING)
1752 .and_then(|v| v.to_str().ok())
1753 .unwrap_or("identity")
1754 .to_string();
1755 let log = AccessLog {
1756 request_id: self.request_id,
1757 method: self.method,
1758 path: self.path,
1759 host: self.host,
1760 client: self.client,
1761 status: response.status().as_u16(),
1762 encoding,
1763 start: self.start,
1764 bytes: std::sync::atomic::AtomicU64::new(0),
1765 };
1766 let (parts, body) = response.into_parts();
1767 let counted = body.into_data_stream().map(move |chunk| {
1768 if let Ok(bytes) = &chunk {
1769 log.bytes
1770 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1771 }
1772 chunk
1773 });
1774 Response::from_parts(parts, Body::from_stream(counted))
1775 }
1776}
1777
1778async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1783 let request_id = assign_request_id(&mut request);
1784 match AccessLogCtx::capture(&request, request_id) {
1785 None => next.run(request).await,
1786 Some(ctx) => ctx.finish(next.run(request).await),
1787 }
1788}
1789
1790fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1792 req_headers
1793 .get(header::IF_NONE_MATCH)
1794 .and_then(|value| value.to_str().ok())
1795 .is_some_and(|value| {
1796 value
1797 .split(',')
1798 .map(str::trim)
1799 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1800 })
1801}
1802
1803fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1804 if let Ok(value) = HeaderValue::from_str(value) {
1805 headers.insert(name, value);
1806 }
1807}
1808
1809fn not_found() -> Response {
1810 (StatusCode::NOT_FOUND, "not found\n").into_response()
1811}
1812
1813fn redirect(status: u16, location: &str) -> Response {
1814 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1815 match HeaderValue::from_str(location) {
1816 Ok(location) => {
1817 let mut headers = HeaderMap::new();
1818 headers.insert(header::LOCATION, location);
1819 (status, headers).into_response()
1820 }
1821 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1822 }
1823}
1824
1825fn deploy_error_response(err: DeployError) -> Response {
1827 let status = match &err {
1828 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1829 StatusCode::NOT_FOUND
1830 }
1831 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1832 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1833 DeployError::Conflict(_) => StatusCode::CONFLICT,
1835 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1837 DeployError::Invalid(_) => StatusCode::BAD_REQUEST,
1839 _ => StatusCode::INTERNAL_SERVER_ERROR,
1840 };
1841 tracing::warn!(error = %err, "request failed");
1842 (status, format!("{err}\n")).into_response()
1843}
1844
1845fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1850 boatramp_core::project::validate_resource_name(kind, value)
1851 .err()
1852 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1853}
1854
1855#[cfg(test)]
1856mod drain_tests {
1857 use super::*;
1858
1859 #[tokio::test]
1860 async fn deadline_forces_shutdown_after_signal() {
1861 let server = std::future::pending::<Result<(), ServeError>>();
1864 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1866 assert!(result.is_ok());
1867 }
1868
1869 #[tokio::test]
1870 async fn server_finishing_first_wins() {
1871 let server = async { Ok(()) };
1874 let signalled = std::future::pending::<()>();
1875 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1876 assert!(result.is_ok());
1877 }
1878
1879 #[tokio::test]
1880 async fn deadline_does_not_trip_before_signal() {
1881 let server = async {
1885 tokio::time::sleep(Duration::from_millis(40)).await;
1886 Err(ServeError::Io(std::io::Error::other("server error")))
1887 };
1888 let signalled = std::future::pending::<()>();
1889 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1890 assert!(result.is_err());
1891 }
1892}
1893
1894#[cfg(all(test, feature = "handlers"))]
1895mod tests {
1896 use super::*;
1897 use boatramp_core::cose::{LocalSigner, TokenAlg};
1898 use boatramp_core::project::ProjectRef;
1899
1900 #[test]
1901 fn query_string_parses_and_url_decodes() {
1902 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1903 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1904 assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); assert_eq!(q.get("flag").map(String::as_str), Some("")); assert_eq!(q.get("dup").map(String::as_str), Some("1")); }
1908
1909 #[test]
1910 fn cookie_header_parses_pairs() {
1911 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1912 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1913 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1914 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1915 }
1916
1917 #[test]
1918 fn apply_vary_merges_without_duplicates() {
1919 let base = (StatusCode::OK, "x").into_response();
1920 let r = apply_vary(base, &["accept-language".into()]);
1921 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1922 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1924 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1925 assert!(v.contains("accept-language") && v.contains("cookie"));
1926 assert_eq!(v.matches("accept-language").count(), 1);
1927 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1929 assert!(plain.headers().get(header::VARY).is_none());
1930 }
1931
1932 #[tokio::test]
1936 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1937 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1938 let public = keys.public_key();
1939
1940 let resp = create_join_token(
1942 Extension(Issuer(Some(keys.clone()))),
1943 Json(CreateJoinTokenRequest {
1944 ttl_secs: Some(600),
1945 }),
1946 )
1947 .await;
1948 assert_eq!(resp.status(), StatusCode::CREATED);
1949 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
1950 .await
1951 .unwrap();
1952 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
1953 let token = parsed["token"].as_str().unwrap();
1954 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
1955 assert!(!jti.is_empty());
1956
1957 let no_issuer = create_join_token(
1959 Extension(Issuer(None)),
1960 Json(CreateJoinTokenRequest { ttl_secs: None }),
1961 )
1962 .await;
1963 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
1964 }
1965
1966 #[tokio::test]
1971 async fn function_write_path_deploy_rollback_alias_remove() {
1972 use boatramp_core::function::Lifecycle;
1973 use boatramp_core::kv::MemoryKv;
1974 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
1975
1976 struct FakeStorage {
1979 present: bool,
1980 }
1981 #[async_trait::async_trait]
1982 impl Storage for FakeStorage {
1983 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
1984 Err(StorageError::NotFound(String::new()))
1985 }
1986 async fn get_range(
1987 &self,
1988 _: &str,
1989 _: u64,
1990 _: Option<u64>,
1991 ) -> Result<GetObject, StorageError> {
1992 Err(StorageError::NotFound(String::new()))
1993 }
1994 async fn put(
1995 &self,
1996 _: &str,
1997 _: ByteStream,
1998 _: PutMeta,
1999 ) -> Result<ObjectMeta, StorageError> {
2000 Err(StorageError::unsupported("fake"))
2001 }
2002 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2003 if self.present {
2004 Ok(ObjectMeta {
2005 key: key.to_string(),
2006 ..Default::default()
2007 })
2008 } else {
2009 Err(StorageError::NotFound(key.to_string()))
2010 }
2011 }
2012 async fn delete(&self, _: &str) -> Result<(), StorageError> {
2013 Ok(())
2014 }
2015 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2016 Ok(Vec::new())
2017 }
2018 }
2019
2020 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
2021 let status = resp.status();
2022 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2023 .await
2024 .unwrap();
2025 let value = if bytes.is_empty() {
2026 serde_json::Value::Null
2027 } else {
2028 serde_json::from_slice(&bytes).unwrap()
2029 };
2030 (status, value)
2031 }
2032
2033 let deploy = DeployStore::new(
2034 Arc::new(FakeStorage { present: true }),
2035 Arc::new(MemoryKv::new()),
2036 );
2037 let v1 = "a".repeat(64);
2038 let v2 = "b".repeat(64);
2039
2040 let (st, body) = body_json(
2042 deploy_function(
2043 State(deploy.clone()),
2044 axum::extract::Extension(crate::ProjectContext::default()),
2045 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2046 axum::extract::Query(DeployFunctionQuery::default()),
2047 Path("greeter".to_string()),
2048 Json(FunctionUpsert {
2049 component: v1.clone(),
2050 config: Default::default(),
2051 lifecycle: Lifecycle::Independent,
2052 }),
2053 )
2054 .await,
2055 )
2056 .await;
2057 assert_eq!(st, StatusCode::OK);
2058 assert_eq!(body["active"], v1);
2059
2060 let (_, body) = body_json(
2062 deploy_function(
2063 State(deploy.clone()),
2064 axum::extract::Extension(crate::ProjectContext::default()),
2065 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2066 axum::extract::Query(DeployFunctionQuery::default()),
2067 Path("greeter".to_string()),
2068 Json(FunctionUpsert {
2069 component: v2.clone(),
2070 config: Default::default(),
2071 lifecycle: Lifecycle::Independent,
2072 }),
2073 )
2074 .await,
2075 )
2076 .await;
2077 assert_eq!(body["active"], v2);
2078 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
2079
2080 let (st, body) = body_json(
2082 rollback_function(
2083 State(deploy.clone()),
2084 axum::extract::Extension(crate::ProjectContext::default()),
2085 Path("greeter".to_string()),
2086 Json(RollbackBody { to: v1.clone() }),
2087 )
2088 .await,
2089 )
2090 .await;
2091 assert_eq!(st, StatusCode::OK);
2092 assert_eq!(body["active"], v1);
2093
2094 let resp = rollback_function(
2096 State(deploy.clone()),
2097 axum::extract::Extension(crate::ProjectContext::default()),
2098 Path("greeter".to_string()),
2099 Json(RollbackBody { to: "c".repeat(64) }),
2100 )
2101 .await;
2102 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2103
2104 let (st, body) = body_json(
2106 alias_function(
2107 State(deploy.clone()),
2108 axum::extract::Extension(crate::ProjectContext::default()),
2109 Path(("greeter".to_string(), "prod".to_string())),
2110 Json(AliasBody {
2111 version: v2.clone(),
2112 }),
2113 )
2114 .await,
2115 )
2116 .await;
2117 assert_eq!(st, StatusCode::OK);
2118 assert_eq!(body["aliases"]["prod"], v2);
2119
2120 let (st, _) = body_json(
2122 remove_function(
2123 State(deploy.clone()),
2124 axum::extract::Extension(crate::ProjectContext::default()),
2125 Path("greeter".to_string()),
2126 )
2127 .await,
2128 )
2129 .await;
2130 assert_eq!(st, StatusCode::NO_CONTENT);
2131 assert!(deploy
2132 .get_function(ProjectRef::DEFAULT, "greeter")
2133 .await
2134 .unwrap()
2135 .is_none());
2136
2137 let empty = DeployStore::new(
2139 Arc::new(FakeStorage { present: false }),
2140 Arc::new(MemoryKv::new()),
2141 );
2142 let resp = deploy_function(
2143 State(empty),
2144 axum::extract::Extension(crate::ProjectContext::default()),
2145 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2146 axum::extract::Query(DeployFunctionQuery::default()),
2147 Path("orphan".to_string()),
2148 Json(FunctionUpsert {
2149 component: v1.clone(),
2150 config: Default::default(),
2151 lifecycle: Lifecycle::default(),
2152 }),
2153 )
2154 .await;
2155 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2156 }
2157
2158 struct StubControl {
2162 admits: std::sync::Mutex<Vec<(String, String)>>,
2163 respond: StubJoin,
2164 }
2165 #[derive(Clone, Copy)]
2166 enum StubJoin {
2167 Admit,
2168 Spent,
2169 Invalid,
2170 Revoked,
2171 }
2172
2173 #[async_trait::async_trait]
2174 impl MeshControl for StubControl {
2175 async fn admit(
2176 &self,
2177 mesh_pubkey_hex: &str,
2178 jti: &str,
2179 _proof: &[u8],
2180 _proof_iat: u64,
2181 _now: u64,
2182 _advertise_addr: Option<&str>,
2183 ) -> Result<JoinOutcome, String> {
2184 self.admits
2185 .lock()
2186 .unwrap()
2187 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2188 Ok(match self.respond {
2189 StubJoin::Admit => JoinOutcome::Admitted {
2190 members: vec!["signed-member".to_string()],
2191 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2192 },
2193 StubJoin::Spent => JoinOutcome::TokenSpent,
2194 StubJoin::Invalid => JoinOutcome::ProofInvalid,
2195 StubJoin::Revoked => JoinOutcome::Revoked,
2196 })
2197 }
2198 async fn rotate_key(&self) -> Result<String, String> {
2199 Ok("cafe".to_string())
2200 }
2201 async fn revoke(&self, _node: u64) -> Result<(), String> {
2202 Ok(())
2203 }
2204 async fn members(&self) -> Result<Vec<MeshMember>, String> {
2205 Ok(Vec::new())
2206 }
2207 async fn promote(&self, _node: u64) -> Result<(), String> {
2208 Ok(())
2209 }
2210 }
2211
2212 #[tokio::test]
2216 async fn cluster_join_dispatches_and_maps_outcomes() {
2217 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2218 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2219 let auth = Auth::with_key(keys.public_key(), kv);
2220 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2221 let req = |proof: &str| JoinRequest {
2222 token: token.clone(),
2223 mesh_pubkey: "302a300506032b6570032100feed".into(),
2224 possession_proof: proof.to_string(),
2225 proof_iat: now_unix(),
2226 advertise_addr: Some("https://joiner:7000".into()),
2227 };
2228
2229 let admitter = Arc::new(StubControl {
2231 admits: std::sync::Mutex::new(Vec::new()),
2232 respond: StubJoin::Admit,
2233 });
2234 let resp = cluster_join(
2235 Extension(auth.clone()),
2236 Extension(MeshControlHandle(Some(admitter.clone()))),
2237 Json(req("aa01")),
2238 )
2239 .await;
2240 assert_eq!(resp.status(), StatusCode::OK);
2241 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2242
2243 let spent = Arc::new(StubControl {
2245 admits: std::sync::Mutex::new(Vec::new()),
2246 respond: StubJoin::Spent,
2247 });
2248 assert_eq!(
2249 cluster_join(
2250 Extension(auth.clone()),
2251 Extension(MeshControlHandle(Some(spent))),
2252 Json(req("aa01")),
2253 )
2254 .await
2255 .status(),
2256 StatusCode::CONFLICT
2257 );
2258 let invalid = Arc::new(StubControl {
2259 admits: std::sync::Mutex::new(Vec::new()),
2260 respond: StubJoin::Invalid,
2261 });
2262 assert_eq!(
2263 cluster_join(
2264 Extension(auth.clone()),
2265 Extension(MeshControlHandle(Some(invalid))),
2266 Json(req("aa01")),
2267 )
2268 .await
2269 .status(),
2270 StatusCode::FORBIDDEN
2271 );
2272 let revoked = Arc::new(StubControl {
2274 admits: std::sync::Mutex::new(Vec::new()),
2275 respond: StubJoin::Revoked,
2276 });
2277 assert_eq!(
2278 cluster_join(
2279 Extension(auth.clone()),
2280 Extension(MeshControlHandle(Some(revoked))),
2281 Json(req("aa01")),
2282 )
2283 .await
2284 .status(),
2285 StatusCode::FORBIDDEN
2286 );
2287
2288 let ok = Arc::new(StubControl {
2290 admits: std::sync::Mutex::new(Vec::new()),
2291 respond: StubJoin::Admit,
2292 });
2293 assert_eq!(
2294 cluster_join(
2295 Extension(auth.clone()),
2296 Extension(MeshControlHandle(Some(ok))),
2297 Json(req("not-hex")),
2298 )
2299 .await
2300 .status(),
2301 StatusCode::BAD_REQUEST
2302 );
2303
2304 let none = cluster_join(
2306 Extension(auth),
2307 Extension(MeshControlHandle(None)),
2308 Json(req("aa01")),
2309 )
2310 .await;
2311 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2312 }
2313
2314 #[tokio::test]
2318 async fn bootstrap_mints_the_first_token_once() {
2319 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2320 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2321 let public = keys.public_key();
2322 let deploy = DeployStore::new(
2323 Arc::new(MemStorage::default()),
2324 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2325 );
2326 let secret = "s3cr3t-bootstrap-value";
2327 let gate = BootstrapGate::new(Some(secret));
2328 let issuer = Issuer(Some(keys.clone()));
2329 let bearer = |s: &str| {
2330 let mut h = HeaderMap::new();
2331 h.insert(
2332 AUTHORIZATION,
2333 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2334 );
2335 h
2336 };
2337 let req = || BootstrapRequest {
2338 roles: vec!["admin".to_string()],
2339 ttl_secs: None,
2340 };
2341
2342 let bad = bootstrap_token(
2344 State(deploy.clone()),
2345 Extension(issuer.clone()),
2346 Extension(gate.clone()),
2347 bearer("wrong"),
2348 Json(req()),
2349 )
2350 .await;
2351 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2352
2353 let ok = bootstrap_token(
2355 State(deploy.clone()),
2356 Extension(issuer.clone()),
2357 Extension(gate.clone()),
2358 bearer(secret),
2359 Json(req()),
2360 )
2361 .await;
2362 assert_eq!(ok.status(), StatusCode::CREATED);
2363 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2364 .await
2365 .unwrap();
2366 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2367 let token = json["token"].as_str().unwrap();
2368 let id = json["id"].as_str().unwrap();
2369 let verified = cose::verify(token, &public, now_unix()).unwrap();
2370 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2371 assert!(deploy
2372 .list_token_meta()
2373 .await
2374 .unwrap()
2375 .iter()
2376 .any(|m| m.revocation_id == id));
2377
2378 let reuse = bootstrap_token(
2380 State(deploy.clone()),
2381 Extension(issuer.clone()),
2382 Extension(gate),
2383 bearer(secret),
2384 Json(req()),
2385 )
2386 .await;
2387 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2388
2389 let disabled = bootstrap_token(
2391 State(deploy),
2392 Extension(issuer),
2393 Extension(BootstrapGate(None)),
2394 bearer(secret),
2395 Json(req()),
2396 )
2397 .await;
2398 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2399 }
2400
2401 #[tokio::test]
2404 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2405 let control = Arc::new(StubControl {
2406 admits: std::sync::Mutex::new(Vec::new()),
2407 respond: StubJoin::Admit,
2408 });
2409 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2410 assert_eq!(resp.status(), StatusCode::OK);
2411 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2412 .await
2413 .unwrap();
2414 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2415 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2416
2417 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2418 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2419 }
2420
2421 #[test]
2422 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2423 use boatramp_core::security::SecurityProfile;
2424 let strict = SecurityProfile::MultiTenant.preset();
2425 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2429 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2430 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2431
2432 assert!(gateway_addr_allowed(public, &strict));
2434 assert!(!gateway_addr_allowed(private, &strict));
2435 assert!(!gateway_addr_allowed(loopback, &strict));
2436 assert!(!gateway_addr_allowed(metadata, &strict));
2437
2438 assert!(gateway_addr_allowed(public, &loose));
2441 assert!(gateway_addr_allowed(private, &loose));
2442 assert!(gateway_addr_allowed(loopback, &loose));
2443 assert!(!gateway_addr_allowed(metadata, &loose));
2444 }
2445
2446 #[tokio::test]
2447 async fn resolve_env_merges_static_and_host_secrets() {
2448 use boatramp_core::config::HandlersSiteConfig;
2449
2450 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2452
2453 let deploy_env = std::collections::BTreeMap::from([
2454 ("GREETING".to_string(), "hi".to_string()),
2455 ("OVERRIDE_ME".to_string(), "static".to_string()),
2456 ]);
2457 let site_handlers = HandlersSiteConfig {
2458 enabled: true,
2459 secrets: std::collections::BTreeMap::from([
2460 (
2462 "SECRET_TOKEN".to_string(),
2463 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2464 ),
2465 (
2466 "OVERRIDE_ME".to_string(),
2467 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2468 ),
2469 (
2470 "MISSING".to_string(),
2471 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2472 ),
2473 ]),
2474 ..Default::default()
2475 };
2476 let env = resolve_env(
2479 "blog",
2480 boatramp_core::project::ProjectRef::DEFAULT,
2481 &deploy_env,
2482 &site_handlers,
2483 true,
2484 None,
2485 )
2486 .await
2487 .expect("resolves");
2488
2489 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2493 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2494 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2495 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2496
2497 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2498 }
2499
2500 #[tokio::test]
2501 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2502 use boatramp_core::config::HandlersSiteConfig;
2503
2504 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2509 let deploy_env = std::collections::BTreeMap::new();
2510 let bare = HandlersSiteConfig {
2511 enabled: true,
2512 secrets: std::collections::BTreeMap::from([(
2513 "STOLEN".to_string(),
2514 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2515 )]),
2516 ..Default::default()
2517 };
2518 let err = resolve_env(
2519 "evil",
2520 boatramp_core::project::ProjectRef::DEFAULT,
2521 &deploy_env,
2522 &bare,
2523 false,
2524 None,
2525 )
2526 .await
2527 .expect_err("multi-tenant must refuse a bare host-env ref");
2528 assert!(
2529 err.contains("STOLEN"),
2530 "error names the offending guest var: {err}"
2531 );
2532 assert!(
2533 err.contains("multi-tenant"),
2534 "error steers the tenant: {err}"
2535 );
2536 assert!(
2537 !err.contains("leak-me"),
2538 "the host value must never appear (never read): {err}"
2539 );
2540
2541 let explicit = HandlersSiteConfig {
2543 enabled: true,
2544 secrets: std::collections::BTreeMap::from([(
2545 "STOLEN".to_string(),
2546 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2547 )]),
2548 ..Default::default()
2549 };
2550 assert!(resolve_env(
2551 "evil",
2552 boatramp_core::project::ProjectRef::DEFAULT,
2553 &deploy_env,
2554 &explicit,
2555 false,
2556 None,
2557 )
2558 .await
2559 .is_err());
2560
2561 let reserved = HandlersSiteConfig {
2563 enabled: true,
2564 secrets: std::collections::BTreeMap::from([(
2565 "TOKEN".to_string(),
2566 "vault:kv/data/app#token".to_string(),
2567 )]),
2568 ..Default::default()
2569 };
2570 let err = resolve_env(
2571 "evil",
2572 boatramp_core::project::ProjectRef::DEFAULT,
2573 &deploy_env,
2574 &reserved,
2575 true,
2576 None,
2577 )
2578 .await
2579 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2580 assert!(err.contains("not yet supported"), "{err}");
2581
2582 let arbitrary = HandlersSiteConfig {
2586 enabled: true,
2587 secrets: std::collections::BTreeMap::from([(
2588 "KEY".to_string(),
2589 "aws:sm/prod/apikey".to_string(),
2590 )]),
2591 ..Default::default()
2592 };
2593 let err = resolve_env(
2594 "evil",
2595 boatramp_core::project::ProjectRef::DEFAULT,
2596 &deploy_env,
2597 &arbitrary,
2598 true,
2599 None,
2600 )
2601 .await
2602 .expect_err("any unknown scheme is reserved, even under single-tenant");
2603 assert!(
2604 err.contains("not yet supported") && err.contains("aws"),
2605 "provider-neutral reservation names the scheme: {err}"
2606 );
2607
2608 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2609 }
2610
2611 #[tokio::test]
2612 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2613 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2618
2619 let static_env = std::collections::BTreeMap::from([
2620 ("STAGE".to_string(), "prod".to_string()),
2621 ("OVERRIDE_ME".to_string(), "static".to_string()),
2622 ]);
2623 let secrets = std::collections::BTreeMap::from([
2624 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2626 (
2628 "OVERRIDE_ME".to_string(),
2629 "BOATRAMP_TEST_FN_SECRET".to_string(),
2630 ),
2631 (
2633 "MISSING".to_string(),
2634 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2635 ),
2636 ]);
2637 let env = resolve_secret_env(
2639 "fn/api",
2640 boatramp_core::project::ProjectRef::DEFAULT,
2641 &static_env,
2642 &secrets,
2643 true,
2644 None,
2645 )
2646 .await
2647 .expect("resolves");
2648
2649 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2650 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2652 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2654 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2656
2657 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2658 }
2659
2660 #[tokio::test]
2661 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2662 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2667 let static_env = std::collections::BTreeMap::new();
2668 let secrets = std::collections::BTreeMap::from([(
2669 "DB_URL".to_string(),
2670 "BOATRAMP_TEST_FN_LEAK".to_string(),
2671 )]);
2672
2673 let err = resolve_secret_env(
2675 "fn/api",
2676 boatramp_core::project::ProjectRef::DEFAULT,
2677 &static_env,
2678 &secrets,
2679 false,
2680 None,
2681 )
2682 .await
2683 .expect_err("multi-tenant must refuse a function host-env ref");
2684 assert!(
2685 err.contains("DB_URL"),
2686 "error names the offending guest var: {err}"
2687 );
2688 assert!(
2689 !err.contains("leak-me"),
2690 "host value must never appear: {err}"
2691 );
2692
2693 let env = resolve_secret_env(
2695 "fn/api",
2696 boatramp_core::project::ProjectRef::DEFAULT,
2697 &static_env,
2698 &secrets,
2699 true,
2700 None,
2701 )
2702 .await
2703 .expect("resolves");
2704 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2705
2706 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2707 }
2708
2709 #[tokio::test]
2710 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2711 use boatramp_core::project::ProjectRef;
2712 use boatramp_core::secret_store::SecretStore;
2713 use std::sync::Arc;
2714
2715 struct XorEnvelope;
2717 #[async_trait::async_trait]
2718 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2719 async fn wrap(
2720 &self,
2721 p: &[u8],
2722 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2723 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2724 }
2725 async fn unwrap(
2726 &self,
2727 c: &[u8],
2728 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2729 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2730 }
2731 }
2732
2733 let store = SecretStore::new(
2734 Arc::new(boatramp_core::kv::MemoryKv::new()),
2735 Arc::new(XorEnvelope),
2736 );
2737 store
2738 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2739 .await
2740 .unwrap();
2741
2742 let static_env = std::collections::BTreeMap::new();
2743 let secrets = std::collections::BTreeMap::from([
2744 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2745 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2746 ]);
2747
2748 let env = resolve_secret_env(
2751 "site",
2752 ProjectRef::new("acme"),
2753 &static_env,
2754 &secrets,
2755 false,
2756 Some(&store),
2757 )
2758 .await
2759 .expect("boatramp refs resolve without the host-env gate");
2760 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2761 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2763
2764 let other_secrets = std::collections::BTreeMap::from([(
2766 "API_KEY".to_string(),
2767 "boatramp:api-key".to_string(),
2768 )]);
2769 let other = resolve_secret_env(
2770 "site",
2771 ProjectRef::new("globex"),
2772 &static_env,
2773 &other_secrets,
2774 false,
2775 Some(&store),
2776 )
2777 .await
2778 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2779 assert!(
2780 !other.iter().any(|(k, _)| k == "API_KEY"),
2781 "a tenant must not read another project's secret"
2782 );
2783
2784 let err = resolve_secret_env(
2786 "site",
2787 ProjectRef::new("acme"),
2788 &static_env,
2789 &other_secrets,
2790 false,
2791 None,
2792 )
2793 .await
2794 .expect_err("no store configured must fail closed");
2795 assert!(err.contains("no internal secret store"), "{err}");
2796 }
2797
2798 fn req() -> Request {
2799 Request::builder()
2800 .uri("/")
2801 .header(header::HOST, "example.com")
2802 .body(Body::empty())
2803 .unwrap()
2804 }
2805
2806 #[test]
2807 fn forwarded_headers_set_standard_triple() {
2808 let mut request = req();
2809 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2810 let h = request.headers();
2811 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2812 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2813 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2814 }
2815
2816 #[test]
2817 fn forwarded_for_overwrites_spoofed_value() {
2818 let mut request = Request::builder()
2821 .uri("/")
2822 .header(header::HOST, "example.com")
2823 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2824 .body(Body::empty())
2825 .unwrap();
2826 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2827 let values: Vec<_> = request
2828 .headers()
2829 .get_all("x-forwarded-for")
2830 .iter()
2831 .collect();
2832 assert_eq!(values.len(), 1);
2833 assert_eq!(values[0], "203.0.113.7");
2834 }
2835
2836 #[test]
2837 fn forwarded_proto_preserves_upstream_tls() {
2838 let mut request = Request::builder()
2840 .uri("/")
2841 .header(header::HOST, "example.com")
2842 .header("x-forwarded-proto", "https")
2843 .body(Body::empty())
2844 .unwrap();
2845 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2846 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2847 }
2848
2849 #[test]
2850 fn forwarded_host_absent_when_no_host_header() {
2851 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2852 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2853 assert!(request.headers().get("x-forwarded-host").is_none());
2854 assert_eq!(
2855 request.headers().get("x-forwarded-for").unwrap(),
2856 "203.0.113.7"
2857 );
2858 }
2859
2860 use boatramp_core::kv::{KvStore, MemoryKv};
2863 use boatramp_core::messaging::{LogMessaging, Messaging};
2864 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2865
2866 const EVENT_CONSUMER: &[u8] =
2867 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2868
2869 #[derive(Default)]
2870 struct MemStorage {
2871 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2872 }
2873
2874 #[async_trait::async_trait]
2875 impl boatramp_core::Storage for MemStorage {
2876 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2877 let bytes = self
2878 .objects
2879 .lock()
2880 .unwrap()
2881 .get(key)
2882 .cloned()
2883 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2884 let body: ByteStream =
2885 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2886 Ok(GetObject {
2887 meta: ObjectMeta {
2888 key: key.to_string(),
2889 ..Default::default()
2890 },
2891 body,
2892 })
2893 }
2894 async fn get_range(
2895 &self,
2896 key: &str,
2897 _: u64,
2898 _: Option<u64>,
2899 ) -> Result<GetObject, StorageError> {
2900 self.get(key).await
2901 }
2902 async fn put(
2903 &self,
2904 key: &str,
2905 mut body: ByteStream,
2906 _: PutMeta,
2907 ) -> Result<ObjectMeta, StorageError> {
2908 use futures::StreamExt;
2909 let mut buf = Vec::new();
2910 while let Some(chunk) = body.next().await {
2911 buf.extend_from_slice(&chunk?);
2912 }
2913 self.objects.lock().unwrap().insert(key.to_string(), buf);
2914 Ok(ObjectMeta {
2915 key: key.to_string(),
2916 ..Default::default()
2917 })
2918 }
2919 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2920 self.objects
2921 .lock()
2922 .unwrap()
2923 .get(key)
2924 .map(|_| ObjectMeta {
2925 key: key.to_string(),
2926 ..Default::default()
2927 })
2928 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2929 }
2930 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2931 self.objects.lock().unwrap().remove(key);
2932 Ok(())
2933 }
2934 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2935 Ok(Vec::new())
2936 }
2937 }
2938
2939 fn observed_state_in(
2942 project: &str,
2943 workload: &str,
2944 host: &str,
2945 healthy: bool,
2946 phase: boatramp_core::compute::ReplicaPhase,
2947 ) -> boatramp_core::compute::ObservedInstance {
2948 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
2949 boatramp_core::compute::ObservedInstance {
2950 handle: InstanceHandle {
2951 project: project.into(),
2952 workload: workload.into(),
2953 replica: 0,
2954 backend_ref: "ref-0".into(),
2955 },
2956 node: 1,
2957 backend: "vmm".into(),
2958 endpoint: Endpoint {
2959 scheme: Scheme::Http,
2960 host: host.into(),
2961 port: 80,
2962 },
2963 region: None,
2964 healthy,
2965 started_at: None,
2966 phase,
2967 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
2968 project: project.into(),
2969 workload: workload.into(),
2970 replica: 0,
2971 data_ref: "snap-0".into(),
2972 }),
2973 }
2974 }
2975
2976 fn observed_state(
2978 workload: &str,
2979 healthy: bool,
2980 phase: boatramp_core::compute::ReplicaPhase,
2981 ) -> boatramp_core::compute::ObservedInstance {
2982 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
2983 }
2984
2985 #[tokio::test]
2986 async fn has_parked_replica_detects_a_zeroed_replica() {
2987 use boatramp_core::compute::ReplicaPhase;
2988 let storage = Arc::new(MemStorage::default());
2989 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2990 let deploy = DeployStore::new(storage, kv);
2991
2992 assert!(!has_parked_replica(&deploy, "default", "w").await);
2994 deploy
2996 .set_replica_state(
2997 ProjectRef::DEFAULT,
2998 &observed_state("w", true, ReplicaPhase::Running),
2999 )
3000 .await
3001 .unwrap();
3002 assert!(!has_parked_replica(&deploy, "default", "w").await);
3003 deploy
3005 .set_replica_state(
3006 ProjectRef::DEFAULT,
3007 &observed_state("w", false, ReplicaPhase::Zero),
3008 )
3009 .await
3010 .unwrap();
3011 assert!(has_parked_replica(&deploy, "default", "w").await);
3012 }
3013
3014 #[tokio::test]
3015 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
3016 use boatramp_core::compute::ReplicaPhase;
3017 let storage = Arc::new(MemStorage::default());
3018 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3019 let deploy = DeployStore::new(storage, kv);
3020
3021 let empty = await_warm(
3023 &deploy,
3024 "default",
3025 "w",
3026 std::time::Duration::from_millis(150),
3027 )
3028 .await;
3029 assert!(empty.is_empty());
3030
3031 deploy
3033 .set_replica_state(
3034 ProjectRef::DEFAULT,
3035 &observed_state("w", true, ReplicaPhase::Running),
3036 )
3037 .await
3038 .unwrap();
3039 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
3040 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
3041 }
3042
3043 #[tokio::test]
3049 async fn compute_endpoints_are_project_scoped() {
3050 use boatramp_core::compute::ReplicaPhase;
3051 let storage = Arc::new(MemStorage::default());
3052 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3053 let deploy = DeployStore::new(storage, kv);
3054
3055 deploy
3057 .set_replica_state(
3058 ProjectRef::new("acme"),
3059 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
3060 )
3061 .await
3062 .unwrap();
3063 deploy
3064 .set_replica_state(
3065 ProjectRef::DEFAULT,
3066 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
3067 )
3068 .await
3069 .unwrap();
3070
3071 assert_eq!(
3073 compute_endpoints(&deploy, "acme", "web").await,
3074 vec!["http://10.0.0.5:80".to_string()],
3075 "acme's web resolves against acme, not default"
3076 );
3077 assert_eq!(
3079 compute_endpoints(&deploy, "default", "web").await,
3080 vec!["http://10.0.0.9:80".to_string()]
3081 );
3082 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
3085 }
3086
3087 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3091 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
3092 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3093 let storage = Arc::new(MemStorage::default());
3094 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3095 let mq = LogMessaging::new(storage, kv.clone());
3096 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3097 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3098 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3099 let topic = "blog/orders/created";
3100
3101 for _ in 0..3 {
3103 mq.publish(topic, b"ok").await.unwrap();
3104 }
3105 loop {
3106 let acked = dispatch_consumer_batch(
3107 &engine,
3108 &mq,
3109 &metrics::Metrics::default(),
3110 "blog",
3111 topic,
3112 "blog/",
3113 "",
3114 boatramp_core::messaging::StartPosition::Latest,
3115 &hash,
3116 EVENT_CONSUMER,
3117 &bindings,
3118 None,
3120 Limits::default(),
3121 Duration::from_secs(30),
3122 5,
3123 10,
3124 )
3125 .await;
3126 if acked == 0 {
3127 break;
3128 }
3129 }
3130 assert_eq!(
3131 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3132 Some(b"3".to_vec())
3133 );
3134
3135 mq.publish(topic, b"fail").await.unwrap();
3138 for _ in 0..5 {
3139 dispatch_consumer_batch(
3140 &engine,
3141 &mq,
3142 &metrics::Metrics::default(),
3143 "blog",
3144 topic,
3145 "blog/",
3146 "",
3147 boatramp_core::messaging::StartPosition::Latest,
3148 &hash,
3149 EVENT_CONSUMER,
3150 &bindings,
3151 None,
3153 Limits::default(),
3154 Duration::ZERO,
3155 2,
3156 10,
3157 )
3158 .await;
3159 }
3160 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
3161 assert_eq!(
3163 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3164 Some(b"3".to_vec())
3165 );
3166 }
3167
3168 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3173 async fn consumer_groups_fan_out_through_the_dispatcher() {
3174 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3175 let storage = Arc::new(MemStorage::default());
3176 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3177 let mq = LogMessaging::new(storage, kv.clone());
3178 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3179 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3180 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3181 let topic = "blog/orders/created";
3182 let start = boatramp_core::messaging::StartPosition::Latest;
3183
3184 for g in ["billing", "audit"] {
3187 let n = dispatch_consumer_batch(
3188 &engine,
3189 &mq,
3190 &metrics::Metrics::default(),
3191 "blog",
3192 topic,
3193 "blog/",
3194 g,
3195 start,
3196 &hash,
3197 EVENT_CONSUMER,
3198 &bindings,
3199 None,
3201 Limits::default(),
3202 Duration::from_secs(30),
3203 5,
3204 10,
3205 )
3206 .await;
3207 assert_eq!(n, 0, "no events yet for group {g}");
3208 }
3209 mq.publish(topic, b"ok").await.unwrap();
3210
3211 for g in ["billing", "audit"] {
3213 let n = dispatch_consumer_batch(
3214 &engine,
3215 &mq,
3216 &metrics::Metrics::default(),
3217 "blog",
3218 topic,
3219 "blog/",
3220 g,
3221 start,
3222 &hash,
3223 EVENT_CONSUMER,
3224 &bindings,
3225 None,
3227 Limits::default(),
3228 Duration::from_secs(30),
3229 5,
3230 10,
3231 )
3232 .await;
3233 assert_eq!(n, 1, "group {g} should receive the message");
3234 }
3235 assert_eq!(
3237 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3238 Some(b"2".to_vec())
3239 );
3240 }
3241
3242 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3246 async fn scheduler_runs_current_consumers_not_previews() {
3247 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3248 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3249 use boatramp_handlers::{HandlerEngine, Limits};
3250 use futures::StreamExt;
3251
3252 let storage = Arc::new(MemStorage::default());
3253 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3254 let deploy = DeployStore::new(storage.clone(), kv.clone());
3255 let messaging: Arc<dyn Messaging> =
3256 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3257
3258 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3260 let stream: ByteStream =
3261 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3262 .boxed();
3263 deploy.put_blob(&hash, stream).await.unwrap();
3264 let mut files = std::collections::BTreeMap::new();
3265 files.insert(
3266 "consumer.wasm".to_string(),
3267 FileEntry {
3268 hash: hash.clone(),
3269 size: EVENT_CONSUMER.len() as u64,
3270 content_type: None,
3271 variants: std::collections::BTreeMap::new(),
3272 },
3273 );
3274 let manifest = Manifest {
3275 files,
3276 config: DeployConfig {
3277 consumers: vec![ConsumerConfig {
3278 tenancy: None,
3279 token_claims: None,
3280 topic: "orders/created".into(),
3281 component: "consumer.wasm".into(),
3282 imports: vec!["wasi:keyvalue".into()],
3283 group: String::new(),
3284 start: Default::default(),
3285 }],
3286 ..Default::default()
3287 },
3288 ..Default::default()
3289 };
3290 let id = deploy.put_manifest(&manifest).await.unwrap();
3291 deploy
3292 .activate(ProjectRef::DEFAULT, "blog", &id)
3293 .await
3294 .unwrap();
3295 deploy
3296 .set_site_config(
3297 ProjectRef::DEFAULT,
3298 "blog",
3299 &SiteConfig {
3300 handlers: Some(HandlersSiteConfig {
3301 enabled: true,
3302 allow_imports: vec!["wasi:keyvalue".into()],
3303 ..Default::default()
3304 }),
3305 ..Default::default()
3306 },
3307 )
3308 .await
3309 .unwrap();
3310
3311 messaging
3313 .publish("blog/orders/created", b"live")
3314 .await
3315 .unwrap();
3316 messaging
3317 .publish("blog/_preview/abc/orders/created", b"preview")
3318 .await
3319 .unwrap();
3320
3321 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3322 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3323 let inner = rt.inner.clone().unwrap();
3324 let mut cache = std::collections::HashMap::new();
3325 let mut crons = std::collections::HashMap::new();
3326 let mut sweep = std::collections::HashMap::new();
3327 let now = CronNow {
3328 minute: 0,
3329 hour: 0,
3330 dom: 1,
3331 month: 1,
3332 dow: 0,
3333 minute_stamp: 0,
3334 };
3335 for _ in 0..3 {
3336 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3337 .await
3338 .unwrap();
3339 }
3340
3341 assert_eq!(
3343 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3344 Some(b"1".to_vec())
3345 );
3346 assert_eq!(
3349 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3350 .await
3351 .unwrap(),
3352 None
3353 );
3354 }
3355
3356 const KV_COUNTER: &[u8] =
3361 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3362
3363 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3368 async fn function_invoker_runs_target_buffers_and_meters() {
3369 use boatramp_core::deploy::DeployStore;
3370 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3371 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3372 use futures::StreamExt;
3373
3374 const HTTP_200: &[u8] =
3377 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3378
3379 let storage = Arc::new(MemStorage::default());
3380 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3381 let deploy = DeployStore::new(storage.clone(), kv.clone());
3382
3383 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3384 let stream: ByteStream =
3385 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3386 deploy.put_blob(&hash, stream).await.unwrap();
3387 let function = Function {
3388 name: "target".into(),
3389 owner: Owner::Project("default".into()),
3390 versions: vec![FunctionVersion {
3391 id: "v1".into(),
3392 component: hash.clone(),
3393 created: 0,
3394 lifecycle: Lifecycle::Independent,
3395 }],
3396 active: "v1".into(),
3397 aliases: Default::default(),
3398 config: Default::default(),
3399 };
3400 deploy
3401 .put_function(ProjectRef::DEFAULT, &function)
3402 .await
3403 .unwrap();
3404
3405 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3406 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3407 rt.set_invoker(deploy.clone());
3408 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3409
3410 let request = || InvokeRequest {
3411 method: "GET".into(),
3412 path: "/".into(),
3413 headers: vec![],
3414 body: vec![],
3415 };
3416
3417 let response = invoker.invoke("target", request(), 1).await.unwrap();
3419 assert_eq!(response.status, 200);
3420
3421 let metering = deploy
3423 .get_metering(ProjectRef::DEFAULT, "target")
3424 .await
3425 .unwrap()
3426 .unwrap();
3427 assert_eq!(metering.invocations, 1);
3428
3429 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3431 assert!(matches!(err, InvokeError::NotFound));
3432 }
3433
3434 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3440 async fn federation_runner_enforces_the_safelist_before_planning() {
3441 use boatramp_core::deploy::DeployStore;
3442 use boatramp_core::project::ProjectRef;
3443 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3444
3445 let storage = Arc::new(MemStorage::default());
3446 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3447 let deploy = DeployStore::new(storage.clone(), kv.clone());
3448 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3449 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3450 rt.set_invoker(deploy.clone());
3451 let runner = rt
3452 .inner
3453 .as_ref()
3454 .unwrap()
3455 .federation_runner
3456 .get()
3457 .unwrap()
3458 .scoped(ProjectRef::new("default"), Vec::new());
3459
3460 let req = |query: &str| GraphqlRequest {
3461 query: Some(query.to_string()),
3462 persisted_hash: None,
3463 variables: "{}".to_string(),
3464 operation_name: None,
3465 authorization: None,
3466 };
3467
3468 assert!(matches!(
3470 runner.run(req("{ me { id } }"), 1).await,
3471 Err(SupergraphRunError::NotSafelisted)
3472 ));
3473
3474 let query = "{ me { id } }";
3477 let hash = crate::graphql_apq::sha256_hex(query);
3478 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3479 .await
3480 .unwrap();
3481 assert!(matches!(
3482 runner.run(req(query), 1).await,
3483 Err(SupergraphRunError::PlanFailed(_))
3484 ));
3485
3486 let persisted = GraphqlRequest {
3488 query: None,
3489 persisted_hash: Some("deadbeef".to_string()),
3490 variables: "{}".to_string(),
3491 operation_name: None,
3492 authorization: None,
3493 };
3494 assert!(matches!(
3495 runner.run(persisted, 1).await,
3496 Err(SupergraphRunError::NotSafelisted)
3497 ));
3498 }
3499
3500 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3509 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI graphql-propagation gate"]
3510 async fn graphql_run_propagates_caller_principal_to_a_scoped_subfetch() {
3511 use boatramp_core::deploy::{sha256_hex, DeployStore};
3512 use boatramp_core::function::{
3513 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3514 };
3515 use boatramp_core::project::ProjectRef;
3516 use boatramp_core::sql::{SqlBackends, SqlValue};
3517 use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3518 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, ScopeFact};
3519
3520 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3524
3525 let storage = Arc::new(MemStorage::default());
3526 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3527 let deploy = DeployStore::new(storage.clone(), kv.clone());
3528
3529 let sql_dir = std::env::temp_dir().join(format!("br-gqlprop-{}", std::process::id()));
3532 let _ = std::fs::remove_dir_all(&sql_dir);
3533 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3534 let db = backends
3535 .database("default", "fn/scopeprobe", "")
3536 .await
3537 .unwrap();
3538 {
3539 let mut tx = db.begin().await.unwrap();
3540 tx.execute(
3541 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3542 &[],
3543 )
3544 .await
3545 .unwrap();
3546 for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3547 tx.execute(
3548 "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3549 &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3550 )
3551 .await
3552 .unwrap();
3553 }
3554 tx.commit().await.unwrap();
3555 }
3556 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3557
3558 let hash = sha256_hex(PROBE);
3561 let stream: ByteStream =
3562 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3563 deploy.put_blob(&hash, stream).await.unwrap();
3564 let function = Function {
3565 name: "scopeprobe".into(),
3566 owner: Owner::Project("default".into()),
3567 versions: vec![FunctionVersion {
3568 id: "v1".into(),
3569 component: hash.clone(),
3570 created: 0,
3571 lifecycle: Lifecycle::Independent,
3572 }],
3573 active: "v1".into(),
3574 aliases: Default::default(),
3575 config: FunctionConfig {
3576 imports: vec!["sql".into()],
3577 tenancy: Some(Tenancy::Scoped {
3578 column: "tenant_id".into(),
3579 sources: vec![TenantSource::None],
3580 read: AccessMode::Own,
3581 write: AccessMode::None,
3582 }),
3583 ..Default::default()
3584 },
3585 };
3586 deploy
3587 .put_function(ProjectRef::DEFAULT, &function)
3588 .await
3589 .unwrap();
3590
3591 crate::graphql_registry::publish(
3593 kv.as_ref(),
3594 "default",
3595 "scopeprobe",
3596 "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }",
3597 )
3598 .await
3599 .unwrap();
3600 let query = "{ items { id } }";
3601 let op_hash = crate::graphql_apq::sha256_hex(query);
3602 kv.put(
3603 &format!("hapq/default/{op_hash}"),
3604 query.as_bytes().to_vec(),
3605 )
3606 .await
3607 .unwrap();
3608
3609 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3610 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3611 rt.set_invoker(deploy.clone());
3612 let fed = rt
3613 .inner
3614 .as_ref()
3615 .unwrap()
3616 .federation_runner
3617 .get()
3618 .unwrap()
3619 .clone();
3620 let req = || GraphqlRequest {
3621 query: Some(query.to_string()),
3622 persisted_hash: None,
3623 variables: "{}".to_string(),
3624 operation_name: None,
3625 authorization: None,
3626 };
3627
3628 let runner_b = fed.scoped(
3630 ProjectRef::new("default"),
3631 vec![ScopeFact {
3632 axis: ScopeAxis::Tenant,
3633 value: SqlValue::Text("tenant_B".into()),
3634 }],
3635 );
3636 let body_b = String::from_utf8_lossy(&runner_b.run(req(), 0).await.unwrap()).into_owned();
3637 assert!(
3638 body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
3639 "graphql::run propagated principal B → the subgraph read ONLY tenant B's rows: {body_b}"
3640 );
3641
3642 let runner_empty = fed.scoped(ProjectRef::new("default"), Vec::new());
3645 let body_none =
3646 String::from_utf8_lossy(&runner_empty.run(req(), 0).await.unwrap()).into_owned();
3647 assert!(
3648 !body_none.contains("\"a1\"")
3649 && !body_none.contains("\"b1\"")
3650 && !body_none.contains("\"b2\""),
3651 "with NO propagated principal the subgraph fails closed — no rows leak: {body_none}"
3652 );
3653
3654 let _ = std::fs::remove_dir_all(&sql_dir);
3655 println!(
3656 "GRAPHQL-RUN PRINCIPAL PROPAGATION OK: graphql::run carried the caller's resolved principal \
3657 (tenant B) to a federated subgraph sub-fetch, which resolved its own-tenancy from the \
3658 inherited principal and returned ONLY tenant B's rows over a real libsql engine; with no \
3659 principal the same sub-fetch failed closed (no rows) — the async lane can drive a \
3660 tenant-scoped supergraph read/write with no request bearer, symmetric to emit::invoke"
3661 );
3662 }
3663
3664 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3673 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
3674 async fn gateway_forces_target_confinement_onto_a_wasm_subgraph() {
3675 use boatramp_core::deploy::{sha256_hex, DeployStore};
3676 use boatramp_core::function::{
3677 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3678 };
3679 use boatramp_core::project::ProjectRef;
3680 use boatramp_core::sql::{SqlBackends, SqlValue};
3681 use boatramp_core::tenancy::{
3682 AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
3683 TenancySchema, TenantSource,
3684 };
3685 use boatramp_handlers::{HandlerEngine, Limits};
3686
3687 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3693
3694 let storage = Arc::new(MemStorage::default());
3695 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3696 let deploy = DeployStore::new(storage.clone(), kv.clone());
3697
3698 let sql_dir = std::env::temp_dir().join(format!("br-gwtarget-{}", std::process::id()));
3701 let _ = std::fs::remove_dir_all(&sql_dir);
3702 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3703 let db = backends
3704 .database("default", "fn/scopeprobe", "")
3705 .await
3706 .unwrap();
3707 {
3708 let mut tx = db.begin().await.unwrap();
3709 tx.execute(
3710 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
3711 &[],
3712 )
3713 .await
3714 .unwrap();
3715 for (id, tenant, published) in [
3716 ("a_pub", "tenant_A", 1),
3717 ("b_pub", "tenant_B", 1),
3718 ("b_priv", "tenant_B", 0),
3719 ] {
3720 tx.execute(
3721 "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
3722 &[
3723 SqlValue::Text(id.into()),
3724 SqlValue::Text(tenant.into()),
3725 SqlValue::Integer(published),
3726 ],
3727 )
3728 .await
3729 .unwrap();
3730 }
3731 tx.commit().await.unwrap();
3732 }
3733 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3734
3735 let hash = sha256_hex(PROBE);
3738 let stream: ByteStream =
3739 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3740 deploy.put_blob(&hash, stream).await.unwrap();
3741 let function = Function {
3742 name: "scopeprobe".into(),
3743 owner: Owner::Project("default".into()),
3744 versions: vec![FunctionVersion {
3745 id: "v1".into(),
3746 component: hash.clone(),
3747 created: 0,
3748 lifecycle: Lifecycle::Independent,
3749 }],
3750 active: "v1".into(),
3751 aliases: Default::default(),
3752 config: FunctionConfig {
3753 imports: vec!["sql".into()],
3754 tenancy: Some(Tenancy::Scoped {
3755 column: "tenant_id".into(),
3756 sources: vec![TenantSource::None],
3757 read: AccessMode::Own,
3758 write: AccessMode::None,
3759 }),
3760 ..Default::default()
3761 },
3762 };
3763 deploy
3764 .put_function(ProjectRef::DEFAULT, &function)
3765 .await
3766 .unwrap();
3767
3768 let sdl = "type Query { items: [Item!]! @tenant(scope: target, via: [domain], public: \"items\") }\n\
3771 type Item @key(fields: \"id\") { id: ID! }";
3772 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3773 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3774
3775 let mut schema = TenancySchema {
3776 default_tenant_key: "tenant_id".into(),
3777 tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
3778 ..Default::default()
3779 };
3780 schema.public_subsets.insert(
3781 "items".into(),
3782 PublicSubset {
3783 predicate: PublicPredicate {
3784 terms: vec![PublicTerm::Cmp {
3785 column: "published".into(),
3786 op: boatramp_core::tenancy::PublicCmp::Eq,
3787 value: boatramp_core::tenancy::PublicLiteral::Int(1),
3788 }],
3789 },
3790 world_public: true,
3791 listable: true,
3792 },
3793 );
3794
3795 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3796 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3797 rt.set_invoker(deploy.clone());
3798 let inner = rt.inner.as_ref().unwrap();
3799 let invoker = inner.invoker.get().unwrap().clone();
3800
3801 let make_router = |domain: Option<&str>| {
3802 crate::graphql_gateway::BackendRouter::new(
3803 invoker.scoped(ProjectRef::new("default"), Vec::new()),
3804 "default".to_string(),
3805 inner.sql.clone(),
3806 std::collections::BTreeMap::new(),
3807 None,
3808 )
3809 .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
3810 schema: Arc::new(schema.clone()),
3811 domain_context: domain.map(str::to_string),
3812 target_handle: None,
3813 capability_anchor: None,
3814 }))
3815 };
3816
3817 let router_b = make_router(Some("tenant_B"));
3819 let out_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({})).await;
3820 let body_b = out_b.to_string();
3821 assert!(
3822 body_b.contains("\"b_pub\"")
3823 && !body_b.contains("\"b_priv\"")
3824 && !body_b.contains("\"a_pub\""),
3825 "gateway forced target B onto the wasm subgraph → ONLY B's PUBLIC row (b_pub), never B's \
3826 private row nor A's: {body_b}"
3827 );
3828
3829 let router_none = make_router(None);
3831 let out_none =
3832 crate::graphql_gateway::execute(&plan, &router_none, &serde_json::json!({})).await;
3833 let body_none = out_none.to_string();
3834 assert!(
3835 !body_none.contains("\"a_pub\"")
3836 && !body_none.contains("\"b_pub\"")
3837 && !body_none.contains("\"b_priv\""),
3838 "with no resolved target the wasm-subgraph target fetch fails closed — no rows: {body_none}"
3839 );
3840
3841 let _ = std::fs::remove_dir_all(&sql_dir);
3842 println!(
3843 "GATEWAY WASM-TARGET OK: the /graphql gateway resolved target tenant B from the routed \
3844 domain and FORCED a HostTenancy::target binding onto the wasm subgraph invocation \
3845 (invoke_target), confining the guest's own SQL to tenant=B AND published=1 over a real \
3846 libsql engine — returned ONLY B's public row (b_pub), never B's private row (b_priv) nor \
3847 tenant A's (a_pub); with no resolved target the fetch failed closed"
3848 );
3849 }
3850
3851 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3861 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-own-propagation gate"]
3862 async fn gateway_propagates_caller_own_principal_to_a_wasm_subgraph() {
3863 use boatramp_core::deploy::{sha256_hex, DeployStore};
3864 use boatramp_core::function::{
3865 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3866 };
3867 use boatramp_core::project::ProjectRef;
3868 use boatramp_core::sql::{SqlBackends, SqlValue};
3869 use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3870 use boatramp_handlers::{HandlerEngine, Limits, ScopeFact};
3871
3872 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3876
3877 let storage = Arc::new(MemStorage::default());
3878 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3879 let deploy = DeployStore::new(storage.clone(), kv.clone());
3880
3881 let sql_dir = std::env::temp_dir().join(format!("br-gwown-{}", std::process::id()));
3882 let _ = std::fs::remove_dir_all(&sql_dir);
3883 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3884 let db = backends
3885 .database("default", "fn/scopeprobe", "")
3886 .await
3887 .unwrap();
3888 {
3889 let mut tx = db.begin().await.unwrap();
3890 tx.execute(
3891 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3892 &[],
3893 )
3894 .await
3895 .unwrap();
3896 for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3897 tx.execute(
3898 "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3899 &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3900 )
3901 .await
3902 .unwrap();
3903 }
3904 tx.commit().await.unwrap();
3905 }
3906 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3907
3908 let hash = sha256_hex(PROBE);
3911 let stream: ByteStream =
3912 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3913 deploy.put_blob(&hash, stream).await.unwrap();
3914 let function = Function {
3915 name: "scopeprobe".into(),
3916 owner: Owner::Project("default".into()),
3917 versions: vec![FunctionVersion {
3918 id: "v1".into(),
3919 component: hash.clone(),
3920 created: 0,
3921 lifecycle: Lifecycle::Independent,
3922 }],
3923 active: "v1".into(),
3924 aliases: Default::default(),
3925 config: FunctionConfig {
3926 imports: vec!["sql".into()],
3927 tenancy: Some(Tenancy::Scoped {
3928 column: "tenant_id".into(),
3929 sources: vec![TenantSource::None],
3930 read: AccessMode::Own,
3931 write: AccessMode::None,
3932 }),
3933 ..Default::default()
3934 },
3935 };
3936 deploy
3937 .put_function(ProjectRef::DEFAULT, &function)
3938 .await
3939 .unwrap();
3940
3941 let sdl = "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }";
3944 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3945 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3946
3947 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3948 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3949 rt.set_invoker(deploy.clone());
3950 let inner = rt.inner.as_ref().unwrap();
3951 let invoker = inner.invoker.get().unwrap().clone();
3952
3953 let make_router = |facts: Vec<ScopeFact>| {
3956 crate::graphql_gateway::BackendRouter::new(
3957 invoker.scoped(ProjectRef::new("default"), facts),
3958 "default".to_string(),
3959 inner.sql.clone(),
3960 std::collections::BTreeMap::new(),
3961 None,
3962 )
3963 };
3964
3965 let router_b = make_router(vec![ScopeFact {
3967 axis: ScopeAxis::Tenant,
3968 value: SqlValue::Text("tenant_B".into()),
3969 }]);
3970 let body_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({}))
3971 .await
3972 .to_string();
3973 assert!(
3974 body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
3975 "gateway propagated principal B → the wasm subgraph read ONLY tenant B's rows: {body_b}"
3976 );
3977
3978 let router_anon = make_router(Vec::new());
3980 let body_anon =
3981 crate::graphql_gateway::execute(&plan, &router_anon, &serde_json::json!({}))
3982 .await
3983 .to_string();
3984 assert!(
3985 !body_anon.contains("\"a1\"")
3986 && !body_anon.contains("\"b1\"")
3987 && !body_anon.contains("\"b2\""),
3988 "with an empty principal the wasm-subgraph own fetch fails closed — no rows: {body_anon}"
3989 );
3990
3991 let _ = std::fs::remove_dir_all(&sql_dir);
3992 println!(
3993 "GATEWAY OWN-PROPAGATION OK: the /graphql gateway propagated the caller's resolved OWN \
3994 principal (tenant B) to a wasm subgraph fetch, which scoped its own read to the \
3995 inherited principal and returned ONLY tenant B's rows over a real libsql engine; with \
3996 an empty principal the same own fetch failed closed (no rows) — anon is never widened"
3997 );
3998 }
3999
4000 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4006 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
4007 async fn gateway_target_or_null_includes_the_shared_base() {
4008 use boatramp_core::deploy::{sha256_hex, DeployStore};
4009 use boatramp_core::function::{
4010 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4011 };
4012 use boatramp_core::project::ProjectRef;
4013 use boatramp_core::sql::{SqlBackends, SqlValue};
4014 use boatramp_core::tenancy::{
4015 AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
4016 TenancySchema, TenantSource,
4017 };
4018 use boatramp_handlers::{HandlerEngine, Limits};
4019
4020 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
4021
4022 let storage = Arc::new(MemStorage::default());
4023 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4024 let deploy = DeployStore::new(storage.clone(), kv.clone());
4025
4026 let sql_dir = std::env::temp_dir().join(format!("br-gwton-{}", std::process::id()));
4027 let _ = std::fs::remove_dir_all(&sql_dir);
4028 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
4029 let db = backends
4030 .database("default", "fn/scopeprobe", "")
4031 .await
4032 .unwrap();
4033 {
4034 let mut tx = db.begin().await.unwrap();
4035 tx.execute(
4036 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
4037 &[],
4038 )
4039 .await
4040 .unwrap();
4041 for (id, tenant, published) in [
4045 ("base_pub", None, 1),
4046 ("base_priv", None, 0),
4047 ("b_pub", Some("tenant_B"), 1),
4048 ("b_priv", Some("tenant_B"), 0),
4049 ("a_pub", Some("tenant_A"), 1),
4050 ] {
4051 tx.execute(
4052 "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
4053 &[
4054 SqlValue::Text(id.into()),
4055 tenant
4056 .map(|t| SqlValue::Text(t.into()))
4057 .unwrap_or(SqlValue::Null),
4058 SqlValue::Integer(published),
4059 ],
4060 )
4061 .await
4062 .unwrap();
4063 }
4064 tx.commit().await.unwrap();
4065 }
4066 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
4067
4068 let hash = sha256_hex(PROBE);
4069 let stream: ByteStream =
4070 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
4071 deploy.put_blob(&hash, stream).await.unwrap();
4072 let function = Function {
4073 name: "scopeprobe".into(),
4074 owner: Owner::Project("default".into()),
4075 versions: vec![FunctionVersion {
4076 id: "v1".into(),
4077 component: hash.clone(),
4078 created: 0,
4079 lifecycle: Lifecycle::Independent,
4080 }],
4081 active: "v1".into(),
4082 aliases: Default::default(),
4083 config: FunctionConfig {
4084 imports: vec!["sql".into()],
4085 tenancy: Some(Tenancy::Scoped {
4086 column: "tenant_id".into(),
4087 sources: vec![TenantSource::None],
4088 read: AccessMode::Own,
4089 write: AccessMode::None,
4090 }),
4091 ..Default::default()
4092 },
4093 };
4094 deploy
4095 .put_function(ProjectRef::DEFAULT, &function)
4096 .await
4097 .unwrap();
4098
4099 let sdl = "type Query { items: [Item!]! @tenant(scope: target_or_null, via: [domain], public: \"items\") }\n\
4101 type Item @key(fields: \"id\") { id: ID! }";
4102 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
4103 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
4104
4105 let mut schema = TenancySchema {
4106 default_tenant_key: "tenant_id".into(),
4107 tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
4108 ..Default::default()
4109 };
4110 schema.public_subsets.insert(
4111 "items".into(),
4112 PublicSubset {
4113 predicate: PublicPredicate {
4114 terms: vec![PublicTerm::Cmp {
4115 column: "published".into(),
4116 op: boatramp_core::tenancy::PublicCmp::Eq,
4117 value: boatramp_core::tenancy::PublicLiteral::Int(1),
4118 }],
4119 },
4120 world_public: true,
4121 listable: true,
4122 },
4123 );
4124
4125 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4126 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
4127 rt.set_invoker(deploy.clone());
4128 let inner = rt.inner.as_ref().unwrap();
4129 let router = crate::graphql_gateway::BackendRouter::new(
4130 inner
4131 .invoker
4132 .get()
4133 .unwrap()
4134 .clone()
4135 .scoped(ProjectRef::new("default"), Vec::new()),
4136 "default".to_string(),
4137 inner.sql.clone(),
4138 std::collections::BTreeMap::new(),
4139 None,
4140 )
4141 .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
4142 schema: Arc::new(schema),
4143 domain_context: Some("tenant_B".into()),
4144 target_handle: None,
4145 capability_anchor: None,
4146 }));
4147
4148 let out = crate::graphql_gateway::execute(&plan, &router, &serde_json::json!({})).await;
4149 let body = out.to_string();
4150 assert!(
4151 body.contains("\"base_pub\"") && body.contains("\"b_pub\""),
4152 "target_or_null returned B's public row AND the shared base floor: {body}"
4153 );
4154 assert!(
4155 !body.contains("\"base_priv\"")
4156 && !body.contains("\"b_priv\"")
4157 && !body.contains("\"a_pub\""),
4158 "the public subset confines BOTH B and base (no base_priv, no b_priv), and no other \
4159 tenant's rows (no a_pub): {body}"
4160 );
4161
4162 let _ = std::fs::remove_dir_all(&sql_dir);
4163 println!(
4164 "GATEWAY TARGET-OR-NULL OK: scope:target_or_null on a wasm subgraph read tenant B's \
4165 public row (b_pub) PLUS the shared NULL-tenant base floor (base_pub), each confined to \
4166 published=1 — never B's private row (b_priv), never a non-public base row (base_priv), \
4167 never another tenant's row (a_pub); the base-only funnel keeps its inheritance floor"
4168 );
4169 }
4170
4171 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4177 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
4178 use crate::scheduler::{run_scheduler_tick, CronNow};
4179 use boatramp_core::deploy::DeployStore;
4180 use boatramp_core::function::{
4181 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4182 };
4183 use boatramp_handlers::{HandlerEngine, Limits};
4184 use futures::StreamExt;
4185
4186 const HTTP_200: &[u8] =
4187 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4188
4189 let storage = Arc::new(MemStorage::default());
4190 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4191 let deploy = DeployStore::new(storage.clone(), kv.clone());
4192
4193 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4194 let stream: ByteStream =
4195 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4196 deploy.put_blob(&hash, stream).await.unwrap();
4197
4198 let acme = ProjectRef::new("acme");
4200 let function = Function {
4201 name: "worker".into(),
4202 owner: Owner::Project("acme".into()),
4203 versions: vec![FunctionVersion {
4204 id: "v1".into(),
4205 component: hash.clone(),
4206 created: 0,
4207 lifecycle: Lifecycle::Independent,
4208 }],
4209 active: "v1".into(),
4210 aliases: Default::default(),
4211 config: Default::default(),
4212 };
4213 deploy.put_function(acme, &function).await.unwrap();
4214 let inv = Invocation {
4215 id: "inv1".into(),
4216 function: "worker".into(),
4217 version: "v1".into(),
4218 mode: InvokeMode::Async,
4219 status: InvocationStatus::Queued,
4220 idempotency_key: None,
4221 attempts: 0,
4222 lease_expires: None,
4223 request_b64: None,
4224 request_content_type: None,
4225 result: None,
4226 created: 0,
4227 updated: 0,
4228 };
4229 deploy.put_invocation(acme, &inv).await.unwrap();
4230
4231 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4232 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4233 let inner = rt.inner.as_ref().unwrap();
4234
4235 let mut wasm_cache = std::collections::HashMap::new();
4238 let mut cron_state = std::collections::HashMap::new();
4239 let mut sweep = std::collections::HashMap::new();
4240 let now = CronNow {
4241 minute: 0,
4242 hour: 0,
4243 dom: 1,
4244 month: 1,
4245 dow: 0,
4246 minute_stamp: 0,
4247 };
4248 run_scheduler_tick(
4249 inner,
4250 &deploy,
4251 &mut wasm_cache,
4252 &mut cron_state,
4253 &mut sweep,
4254 now,
4255 )
4256 .await
4257 .unwrap();
4258
4259 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
4262 assert_eq!(settled.status, InvocationStatus::Succeeded);
4264 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
4266 assert_eq!(metering.invocations, 1);
4267 assert!(deploy
4269 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
4270 .await
4271 .unwrap()
4272 .is_none());
4273 assert!(deploy
4274 .get_metering(ProjectRef::DEFAULT, "worker")
4275 .await
4276 .unwrap()
4277 .is_none());
4278 }
4279
4280 #[cfg(feature = "handlers")]
4284 async fn poll_invocation_settled(
4285 deploy: &boatramp_core::deploy::DeployStore,
4286 project: ProjectRef<'_>,
4287 function: &str,
4288 id: &str,
4289 ) -> boatramp_core::function::Invocation {
4290 use boatramp_core::function::InvocationStatus;
4291 for _ in 0..200 {
4292 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
4293 if matches!(
4294 inv.status,
4295 InvocationStatus::Succeeded | InvocationStatus::Failed
4296 ) {
4297 return inv;
4298 }
4299 }
4300 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4301 }
4302 panic!("invocation {function}/{id} never settled");
4303 }
4304
4305 #[cfg(feature = "handlers")]
4310 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4311 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
4312 use crate::scheduler::{run_scheduler_tick, CronNow};
4313 use boatramp_core::deploy::DeployStore;
4314 use boatramp_core::function::{
4315 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4316 };
4317 use boatramp_handlers::{HandlerEngine, Limits};
4318 use futures::StreamExt;
4319
4320 const HTTP_200: &[u8] =
4321 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4322
4323 let storage = Arc::new(MemStorage::default());
4324 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4325 let deploy = DeployStore::new(storage.clone(), kv.clone());
4326 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4327 let stream: ByteStream =
4328 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4329 deploy.put_blob(&hash, stream).await.unwrap();
4330
4331 let function = Function {
4332 name: "worker".into(),
4333 owner: Owner::Project("default".into()),
4334 versions: vec![FunctionVersion {
4335 id: "v1".into(),
4336 component: hash.clone(),
4337 created: 0,
4338 lifecycle: Lifecycle::Independent,
4339 }],
4340 active: "v1".into(),
4341 aliases: Default::default(),
4342 config: Default::default(),
4343 };
4344 deploy
4345 .put_function(ProjectRef::DEFAULT, &function)
4346 .await
4347 .unwrap();
4348
4349 let base = Invocation {
4352 id: String::new(),
4353 function: "worker".into(),
4354 version: "v1".into(),
4355 mode: InvokeMode::Async,
4356 status: InvocationStatus::Running,
4357 idempotency_key: None,
4358 attempts: 1,
4359 lease_expires: None,
4360 request_b64: None,
4361 request_content_type: None,
4362 result: None,
4363 created: 0,
4364 updated: 0,
4365 };
4366 let orphan = Invocation {
4367 id: "orphan".into(),
4368 lease_expires: Some(1),
4369 ..base.clone()
4370 };
4371 deploy
4372 .put_invocation(ProjectRef::DEFAULT, &orphan)
4373 .await
4374 .unwrap();
4375 let live = Invocation {
4376 id: "live".into(),
4377 lease_expires: Some(u64::MAX),
4378 ..base.clone()
4379 };
4380 deploy
4381 .put_invocation(ProjectRef::DEFAULT, &live)
4382 .await
4383 .unwrap();
4384
4385 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4386 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4387 let inner = rt.inner.as_ref().unwrap();
4388
4389 let now = CronNow {
4390 minute: 0,
4391 hour: 0,
4392 dom: 1,
4393 month: 1,
4394 dow: 0,
4395 minute_stamp: 0,
4396 };
4397 let mut wasm_cache = std::collections::HashMap::new();
4398 let mut cron_state = std::collections::HashMap::new();
4399 let mut sweep = std::collections::HashMap::new();
4400 run_scheduler_tick(
4401 inner,
4402 &deploy,
4403 &mut wasm_cache,
4404 &mut cron_state,
4405 &mut sweep,
4406 now,
4407 )
4408 .await
4409 .unwrap();
4410
4411 let settled =
4413 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
4414 assert_eq!(settled.status, InvocationStatus::Succeeded);
4415 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
4416 assert_eq!(
4417 settled.lease_expires, None,
4418 "a settled invocation drops its lease"
4419 );
4420 let live_after = deploy
4422 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
4423 .await
4424 .unwrap()
4425 .unwrap();
4426 assert_eq!(live_after.status, InvocationStatus::Running);
4427 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
4428 assert_eq!(live_after.lease_expires, Some(u64::MAX));
4429 }
4430
4431 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4446 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
4447 use boatramp_core::deploy::DeployStore;
4448 use boatramp_core::function::{
4449 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4450 };
4451 use boatramp_handlers::{HandlerEngine, Limits};
4452 use futures::StreamExt;
4453
4454 let storage = Arc::new(MemStorage::default());
4455 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4456 let deploy = DeployStore::new(storage.clone(), kv.clone());
4457
4458 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4461 let stream: ByteStream =
4462 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4463 deploy.put_blob(&hash, stream).await.unwrap();
4464
4465 let store = Function {
4470 name: "store".into(),
4471 owner: Owner::Project("default".into()),
4472 versions: vec![FunctionVersion {
4473 id: "v1".into(),
4474 component: hash.clone(),
4475 created: 0,
4476 lifecycle: Lifecycle::Independent,
4477 }],
4478 active: "v1".into(),
4479 aliases: Default::default(),
4480 config: FunctionConfig {
4481 imports: vec!["wasi:keyvalue".into()],
4482 ..Default::default()
4483 },
4484 };
4485 let acme = ProjectRef::new("acme");
4486 let globex = ProjectRef::new("globex");
4487
4488 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4489 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4490 let inner = rt.inner.as_ref().unwrap();
4491
4492 let request = || {
4493 axum::http::Request::builder()
4494 .method("GET")
4495 .uri("/")
4496 .body(axum::body::Body::empty())
4497 .unwrap()
4498 };
4499
4500 let component = store.resolve(&store.active).unwrap().to_owned();
4503 for project in [acme, globex, ProjectRef::DEFAULT] {
4504 let (response, _) = execute_function(
4505 inner,
4506 &deploy,
4507 project,
4508 &store,
4509 &component,
4510 request(),
4511 0,
4512 boatramp_handlers::Lane::Sync,
4513 crate::function_runtime::FnTenant::Request,
4514 )
4515 .await;
4516 assert!(response.status().is_success(), "invocation should succeed");
4517 }
4518
4519 assert_eq!(
4522 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
4523 Some(b"1".to_vec()),
4524 "acme's write must be tenant-qualified"
4525 );
4526 assert_eq!(
4527 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
4528 Some(b"1".to_vec()),
4529 "globex's write must be tenant-qualified"
4530 );
4531 assert_eq!(
4532 kv.get("hkv/fn/store/hits").await.unwrap(),
4533 Some(b"1".to_vec()),
4534 "the default project must keep the byte-identical pre-project key"
4535 );
4536 }
4539
4540 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4544 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
4545 use boatramp_core::config::{
4546 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
4547 };
4548 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
4549 use boatramp_handlers::{HandlerEngine, Limits};
4550 use futures::StreamExt;
4551 use std::sync::atomic::Ordering;
4552
4553 let storage = Arc::new(MemStorage::default());
4554 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4555 let deploy = DeployStore::new(storage.clone(), kv.clone());
4556
4557 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4558 let stream: ByteStream =
4559 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4560 deploy.put_blob(&hash, stream).await.unwrap();
4561 let mut files = std::collections::BTreeMap::new();
4562 files.insert(
4563 "counter.wasm".to_string(),
4564 FileEntry {
4565 hash: hash.clone(),
4566 size: KV_COUNTER.len() as u64,
4567 content_type: None,
4568 variants: std::collections::BTreeMap::new(),
4569 },
4570 );
4571 let manifest = Manifest {
4572 files,
4573 config: DeployConfig {
4574 handlers: vec![HandlerConfig {
4575 tenancy: None,
4576 token_claims: None,
4577 route: "/".into(),
4578 methods: Vec::new(),
4579 component: "counter.wasm".into(),
4580 imports: vec!["wasi:keyvalue".into()],
4581 streaming: false,
4582 limits: None,
4583 env: std::collections::BTreeMap::new(),
4584 invoke_targets: Vec::new(),
4585 }],
4586 crons: vec![CronConfig {
4587 schedule: "* * * * *".into(),
4588 route: "/".into(),
4589 overlap: Overlap::Skip,
4590 }],
4591 ..Default::default()
4592 },
4593 ..Default::default()
4594 };
4595 let id = deploy.put_manifest(&manifest).await.unwrap();
4596 deploy
4597 .activate(ProjectRef::DEFAULT, "blog", &id)
4598 .await
4599 .unwrap();
4600 deploy
4601 .set_site_config(
4602 ProjectRef::DEFAULT,
4603 "blog",
4604 &SiteConfig {
4605 handlers: Some(HandlersSiteConfig {
4606 enabled: true,
4607 allow_imports: vec!["wasi:keyvalue".into()],
4608 ..Default::default()
4609 }),
4610 ..Default::default()
4611 },
4612 )
4613 .await
4614 .unwrap();
4615
4616 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4617 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4618 let inner = rt.inner.clone().unwrap();
4619 let mut wasm = std::collections::HashMap::new();
4620 let mut crons = std::collections::HashMap::new();
4621 let mut sweep = std::collections::HashMap::new();
4622 let at = |stamp| CronNow {
4623 minute: 0,
4624 hour: 0,
4625 dom: 1,
4626 month: 1,
4627 dow: 0,
4628 minute_stamp: stamp,
4629 };
4630
4631 let (_, handles) =
4633 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
4634 .await
4635 .unwrap();
4636 for h in handles {
4637 h.await.unwrap();
4638 }
4639 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
4640
4641 let (_, handles) =
4643 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
4644 .await
4645 .unwrap();
4646 assert!(handles.is_empty());
4647 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
4648
4649 let (_, handles) =
4651 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
4652 .await
4653 .unwrap();
4654 for h in handles {
4655 h.await.unwrap();
4656 }
4657 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
4658
4659 crons
4663 .get("default|blog|cron|0")
4664 .unwrap()
4665 .running
4666 .store(true, Ordering::Release);
4667 let (_, handles) =
4668 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
4669 .await
4670 .unwrap();
4671 assert!(handles.is_empty());
4672 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
4673 }
4674
4675 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4679 async fn cron_leader_gate_suppresses_crons_off_leader() {
4680 use boatramp_core::config::{
4681 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
4682 };
4683 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
4684 use boatramp_handlers::{HandlerEngine, Limits};
4685 use futures::StreamExt;
4686
4687 let storage = Arc::new(MemStorage::default());
4688 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4689 let deploy = DeployStore::new(storage.clone(), kv.clone());
4690
4691 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4692 let stream: ByteStream =
4693 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4694 deploy.put_blob(&hash, stream).await.unwrap();
4695 let mut files = std::collections::BTreeMap::new();
4696 files.insert(
4697 "counter.wasm".to_string(),
4698 FileEntry {
4699 hash: hash.clone(),
4700 size: KV_COUNTER.len() as u64,
4701 content_type: None,
4702 variants: std::collections::BTreeMap::new(),
4703 },
4704 );
4705 let manifest = Manifest {
4706 files,
4707 config: DeployConfig {
4708 handlers: vec![HandlerConfig {
4709 tenancy: None,
4710 token_claims: None,
4711 route: "/".into(),
4712 methods: Vec::new(),
4713 component: "counter.wasm".into(),
4714 imports: vec!["wasi:keyvalue".into()],
4715 streaming: false,
4716 limits: None,
4717 env: std::collections::BTreeMap::new(),
4718 invoke_targets: Vec::new(),
4719 }],
4720 crons: vec![CronConfig {
4721 schedule: "* * * * *".into(),
4722 route: "/".into(),
4723 overlap: Overlap::Skip,
4724 }],
4725 ..Default::default()
4726 },
4727 ..Default::default()
4728 };
4729 let id = deploy.put_manifest(&manifest).await.unwrap();
4730 deploy
4731 .activate(ProjectRef::DEFAULT, "blog", &id)
4732 .await
4733 .unwrap();
4734 deploy
4735 .set_site_config(
4736 ProjectRef::DEFAULT,
4737 "blog",
4738 &SiteConfig {
4739 handlers: Some(HandlersSiteConfig {
4740 enabled: true,
4741 allow_imports: vec!["wasi:keyvalue".into()],
4742 ..Default::default()
4743 }),
4744 ..Default::default()
4745 },
4746 )
4747 .await
4748 .unwrap();
4749
4750 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4751 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4752 rt.set_cron_leader_gate(Arc::new(|| false));
4754 let inner = rt.inner.clone().unwrap();
4755 let mut wasm = std::collections::HashMap::new();
4756 let mut crons = std::collections::HashMap::new();
4757 let mut sweep = std::collections::HashMap::new();
4758 let now = CronNow {
4759 minute: 0,
4760 hour: 0,
4761 dom: 1,
4762 month: 1,
4763 dow: 0,
4764 minute_stamp: 100,
4765 };
4766
4767 let (_, handles) =
4768 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
4769 .await
4770 .unwrap();
4771 assert!(handles.is_empty(), "a non-leader must not fire crons");
4773 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
4774 }
4775
4776 #[tokio::test]
4780 async fn project_tenancy_knobs_override_wins_else_node_base() {
4781 use boatramp_core::security::ResolvedProjectTenancy;
4782 use boatramp_handlers::{HandlerEngine, Limits};
4783
4784 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
4785 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
4786 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4787 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
4788 rt.set_tenancy_posture(true, false);
4790 let mut overrides = std::collections::BTreeMap::new();
4792 overrides.insert(
4793 "preview".to_string(),
4794 ResolvedProjectTenancy {
4795 require_tenancy_declaration: true,
4796 allow_cross_tenant_db: true,
4797 capability_max_ttl_secs: Some(1800),
4798 },
4799 );
4800 rt.set_project_tenancy_overrides(overrides);
4801 let inner = rt.inner.as_ref().unwrap();
4802
4803 let p = inner.project_tenancy_knobs("preview");
4805 assert!(p.require_tenancy_declaration);
4806 assert!(p.allow_cross_tenant_db);
4807 assert_eq!(p.capability_max_ttl_secs, Some(1800));
4808 let b = inner.project_tenancy_knobs("prod");
4810 assert!(b.require_tenancy_declaration);
4811 assert!(!b.allow_cross_tenant_db);
4812 assert_eq!(b.capability_max_ttl_secs, None);
4813 }
4814
4815 #[tokio::test]
4822 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
4823 use boatramp_core::config::HandlersSiteConfig;
4824 use boatramp_core::project::ProjectRef;
4825 use boatramp_handlers::{HandlerEngine, Limits};
4826
4827 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
4828 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
4829 let sql_dir =
4831 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
4832 let _ = std::fs::remove_dir_all(&sql_dir);
4833 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
4834 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
4835
4836 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4837 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
4838 let inner = rt.inner.as_ref().unwrap();
4839
4840 let site = HandlersSiteConfig {
4844 enabled: true,
4845 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
4846 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
4847 ..Default::default()
4848 };
4849 let env = std::collections::BTreeMap::new();
4850 let build = |imports: &[&str]| {
4851 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
4852 let site = &site;
4853 let env = &env;
4854 async move {
4855 crate::handler_dispatch::build_bindings(
4856 inner,
4857 ProjectRef::new("default"),
4858 "shop",
4859 "shop",
4860 None,
4861 &imports,
4862 site,
4863 env,
4864 &[],
4865 0,
4866 None,
4867 None,
4868 None,
4869 None,
4870 None,
4871 None,
4872 None,
4873 None,
4874 )
4875 .await
4876 .expect("no secrets → resolves")
4877 .sql_database_names()
4878 }
4879 };
4880
4881 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
4884 assert_eq!(
4886 build(&["sql", "sql:*"]).await,
4887 vec!["", "privileged", "product"]
4888 );
4889 assert!(build(&["sql:secret"]).await.is_empty());
4891 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
4893
4894 let _ = std::fs::remove_dir_all(&sql_dir);
4895 }
4896
4897 #[tokio::test]
4908 async fn managed_dependency_not_ready_gates_with_a_retryable_503() {
4909 use crate::handler_dispatch::{build_bindings, BindingsError};
4910 use boatramp_core::config::HandlersSiteConfig;
4911 use boatramp_core::project::ProjectRef;
4912 use boatramp_core::sql::{SqlBackend, SqlBackends, SqlError};
4913 use boatramp_handlers::{HandlerEngine, Limits};
4914 use std::sync::atomic::{AtomicUsize, Ordering};
4915
4916 enum Outcome {
4918 Unavailable, Other, Ready, UnavailableThenOk, }
4923 struct FakeSql {
4924 outcome: Outcome,
4925 calls: AtomicUsize,
4926 real: Arc<dyn SqlBackend>,
4927 }
4928 #[async_trait::async_trait]
4929 impl SqlBackends for FakeSql {
4930 async fn database(
4931 &self,
4932 _project: &str,
4933 _site: &str,
4934 _name: &str,
4935 ) -> Result<Arc<dyn SqlBackend>, SqlError> {
4936 let n = self.calls.fetch_add(1, Ordering::SeqCst);
4937 match self.outcome {
4938 Outcome::Unavailable => {
4939 Err(SqlError::unavailable("workload has no replica yet"))
4940 }
4941 Outcome::Other => Err(SqlError::other("external db connection refused")),
4942 Outcome::Ready => Ok(Arc::clone(&self.real)),
4943 Outcome::UnavailableThenOk if n == 0 => {
4944 Err(SqlError::unavailable("still initializing"))
4945 }
4946 Outcome::UnavailableThenOk => Ok(Arc::clone(&self.real)),
4947 }
4948 }
4949 }
4950
4951 let sql_dir =
4953 std::env::temp_dir().join(format!("boatramp-readiness-gate-{}", std::process::id()));
4954 let _ = std::fs::remove_dir_all(&sql_dir);
4955 let real = boatramp_storage::LibsqlSqlBackends::local(&sql_dir)
4956 .database("default", "shop", "")
4957 .await
4958 .unwrap();
4959
4960 async fn build_with(
4963 outcome: Outcome,
4964 real: &Arc<dyn SqlBackend>,
4965 ) -> (Result<boatramp_handlers::Bindings, BindingsError>, usize) {
4966 let kv: Arc<dyn boatramp_core::kv::KvStore> =
4967 Arc::new(boatramp_core::kv::MemoryKv::new());
4968 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
4969 let fake = Arc::new(FakeSql {
4970 outcome,
4971 calls: AtomicUsize::new(0),
4972 real: Arc::clone(real),
4973 });
4974 let sql: Arc<dyn SqlBackends> = fake.clone();
4975 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4976 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
4977 let inner = rt.inner.as_ref().unwrap();
4978 let site = HandlersSiteConfig {
4979 enabled: true,
4980 allow_imports: vec!["sql".into()],
4981 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
4982 ..Default::default()
4983 };
4984 let imports = vec!["sql".to_string()];
4985 let env = std::collections::BTreeMap::new();
4986 let r = build_bindings(
4987 inner,
4988 ProjectRef::new("default"),
4989 "shop",
4990 "shop",
4991 None,
4992 &imports,
4993 &site,
4994 &env,
4995 &[],
4996 0,
4997 None,
4998 None,
4999 None,
5000 None,
5001 None,
5002 None,
5003 None,
5004 None,
5005 )
5006 .await;
5007 (r, fake.calls.load(Ordering::SeqCst))
5008 }
5009
5010 let (r, _) = build_with(Outcome::Unavailable, &real).await;
5012 match r {
5013 Err(BindingsError::NotReady {
5014 retry_after_secs, ..
5015 }) => {
5016 assert!(retry_after_secs >= 1, "advertises a Retry-After");
5017 }
5018 Err(BindingsError::Refused(m)) => {
5019 panic!("a not-ready managed DB must gate with NotReady, got Refused({m})")
5020 }
5021 Ok(_) => panic!("a not-ready managed DB must gate, but bindings were built"),
5022 }
5023
5024 let (r, _) = build_with(Outcome::Other, &real).await;
5026 let bindings = r.expect("a non-managed DB outage must NOT gate the whole request");
5027 assert!(
5028 !bindings.sql_database_names().contains(&String::new()),
5029 "the broken default DB is left ungranted (skipped), not gated"
5030 );
5031
5032 let (r, _) = build_with(Outcome::Ready, &real).await;
5034 let bindings = r.expect("a ready DB builds");
5035 assert!(bindings.sql_database_names().contains(&String::new()));
5036
5037 let (r, calls) = build_with(Outcome::UnavailableThenOk, &real).await;
5040 let bindings = r.expect("the readiness retry catches a DB a moment from ready");
5041 assert!(bindings.sql_database_names().contains(&String::new()));
5042 assert_eq!(calls, 2, "one bounded retry on Unavailable");
5043
5044 let resp = crate::sql_starting_response(2);
5046 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
5047 assert_eq!(
5048 resp.headers()
5049 .get(axum::http::header::RETRY_AFTER)
5050 .and_then(|v| v.to_str().ok()),
5051 Some("2"),
5052 "the readiness 503 advertises Retry-After"
5053 );
5054
5055 let _ = std::fs::remove_dir_all(&sql_dir);
5056 println!(
5057 "MANAGED-DEP READINESS GATE OK: a required managed database that is still starting gates \
5058 the invocation with a retryable 503 + Retry-After (fail-closed, guest never runs); a \
5059 brief startup blip is caught by the bounded readiness retry; an external/local DB \
5060 outage is skipped (per-DB resilience), never gating the whole request."
5061 );
5062 }
5063
5064 #[tokio::test]
5079 async fn consumer_signed_context_dispatch_resolves_per_message_tenant_on_a_real_engine() {
5080 use crate::handler_dispatch::ConsumerRebuild;
5081 use boatramp_core::config::HandlersSiteConfig;
5082 use boatramp_core::cose::{mint_context, Signer};
5083 use boatramp_core::orm::{Expr, Select, SelectItem};
5084 use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
5085 use boatramp_core::tenancy::{AccessMode, Tenancy, TenantSource};
5086 use boatramp_handlers::{HandlerEngine, Limits, TenantAxis, TenantDenied};
5087
5088 let sql_dir =
5090 std::env::temp_dir().join(format!("boatramp-consumer-sctx-{}", std::process::id()));
5091 let _ = std::fs::remove_dir_all(&sql_dir);
5092 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
5093 let db = backends.database("default", "worker", "").await.unwrap();
5094 {
5095 let mut tx = db.begin().await.unwrap();
5096 tx.execute(
5097 "CREATE TABLE notes (id TEXT PRIMARY KEY, tenant_id TEXT, body TEXT)",
5098 &[],
5099 )
5100 .await
5101 .unwrap();
5102 for (id, tenant, body) in [("n1", "acme", "acme-note"), ("n2", "globex", "globex-note")]
5103 {
5104 tx.execute(
5105 "INSERT INTO notes (id, tenant_id, body) VALUES (?1, ?2, ?3)",
5106 &[
5107 SqlValue::Text(id.into()),
5108 SqlValue::Text(tenant.into()),
5109 SqlValue::Text(body.into()),
5110 ],
5111 )
5112 .await
5113 .unwrap();
5114 }
5115 tx.commit().await.unwrap();
5116 }
5117
5118 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
5119 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
5120 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
5121 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
5122 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5123 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
5124 rt.set_tenancy_posture(true, false);
5127 let signer: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
5129 rt.set_session_signer(signer.clone());
5130 let stranger: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
5131 let inner = rt.inner.as_ref().unwrap();
5132
5133 let tenancy = Tenancy::Scoped {
5135 column: "tenant_id".into(),
5136 sources: vec![TenantSource::SignedContext],
5137 read: AccessMode::Own,
5138 write: AccessMode::Own,
5139 };
5140 let site = HandlersSiteConfig {
5141 enabled: true,
5142 allow_imports: vec!["sql".into()],
5143 ..Default::default()
5145 };
5146 let imports = vec!["sql".to_string()];
5147 let rebuild = ConsumerRebuild {
5148 inner,
5149 project: ProjectRef::new("default"),
5150 site: "worker",
5151 scope: "worker",
5152 imports: &imports,
5153 site_handlers: &site,
5154 tenancy: Some(&tenancy),
5155 token_claims: None,
5156 };
5157
5158 async fn read_bodies(
5160 db: &dyn boatramp_core::sql::SqlBackend,
5161 scope: &boatramp_core::orm::Scope,
5162 ) -> Vec<String> {
5163 let mut q = Select {
5164 columns: vec![SelectItem {
5165 expr: Expr::col("body"),
5166 alias: None,
5167 }],
5168 ..Select::from("notes")
5169 };
5170 q.force_scope(scope).unwrap();
5171 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
5172 let mut tx = db.begin().await.unwrap();
5173 let rows = tx.query(&sql, ¶ms).await.expect("query runs");
5174 tx.commit().await.unwrap();
5175 let mut out: Vec<String> = rows
5176 .rows
5177 .into_iter()
5178 .flatten()
5179 .filter_map(|v| match v {
5180 SqlValue::Text(s) => Some(s),
5181 _ => None,
5182 })
5183 .collect();
5184 out.sort();
5185 out
5186 }
5187
5188 let now = boatramp_core::time::now_unix();
5189
5190 let env = mint_context("acme", 3600, now, signer.as_ref())
5192 .await
5193 .unwrap();
5194 let sealed = rebuild.bindings_for(Some(&env)).await.unwrap();
5195 let ht = sealed
5196 .resolved_tenancy()
5197 .expect("a signed-context consumer resolves a tenancy");
5198 assert_eq!(
5199 ht.value(),
5200 Some(&SqlValue::Text("acme".into())),
5201 "the sealed envelope's tenant is the consumer's own principal"
5202 );
5203 assert!(
5205 ht.facts()
5206 .iter()
5207 .any(|f| f.value == SqlValue::Text("acme".into())),
5208 "the resolved fact carries acme (this is the graphql::run caller principal too)"
5209 );
5210 let read_scope = ht.orm_scope(TenantAxis::Read).unwrap().unwrap();
5211 assert_eq!(
5212 read_bodies(db.as_ref(), &read_scope).await,
5213 vec!["acme-note".to_string()],
5214 "the resolved tenant scopes a real engine to acme's row ONLY (never globex)"
5215 );
5216 assert!(
5218 ht.orm_scope(TenantAxis::Write).unwrap().is_some(),
5219 "the write axis resolves the sealed tenant"
5220 );
5221
5222 let plain = rebuild.bindings_for(None).await.unwrap();
5224 let ht = plain
5225 .resolved_tenancy()
5226 .expect("the tenancy decision is present (but factless)");
5227 assert!(
5228 ht.value().is_none(),
5229 "an unsealed message resolves no own tenant"
5230 );
5231 assert!(
5232 matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5233 "a scoped op with no resolved tenant fails closed (never runs unscoped)"
5234 );
5235
5236 let forged = mint_context("globex", 3600, now, stranger.as_ref())
5238 .await
5239 .unwrap();
5240 let forged_b = rebuild.bindings_for(Some(&forged)).await.unwrap();
5241 let ht = forged_b
5242 .resolved_tenancy()
5243 .expect("the tenancy decision is present (but factless)");
5244 assert!(
5245 ht.value().is_none(),
5246 "a stranger-signed envelope resolves no own tenant (never masquerades as globex)"
5247 );
5248 assert!(
5249 matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5250 "a forged envelope fails the scoped op closed"
5251 );
5252
5253 let expired = mint_context("acme", 3600, now.saturating_sub(7200), signer.as_ref())
5256 .await
5257 .unwrap();
5258 let expired_b = rebuild.bindings_for(Some(&expired)).await.unwrap();
5259 let ht = expired_b
5260 .resolved_tenancy()
5261 .expect("the tenancy decision is present (but factless)");
5262 assert!(
5263 ht.value().is_none()
5264 && matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5265 "an expired envelope resolves no own tenant and fails the scoped op closed"
5266 );
5267
5268 let env_b = mint_context("globex", 3600, now, signer.as_ref())
5273 .await
5274 .unwrap();
5275 let a = rebuild.bindings_for(Some(&env)).await.unwrap();
5276 let b = rebuild.bindings_for(Some(&env_b)).await.unwrap();
5277 let ht_a = a.resolved_tenancy().unwrap();
5278 let ht_b = b.resolved_tenancy().unwrap();
5279 assert_eq!(ht_a.value(), Some(&SqlValue::Text("acme".into())));
5280 assert_eq!(ht_b.value(), Some(&SqlValue::Text("globex".into())));
5281 assert_eq!(
5282 read_bodies(
5283 db.as_ref(),
5284 &ht_a.orm_scope(TenantAxis::Read).unwrap().unwrap()
5285 )
5286 .await,
5287 vec!["acme-note".to_string()],
5288 "message A stays scoped to acme"
5289 );
5290 assert_eq!(
5291 read_bodies(
5292 db.as_ref(),
5293 &ht_b.orm_scope(TenantAxis::Read).unwrap().unwrap()
5294 )
5295 .await,
5296 vec!["globex-note".to_string()],
5297 "message B (interleaved) scopes to globex ONLY — no bleed from A's binding"
5298 );
5299
5300 println!(
5301 "CONSUMER SIGNED-CONTEXT DISPATCH OK: the site-consumers async lane resolves each \
5302 claimed message's host-sealed signed_context per message; a valid fleet-signed \
5303 envelope scopes a real libsql engine to the originator's tenant ONLY, while an \
5304 unsealed or forged message resolves no principal and fails an own op closed (never \
5305 cross-tenant, never unscoped). The resolved principal is the same value that \
5306 propagates onto a graphql::run sub-fetch."
5307 );
5308
5309 let _ = std::fs::remove_dir_all(&sql_dir);
5310 }
5311}