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, post_graphql_compose, put_graphql_function_subgraph,
56 put_graphql_sql_subgraph, 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, get_deploy_status, list_functions, remove_function,
133 rollback_function,
134};
135#[cfg(feature = "handlers")]
140pub use function_api::{
141 component_requires, host_capability_features, host_capability_features_detailed, unmet_requires,
142};
143#[cfg(all(test, feature = "handlers"))]
144use function_api::{AliasBody, DeployFunctionQuery, FunctionUpsert, RollbackBody};
145pub use function_api::{CapabilityFeature, Lifecycle};
148mod gateway;
149mod host;
150pub(crate) use host::{is_local_host, parse_deploy_host, strip_port};
151#[cfg(feature = "http3")]
152mod http3;
153mod limits;
154#[cfg(feature = "handlers")]
155mod logs;
156#[cfg(feature = "handlers")]
157mod metrics;
158#[cfg(feature = "oidc")]
159mod oidc;
160mod operator;
161pub(crate) use operator::prometheus_metrics;
162#[cfg(feature = "handlers")]
163pub(crate) use operator::{
164 operator_dlq, operator_function_logs, operator_function_logs_stream, operator_handler_stats,
165 operator_logs, operator_logs_stream,
166};
167mod proxy;
168pub use proxy::spawn_compute_reconcile;
169pub(crate) use proxy::{
170 await_warm, compute_endpoint_regions, compute_endpoints, dispatch_gateway, has_parked_replica,
171 proxy, COMPUTE_WAKE_TIMEOUT,
172};
173mod splice;
174mod http_serve;
177pub use http_serve::{
178 alpn_h1_h2, serve_plaintext, serve_plaintext_listener, serve_router_conn, serve_tls,
179 serve_tls_listener, ReloadableTls, ServeInput,
180};
181#[cfg(feature = "handlers")]
183pub(crate) use proxy::is_upgrade_request;
184#[cfg(all(test, feature = "handlers"))]
185use proxy::{gateway_addr_allowed, CLOUD_METADATA_IPV4};
186mod project_api;
187pub(crate) use project_api::{create_project, delete_project, get_project, list_projects};
188mod project_scope;
189pub(crate) use project_scope::{project_scope, OriginalPath, ProjectContext};
190mod ratelimit;
191mod routes;
192pub use routes::{router, router_with, router_with_fast};
193#[cfg(feature = "mcp")]
194mod mcp_http;
195#[cfg(feature = "handlers")]
196mod scheduler;
197mod serve_pipeline;
198#[cfg(feature = "handlers")]
199mod tenant_resolve;
200pub use serve_pipeline::{http_redirect_router, FastServe};
201#[cfg(test)]
202mod hotpath_test;
203#[cfg(all(test, feature = "handlers"))]
204use serve_pipeline::{apply_vary, parse_cookie_header, parse_query_string};
205pub(crate) use serve_pipeline::{
206 serve_bootstrap_identity, serve_by_host, serve_domain_challenge, serve_preview, serve_sites,
207 BootstrapAttestation,
208};
209pub mod signer;
212mod srvmetrics;
213#[cfg(all(feature = "handlers", test))]
214use scheduler::run_scheduler_tick;
215#[cfg(feature = "handlers")]
216pub(crate) use scheduler::{
217 acquire_site_permit, effective_limits, handler_error_response, handler_unavailable,
218 sql_starting_response, CronNow,
219};
220#[cfg(feature = "handlers")]
221use scheduler::{CONSUMER_BATCH, CONSUMER_LEASE, CONSUMER_MAX_ATTEMPTS};
222#[cfg(feature = "handlers")]
223mod function_runtime;
224#[cfg(feature = "handlers")]
225pub(crate) use function_runtime::{
226 b64_decode, b64_encode, blob_storage_prefix, capture_response, delete_trigger_handler,
227 dispatch_function_triggers, drain_function_invocations, execute_function, get_function_usage,
228 get_invocation_record, invoke_function, list_triggers_handler, new_invocation_id,
229 put_trigger_handler, webhook_ingress,
230};
231#[cfg(feature = "session")]
235mod session_serve;
236#[cfg(feature = "handlers")]
237mod stream;
238#[cfg(feature = "handlers")]
239mod workflow;
240pub use auth::{require_auth, Auth};
241#[cfg(feature = "http3")]
242pub use http3::{
243 advertise_http3, http3_endpoint, quinn_server_config, serve_http3, serve_http3_endpoint,
244 Http3Error,
245};
246pub use limits::{ServerLimits, UploadGuard};
247#[cfg(feature = "oidc")]
248pub use oidc::{OidcConfig, OidcError, OidcVerifier};
249use ratelimit::{KvRateLimiter, RateLimitStore, RateLimiter};
250#[cfg(feature = "handlers")]
251pub(crate) use stream::{route_matches, serve_stream, serve_ws_stream};
252#[cfg(feature = "handlers")]
253pub(crate) use workflow::{
254 define_workflow, delete_workflow_handler, drain_workflow_runs, get_workflow_handler,
255 get_workflow_run_handler, list_workflows_handler, start_workflow_run,
256};
257pub use srvmetrics::{server_metrics, ServerMetrics};
260
261#[derive(Clone, Default)]
266pub struct HandlerRuntime {
267 #[cfg(feature = "handlers")]
268 inner: Option<Arc<HandlerRuntimeInner>>,
269}
270
271#[cfg(feature = "handlers")]
272struct HandlerRuntimeInner {
273 engine: boatramp_handlers::HandlerEngine,
274 async_drain_gate: Arc<tokio::sync::Semaphore>,
280 kv: Arc<dyn boatramp_core::kv::KvStore>,
281 storage: Arc<dyn boatramp_core::Storage>,
282 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
286 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
289 site_semaphores:
292 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
293 stream_semaphores:
297 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
298 stream_ip_counts: Arc<std::sync::Mutex<std::collections::HashMap<(String, IpAddr), u32>>>,
301 metrics: metrics::Metrics,
304 logs: Arc<logs::LogStore>,
306 #[cfg(feature = "handlers")]
310 graphql_cache: graphql_cache::GraphqlCache,
311 cron_leader_gate: std::sync::OnceLock<CronLeaderGate>,
317 max_blob_bytes: std::sync::OnceLock<u64>,
321 max_component_bytes: std::sync::OnceLock<u64>,
326 allow_env_secret_refs: std::sync::OnceLock<bool>,
335 require_tenancy_declaration: std::sync::OnceLock<bool>,
341 allow_cross_tenant_db: std::sync::OnceLock<bool>,
346 secret_store: std::sync::OnceLock<Arc<boatramp_core::secret_store::SecretStore>>,
352 function_meter_locks:
356 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
357 function_semaphores:
360 std::sync::Mutex<std::collections::HashMap<String, Arc<tokio::sync::Semaphore>>>,
361 watch_provider: std::sync::OnceLock<Arc<dyn boatramp_core::blob_provision::WatchProvider>>,
366 provision_tier: std::sync::OnceLock<boatramp_core::blob_notify::ProvisionTier>,
370 invoker: std::sync::OnceLock<Arc<function_runtime::FunctionInvoker>>,
378 federation_runner: std::sync::OnceLock<Arc<graphql_gateway::FederationRunner>>,
383 #[cfg(feature = "email")]
388 email_profile_store: std::sync::OnceLock<Arc<boatramp_core::email_config::EmailProfileStore>>,
389 #[cfg(feature = "email")]
394 email_spool: std::sync::OnceLock<Arc<dyn boatramp_handlers::EmailSpool>>,
395 #[cfg(feature = "admin")]
399 admin_controller: std::sync::OnceLock<Arc<admin_controller::ServerAdminController>>,
400 #[cfg(feature = "admin")]
404 admin_surfaces:
405 std::sync::OnceLock<std::collections::BTreeSet<boatramp_handlers::AdminSurface>>,
406 #[cfg(feature = "session")]
413 session_store: std::sync::OnceLock<session_store::SessionStore>,
414 session_signer: std::sync::OnceLock<Arc<dyn Signer>>,
419 #[cfg(feature = "capability")]
425 capability_max_ttl_secs: std::sync::OnceLock<u64>,
426 #[cfg(feature = "handlers")]
431 tenancy_posture_overrides: std::sync::OnceLock<
432 Arc<std::collections::BTreeMap<String, boatramp_core::security::ResolvedProjectTenancy>>,
433 >,
434}
435
436#[cfg(feature = "handlers")]
437impl HandlerRuntimeInner {
438 pub(crate) fn project_tenancy_knobs(
444 &self,
445 project: &str,
446 ) -> boatramp_core::security::ResolvedProjectTenancy {
447 if let Some(map) = self.tenancy_posture_overrides.get() {
448 if let Some(knobs) = map.get(project) {
449 return *knobs;
450 }
451 }
452 boatramp_core::security::ResolvedProjectTenancy {
453 require_tenancy_declaration: self
454 .require_tenancy_declaration
455 .get()
456 .copied()
457 .unwrap_or(true),
458 allow_cross_tenant_db: self.allow_cross_tenant_db.get().copied().unwrap_or(false),
459 #[cfg(feature = "capability")]
460 capability_max_ttl_secs: self.capability_max_ttl_secs.get().copied(),
461 #[cfg(not(feature = "capability"))]
462 capability_max_ttl_secs: None,
463 }
464 }
465}
466
467pub type CronLeaderGate = Arc<dyn Fn() -> bool + Send + Sync>;
470
471impl HandlerRuntime {
472 pub fn disabled() -> Self {
474 Self::default()
475 }
476
477 #[cfg(feature = "handlers")]
483 pub fn new(
484 engine: boatramp_handlers::HandlerEngine,
485 kv: Arc<dyn boatramp_core::kv::KvStore>,
486 storage: Arc<dyn boatramp_core::Storage>,
487 sql: Option<Arc<dyn boatramp_core::sql::SqlBackends>>,
488 messaging: Option<Arc<dyn boatramp_core::messaging::Messaging>>,
489 ) -> Self {
490 let async_drain_slots = engine.async_max_concurrency().max(1);
493 Self {
494 inner: Some(Arc::new(HandlerRuntimeInner {
495 engine,
496 async_drain_gate: Arc::new(tokio::sync::Semaphore::new(async_drain_slots)),
497 kv,
498 storage,
499 sql,
500 messaging,
501 site_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
502 stream_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
503 stream_ip_counts: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
504 metrics: metrics::Metrics::default(),
505 logs: Arc::new(logs::LogStore::default()),
506 #[cfg(feature = "handlers")]
507 graphql_cache: graphql_cache::GraphqlCache::default(),
508 cron_leader_gate: std::sync::OnceLock::new(),
509 max_blob_bytes: std::sync::OnceLock::new(),
510 max_component_bytes: std::sync::OnceLock::new(),
511 allow_env_secret_refs: std::sync::OnceLock::new(),
512 require_tenancy_declaration: std::sync::OnceLock::new(),
513 allow_cross_tenant_db: std::sync::OnceLock::new(),
514 secret_store: std::sync::OnceLock::new(),
515 function_meter_locks: std::sync::Mutex::new(std::collections::HashMap::new()),
516 function_semaphores: std::sync::Mutex::new(std::collections::HashMap::new()),
517 watch_provider: std::sync::OnceLock::new(),
518 provision_tier: std::sync::OnceLock::new(),
519 invoker: std::sync::OnceLock::new(),
520 federation_runner: std::sync::OnceLock::new(),
521 #[cfg(feature = "email")]
522 email_profile_store: std::sync::OnceLock::new(),
523 #[cfg(feature = "email")]
524 email_spool: std::sync::OnceLock::new(),
525 #[cfg(feature = "admin")]
526 admin_controller: std::sync::OnceLock::new(),
527 #[cfg(feature = "admin")]
528 admin_surfaces: std::sync::OnceLock::new(),
529 #[cfg(feature = "session")]
530 session_store: std::sync::OnceLock::new(),
531 session_signer: std::sync::OnceLock::new(),
532 #[cfg(feature = "capability")]
533 capability_max_ttl_secs: std::sync::OnceLock::new(),
534 #[cfg(feature = "handlers")]
535 tenancy_posture_overrides: std::sync::OnceLock::new(),
536 })),
537 }
538 }
539
540 #[cfg(feature = "handlers")]
543 pub(crate) fn sql_provider(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
544 self.inner.as_ref().and_then(|inner| inner.sql.clone())
545 }
546
547 pub async fn precompile_component(&self, hash: &str, wasm: &[u8]) -> Result<(), String> {
554 #[cfg(feature = "handlers")]
555 if let Some(inner) = self.inner.as_ref() {
556 return inner
557 .engine
558 .precompile_gated(hash, wasm)
559 .await
560 .map_err(|e| e.to_string());
561 }
562 let _ = (hash, wasm);
563 Ok(())
564 }
565
566 #[cfg(feature = "handlers")]
571 pub(crate) async fn acquire_compile_permit(&self) -> Option<tokio::sync::OwnedSemaphorePermit> {
572 match self.inner.as_ref() {
573 Some(inner) => Some(inner.engine.acquire_compile_permit().await),
574 None => None,
575 }
576 }
577
578 #[cfg(feature = "handlers")]
582 pub(crate) async fn precompile_component_holding_permit(
583 &self,
584 hash: &str,
585 wasm: &[u8],
586 ) -> Result<(), String> {
587 match self.inner.as_ref() {
588 Some(inner) => inner
589 .engine
590 .precompile_off_runtime(hash, wasm)
591 .await
592 .map_err(|e| e.to_string()),
593 None => Ok(()),
594 }
595 }
596
597 #[cfg(feature = "handlers")]
601 pub(crate) fn invoker(&self) -> Option<Arc<function_runtime::FunctionInvoker>> {
602 self.inner
603 .as_ref()
604 .and_then(|inner| inner.invoker.get().cloned())
605 }
606
607 #[cfg(feature = "handlers")]
611 pub(crate) async fn introspect_subgraph_sdl(
612 &self,
613 deploy: &DeployStore,
614 project: boatramp_core::project::ProjectRef<'_>,
615 function: &boatramp_core::function::Function,
616 component: &str,
617 ) -> Result<String, function_runtime::SubgraphSdlError> {
618 match self.inner.as_ref() {
619 Some(inner) => {
620 function_runtime::introspect_service_sdl(
621 inner, deploy, project, function, component,
622 )
623 .await
624 }
625 None => Err(function_runtime::SubgraphSdlError::Unavailable),
626 }
627 }
628
629 #[cfg(feature = "handlers")]
635 pub fn set_invoker(&self, deploy: DeployStore) {
636 if let Some(inner) = self.inner.as_ref() {
637 let invoker = Arc::new(function_runtime::FunctionInvoker::new(
638 deploy,
639 Arc::downgrade(inner),
640 ));
641 let _ = inner.invoker.set(invoker);
642 let runner = Arc::new(graphql_gateway::FederationRunner::new(Arc::downgrade(
645 inner,
646 )));
647 let _ = inner.federation_runner.set(runner);
648 }
649 }
650
651 #[cfg(feature = "handlers")]
655 pub fn sql_backends(&self) -> Option<Arc<dyn boatramp_core::sql::SqlBackends>> {
656 self.inner.as_ref().and_then(|inner| inner.sql.clone())
657 }
658
659 #[cfg(feature = "handlers")]
663 pub fn set_watch_provider(
664 &self,
665 provider: Arc<dyn boatramp_core::blob_provision::WatchProvider>,
666 ) {
667 if let Some(inner) = self.inner.as_ref() {
668 let _ = inner.watch_provider.set(provider);
669 }
670 }
671
672 #[cfg(feature = "handlers")]
676 pub fn set_provision_tier(&self, tier: boatramp_core::blob_notify::ProvisionTier) {
677 if let Some(inner) = self.inner.as_ref() {
678 let _ = inner.provision_tier.set(tier);
679 }
680 }
681
682 #[cfg(feature = "handlers")]
686 pub fn set_max_blob_bytes(&self, max_bytes: u64) {
687 if let Some(inner) = self.inner.as_ref() {
688 let _ = inner.max_blob_bytes.set(max_bytes);
689 }
690 }
691
692 #[cfg(feature = "handlers")]
695 pub fn set_max_component_bytes(&self, max_bytes: u64) {
696 if let Some(inner) = self.inner.as_ref() {
697 let _ = inner.max_component_bytes.set(max_bytes);
698 }
699 }
700
701 #[cfg(feature = "handlers")]
709 pub fn set_allow_env_secret_refs(&self, allow: bool) {
710 if let Some(inner) = self.inner.as_ref() {
711 let _ = inner.allow_env_secret_refs.set(allow);
712 }
713 }
714
715 #[cfg(feature = "handlers")]
720 pub fn set_tenancy_posture(&self, require_declaration: bool, allow_cross_tenant: bool) {
721 if let Some(inner) = self.inner.as_ref() {
722 let _ = inner.require_tenancy_declaration.set(require_declaration);
723 let _ = inner.allow_cross_tenant_db.set(allow_cross_tenant);
724 }
725 }
726
727 #[cfg(feature = "handlers")]
734 pub fn set_project_tenancy_overrides(
735 &self,
736 overrides: std::collections::BTreeMap<
737 String,
738 boatramp_core::security::ResolvedProjectTenancy,
739 >,
740 ) {
741 if overrides.is_empty() {
742 return;
743 }
744 if let Some(inner) = self.inner.as_ref() {
745 let _ = inner.tenancy_posture_overrides.set(Arc::new(overrides));
746 }
747 }
748
749 #[cfg(feature = "handlers")]
755 pub fn set_session_signer(&self, signer: Arc<dyn Signer>) {
756 if let Some(inner) = self.inner.as_ref() {
757 let _ = inner.session_signer.set(signer);
758 }
759 }
760
761 #[cfg(feature = "capability")]
768 pub fn set_capability_minting(&self, max_ttl_secs: u64) {
769 if max_ttl_secs == 0 {
770 return;
771 }
772 if let Some(inner) = self.inner.as_ref() {
773 let _ = inner.capability_max_ttl_secs.set(max_ttl_secs);
774 }
775 }
776
777 #[cfg(feature = "handlers")]
782 pub fn set_secret_store(&self, store: Arc<boatramp_core::secret_store::SecretStore>) {
783 if let Some(inner) = self.inner.as_ref() {
784 let _ = inner.secret_store.set(store);
785 }
786 }
787
788 #[cfg(feature = "email")]
792 pub fn set_email_profile_store(
793 &self,
794 store: Arc<boatramp_core::email_config::EmailProfileStore>,
795 ) {
796 if let Some(inner) = self.inner.as_ref() {
797 let _ = inner.email_profile_store.set(store);
798 }
799 }
800
801 #[cfg(feature = "email")]
806 pub fn set_email_spool(&self, spool: Arc<dyn boatramp_handlers::EmailSpool>) {
807 if let Some(inner) = self.inner.as_ref() {
808 let _ = inner.email_spool.set(spool);
809 }
810 }
811
812 #[cfg(feature = "admin")]
817 pub fn set_admin(
818 &self,
819 controller: Arc<admin_controller::ServerAdminController>,
820 surfaces: std::collections::BTreeSet<boatramp_handlers::AdminSurface>,
821 ) {
822 if let Some(inner) = self.inner.as_ref() {
823 let _ = inner.admin_controller.set(controller);
824 let _ = inner.admin_surfaces.set(surfaces);
825 }
826 }
827
828 #[cfg(feature = "handlers")]
833 pub(crate) fn allow_env_secret_refs(&self) -> bool {
834 self.inner
835 .as_ref()
836 .and_then(|inner| inner.allow_env_secret_refs.get().copied())
837 .unwrap_or(false)
838 }
839
840 #[cfg(feature = "handlers")]
845 pub fn set_cron_leader_gate(&self, gate: CronLeaderGate) {
846 if let Some(inner) = self.inner.as_ref() {
847 let _ = inner.cron_leader_gate.set(gate);
848 }
849 }
850
851 #[cfg(feature = "handlers")]
858 async fn precheck_activation(
859 &self,
860 deploy: &DeployStore,
861 manifest: &Manifest,
862 site_config: Option<&SiteConfig>,
863 ) -> Result<(), String> {
864 let Some(inner) = self.inner.as_ref() else {
865 return Ok(());
866 };
867 if manifest.config.handlers.is_empty() && manifest.config.consumers.is_empty() {
870 return Ok(());
871 }
872 let site_handlers = site_config
874 .and_then(|c| c.handlers.as_ref())
875 .filter(|h| h.enabled)
876 .ok_or_else(|| {
877 "deployment ships handlers/consumers but the site has them disabled".to_string()
878 })?;
879 let max_component = inner.max_component_bytes.get().copied().unwrap_or(0);
880
881 let allow_env_secret_refs = inner.allow_env_secret_refs.get().copied().unwrap_or(false);
888 crate::handler_dispatch::admit_secret_refs(&site_handlers.secrets, allow_env_secret_refs)
889 .map_err(|err| format!("handler secrets: {err}"))?;
890
891 let sync_ceiling = inner.engine.sync_timeout_ms();
897 let async_ceiling = inner.engine.async_timeout_ms();
898 if let Some(ms) = site_handlers.max_timeout_ms {
899 if u64::from(ms) > sync_ceiling {
900 tracing::warn!(
901 "site max_timeout_ms={ms} exceeds sync_max_timeout_ms={sync_ceiling}: \
902 synchronous HTTP handlers are capped at {sync_ceiling}ms; the extra time \
903 applies only to async calls (?mode=async / triggers), capped at \
904 async_max_timeout_ms={async_ceiling}"
905 );
906 }
907 }
908
909 for handler in &manifest.config.handlers {
911 if let Some(ms) = handler.limits.as_ref().and_then(|l| l.timeout_ms) {
912 if u64::from(ms) > sync_ceiling {
913 let route = &handler.route;
914 tracing::warn!(
915 "route {route:?} declares limits.timeout_ms={ms}, above \
916 sync_max_timeout_ms={sync_ceiling}: synchronous HTTP calls to this route \
917 are capped at {sync_ceiling}ms; the {ms}ms only applies to async calls \
918 (?mode=async / a queue trigger / a #[consumer]), capped at \
919 async_max_timeout_ms={async_ceiling}. If you need {ms}ms synchronously, \
920 that isn't possible — move the work to the async lane"
921 );
922 }
923 }
924 if !handler.streaming {
929 if let Some(entry) = manifest.files.get(&handler.component) {
930 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
931 if crate::function_api::component_declares_streaming_route(
932 &bytes,
933 &handler.route,
934 ) {
935 let route = &handler.route;
936 tracing::warn!(
937 "route {route:?} is a streaming handler (#[handler(stream)]) but \
938 its config lacks streaming = true: it will run on the sync request \
939 lane and be cut at sync_max_timeout_ms={sync_ceiling}ms. Set \
940 streaming = true so it serves on the dedicated streaming lane (its \
941 own concurrency budget + a much larger wall-clock)."
942 );
943 }
944 }
945 }
946 }
947 if let Some(entry) = manifest.files.get(&handler.component) {
951 if let Ok(bytes) = read_blob_bytes(deploy, &entry.hash).await {
952 let unmet = crate::function_api::unmet_requires(&bytes);
953 if !unmet.is_empty() {
954 return Err(format!(
955 "route {:?} [{}] requires capabilities this host does not implement: \
956 {}. Upgrade boatramp or enable those features — see `boatramp \
957 capabilities`.",
958 handler.route,
959 handler.methods.join(","),
960 unmet.join(", ")
961 ));
962 }
963 }
964 }
965 precheck_component(
966 deploy,
967 manifest,
968 site_handlers,
969 inner,
970 max_component,
971 &handler.imports,
972 &handler.component,
973 &format!("route {:?} [{}]", handler.route, handler.methods.join(",")),
976 false,
977 )
978 .await?;
979 }
980 for consumer in &manifest.config.consumers {
981 precheck_component(
982 deploy,
983 manifest,
984 site_handlers,
985 inner,
986 max_component,
987 &consumer.imports,
988 &consumer.component,
989 &format!("consumer {:?}", consumer.topic),
990 true,
991 )
992 .await?;
993 }
994 Ok(())
995 }
996
997 #[cfg(not(feature = "handlers"))]
998 async fn precheck_activation(
999 &self,
1000 _deploy: &DeployStore,
1001 _manifest: &Manifest,
1002 _site_config: Option<&SiteConfig>,
1003 ) -> Result<(), String> {
1004 Ok(())
1005 }
1006}
1007
1008#[derive(Default, Clone)]
1014pub struct ServerOptions {
1015 pub limits: ServerLimits,
1017 pub probe: Option<Arc<dyn boatramp_core::domain_verify::DomainProbe>>,
1020 pub default_site: Option<String>,
1023 pub implicit_routing: bool,
1029 pub protect_previews: bool,
1034 pub cluster_rate_limit_kv: Option<Arc<dyn boatramp_core::kv::KvStore>>,
1038 pub issuer: Option<Arc<dyn Signer>>,
1042 pub bootstrap_secret: Option<String>,
1047 pub bootstrap_attestation: Option<String>,
1053 pub mesh_control: Option<Arc<dyn MeshControl>>,
1057 pub cors_allowed_origins: Vec<String>,
1067 #[cfg(feature = "oidc")]
1070 pub oidc_verifier: Option<Arc<oidc::OidcVerifier>>,
1071 pub posture: boatramp_core::security::SecurityPosture,
1075 pub served_over_tls: bool,
1080 pub pop_origin: Option<String>,
1087 pub daemon_runtime: Option<Arc<DaemonRuntime>>,
1091 pub operator_sql: Option<Arc<dyn boatramp_core::sql::OperatorSql>>,
1095 pub tenant_deprovisioner: Option<Arc<dyn boatramp_core::sql::TenantDeprovisioner>>,
1100 pub compute_exec: Option<Arc<dyn boatramp_core::compute::ComputeExec>>,
1104 pub compute_volumes: Option<Arc<dyn boatramp_core::compute::ComputeVolumes>>,
1109 pub compute_control: Option<Arc<dyn boatramp_core::compute::ComputeControl>>,
1113 pub secret_store: Option<Arc<boatramp_core::secret_store::SecretStore>>,
1121 pub email_profile_store: Option<Arc<boatramp_core::email_config::EmailProfileStore>>,
1129 #[cfg(feature = "console")]
1133 pub console: Option<console::ConsoleMount>,
1134}
1135
1136#[derive(Clone, Copy)]
1140struct ServedOverTls(bool);
1141
1142#[derive(Clone, Copy, Default)]
1147struct ImplicitRouting(bool);
1148
1149const DAEMON_RELOAD_BACKSTOP: std::time::Duration = std::time::Duration::from_secs(300);
1161
1162pub struct DaemonRuntime {
1163 baseline: boatramp_core::daemon_config::ConfigBaseline,
1164 state: std::sync::RwLock<DaemonState>,
1165 reload: tokio::sync::Notify,
1168}
1169
1170struct DaemonState {
1171 effective: Arc<boatramp_core::daemon_config::EffectiveConfig>,
1172 generation: Option<String>,
1173}
1174
1175pub fn config_baseline(options: &ServerOptions) -> boatramp_core::daemon_config::ConfigBaseline {
1180 #[cfg(feature = "console")]
1184 let (console_enabled, console_host, console_path) = match options.console.as_ref() {
1185 Some(m) => (true, Some(m.host.clone()), Some(m.path.clone())),
1186 None => (false, None, None),
1187 };
1188 #[cfg(not(feature = "console"))]
1189 let (console_enabled, console_host, console_path) = (false, None, None);
1190 boatramp_core::daemon_config::ConfigBaseline {
1191 default_site: options.default_site.clone(),
1192 protect_previews: options.protect_previews,
1193 max_upload_bytes: options.limits.max_upload_bytes.unwrap_or(0),
1194 upload_idle_timeout_secs: options.limits.upload_idle_timeout.map(|d| d.as_secs()),
1195 max_concurrent_uploads: options.limits.max_concurrent_uploads.map(|n| n as u64),
1196 cluster_rate_limit: options.cluster_rate_limit_kv.is_some(),
1197 compute_vcpus: 0,
1198 compute_mem_mib: 0,
1199 console_enabled,
1200 console_host,
1201 console_path,
1202 max_upload_ceiling: options.posture.max_upload_bytes,
1203 max_concurrent_uploads_ceiling: None,
1204 posture: options.posture,
1205 }
1206}
1207
1208impl DaemonRuntime {
1209 pub fn new(baseline: boatramp_core::daemon_config::ConfigBaseline) -> Self {
1213 let effective =
1214 Arc::new(boatramp_core::daemon_config::DaemonConfig::default().resolve(&baseline));
1215 Self {
1216 baseline,
1217 state: std::sync::RwLock::new(DaemonState {
1218 effective,
1219 generation: None,
1220 }),
1221 reload: tokio::sync::Notify::new(),
1222 }
1223 }
1224
1225 pub fn notify_reload(&self) {
1229 self.reload.notify_one();
1230 }
1231
1232 pub fn effective(&self) -> Arc<boatramp_core::daemon_config::EffectiveConfig> {
1234 self.state
1235 .read()
1236 .expect("daemon config lock")
1237 .effective
1238 .clone()
1239 }
1240
1241 pub fn generation(&self) -> Option<String> {
1244 self.state
1245 .read()
1246 .expect("daemon config lock")
1247 .generation
1248 .clone()
1249 }
1250
1251 pub fn baseline(&self) -> &boatramp_core::daemon_config::ConfigBaseline {
1253 &self.baseline
1254 }
1255
1256 pub async fn reload(&self, deploy: &DeployStore) -> Result<(), DeployError> {
1259 let cfg = deploy.get_daemon_config().await?.unwrap_or_default();
1260 let generation = deploy.daemon_config_generation().await?;
1261 let effective = Arc::new(cfg.resolve(&self.baseline));
1262 *self.state.write().expect("daemon config lock") = DaemonState {
1263 effective,
1264 generation,
1265 };
1266 Ok(())
1267 }
1268}
1269
1270#[derive(Clone, Copy, Default)]
1273struct PreviewPolicy {
1274 protect: bool,
1275}
1276
1277#[derive(Clone, Default)]
1282struct Issuer(Option<Arc<dyn Signer>>);
1283
1284#[derive(Clone, Default)]
1289struct BootstrapGate(Option<Arc<BootstrapInner>>);
1290
1291struct BootstrapInner {
1292 secret_hash: String,
1295 lock: tokio::sync::Mutex<()>,
1298}
1299
1300impl BootstrapGate {
1301 fn new(secret: Option<&str>) -> Self {
1302 Self(secret.filter(|s| !s.is_empty()).map(|s| {
1303 Arc::new(BootstrapInner {
1304 secret_hash: boatramp_core::deploy::sha256_hex(s.as_bytes()),
1305 lock: tokio::sync::Mutex::new(()),
1306 })
1307 }))
1308 }
1309}
1310
1311#[async_trait::async_trait]
1315pub trait MeshControl: Send + Sync {
1316 async fn admit(
1324 &self,
1325 mesh_pubkey_hex: &str,
1326 jti: &str,
1327 possession_proof: &[u8],
1328 proof_iat: u64,
1329 now: u64,
1330 advertise_addr: Option<&str>,
1331 ) -> Result<JoinOutcome, String>;
1332
1333 async fn rotate_key(&self) -> Result<String, String>;
1337
1338 async fn revoke(&self, node: u64) -> Result<(), String>;
1342
1343 async fn members(&self) -> Result<Vec<MeshMember>, String>;
1347
1348 async fn promote(&self, node: u64) -> Result<(), String>;
1351}
1352
1353pub enum JoinOutcome {
1355 Admitted {
1358 members: Vec<String>,
1360 addrs: std::collections::BTreeMap<u64, String>,
1362 },
1363 TokenSpent,
1365 ProofInvalid,
1367 Revoked,
1370}
1371
1372#[derive(Debug, Clone, Serialize)]
1374pub struct MeshMember {
1375 pub node: u64,
1377 pub voter: bool,
1379 pub caught_up: bool,
1381 pub leader: bool,
1383 #[serde(default, skip_serializing_if = "Option::is_none")]
1387 pub addr: Option<String>,
1388}
1389
1390#[derive(Clone, Default)]
1393struct MeshControlHandle(Option<Arc<dyn MeshControl>>);
1394
1395#[cfg(feature = "oidc")]
1397#[derive(Clone, Default)]
1398struct OidcState(Option<Arc<oidc::OidcVerifier>>);
1399
1400#[cfg(feature = "oidc")]
1403const EXCHANGE_TTL_SECS: u64 = 3600;
1404
1405use boatramp_core::time::now_unix;
1406
1407#[derive(Clone)]
1409struct CorsState(Arc<Vec<String>>);
1410
1411const CORS_ALLOW_METHODS: &str = "GET, POST, PUT, DELETE, OPTIONS";
1413const CORS_ALLOW_HEADERS: &str = "authorization, content-type";
1416const CORS_MAX_AGE: &str = "600";
1418
1419fn cors_origin_allowed(allowed: &[String], origin: &str) -> bool {
1423 allowed.iter().any(|a| a == "*" || a == origin)
1424}
1425
1426async fn cors(
1433 State(allowed): State<CorsState>,
1434 request: Request,
1435 next: axum::middleware::Next,
1436) -> Response {
1437 let origin = request
1438 .headers()
1439 .get(header::ORIGIN)
1440 .and_then(|v| v.to_str().ok())
1441 .filter(|o| cors_origin_allowed(&allowed.0, o))
1442 .map(str::to_string);
1443 let is_preflight = request.method() == Method::OPTIONS
1445 && request
1446 .headers()
1447 .contains_key(header::ACCESS_CONTROL_REQUEST_METHOD);
1448 if is_preflight {
1449 let allow_headers = request
1451 .headers()
1452 .get(header::ACCESS_CONTROL_REQUEST_HEADERS)
1453 .and_then(|v| v.to_str().ok())
1454 .map(str::to_string)
1455 .unwrap_or_else(|| CORS_ALLOW_HEADERS.to_string());
1456 let mut response = Response::new(Body::empty());
1457 *response.status_mut() = StatusCode::NO_CONTENT;
1458 if let Some(origin) = origin {
1459 let headers = response.headers_mut();
1460 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1461 set_header(headers, header::VARY, "Origin");
1462 set_header(
1463 headers,
1464 header::ACCESS_CONTROL_ALLOW_METHODS,
1465 CORS_ALLOW_METHODS,
1466 );
1467 set_header(
1468 headers,
1469 header::ACCESS_CONTROL_ALLOW_HEADERS,
1470 &allow_headers,
1471 );
1472 set_header(headers, header::ACCESS_CONTROL_MAX_AGE, CORS_MAX_AGE);
1473 }
1474 return response;
1475 }
1476 let mut response = next.run(request).await;
1477 if let Some(origin) = origin {
1478 let headers = response.headers_mut();
1479 set_header(headers, header::ACCESS_CONTROL_ALLOW_ORIGIN, &origin);
1480 if let Ok(value) = HeaderValue::from_str("Origin") {
1483 headers.append(header::VARY, value);
1484 }
1485 }
1486 response
1487}
1488
1489const DRAIN_DEADLINE: Duration = Duration::from_secs(30);
1494
1495#[derive(Debug, thiserror::Error)]
1497pub enum ServeError {
1498 #[error("server I/O: {0}")]
1500 Io(#[from] std::io::Error),
1501}
1502
1503pub async fn serve(
1506 addr: SocketAddr,
1507 deploy: DeployStore,
1508 auth: Auth,
1509 handlers: HandlerRuntime,
1510) -> Result<(), ServeError> {
1511 serve_with(addr, deploy, auth, handlers, ServerOptions::default()).await
1512}
1513
1514pub(crate) fn disable_nagle(stream: &mut tokio::net::TcpStream) {
1524 if let Err(err) = stream.set_nodelay(true) {
1525 tracing::debug!(%err, "failed to set TCP_NODELAY on an accepted connection");
1526 }
1527}
1528
1529pub async fn serve_with(
1531 addr: SocketAddr,
1532 deploy: DeployStore,
1533 auth: Auth,
1534 handlers: HandlerRuntime,
1535 options: ServerOptions,
1536) -> Result<(), ServeError> {
1537 let tcp = tokio::net::TcpListener::bind(addr).await?;
1538 tracing::info!(%addr, auth = !auth.is_disabled(), "boatramp server listening");
1539 let splice_ctx = splice::SpliceCtx {
1545 deploy: deploy.clone(),
1546 posture: options.posture,
1547 daemon: options.daemon_runtime.clone(),
1548 };
1549 #[cfg(feature = "handlers")]
1552 let scheduler = handlers.spawn_scheduler(deploy.clone());
1553 let gateway_prober = gateway::spawn_active_health_prober();
1557 let (router, fast) = router_with_fast(deploy, auth, handlers, options);
1560
1561 let (signalled_tx, signalled_rx) = tokio::sync::watch::channel(false);
1565 let server = splice::serve(tcp, splice_ctx, (router, fast), async move {
1569 shutdown_signal().await;
1570 let _ = signalled_tx.send(true);
1571 });
1572 let signalled = {
1573 let mut rx = signalled_rx;
1574 async move {
1575 let _ = rx.wait_for(|fired| *fired).await;
1576 }
1577 };
1578 let result = serve_with_drain_deadline(
1579 async move { server.await.map_err(ServeError::from) },
1580 signalled,
1581 DRAIN_DEADLINE,
1582 )
1583 .await;
1584 #[cfg(feature = "handlers")]
1586 if let Some(handle) = scheduler {
1587 handle.abort();
1588 }
1589 gateway_prober.abort();
1590 result
1591}
1592
1593async fn serve_with_drain_deadline<Srv, Sig>(
1598 server: Srv,
1599 signalled: Sig,
1600 deadline: Duration,
1601) -> Result<(), ServeError>
1602where
1603 Srv: Future<Output = Result<(), ServeError>>,
1604 Sig: Future<Output = ()>,
1605{
1606 tokio::pin!(server);
1607 let drain_cap = async move {
1608 signalled.await;
1609 tokio::time::sleep(deadline).await;
1610 };
1611 tokio::select! {
1612 result = &mut server => result,
1613 _ = drain_cap => {
1614 tracing::warn!(
1615 deadline_s = deadline.as_secs(),
1616 "drain deadline exceeded; forcing shutdown with requests still in flight"
1617 );
1618 Ok(())
1619 }
1620 }
1621}
1622
1623pub async fn shutdown_signal() {
1626 let ctrl_c = async {
1627 let _ = tokio::signal::ctrl_c().await;
1628 };
1629 #[cfg(unix)]
1630 let terminate = async {
1631 if let Ok(mut sig) =
1632 tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
1633 {
1634 sig.recv().await;
1635 }
1636 };
1637 #[cfg(not(unix))]
1638 let terminate = std::future::pending::<()>();
1639
1640 tokio::select! {
1641 _ = ctrl_c => {}
1642 _ = terminate => {}
1643 }
1644 tracing::info!("shutdown signal received; draining");
1645}
1646
1647async fn healthz(Extension(daemon): Extension<Arc<DaemonRuntime>>) -> String {
1651 match daemon.generation() {
1652 Some(gen) => format!("ok gen={gen}"),
1653 None => "ok".to_string(),
1654 }
1655}
1656
1657async fn readyz(State(deploy): State<DeployStore>) -> Response {
1659 match deploy.ready().await {
1660 Ok(()) => (StatusCode::OK, "ready\n").into_response(),
1661 Err(err) => {
1662 tracing::warn!(error = %err, "readiness probe failed");
1663 (StatusCode::SERVICE_UNAVAILABLE, "not ready\n").into_response()
1664 }
1665 }
1666}
1667
1668#[derive(Clone)]
1673pub struct RequestId(pub String);
1674
1675#[cfg(feature = "handlers")]
1681#[derive(Clone)]
1682pub struct DomainContext(pub String);
1683
1684fn request_id_for(headers: &HeaderMap) -> String {
1687 if let Some(id) = headers
1688 .get("x-request-id")
1689 .and_then(|v| v.to_str().ok())
1690 .map(str::trim)
1691 .filter(|s| !s.is_empty())
1692 {
1693 return id.chars().filter(|c| !c.is_control()).take(128).collect();
1694 }
1695 use std::sync::atomic::{AtomicU64, Ordering};
1696 static SEQ: AtomicU64 = AtomicU64::new(0);
1697 let n = SEQ.fetch_add(1, Ordering::Relaxed);
1698 format!("{:x}-{:x}", boatramp_core::time::now_unix_ms(), n)
1699}
1700
1701struct AccessLog {
1705 request_id: String,
1706 method: Method,
1707 path: String,
1708 host: String,
1709 client: String,
1710 status: u16,
1711 encoding: String,
1713 start: std::time::Instant,
1714 bytes: std::sync::atomic::AtomicU64,
1715}
1716
1717impl Drop for AccessLog {
1718 fn drop(&mut self) {
1719 let bytes = self.bytes.load(std::sync::atomic::Ordering::Relaxed);
1720 srvmetrics::server_metrics().record_request(self.status, bytes);
1723 tracing::info!(
1724 target: "boatramp::access",
1725 request_id = %self.request_id,
1726 method = %self.method,
1727 path = %self.path,
1728 host = %self.host,
1729 client = %self.client,
1730 status = self.status,
1731 bytes = bytes,
1732 encoding = %self.encoding,
1733 cache_result = srvmetrics::cache_result(self.status),
1734 elapsed_ms = self.start.elapsed().as_millis() as u64,
1735 "request"
1736 );
1737 }
1738}
1739
1740pub(crate) fn assign_request_id(request: &mut axum::extract::Request) -> String {
1746 let request_id = request_id_for(request.headers());
1747 request
1748 .extensions_mut()
1749 .insert(RequestId(request_id.clone()));
1750 request_id
1751}
1752
1753pub(crate) struct AccessLogCtx {
1758 request_id: String,
1759 method: Method,
1760 path: String,
1761 host: String,
1762 client: String,
1763 start: std::time::Instant,
1764}
1765
1766impl AccessLogCtx {
1767 pub(crate) fn capture(request: &axum::extract::Request, request_id: String) -> Option<Self> {
1773 if !tracing::enabled!(target: "boatramp::access", tracing::Level::INFO) {
1774 return None;
1775 }
1776 Some(Self {
1777 request_id,
1778 method: request.method().clone(),
1779 path: request.uri().path().to_string(),
1780 host: request
1781 .headers()
1782 .get(header::HOST)
1783 .and_then(|value| value.to_str().ok())
1784 .or_else(|| request.uri().host()) .unwrap_or("-")
1786 .to_string(),
1787 client: request
1788 .extensions()
1789 .get::<axum::extract::ConnectInfo<SocketAddr>>()
1790 .map(|info| info.0.ip().to_string())
1791 .unwrap_or_else(|| "-".to_string()),
1792 start: std::time::Instant::now(),
1793 })
1794 }
1795
1796 pub(crate) fn finish(self, response: Response) -> Response {
1800 let encoding = response
1801 .headers()
1802 .get(header::CONTENT_ENCODING)
1803 .and_then(|v| v.to_str().ok())
1804 .unwrap_or("identity")
1805 .to_string();
1806 let log = AccessLog {
1807 request_id: self.request_id,
1808 method: self.method,
1809 path: self.path,
1810 host: self.host,
1811 client: self.client,
1812 status: response.status().as_u16(),
1813 encoding,
1814 start: self.start,
1815 bytes: std::sync::atomic::AtomicU64::new(0),
1816 };
1817 let (parts, body) = response.into_parts();
1818 let counted = body.into_data_stream().map(move |chunk| {
1819 if let Ok(bytes) = &chunk {
1820 log.bytes
1821 .fetch_add(bytes.len() as u64, std::sync::atomic::Ordering::Relaxed);
1822 }
1823 chunk
1824 });
1825 Response::from_parts(parts, Body::from_stream(counted))
1826 }
1827}
1828
1829async fn access_log(mut request: axum::extract::Request, next: axum::middleware::Next) -> Response {
1834 let request_id = assign_request_id(&mut request);
1835 match AccessLogCtx::capture(&request, request_id) {
1836 None => next.run(request).await,
1837 Some(ctx) => ctx.finish(next.run(request).await),
1838 }
1839}
1840
1841fn if_none_match(req_headers: &HeaderMap, etag: &str) -> bool {
1843 req_headers
1844 .get(header::IF_NONE_MATCH)
1845 .and_then(|value| value.to_str().ok())
1846 .is_some_and(|value| {
1847 value
1848 .split(',')
1849 .map(str::trim)
1850 .any(|tag| tag == "*" || tag == etag || tag.trim_start_matches("W/") == etag)
1851 })
1852}
1853
1854fn set_header(headers: &mut HeaderMap, name: header::HeaderName, value: &str) {
1855 if let Ok(value) = HeaderValue::from_str(value) {
1856 headers.insert(name, value);
1857 }
1858}
1859
1860fn not_found() -> Response {
1861 (StatusCode::NOT_FOUND, "not found\n").into_response()
1862}
1863
1864fn redirect(status: u16, location: &str) -> Response {
1865 let status = StatusCode::from_u16(status).unwrap_or(StatusCode::FOUND);
1866 match HeaderValue::from_str(location) {
1867 Ok(location) => {
1868 let mut headers = HeaderMap::new();
1869 headers.insert(header::LOCATION, location);
1870 (status, headers).into_response()
1871 }
1872 Err(_) => (StatusCode::INTERNAL_SERVER_ERROR, "bad redirect target\n").into_response(),
1873 }
1874}
1875
1876fn deploy_error_response(err: DeployError) -> Response {
1878 let status = match &err {
1879 DeployError::NotFound(_) | DeployError::Storage(StorageError::NotFound(_)) => {
1880 StatusCode::NOT_FOUND
1881 }
1882 DeployError::HashMismatch { .. } => StatusCode::BAD_REQUEST,
1883 DeployError::Incomplete(_) => StatusCode::CONFLICT,
1884 DeployError::Conflict(_) => StatusCode::CONFLICT,
1886 DeployError::Ambiguous(_) => StatusCode::NOT_FOUND,
1888 DeployError::Invalid(_) => StatusCode::BAD_REQUEST,
1890 _ => StatusCode::INTERNAL_SERVER_ERROR,
1891 };
1892 tracing::warn!(error = %err, "request failed");
1893 (status, format!("{err}\n")).into_response()
1894}
1895
1896fn reject_invalid_name(kind: &'static str, value: &str) -> Option<Response> {
1901 boatramp_core::project::validate_resource_name(kind, value)
1902 .err()
1903 .map(|err| (StatusCode::UNPROCESSABLE_ENTITY, format!("{err}\n")).into_response())
1904}
1905
1906#[cfg(test)]
1907mod drain_tests {
1908 use super::*;
1909
1910 #[tokio::test]
1911 async fn deadline_forces_shutdown_after_signal() {
1912 let server = std::future::pending::<Result<(), ServeError>>();
1915 let signalled = async {}; let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(20)).await;
1917 assert!(result.is_ok());
1918 }
1919
1920 #[tokio::test]
1921 async fn server_finishing_first_wins() {
1922 let server = async { Ok(()) };
1925 let signalled = std::future::pending::<()>();
1926 let result = serve_with_drain_deadline(server, signalled, Duration::from_secs(30)).await;
1927 assert!(result.is_ok());
1928 }
1929
1930 #[tokio::test]
1931 async fn deadline_does_not_trip_before_signal() {
1932 let server = async {
1936 tokio::time::sleep(Duration::from_millis(40)).await;
1937 Err(ServeError::Io(std::io::Error::other("server error")))
1938 };
1939 let signalled = std::future::pending::<()>();
1940 let result = serve_with_drain_deadline(server, signalled, Duration::from_millis(10)).await;
1941 assert!(result.is_err());
1942 }
1943}
1944
1945#[cfg(all(test, feature = "handlers"))]
1946mod tests {
1947 use super::*;
1948 use boatramp_core::cose::{LocalSigner, TokenAlg};
1949 use boatramp_core::project::ProjectRef;
1950
1951 #[test]
1952 fn query_string_parses_and_url_decodes() {
1953 let q = parse_query_string("lang=fr&city=S%C3%A3o+Paulo&flag&dup=1&dup=2");
1954 assert_eq!(q.get("lang").map(String::as_str), Some("fr"));
1955 assert_eq!(q.get("city").map(String::as_str), Some("São Paulo")); assert_eq!(q.get("flag").map(String::as_str), Some("")); assert_eq!(q.get("dup").map(String::as_str), Some("1")); }
1959
1960 #[test]
1961 fn cookie_header_parses_pairs() {
1962 let c = parse_cookie_header("beta=1; sid = abc ; empty=");
1963 assert_eq!(c.get("beta").map(String::as_str), Some("1"));
1964 assert_eq!(c.get("sid").map(String::as_str), Some("abc"));
1965 assert_eq!(c.get("empty").map(String::as_str), Some(""));
1966 }
1967
1968 #[test]
1969 fn apply_vary_merges_without_duplicates() {
1970 let base = (StatusCode::OK, "x").into_response();
1971 let r = apply_vary(base, &["accept-language".into()]);
1972 assert_eq!(r.headers().get(header::VARY).unwrap(), "accept-language");
1973 let r = apply_vary(r, &["cookie".into(), "accept-language".into()]);
1975 let v = r.headers().get(header::VARY).unwrap().to_str().unwrap();
1976 assert!(v.contains("accept-language") && v.contains("cookie"));
1977 assert_eq!(v.matches("accept-language").count(), 1);
1978 let plain = apply_vary((StatusCode::OK, "y").into_response(), &[]);
1980 assert!(plain.headers().get(header::VARY).is_none());
1981 }
1982
1983 #[tokio::test]
1987 async fn join_token_endpoint_mints_a_verifiable_bearer_token() {
1988 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
1989 let public = keys.public_key();
1990
1991 let resp = create_join_token(
1993 Extension(Issuer(Some(keys.clone()))),
1994 Json(CreateJoinTokenRequest {
1995 ttl_secs: Some(600),
1996 }),
1997 )
1998 .await;
1999 assert_eq!(resp.status(), StatusCode::CREATED);
2000 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2001 .await
2002 .unwrap();
2003 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2004 let token = parsed["token"].as_str().unwrap();
2005 let jti = cose::verify_join(token, &public, now_unix()).unwrap();
2006 assert!(!jti.is_empty());
2007
2008 let no_issuer = create_join_token(
2010 Extension(Issuer(None)),
2011 Json(CreateJoinTokenRequest { ttl_secs: None }),
2012 )
2013 .await;
2014 assert_eq!(no_issuer.status(), StatusCode::NOT_IMPLEMENTED);
2015 }
2016
2017 #[tokio::test]
2022 async fn function_write_path_deploy_rollback_alias_remove() {
2023 use boatramp_core::function::Lifecycle;
2024 use boatramp_core::kv::MemoryKv;
2025 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, Storage, StorageError};
2026
2027 struct FakeStorage {
2030 present: bool,
2031 }
2032 #[async_trait::async_trait]
2033 impl Storage for FakeStorage {
2034 async fn get(&self, _: &str) -> Result<GetObject, StorageError> {
2035 Err(StorageError::NotFound(String::new()))
2036 }
2037 async fn get_range(
2038 &self,
2039 _: &str,
2040 _: u64,
2041 _: Option<u64>,
2042 ) -> Result<GetObject, StorageError> {
2043 Err(StorageError::NotFound(String::new()))
2044 }
2045 async fn put(
2046 &self,
2047 _: &str,
2048 _: ByteStream,
2049 _: PutMeta,
2050 ) -> Result<ObjectMeta, StorageError> {
2051 Err(StorageError::unsupported("fake"))
2052 }
2053 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2054 if self.present {
2055 Ok(ObjectMeta {
2056 key: key.to_string(),
2057 ..Default::default()
2058 })
2059 } else {
2060 Err(StorageError::NotFound(key.to_string()))
2061 }
2062 }
2063 async fn delete(&self, _: &str) -> Result<(), StorageError> {
2064 Ok(())
2065 }
2066 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2067 Ok(Vec::new())
2068 }
2069 }
2070
2071 async fn body_json(resp: Response) -> (StatusCode, serde_json::Value) {
2072 let status = resp.status();
2073 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
2074 .await
2075 .unwrap();
2076 let value = if bytes.is_empty() {
2077 serde_json::Value::Null
2078 } else {
2079 serde_json::from_slice(&bytes).unwrap()
2080 };
2081 (status, value)
2082 }
2083
2084 let deploy = DeployStore::new(
2085 Arc::new(FakeStorage { present: true }),
2086 Arc::new(MemoryKv::new()),
2087 );
2088 let v1 = "a".repeat(64);
2089 let v2 = "b".repeat(64);
2090
2091 let (st, body) = body_json(
2093 deploy_function(
2094 State(deploy.clone()),
2095 axum::extract::Extension(crate::ProjectContext::default()),
2096 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2097 axum::extract::Query(DeployFunctionQuery::default()),
2098 Path("greeter".to_string()),
2099 Json(FunctionUpsert {
2100 component: v1.clone(),
2101 config: Default::default(),
2102 lifecycle: Lifecycle::Independent,
2103 }),
2104 )
2105 .await,
2106 )
2107 .await;
2108 assert_eq!(st, StatusCode::OK);
2109 assert_eq!(body["active"], v1);
2110
2111 let (_, body) = body_json(
2113 deploy_function(
2114 State(deploy.clone()),
2115 axum::extract::Extension(crate::ProjectContext::default()),
2116 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2117 axum::extract::Query(DeployFunctionQuery::default()),
2118 Path("greeter".to_string()),
2119 Json(FunctionUpsert {
2120 component: v2.clone(),
2121 config: Default::default(),
2122 lifecycle: Lifecycle::Independent,
2123 }),
2124 )
2125 .await,
2126 )
2127 .await;
2128 assert_eq!(body["active"], v2);
2129 assert_eq!(body["versions"].as_array().unwrap().len(), 2);
2130
2131 let (st, body) = body_json(
2133 rollback_function(
2134 State(deploy.clone()),
2135 axum::extract::Extension(crate::ProjectContext::default()),
2136 Path("greeter".to_string()),
2137 Json(RollbackBody { to: v1.clone() }),
2138 )
2139 .await,
2140 )
2141 .await;
2142 assert_eq!(st, StatusCode::OK);
2143 assert_eq!(body["active"], v1);
2144
2145 let resp = rollback_function(
2147 State(deploy.clone()),
2148 axum::extract::Extension(crate::ProjectContext::default()),
2149 Path("greeter".to_string()),
2150 Json(RollbackBody { to: "c".repeat(64) }),
2151 )
2152 .await;
2153 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2154
2155 let (st, body) = body_json(
2157 alias_function(
2158 State(deploy.clone()),
2159 axum::extract::Extension(crate::ProjectContext::default()),
2160 Path(("greeter".to_string(), "prod".to_string())),
2161 Json(AliasBody {
2162 version: v2.clone(),
2163 }),
2164 )
2165 .await,
2166 )
2167 .await;
2168 assert_eq!(st, StatusCode::OK);
2169 assert_eq!(body["aliases"]["prod"], v2);
2170
2171 let (st, _) = body_json(
2173 remove_function(
2174 State(deploy.clone()),
2175 axum::extract::Extension(crate::ProjectContext::default()),
2176 Path("greeter".to_string()),
2177 )
2178 .await,
2179 )
2180 .await;
2181 assert_eq!(st, StatusCode::NO_CONTENT);
2182 assert!(deploy
2183 .get_function(ProjectRef::DEFAULT, "greeter")
2184 .await
2185 .unwrap()
2186 .is_none());
2187
2188 let empty = DeployStore::new(
2190 Arc::new(FakeStorage { present: false }),
2191 Arc::new(MemoryKv::new()),
2192 );
2193 let resp = deploy_function(
2194 State(empty),
2195 axum::extract::Extension(crate::ProjectContext::default()),
2196 axum::extract::Extension(Arc::new(crate::HandlerRuntime::disabled())),
2197 axum::extract::Query(DeployFunctionQuery::default()),
2198 Path("orphan".to_string()),
2199 Json(FunctionUpsert {
2200 component: v1.clone(),
2201 config: Default::default(),
2202 lifecycle: Lifecycle::default(),
2203 }),
2204 )
2205 .await;
2206 assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
2207 }
2208
2209 struct StubControl {
2213 admits: std::sync::Mutex<Vec<(String, String)>>,
2214 respond: StubJoin,
2215 }
2216 #[derive(Clone, Copy)]
2217 enum StubJoin {
2218 Admit,
2219 Spent,
2220 Invalid,
2221 Revoked,
2222 }
2223
2224 #[async_trait::async_trait]
2225 impl MeshControl for StubControl {
2226 async fn admit(
2227 &self,
2228 mesh_pubkey_hex: &str,
2229 jti: &str,
2230 _proof: &[u8],
2231 _proof_iat: u64,
2232 _now: u64,
2233 _advertise_addr: Option<&str>,
2234 ) -> Result<JoinOutcome, String> {
2235 self.admits
2236 .lock()
2237 .unwrap()
2238 .push((mesh_pubkey_hex.to_string(), jti.to_string()));
2239 Ok(match self.respond {
2240 StubJoin::Admit => JoinOutcome::Admitted {
2241 members: vec!["signed-member".to_string()],
2242 addrs: std::collections::BTreeMap::from([(7u64, "https://x:7000".to_string())]),
2243 },
2244 StubJoin::Spent => JoinOutcome::TokenSpent,
2245 StubJoin::Invalid => JoinOutcome::ProofInvalid,
2246 StubJoin::Revoked => JoinOutcome::Revoked,
2247 })
2248 }
2249 async fn rotate_key(&self) -> Result<String, String> {
2250 Ok("cafe".to_string())
2251 }
2252 async fn revoke(&self, _node: u64) -> Result<(), String> {
2253 Ok(())
2254 }
2255 async fn members(&self) -> Result<Vec<MeshMember>, String> {
2256 Ok(Vec::new())
2257 }
2258 async fn promote(&self, _node: u64) -> Result<(), String> {
2259 Ok(())
2260 }
2261 }
2262
2263 #[tokio::test]
2267 async fn cluster_join_dispatches_and_maps_outcomes() {
2268 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2269 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
2270 let auth = Auth::with_key(keys.public_key(), kv);
2271 let token = cose::mint_join(600, now_unix(), &*keys).await.unwrap();
2272 let req = |proof: &str| JoinRequest {
2273 token: token.clone(),
2274 mesh_pubkey: "302a300506032b6570032100feed".into(),
2275 possession_proof: proof.to_string(),
2276 proof_iat: now_unix(),
2277 advertise_addr: Some("https://joiner:7000".into()),
2278 };
2279
2280 let admitter = Arc::new(StubControl {
2282 admits: std::sync::Mutex::new(Vec::new()),
2283 respond: StubJoin::Admit,
2284 });
2285 let resp = cluster_join(
2286 Extension(auth.clone()),
2287 Extension(MeshControlHandle(Some(admitter.clone()))),
2288 Json(req("aa01")),
2289 )
2290 .await;
2291 assert_eq!(resp.status(), StatusCode::OK);
2292 assert_eq!(admitter.admits.lock().unwrap().len(), 1);
2293
2294 let spent = Arc::new(StubControl {
2296 admits: std::sync::Mutex::new(Vec::new()),
2297 respond: StubJoin::Spent,
2298 });
2299 assert_eq!(
2300 cluster_join(
2301 Extension(auth.clone()),
2302 Extension(MeshControlHandle(Some(spent))),
2303 Json(req("aa01")),
2304 )
2305 .await
2306 .status(),
2307 StatusCode::CONFLICT
2308 );
2309 let invalid = Arc::new(StubControl {
2310 admits: std::sync::Mutex::new(Vec::new()),
2311 respond: StubJoin::Invalid,
2312 });
2313 assert_eq!(
2314 cluster_join(
2315 Extension(auth.clone()),
2316 Extension(MeshControlHandle(Some(invalid))),
2317 Json(req("aa01")),
2318 )
2319 .await
2320 .status(),
2321 StatusCode::FORBIDDEN
2322 );
2323 let revoked = Arc::new(StubControl {
2325 admits: std::sync::Mutex::new(Vec::new()),
2326 respond: StubJoin::Revoked,
2327 });
2328 assert_eq!(
2329 cluster_join(
2330 Extension(auth.clone()),
2331 Extension(MeshControlHandle(Some(revoked))),
2332 Json(req("aa01")),
2333 )
2334 .await
2335 .status(),
2336 StatusCode::FORBIDDEN
2337 );
2338
2339 let ok = Arc::new(StubControl {
2341 admits: std::sync::Mutex::new(Vec::new()),
2342 respond: StubJoin::Admit,
2343 });
2344 assert_eq!(
2345 cluster_join(
2346 Extension(auth.clone()),
2347 Extension(MeshControlHandle(Some(ok))),
2348 Json(req("not-hex")),
2349 )
2350 .await
2351 .status(),
2352 StatusCode::BAD_REQUEST
2353 );
2354
2355 let none = cluster_join(
2357 Extension(auth),
2358 Extension(MeshControlHandle(None)),
2359 Json(req("aa01")),
2360 )
2361 .await;
2362 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2363 }
2364
2365 #[tokio::test]
2369 async fn bootstrap_mints_the_first_token_once() {
2370 use axum::http::{header::AUTHORIZATION, HeaderMap, HeaderValue};
2371 let keys: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
2372 let public = keys.public_key();
2373 let deploy = DeployStore::new(
2374 Arc::new(MemStorage::default()),
2375 Arc::new(MemoryKv::new()) as Arc<dyn KvStore>,
2376 );
2377 let secret = "s3cr3t-bootstrap-value";
2378 let gate = BootstrapGate::new(Some(secret));
2379 let issuer = Issuer(Some(keys.clone()));
2380 let bearer = |s: &str| {
2381 let mut h = HeaderMap::new();
2382 h.insert(
2383 AUTHORIZATION,
2384 HeaderValue::from_str(&format!("Bearer {s}")).unwrap(),
2385 );
2386 h
2387 };
2388 let req = || BootstrapRequest {
2389 roles: vec!["admin".to_string()],
2390 ttl_secs: None,
2391 };
2392
2393 let bad = bootstrap_token(
2395 State(deploy.clone()),
2396 Extension(issuer.clone()),
2397 Extension(gate.clone()),
2398 bearer("wrong"),
2399 Json(req()),
2400 )
2401 .await;
2402 assert_eq!(bad.status(), StatusCode::UNAUTHORIZED);
2403
2404 let ok = bootstrap_token(
2406 State(deploy.clone()),
2407 Extension(issuer.clone()),
2408 Extension(gate.clone()),
2409 bearer(secret),
2410 Json(req()),
2411 )
2412 .await;
2413 assert_eq!(ok.status(), StatusCode::CREATED);
2414 let body = axum::body::to_bytes(ok.into_body(), usize::MAX)
2415 .await
2416 .unwrap();
2417 let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
2418 let token = json["token"].as_str().unwrap();
2419 let id = json["id"].as_str().unwrap();
2420 let verified = cose::verify(token, &public, now_unix()).unwrap();
2421 assert!(verified.roles.iter().any(|r| r.name == "admin"));
2422 assert!(deploy
2423 .list_token_meta()
2424 .await
2425 .unwrap()
2426 .iter()
2427 .any(|m| m.revocation_id == id));
2428
2429 let reuse = bootstrap_token(
2431 State(deploy.clone()),
2432 Extension(issuer.clone()),
2433 Extension(gate),
2434 bearer(secret),
2435 Json(req()),
2436 )
2437 .await;
2438 assert_eq!(reuse.status(), StatusCode::CONFLICT);
2439
2440 let disabled = bootstrap_token(
2442 State(deploy),
2443 Extension(issuer),
2444 Extension(BootstrapGate(None)),
2445 bearer(secret),
2446 Json(req()),
2447 )
2448 .await;
2449 assert_eq!(disabled.status(), StatusCode::NOT_IMPLEMENTED);
2450 }
2451
2452 #[tokio::test]
2455 async fn cluster_rotate_key_returns_the_new_pubkey_or_501() {
2456 let control = Arc::new(StubControl {
2457 admits: std::sync::Mutex::new(Vec::new()),
2458 respond: StubJoin::Admit,
2459 });
2460 let resp = cluster_rotate_key(Extension(MeshControlHandle(Some(control)))).await;
2461 assert_eq!(resp.status(), StatusCode::OK);
2462 let body = axum::body::to_bytes(resp.into_body(), usize::MAX)
2463 .await
2464 .unwrap();
2465 let parsed: serde_json::Value = serde_json::from_slice(&body).unwrap();
2466 assert_eq!(parsed["pubkey"].as_str(), Some("cafe"));
2467
2468 let none = cluster_rotate_key(Extension(MeshControlHandle(None))).await;
2469 assert_eq!(none.status(), StatusCode::NOT_IMPLEMENTED);
2470 }
2471
2472 #[test]
2473 fn gateway_addr_gate_refuses_metadata_and_private_per_posture() {
2474 use boatramp_core::security::SecurityProfile;
2475 let strict = SecurityProfile::MultiTenant.preset();
2476 let loose = SecurityProfile::SingleTenant.preset(); let public: IpAddr = "93.184.216.34".parse().unwrap(); let private: IpAddr = "10.1.2.3".parse().unwrap();
2480 let loopback: IpAddr = "127.0.0.1".parse().unwrap();
2481 let metadata: IpAddr = IpAddr::V4(CLOUD_METADATA_IPV4);
2482
2483 assert!(gateway_addr_allowed(public, &strict));
2485 assert!(!gateway_addr_allowed(private, &strict));
2486 assert!(!gateway_addr_allowed(loopback, &strict));
2487 assert!(!gateway_addr_allowed(metadata, &strict));
2488
2489 assert!(gateway_addr_allowed(public, &loose));
2492 assert!(gateway_addr_allowed(private, &loose));
2493 assert!(gateway_addr_allowed(loopback, &loose));
2494 assert!(!gateway_addr_allowed(metadata, &loose));
2495 }
2496
2497 #[tokio::test]
2498 async fn resolve_env_merges_static_and_host_secrets() {
2499 use boatramp_core::config::HandlersSiteConfig;
2500
2501 std::env::set_var("BOATRAMP_TEST_RESOLVE_SECRET", "topsecret");
2503
2504 let deploy_env = std::collections::BTreeMap::from([
2505 ("GREETING".to_string(), "hi".to_string()),
2506 ("OVERRIDE_ME".to_string(), "static".to_string()),
2507 ]);
2508 let site_handlers = HandlersSiteConfig {
2509 enabled: true,
2510 secrets: std::collections::BTreeMap::from([
2511 (
2513 "SECRET_TOKEN".to_string(),
2514 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2515 ),
2516 (
2517 "OVERRIDE_ME".to_string(),
2518 "BOATRAMP_TEST_RESOLVE_SECRET".to_string(),
2519 ),
2520 (
2521 "MISSING".to_string(),
2522 "BOATRAMP_TEST_NOT_SET_VAR".to_string(),
2523 ),
2524 ]),
2525 ..Default::default()
2526 };
2527 let env = resolve_env(
2530 "blog",
2531 boatramp_core::project::ProjectRef::DEFAULT,
2532 &deploy_env,
2533 &site_handlers,
2534 true,
2535 None,
2536 )
2537 .await
2538 .expect("resolves");
2539
2540 assert!(env.contains(&("GREETING".to_string(), "hi".to_string())));
2544 assert!(env.contains(&("SECRET_TOKEN".to_string(), "topsecret".to_string())));
2545 assert!(env.contains(&("OVERRIDE_ME".to_string(), "topsecret".to_string())));
2546 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2547
2548 std::env::remove_var("BOATRAMP_TEST_RESOLVE_SECRET");
2549 }
2550
2551 #[tokio::test]
2552 async fn multi_tenant_posture_refuses_a_host_env_handler_secret() {
2553 use boatramp_core::config::HandlersSiteConfig;
2554
2555 std::env::set_var("BOATRAMP_TEST_OTHER_TENANT_SECRET", "leak-me");
2560 let deploy_env = std::collections::BTreeMap::new();
2561 let bare = HandlersSiteConfig {
2562 enabled: true,
2563 secrets: std::collections::BTreeMap::from([(
2564 "STOLEN".to_string(),
2565 "BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2566 )]),
2567 ..Default::default()
2568 };
2569 let err = resolve_env(
2570 "evil",
2571 boatramp_core::project::ProjectRef::DEFAULT,
2572 &deploy_env,
2573 &bare,
2574 false,
2575 None,
2576 )
2577 .await
2578 .expect_err("multi-tenant must refuse a bare host-env ref");
2579 assert!(
2580 err.contains("STOLEN"),
2581 "error names the offending guest var: {err}"
2582 );
2583 assert!(
2584 err.contains("multi-tenant"),
2585 "error steers the tenant: {err}"
2586 );
2587 assert!(
2588 !err.contains("leak-me"),
2589 "the host value must never appear (never read): {err}"
2590 );
2591
2592 let explicit = HandlersSiteConfig {
2594 enabled: true,
2595 secrets: std::collections::BTreeMap::from([(
2596 "STOLEN".to_string(),
2597 "env:BOATRAMP_TEST_OTHER_TENANT_SECRET".to_string(),
2598 )]),
2599 ..Default::default()
2600 };
2601 assert!(resolve_env(
2602 "evil",
2603 boatramp_core::project::ProjectRef::DEFAULT,
2604 &deploy_env,
2605 &explicit,
2606 false,
2607 None,
2608 )
2609 .await
2610 .is_err());
2611
2612 let reserved = HandlersSiteConfig {
2614 enabled: true,
2615 secrets: std::collections::BTreeMap::from([(
2616 "TOKEN".to_string(),
2617 "vault:kv/data/app#token".to_string(),
2618 )]),
2619 ..Default::default()
2620 };
2621 let err = resolve_env(
2622 "evil",
2623 boatramp_core::project::ProjectRef::DEFAULT,
2624 &deploy_env,
2625 &reserved,
2626 true,
2627 None,
2628 )
2629 .await
2630 .expect_err("a reserved scheme is not yet supported, even under single-tenant");
2631 assert!(err.contains("not yet supported"), "{err}");
2632
2633 let arbitrary = HandlersSiteConfig {
2637 enabled: true,
2638 secrets: std::collections::BTreeMap::from([(
2639 "KEY".to_string(),
2640 "aws:sm/prod/apikey".to_string(),
2641 )]),
2642 ..Default::default()
2643 };
2644 let err = resolve_env(
2645 "evil",
2646 boatramp_core::project::ProjectRef::DEFAULT,
2647 &deploy_env,
2648 &arbitrary,
2649 true,
2650 None,
2651 )
2652 .await
2653 .expect_err("any unknown scheme is reserved, even under single-tenant");
2654 assert!(
2655 err.contains("not yet supported") && err.contains("aws"),
2656 "provider-neutral reservation names the scheme: {err}"
2657 );
2658
2659 std::env::remove_var("BOATRAMP_TEST_OTHER_TENANT_SECRET");
2660 }
2661
2662 #[tokio::test]
2663 async fn function_resolve_secret_env_reads_host_and_matches_handler_semantics() {
2664 std::env::set_var("BOATRAMP_TEST_FN_SECRET", "fnsecret");
2669
2670 let static_env = std::collections::BTreeMap::from([
2671 ("STAGE".to_string(), "prod".to_string()),
2672 ("OVERRIDE_ME".to_string(), "static".to_string()),
2673 ]);
2674 let secrets = std::collections::BTreeMap::from([
2675 ("DB_URL".to_string(), "BOATRAMP_TEST_FN_SECRET".to_string()),
2677 (
2679 "OVERRIDE_ME".to_string(),
2680 "BOATRAMP_TEST_FN_SECRET".to_string(),
2681 ),
2682 (
2684 "MISSING".to_string(),
2685 "BOATRAMP_TEST_FN_NOT_SET".to_string(),
2686 ),
2687 ]);
2688 let env = resolve_secret_env(
2690 "fn/api",
2691 boatramp_core::project::ProjectRef::DEFAULT,
2692 &static_env,
2693 &secrets,
2694 true,
2695 None,
2696 )
2697 .await
2698 .expect("resolves");
2699
2700 assert!(env.contains(&("STAGE".to_string(), "prod".to_string())));
2701 assert!(env.contains(&("DB_URL".to_string(), "fnsecret".to_string())));
2703 assert!(env.contains(&("OVERRIDE_ME".to_string(), "fnsecret".to_string())));
2705 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2707
2708 std::env::remove_var("BOATRAMP_TEST_FN_SECRET");
2709 }
2710
2711 #[tokio::test]
2712 async fn multi_tenant_posture_refuses_a_host_env_function_secret() {
2713 std::env::set_var("BOATRAMP_TEST_FN_LEAK", "leak-me");
2718 let static_env = std::collections::BTreeMap::new();
2719 let secrets = std::collections::BTreeMap::from([(
2720 "DB_URL".to_string(),
2721 "BOATRAMP_TEST_FN_LEAK".to_string(),
2722 )]);
2723
2724 let err = resolve_secret_env(
2726 "fn/api",
2727 boatramp_core::project::ProjectRef::DEFAULT,
2728 &static_env,
2729 &secrets,
2730 false,
2731 None,
2732 )
2733 .await
2734 .expect_err("multi-tenant must refuse a function host-env ref");
2735 assert!(
2736 err.contains("DB_URL"),
2737 "error names the offending guest var: {err}"
2738 );
2739 assert!(
2740 !err.contains("leak-me"),
2741 "host value must never appear: {err}"
2742 );
2743
2744 let env = resolve_secret_env(
2746 "fn/api",
2747 boatramp_core::project::ProjectRef::DEFAULT,
2748 &static_env,
2749 &secrets,
2750 true,
2751 None,
2752 )
2753 .await
2754 .expect("resolves");
2755 assert!(env.contains(&("DB_URL".to_string(), "leak-me".to_string())));
2756
2757 std::env::remove_var("BOATRAMP_TEST_FN_LEAK");
2758 }
2759
2760 #[tokio::test]
2761 async fn boatramp_scheme_resolves_from_the_project_scoped_store() {
2762 use boatramp_core::project::ProjectRef;
2763 use boatramp_core::secret_store::SecretStore;
2764 use std::sync::Arc;
2765
2766 struct XorEnvelope;
2768 #[async_trait::async_trait]
2769 impl boatramp_core::envelope::KeyEnvelope for XorEnvelope {
2770 async fn wrap(
2771 &self,
2772 p: &[u8],
2773 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2774 Ok(p.iter().map(|b| b ^ 0x5a).collect())
2775 }
2776 async fn unwrap(
2777 &self,
2778 c: &[u8],
2779 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
2780 Ok(c.iter().map(|b| b ^ 0x5a).collect())
2781 }
2782 }
2783
2784 let store = SecretStore::new(
2785 Arc::new(boatramp_core::kv::MemoryKv::new()),
2786 Arc::new(XorEnvelope),
2787 );
2788 store
2789 .set(ProjectRef::new("acme"), "api-key", b"s3cr3t")
2790 .await
2791 .unwrap();
2792
2793 let static_env = std::collections::BTreeMap::new();
2794 let secrets = std::collections::BTreeMap::from([
2795 ("API_KEY".to_string(), "boatramp:api-key".to_string()),
2796 ("MISSING".to_string(), "boatramp:not-set".to_string()),
2797 ]);
2798
2799 let env = resolve_secret_env(
2802 "site",
2803 ProjectRef::new("acme"),
2804 &static_env,
2805 &secrets,
2806 false,
2807 Some(&store),
2808 )
2809 .await
2810 .expect("boatramp refs resolve without the host-env gate");
2811 assert!(env.contains(&("API_KEY".to_string(), "s3cr3t".to_string())));
2812 assert!(!env.iter().any(|(k, _)| k == "MISSING"));
2814
2815 let other_secrets = std::collections::BTreeMap::from([(
2817 "API_KEY".to_string(),
2818 "boatramp:api-key".to_string(),
2819 )]);
2820 let other = resolve_secret_env(
2821 "site",
2822 ProjectRef::new("globex"),
2823 &static_env,
2824 &other_secrets,
2825 false,
2826 Some(&store),
2827 )
2828 .await
2829 .expect("resolves (a foreign project's secret is simply absent → skipped)");
2830 assert!(
2831 !other.iter().any(|(k, _)| k == "API_KEY"),
2832 "a tenant must not read another project's secret"
2833 );
2834
2835 let err = resolve_secret_env(
2837 "site",
2838 ProjectRef::new("acme"),
2839 &static_env,
2840 &other_secrets,
2841 false,
2842 None,
2843 )
2844 .await
2845 .expect_err("no store configured must fail closed");
2846 assert!(err.contains("no internal secret store"), "{err}");
2847 }
2848
2849 fn req() -> Request {
2850 Request::builder()
2851 .uri("/")
2852 .header(header::HOST, "example.com")
2853 .body(Body::empty())
2854 .unwrap()
2855 }
2856
2857 #[test]
2858 fn forwarded_headers_set_standard_triple() {
2859 let mut request = req();
2860 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2861 let h = request.headers();
2862 assert_eq!(h.get("x-forwarded-for").unwrap(), "203.0.113.7");
2863 assert_eq!(h.get("x-forwarded-host").unwrap(), "example.com");
2864 assert_eq!(h.get("x-forwarded-proto").unwrap(), "http");
2865 }
2866
2867 #[test]
2868 fn forwarded_for_overwrites_spoofed_value() {
2869 let mut request = Request::builder()
2872 .uri("/")
2873 .header(header::HOST, "example.com")
2874 .header("x-forwarded-for", "10.0.0.1, 1.2.3.4")
2875 .body(Body::empty())
2876 .unwrap();
2877 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2878 let values: Vec<_> = request
2879 .headers()
2880 .get_all("x-forwarded-for")
2881 .iter()
2882 .collect();
2883 assert_eq!(values.len(), 1);
2884 assert_eq!(values[0], "203.0.113.7");
2885 }
2886
2887 #[test]
2888 fn forwarded_proto_preserves_upstream_tls() {
2889 let mut request = Request::builder()
2891 .uri("/")
2892 .header(header::HOST, "example.com")
2893 .header("x-forwarded-proto", "https")
2894 .body(Body::empty())
2895 .unwrap();
2896 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2897 assert_eq!(request.headers().get("x-forwarded-proto").unwrap(), "https");
2898 }
2899
2900 #[test]
2901 fn forwarded_host_absent_when_no_host_header() {
2902 let mut request = Request::builder().uri("/").body(Body::empty()).unwrap();
2903 set_forwarded_headers(&mut request, "203.0.113.7".parse().unwrap());
2904 assert!(request.headers().get("x-forwarded-host").is_none());
2905 assert_eq!(
2906 request.headers().get("x-forwarded-for").unwrap(),
2907 "203.0.113.7"
2908 );
2909 }
2910
2911 use boatramp_core::kv::{KvStore, MemoryKv};
2914 use boatramp_core::messaging::{LogMessaging, Messaging};
2915 use boatramp_core::{ByteStream, GetObject, ObjectMeta, PutMeta, StorageError};
2916
2917 const EVENT_CONSUMER: &[u8] =
2918 include_bytes!("../../boatramp-handlers/tests/fixtures/event-consumer.wasm");
2919
2920 #[derive(Default)]
2921 struct MemStorage {
2922 objects: std::sync::Mutex<std::collections::HashMap<String, Vec<u8>>>,
2923 }
2924
2925 #[async_trait::async_trait]
2926 impl boatramp_core::Storage for MemStorage {
2927 async fn get(&self, key: &str) -> Result<GetObject, StorageError> {
2928 let bytes = self
2929 .objects
2930 .lock()
2931 .unwrap()
2932 .get(key)
2933 .cloned()
2934 .ok_or_else(|| StorageError::NotFound(key.to_string()))?;
2935 let body: ByteStream =
2936 futures::stream::once(async move { Ok(bytes::Bytes::from(bytes)) }).boxed();
2937 Ok(GetObject {
2938 meta: ObjectMeta {
2939 key: key.to_string(),
2940 ..Default::default()
2941 },
2942 body,
2943 })
2944 }
2945 async fn get_range(
2946 &self,
2947 key: &str,
2948 _: u64,
2949 _: Option<u64>,
2950 ) -> Result<GetObject, StorageError> {
2951 self.get(key).await
2952 }
2953 async fn put(
2954 &self,
2955 key: &str,
2956 mut body: ByteStream,
2957 _: PutMeta,
2958 ) -> Result<ObjectMeta, StorageError> {
2959 use futures::StreamExt;
2960 let mut buf = Vec::new();
2961 while let Some(chunk) = body.next().await {
2962 buf.extend_from_slice(&chunk?);
2963 }
2964 self.objects.lock().unwrap().insert(key.to_string(), buf);
2965 Ok(ObjectMeta {
2966 key: key.to_string(),
2967 ..Default::default()
2968 })
2969 }
2970 async fn head(&self, key: &str) -> Result<ObjectMeta, StorageError> {
2971 self.objects
2972 .lock()
2973 .unwrap()
2974 .get(key)
2975 .map(|_| ObjectMeta {
2976 key: key.to_string(),
2977 ..Default::default()
2978 })
2979 .ok_or_else(|| StorageError::NotFound(key.to_string()))
2980 }
2981 async fn delete(&self, key: &str) -> Result<(), StorageError> {
2982 self.objects.lock().unwrap().remove(key);
2983 Ok(())
2984 }
2985 async fn list(&self, _: &str) -> Result<Vec<ObjectMeta>, StorageError> {
2986 Ok(Vec::new())
2987 }
2988 }
2989
2990 fn observed_state_in(
2993 project: &str,
2994 workload: &str,
2995 host: &str,
2996 healthy: bool,
2997 phase: boatramp_core::compute::ReplicaPhase,
2998 ) -> boatramp_core::compute::ObservedInstance {
2999 use boatramp_core::compute::{Endpoint, InstanceHandle, ReplicaPhase, Scheme, Snapshot};
3000 boatramp_core::compute::ObservedInstance {
3001 handle: InstanceHandle {
3002 project: project.into(),
3003 workload: workload.into(),
3004 replica: 0,
3005 backend_ref: "ref-0".into(),
3006 },
3007 node: 1,
3008 backend: "vmm".into(),
3009 endpoint: Endpoint {
3010 scheme: Scheme::Http,
3011 host: host.into(),
3012 port: 80,
3013 },
3014 region: None,
3015 healthy,
3016 started_at: None,
3017 phase,
3018 snapshot: matches!(phase, ReplicaPhase::Zero).then(|| Snapshot {
3019 project: project.into(),
3020 workload: workload.into(),
3021 replica: 0,
3022 data_ref: "snap-0".into(),
3023 }),
3024 }
3025 }
3026
3027 fn observed_state(
3029 workload: &str,
3030 healthy: bool,
3031 phase: boatramp_core::compute::ReplicaPhase,
3032 ) -> boatramp_core::compute::ObservedInstance {
3033 observed_state_in("default", workload, "10.0.0.2", healthy, phase)
3034 }
3035
3036 #[tokio::test]
3037 async fn has_parked_replica_detects_a_zeroed_replica() {
3038 use boatramp_core::compute::ReplicaPhase;
3039 let storage = Arc::new(MemStorage::default());
3040 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3041 let deploy = DeployStore::new(storage, kv);
3042
3043 assert!(!has_parked_replica(&deploy, "default", "w").await);
3045 deploy
3047 .set_replica_state(
3048 ProjectRef::DEFAULT,
3049 &observed_state("w", true, ReplicaPhase::Running),
3050 )
3051 .await
3052 .unwrap();
3053 assert!(!has_parked_replica(&deploy, "default", "w").await);
3054 deploy
3056 .set_replica_state(
3057 ProjectRef::DEFAULT,
3058 &observed_state("w", false, ReplicaPhase::Zero),
3059 )
3060 .await
3061 .unwrap();
3062 assert!(has_parked_replica(&deploy, "default", "w").await);
3063 }
3064
3065 #[tokio::test]
3066 async fn await_warm_returns_immediately_when_healthy_and_times_out_otherwise() {
3067 use boatramp_core::compute::ReplicaPhase;
3068 let storage = Arc::new(MemStorage::default());
3069 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3070 let deploy = DeployStore::new(storage, kv);
3071
3072 let empty = await_warm(
3074 &deploy,
3075 "default",
3076 "w",
3077 std::time::Duration::from_millis(150),
3078 )
3079 .await;
3080 assert!(empty.is_empty());
3081
3082 deploy
3084 .set_replica_state(
3085 ProjectRef::DEFAULT,
3086 &observed_state("w", true, ReplicaPhase::Running),
3087 )
3088 .await
3089 .unwrap();
3090 let warm = await_warm(&deploy, "default", "w", std::time::Duration::from_secs(5)).await;
3091 assert_eq!(warm, vec!["http://10.0.0.2:80".to_string()]);
3092 }
3093
3094 #[tokio::test]
3100 async fn compute_endpoints_are_project_scoped() {
3101 use boatramp_core::compute::ReplicaPhase;
3102 let storage = Arc::new(MemStorage::default());
3103 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3104 let deploy = DeployStore::new(storage, kv);
3105
3106 deploy
3108 .set_replica_state(
3109 ProjectRef::new("acme"),
3110 &observed_state_in("acme", "web", "10.0.0.5", true, ReplicaPhase::Running),
3111 )
3112 .await
3113 .unwrap();
3114 deploy
3115 .set_replica_state(
3116 ProjectRef::DEFAULT,
3117 &observed_state_in("default", "web", "10.0.0.9", true, ReplicaPhase::Running),
3118 )
3119 .await
3120 .unwrap();
3121
3122 assert_eq!(
3124 compute_endpoints(&deploy, "acme", "web").await,
3125 vec!["http://10.0.0.5:80".to_string()],
3126 "acme's web resolves against acme, not default"
3127 );
3128 assert_eq!(
3130 compute_endpoints(&deploy, "default", "web").await,
3131 vec!["http://10.0.0.9:80".to_string()]
3132 );
3133 assert!(compute_endpoints(&deploy, "beta", "web").await.is_empty());
3136 }
3137
3138 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3142 async fn dispatcher_delivers_at_least_once_then_dead_letters() {
3143 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3144 let storage = Arc::new(MemStorage::default());
3145 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3146 let mq = LogMessaging::new(storage, kv.clone());
3147 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3148 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3149 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3150 let topic = "blog/orders/created";
3151
3152 for _ in 0..3 {
3154 mq.publish(topic, b"ok").await.unwrap();
3155 }
3156 loop {
3157 let acked = dispatch_consumer_batch(
3158 &engine,
3159 &mq,
3160 &metrics::Metrics::default(),
3161 "blog",
3162 topic,
3163 "blog/",
3164 "",
3165 boatramp_core::messaging::StartPosition::Latest,
3166 &hash,
3167 EVENT_CONSUMER,
3168 &bindings,
3169 None,
3171 Limits::default(),
3172 Duration::from_secs(30),
3173 5,
3174 10,
3175 )
3176 .await;
3177 if acked == 0 {
3178 break;
3179 }
3180 }
3181 assert_eq!(
3182 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3183 Some(b"3".to_vec())
3184 );
3185
3186 mq.publish(topic, b"fail").await.unwrap();
3189 for _ in 0..5 {
3190 dispatch_consumer_batch(
3191 &engine,
3192 &mq,
3193 &metrics::Metrics::default(),
3194 "blog",
3195 topic,
3196 "blog/",
3197 "",
3198 boatramp_core::messaging::StartPosition::Latest,
3199 &hash,
3200 EVENT_CONSUMER,
3201 &bindings,
3202 None,
3204 Limits::default(),
3205 Duration::ZERO,
3206 2,
3207 10,
3208 )
3209 .await;
3210 }
3211 assert_eq!(mq.dead_letter_count(topic).await.unwrap(), 1);
3212 assert_eq!(
3214 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3215 Some(b"3".to_vec())
3216 );
3217 }
3218
3219 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3224 async fn consumer_groups_fan_out_through_the_dispatcher() {
3225 use boatramp_handlers::{Bindings, HandlerEngine, Limits};
3226 let storage = Arc::new(MemStorage::default());
3227 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3228 let mq = LogMessaging::new(storage, kv.clone());
3229 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3230 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3231 let bindings = Bindings::new("blog").with_keyvalue("blog", kv.clone());
3232 let topic = "blog/orders/created";
3233 let start = boatramp_core::messaging::StartPosition::Latest;
3234
3235 for g in ["billing", "audit"] {
3238 let n = dispatch_consumer_batch(
3239 &engine,
3240 &mq,
3241 &metrics::Metrics::default(),
3242 "blog",
3243 topic,
3244 "blog/",
3245 g,
3246 start,
3247 &hash,
3248 EVENT_CONSUMER,
3249 &bindings,
3250 None,
3252 Limits::default(),
3253 Duration::from_secs(30),
3254 5,
3255 10,
3256 )
3257 .await;
3258 assert_eq!(n, 0, "no events yet for group {g}");
3259 }
3260 mq.publish(topic, b"ok").await.unwrap();
3261
3262 for g in ["billing", "audit"] {
3264 let n = dispatch_consumer_batch(
3265 &engine,
3266 &mq,
3267 &metrics::Metrics::default(),
3268 "blog",
3269 topic,
3270 "blog/",
3271 g,
3272 start,
3273 &hash,
3274 EVENT_CONSUMER,
3275 &bindings,
3276 None,
3278 Limits::default(),
3279 Duration::from_secs(30),
3280 5,
3281 10,
3282 )
3283 .await;
3284 assert_eq!(n, 1, "group {g} should receive the message");
3285 }
3286 assert_eq!(
3288 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3289 Some(b"2".to_vec())
3290 );
3291 }
3292
3293 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3297 async fn scheduler_runs_current_consumers_not_previews() {
3298 use boatramp_core::config::{ConsumerConfig, DeployConfig, HandlersSiteConfig, SiteConfig};
3299 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
3300 use boatramp_handlers::{HandlerEngine, Limits};
3301 use futures::StreamExt;
3302
3303 let storage = Arc::new(MemStorage::default());
3304 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3305 let deploy = DeployStore::new(storage.clone(), kv.clone());
3306 let messaging: Arc<dyn Messaging> =
3307 Arc::new(LogMessaging::new(storage.clone(), kv.clone()));
3308
3309 let hash = boatramp_core::deploy::sha256_hex(EVENT_CONSUMER);
3311 let stream: ByteStream =
3312 futures::stream::once(async move { Ok(bytes::Bytes::from_static(EVENT_CONSUMER)) })
3313 .boxed();
3314 deploy.put_blob(&hash, stream).await.unwrap();
3315 let mut files = std::collections::BTreeMap::new();
3316 files.insert(
3317 "consumer.wasm".to_string(),
3318 FileEntry {
3319 hash: hash.clone(),
3320 size: EVENT_CONSUMER.len() as u64,
3321 content_type: None,
3322 variants: std::collections::BTreeMap::new(),
3323 },
3324 );
3325 let manifest = Manifest {
3326 files,
3327 config: DeployConfig {
3328 consumers: vec![ConsumerConfig {
3329 tenancy: None,
3330 token_claims: None,
3331 topic: "orders/created".into(),
3332 component: "consumer.wasm".into(),
3333 imports: vec!["wasi:keyvalue".into()],
3334 group: String::new(),
3335 start: Default::default(),
3336 }],
3337 ..Default::default()
3338 },
3339 ..Default::default()
3340 };
3341 let id = deploy.put_manifest(&manifest).await.unwrap();
3342 deploy
3343 .activate(ProjectRef::DEFAULT, "blog", &id)
3344 .await
3345 .unwrap();
3346 deploy
3347 .set_site_config(
3348 ProjectRef::DEFAULT,
3349 "blog",
3350 &SiteConfig {
3351 handlers: Some(HandlersSiteConfig {
3352 enabled: true,
3353 allow_imports: vec!["wasi:keyvalue".into()],
3354 ..Default::default()
3355 }),
3356 ..Default::default()
3357 },
3358 )
3359 .await
3360 .unwrap();
3361
3362 messaging
3364 .publish("blog/orders/created", b"live")
3365 .await
3366 .unwrap();
3367 messaging
3368 .publish("blog/_preview/abc/orders/created", b"preview")
3369 .await
3370 .unwrap();
3371
3372 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3373 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, Some(messaging));
3374 let inner = rt.inner.clone().unwrap();
3375 let mut cache = std::collections::HashMap::new();
3376 let mut crons = std::collections::HashMap::new();
3377 let mut sweep = std::collections::HashMap::new();
3378 let now = CronNow {
3379 minute: 0,
3380 hour: 0,
3381 dom: 1,
3382 month: 1,
3383 dow: 0,
3384 minute_stamp: 0,
3385 };
3386 for _ in 0..3 {
3387 run_scheduler_tick(&inner, &deploy, &mut cache, &mut crons, &mut sweep, now)
3388 .await
3389 .unwrap();
3390 }
3391
3392 assert_eq!(
3394 kv.get("hkv/blog/delivered/orders/created").await.unwrap(),
3395 Some(b"1".to_vec())
3396 );
3397 assert_eq!(
3400 kv.get("hkv/blog/_preview/abc/delivered/orders/created")
3401 .await
3402 .unwrap(),
3403 None
3404 );
3405 }
3406
3407 const KV_COUNTER: &[u8] =
3412 include_bytes!("../../boatramp-handlers/tests/fixtures/kv-counter.wasm");
3413
3414 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3419 async fn function_invoker_runs_target_buffers_and_meters() {
3420 use boatramp_core::deploy::DeployStore;
3421 use boatramp_core::function::{Function, FunctionVersion, Lifecycle, Owner};
3422 use boatramp_handlers::{HandlerEngine, InvokeError, InvokeRequest, Invoker, Limits};
3423 use futures::StreamExt;
3424
3425 const HTTP_200: &[u8] =
3428 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
3429
3430 let storage = Arc::new(MemStorage::default());
3431 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3432 let deploy = DeployStore::new(storage.clone(), kv.clone());
3433
3434 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
3435 let stream: ByteStream =
3436 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
3437 deploy.put_blob(&hash, stream).await.unwrap();
3438 let function = Function {
3439 name: "target".into(),
3440 owner: Owner::Project("default".into()),
3441 versions: vec![FunctionVersion {
3442 id: "v1".into(),
3443 component: hash.clone(),
3444 created: 0,
3445 lifecycle: Lifecycle::Independent,
3446 }],
3447 active: "v1".into(),
3448 aliases: Default::default(),
3449 config: Default::default(),
3450 };
3451 deploy
3452 .put_function(ProjectRef::DEFAULT, &function)
3453 .await
3454 .unwrap();
3455
3456 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3457 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
3458 rt.set_invoker(deploy.clone());
3459 let invoker = rt.inner.as_ref().unwrap().invoker.get().unwrap().clone();
3460
3461 let request = || InvokeRequest {
3462 method: "GET".into(),
3463 path: "/".into(),
3464 headers: vec![],
3465 body: vec![],
3466 };
3467
3468 let response = invoker.invoke("target", request(), 1).await.unwrap();
3470 assert_eq!(response.status, 200);
3471
3472 let metering = deploy
3474 .get_metering(ProjectRef::DEFAULT, "target")
3475 .await
3476 .unwrap()
3477 .unwrap();
3478 assert_eq!(metering.invocations, 1);
3479
3480 let err = invoker.invoke("ghost", request(), 1).await.unwrap_err();
3482 assert!(matches!(err, InvokeError::NotFound));
3483 }
3484
3485 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3491 async fn federation_runner_enforces_the_safelist_before_planning() {
3492 use boatramp_core::deploy::DeployStore;
3493 use boatramp_core::project::ProjectRef;
3494 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, SupergraphRunError};
3495
3496 let storage = Arc::new(MemStorage::default());
3497 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3498 let deploy = DeployStore::new(storage.clone(), kv.clone());
3499 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3500 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
3501 rt.set_invoker(deploy.clone());
3502 let runner = rt
3503 .inner
3504 .as_ref()
3505 .unwrap()
3506 .federation_runner
3507 .get()
3508 .unwrap()
3509 .scoped(ProjectRef::new("default"), Vec::new());
3510
3511 let req = |query: &str| GraphqlRequest {
3512 query: Some(query.to_string()),
3513 persisted_hash: None,
3514 variables: "{}".to_string(),
3515 operation_name: None,
3516 authorization: None,
3517 };
3518
3519 assert!(matches!(
3521 runner.run(req("{ me { id } }"), 1).await,
3522 Err(SupergraphRunError::NotSafelisted)
3523 ));
3524
3525 let query = "{ me { id } }";
3528 let hash = crate::graphql_apq::sha256_hex(query);
3529 kv.put(&format!("hapq/default/{hash}"), query.as_bytes().to_vec())
3530 .await
3531 .unwrap();
3532 assert!(matches!(
3533 runner.run(req(query), 1).await,
3534 Err(SupergraphRunError::PlanFailed(_))
3535 ));
3536
3537 let persisted = GraphqlRequest {
3539 query: None,
3540 persisted_hash: Some("deadbeef".to_string()),
3541 variables: "{}".to_string(),
3542 operation_name: None,
3543 authorization: None,
3544 };
3545 assert!(matches!(
3546 runner.run(persisted, 1).await,
3547 Err(SupergraphRunError::NotSafelisted)
3548 ));
3549 }
3550
3551 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3560 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI graphql-propagation gate"]
3561 async fn graphql_run_propagates_caller_principal_to_a_scoped_subfetch() {
3562 use boatramp_core::deploy::{sha256_hex, DeployStore};
3563 use boatramp_core::function::{
3564 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3565 };
3566 use boatramp_core::project::ProjectRef;
3567 use boatramp_core::sql::{SqlBackends, SqlValue};
3568 use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3569 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, ScopeFact};
3570
3571 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3575
3576 let storage = Arc::new(MemStorage::default());
3577 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3578 let deploy = DeployStore::new(storage.clone(), kv.clone());
3579
3580 let sql_dir = std::env::temp_dir().join(format!("br-gqlprop-{}", std::process::id()));
3583 let _ = std::fs::remove_dir_all(&sql_dir);
3584 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3585 let db = backends
3586 .database("default", "fn/scopeprobe", "")
3587 .await
3588 .unwrap();
3589 {
3590 let mut tx = db.begin().await.unwrap();
3591 tx.execute(
3592 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3593 &[],
3594 )
3595 .await
3596 .unwrap();
3597 for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3598 tx.execute(
3599 "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3600 &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3601 )
3602 .await
3603 .unwrap();
3604 }
3605 tx.commit().await.unwrap();
3606 }
3607 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3608
3609 let hash = sha256_hex(PROBE);
3612 let stream: ByteStream =
3613 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3614 deploy.put_blob(&hash, stream).await.unwrap();
3615 let function = Function {
3616 name: "scopeprobe".into(),
3617 owner: Owner::Project("default".into()),
3618 versions: vec![FunctionVersion {
3619 id: "v1".into(),
3620 component: hash.clone(),
3621 created: 0,
3622 lifecycle: Lifecycle::Independent,
3623 }],
3624 active: "v1".into(),
3625 aliases: Default::default(),
3626 config: FunctionConfig {
3627 imports: vec!["sql".into()],
3628 tenancy: Some(Tenancy::Scoped {
3629 column: "tenant_id".into(),
3630 sources: vec![TenantSource::None],
3631 read: AccessMode::Own,
3632 write: AccessMode::None,
3633 }),
3634 ..Default::default()
3635 },
3636 };
3637 deploy
3638 .put_function(ProjectRef::DEFAULT, &function)
3639 .await
3640 .unwrap();
3641
3642 crate::graphql_registry::publish(
3644 kv.as_ref(),
3645 "default",
3646 "scopeprobe",
3647 "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }",
3648 )
3649 .await
3650 .unwrap();
3651 let query = "{ items { id } }";
3652 let op_hash = crate::graphql_apq::sha256_hex(query);
3653 kv.put(
3654 &format!("hapq/default/{op_hash}"),
3655 query.as_bytes().to_vec(),
3656 )
3657 .await
3658 .unwrap();
3659
3660 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3661 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3662 rt.set_invoker(deploy.clone());
3663 let fed = rt
3664 .inner
3665 .as_ref()
3666 .unwrap()
3667 .federation_runner
3668 .get()
3669 .unwrap()
3670 .clone();
3671 let req = || GraphqlRequest {
3672 query: Some(query.to_string()),
3673 persisted_hash: None,
3674 variables: "{}".to_string(),
3675 operation_name: None,
3676 authorization: None,
3677 };
3678
3679 let runner_b = fed.scoped(
3681 ProjectRef::new("default"),
3682 vec![ScopeFact {
3683 axis: ScopeAxis::Tenant,
3684 value: SqlValue::Text("tenant_B".into()),
3685 }],
3686 );
3687 let body_b = String::from_utf8_lossy(&runner_b.run(req(), 0).await.unwrap()).into_owned();
3688 assert!(
3689 body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
3690 "graphql::run propagated principal B → the subgraph read ONLY tenant B's rows: {body_b}"
3691 );
3692
3693 let runner_empty = fed.scoped(ProjectRef::new("default"), Vec::new());
3696 let body_none =
3697 String::from_utf8_lossy(&runner_empty.run(req(), 0).await.unwrap()).into_owned();
3698 assert!(
3699 !body_none.contains("\"a1\"")
3700 && !body_none.contains("\"b1\"")
3701 && !body_none.contains("\"b2\""),
3702 "with NO propagated principal the subgraph fails closed — no rows leak: {body_none}"
3703 );
3704
3705 let _ = std::fs::remove_dir_all(&sql_dir);
3706 println!(
3707 "GRAPHQL-RUN PRINCIPAL PROPAGATION OK: graphql::run carried the caller's resolved principal \
3708 (tenant B) to a federated subgraph sub-fetch, which resolved its own-tenancy from the \
3709 inherited principal and returned ONLY tenant B's rows over a real libsql engine; with no \
3710 principal the same sub-fetch failed closed (no rows) — the async lane can drive a \
3711 tenant-scoped supergraph read/write with no request bearer, symmetric to emit::invoke"
3712 );
3713 }
3714
3715 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3724 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
3725 async fn gateway_forces_target_confinement_onto_a_wasm_subgraph() {
3726 use boatramp_core::deploy::{sha256_hex, DeployStore};
3727 use boatramp_core::function::{
3728 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3729 };
3730 use boatramp_core::project::ProjectRef;
3731 use boatramp_core::sql::{SqlBackends, SqlValue};
3732 use boatramp_core::tenancy::{
3733 AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
3734 TenancySchema, TenantSource,
3735 };
3736 use boatramp_handlers::{HandlerEngine, Limits};
3737
3738 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3744
3745 let storage = Arc::new(MemStorage::default());
3746 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3747 let deploy = DeployStore::new(storage.clone(), kv.clone());
3748
3749 let sql_dir = std::env::temp_dir().join(format!("br-gwtarget-{}", std::process::id()));
3752 let _ = std::fs::remove_dir_all(&sql_dir);
3753 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3754 let db = backends
3755 .database("default", "fn/scopeprobe", "")
3756 .await
3757 .unwrap();
3758 {
3759 let mut tx = db.begin().await.unwrap();
3760 tx.execute(
3761 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
3762 &[],
3763 )
3764 .await
3765 .unwrap();
3766 for (id, tenant, published) in [
3767 ("a_pub", "tenant_A", 1),
3768 ("b_pub", "tenant_B", 1),
3769 ("b_priv", "tenant_B", 0),
3770 ] {
3771 tx.execute(
3772 "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
3773 &[
3774 SqlValue::Text(id.into()),
3775 SqlValue::Text(tenant.into()),
3776 SqlValue::Integer(published),
3777 ],
3778 )
3779 .await
3780 .unwrap();
3781 }
3782 tx.commit().await.unwrap();
3783 }
3784 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3785
3786 let hash = sha256_hex(PROBE);
3789 let stream: ByteStream =
3790 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3791 deploy.put_blob(&hash, stream).await.unwrap();
3792 let function = Function {
3793 name: "scopeprobe".into(),
3794 owner: Owner::Project("default".into()),
3795 versions: vec![FunctionVersion {
3796 id: "v1".into(),
3797 component: hash.clone(),
3798 created: 0,
3799 lifecycle: Lifecycle::Independent,
3800 }],
3801 active: "v1".into(),
3802 aliases: Default::default(),
3803 config: FunctionConfig {
3804 imports: vec!["sql".into()],
3805 tenancy: Some(Tenancy::Scoped {
3806 column: "tenant_id".into(),
3807 sources: vec![TenantSource::None],
3808 read: AccessMode::Own,
3809 write: AccessMode::None,
3810 }),
3811 ..Default::default()
3812 },
3813 };
3814 deploy
3815 .put_function(ProjectRef::DEFAULT, &function)
3816 .await
3817 .unwrap();
3818
3819 let sdl = "type Query { items: [Item!]! @tenant(scope: target, via: [domain], public: \"items\") }\n\
3822 type Item @key(fields: \"id\") { id: ID! }";
3823 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3824 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3825
3826 let mut schema = TenancySchema {
3827 default_tenant_key: "tenant_id".into(),
3828 tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
3829 ..Default::default()
3830 };
3831 schema.public_subsets.insert(
3832 "items".into(),
3833 PublicSubset {
3834 predicate: PublicPredicate {
3835 terms: vec![PublicTerm::Cmp {
3836 column: "published".into(),
3837 op: boatramp_core::tenancy::PublicCmp::Eq,
3838 value: boatramp_core::tenancy::PublicLiteral::Int(1),
3839 }],
3840 },
3841 world_public: true,
3842 listable: true,
3843 },
3844 );
3845
3846 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3847 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
3848 rt.set_invoker(deploy.clone());
3849 let inner = rt.inner.as_ref().unwrap();
3850 let invoker = inner.invoker.get().unwrap().clone();
3851
3852 let make_router = |domain: Option<&str>| {
3853 crate::graphql_gateway::BackendRouter::new(
3854 invoker.scoped(ProjectRef::new("default"), Vec::new()),
3855 "default".to_string(),
3856 inner.sql.clone(),
3857 std::collections::BTreeMap::new(),
3858 None,
3859 )
3860 .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
3861 schema: Arc::new(schema.clone()),
3862 domain_context: domain.map(str::to_string),
3863 target_handle: None,
3864 capability_anchor: None,
3865 }))
3866 };
3867
3868 let router_b = make_router(Some("tenant_B"));
3870 let out_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({})).await;
3871 let body_b = out_b.to_string();
3872 assert!(
3873 body_b.contains("\"b_pub\"")
3874 && !body_b.contains("\"b_priv\"")
3875 && !body_b.contains("\"a_pub\""),
3876 "gateway forced target B onto the wasm subgraph → ONLY B's PUBLIC row (b_pub), never B's \
3877 private row nor A's: {body_b}"
3878 );
3879
3880 let router_none = make_router(None);
3882 let out_none =
3883 crate::graphql_gateway::execute(&plan, &router_none, &serde_json::json!({})).await;
3884 let body_none = out_none.to_string();
3885 assert!(
3886 !body_none.contains("\"a_pub\"")
3887 && !body_none.contains("\"b_pub\"")
3888 && !body_none.contains("\"b_priv\""),
3889 "with no resolved target the wasm-subgraph target fetch fails closed — no rows: {body_none}"
3890 );
3891
3892 let _ = std::fs::remove_dir_all(&sql_dir);
3893 println!(
3894 "GATEWAY WASM-TARGET OK: the /graphql gateway resolved target tenant B from the routed \
3895 domain and FORCED a HostTenancy::target binding onto the wasm subgraph invocation \
3896 (invoke_target), confining the guest's own SQL to tenant=B AND published=1 over a real \
3897 libsql engine — returned ONLY B's public row (b_pub), never B's private row (b_priv) nor \
3898 tenant A's (a_pub); with no resolved target the fetch failed closed"
3899 );
3900 }
3901
3902 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
3912 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-own-propagation gate"]
3913 async fn gateway_propagates_caller_own_principal_to_a_wasm_subgraph() {
3914 use boatramp_core::deploy::{sha256_hex, DeployStore};
3915 use boatramp_core::function::{
3916 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
3917 };
3918 use boatramp_core::project::ProjectRef;
3919 use boatramp_core::sql::{SqlBackends, SqlValue};
3920 use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
3921 use boatramp_handlers::{HandlerEngine, Limits, ScopeFact};
3922
3923 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
3927
3928 let storage = Arc::new(MemStorage::default());
3929 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
3930 let deploy = DeployStore::new(storage.clone(), kv.clone());
3931
3932 let sql_dir = std::env::temp_dir().join(format!("br-gwown-{}", std::process::id()));
3933 let _ = std::fs::remove_dir_all(&sql_dir);
3934 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
3935 let db = backends
3936 .database("default", "fn/scopeprobe", "")
3937 .await
3938 .unwrap();
3939 {
3940 let mut tx = db.begin().await.unwrap();
3941 tx.execute(
3942 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
3943 &[],
3944 )
3945 .await
3946 .unwrap();
3947 for (id, tenant) in [("a1", "tenant_A"), ("b1", "tenant_B"), ("b2", "tenant_B")] {
3948 tx.execute(
3949 "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
3950 &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
3951 )
3952 .await
3953 .unwrap();
3954 }
3955 tx.commit().await.unwrap();
3956 }
3957 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
3958
3959 let hash = sha256_hex(PROBE);
3962 let stream: ByteStream =
3963 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
3964 deploy.put_blob(&hash, stream).await.unwrap();
3965 let function = Function {
3966 name: "scopeprobe".into(),
3967 owner: Owner::Project("default".into()),
3968 versions: vec![FunctionVersion {
3969 id: "v1".into(),
3970 component: hash.clone(),
3971 created: 0,
3972 lifecycle: Lifecycle::Independent,
3973 }],
3974 active: "v1".into(),
3975 aliases: Default::default(),
3976 config: FunctionConfig {
3977 imports: vec!["sql".into()],
3978 tenancy: Some(Tenancy::Scoped {
3979 column: "tenant_id".into(),
3980 sources: vec![TenantSource::None],
3981 read: AccessMode::Own,
3982 write: AccessMode::None,
3983 }),
3984 ..Default::default()
3985 },
3986 };
3987 deploy
3988 .put_function(ProjectRef::DEFAULT, &function)
3989 .await
3990 .unwrap();
3991
3992 let sdl = "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }";
3995 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
3996 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
3997
3998 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
3999 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
4000 rt.set_invoker(deploy.clone());
4001 let inner = rt.inner.as_ref().unwrap();
4002 let invoker = inner.invoker.get().unwrap().clone();
4003
4004 let make_router = |facts: Vec<ScopeFact>| {
4007 crate::graphql_gateway::BackendRouter::new(
4008 invoker.scoped(ProjectRef::new("default"), facts),
4009 "default".to_string(),
4010 inner.sql.clone(),
4011 std::collections::BTreeMap::new(),
4012 None,
4013 )
4014 };
4015
4016 let router_b = make_router(vec![ScopeFact {
4018 axis: ScopeAxis::Tenant,
4019 value: SqlValue::Text("tenant_B".into()),
4020 }]);
4021 let body_b = crate::graphql_gateway::execute(&plan, &router_b, &serde_json::json!({}))
4022 .await
4023 .to_string();
4024 assert!(
4025 body_b.contains("\"b1\"") && body_b.contains("\"b2\"") && !body_b.contains("\"a1\""),
4026 "gateway propagated principal B → the wasm subgraph read ONLY tenant B's rows: {body_b}"
4027 );
4028
4029 let router_anon = make_router(Vec::new());
4031 let body_anon =
4032 crate::graphql_gateway::execute(&plan, &router_anon, &serde_json::json!({}))
4033 .await
4034 .to_string();
4035 assert!(
4036 !body_anon.contains("\"a1\"")
4037 && !body_anon.contains("\"b1\"")
4038 && !body_anon.contains("\"b2\""),
4039 "with an empty principal the wasm-subgraph own fetch fails closed — no rows: {body_anon}"
4040 );
4041
4042 let _ = std::fs::remove_dir_all(&sql_dir);
4043 println!(
4044 "GATEWAY OWN-PROPAGATION OK: the /graphql gateway propagated the caller's resolved OWN \
4045 principal (tenant B) to a wasm subgraph fetch, which scoped its own read to the \
4046 inherited principal and returned ONLY tenant B's rows over a real libsql engine; with \
4047 an empty principal the same own fetch failed closed (no rows) — anon is never widened"
4048 );
4049 }
4050
4051 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4057 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI gateway-target gate"]
4058 async fn gateway_target_or_null_includes_the_shared_base() {
4059 use boatramp_core::deploy::{sha256_hex, DeployStore};
4060 use boatramp_core::function::{
4061 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4062 };
4063 use boatramp_core::project::ProjectRef;
4064 use boatramp_core::sql::{SqlBackends, SqlValue};
4065 use boatramp_core::tenancy::{
4066 AccessMode, PublicPredicate, PublicSubset, PublicTerm, TableScope, Tenancy,
4067 TenancySchema, TenantSource,
4068 };
4069 use boatramp_handlers::{HandlerEngine, Limits};
4070
4071 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
4072
4073 let storage = Arc::new(MemStorage::default());
4074 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4075 let deploy = DeployStore::new(storage.clone(), kv.clone());
4076
4077 let sql_dir = std::env::temp_dir().join(format!("br-gwton-{}", std::process::id()));
4078 let _ = std::fs::remove_dir_all(&sql_dir);
4079 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
4080 let db = backends
4081 .database("default", "fn/scopeprobe", "")
4082 .await
4083 .unwrap();
4084 {
4085 let mut tx = db.begin().await.unwrap();
4086 tx.execute(
4087 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT, published INTEGER)",
4088 &[],
4089 )
4090 .await
4091 .unwrap();
4092 for (id, tenant, published) in [
4096 ("base_pub", None, 1),
4097 ("base_priv", None, 0),
4098 ("b_pub", Some("tenant_B"), 1),
4099 ("b_priv", Some("tenant_B"), 0),
4100 ("a_pub", Some("tenant_A"), 1),
4101 ] {
4102 tx.execute(
4103 "INSERT INTO items (id, tenant_id, published) VALUES (?1, ?2, ?3)",
4104 &[
4105 SqlValue::Text(id.into()),
4106 tenant
4107 .map(|t| SqlValue::Text(t.into()))
4108 .unwrap_or(SqlValue::Null),
4109 SqlValue::Integer(published),
4110 ],
4111 )
4112 .await
4113 .unwrap();
4114 }
4115 tx.commit().await.unwrap();
4116 }
4117 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
4118
4119 let hash = sha256_hex(PROBE);
4120 let stream: ByteStream =
4121 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
4122 deploy.put_blob(&hash, stream).await.unwrap();
4123 let function = Function {
4124 name: "scopeprobe".into(),
4125 owner: Owner::Project("default".into()),
4126 versions: vec![FunctionVersion {
4127 id: "v1".into(),
4128 component: hash.clone(),
4129 created: 0,
4130 lifecycle: Lifecycle::Independent,
4131 }],
4132 active: "v1".into(),
4133 aliases: Default::default(),
4134 config: FunctionConfig {
4135 imports: vec!["sql".into()],
4136 tenancy: Some(Tenancy::Scoped {
4137 column: "tenant_id".into(),
4138 sources: vec![TenantSource::None],
4139 read: AccessMode::Own,
4140 write: AccessMode::None,
4141 }),
4142 ..Default::default()
4143 },
4144 };
4145 deploy
4146 .put_function(ProjectRef::DEFAULT, &function)
4147 .await
4148 .unwrap();
4149
4150 let sdl = "type Query { items: [Item!]! @tenant(scope: target_or_null, via: [domain], public: \"items\") }\n\
4152 type Item @key(fields: \"id\") { id: ID! }";
4153 let sg = crate::graphql_federation::compose(&[("scopeprobe".into(), sdl.into())]).unwrap();
4154 let plan = crate::graphql_plan::plan("{ items { id } }", &sg).unwrap();
4155
4156 let mut schema = TenancySchema {
4157 default_tenant_key: "tenant_id".into(),
4158 tables: std::collections::BTreeMap::from([("items".into(), TableScope::Tenant)]),
4159 ..Default::default()
4160 };
4161 schema.public_subsets.insert(
4162 "items".into(),
4163 PublicSubset {
4164 predicate: PublicPredicate {
4165 terms: vec![PublicTerm::Cmp {
4166 column: "published".into(),
4167 op: boatramp_core::tenancy::PublicCmp::Eq,
4168 value: boatramp_core::tenancy::PublicLiteral::Int(1),
4169 }],
4170 },
4171 world_public: true,
4172 listable: true,
4173 },
4174 );
4175
4176 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4177 let rt = HandlerRuntime::new(engine, kv.clone(), storage, Some(sql), None);
4178 rt.set_invoker(deploy.clone());
4179 let inner = rt.inner.as_ref().unwrap();
4180 let router = crate::graphql_gateway::BackendRouter::new(
4181 inner
4182 .invoker
4183 .get()
4184 .unwrap()
4185 .clone()
4186 .scoped(ProjectRef::new("default"), Vec::new()),
4187 "default".to_string(),
4188 inner.sql.clone(),
4189 std::collections::BTreeMap::new(),
4190 None,
4191 )
4192 .with_target_inputs(Some(crate::graphql_gateway::TargetInputs {
4193 schema: Arc::new(schema),
4194 domain_context: Some("tenant_B".into()),
4195 target_handle: None,
4196 capability_anchor: None,
4197 }));
4198
4199 let out = crate::graphql_gateway::execute(&plan, &router, &serde_json::json!({})).await;
4200 let body = out.to_string();
4201 assert!(
4202 body.contains("\"base_pub\"") && body.contains("\"b_pub\""),
4203 "target_or_null returned B's public row AND the shared base floor: {body}"
4204 );
4205 assert!(
4206 !body.contains("\"base_priv\"")
4207 && !body.contains("\"b_priv\"")
4208 && !body.contains("\"a_pub\""),
4209 "the public subset confines BOTH B and base (no base_priv, no b_priv), and no other \
4210 tenant's rows (no a_pub): {body}"
4211 );
4212
4213 let _ = std::fs::remove_dir_all(&sql_dir);
4214 println!(
4215 "GATEWAY TARGET-OR-NULL OK: scope:target_or_null on a wasm subgraph read tenant B's \
4216 public row (b_pub) PLUS the shared NULL-tenant base floor (base_pub), each confined to \
4217 published=1 — never B's private row (b_priv), never a non-public base row (base_priv), \
4218 never another tenant's row (a_pub); the base-only funnel keeps its inheritance floor"
4219 );
4220 }
4221
4222 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4236 #[ignore = "run on the host toolchain (real libsql static-musl segfault); wired in the CI deploy-resilience gate"]
4237 async fn async_deploy_never_activates_or_serves_an_unvalidated_component() {
4238 use crate::function_api::{run_async_deploy, DeployStatus};
4239 use boatramp_core::deploy::{sha256_hex, DeployStore};
4240 use boatramp_core::function::{
4241 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4242 };
4243 use boatramp_core::project::ProjectRef;
4244 use boatramp_core::sql::{SqlBackends, SqlValue};
4245 use boatramp_core::tenancy::{AccessMode, ScopeAxis, Tenancy, TenantSource};
4246 use boatramp_handlers::{GraphqlRequest, HandlerEngine, Limits, ScopeFact};
4247
4248 const PROBE: &[u8] = include_bytes!("../tests/fixtures/graphql-scope-probe.wasm");
4251 const SVC_SDL: &str =
4252 "type Query { items: [Item!]! }\ntype Item @key(fields: \"id\") { id: ID! }";
4253
4254 let storage = Arc::new(MemStorage::default());
4255 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4256 let deploy = DeployStore::new(storage.clone(), kv.clone());
4257
4258 let sql_dir = std::env::temp_dir().join(format!("br-deployres-{}", std::process::id()));
4260 let _ = std::fs::remove_dir_all(&sql_dir);
4261 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
4262 let db = backends.database("default", "fn/svc", "").await.unwrap();
4263 {
4264 let mut tx = db.begin().await.unwrap();
4265 tx.execute(
4266 "CREATE TABLE items (id TEXT PRIMARY KEY, tenant_id TEXT)",
4267 &[],
4268 )
4269 .await
4270 .unwrap();
4271 for (id, tenant) in [("b1", "tenant_B"), ("b2", "tenant_B")] {
4272 tx.execute(
4273 "INSERT INTO items (id, tenant_id) VALUES (?1, ?2)",
4274 &[SqlValue::Text(id.into()), SqlValue::Text(tenant.into())],
4275 )
4276 .await
4277 .unwrap();
4278 }
4279 tx.commit().await.unwrap();
4280 }
4281 let sql: Arc<dyn SqlBackends> = Arc::new(backends);
4282
4283 let hash = sha256_hex(PROBE);
4285 let stream: ByteStream =
4286 futures::stream::once(async move { Ok(bytes::Bytes::from_static(PROBE)) }).boxed();
4287 deploy.put_blob(&hash, stream).await.unwrap();
4288 let svc_config = || FunctionConfig {
4289 imports: vec!["sql".into()],
4290 tenancy: Some(Tenancy::Scoped {
4291 column: "tenant_id".into(),
4292 sources: vec![TenantSource::None],
4293 read: AccessMode::Own,
4294 write: AccessMode::None,
4295 }),
4296 ..Default::default()
4297 };
4298 let function = Function {
4299 name: "svc".into(),
4300 owner: Owner::Project("default".into()),
4301 versions: vec![FunctionVersion {
4302 id: "v1".into(),
4303 component: hash.clone(),
4304 created: 0,
4305 lifecycle: Lifecycle::Independent,
4306 }],
4307 active: "v1".into(),
4308 aliases: Default::default(),
4309 config: svc_config(),
4310 };
4311 deploy
4312 .put_function(ProjectRef::DEFAULT, &function)
4313 .await
4314 .unwrap();
4315
4316 crate::graphql_registry::publish(kv.as_ref(), "default", "svc", SVC_SDL)
4318 .await
4319 .unwrap();
4320 let query = "{ items { id } }";
4321 let op_hash = crate::graphql_apq::sha256_hex(query);
4322 kv.put(
4323 &format!("hapq/default/{op_hash}"),
4324 query.as_bytes().to_vec(),
4325 )
4326 .await
4327 .unwrap();
4328
4329 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4330 let rt = Arc::new(HandlerRuntime::new(
4331 engine,
4332 kv.clone(),
4333 storage,
4334 Some(sql),
4335 None,
4336 ));
4337 rt.set_invoker(deploy.clone());
4338 let fed = rt
4339 .inner
4340 .as_ref()
4341 .unwrap()
4342 .federation_runner
4343 .get()
4344 .unwrap()
4345 .clone();
4346 let serve_svc = || {
4347 let fed = fed.clone();
4348 async move {
4349 let runner = fed.scoped(
4350 ProjectRef::new("default"),
4351 vec![ScopeFact {
4352 axis: ScopeAxis::Tenant,
4353 value: SqlValue::Text("tenant_B".into()),
4354 }],
4355 );
4356 let req = GraphqlRequest {
4357 query: Some(query.to_string()),
4358 persisted_hash: None,
4359 variables: "{}".to_string(),
4360 operation_name: None,
4361 authorization: None,
4362 };
4363 String::from_utf8_lossy(&runner.run(req, 0).await.unwrap()).into_owned()
4364 }
4365 };
4366
4367 let before = serve_svc().await;
4369 assert!(
4370 before.contains("\"b1\"") && before.contains("\"b2\""),
4371 "baseline: the active version serves through the gateway: {before}"
4372 );
4373 let version_good =
4374 crate::graphql_registry::composition_version(kv.as_ref(), "default").await;
4375 assert!(
4376 crate::graphql_registry::supergraph(kv.as_ref(), "default")
4377 .await
4378 .is_ok(),
4379 "baseline supergraph composes"
4380 );
4381
4382 let garbage: Vec<u8> = vec![
4385 0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00, 0xff, 0xff, 0xde, 0xad,
4386 ];
4387 let garbage_hash = sha256_hex(&garbage);
4388 let g = garbage.clone();
4389 let gstream: ByteStream =
4390 futures::stream::once(async move { Ok(bytes::Bytes::from(g)) }).boxed();
4391 deploy.put_blob(&garbage_hash, gstream).await.unwrap();
4392 let status = run_async_deploy(
4393 deploy.clone(),
4394 rt.clone(),
4395 "default",
4396 "svc",
4397 &garbage_hash,
4398 svc_config(),
4399 Lifecycle::Independent,
4400 Some(false),
4401 false,
4402 0,
4403 )
4404 .await;
4405 assert!(
4406 matches!(status, DeployStatus::Failed { .. }),
4407 "an uncompilable component must fail validation, got {status:?}"
4408 );
4409 let f = deploy
4411 .get_function(ProjectRef::DEFAULT, "svc")
4412 .await
4413 .unwrap()
4414 .unwrap();
4415 assert_eq!(
4416 f.active, "v1",
4417 "the failed deploy must NOT flip `active` (still the validated v1), got {}",
4418 f.active
4419 );
4420 assert!(
4421 !f.versions.iter().any(|v| v.id == garbage_hash),
4422 "the failed component must not even be persisted as a version"
4423 );
4424 let after = serve_svc().await;
4426 assert!(
4427 after.contains("\"b1\"") && after.contains("\"b2\""),
4428 "the previously-active version must keep serving after a failed deploy: {after}"
4429 );
4430 assert!(
4432 crate::graphql_registry::supergraph(kv.as_ref(), "default")
4433 .await
4434 .is_ok(),
4435 "the live supergraph must still compose after a failed deploy"
4436 );
4437 assert_eq!(
4438 crate::graphql_registry::composition_version(kv.as_ref(), "default").await,
4439 version_good,
4440 "a failed deploy must not touch the composition version"
4441 );
4442
4443 let status = run_async_deploy(
4446 deploy.clone(),
4447 rt.clone(),
4448 "default",
4449 "svc",
4450 &hash,
4451 svc_config(),
4452 Lifecycle::Independent,
4453 Some(false),
4454 false,
4455 0,
4456 )
4457 .await;
4458 assert!(
4459 matches!(status, DeployStatus::Active),
4460 "a valid component must validate to Active, got {status:?}"
4461 );
4462 let f = deploy
4463 .get_function(ProjectRef::DEFAULT, "svc")
4464 .await
4465 .unwrap()
4466 .unwrap();
4467 assert_eq!(
4468 f.active, hash,
4469 "a valid deploy flips `active` to the validated version only after Active"
4470 );
4471
4472 const ACCOUNTS: &str =
4474 "type Query { me: User }\ntype User @key(fields: \"id\") { id: ID! name: String }";
4475 const REVIEWS: &str = "type Query { topReviews: [Review] }\ntype Review { id: ID! body: String author: User }\nextend type User @key(fields: \"id\") { id: ID! @external reviews: [Review] }";
4476 assert_eq!(
4477 crate::graphql_registry::composition_version(kv.as_ref(), "batchproj").await,
4478 0
4479 );
4480 crate::graphql_registry::stage_subgraph(
4481 kv.as_ref(),
4482 "batchproj",
4483 "accounts",
4484 ACCOUNTS,
4485 "h1",
4486 )
4487 .await
4488 .unwrap();
4489 crate::graphql_registry::stage_subgraph(kv.as_ref(), "batchproj", "reviews", REVIEWS, "h2")
4490 .await
4491 .unwrap();
4492 assert!(
4494 crate::graphql_registry::subgraph_names(kv.as_ref(), "batchproj")
4495 .await
4496 .is_empty()
4497 );
4498 let sg = crate::graphql_registry::compose_batch(kv.as_ref(), "batchproj")
4499 .await
4500 .unwrap();
4501 assert!(
4502 sg.entities.contains_key("User"),
4503 "the batch composed as a whole"
4504 );
4505 assert_eq!(
4506 crate::graphql_registry::subgraph_names(kv.as_ref(), "batchproj").await,
4507 vec!["accounts".to_string(), "reviews".to_string()]
4508 );
4509 assert_eq!(
4510 crate::graphql_registry::composition_version(kv.as_ref(), "batchproj").await,
4511 1,
4512 "a batch of N subgraphs bumps the composition version exactly ONCE"
4513 );
4514 crate::graphql_registry::stage_subgraph(
4516 kv.as_ref(),
4517 "batchproj",
4518 "clash",
4519 "type Review { id: ID! body: String }",
4520 "h3",
4521 )
4522 .await
4523 .unwrap();
4524 assert!(matches!(
4525 crate::graphql_registry::compose_batch(kv.as_ref(), "batchproj").await,
4526 Err(crate::graphql_registry::PublishError::Composition(_))
4527 ));
4528 assert_eq!(
4529 crate::graphql_registry::subgraph_names(kv.as_ref(), "batchproj").await,
4530 vec!["accounts".to_string(), "reviews".to_string()],
4531 "a non-composing batch must promote NOTHING"
4532 );
4533 assert_eq!(
4534 crate::graphql_registry::composition_version(kv.as_ref(), "batchproj").await,
4535 1,
4536 "a non-composing batch must not bump the version"
4537 );
4538
4539 let _ = std::fs::remove_dir_all(&sql_dir);
4540 println!(
4541 "DEPLOY-RESILIENCE ASYNC/BATCH OK: over a real wasm engine + real libsql, an async \
4542 deploy of an uncompilable component returned Failed WITHOUT flipping `active` (the \
4543 validated v1 kept serving tenant B's rows through the gateway and the supergraph still \
4544 composed); a valid async deploy reached Active and only then flipped `active`; and a \
4545 batch compose promoted N staged subgraphs with exactly ONE version bump while a \
4546 non-composing batch promoted nothing and left the live set + version intact"
4547 );
4548 }
4549
4550 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4556 async fn scheduler_drains_a_non_default_projects_invocation_in_its_own_tenant() {
4557 use crate::scheduler::{run_scheduler_tick, CronNow};
4558 use boatramp_core::deploy::DeployStore;
4559 use boatramp_core::function::{
4560 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4561 };
4562 use boatramp_handlers::{HandlerEngine, Limits};
4563 use futures::StreamExt;
4564
4565 const HTTP_200: &[u8] =
4566 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4567
4568 let storage = Arc::new(MemStorage::default());
4569 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4570 let deploy = DeployStore::new(storage.clone(), kv.clone());
4571
4572 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4573 let stream: ByteStream =
4574 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4575 deploy.put_blob(&hash, stream).await.unwrap();
4576
4577 let acme = ProjectRef::new("acme");
4579 let function = Function {
4580 name: "worker".into(),
4581 owner: Owner::Project("acme".into()),
4582 versions: vec![FunctionVersion {
4583 id: "v1".into(),
4584 component: hash.clone(),
4585 created: 0,
4586 lifecycle: Lifecycle::Independent,
4587 }],
4588 active: "v1".into(),
4589 aliases: Default::default(),
4590 config: Default::default(),
4591 };
4592 deploy.put_function(acme, &function).await.unwrap();
4593 let inv = Invocation {
4594 id: "inv1".into(),
4595 function: "worker".into(),
4596 version: "v1".into(),
4597 mode: InvokeMode::Async,
4598 status: InvocationStatus::Queued,
4599 idempotency_key: None,
4600 attempts: 0,
4601 lease_expires: None,
4602 request_b64: None,
4603 request_content_type: None,
4604 result: None,
4605 created: 0,
4606 updated: 0,
4607 };
4608 deploy.put_invocation(acme, &inv).await.unwrap();
4609
4610 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4611 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4612 let inner = rt.inner.as_ref().unwrap();
4613
4614 let mut wasm_cache = std::collections::HashMap::new();
4617 let mut cron_state = std::collections::HashMap::new();
4618 let mut sweep = std::collections::HashMap::new();
4619 let now = CronNow {
4620 minute: 0,
4621 hour: 0,
4622 dom: 1,
4623 month: 1,
4624 dow: 0,
4625 minute_stamp: 0,
4626 };
4627 run_scheduler_tick(
4628 inner,
4629 &deploy,
4630 &mut wasm_cache,
4631 &mut cron_state,
4632 &mut sweep,
4633 now,
4634 )
4635 .await
4636 .unwrap();
4637
4638 let settled = poll_invocation_settled(&deploy, acme, "worker", "inv1").await;
4641 assert_eq!(settled.status, InvocationStatus::Succeeded);
4643 let metering = deploy.get_metering(acme, "worker").await.unwrap().unwrap();
4645 assert_eq!(metering.invocations, 1);
4646 assert!(deploy
4648 .get_invocation(ProjectRef::DEFAULT, "worker", "inv1")
4649 .await
4650 .unwrap()
4651 .is_none());
4652 assert!(deploy
4653 .get_metering(ProjectRef::DEFAULT, "worker")
4654 .await
4655 .unwrap()
4656 .is_none());
4657 }
4658
4659 #[cfg(feature = "handlers")]
4663 async fn poll_invocation_settled(
4664 deploy: &boatramp_core::deploy::DeployStore,
4665 project: ProjectRef<'_>,
4666 function: &str,
4667 id: &str,
4668 ) -> boatramp_core::function::Invocation {
4669 use boatramp_core::function::InvocationStatus;
4670 for _ in 0..200 {
4671 if let Some(inv) = deploy.get_invocation(project, function, id).await.unwrap() {
4672 if matches!(
4673 inv.status,
4674 InvocationStatus::Succeeded | InvocationStatus::Failed
4675 ) {
4676 return inv;
4677 }
4678 }
4679 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
4680 }
4681 panic!("invocation {function}/{id} never settled");
4682 }
4683
4684 #[cfg(feature = "handlers")]
4689 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4690 async fn drain_reclaims_an_expired_lease_and_skips_a_live_one() {
4691 use crate::scheduler::{run_scheduler_tick, CronNow};
4692 use boatramp_core::deploy::DeployStore;
4693 use boatramp_core::function::{
4694 Function, FunctionVersion, Invocation, InvocationStatus, InvokeMode, Lifecycle, Owner,
4695 };
4696 use boatramp_handlers::{HandlerEngine, Limits};
4697 use futures::StreamExt;
4698
4699 const HTTP_200: &[u8] =
4700 include_bytes!("../../boatramp-handlers/tests/fixtures/http-200.wasm");
4701
4702 let storage = Arc::new(MemStorage::default());
4703 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4704 let deploy = DeployStore::new(storage.clone(), kv.clone());
4705 let hash = boatramp_core::deploy::sha256_hex(HTTP_200);
4706 let stream: ByteStream =
4707 futures::stream::once(async move { Ok(bytes::Bytes::from_static(HTTP_200)) }).boxed();
4708 deploy.put_blob(&hash, stream).await.unwrap();
4709
4710 let function = Function {
4711 name: "worker".into(),
4712 owner: Owner::Project("default".into()),
4713 versions: vec![FunctionVersion {
4714 id: "v1".into(),
4715 component: hash.clone(),
4716 created: 0,
4717 lifecycle: Lifecycle::Independent,
4718 }],
4719 active: "v1".into(),
4720 aliases: Default::default(),
4721 config: Default::default(),
4722 };
4723 deploy
4724 .put_function(ProjectRef::DEFAULT, &function)
4725 .await
4726 .unwrap();
4727
4728 let base = Invocation {
4731 id: String::new(),
4732 function: "worker".into(),
4733 version: "v1".into(),
4734 mode: InvokeMode::Async,
4735 status: InvocationStatus::Running,
4736 idempotency_key: None,
4737 attempts: 1,
4738 lease_expires: None,
4739 request_b64: None,
4740 request_content_type: None,
4741 result: None,
4742 created: 0,
4743 updated: 0,
4744 };
4745 let orphan = Invocation {
4746 id: "orphan".into(),
4747 lease_expires: Some(1),
4748 ..base.clone()
4749 };
4750 deploy
4751 .put_invocation(ProjectRef::DEFAULT, &orphan)
4752 .await
4753 .unwrap();
4754 let live = Invocation {
4755 id: "live".into(),
4756 lease_expires: Some(u64::MAX),
4757 ..base.clone()
4758 };
4759 deploy
4760 .put_invocation(ProjectRef::DEFAULT, &live)
4761 .await
4762 .unwrap();
4763
4764 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4765 let rt = HandlerRuntime::new(engine, kv.clone(), storage.clone(), None, None);
4766 let inner = rt.inner.as_ref().unwrap();
4767
4768 let now = CronNow {
4769 minute: 0,
4770 hour: 0,
4771 dom: 1,
4772 month: 1,
4773 dow: 0,
4774 minute_stamp: 0,
4775 };
4776 let mut wasm_cache = std::collections::HashMap::new();
4777 let mut cron_state = std::collections::HashMap::new();
4778 let mut sweep = std::collections::HashMap::new();
4779 run_scheduler_tick(
4780 inner,
4781 &deploy,
4782 &mut wasm_cache,
4783 &mut cron_state,
4784 &mut sweep,
4785 now,
4786 )
4787 .await
4788 .unwrap();
4789
4790 let settled =
4792 poll_invocation_settled(&deploy, ProjectRef::DEFAULT, "worker", "orphan").await;
4793 assert_eq!(settled.status, InvocationStatus::Succeeded);
4794 assert_eq!(settled.attempts, 2, "a reclaim counts as another attempt");
4795 assert_eq!(
4796 settled.lease_expires, None,
4797 "a settled invocation drops its lease"
4798 );
4799 let live_after = deploy
4801 .get_invocation(ProjectRef::DEFAULT, "worker", "live")
4802 .await
4803 .unwrap()
4804 .unwrap();
4805 assert_eq!(live_after.status, InvocationStatus::Running);
4806 assert_eq!(live_after.attempts, 1, "a live lease is never reclaimed");
4807 assert_eq!(live_after.lease_expires, Some(u64::MAX));
4808 }
4809
4810 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4825 async fn guest_kv_is_isolated_between_same_named_functions_in_two_projects() {
4826 use boatramp_core::deploy::DeployStore;
4827 use boatramp_core::function::{
4828 Function, FunctionConfig, FunctionVersion, Lifecycle, Owner,
4829 };
4830 use boatramp_handlers::{HandlerEngine, Limits};
4831 use futures::StreamExt;
4832
4833 let storage = Arc::new(MemStorage::default());
4834 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4835 let deploy = DeployStore::new(storage.clone(), kv.clone());
4836
4837 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4840 let stream: ByteStream =
4841 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4842 deploy.put_blob(&hash, stream).await.unwrap();
4843
4844 let store = Function {
4849 name: "store".into(),
4850 owner: Owner::Project("default".into()),
4851 versions: vec![FunctionVersion {
4852 id: "v1".into(),
4853 component: hash.clone(),
4854 created: 0,
4855 lifecycle: Lifecycle::Independent,
4856 }],
4857 active: "v1".into(),
4858 aliases: Default::default(),
4859 config: FunctionConfig {
4860 imports: vec!["wasi:keyvalue".into()],
4861 ..Default::default()
4862 },
4863 };
4864 let acme = ProjectRef::new("acme");
4865 let globex = ProjectRef::new("globex");
4866
4867 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4868 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4869 let inner = rt.inner.as_ref().unwrap();
4870
4871 let request = || {
4872 axum::http::Request::builder()
4873 .method("GET")
4874 .uri("/")
4875 .body(axum::body::Body::empty())
4876 .unwrap()
4877 };
4878
4879 let component = store.resolve(&store.active).unwrap().to_owned();
4882 for project in [acme, globex, ProjectRef::DEFAULT] {
4883 let (response, _) = execute_function(
4884 inner,
4885 &deploy,
4886 project,
4887 &store,
4888 &component,
4889 request(),
4890 0,
4891 boatramp_handlers::Lane::Sync,
4892 crate::function_runtime::FnTenant::Request,
4893 )
4894 .await;
4895 assert!(response.status().is_success(), "invocation should succeed");
4896 }
4897
4898 assert_eq!(
4901 kv.get("hkv/acme/fn/store/hits").await.unwrap(),
4902 Some(b"1".to_vec()),
4903 "acme's write must be tenant-qualified"
4904 );
4905 assert_eq!(
4906 kv.get("hkv/globex/fn/store/hits").await.unwrap(),
4907 Some(b"1".to_vec()),
4908 "globex's write must be tenant-qualified"
4909 );
4910 assert_eq!(
4911 kv.get("hkv/fn/store/hits").await.unwrap(),
4912 Some(b"1".to_vec()),
4913 "the default project must keep the byte-identical pre-project key"
4914 );
4915 }
4918
4919 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
4923 async fn scheduler_fires_crons_with_dedup_and_overlap_skip() {
4924 use boatramp_core::config::{
4925 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
4926 };
4927 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
4928 use boatramp_handlers::{HandlerEngine, Limits};
4929 use futures::StreamExt;
4930 use std::sync::atomic::Ordering;
4931
4932 let storage = Arc::new(MemStorage::default());
4933 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
4934 let deploy = DeployStore::new(storage.clone(), kv.clone());
4935
4936 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
4937 let stream: ByteStream =
4938 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
4939 deploy.put_blob(&hash, stream).await.unwrap();
4940 let mut files = std::collections::BTreeMap::new();
4941 files.insert(
4942 "counter.wasm".to_string(),
4943 FileEntry {
4944 hash: hash.clone(),
4945 size: KV_COUNTER.len() as u64,
4946 content_type: None,
4947 variants: std::collections::BTreeMap::new(),
4948 },
4949 );
4950 let manifest = Manifest {
4951 files,
4952 config: DeployConfig {
4953 handlers: vec![HandlerConfig {
4954 tenancy: None,
4955 token_claims: None,
4956 route: "/".into(),
4957 methods: Vec::new(),
4958 component: "counter.wasm".into(),
4959 imports: vec!["wasi:keyvalue".into()],
4960 streaming: false,
4961 limits: None,
4962 env: std::collections::BTreeMap::new(),
4963 invoke_targets: Vec::new(),
4964 }],
4965 crons: vec![CronConfig {
4966 schedule: "* * * * *".into(),
4967 route: "/".into(),
4968 overlap: Overlap::Skip,
4969 }],
4970 ..Default::default()
4971 },
4972 ..Default::default()
4973 };
4974 let id = deploy.put_manifest(&manifest).await.unwrap();
4975 deploy
4976 .activate(ProjectRef::DEFAULT, "blog", &id)
4977 .await
4978 .unwrap();
4979 deploy
4980 .set_site_config(
4981 ProjectRef::DEFAULT,
4982 "blog",
4983 &SiteConfig {
4984 handlers: Some(HandlersSiteConfig {
4985 enabled: true,
4986 allow_imports: vec!["wasi:keyvalue".into()],
4987 ..Default::default()
4988 }),
4989 ..Default::default()
4990 },
4991 )
4992 .await
4993 .unwrap();
4994
4995 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
4996 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
4997 let inner = rt.inner.clone().unwrap();
4998 let mut wasm = std::collections::HashMap::new();
4999 let mut crons = std::collections::HashMap::new();
5000 let mut sweep = std::collections::HashMap::new();
5001 let at = |stamp| CronNow {
5002 minute: 0,
5003 hour: 0,
5004 dom: 1,
5005 month: 1,
5006 dow: 0,
5007 minute_stamp: stamp,
5008 };
5009
5010 let (_, handles) =
5012 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
5013 .await
5014 .unwrap();
5015 for h in handles {
5016 h.await.unwrap();
5017 }
5018 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
5019
5020 let (_, handles) =
5022 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(100))
5023 .await
5024 .unwrap();
5025 assert!(handles.is_empty());
5026 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"1".to_vec()));
5027
5028 let (_, handles) =
5030 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(101))
5031 .await
5032 .unwrap();
5033 for h in handles {
5034 h.await.unwrap();
5035 }
5036 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
5037
5038 crons
5042 .get("default|blog|cron|0")
5043 .unwrap()
5044 .running
5045 .store(true, Ordering::Release);
5046 let (_, handles) =
5047 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, at(102))
5048 .await
5049 .unwrap();
5050 assert!(handles.is_empty());
5051 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), Some(b"2".to_vec()));
5052 }
5053
5054 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
5058 async fn cron_leader_gate_suppresses_crons_off_leader() {
5059 use boatramp_core::config::{
5060 CronConfig, DeployConfig, HandlerConfig, HandlersSiteConfig, Overlap, SiteConfig,
5061 };
5062 use boatramp_core::deploy::{DeployStore, FileEntry, Manifest};
5063 use boatramp_handlers::{HandlerEngine, Limits};
5064 use futures::StreamExt;
5065
5066 let storage = Arc::new(MemStorage::default());
5067 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
5068 let deploy = DeployStore::new(storage.clone(), kv.clone());
5069
5070 let hash = boatramp_core::deploy::sha256_hex(KV_COUNTER);
5071 let stream: ByteStream =
5072 futures::stream::once(async move { Ok(bytes::Bytes::from_static(KV_COUNTER)) }).boxed();
5073 deploy.put_blob(&hash, stream).await.unwrap();
5074 let mut files = std::collections::BTreeMap::new();
5075 files.insert(
5076 "counter.wasm".to_string(),
5077 FileEntry {
5078 hash: hash.clone(),
5079 size: KV_COUNTER.len() as u64,
5080 content_type: None,
5081 variants: std::collections::BTreeMap::new(),
5082 },
5083 );
5084 let manifest = Manifest {
5085 files,
5086 config: DeployConfig {
5087 handlers: vec![HandlerConfig {
5088 tenancy: None,
5089 token_claims: None,
5090 route: "/".into(),
5091 methods: Vec::new(),
5092 component: "counter.wasm".into(),
5093 imports: vec!["wasi:keyvalue".into()],
5094 streaming: false,
5095 limits: None,
5096 env: std::collections::BTreeMap::new(),
5097 invoke_targets: Vec::new(),
5098 }],
5099 crons: vec![CronConfig {
5100 schedule: "* * * * *".into(),
5101 route: "/".into(),
5102 overlap: Overlap::Skip,
5103 }],
5104 ..Default::default()
5105 },
5106 ..Default::default()
5107 };
5108 let id = deploy.put_manifest(&manifest).await.unwrap();
5109 deploy
5110 .activate(ProjectRef::DEFAULT, "blog", &id)
5111 .await
5112 .unwrap();
5113 deploy
5114 .set_site_config(
5115 ProjectRef::DEFAULT,
5116 "blog",
5117 &SiteConfig {
5118 handlers: Some(HandlersSiteConfig {
5119 enabled: true,
5120 allow_imports: vec!["wasi:keyvalue".into()],
5121 ..Default::default()
5122 }),
5123 ..Default::default()
5124 },
5125 )
5126 .await
5127 .unwrap();
5128
5129 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5130 let rt = HandlerRuntime::new(engine, kv.clone(), storage, None, None);
5131 rt.set_cron_leader_gate(Arc::new(|| false));
5133 let inner = rt.inner.clone().unwrap();
5134 let mut wasm = std::collections::HashMap::new();
5135 let mut crons = std::collections::HashMap::new();
5136 let mut sweep = std::collections::HashMap::new();
5137 let now = CronNow {
5138 minute: 0,
5139 hour: 0,
5140 dom: 1,
5141 month: 1,
5142 dow: 0,
5143 minute_stamp: 100,
5144 };
5145
5146 let (_, handles) =
5147 run_scheduler_tick(&inner, &deploy, &mut wasm, &mut crons, &mut sweep, now)
5148 .await
5149 .unwrap();
5150 assert!(handles.is_empty(), "a non-leader must not fire crons");
5152 assert_eq!(kv.get("hkv/blog/hits").await.unwrap(), None);
5153 }
5154
5155 #[tokio::test]
5159 async fn project_tenancy_knobs_override_wins_else_node_base() {
5160 use boatramp_core::security::ResolvedProjectTenancy;
5161 use boatramp_handlers::{HandlerEngine, Limits};
5162
5163 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
5164 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
5165 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5166 let rt = HandlerRuntime::new(engine, kv, storage, None, None);
5167 rt.set_tenancy_posture(true, false);
5169 let mut overrides = std::collections::BTreeMap::new();
5171 overrides.insert(
5172 "preview".to_string(),
5173 ResolvedProjectTenancy {
5174 require_tenancy_declaration: true,
5175 allow_cross_tenant_db: true,
5176 capability_max_ttl_secs: Some(1800),
5177 },
5178 );
5179 rt.set_project_tenancy_overrides(overrides);
5180 let inner = rt.inner.as_ref().unwrap();
5181
5182 let p = inner.project_tenancy_knobs("preview");
5184 assert!(p.require_tenancy_declaration);
5185 assert!(p.allow_cross_tenant_db);
5186 assert_eq!(p.capability_max_ttl_secs, Some(1800));
5187 let b = inner.project_tenancy_knobs("prod");
5189 assert!(b.require_tenancy_declaration);
5190 assert!(!b.allow_cross_tenant_db);
5191 assert_eq!(b.capability_max_ttl_secs, None);
5192 }
5193
5194 #[tokio::test]
5201 async fn build_bindings_dispatches_named_sql_databases_with_least_privilege() {
5202 use boatramp_core::config::HandlersSiteConfig;
5203 use boatramp_core::project::ProjectRef;
5204 use boatramp_handlers::{HandlerEngine, Limits};
5205
5206 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
5207 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
5208 let sql_dir =
5210 std::env::temp_dir().join(format!("boatramp-named-sql-{}", std::process::id()));
5211 let _ = std::fs::remove_dir_all(&sql_dir);
5212 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
5213 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
5214
5215 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5216 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
5217 let inner = rt.inner.as_ref().unwrap();
5218
5219 let site = HandlersSiteConfig {
5223 enabled: true,
5224 allow_imports: vec!["sql".into(), "sql:product".into(), "sql:privileged".into()],
5225 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
5226 ..Default::default()
5227 };
5228 let env = std::collections::BTreeMap::new();
5229 let build = |imports: &[&str]| {
5230 let imports: Vec<String> = imports.iter().copied().map(String::from).collect();
5231 let site = &site;
5232 let env = &env;
5233 async move {
5234 crate::handler_dispatch::build_bindings(
5235 inner,
5236 ProjectRef::new("default"),
5237 "shop",
5238 "shop",
5239 None,
5240 &imports,
5241 site,
5242 env,
5243 &[],
5244 0,
5245 None,
5246 None,
5247 None,
5248 None,
5249 None,
5250 None,
5251 None,
5252 None,
5253 )
5254 .await
5255 .expect("no secrets → resolves")
5256 .sql_database_names()
5257 }
5258 };
5259
5260 assert_eq!(build(&["sql", "sql:product"]).await, vec!["", "product"]);
5263 assert_eq!(
5265 build(&["sql", "sql:*"]).await,
5266 vec!["", "privileged", "product"]
5267 );
5268 assert!(build(&["sql:secret"]).await.is_empty());
5270 assert_eq!(build(&["sql:product"]).await, vec!["product"]);
5272
5273 let _ = std::fs::remove_dir_all(&sql_dir);
5274 }
5275
5276 #[tokio::test]
5287 async fn managed_dependency_not_ready_gates_with_a_retryable_503() {
5288 use crate::handler_dispatch::{build_bindings, BindingsError};
5289 use boatramp_core::config::HandlersSiteConfig;
5290 use boatramp_core::project::ProjectRef;
5291 use boatramp_core::sql::{SqlBackend, SqlBackends, SqlError};
5292 use boatramp_handlers::{HandlerEngine, Limits};
5293 use std::sync::atomic::{AtomicUsize, Ordering};
5294
5295 enum Outcome {
5297 Unavailable, Other, Ready, UnavailableThenOk, }
5302 struct FakeSql {
5303 outcome: Outcome,
5304 calls: AtomicUsize,
5305 real: Arc<dyn SqlBackend>,
5306 }
5307 #[async_trait::async_trait]
5308 impl SqlBackends for FakeSql {
5309 async fn database(
5310 &self,
5311 _project: &str,
5312 _site: &str,
5313 _name: &str,
5314 ) -> Result<Arc<dyn SqlBackend>, SqlError> {
5315 let n = self.calls.fetch_add(1, Ordering::SeqCst);
5316 match self.outcome {
5317 Outcome::Unavailable => {
5318 Err(SqlError::unavailable("workload has no replica yet"))
5319 }
5320 Outcome::Other => Err(SqlError::other("external db connection refused")),
5321 Outcome::Ready => Ok(Arc::clone(&self.real)),
5322 Outcome::UnavailableThenOk if n == 0 => {
5323 Err(SqlError::unavailable("still initializing"))
5324 }
5325 Outcome::UnavailableThenOk => Ok(Arc::clone(&self.real)),
5326 }
5327 }
5328 }
5329
5330 let sql_dir =
5332 std::env::temp_dir().join(format!("boatramp-readiness-gate-{}", std::process::id()));
5333 let _ = std::fs::remove_dir_all(&sql_dir);
5334 let real = boatramp_storage::LibsqlSqlBackends::local(&sql_dir)
5335 .database("default", "shop", "")
5336 .await
5337 .unwrap();
5338
5339 async fn build_with(
5342 outcome: Outcome,
5343 real: &Arc<dyn SqlBackend>,
5344 ) -> (Result<boatramp_handlers::Bindings, BindingsError>, usize) {
5345 let kv: Arc<dyn boatramp_core::kv::KvStore> =
5346 Arc::new(boatramp_core::kv::MemoryKv::new());
5347 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
5348 let fake = Arc::new(FakeSql {
5349 outcome,
5350 calls: AtomicUsize::new(0),
5351 real: Arc::clone(real),
5352 });
5353 let sql: Arc<dyn SqlBackends> = fake.clone();
5354 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5355 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
5356 let inner = rt.inner.as_ref().unwrap();
5357 let site = HandlersSiteConfig {
5358 enabled: true,
5359 allow_imports: vec!["sql".into()],
5360 tenancy: Some(boatramp_core::tenancy::Tenancy::Disabled),
5361 ..Default::default()
5362 };
5363 let imports = vec!["sql".to_string()];
5364 let env = std::collections::BTreeMap::new();
5365 let r = build_bindings(
5366 inner,
5367 ProjectRef::new("default"),
5368 "shop",
5369 "shop",
5370 None,
5371 &imports,
5372 &site,
5373 &env,
5374 &[],
5375 0,
5376 None,
5377 None,
5378 None,
5379 None,
5380 None,
5381 None,
5382 None,
5383 None,
5384 )
5385 .await;
5386 (r, fake.calls.load(Ordering::SeqCst))
5387 }
5388
5389 let (r, _) = build_with(Outcome::Unavailable, &real).await;
5391 match r {
5392 Err(BindingsError::NotReady {
5393 retry_after_secs, ..
5394 }) => {
5395 assert!(retry_after_secs >= 1, "advertises a Retry-After");
5396 }
5397 Err(BindingsError::Refused(m)) => {
5398 panic!("a not-ready managed DB must gate with NotReady, got Refused({m})")
5399 }
5400 Ok(_) => panic!("a not-ready managed DB must gate, but bindings were built"),
5401 }
5402
5403 let (r, _) = build_with(Outcome::Other, &real).await;
5405 let bindings = r.expect("a non-managed DB outage must NOT gate the whole request");
5406 assert!(
5407 !bindings.sql_database_names().contains(&String::new()),
5408 "the broken default DB is left ungranted (skipped), not gated"
5409 );
5410
5411 let (r, _) = build_with(Outcome::Ready, &real).await;
5413 let bindings = r.expect("a ready DB builds");
5414 assert!(bindings.sql_database_names().contains(&String::new()));
5415
5416 let (r, calls) = build_with(Outcome::UnavailableThenOk, &real).await;
5419 let bindings = r.expect("the readiness retry catches a DB a moment from ready");
5420 assert!(bindings.sql_database_names().contains(&String::new()));
5421 assert_eq!(calls, 2, "one bounded retry on Unavailable");
5422
5423 let resp = crate::sql_starting_response(2);
5425 assert_eq!(resp.status(), axum::http::StatusCode::SERVICE_UNAVAILABLE);
5426 assert_eq!(
5427 resp.headers()
5428 .get(axum::http::header::RETRY_AFTER)
5429 .and_then(|v| v.to_str().ok()),
5430 Some("2"),
5431 "the readiness 503 advertises Retry-After"
5432 );
5433
5434 let _ = std::fs::remove_dir_all(&sql_dir);
5435 println!(
5436 "MANAGED-DEP READINESS GATE OK: a required managed database that is still starting gates \
5437 the invocation with a retryable 503 + Retry-After (fail-closed, guest never runs); a \
5438 brief startup blip is caught by the bounded readiness retry; an external/local DB \
5439 outage is skipped (per-DB resilience), never gating the whole request."
5440 );
5441 }
5442
5443 #[tokio::test]
5458 async fn consumer_signed_context_dispatch_resolves_per_message_tenant_on_a_real_engine() {
5459 use crate::handler_dispatch::ConsumerRebuild;
5460 use boatramp_core::config::HandlersSiteConfig;
5461 use boatramp_core::cose::{mint_context, Signer};
5462 use boatramp_core::orm::{Expr, Select, SelectItem};
5463 use boatramp_core::sql::{Dialect, SqlBackends, SqlValue};
5464 use boatramp_core::tenancy::{AccessMode, Tenancy, TenantSource};
5465 use boatramp_handlers::{HandlerEngine, Limits, TenantAxis, TenantDenied};
5466
5467 let sql_dir =
5469 std::env::temp_dir().join(format!("boatramp-consumer-sctx-{}", std::process::id()));
5470 let _ = std::fs::remove_dir_all(&sql_dir);
5471 let backends = boatramp_storage::LibsqlSqlBackends::local(&sql_dir);
5472 let db = backends.database("default", "worker", "").await.unwrap();
5473 {
5474 let mut tx = db.begin().await.unwrap();
5475 tx.execute(
5476 "CREATE TABLE notes (id TEXT PRIMARY KEY, tenant_id TEXT, body TEXT)",
5477 &[],
5478 )
5479 .await
5480 .unwrap();
5481 for (id, tenant, body) in [("n1", "acme", "acme-note"), ("n2", "globex", "globex-note")]
5482 {
5483 tx.execute(
5484 "INSERT INTO notes (id, tenant_id, body) VALUES (?1, ?2, ?3)",
5485 &[
5486 SqlValue::Text(id.into()),
5487 SqlValue::Text(tenant.into()),
5488 SqlValue::Text(body.into()),
5489 ],
5490 )
5491 .await
5492 .unwrap();
5493 }
5494 tx.commit().await.unwrap();
5495 }
5496
5497 let kv: Arc<dyn boatramp_core::kv::KvStore> = Arc::new(boatramp_core::kv::MemoryKv::new());
5498 let storage: Arc<dyn boatramp_core::Storage> = Arc::new(MemStorage::default());
5499 let sql: Arc<dyn boatramp_core::sql::SqlBackends> =
5500 Arc::new(boatramp_storage::LibsqlSqlBackends::local(&sql_dir));
5501 let engine = HandlerEngine::new(Limits::default(), 16).unwrap();
5502 let rt = HandlerRuntime::new(engine, kv, storage, Some(sql), None);
5503 rt.set_tenancy_posture(true, false);
5506 let signer: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
5508 rt.set_session_signer(signer.clone());
5509 let stranger: Arc<dyn Signer> = Arc::new(LocalSigner::generate(TokenAlg::Es256));
5510 let inner = rt.inner.as_ref().unwrap();
5511
5512 let tenancy = Tenancy::Scoped {
5514 column: "tenant_id".into(),
5515 sources: vec![TenantSource::SignedContext],
5516 read: AccessMode::Own,
5517 write: AccessMode::Own,
5518 };
5519 let site = HandlersSiteConfig {
5520 enabled: true,
5521 allow_imports: vec!["sql".into()],
5522 ..Default::default()
5524 };
5525 let imports = vec!["sql".to_string()];
5526 let rebuild = ConsumerRebuild {
5527 inner,
5528 project: ProjectRef::new("default"),
5529 site: "worker",
5530 scope: "worker",
5531 imports: &imports,
5532 site_handlers: &site,
5533 tenancy: Some(&tenancy),
5534 token_claims: None,
5535 };
5536
5537 async fn read_bodies(
5539 db: &dyn boatramp_core::sql::SqlBackend,
5540 scope: &boatramp_core::orm::Scope,
5541 ) -> Vec<String> {
5542 let mut q = Select {
5543 columns: vec![SelectItem {
5544 expr: Expr::col("body"),
5545 alias: None,
5546 }],
5547 ..Select::from("notes")
5548 };
5549 q.force_scope(scope).unwrap();
5550 let (sql, params) = q.compile(Dialect::Sqlite).unwrap();
5551 let mut tx = db.begin().await.unwrap();
5552 let rows = tx.query(&sql, ¶ms).await.expect("query runs");
5553 tx.commit().await.unwrap();
5554 let mut out: Vec<String> = rows
5555 .rows
5556 .into_iter()
5557 .flatten()
5558 .filter_map(|v| match v {
5559 SqlValue::Text(s) => Some(s),
5560 _ => None,
5561 })
5562 .collect();
5563 out.sort();
5564 out
5565 }
5566
5567 let now = boatramp_core::time::now_unix();
5568
5569 let env = mint_context("acme", 3600, now, signer.as_ref())
5571 .await
5572 .unwrap();
5573 let sealed = rebuild.bindings_for(Some(&env)).await.unwrap();
5574 let ht = sealed
5575 .resolved_tenancy()
5576 .expect("a signed-context consumer resolves a tenancy");
5577 assert_eq!(
5578 ht.value(),
5579 Some(&SqlValue::Text("acme".into())),
5580 "the sealed envelope's tenant is the consumer's own principal"
5581 );
5582 assert!(
5584 ht.facts()
5585 .iter()
5586 .any(|f| f.value == SqlValue::Text("acme".into())),
5587 "the resolved fact carries acme (this is the graphql::run caller principal too)"
5588 );
5589 let read_scope = ht.orm_scope(TenantAxis::Read).unwrap().unwrap();
5590 assert_eq!(
5591 read_bodies(db.as_ref(), &read_scope).await,
5592 vec!["acme-note".to_string()],
5593 "the resolved tenant scopes a real engine to acme's row ONLY (never globex)"
5594 );
5595 assert!(
5597 ht.orm_scope(TenantAxis::Write).unwrap().is_some(),
5598 "the write axis resolves the sealed tenant"
5599 );
5600
5601 let plain = rebuild.bindings_for(None).await.unwrap();
5603 let ht = plain
5604 .resolved_tenancy()
5605 .expect("the tenancy decision is present (but factless)");
5606 assert!(
5607 ht.value().is_none(),
5608 "an unsealed message resolves no own tenant"
5609 );
5610 assert!(
5611 matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5612 "a scoped op with no resolved tenant fails closed (never runs unscoped)"
5613 );
5614
5615 let forged = mint_context("globex", 3600, now, stranger.as_ref())
5617 .await
5618 .unwrap();
5619 let forged_b = rebuild.bindings_for(Some(&forged)).await.unwrap();
5620 let ht = forged_b
5621 .resolved_tenancy()
5622 .expect("the tenancy decision is present (but factless)");
5623 assert!(
5624 ht.value().is_none(),
5625 "a stranger-signed envelope resolves no own tenant (never masquerades as globex)"
5626 );
5627 assert!(
5628 matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5629 "a forged envelope fails the scoped op closed"
5630 );
5631
5632 let expired = mint_context("acme", 3600, now.saturating_sub(7200), signer.as_ref())
5635 .await
5636 .unwrap();
5637 let expired_b = rebuild.bindings_for(Some(&expired)).await.unwrap();
5638 let ht = expired_b
5639 .resolved_tenancy()
5640 .expect("the tenancy decision is present (but factless)");
5641 assert!(
5642 ht.value().is_none()
5643 && matches!(ht.orm_scope(TenantAxis::Read), Err(TenantDenied::NoSource)),
5644 "an expired envelope resolves no own tenant and fails the scoped op closed"
5645 );
5646
5647 let env_b = mint_context("globex", 3600, now, signer.as_ref())
5652 .await
5653 .unwrap();
5654 let a = rebuild.bindings_for(Some(&env)).await.unwrap();
5655 let b = rebuild.bindings_for(Some(&env_b)).await.unwrap();
5656 let ht_a = a.resolved_tenancy().unwrap();
5657 let ht_b = b.resolved_tenancy().unwrap();
5658 assert_eq!(ht_a.value(), Some(&SqlValue::Text("acme".into())));
5659 assert_eq!(ht_b.value(), Some(&SqlValue::Text("globex".into())));
5660 assert_eq!(
5661 read_bodies(
5662 db.as_ref(),
5663 &ht_a.orm_scope(TenantAxis::Read).unwrap().unwrap()
5664 )
5665 .await,
5666 vec!["acme-note".to_string()],
5667 "message A stays scoped to acme"
5668 );
5669 assert_eq!(
5670 read_bodies(
5671 db.as_ref(),
5672 &ht_b.orm_scope(TenantAxis::Read).unwrap().unwrap()
5673 )
5674 .await,
5675 vec!["globex-note".to_string()],
5676 "message B (interleaved) scopes to globex ONLY — no bleed from A's binding"
5677 );
5678
5679 println!(
5680 "CONSUMER SIGNED-CONTEXT DISPATCH OK: the site-consumers async lane resolves each \
5681 claimed message's host-sealed signed_context per message; a valid fleet-signed \
5682 envelope scopes a real libsql engine to the originator's tenant ONLY, while an \
5683 unsealed or forged message resolves no principal and fails an own op closed (never \
5684 cross-tenant, never unscoped). The resolved principal is the same value that \
5685 propagates onto a graphql::run sub-fetch."
5686 );
5687
5688 let _ = std::fs::remove_dir_all(&sql_dir);
5689 }
5690}