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    /// Explicit browser or server rendering, independent of file availability.
169    pub mode: RouteMode,
170    /// JS modules for this route, in load order. An empty list on an SSR route
171    /// declares static output: no island definitions or module preloads.
172    pub js: Vec<String>,
173    /// Stylesheets for this route.
174    pub css: Vec<String>,
175    /// Fonts to preload for this route.
176    pub fonts: Vec<String>,
177    /// Static module dependencies, excluding duplicates and route entrypoints at render.
178    #[serde(default)]
179    pub modulepreload: Vec<String>,
180    /// Total brotli-compressed size of the route's assets, in bytes.
181    pub total_size_br: u64,
182    /// Size above which the build warns about this route's payload.
183    pub budget_warn_threshold: u64,
184    /// Required for SSR, absent for an intentional browser route.
185    #[serde(default)]
186    pub ir: Option<String>,
187}
188
189impl RouteAssets {
190    /// A native route whose accepted product publishes no browser entry.
191    pub(crate) fn is_static_ssr(&self) -> bool {
192        self.mode == RouteMode::Ssr && self.js.is_empty()
193    }
194}
195
196impl<'de> Deserialize<'de> for AssetManifest {
197    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
198        let value = serde_json::Value::deserialize(deserializer)?;
199        // Diagnose old schemas before their missing schema-2 fields.
200        if value.get("version").and_then(|v| v.as_u64()) != Some(2) {
201            return Err(serde::de::Error::custom(format!(
202                "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
203                value.get("version").unwrap_or(&serde_json::Value::Null)
204            )));
205        }
206        if value.get("app").is_none() {
207            return Err(serde::de::Error::custom("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2"));
208        }
209        if let Some(assets) = value.get("assets").and_then(serde_json::Value::as_object) {
210            for (name, metadata) in assets {
211                if metadata
212                    .get("integrity")
213                    .and_then(serde_json::Value::as_str)
214                    .is_none()
215                {
216                    return Err(serde::de::Error::custom(format!(
217                        "MANIFEST-103 missing or non-string SHA-384 integrity for {name:?}"
218                    )));
219                }
220            }
221        }
222        #[derive(Deserialize)]
223        struct Raw {
224            version: u32,
225            writer: String,
226            app: String,
227            build: String,
228            aliases: HashMap<String, String>,
229            assets: HashMap<String, AssetMetadata>,
230            routes: HashMap<String, RouteAssets>,
231        }
232        let raw: Raw = serde_json::from_value(value).map_err(serde::de::Error::custom)?;
233        let manifest = AssetManifest {
234            version: raw.version,
235            writer: raw.writer,
236            app: raw.app,
237            build: raw.build,
238            aliases: raw.aliases,
239            assets: raw.assets,
240            routes: raw.routes,
241        };
242        manifest.validate().map_err(serde::de::Error::custom)?;
243        Ok(manifest)
244    }
245}
246
247impl AssetManifest {
248    /// Read and validate schema, versions, modes and references at startup.
249    pub fn from_json(json: &str) -> Result<Self, String> {
250        serde_json::from_str(json).map_err(|e| format!("manifest.json: {e}"))
251    }
252
253    /// Revalidate even a manifest constructed by the caller before loading modules.
254    pub fn validate(&self) -> Result<(), String> {
255        if self.version != 2 {
256            return Err(format!(
257                "MANIFEST-101 unsupported manifest schema {}; reader requires schema 2",
258                self.version
259            ));
260        }
261        if self.app.is_empty() {
262            return Err("MANIFEST-101 manifest without app is a legacy schema-1 manifest; rebuild for schema 2".into());
263        }
264        crate::sw::ServiceWorker::for_app(&self.app).map_err(|e| format!("MANIFEST-103 {e}"))?;
265        let parts = self.writer.split('.').collect::<Vec<_>>();
266        if parts.len() != 3
267            || parts.iter().any(|p| {
268                p.is_empty()
269                    || !p.bytes().all(|b| b.is_ascii_digit())
270                    || (p.len() > 1 && p.starts_with('0'))
271            })
272        {
273            return Err(format!(
274                "MANIFEST-102 writer {:?} must be a numeric base X.Y.Z without a prerelease suffix",
275                self.writer
276            ));
277        }
278        let mut writer = [0u16; 3];
279        for (i, part) in parts.iter().enumerate() {
280            writer[i] = part.parse().map_err(|_| {
281                format!(
282                    "MANIFEST-102 writer {:?} exceeds the u16 version range",
283                    self.writer
284                )
285            })?;
286        }
287        let reader = forma_ir::format::reader_version();
288        if !forma_ir::format::accepts_writer(reader, writer) {
289            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]));
290        }
291        if self.build.is_empty() {
292            return Err("MANIFEST-103 build must not be empty".into());
293        }
294        fn filename(name: &str) -> bool {
295            !name.is_empty()
296                && !name.starts_with('/')
297                && !name.contains(['\\', '?', '#', ':'])
298                && !name.chars().any(char::is_control)
299                && name
300                    .split('/')
301                    .all(|p| !p.is_empty() && p != "." && p != "..")
302        }
303        for (name, meta) in &self.assets {
304            if !filename(name) {
305                return Err(format!("MANIFEST-103 invalid asset filename {name:?}"));
306            }
307            if !valid_integrity(&meta.integrity) {
308                return Err(format!(
309                    "MANIFEST-103 invalid SHA-384 integrity for {name:?}"
310                ));
311            }
312            if meta.retained && !meta.immutable {
313                return Err(format!(
314                    "MANIFEST-103 retained asset {name:?} must be immutable: a retained file is the previous release's, served as a content-named asset"
315                ));
316            }
317        }
318        for (alias, file) in &self.aliases {
319            if !filename(alias) || !self.assets.contains_key(file) {
320                return Err(format!(
321                    "MANIFEST-103 alias {alias:?} names unlisted asset {file:?}"
322                ));
323            }
324        }
325        for (pattern, route) in &self.routes {
326            if !pattern.starts_with('/') {
327                return Err(format!("MANIFEST-103 route {pattern:?} must begin with /"));
328            }
329            match (route.mode,route.ir.as_deref()) {
330                (RouteMode::Ssr,Some(file)) if file.ends_with(".ir")=>{},
331                (RouteMode::Browser,None)=>{},
332                _=>return Err(format!("MANIFEST-103 route {pattern}: ssr requires an .ir module; browser must not declare one")),
333            }
334            if route.is_static_ssr() && !route.modulepreload.is_empty() {
335                return Err(format!("MANIFEST-103 route {pattern}: static SSR must not declare module preloads without a JavaScript entry"));
336            }
337            for file in route
338                .js
339                .iter()
340                .chain(&route.css)
341                .chain(&route.fonts)
342                .chain(&route.modulepreload)
343                .chain(route.ir.iter())
344            {
345                if !self.assets.contains_key(file) {
346                    return Err(format!("MANIFEST-103 route {pattern} references {file:?}, which is not one of the build's files (the manifest's assets)"));
347                }
348            }
349        }
350        crate::sw::validate_manifest_app(self)?;
351        Ok(())
352    }
353
354    /// Resolve a logical name through the manifest's explicit aliases.
355    pub fn resolve<'a>(&'a self, logical_name: &'a str) -> &'a str {
356        self.aliases
357            .get(logical_name)
358            .map(String::as_str)
359            .unwrap_or(logical_name)
360    }
361
362    /// Get assets and the declared mode for a route.
363    pub fn route(&self, pattern: &str) -> Option<&RouteAssets> {
364        self.routes.get(pattern)
365    }
366}
367
368/// Full SHA-384, in the single canonical SRI spelling emitted by the build.
369pub(crate) fn valid_integrity(integrity: &str) -> bool {
370    use base64::Engine;
371    let Some(encoded) = integrity.strip_prefix("sha384-") else {
372        return false;
373    };
374    if encoded.len() != 64 {
375        return false;
376    }
377    let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
378        return false;
379    };
380    bytes.len() == 48 && base64::engine::general_purpose::STANDARD.encode(bytes) == encoded
381}