Skip to main content

forma_server/
types.rs

1use serde::Deserialize;
2use std::collections::HashMap;
3
4/// Document metadata for an explicitly browser-rendered page.
5/// Server-rendered pages use a bound [`crate::PageHandle`].
6#[derive(Debug)]
7pub struct PageConfig<'a> {
8    /// Text for the page's `<title>`, HTML-escaped on the way out.
9    pub title: &'a str,
10    /// The route this page was matched by, used to look up its assets in the
11    /// manifest (e.g. `/users/:id`).
12    pub route_pattern: &'a str,
13    /// Build manifest naming the content-hashed JS/CSS/font/IR files.
14    pub manifest: &'a AssetManifest,
15    /// Optional server data to expose to the client as
16    /// `window.__FORMA_CONFIG__`.
17    ///
18    /// This is the safe path: the value is serialized by `serde_json` with
19    /// every `<` escaped, which cannot be misused no matter what the data
20    /// contains. Application code belongs in the page bundle.
21    pub config_json: Option<&'a serde_json::Value>,
22    /// Optional `class` attribute for `<body>`, HTML-escaped.
23    pub body_class: Option<&'a str>,
24    /// Optional inline `<style>` for personality/theme overrides.
25    ///
26    /// Emitted inside a `<style>` element. `</` is neutralized (as a CSS
27    /// escape, losslessly) so the value cannot break out; everything else is
28    /// passed through as CSS.
29    pub personality_css: Option<&'a str>,
30    /// Optional extra HTML before `<div id="app">`.
31    ///
32    /// **RAW, TRUSTED HTML.** Interpolated verbatim with no escaping — that is
33    /// the point of the field (callers use it to emit their own markup, such
34    /// as a hand-written shell or a logo). Never build it from untrusted data.
35    ///
36    /// Construct reviewed markup with [`RawHtml::trusted`](crate::RawHtml::trusted)
37    /// and borrow it here. Plain strings do not satisfy this field's type.
38    ///
39    /// What is not the application's own markup has a typed home instead:
40    /// `modulepreload` links come from the build's manifest
41    /// ([`RenderCtx::with_module_preloads`](crate::RenderCtx::with_module_preloads)),
42    /// head links from [`RenderCtx::with_head_link`](crate::RenderCtx::with_head_link),
43    /// and data from [`PageConfig::config_json`].
44    pub body_prefix: Option<&'a crate::RawHtml>,
45}
46
47/// `X-Content-Type-Options: nosniff` — belongs on every response, not just
48/// documents.
49pub const NOSNIFF: (&str, &str) = ("x-content-type-options", "nosniff");
50
51/// Security headers that only make sense on an HTML **document** response.
52///
53/// Deliberately not one flat set applied as a blanket layer: a browser ignores
54/// `Cross-Origin-Opener-Policy`, `Referrer-Policy` and `Permissions-Policy` on
55/// a JSON/CSS/JS response, so a global layer would put ~150 dead bytes on
56/// every asset and every API poll. Use [`asset_security_headers`] there.
57///
58/// HSTS is deliberately absent: it is the TLS terminator's responsibility, not
59/// the application's.
60///
61/// Caveat on `Cross-Origin-Opener-Policy: same-origin` — it applies only to
62/// top-level documents in a secure context (a no-op over plain HTTP on a
63/// non-localhost host) and it severs `window.opener`, which breaks popup-based
64/// OAuth flows.
65pub fn document_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
66    use axum::http::{HeaderName, HeaderValue};
67    static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
68        std::sync::OnceLock::new();
69    HEADERS.get_or_init(|| {
70        vec![
71            (
72                HeaderName::from_static("x-content-type-options"),
73                HeaderValue::from_static("nosniff"),
74            ),
75            (
76                HeaderName::from_static("referrer-policy"),
77                HeaderValue::from_static("strict-origin-when-cross-origin"),
78            ),
79            (
80                // `interest-cohort=()` is deliberately omitted: FLoC was
81                // withdrawn in 2022 and the token is not a registered feature,
82                // so Chrome logs an "Unrecognized feature" warning for it on
83                // every page load.
84                HeaderName::from_static("permissions-policy"),
85                HeaderValue::from_static("geolocation=(), camera=(), microphone=()"),
86            ),
87            (
88                HeaderName::from_static("cross-origin-opener-policy"),
89                HeaderValue::from_static("same-origin"),
90            ),
91        ]
92    })
93}
94
95/// Security headers for subresource responses (`/_assets/*`, `/sw.js`).
96///
97/// `Cross-Origin-Resource-Policy` belongs here rather than on the document.
98/// `same-origin` is correct while assets are served from the app's own origin;
99/// an app that fronts `/_assets` with a separate CDN origin must relax it.
100pub fn asset_security_headers() -> &'static [(axum::http::HeaderName, axum::http::HeaderValue)] {
101    use axum::http::{HeaderName, HeaderValue};
102    static HEADERS: std::sync::OnceLock<Vec<(HeaderName, HeaderValue)>> =
103        std::sync::OnceLock::new();
104    HEADERS.get_or_init(|| {
105        vec![
106            (
107                HeaderName::from_static("x-content-type-options"),
108                HeaderValue::from_static("nosniff"),
109            ),
110            (
111                HeaderName::from_static("cross-origin-resource-policy"),
112                HeaderValue::from_static("same-origin"),
113            ),
114        ]
115    })
116}
117
118/// Validated schema-2 build manifest. Deserialize through the shared validator.
119#[derive(Debug, Clone)]
120pub struct AssetManifest {
121    /// Manifest schema, currently 2.
122    pub version: u32,
123    /// Writer's numeric base version, without package prerelease metadata.
124    pub writer: String,
125    /// Application identity; the same grammar as the worker namespace.
126    pub app: String,
127    /// The build's deployment identity (FORMA-186, DR-29): `@getforma/build` computes
128    /// it from the build's own outputs and compiles it into every bundle, and this
129    /// server writes it on the page's root as `data-forma-build`, where core
130    /// compares it with its bundle's before it activates anything. Opaque here: any
131    /// non-empty string, written escaped.
132    pub build: String,
133    /// Logical names mapped to emitted filenames.
134    pub aliases: HashMap<String, String>,
135    /// Metadata keyed by emitted filenames, including retained files.
136    pub assets: HashMap<String, AssetMetadata>,
137    /// Explicit rendering mode and assets for each route.
138    pub routes: HashMap<String, RouteAssets>,
139}
140
141/// Metadata for one emitted file, including its full uncompressed-byte digest.
142#[derive(Debug, Clone, Deserialize)]
143pub struct AssetMetadata {
144    /// Whether this URL may be cached immutably; never inferred from its name.
145    pub immutable: bool,
146    /// Canonical SHA-384 of uncompressed final bytes (`sha384-` and 64 base64 characters).
147    pub integrity: String,
148    /// A file of the previous release that the build copied in for tabs still open on
149    /// it (FORMA-186, DR-29, L-25). It is served as any immutable asset is, and a
150    /// retained entry that is not immutable is refused at start.
151    #[serde(default)]
152    pub retained: bool,
153}
154
155/// The manifest's explicit route mode.
156#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
157#[serde(rename_all = "lowercase")]
158pub enum RouteMode {
159    /// A required compiled module is rendered on the server.
160    Ssr,
161    /// An intentionally browser-rendered route, with no compiled module.
162    Browser,
163}
164
165/// Per-route asset references.
166#[derive(Debug, Clone, Deserialize)]
167pub struct RouteAssets {
168    /// Compiler-verified owner and outlet for an opt-in native navigation group.
169    #[cfg(feature = "navigation")]
170    #[serde(default)]
171    pub navigation: Option<RouteNavigation>,
172    /// Explicit browser or server rendering, independent of file availability.
173    pub mode: RouteMode,
174    /// JS modules for this route, in load order. An empty list on an SSR route
175    /// declares static output: no island definitions or module preloads.
176    pub js: Vec<String>,
177    /// Stylesheets for this route.
178    pub css: Vec<String>,
179    /// Fonts to preload for this route.
180    pub fonts: Vec<String>,
181    /// Static module dependencies, excluding duplicates and route entrypoints at render.
182    #[serde(default)]
183    pub modulepreload: Vec<String>,
184    /// Total brotli-compressed size of the route's assets, in bytes.
185    pub total_size_br: u64,
186    /// Size above which the build warns about this route's payload.
187    pub budget_warn_threshold: u64,
188    /// Required for SSR, absent for an intentional browser route.
189    #[serde(default)]
190    pub ir: Option<String>,
191}
192
193/// Compiler/build declaration for one independently rendered navigation owner.
194#[cfg(feature = "navigation")]
195#[derive(Debug, Clone, Deserialize)]
196#[serde(deny_unknown_fields)]
197pub struct RouteNavigation {
198    /// Shared verified bootstrap group.
199    pub group: String,
200    /// Owner identity within that group.
201    pub owner: String,
202    /// Literal empty div outside all island subtrees, or no child outlet.
203    pub outlet: Option<String>,
204}
205
206impl RouteAssets {
207    /// A native route whose accepted product publishes no browser entry.
208    pub(crate) fn is_static_ssr(&self) -> bool {
209        self.mode == RouteMode::Ssr && self.js.is_empty()
210    }
211}
212
213impl<'de> Deserialize<'de> for AssetManifest {
214    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
215        let value = serde_json::Value::deserialize(deserializer)?;
216        // Diagnose old schemas before their missing schema-2 fields.
217        if value.get("version").and_then(|v| v.as_u64()) != Some(2) {
218            return Err(serde::de::Error::custom(format!(
219                "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
220                value.get("version").unwrap_or(&serde_json::Value::Null)
221            )));
222        }
223        if value.get("app").is_none() {
224            return Err(serde::de::Error::custom("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2"));
225        }
226        if let Some(assets) = value.get("assets").and_then(serde_json::Value::as_object) {
227            for (name, metadata) in assets {
228                if metadata
229                    .get("integrity")
230                    .and_then(serde_json::Value::as_str)
231                    .is_none()
232                {
233                    return Err(serde::de::Error::custom(format!(
234                        "MANIFEST-103 missing or non-string SHA-384 integrity for {name:?}"
235                    )));
236                }
237            }
238        }
239        // The reserved navigation capability must not disappear as an unknown
240        // serde field when this reader was built without its feature. Ordinary
241        // forward-compatible fields retain their existing behavior.
242        #[cfg(not(feature = "navigation"))]
243        if let Some(routes) = value.get("routes").and_then(serde_json::Value::as_object) {
244            for (pattern, route) in routes {
245                if route.get("navigation").is_some() {
246                    return Err(serde::de::Error::custom(format!(
247                        "MANIFEST-103 route {pattern}: native navigation capability requires the forma-server navigation feature"
248                    )));
249                }
250            }
251        }
252        #[derive(Deserialize)]
253        struct Raw {
254            version: u32,
255            writer: String,
256            app: String,
257            build: String,
258            aliases: HashMap<String, String>,
259            assets: HashMap<String, AssetMetadata>,
260            routes: HashMap<String, RouteAssets>,
261        }
262        let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
263        let manifest = AssetManifest {
264            version: raw.version,
265            writer: raw.writer,
266            app: raw.app,
267            build: raw.build,
268            aliases: raw.aliases,
269            assets: raw.assets,
270            routes: raw.routes,
271        };
272        manifest.validate().map_err(serde::de::Error::custom)?;
273        Ok(manifest)
274    }
275}
276
277impl AssetManifest {
278    /// Read and validate schema, versions, modes and references at startup.
279    pub fn from_json(json: &str) -> Result<Self, String> {
280        serde_json::from_str(json).map_err(|e| format!("manifest.json: {e}"))
281    }
282
283    /// Revalidate even a manifest constructed by the caller before loading modules.
284    pub fn validate(&self) -> Result<(), String> {
285        if self.version != 2 {
286            return Err(format!(
287                "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
288                self.version
289            ));
290        }
291        if self.app.is_empty() {
292            return Err("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2".into());
293        }
294        crate::sw::ServiceWorker::for_app(&self.app).map_err(|e| format!("MANIFEST-103 {e}"))?;
295        let parts = self.writer.split('.').collect::<Vec<_>>();
296        if parts.len() != 3
297            || parts.iter().any(|p| {
298                p.is_empty()
299                    || !p.bytes().all(|b| b.is_ascii_digit())
300                    || (p.len() > 1 && p.starts_with('0'))
301            })
302        {
303            return Err(format!(
304                "MANIFEST-102 writer {:?} must be a numeric base X.Y.Z without a prerelease suffix",
305                self.writer
306            ));
307        }
308        let mut writer = [0u16; 3];
309        for (i, part) in parts.iter().enumerate() {
310            writer[i] = part.parse().map_err(|_| {
311                format!(
312                    "MANIFEST-102 writer {:?} exceeds the u16 version range",
313                    self.writer
314                )
315            })?;
316        }
317        let reader = forma_ir::format::reader_version();
318        if !forma_ir::format::accepts_writer(reader, writer) {
319            return Err(format!("MANIFEST-102 writer {} is incompatible with reader {}.{}.{}; upgrade the reader within major {} to at least minor {}",self.writer,reader[0],reader[1],reader[2],writer[0],writer[1]));
320        }
321        if self.build.is_empty() {
322            return Err("MANIFEST-103 build must not be empty".into());
323        }
324        fn filename(name: &str) -> bool {
325            !name.is_empty()
326                && !name.starts_with('/')
327                && !name.contains(['\\', '?', '#', ':'])
328                && !name.chars().any(char::is_control)
329                && name
330                    .split('/')
331                    .all(|p| !p.is_empty() && p != "." && p != "..")
332        }
333        for (name, meta) in &self.assets {
334            if !filename(name) {
335                return Err(format!("MANIFEST-103 invalid asset filename {name:?}"));
336            }
337            if !valid_integrity(&meta.integrity) {
338                return Err(format!(
339                    "MANIFEST-103 invalid SHA-384 integrity for {name:?}"
340                ));
341            }
342            if meta.retained && !meta.immutable {
343                return Err(format!(
344                    "MANIFEST-103 retained asset {name:?} must be immutable: a retained file is the previous release's, served as a content-named asset"
345                ));
346            }
347        }
348        for (alias, file) in &self.aliases {
349            if !filename(alias) || !self.assets.contains_key(file) {
350                return Err(format!(
351                    "MANIFEST-103 alias {alias:?} names unlisted asset {file:?}"
352                ));
353            }
354        }
355        for (pattern, route) in &self.routes {
356            if !pattern.starts_with('/') {
357                return Err(format!("MANIFEST-103 route {pattern:?} must begin with /"));
358            }
359            match (route.mode,route.ir.as_deref()) {
360                (RouteMode::Ssr,Some(file)) if file.ends_with(".ir")=>{},
361                (RouteMode::Browser,None)=>{},
362                _=>return Err(format!("MANIFEST-103 route {pattern}: ssr requires an .ir module; browser must not declare one")),
363            }
364            if route.is_static_ssr() && !route.modulepreload.is_empty() {
365                return Err(format!("MANIFEST-103 route {pattern}: static SSR must not declare module preloads without a JavaScript entry"));
366            }
367            #[cfg(feature = "navigation")]
368            if let Some(nav) = &route.navigation {
369                if !crate::navigation::identifier(&nav.group)
370                    || !crate::navigation::identifier(&nav.owner)
371                    || nav
372                        .outlet
373                        .as_deref()
374                        .is_some_and(|name| !crate::navigation::identifier(name))
375                    || route.mode != RouteMode::Ssr
376                    || route.js.len() != 1
377                    || !route.modulepreload.is_empty()
378                {
379                    return Err(format!(
380                        "MANIFEST-103 route {pattern}: invalid native navigation owner declaration"
381                    ));
382                }
383            }
384            for file in route
385                .js
386                .iter()
387                .chain(&route.css)
388                .chain(&route.fonts)
389                .chain(&route.modulepreload)
390                .chain(route.ir.iter())
391            {
392                if !self.assets.contains_key(file) {
393                    return Err(format!("MANIFEST-103 route {pattern} references {file:?}, which is not one of the build's files (the manifest's assets)"));
394                }
395            }
396        }
397        crate::sw::validate_manifest_app(self)?;
398        Ok(())
399    }
400
401    /// Resolve a logical name through the manifest's explicit aliases.
402    pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
403        self.aliases
404            .get(logical_name)
405            .map(String::as_str)
406            .unwrap_or(logical_name)
407    }
408
409    /// Get assets and the declared mode for a route.
410    pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
411        self.routes.get(pattern)
412    }
413}
414
415/// Full SHA-384, in the single canonical SRI spelling emitted by the build.
416pub(crate) fn valid_integrity(integrity: &str) -> bool {
417    use base64::Engine;
418    let Some(encoded) = integrity.strip_prefix("sha384-") else {
419        return false;
420    };
421    if encoded.len() != 64 {
422        return false;
423    }
424    let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
425        return false;
426    };
427    bytes.len() == 48 && base64::engine::general_purpose::STANDARD.encode(bytes) == encoded
428}