Skip to main content

cratefield_core/
harness.rs

1//! The `Harness` builder and axum router assembly (issue #2, architecture
2//! section 4).
3//!
4//! `Harness::build()` collects **all** configuration problems and reports
5//! them together; `Harness::router(ports)` mounts every module under
6//! `/v1/<name>` and adds `GET /__health` and `GET /__ready` plus the
7//! middleware stack from architecture section 6.
8
9use 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
38/// A runtime resolves environment bindings into [`Ports`] and declares
39/// statically which ports it can provide, so `Harness::build` can reject a
40/// module that requires something the runtime will never hand it
41/// (ADR 0002). Reference implementation: `cratefield-runtime-cloudflare`.
42pub trait Runtime: Send + Sync + 'static {
43    fn provides(&self) -> Vec<Port>;
44}
45
46/// A built harness: immutable after `build()`.
47pub 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    /// The single module-provided `/.well-known` router, if any
54    /// (issue #46); nested at the root by `router()`.
55    well_known: Option<Router>,
56    /// The composed UI surface (ADR 0010), rendered once for
57    /// `GET /__surface`: the admin variant and the public subset.
58    surface: Arc<SurfaceVariants>,
59    /// The renderer mounted at `/ui`, if the venture chose one.
60    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    /// Builds the context a module sees: its declared ports (view), the
117    /// config, the shared bus, templates and venture. `router()` uses this
118    /// per module; `cratefield-runtime-cloudflare` uses it for scheduled
119    /// fan-out.
120    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    /// The sidecar mounts that apply: read from configuration, not from
132    /// the composition (ADR 0009), so the same artifact serves ventures
133    /// with and without them. A malformed table mounts nothing and is
134    /// logged; a mount that collides with an in-process module is
135    /// dropped and logged. Neither takes down the in-process modules.
136    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    /// The runtime this harness was validated against, if one was supplied.
158    pub fn runtime(&self) -> Option<&Arc<dyn Runtime>> {
159        self.runtime.as_ref()
160    }
161
162    /// The composed UI surface, admin actions included, for tooling
163    /// (`fz`, the control plane). `GET /__surface` serves the same
164    /// document, public subset unless the admin bearer is presented.
165    #[must_use]
166    pub fn surface(&self) -> &SurfaceDocument {
167        &self.surface.document
168    }
169
170    /// Assembles the full router: each module nested under `/v1/<name>`,
171    /// the one `/.well-known` router (if any) nested at the root,
172    /// `GET /__health`, `GET /__ready`, `GET /__surface`, the UI renderer
173    /// at `/ui` when one is mounted, and the shared middleware
174    /// (request-id/Scope, CORS allowlist, 64 KiB body limit, `/v1/*`
175    /// security headers). Nothing but `/.well-known`, `/ui` and the
176    /// `/__*` probes is ever mounted at the root.
177    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            http: _,
223            clock,
224            id_gen,
225            defer,
226            dispatcher: _,
227        } = ports;
228
229        let health_state = HealthState {
230            venture: Arc::clone(&self.venture),
231            modules: self.modules.clone(),
232            harness_build: config
233                .get("HARNESS_BUILD")
234                .filter(|build| !build.is_empty()),
235            mailer_configured: mailer.is_some(),
236            captcha_configured: captcha.is_some(),
237        };
238
239        let scope_state = ScopeState {
240            defer: defer.unwrap_or_else(|| Arc::new(crate::ports::NoopDefer)),
241            id_gen: id_gen.unwrap_or_else(|| Arc::new(crate::ports::UlidIdGen)),
242        };
243        let ready_state = ReadyState {
244            db,
245            clock: clock.unwrap_or_else(|| Arc::new(SystemClock)),
246        };
247
248        let surface_state = SurfaceState {
249            config,
250            source: surface_source,
251        };
252
253        let root = Router::new()
254            .route("/__health", get(health_handler))
255            .with_state(health_state)
256            .route("/__ready", get(ready_handler))
257            .with_state(ready_state)
258            .route("/__surface", get(surface_handler))
259            .with_state(surface_state)
260            .merge(api);
261        let root = match &self.well_known {
262            Some(well_known) => root.nest(
263                "/.well-known",
264                well_known
265                    .clone()
266                    .layer(DefaultBodyLimit::max(MAX_BODY_BYTES)),
267            ),
268            None => root,
269        };
270        let root = match ui {
271            Some(ui) => root.nest("/ui", ui),
272            None => root,
273        };
274
275        root.layer(from_fn_with_state(scope_state, scope_layer))
276            .layer(cors_layer(&self.venture.cors_origins))
277    }
278}
279
280#[derive(Clone)]
281struct HealthState {
282    venture: Arc<Venture>,
283    modules: Vec<Arc<dyn Module>>,
284    /// Git sha injected as a var by the deploy workflow (issue #14).
285    harness_build: Option<String>,
286    mailer_configured: bool,
287    captcha_configured: bool,
288}
289
290async fn health_handler(State(state): State<HealthState>) -> impl IntoResponse {
291    let modules: Vec<serde_json::Value> = state
292        .modules
293        .iter()
294        .map(|module| {
295            json!({
296                "name": module.name(),
297                "version": module.version(),
298                "emits": module.emits(),
299            })
300        })
301        .collect();
302    Json(json!({
303        "venture": state.venture.name,
304        "env": state.venture.env.as_str(),
305        "harness_api": HARNESS_API,
306        "harness_build": state.harness_build,
307        // Port presence: the Mailer/Captcha traits carry no probe, so a
308        // NotConfigured adapter still reports its port as configured.
309        "mailer": if state.mailer_configured { "configured" } else { "not_configured" },
310        "captcha": if state.captcha_configured { "configured" } else { "absent" },
311        "modules": modules,
312    }))
313}
314
315#[derive(Clone)]
316struct ReadyState {
317    db: Option<Arc<dyn Database>>,
318    clock: Arc<dyn Clock>,
319}
320
321/// `GET /__ready`: `SELECT 1` through the `Database` port with a 2 s
322/// timeout supplied by the runtime's clock; 503 problem on failure
323/// (architecture section 6).
324async fn ready_handler(State(state): State<ReadyState>) -> impl IntoResponse {
325    let Some(db) = state.db else {
326        return Problem::not_ready("database port is not configured").into_response();
327    };
328    let stmt = Statement::new("SELECT 1");
329    let query = async move { db.query(&stmt).await };
330    match crate::ports::timeout(&*state.clock, query, Duration::from_secs(2)).await {
331        Some(Ok(_rows)) => Json(json!({ "ok": true })).into_response(),
332        Some(Err(err)) => {
333            error!(error = %err, "readiness probe query failed");
334            Problem::not_ready("database query failed").into_response()
335        }
336        None => Problem::not_ready("database did not answer within 2 s").into_response(),
337    }
338}
339
340#[derive(Clone)]
341struct SurfaceState {
342    config: Arc<dyn Config>,
343    source: Arc<dyn SurfaceSource>,
344}
345
346/// The build-time surface plus whatever the mounted sidecars answer
347/// (issue #76). Sidecar surfaces are fetched on every call: a sidecar's
348/// own `/__surface` is prerendered, the service binding runs on the same
349/// thread (ADR 0009), and a cache here would hide a redeploy. Only the
350/// public part of a sidecar merges: its admin routes take its own token,
351/// which this host does not hold.
352struct MergedSurface {
353    base: Arc<SurfaceVariants>,
354    mounts: Vec<SidecarMount>,
355    dispatcher: Option<Arc<dyn Dispatcher>>,
356}
357
358impl MergedSurface {
359    /// What each mounted sidecar contributes, in mount order.
360    async fn sidecar_modules(&self) -> Vec<crate::surface::ModuleSurface> {
361        let mut extra = Vec::new();
362        let Some(dispatcher) = &self.dispatcher else {
363            return extra;
364        };
365        for mount in &self.mounts {
366            if !dispatcher.has(&mount.binding) {
367                continue;
368            }
369            let request = axum::http::Request::builder()
370                .method(axum::http::Method::GET)
371                .uri("/__surface")
372                .header(header::ACCEPT, "application/json")
373                .body(bytes::Bytes::new())
374                .expect("static request builds");
375            let answer = match dispatcher.dispatch(&mount.binding, request).await {
376                Ok(response) if response.status().is_success() => response,
377                Ok(response) => {
378                    tracing::warn!(module = mount.name, status = %response.status(), "sidecar surface not available");
379                    continue;
380                }
381                Err(err) => {
382                    tracing::warn!(module = mount.name, error = %err, "sidecar surface fetch failed");
383                    continue;
384                }
385            };
386            match serde_json::from_slice::<SurfaceDocument>(answer.body()) {
387                Ok(document) => extra.extend(
388                    document
389                        .modules
390                        .into_iter()
391                        .filter(|m| m.name == mount.name)
392                        .map(|m| crate::surface::ModuleSurface {
393                            name: m.name,
394                            version: m.version,
395                            surface: m.surface.public(),
396                        }),
397                ),
398                Err(err) => {
399                    tracing::warn!(module = mount.name, error = %err, "sidecar surface is not a surface document");
400                }
401            }
402        }
403        extra
404    }
405}
406
407#[async_trait::async_trait]
408impl SurfaceSource for MergedSurface {
409    async fn current(&self) -> Arc<SurfaceDocument> {
410        if self.mounts.is_empty() {
411            return Arc::clone(&self.base.document);
412        }
413        let mut document = (*self.base.document).clone();
414        document.modules.extend(self.sidecar_modules().await);
415        Arc::new(document)
416    }
417
418    fn built(&self) -> Arc<SurfaceDocument> {
419        Arc::clone(&self.base.document)
420    }
421
422    fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
423        Some(if admin {
424            &self.base.full
425        } else {
426            &self.base.public
427        })
428    }
429}
430
431/// `GET /__surface` (ADR 0010): the composed surface, public subset by
432/// default, admin actions included when `Authorization: Bearer
433/// <ADMIN_TOKEN>` is valid. A wrong or stale bearer is not an error here,
434/// it just gets the public document: this route exists to be read by
435/// renderers and tooling, and a `403` would leak whether admin is on.
436/// Strong `ETag` per variant; `If-None-Match` answers `304`.
437async fn surface_handler(
438    State(state): State<SurfaceState>,
439    headers: HeaderMap,
440) -> impl IntoResponse {
441    let admin = require_admin(&*state.config, &headers).is_ok();
442    // With no sidecar the source hands back the build-time Arc, and the
443    // prerendered variants are reused; with sidecars the merged document
444    // is rendered per request (a hash, microseconds).
445    let current = state.source.current().await;
446    let built = state.source.built();
447    let prerendered = Arc::ptr_eq(&current, &built).then(|| state.source.rendered(admin));
448    let fresh;
449    let rendered: &RenderedSurface = if let Some(rendered) = prerendered.flatten() {
450        rendered
451    } else {
452        fresh = if admin {
453            RenderedSurface::render(&current)
454        } else {
455            RenderedSurface::render(&current.public())
456        };
457        &fresh
458    };
459    let matches = headers
460        .get(header::IF_NONE_MATCH)
461        .and_then(|value| value.to_str().ok())
462        .is_some_and(|value| {
463            value
464                .split(',')
465                .map(str::trim)
466                .any(|tag| tag == "*" || tag == rendered.etag)
467        });
468    let mut response = if matches {
469        StatusCode::NOT_MODIFIED.into_response()
470    } else {
471        (
472            [(header::CONTENT_TYPE, "application/json")],
473            rendered.json.clone(),
474        )
475            .into_response()
476    };
477    let response_headers = response.headers_mut();
478    response_headers.insert(
479        header::ETAG,
480        header::HeaderValue::from_str(&rendered.etag).expect("hex etag is a valid header"),
481    );
482    response_headers.insert(
483        header::CACHE_CONTROL,
484        header::HeaderValue::from_static("no-cache"),
485    );
486    response_headers.insert(
487        header::VARY,
488        header::HeaderValue::from_static("Authorization"),
489    );
490    response
491}
492
493/// Builder: `.venture(..)`, `.module(..)`, `.runtime(..)`, `.template(..)`,
494/// then `.build()`.
495#[derive(Default)]
496pub struct HarnessBuilder {
497    venture: Option<Venture>,
498    modules: Vec<Arc<dyn Module>>,
499    provides: Vec<Port>,
500    runtime: Option<Arc<dyn Runtime>>,
501    module_templates: Vec<(String, Box<dyn Template>)>,
502    overrides: Vec<(String, Box<dyn Template>)>,
503    ui: Option<Arc<dyn UiMount>>,
504}
505
506impl HarnessBuilder {
507    #[must_use]
508    pub fn venture(mut self, venture: Venture) -> Self {
509        self.venture = Some(venture);
510        self
511    }
512
513    /// Adds a module. Composition is compile-time: the wasm binary contains
514    /// exactly the modules listed here (ADR 0003).
515    #[must_use]
516    pub fn module(mut self, module: impl Module) -> Self {
517        self.modules.push(Arc::new(module));
518        self
519    }
520
521    /// Adds an already-shared module (`cratefield-testing` keeps handles to
522    /// apply migrations and run conformance).
523    #[must_use]
524    pub fn module_arc(mut self, module: Arc<dyn Module>) -> Self {
525        self.modules.push(module);
526        self
527    }
528
529    /// Declares the runtime: its `provides()` set drives build-time
530    /// checking of every module's `requires()`. The runtime is kept on the
531    /// built harness for tooling (`fz doctor`, scheduled fan-out).
532    #[must_use]
533    pub fn runtime(mut self, runtime: impl Runtime) -> Self {
534        let runtime: Arc<dyn Runtime> = Arc::new(runtime);
535        self.provides = runtime.provides();
536        self.runtime = Some(runtime);
537        self
538    }
539
540    /// Registers module default templates (`<module>/<template>` ids).
541    /// Call before overrides; see `template.rs` for the convention.
542    #[must_use]
543    pub fn templates(
544        mut self,
545        templates: impl IntoIterator<Item = (String, Box<dyn Template>)>,
546    ) -> Self {
547        self.module_templates.extend(templates);
548        self
549    }
550
551    /// Mounts a UI renderer at `/ui` (ADR 0010): `cratefield_ui::Ui`. Off
552    /// unless called, so a venture without a UI serves nothing there.
553    #[must_use]
554    pub fn ui(mut self, ui: impl UiMount) -> Self {
555        self.ui = Some(Arc::new(ui));
556        self
557    }
558
559    /// Venture template override. Wins over any module default with the
560    /// same id; the id's module part must name a registered module.
561    #[must_use]
562    pub fn template(mut self, id: impl Into<String>, template: Box<dyn Template>) -> Self {
563        self.overrides.push((id.into(), template));
564        self
565    }
566
567    /// Validates everything, collecting **all** problems before failing
568    /// (issue #2).
569    ///
570    /// # Errors
571    ///
572    /// `Err` whose `Display` lists every problem: invalid venture, unknown
573    /// or duplicated port declarations, duplicate module names, tables or
574    /// `/.well-known` routers, `harness_api` mismatches, invalid UI
575    /// surfaces, unprovided required ports, and template ids naming
576    /// unregistered modules.
577    pub fn build(self) -> Result<Harness, ConfigError> {
578        let mut errors = ConfigError::default();
579
580        let venture = if let Some(venture) = self.venture {
581            venture.validate(&mut errors);
582            venture
583        } else {
584            errors.push("missing venture: call .venture(Venture::new(..)) before .build()");
585            Venture::new("invalid", "invalid.invalid")
586        };
587
588        let mut names: HashMap<&'static str, usize> = HashMap::new();
589        let mut tables: HashMap<&'static str, &'static str> = HashMap::new();
590
591        for module in &self.modules {
592            if module.harness_api() != HARNESS_API {
593                errors.push(harness_api_mismatch(module.as_ref()));
594            }
595
596            let name = module.name();
597            if name.is_empty() || !is_module_name(name) {
598                errors.push(format!(
599                    "module name `{name}` must be kebab-case ([a-z0-9]+ separated by '-')"
600                ));
601            }
602            match names.get(name) {
603                Some(_) => errors.push(format!("duplicate module name `{name}`")),
604                None => {
605                    names.insert(name, 1);
606                }
607            }
608
609            for port in module.requires().iter().chain(module.optional()) {
610                if !Port::ALL.contains(port) {
611                    errors.push(format!(
612                        "module `{name}` declares unknown port {}",
613                        port.name()
614                    ));
615                }
616            }
617            for port in module.requires() {
618                if module.optional().contains(port) {
619                    errors.push(format!(
620                        "module `{name}` lists port {} in both requires() and optional()",
621                        port.name()
622                    ));
623                }
624            }
625
626            module.surface().validate(name, &mut errors);
627
628            for table in module.tables() {
629                match tables.get(table) {
630                    Some(owner) => errors.push(format!(
631                        "duplicate table `{table}` claimed by modules `{owner}` and `{name}`"
632                    )),
633                    None => {
634                        tables.insert(table, name);
635                    }
636                }
637            }
638        }
639
640        let well_known = collect_well_known(&self.modules, &mut errors);
641
642        for module in &self.modules {
643            for port in module.requires() {
644                if !self.provides.contains(port) {
645                    errors.push(format!(
646                        "module `{}` requires port {} which the runtime does not provide",
647                        module.name(),
648                        port.name()
649                    ));
650                }
651            }
652            warn_undeclared_ports(module.as_ref(), &self.provides);
653        }
654
655        check_template_ids(
656            self.overrides.iter().chain(self.module_templates.iter()),
657            &names,
658            &mut errors,
659        );
660
661        let surface = Arc::new(SurfaceVariants::compose(
662            &venture,
663            &self.modules,
664            self.ui.as_ref(),
665        ));
666        if let Some(ui) = &self.ui {
667            ui.validate(&surface.document, &mut errors);
668        }
669
670        errors.into_result()?;
671
672        let mut registry = TemplateRegistry::new();
673        registry.register_all(self.module_templates);
674        registry.register_all(self.overrides);
675
676        let mut events = EventBus::new();
677        for module in &self.modules {
678            for (name, handler) in module.events() {
679                events = events.on(name, handler);
680            }
681        }
682
683        Ok(Harness {
684            venture: Arc::new(venture),
685            modules: self.modules,
686            templates: Arc::new(registry),
687            events,
688            runtime: self.runtime,
689            well_known,
690            surface,
691            ui: self.ui,
692        })
693    }
694}
695
696/// A template id's module part must name a registered module.
697fn check_template_ids<'a>(
698    ids: impl Iterator<Item = &'a (String, Box<dyn Template>)>,
699    names: &HashMap<&'static str, usize>,
700    errors: &mut ConfigError,
701) {
702    for (id, _) in ids {
703        let Some(module_name) = id.split('/').next() else {
704            continue;
705        };
706        if !names.contains_key(module_name) {
707            errors.push(format!(
708                "template `{id}` names module `{module_name}` which is not registered"
709            ));
710        }
711    }
712}
713
714fn is_module_name(name: &str) -> bool {
715    name.split('-').all(|part| {
716        !part.is_empty()
717            && part
718                .chars()
719                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
720    })
721}
722
723/// Collects the modules' `/.well-known` routers (issue #46): at most one
724/// module may provide one — `/.well-known` is a singleton discovery
725/// namespace — and more is a build error naming every provider.
726fn collect_well_known(modules: &[Arc<dyn Module>], errors: &mut ConfigError) -> Option<Router> {
727    let mut providers: Vec<&'static str> = Vec::new();
728    let mut well_known = None;
729    for module in modules {
730        if let Some(router) = module.well_known() {
731            providers.push(module.name());
732            well_known = Some(router);
733        }
734    }
735    if providers.len() > 1 {
736        let listed = providers
737            .iter()
738            .map(|name| format!("`{name}`"))
739            .collect::<Vec<_>>()
740            .join(", ");
741        errors.push(format!(
742            "modules {listed} all provide a well-known router; at most one module may occupy /.well-known"
743        ));
744    }
745    well_known
746}