Skip to main content

cratefield_core/
surface.rs

1//! The UI surface (ADR 0010, issue #70). A module declares the actions it
2//! serves and the views that compose them; `Harness::build` validates the
3//! declaration and `GET /__surface` serves the composed document.
4//!
5//! An axum `Router` is opaque, so nothing here is discovered: a module says
6//! what it offers, and the input schema of each action is derived with
7//! `schemars` from the same serde type the handler deserializes, which is
8//! what keeps the declaration from drifting.
9//!
10//! UI hints ride on the schema as `x-cf-*` extension keywords set with
11//! `#[schemars(extend("x-cf-label" = "Email"))]` on a field. The keywords
12//! the renderer understands are listed in [`HINT_KEYWORDS`].
13
14use std::collections::{BTreeSet, HashSet};
15
16use http::Method;
17use schemars::{JsonSchema, Schema, SchemaGenerator};
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::config::ConfigError;
22use crate::module::{HARNESS_API, Module};
23use crate::venture::Venture;
24
25/// Contract version of the surface document, independent of
26/// [`HARNESS_API`]: a renderer or the control plane checks it before
27/// reading the document.
28pub const SURFACE_API: u32 = 1;
29
30/// Largest sidecar surface document the host will merge (issue #131). A
31/// whole module's declaration is a few kilobytes; anything approaching
32/// this cap is hostile or accidental, and parsing it costs the host.
33pub const MAX_SIDECAR_SURFACE_BYTES: usize = 256 * 1024;
34/// Largest action list a merged sidecar surface may declare (issue #131).
35pub const MAX_SIDECAR_ACTIONS: usize = 64;
36/// Largest view list a merged sidecar surface may declare (issue #131).
37pub const MAX_SIDECAR_VIEWS: usize = 64;
38
39/// The `x-cf-*` extension keywords the renderer understands on a field
40/// schema. Anything else under `x-cf-` is ignored, never an error, so a
41/// module can target a newer renderer than the one that serves it.
42///
43/// | Keyword | Value | Meaning |
44/// |---|---|---|
45/// | `x-cf-label` | string | Field label; defaults to the field name |
46/// | `x-cf-placeholder` | string | Input placeholder |
47/// | `x-cf-help` | string | Help text under the input |
48/// | `x-cf-widget` | `"text"`, `"email"`, `"select"`, `"textarea"`, `"checkbox"`, `"hidden"` | Input widget; inferred from the schema when absent |
49/// | `x-cf-hidden` | bool | Never rendered; the renderer supplies it (`captchaToken`) or omits it |
50/// | `x-cf-options` | array of `{value, label}` | Choices for a `select`, when `enum` on the schema is not enough |
51pub const HINT_KEYWORDS: &[&str] = &[
52    "x-cf-label",
53    "x-cf-placeholder",
54    "x-cf-help",
55    "x-cf-widget",
56    "x-cf-hidden",
57    "x-cf-options",
58];
59
60/// Who an action is for. Drives the public/admin split of `/__surface`
61/// and, in the renderer, which pages need the admin session.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(rename_all = "kebab-case")]
64pub enum Audience {
65    /// Anyone; the action is the venture's public face (`join`,
66    /// `subscribe`).
67    Public,
68    /// Needs `Authorization: Bearer <ADMIN_TOKEN>`; path must be under
69    /// `/admin/`.
70    Admin,
71    /// Reached only through a signed link the module minted (`confirm`,
72    /// `unsubscribe`, `status`); rendered as a landing page, never as a
73    /// form.
74    Link,
75}
76
77/// What the browser should do with a successful response.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79#[serde(tag = "kind", rename_all = "kebab-case")]
80pub enum Outcome {
81    /// The module answers `202` (or `200`) with nothing the user needs to
82    /// see; show `message`.
83    Accepted { message: String },
84    /// The module answers with a redirect the browser follows.
85    Redirect,
86    /// The module answers with a JSON body the view renders (`status`).
87    Json,
88}
89
90/// One route the module serves, described for a renderer.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct Action {
93    /// Kebab-case, unique within the module (`join`, `confirm`,
94    /// `export-csv`).
95    pub name: String,
96    /// HTTP method, serialized as its upper-case name.
97    #[serde(with = "method_serde")]
98    pub method: Method,
99    /// Path relative to `/v1/<module>`, always starting with `/`.
100    pub path: String,
101    pub audience: Audience,
102    /// JSON Schema of the request body (or of the query for a `GET`).
103    /// `None` for an action that takes nothing.
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub input: Option<Schema>,
106    pub outcome: Outcome,
107    /// The explicit protection this route demands (issue #133). Kept in
108    /// sync with [`captcha`](Self::captcha) by the builders and asserted
109    /// consistent by [`Surface::validate`]: `true` ⟺ [`HumanForm`].
110    /// Deliberately not serialized: the surface document's wire shape is
111    /// unchanged (`captcha` carries the human-route signal; machine
112    /// routes never reach the public subset), so existing consumers and
113    /// the compatibility-doc example are unaffected.
114    ///
115    /// [`HumanForm`]: crate::route_policy::RoutePolicy::HumanForm
116    #[serde(skip)]
117    pub policy: crate::route_policy::RoutePolicy,
118    /// Whether the module verifies a captcha token on this action and the
119    /// renderer includes the widget. A legacy mirror of `policy ==
120    /// HumanForm` — the surface JSON keeps the `captcha` key for existing
121    /// consumers; new code declares a `policy`.
122    pub captcha: bool,
123}
124
125impl Action {
126    /// A public `POST` that answers `202`, the common case for a signup
127    /// form. Add `.input::<Body>()`, `.captcha()` and friends.
128    #[must_use]
129    pub fn post(name: impl Into<String>, path: impl Into<String>) -> Self {
130        Self::new(name, Method::POST, path)
131    }
132
133    /// A `GET`. Audience defaults to `Link` because a module's `GET`s are
134    /// the signed-link landings; call `.audience(..)` otherwise.
135    #[must_use]
136    pub fn get(name: impl Into<String>, path: impl Into<String>) -> Self {
137        Self::new(name, Method::GET, path)
138            .audience(Audience::Link)
139            .outcome(Outcome::Redirect)
140    }
141
142    /// A `DELETE`, admin by default.
143    #[must_use]
144    pub fn delete(name: impl Into<String>, path: impl Into<String>) -> Self {
145        Self::new(name, Method::DELETE, path)
146            .audience(Audience::Admin)
147            .outcome(Outcome::Json)
148    }
149
150    #[must_use]
151    pub fn new(name: impl Into<String>, method: Method, path: impl Into<String>) -> Self {
152        Self {
153            name: name.into(),
154            method,
155            path: path.into(),
156            audience: Audience::Public,
157            input: None,
158            outcome: Outcome::Accepted {
159                message: "Thanks, you're in.".to_owned(),
160            },
161            policy: crate::route_policy::RoutePolicy::default(),
162            captcha: false,
163        }
164    }
165
166    #[must_use]
167    pub fn audience(mut self, audience: Audience) -> Self {
168        self.audience = audience;
169        self
170    }
171
172    /// Derives the input schema from the handler's own body type.
173    #[must_use]
174    pub fn input<T: JsonSchema>(mut self) -> Self {
175        self.input = Some(schema_for::<T>());
176        self
177    }
178
179    /// Supplies a schema built by hand or adjusted after derivation (a
180    /// `select` whose options come from runtime settings).
181    #[must_use]
182    pub fn input_schema(mut self, schema: Schema) -> Self {
183        self.input = Some(schema);
184        self
185    }
186
187    #[must_use]
188    pub fn outcome(mut self, outcome: Outcome) -> Self {
189        self.outcome = outcome;
190        self
191    }
192
193    /// Shorthand for `.outcome(Outcome::Accepted { message })`.
194    #[must_use]
195    pub fn accepted(self, message: impl Into<String>) -> Self {
196        self.outcome(Outcome::Accepted {
197            message: message.into(),
198        })
199    }
200
201    /// Shorthand for `.policy(RoutePolicy::HumanForm)` (issue #133).
202    #[must_use]
203    pub fn captcha(mut self) -> Self {
204        self.policy = crate::route_policy::RoutePolicy::HumanForm;
205        self.captcha = true;
206        self
207    }
208
209    /// Declares how this route proves its requests are legitimate
210    /// (issue #133). A payments webhook is `.policy(RoutePolicy::Signature)`
211    /// — never `.captcha()`, which would render a widget a machine cannot
212    /// fill and let the route be counted as captcha-protected.
213    #[must_use]
214    pub fn policy(mut self, policy: crate::route_policy::RoutePolicy) -> Self {
215        self.policy = policy;
216        self.captcha = policy == crate::route_policy::RoutePolicy::HumanForm;
217        self
218    }
219
220    /// Whether this route needs the human-form gate: the declared
221    /// [`HumanForm`] policy, or the legacy `captcha: true` mirror on an
222    /// otherwise-`Open` route. The one place that alias is resolved, so
223    /// the [`WriteGuards`] audit, the sidecar merge (issue #131) and the
224    /// UI dispatch all check the same predicate.
225    ///
226    /// [`HumanForm`]: crate::route_policy::RoutePolicy::HumanForm
227    /// [`WriteGuards`]: crate::route_policy::WriteGuards
228    #[must_use]
229    pub fn demands_captcha(&self) -> bool {
230        self.policy == crate::route_policy::RoutePolicy::HumanForm || self.captcha
231    }
232}
233
234/// A column of a [`View::Table`].
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct Column {
237    /// Field name in each row (the CSV header or JSON key).
238    pub key: String,
239    pub label: String,
240}
241
242impl Column {
243    #[must_use]
244    pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
245        Self {
246            key: key.into(),
247            label: label.into(),
248        }
249    }
250}
251
252/// How actions compose into something to render.
253#[derive(Debug, Clone, Serialize, Deserialize)]
254#[serde(tag = "kind", rename_all = "kebab-case")]
255pub enum View {
256    /// A form for one action.
257    Form { action: String },
258    /// A page that reads a `Json` action (the waitlist status).
259    Status { action: String },
260    /// A table over an action that returns rows (an admin export).
261    Table {
262        source: String,
263        columns: Vec<Column>,
264    },
265}
266
267impl View {
268    #[must_use]
269    pub fn form(action: impl Into<String>) -> Self {
270        View::Form {
271            action: action.into(),
272        }
273    }
274
275    #[must_use]
276    pub fn status(action: impl Into<String>) -> Self {
277        View::Status {
278            action: action.into(),
279        }
280    }
281
282    #[must_use]
283    pub fn table(source: impl Into<String>, columns: Vec<Column>) -> Self {
284        View::Table {
285            source: source.into(),
286            columns,
287        }
288    }
289
290    fn action_names(&self) -> Vec<&str> {
291        match self {
292            View::Form { action } | View::Status { action } => vec![action],
293            View::Table { source, .. } => vec![source],
294        }
295    }
296}
297
298/// What a module declares from [`Module::surface`].
299#[derive(Debug, Clone, Default, Serialize, Deserialize)]
300pub struct Surface {
301    pub actions: Vec<Action>,
302    pub views: Vec<View>,
303}
304
305impl Surface {
306    /// A module with no UI. The default of [`Module::surface`].
307    #[must_use]
308    pub fn none() -> Self {
309        Self::default()
310    }
311
312    #[must_use]
313    pub fn new() -> Self {
314        Self::default()
315    }
316
317    #[must_use]
318    pub fn action(mut self, action: Action) -> Self {
319        self.actions.push(action);
320        self
321    }
322
323    #[must_use]
324    pub fn view(mut self, view: View) -> Self {
325        self.views.push(view);
326        self
327    }
328
329    /// `true` when nothing is declared.
330    #[must_use]
331    pub fn is_empty(&self) -> bool {
332        self.actions.is_empty() && self.views.is_empty()
333    }
334
335    /// Validates the declaration for `module` into `errors`, collecting
336    /// every problem (the `Harness::build` convention): duplicate or
337    /// malformed action names, paths not starting with `/`, an `Admin`
338    /// action outside `/admin/` (or a non-admin one inside it), an input
339    /// schema that is not an object, and views naming unknown actions.
340    pub fn validate(&self, module: &str, errors: &mut ConfigError) {
341        let mut seen: HashSet<&str> = HashSet::new();
342        for action in &self.actions {
343            let name = action.name.as_str();
344            if !is_kebab(name) {
345                errors.push(format!(
346                    "module `{module}` surface action `{name}` must be kebab-case"
347                ));
348            }
349            if !seen.insert(name) {
350                errors.push(format!(
351                    "module `{module}` surface declares action `{name}` twice"
352                ));
353            }
354            if !action.path.starts_with('/') {
355                errors.push(format!(
356                    "module `{module}` surface action `{name}` path `{}` must start with '/' \
357                     (relative to /v1/{module})",
358                    action.path
359                ));
360            }
361            if action.path.split('/').any(|segment| segment == "..") {
362                errors.push(format!(
363                    "module `{module}` surface action `{name}` path `{}` must not contain '..' \
364                     segments (an action stays inside its module's mount)",
365                    action.path
366                ));
367            }
368            let under_admin = action.path == "/admin" || action.path.starts_with("/admin/");
369            match action.audience {
370                Audience::Admin if !under_admin => errors.push(format!(
371                    "module `{module}` surface action `{name}` is admin but its path `{}` \
372                     is not under /admin/",
373                    action.path
374                )),
375                Audience::Public | Audience::Link if under_admin => errors.push(format!(
376                    "module `{module}` surface action `{name}` is under /admin/ but its \
377                     audience is not admin",
378                )),
379                _ => {}
380            }
381            if let Some(schema) = &action.input
382                && !is_object_schema(schema)
383            {
384                errors.push(format!(
385                    "module `{module}` surface action `{name}` input schema must describe an \
386                     object (a struct with named fields), so a renderer can lay out fields"
387                ));
388            }
389            match action.policy {
390                crate::route_policy::RoutePolicy::HumanForm if !action.captcha => {
391                    errors.push(format!(
392                        "module `{module}` surface action `{name}` declares policy=HumanForm \
393                         but captcha=false; the renderer would omit the widget the policy \
394                         demands — declare protection with `.captcha()` (issue #133)",
395                    ));
396                }
397                crate::route_policy::RoutePolicy::Signature if action.captcha => {
398                    errors.push(format!(
399                        "module `{module}` surface action `{name}` declares policy=Signature \
400                         (a machine caller) but captcha=true; a captcha cannot protect a \
401                         webhook — drop `.captcha()` (issue #133)",
402                    ));
403                }
404                // `Open` accepts either: `captcha: true` is the legacy
405                // pre-#133 mirror (WriteGuards treats it as HumanForm),
406                // and an Open unprotected route is the default.
407                _ => {}
408            }
409        }
410        for view in &self.views {
411            for referenced in view.action_names() {
412                if !seen.contains(referenced) {
413                    errors.push(format!(
414                        "module `{module}` surface view references action `{referenced}` \
415                         which the module does not declare"
416                    ));
417                }
418            }
419        }
420    }
421
422    /// The subset a renderer may show without the admin session: every
423    /// non-admin action that is not a machine route, and every view that
424    /// references only those. [`Signature`](crate::route_policy::RoutePolicy::Signature)
425    /// actions (webhooks) are called by providers, never by browsers —
426    /// exposing them in the public surface was part of the issue #133
427    /// confusion (a captcha widget rendered on a route no human submits).
428    #[must_use]
429    pub fn public(&self) -> Surface {
430        let actions: Vec<Action> = self
431            .actions
432            .iter()
433            .filter(|action| {
434                action.audience != Audience::Admin
435                    && action.policy != crate::route_policy::RoutePolicy::Signature
436            })
437            .cloned()
438            .collect();
439        let names: BTreeSet<&str> = actions.iter().map(|a| a.name.as_str()).collect();
440        let views = self
441            .views
442            .iter()
443            .filter(|view| view.action_names().iter().all(|n| names.contains(n)))
444            .cloned()
445            .collect();
446        Surface { actions, views }
447    }
448}
449
450/// One module's entry in the composed document.
451#[derive(Debug, Clone, Serialize, Deserialize)]
452pub struct ModuleSurface {
453    pub name: String,
454    pub version: String,
455    #[serde(flatten)]
456    pub surface: Surface,
457}
458
459/// The venture identity a renderer needs.
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct VentureSurface {
462    pub name: String,
463    pub public_url: String,
464}
465
466/// The document `GET /__surface` serves: composed at `Harness::build`, one
467/// entry per module in mount order, modules with an empty surface omitted.
468#[derive(Debug, Clone, Serialize, Deserialize)]
469pub struct SurfaceDocument {
470    pub surface_api: u32,
471    pub harness_api: u32,
472    pub venture: VentureSurface,
473    pub modules: Vec<ModuleSurface>,
474    /// The mounted renderer's build-time configuration (`UiMount::describe`).
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub ui: Option<serde_json::Value>,
477}
478
479impl SurfaceDocument {
480    /// Composes the full document (admin actions included).
481    #[must_use]
482    pub fn compose(venture: &Venture, modules: &[std::sync::Arc<dyn Module>]) -> Self {
483        let modules = modules
484            .iter()
485            .map(|module| ModuleSurface {
486                name: module.name().to_owned(),
487                version: module.version().to_owned(),
488                surface: module.surface(),
489            })
490            .filter(|entry| !entry.surface.is_empty())
491            .collect();
492        Self {
493            surface_api: SURFACE_API,
494            harness_api: HARNESS_API,
495            venture: VentureSurface {
496                name: venture.name.clone(),
497                public_url: venture.public_url.clone(),
498            },
499            modules,
500            ui: None,
501        }
502    }
503
504    /// The public subset: admin actions and the views over them removed,
505    /// modules left with nothing omitted.
506    #[must_use]
507    pub fn public(&self) -> Self {
508        Self {
509            surface_api: self.surface_api,
510            harness_api: self.harness_api,
511            venture: self.venture.clone(),
512            modules: self
513                .modules
514                .iter()
515                .map(|entry| ModuleSurface {
516                    name: entry.name.clone(),
517                    version: entry.version.clone(),
518                    surface: entry.surface.public(),
519                })
520                .filter(|entry| !entry.surface.is_empty())
521                .collect(),
522            ui: self.ui.clone(),
523        }
524    }
525}
526
527/// Validates a sidecar's `/__surface` answer against the mount it is
528/// served from, and returns the single public entry to merge (issue #131).
529/// A sidecar speaks for exactly one module and only its public part ever
530/// merges — its admin actions stay behind its own token — so anything
531/// else in the document is rejected wholesale rather than filtered:
532/// a wrong contract version, a missing or duplicated mount entry, or a
533/// surface that fails [`Surface::validate`] or exceeds the declaration
534/// caps. `Ok(empty)` means the sidecar declared no surface for the mount,
535/// which contributes nothing and is not a failure.
536pub(crate) fn sanitize_sidecar_document(
537    document: &SurfaceDocument,
538    mount: &str,
539) -> Result<Vec<ModuleSurface>, Vec<String>> {
540    let mut errors = Vec::new();
541    if document.surface_api != SURFACE_API {
542        errors.push(format!(
543            "surface contract {} is not {SURFACE_API}",
544            document.surface_api
545        ));
546    }
547    if document.harness_api != HARNESS_API {
548        errors.push(format!(
549            "harness contract {} is not {HARNESS_API}",
550            document.harness_api
551        ));
552    }
553    let named: Vec<&ModuleSurface> = document
554        .modules
555        .iter()
556        .filter(|entry| entry.name == mount)
557        .collect();
558    match named.len() {
559        0 => {
560            if !document.modules.is_empty() {
561                let others: Vec<&str> = document.modules.iter().map(|m| m.name.as_str()).collect();
562                errors.push(format!(
563                    "declares no surface for the mounted module `{mount}` (it names [{}] instead)",
564                    others.join(", ")
565                ));
566            }
567        }
568        1 => {}
569        count => errors.push(format!("declares module `{mount}` {count} times")),
570    }
571    if let Some(entry) = named.first() {
572        if entry.surface.actions.len() > MAX_SIDECAR_ACTIONS {
573            errors.push(format!(
574                "declares {} actions, more than the {MAX_SIDECAR_ACTIONS} a merged surface allows",
575                entry.surface.actions.len()
576            ));
577        }
578        if entry.surface.views.len() > MAX_SIDECAR_VIEWS {
579            errors.push(format!(
580                "declares {} views, more than the {MAX_SIDECAR_VIEWS} a merged surface allows",
581                entry.surface.views.len()
582            ));
583        }
584        let mut validation = ConfigError::default();
585        entry.surface.validate(mount, &mut validation);
586        errors.extend(validation.problems);
587    }
588    if !errors.is_empty() {
589        return Err(errors);
590    }
591    Ok(named
592        .into_iter()
593        .map(|entry| ModuleSurface {
594            name: entry.name.clone(),
595            version: entry.version.clone(),
596            surface: entry.surface.public(),
597        })
598        .collect())
599}
600
601/// Removes every action that demands the human-form gate, and the views
602/// left dangling by it, returning how many actions were dropped (issue
603/// #131). A production host with no `Captcha` port cannot render or
604/// verify a captcha widget for a merged sidecar action, so the honest
605/// answer is that the action is not publicly offered here — the same
606/// refusal [`crate::route_policy::production_readiness`] makes at boot
607/// for in-process modules, enforced at merge time because a sidecar's
608/// declaration is not known then.
609pub(crate) fn strip_unguarded_captcha_actions(surface: &mut Surface) -> usize {
610    let kept: Vec<Action> = surface
611        .actions
612        .iter()
613        .filter(|action| !action.demands_captcha())
614        .cloned()
615        .collect();
616    let dropped = surface.actions.len() - kept.len();
617    if dropped > 0 {
618        let names: std::collections::BTreeSet<&str> =
619            kept.iter().map(|a| a.name.as_str()).collect();
620        surface
621            .views
622            .retain(|view| view.action_names().iter().all(|n| names.contains(n)));
623        surface.actions = kept;
624    }
625    dropped
626}
627
628/// Where the current surface comes from (issue #76). With no sidecar
629/// mounted this is the document composed at build; with sidecars, each
630/// call fetches every mounted sidecar's `/__surface` (public part) and
631/// merges it in, so a sidecar redeploy is seen on the next request
632/// (ADR 0009). An unreachable sidecar contributes nothing and is logged.
633#[async_trait::async_trait]
634pub trait SurfaceSource: Send + Sync {
635    /// The full document: admin actions of in-process modules included,
636    /// sidecar modules appended.
637    async fn current(&self) -> std::sync::Arc<SurfaceDocument>;
638    /// The build-time document alone, for checks that must not wait on a
639    /// network (a `UiSpec` validated against what the artifact ships).
640    fn built(&self) -> std::sync::Arc<SurfaceDocument>;
641    /// A prerendered build-time document (admin variant when `admin`),
642    /// if the source keeps one; `None` means render the current document.
643    fn rendered(&self, admin: bool) -> Option<&RenderedSurface> {
644        let _ = admin;
645        None
646    }
647}
648
649/// What a UI renderer gets from the harness (ADR 0010). Built by
650/// `Harness::router` for every router it assembles.
651pub struct UiContext {
652    /// The composed surface, admin actions included; the renderer applies
653    /// its own audience rules per page. Sidecar modules arrive through
654    /// [`SurfaceSource::current`].
655    pub surface: std::sync::Arc<dyn SurfaceSource>,
656    /// The `/v1` API router, for in-process dispatch: a form post becomes
657    /// the JSON request the module accepts and is sent through this
658    /// service, so every module layer runs and nothing leaves the process.
659    /// The request must carry the caller's [`crate::Scope`] in its
660    /// extensions, because the scope layer sits above `/v1`.
661    pub api: axum::Router,
662    pub config: std::sync::Arc<dyn crate::config::Config>,
663    pub venture: std::sync::Arc<Venture>,
664    /// Whether the `Captcha` port is configured, so the renderer knows to
665    /// include the widget on actions that declare `captcha`.
666    pub captcha_configured: bool,
667    /// The `Signer`, for the admin session cookie (issue #74). Absent
668    /// means no admin UI, the way an unset `ADMIN_TOKEN` does.
669    pub signer: Option<std::sync::Arc<dyn crate::ports::Signer>>,
670    /// The `RateLimiter`, for the admin login form.
671    pub rate_limiter: Option<std::sync::Arc<dyn crate::ports::RateLimiter>>,
672}
673
674/// A renderer the venture mounts at `/ui` with `HarnessBuilder::ui`
675/// (ADR 0010). Core defines the seam; `cratefield-ui` is the implementation,
676/// kept out of core so a venture without a UI carries no `maud`.
677pub trait UiMount: Send + Sync + 'static {
678    /// The router nested at `/ui`, built per `Harness::router` call.
679    fn router(&self, ctx: UiContext) -> axum::Router;
680    /// Build-time check of whatever the renderer was configured with (a
681    /// `UiSpec`) against the composed surface; problems go into the same
682    /// list as every other build error.
683    fn validate(&self, surface: &SurfaceDocument, errors: &mut ConfigError) {
684        let _ = (surface, errors);
685    }
686    /// The renderer's build-time configuration as JSON, published in the
687    /// surface document as `ui` so tooling can read the copy and theme a
688    /// venture ships with.
689    fn describe(&self) -> Option<serde_json::Value> {
690        None
691    }
692}
693
694/// A document serialized once, with the strong `ETag` clients revalidate
695/// against. Built at `Harness::build` for both the public and the admin
696/// variant.
697#[derive(Debug, Clone)]
698pub struct RenderedSurface {
699    pub json: String,
700    /// Quoted strong validator: `"<first 32 hex of sha256(json)>"`.
701    pub etag: String,
702}
703
704impl RenderedSurface {
705    #[must_use]
706    pub fn render(document: &SurfaceDocument) -> Self {
707        let json = serde_json::to_string(document).unwrap_or_else(|_| "{}".to_owned());
708        let digest = Sha256::digest(json.as_bytes());
709        let mut hex = String::with_capacity(32);
710        for byte in &digest[..16] {
711            use std::fmt::Write as _;
712            let _ = write!(hex, "{byte:02x}");
713        }
714        Self {
715            json,
716            etag: format!("\"{hex}\""),
717        }
718    }
719}
720
721/// Generates the schema for `T` the way every action does: draft 2020-12,
722/// definitions inlined so a renderer never has to resolve `$ref`.
723#[must_use]
724pub fn schema_for<T: JsonSchema>() -> Schema {
725    let mut settings = schemars::generate::SchemaSettings::draft2020_12();
726    settings.inline_subschemas = true;
727    SchemaGenerator::new(settings).into_root_schema_for::<T>()
728}
729
730/// Sets one `x-cf-*` (or any) keyword on a field of an object schema after
731/// derivation, for hints that only exist at runtime: a `select` whose
732/// options are the configured product list. Unknown fields are ignored so
733/// a rename in the body type cannot panic at build.
734pub fn hint_field(schema: &mut Schema, field: &str, key: &str, value: serde_json::Value) {
735    if let Some(properties) = schema
736        .as_object_mut()
737        .and_then(|root| root.get_mut("properties"))
738        .and_then(serde_json::Value::as_object_mut)
739        && let Some(property) = properties
740            .get_mut(field)
741            .and_then(serde_json::Value::as_object_mut)
742    {
743        property.insert(key.to_owned(), value);
744    }
745}
746
747fn is_object_schema(schema: &Schema) -> bool {
748    let value = schema.as_value();
749    match value.get("type") {
750        Some(serde_json::Value::String(t)) => t == "object",
751        Some(serde_json::Value::Array(types)) => types.iter().any(|t| t == "object"),
752        _ => value.get("properties").is_some(),
753    }
754}
755
756fn is_kebab(name: &str) -> bool {
757    !name.is_empty()
758        && name.split('-').all(|part| {
759            !part.is_empty()
760                && part
761                    .chars()
762                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
763        })
764}
765
766mod method_serde {
767    use http::Method;
768    use serde::{Deserialize, Deserializer, Serialize, Serializer};
769
770    pub fn serialize<S: Serializer>(method: &Method, serializer: S) -> Result<S::Ok, S::Error> {
771        method.as_str().serialize(serializer)
772    }
773
774    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Method, D::Error> {
775        let text = String::deserialize(deserializer)?;
776        Method::from_bytes(text.as_bytes()).map_err(serde::de::Error::custom)
777    }
778}
779
780#[cfg(test)]
781mod tests {
782    use super::*;
783
784    #[derive(JsonSchema)]
785    #[allow(dead_code)]
786    struct JoinBody {
787        #[schemars(extend("x-cf-label" = "Email", "x-cf-widget" = "email"))]
788        email: String,
789        product: String,
790        #[schemars(extend("x-cf-hidden" = true))]
791        #[serde(rename = "captchaToken")]
792        captcha_token: Option<String>,
793    }
794
795    fn join() -> Action {
796        Action::post("join", "/").input::<JoinBody>().captcha()
797    }
798
799    fn errors_of(surface: &Surface) -> Vec<String> {
800        let mut errors = ConfigError::default();
801        surface.validate("waitlist", &mut errors);
802        match errors.into_result() {
803            Ok(()) => Vec::new(),
804            Err(err) => err.to_string().lines().skip(1).map(str::to_owned).collect(),
805        }
806    }
807
808    #[test]
809    fn schema_carries_hints_and_is_inlined() {
810        let schema = schema_for::<JoinBody>();
811        let value = schema.as_value();
812        assert_eq!(value["type"], "object");
813        assert_eq!(value["properties"]["email"]["x-cf-label"], "Email");
814        assert_eq!(value["properties"]["email"]["x-cf-widget"], "email");
815        assert_eq!(value["properties"]["captchaToken"]["x-cf-hidden"], true);
816        assert!(value.get("$defs").is_none(), "subschemas must be inlined");
817    }
818
819    #[test]
820    fn hint_field_sets_a_keyword_and_ignores_unknown_fields() {
821        let mut schema = schema_for::<JoinBody>();
822        hint_field(
823            &mut schema,
824            "product",
825            "enum",
826            serde_json::json!(["a", "b"]),
827        );
828        hint_field(&mut schema, "missing", "x-cf-label", serde_json::json!("x"));
829        let value = schema.as_value();
830        assert_eq!(
831            value["properties"]["product"]["enum"],
832            serde_json::json!(["a", "b"])
833        );
834        assert!(value["properties"].get("missing").is_none());
835    }
836
837    #[test]
838    fn valid_surface_has_no_errors() {
839        let surface = Surface::new()
840            .action(join())
841            .action(Action::get("confirm", "/confirm"))
842            .action(
843                Action::get("export", "/admin/export.csv")
844                    .audience(Audience::Admin)
845                    .outcome(Outcome::Json),
846            )
847            .view(View::form("join"))
848            .view(View::table("export", vec![Column::new("email", "Email")]));
849        assert!(errors_of(&surface).is_empty());
850    }
851
852    #[test]
853    fn every_validation_rule_names_the_module_and_action() {
854        #[derive(JsonSchema)]
855        #[allow(dead_code)]
856        struct NotAnObject(Vec<String>);
857
858        let surface = Surface::new()
859            .action(join())
860            .action(join())
861            .action(Action::post("Bad Name", "no-slash"))
862            .action(Action::post("hidden", "/admin/thing"))
863            .action(Action::delete("wipe", "/wipe"))
864            .action(Action::post("list", "/list").input::<NotAnObject>())
865            .view(View::form("missing"));
866        let errors = errors_of(&surface);
867        let joined = errors.join("\n");
868        for needle in [
869            "declares action `join` twice",
870            "action `Bad Name` must be kebab-case",
871            "path `no-slash` must start with '/'",
872            "action `hidden` is under /admin/ but its audience is not admin",
873            "action `wipe` is admin but its path `/wipe` is not under /admin/",
874            "action `list` input schema must describe an object",
875            "view references action `missing`",
876        ] {
877            assert!(joined.contains(needle), "missing `{needle}` in:\n{joined}");
878        }
879        assert!(joined.lines().all(|l| l.contains("`waitlist`")), "{joined}");
880    }
881
882    #[test]
883    fn public_subset_drops_admin_actions_and_their_views() {
884        let surface = Surface::new()
885            .action(join())
886            .action(
887                Action::get("export", "/admin/export.csv")
888                    .audience(Audience::Admin)
889                    .outcome(Outcome::Json),
890            )
891            .view(View::form("join"))
892            .view(View::table("export", vec![]));
893        let public = surface.public();
894        assert_eq!(public.actions.len(), 1);
895        assert_eq!(public.views.len(), 1);
896        assert!(matches!(public.views[0], View::Form { .. }));
897    }
898
899    #[test]
900    fn rendered_surface_etag_is_stable_and_differs_per_variant() {
901        let doc = SurfaceDocument {
902            surface_api: SURFACE_API,
903            harness_api: HARNESS_API,
904            venture: VentureSurface {
905                name: "v".into(),
906                public_url: "https://v.test".into(),
907            },
908            modules: vec![ModuleSurface {
909                name: "waitlist".into(),
910                version: "0.1.0".into(),
911                surface: Surface::new()
912                    .action(join())
913                    .action(Action::delete("wipe", "/admin/wipe")),
914            }],
915            ui: None,
916        };
917        let full = RenderedSurface::render(&doc);
918        let again = RenderedSurface::render(&doc);
919        let public = RenderedSurface::render(&doc.public());
920        assert_eq!(full.etag, again.etag);
921        assert_ne!(full.etag, public.etag);
922        assert!(full.etag.starts_with('"') && full.etag.ends_with('"'));
923        assert_eq!(full.etag.len(), 34);
924        let parsed: serde_json::Value = serde_json::from_str(&full.json).unwrap();
925        assert_eq!(parsed["modules"][0]["actions"][0]["method"], "POST");
926        assert_eq!(parsed["modules"][0]["actions"][1]["audience"], "admin");
927        assert_eq!(parsed["surface_api"], SURFACE_API);
928    }
929
930    #[test]
931    fn document_omits_modules_without_a_surface() {
932        struct Silent;
933        impl Module for Silent {
934            fn name(&self) -> &'static str {
935                "silent"
936            }
937            fn version(&self) -> &'static str {
938                "0.0.0"
939            }
940            fn requires(&self) -> &'static [crate::ports::Port] {
941                &[]
942            }
943            fn migrations(&self) -> crate::module::Migrations {
944                crate::module::Migrations::EMPTY
945            }
946            fn validate_config(&self, _: &dyn crate::config::Config) -> Result<(), ConfigError> {
947                Ok(())
948            }
949            fn router(&self, _: crate::module::ModuleContext) -> axum::Router {
950                axum::Router::new()
951            }
952        }
953        let venture = Venture::new("v", "v.test");
954        let modules: Vec<std::sync::Arc<dyn Module>> = vec![std::sync::Arc::new(Silent)];
955        let doc = SurfaceDocument::compose(&venture, &modules);
956        assert!(doc.modules.is_empty());
957    }
958}