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            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    /// Git sha injected as a var by the deploy workflow (issue #14).
287    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        // Port presence: the Mailer/Captcha traits carry no probe, so a
310        // NotConfigured adapter still reports its port as configured.
311        "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
323/// `GET /__ready`: `SELECT 1` through the `Database` port with a 2 s
324/// timeout supplied by the runtime's clock; 503 problem on failure
325/// (architecture section 6).
326async 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
349/// The build-time surface plus whatever the mounted sidecars answer
350/// (issue #76). Sidecar surfaces are fetched on every call: a sidecar's
351/// own `/__surface` is prerendered, the service binding runs on the same
352/// thread (ADR 0009), and a cache here would hide a redeploy. Only the
353/// public part of a sidecar merges: its admin routes take its own token,
354/// which this host does not hold.
355struct MergedSurface {
356    base: Arc<SurfaceVariants>,
357    mounts: Vec<SidecarMount>,
358    dispatcher: Option<Arc<dyn Dispatcher>>,
359}
360
361impl MergedSurface {
362    /// What each mounted sidecar contributes, in mount order.
363    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
434/// `GET /__surface` (ADR 0010): the composed surface, public subset by
435/// default, admin actions included when `Authorization: Bearer
436/// <ADMIN_TOKEN>` is valid. A wrong or stale bearer is not an error here,
437/// it just gets the public document: this route exists to be read by
438/// renderers and tooling, and a `403` would leak whether admin is on.
439/// Strong `ETag` per variant; `If-None-Match` answers `304`.
440async 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    // With no sidecar the source hands back the build-time Arc, and the
446    // prerendered variants are reused; with sidecars the merged document
447    // is rendered per request (a hash, microseconds).
448    let current = state.source.current().await;
449    let built = state.source.built();
450    let prerendered = Arc::ptr_eq(&current, &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(&current)
457        } else {
458            RenderedSurface::render(&current.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/// Builder: `.venture(..)`, `.module(..)`, `.runtime(..)`, `.template(..)`,
497/// then `.build()`.
498#[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    /// Adds a module. Composition is compile-time: the wasm binary contains
517    /// exactly the modules listed here (ADR 0003).
518    #[must_use]
519    pub fn module(mut self, module: impl Module) -> Self {
520        self.modules.push(Arc::new(module));
521        self
522    }
523
524    /// Adds an already-shared module (`cratefield-testing` keeps handles to
525    /// apply migrations and run conformance).
526    #[must_use]
527    pub fn module_arc(mut self, module: Arc<dyn Module>) -> Self {
528        self.modules.push(module);
529        self
530    }
531
532    /// Declares the runtime: its `provides()` set drives build-time
533    /// checking of every module's `requires()`. The runtime is kept on the
534    /// built harness for tooling (`fz doctor`, scheduled fan-out).
535    #[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    /// Registers module default templates (`<module>/<template>` ids).
544    /// Call before overrides; see `template.rs` for the convention.
545    #[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    /// Mounts a UI renderer at `/ui` (ADR 0010): `cratefield_ui::Ui`. Off
555    /// unless called, so a venture without a UI serves nothing there.
556    #[must_use]
557    pub fn ui(mut self, ui: impl UiMount) -> Self {
558        self.ui = Some(Arc::new(ui));
559        self
560    }
561
562    /// Venture template override. Wins over any module default with the
563    /// same id; the id's module part must name a registered module.
564    #[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    /// Validates everything, collecting **all** problems before failing
571    /// (issue #2).
572    ///
573    /// # Errors
574    ///
575    /// `Err` whose `Display` lists every problem: invalid venture, unknown
576    /// or duplicated port declarations, duplicate module names, tables or
577    /// `/.well-known` routers, `harness_api` mismatches, invalid UI
578    /// surfaces, unprovided required ports, and template ids naming
579    /// unregistered modules.
580    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
699/// A template id's module part must name a registered module.
700fn 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
726/// Collects the modules' `/.well-known` routers (issue #46): at most one
727/// module may provide one — `/.well-known` is a singleton discovery
728/// namespace — and more is a build error naming every provider.
729fn 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}