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