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