1use std::collections::HashMap;
10use std::sync::Arc;
11use std::time::Duration;
12
13use axum::Router;
14use axum::extract::{DefaultBodyLimit, State};
15use axum::http::{HeaderMap, StatusCode, header};
16use axum::middleware::from_fn_with_state;
17use axum::response::IntoResponse;
18use axum::routing::get;
19use serde_json::json;
20use tracing::error;
21
22use crate::admin::require_admin;
23use crate::config::Config;
24use crate::config::ConfigError;
25use crate::events::EventBus;
26use crate::http::{
27 Json, MAX_BODY_BYTES, ScopeState, cors_layer, scope_layer, security_headers_layer,
28};
29use crate::module::{HARNESS_API, Module, ModuleContext, harness_api_mismatch};
30use crate::ports::Dispatcher;
31use crate::ports::{Clock, Database, Port, Ports, Statement, SystemClock, warn_undeclared_ports};
32use crate::problem::Problem;
33use crate::sidecar::SidecarMount;
34use crate::surface::{RenderedSurface, SurfaceDocument, SurfaceSource, UiContext, UiMount};
35use crate::template::{Template, TemplateRegistry};
36use crate::venture::Venture;
37
38pub trait Runtime: Send + Sync + 'static {
43 fn provides(&self) -> Vec<Port>;
44}
45
46pub struct Harness {
48 venture: Arc<Venture>,
49 modules: Vec<Arc<dyn Module>>,
50 templates: Arc<TemplateRegistry>,
51 events: EventBus,
52 runtime: Option<Arc<dyn Runtime>>,
53 well_known: Option<Router>,
56 surface: Arc<SurfaceVariants>,
59 ui: Option<Arc<dyn UiMount>>,
61}
62
63struct SurfaceVariants {
64 document: Arc<SurfaceDocument>,
65 full: RenderedSurface,
66 public: RenderedSurface,
67}
68
69impl SurfaceVariants {
70 fn compose(
71 venture: &Venture,
72 modules: &[Arc<dyn Module>],
73 ui: Option<&Arc<dyn UiMount>>,
74 ) -> Self {
75 let mut document = SurfaceDocument::compose(venture, modules);
76 document.ui = ui.and_then(|ui| ui.describe());
77 Self {
78 full: RenderedSurface::render(&document),
79 public: RenderedSurface::render(&document.public()),
80 document: Arc::new(document),
81 }
82 }
83}
84
85impl std::fmt::Debug for Harness {
86 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
87 let modules: Vec<&str> = self.modules.iter().map(|m| m.name()).collect();
88 f.debug_struct("Harness")
89 .field("venture", &self.venture.name)
90 .field("modules", &modules)
91 .finish_non_exhaustive()
92 }
93}
94
95impl Harness {
96 pub fn builder() -> HarnessBuilder {
97 HarnessBuilder::default()
98 }
99
100 pub fn venture(&self) -> &Arc<Venture> {
101 &self.venture
102 }
103
104 pub fn modules(&self) -> &[Arc<dyn Module>] {
105 &self.modules
106 }
107
108 pub fn templates(&self) -> &Arc<TemplateRegistry> {
109 &self.templates
110 }
111
112 pub fn events(&self) -> &EventBus {
113 &self.events
114 }
115
116 pub fn module_context(&self, module: &dyn Module, ports: &Ports) -> ModuleContext {
121 ModuleContext {
122 config: Arc::clone(&ports.config),
123 ports: ports.view_for(module),
124 events: self.events.clone(),
125 templates: Arc::clone(&self.templates),
126 venture: Arc::clone(&self.venture),
127 ui_mounted: self.ui.is_some(),
128 }
129 }
130
131 fn sidecar_mounts(&self, ports: &Ports) -> Vec<SidecarMount> {
137 let mounts = match crate::sidecar::SidecarMounts::from_config(ports.config.as_ref()) {
138 Ok(mounts) => mounts,
139 Err(errors) => {
140 for error in errors {
141 tracing::error!(error, "ignoring the sidecar mount table");
142 }
143 crate::sidecar::SidecarMounts::default()
144 }
145 };
146 let module_names: Vec<&str> = self.modules.iter().map(|m| m.name()).collect();
147 for collision in mounts.collisions(&module_names) {
148 tracing::error!(error = collision, "ignoring the colliding sidecar mount");
149 }
150 mounts
151 .iter()
152 .filter(|m| !module_names.contains(&m.name.as_str()))
153 .cloned()
154 .collect()
155 }
156
157 pub fn runtime(&self) -> Option<&Arc<dyn Runtime>> {
159 self.runtime.as_ref()
160 }
161
162 #[must_use]
166 pub fn surface(&self) -> &SurfaceDocument {
167 &self.surface.document
168 }
169
170 pub fn router(&self, ports: Ports) -> Router {
178 let mut api = Router::new();
179 for module in &self.modules {
180 let ctx = self.module_context(module.as_ref(), &ports);
181 api = api.nest(&format!("/v1/{}", module.name()), module.router(ctx));
182 }
183 let mounted = self.sidecar_mounts(&ports);
184 for mount in &mounted {
185 api = api.nest(
186 &format!("/v1/{}", mount.name),
187 crate::sidecar::router(mount.clone(), ports.dispatcher.clone()),
188 );
189 }
190 let api = api
191 .layer(axum::middleware::from_fn(security_headers_layer))
192 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES));
193
194 let surface_source: Arc<dyn SurfaceSource> = Arc::new(MergedSurface {
195 base: Arc::clone(&self.surface),
196 mounts: mounted,
197 dispatcher: ports.dispatcher.clone(),
198 });
199
200 let ui = self.ui.as_ref().map(|ui| {
201 ui.router(UiContext {
202 surface: Arc::clone(&surface_source),
203 api: api.clone(),
204 config: Arc::clone(&ports.config),
205 venture: Arc::clone(&self.venture),
206 captcha_configured: ports.captcha.is_some(),
207 signer: ports.signer.clone(),
208 rate_limiter: ports.rate_limiter.clone(),
209 })
210 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
211 });
212
213 let Ports {
214 config,
215 db,
216 mailer,
217 captcha,
218 rate_limiter: _,
219 signer: _,
220 kv: _,
221 blob: _,
222 push: _,
223 payments: _,
224 http: _,
225 clock,
226 id_gen,
227 defer,
228 dispatcher: _,
229 } = ports;
230
231 let health_state = HealthState {
232 venture: Arc::clone(&self.venture),
233 modules: self.modules.clone(),
234 harness_build: config
235 .get("HARNESS_BUILD")
236 .filter(|build| !build.is_empty()),
237 mailer_configured: mailer.is_some(),
238 captcha_configured: captcha.is_some(),
239 };
240
241 let scope_state = ScopeState {
242 defer: defer.unwrap_or_else(|| Arc::new(crate::ports::NoopDefer)),
243 id_gen: id_gen.unwrap_or_else(|| Arc::new(crate::ports::UlidIdGen)),
244 };
245 let ready_state = ReadyState {
246 db,
247 clock: clock.unwrap_or_else(|| Arc::new(SystemClock)),
248 };
249
250 let surface_state = SurfaceState {
251 config,
252 source: surface_source,
253 };
254
255 let root = Router::new()
256 .route("/__health", get(health_handler))
257 .with_state(health_state)
258 .route("/__ready", get(ready_handler))
259 .with_state(ready_state)
260 .route("/__surface", get(surface_handler))
261 .with_state(surface_state)
262 .merge(api);
263 let root = match &self.well_known {
264 Some(well_known) => root.nest(
265 "/.well-known",
266 well_known
267 .clone()
268 .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)),
269 ),
270 None => root,
271 };
272 let root = match ui {
273 Some(ui) => root.nest("/ui", ui),
274 None => root,
275 };
276
277 root.layer(from_fn_with_state(scope_state, scope_layer))
278 .layer(cors_layer(&self.venture.cors_origins))
279 }
280}
281
282#[derive(Clone)]
283struct HealthState {
284 venture: Arc<Venture>,
285 modules: Vec<Arc<dyn Module>>,
286 harness_build: Option<String>,
288 mailer_configured: bool,
289 captcha_configured: bool,
290}
291
292async fn health_handler(State(state): State<HealthState>) -> impl IntoResponse {
293 let modules: Vec<serde_json::Value> = state
294 .modules
295 .iter()
296 .map(|module| {
297 json!({
298 "name": module.name(),
299 "version": module.version(),
300 "emits": module.emits(),
301 })
302 })
303 .collect();
304 Json(json!({
305 "venture": state.venture.name,
306 "env": state.venture.env.as_str(),
307 "harness_api": HARNESS_API,
308 "harness_build": state.harness_build,
309 "mailer": if state.mailer_configured { "configured" } else { "not_configured" },
312 "captcha": if state.captcha_configured { "configured" } else { "absent" },
313 "modules": modules,
314 }))
315}
316
317#[derive(Clone)]
318struct ReadyState {
319 db: Option<Arc<dyn Database>>,
320 clock: Arc<dyn Clock>,
321}
322
323async fn ready_handler(State(state): State<ReadyState>) -> impl IntoResponse {
327 let Some(db) = state.db else {
328 return Problem::not_ready("database port is not configured").into_response();
329 };
330 let stmt = Statement::new("SELECT 1");
331 let query = async move { db.query(&stmt).await };
332 match crate::ports::timeout(&*state.clock, query, Duration::from_secs(2)).await {
333 Some(Ok(_rows)) => Json(json!({ "ok": true })).into_response(),
334 Some(Err(err)) => {
335 error!(error = %err, "readiness probe query failed");
336 crate::logging::forward_internal_error(&format!("readiness probe query failed: {err}"));
337 Problem::not_ready("database query failed").into_response()
338 }
339 None => Problem::not_ready("database did not answer within 2 s").into_response(),
340 }
341}
342
343#[derive(Clone)]
344struct SurfaceState {
345 config: Arc<dyn Config>,
346 source: Arc<dyn SurfaceSource>,
347}
348
349struct MergedSurface {
356 base: Arc<SurfaceVariants>,
357 mounts: Vec<SidecarMount>,
358 dispatcher: Option<Arc<dyn Dispatcher>>,
359}
360
361impl MergedSurface {
362 async fn sidecar_modules(&self) -> Vec<crate::surface::ModuleSurface> {
364 let mut extra = Vec::new();
365 let Some(dispatcher) = &self.dispatcher else {
366 return extra;
367 };
368 for mount in &self.mounts {
369 if !dispatcher.has(&mount.binding) {
370 continue;
371 }
372 let request = axum::http::Request::builder()
373 .method(axum::http::Method::GET)
374 .uri("/__surface")
375 .header(header::ACCEPT, "application/json")
376 .body(bytes::Bytes::new())
377 .expect("static request builds");
378 let answer = match dispatcher.dispatch(&mount.binding, request).await {
379 Ok(response) if response.status().is_success() => response,
380 Ok(response) => {
381 tracing::warn!(module = mount.name, status = %response.status(), "sidecar surface not available");
382 continue;
383 }
384 Err(err) => {
385 tracing::warn!(module = mount.name, error = %err, "sidecar surface fetch failed");
386 continue;
387 }
388 };
389 match serde_json::from_slice::<SurfaceDocument>(answer.body()) {
390 Ok(document) => extra.extend(
391 document
392 .modules
393 .into_iter()
394 .filter(|m| m.name == mount.name)
395 .map(|m| crate::surface::ModuleSurface {
396 name: m.name,
397 version: m.version,
398 surface: m.surface.public(),
399 }),
400 ),
401 Err(err) => {
402 tracing::warn!(module = mount.name, error = %err, "sidecar surface is not a surface document");
403 }
404 }
405 }
406 extra
407 }
408}
409
410#[async_trait::async_trait]
411impl SurfaceSource for MergedSurface {
412 async fn current(&self) -> Arc<SurfaceDocument> {
413 if self.mounts.is_empty() {
414 return Arc::clone(&self.base.document);
415 }
416 let mut document = (*self.base.document).clone();
417 document.modules.extend(self.sidecar_modules().await);
418 Arc::new(document)
419 }
420
421 fn built(&self) -> Arc<SurfaceDocument> {
422 Arc::clone(&self.base.document)
423 }
424
425 fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
426 Some(if admin {
427 &self.base.full
428 } else {
429 &self.base.public
430 })
431 }
432}
433
434async fn surface_handler(
441 State(state): State<SurfaceState>,
442 headers: HeaderMap,
443) -> impl IntoResponse {
444 let admin = require_admin(&*state.config, &headers).is_ok();
445 let current = state.source.current().await;
449 let built = state.source.built();
450 let prerendered = Arc::ptr_eq(¤t, &built).then(|| state.source.rendered(admin));
451 let fresh;
452 let rendered: &RenderedSurface = if let Some(rendered) = prerendered.flatten() {
453 rendered
454 } else {
455 fresh = if admin {
456 RenderedSurface::render(¤t)
457 } else {
458 RenderedSurface::render(¤t.public())
459 };
460 &fresh
461 };
462 let matches = headers
463 .get(header::IF_NONE_MATCH)
464 .and_then(|value| value.to_str().ok())
465 .is_some_and(|value| {
466 value
467 .split(',')
468 .map(str::trim)
469 .any(|tag| tag == "*" || tag == rendered.etag)
470 });
471 let mut response = if matches {
472 StatusCode::NOT_MODIFIED.into_response()
473 } else {
474 (
475 [(header::CONTENT_TYPE, "application/json")],
476 rendered.json.clone(),
477 )
478 .into_response()
479 };
480 let response_headers = response.headers_mut();
481 response_headers.insert(
482 header::ETAG,
483 header::HeaderValue::from_str(&rendered.etag).expect("hex etag is a valid header"),
484 );
485 response_headers.insert(
486 header::CACHE_CONTROL,
487 header::HeaderValue::from_static("no-cache"),
488 );
489 response_headers.insert(
490 header::VARY,
491 header::HeaderValue::from_static("Authorization"),
492 );
493 response
494}
495
496#[derive(Default)]
499pub struct HarnessBuilder {
500 venture: Option<Venture>,
501 modules: Vec<Arc<dyn Module>>,
502 provides: Vec<Port>,
503 runtime: Option<Arc<dyn Runtime>>,
504 module_templates: Vec<(String, Box<dyn Template>)>,
505 overrides: Vec<(String, Box<dyn Template>)>,
506 ui: Option<Arc<dyn UiMount>>,
507}
508
509impl HarnessBuilder {
510 #[must_use]
511 pub fn venture(mut self, venture: Venture) -> Self {
512 self.venture = Some(venture);
513 self
514 }
515
516 #[must_use]
519 pub fn module(mut self, module: impl Module) -> Self {
520 self.modules.push(Arc::new(module));
521 self
522 }
523
524 #[must_use]
527 pub fn module_arc(mut self, module: Arc<dyn Module>) -> Self {
528 self.modules.push(module);
529 self
530 }
531
532 #[must_use]
536 pub fn runtime(mut self, runtime: impl Runtime) -> Self {
537 let runtime: Arc<dyn Runtime> = Arc::new(runtime);
538 self.provides = runtime.provides();
539 self.runtime = Some(runtime);
540 self
541 }
542
543 #[must_use]
546 pub fn templates(
547 mut self,
548 templates: impl IntoIterator<Item = (String, Box<dyn Template>)>,
549 ) -> Self {
550 self.module_templates.extend(templates);
551 self
552 }
553
554 #[must_use]
557 pub fn ui(mut self, ui: impl UiMount) -> Self {
558 self.ui = Some(Arc::new(ui));
559 self
560 }
561
562 #[must_use]
565 pub fn template(mut self, id: impl Into<String>, template: Box<dyn Template>) -> Self {
566 self.overrides.push((id.into(), template));
567 self
568 }
569
570 pub fn build(self) -> Result<Harness, ConfigError> {
581 let mut errors = ConfigError::default();
582
583 let venture = if let Some(venture) = self.venture {
584 venture.validate(&mut errors);
585 venture
586 } else {
587 errors.push("missing venture: call .venture(Venture::new(..)) before .build()");
588 Venture::new("invalid", "invalid.invalid")
589 };
590
591 let mut names: HashMap<&'static str, usize> = HashMap::new();
592 let mut tables: HashMap<&'static str, &'static str> = HashMap::new();
593
594 for module in &self.modules {
595 if module.harness_api() != HARNESS_API {
596 errors.push(harness_api_mismatch(module.as_ref()));
597 }
598
599 let name = module.name();
600 if name.is_empty() || !is_module_name(name) {
601 errors.push(format!(
602 "module name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
603 ));
604 }
605 match names.get(name) {
606 Some(_) => errors.push(format!("duplicate module name `{name}`")),
607 None => {
608 names.insert(name, 1);
609 }
610 }
611
612 for port in module.requires().iter().chain(module.optional()) {
613 if !Port::ALL.contains(port) {
614 errors.push(format!(
615 "module `{name}` declares unknown port {}",
616 port.name()
617 ));
618 }
619 }
620 for port in module.requires() {
621 if module.optional().contains(port) {
622 errors.push(format!(
623 "module `{name}` lists port {} in both requires() and optional()",
624 port.name()
625 ));
626 }
627 }
628
629 module.surface().validate(name, &mut errors);
630
631 for table in module.tables() {
632 match tables.get(table) {
633 Some(owner) => errors.push(format!(
634 "duplicate table `{table}` claimed by modules `{owner}` and `{name}`"
635 )),
636 None => {
637 tables.insert(table, name);
638 }
639 }
640 }
641 }
642
643 let well_known = collect_well_known(&self.modules, &mut errors);
644
645 for module in &self.modules {
646 for port in module.requires() {
647 if !self.provides.contains(port) {
648 errors.push(format!(
649 "module `{}` requires port {} which the runtime does not provide",
650 module.name(),
651 port.name()
652 ));
653 }
654 }
655 warn_undeclared_ports(module.as_ref(), &self.provides);
656 }
657
658 check_template_ids(
659 self.overrides.iter().chain(self.module_templates.iter()),
660 &names,
661 &mut errors,
662 );
663
664 let surface = Arc::new(SurfaceVariants::compose(
665 &venture,
666 &self.modules,
667 self.ui.as_ref(),
668 ));
669 if let Some(ui) = &self.ui {
670 ui.validate(&surface.document, &mut errors);
671 }
672
673 errors.into_result()?;
674
675 let mut registry = TemplateRegistry::new();
676 registry.register_all(self.module_templates);
677 registry.register_all(self.overrides);
678
679 let mut events = EventBus::new();
680 for module in &self.modules {
681 for (name, handler) in module.events() {
682 events = events.on(name, handler);
683 }
684 }
685
686 Ok(Harness {
687 venture: Arc::new(venture),
688 modules: self.modules,
689 templates: Arc::new(registry),
690 events,
691 runtime: self.runtime,
692 well_known,
693 surface,
694 ui: self.ui,
695 })
696 }
697}
698
699fn check_template_ids<'a>(
701 ids: impl Iterator<Item = &'a (String, Box<dyn Template>)>,
702 names: &HashMap<&'static str, usize>,
703 errors: &mut ConfigError,
704) {
705 for (id, _) in ids {
706 let Some(module_name) = id.split('/').next() else {
707 continue;
708 };
709 if !names.contains_key(module_name) {
710 errors.push(format!(
711 "template `{id}` names module `{module_name}` which is not registered"
712 ));
713 }
714 }
715}
716
717fn is_module_name(name: &str) -> bool {
718 name.split('-').all(|part| {
719 !part.is_empty()
720 && part
721 .chars()
722 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
723 })
724}
725
726fn collect_well_known(modules: &[Arc<dyn Module>], errors: &mut ConfigError) -> Option<Router> {
730 let mut providers: Vec<&'static str> = Vec::new();
731 let mut well_known = None;
732 for module in modules {
733 if let Some(router) = module.well_known() {
734 providers.push(module.name());
735 well_known = Some(router);
736 }
737 }
738 if providers.len() > 1 {
739 let listed = providers
740 .iter()
741 .map(|name| format!("`{name}`"))
742 .collect::<Vec<_>>()
743 .join(", ");
744 errors.push(format!(
745 "modules {listed} all provide a well-known router; at most one module may occupy /.well-known"
746 ));
747 }
748 well_known
749}