majordomus_cli/http/swagger.rs
1//! The Swagger UI shell: one HTML page that loads the pinned Swagger UI distribution and
2//! points it at `/openapi.json`. It embeds no specification of its own; what it shows is
3//! whatever the server generated from the registry at the moment of the request. The
4//! page is rendered once per process and served from memory.
5//!
6//! The UI's own assets are fetched by the browser from the unpkg CDN. That is the one
7//! part of the HTTP projection that is not available offline; the OpenAPI document is.
8//!
9//! The frame around it is this repository's, and so is what the frame owes a reader: a
10//! `<main>` landmark and a level-one heading, which the page had neither of until the UI
11//! audit was first allowed to look at this surface. The widget itself carries
12//! `data-mj-foreign`, the declaration a page makes when a subtree is a third party's to
13//! answer for: the accessibility engine skips it and the audit's report says it was
14//! skipped and why, rather than reporting a clean page or an unfixable one.
15//!
16//! The frame around it is this repository's. The type stack and the accent come from
17//! `share/design/tokens.yaml` like every other surface, compiled in through `tokens.css`,
18//! so the API viewer reads as part of the same tool rather than as a stock installation of
19//! somebody else's. What is inside the widget — the operation blocks, the schema tables,
20//! the try-it form — is Swagger UI's own stylesheet and is left alone: restyling a third
21//! party's component tree against a pinned version is a maintenance bill this page does not
22//! need to take on.
23//!
24//! The page pins itself light. Swagger UI 5 ships no dark theme, so a dark frame around a
25//! permanently light widget is worse than a light one; `class="light"` is the escape hatch
26//! the generated token block provides for exactly this, and it also stops the browser from
27//! rendering dark form controls inside a light panel.
28
29use std::sync::LazyLock;
30
31/// The Swagger UI distribution version the page pins.
32pub const SWAGGER_UI_VERSION: &str = "5.17.14";
33
34/// The subresource integrity of the pinned bundle: the browser runs the script only when its
35/// bytes hash to this, so a substituted file on the CDN never runs beside the write routes.
36/// Computed from `https://unpkg.com/swagger-ui-dist@5.17.14/swagger-ui-bundle.js`.
37pub const SWAGGER_BUNDLE_SRI: &str =
38 "sha384-wmyclcVGX/WhUkdkATwhaK1X1JtiNrr2EoYJ+diV3vj4v6OC5yCeSu+yW13SYJep";
39
40/// The subresource integrity of the pinned stylesheet, for the same reason.
41pub const SWAGGER_CSS_SRI: &str =
42 "sha384-wxLW6kwyHktdDGr6Pv1zgm/VGJh99lfUbzSn6HNHBENZlCN7W602k9VkGdxuFvPn";
43
44/// The one inline script the page runs: it starts the widget on the specification.
45const BOOT: &str = "window.ui = SwaggerUIBundle({ url: \"/openapi.json\", dom_id: \"#swagger-ui\", deepLinking: true });";
46
47/// Where the page names the checkout that is serving it: [`page_for`] puts the line there,
48/// and the page rendered once keeps the bare marker, which a browser shows as nothing.
49pub const CHECKOUT_MARKER: &str = "<!-- mj:checkout -->";
50
51/// The path the page loads the specification from.
52pub const SPEC_PATH: &str = "/openapi.json";
53
54/// The path the page is served at.
55///
56/// It is `/swagger` and not `/docs`: `/docs` is where this repository's own documentation
57/// is served, and a viewer for the API is not the documentation. The rule that holds the
58/// two apart is `project.web-surface-declared-once`.
59pub const SWAGGER_PATH: &str = "/swagger";
60
61static PAGE: LazyLock<String> = LazyLock::new(|| {
62 format!(
63 r##"<!doctype html>
64<html lang="en" class="light">
65<head>
66<meta charset="utf-8">
67<meta name="viewport" content="width=device-width, initial-scale=1">
68<title>Majordomus API</title>
69<link rel="stylesheet" href="https://unpkg.com/swagger-ui-dist@{v}/swagger-ui.css" integrity="{css_sri}" crossorigin="anonymous">
70<style>
71{tokens}
72/* The frame: the page around the widget, in this repository's type and colour. */
73body {{ margin: 0; background: var(--mj-bg); color: var(--mj-fg); font-family: var(--font-sans); }}
74.swagger-ui, .swagger-ui .info .title, .swagger-ui .opblock-tag {{ font-family: var(--font-sans); }}
75.swagger-ui .microlight, .swagger-ui code, .swagger-ui pre {{ font-family: var(--font-mono); }}
76/* Swagger UI's topbar is its own branding and a form for choosing a specification. This
77 page serves one specification and the process advertises its surfaces on its home page,
78 so the bar is a logo and a field that must not be used. */
79.swagger-ui .topbar {{ display: none; }}
80.swagger-ui .info .title small.version-stamp {{ background: var(--mj-accent-fill); }}
81.swagger-ui a {{ color: var(--mj-accent); }}
82/* A tag's header is its name, its description and the expand button on one row. A module
83 name such as intent_realization is one word the browser will not break, so at 320 px it
84 pushed the button past the viewport. The row may now shrink, and the name may break. */
85.swagger-ui .opblock-tag {{ min-width: 0; }}
86.swagger-ui .opblock-tag > a, .swagger-ui .opblock-tag small {{ min-width: 0; overflow-wrap: anywhere; }}
87/* The frame owes the page a landmark and a heading; the widget renders its own title as an
88 h2 beneath this one, so the order holds. Without them the page has neither, which is what
89 the audit found the first time it was allowed to look at this surface. */
90.mj-api-title {{ margin: 0; padding: 1rem 1.25rem 0; color: var(--mj-fg); }}
91/* Which checkout answered: several servers share one machine, and a page that does not say
92 whose it is reads the same from all of them (I1031). */
93.mj-checkout {{ margin: 0; padding: 0.25rem 1.25rem 0; color: var(--mj-muted); font-size: 0.9rem; }}
94.mj-checkout .mono {{ font-family: var(--font-mono); }}
95</style>
96</head>
97<body>
98<h1 class="mj-api-title">Majordomus API</h1>
99{checkout}
100<main id="swagger-ui" data-mj-foreign="swagger-ui-dist@{v}"></main>
101<script src="https://unpkg.com/swagger-ui-dist@{v}/swagger-ui-bundle.js" integrity="{bundle_sri}" crossorigin="anonymous"></script>
102<script>{boot}</script>
103</body>
104</html>
105"##,
106 v = SWAGGER_UI_VERSION,
107 css_sri = SWAGGER_CSS_SRI,
108 bundle_sri = SWAGGER_BUNDLE_SRI,
109 boot = BOOT,
110 checkout = CHECKOUT_MARKER,
111 tokens = crate::web::html::TOKENS
112 )
113});
114
115/// The page, rendered once.
116///
117/// ```
118/// use majordomus_cli::http::swagger;
119/// let page = swagger::page();
120/// assert!(page.contains(swagger::SWAGGER_UI_VERSION));
121/// assert!(page.contains("url: \"/openapi.json\""));
122/// assert!(!page.contains("\"paths\""), "the shell embeds no specification");
123/// assert!(page.contains("--font-sans"), "the shell carries the repository's design tokens");
124/// ```
125pub fn page() -> &'static str {
126 PAGE.as_str()
127}
128
129/// The page as one server serves it: the shell, with the line naming the checkout that is
130/// serving it where [`CHECKOUT_MARKER`] stands. `checkout` is HTML the caller has escaped —
131/// the router renders it from the repository model (`crate::web::home::Checkout::line`),
132/// which names the checkout by its identity and never by where it sits on the host.
133///
134/// ```
135/// use majordomus_cli::http::swagger;
136/// let page = swagger::page_for("<p class=\"mj-checkout\">checkout 0123456789ab</p>");
137/// assert!(page.contains("checkout 0123456789ab"));
138/// assert!(!page.contains(swagger::CHECKOUT_MARKER), "the marker is replaced, not kept");
139/// assert_eq!(page.len() - swagger::page().len() + swagger::CHECKOUT_MARKER.len(),
140/// "<p class=\"mj-checkout\">checkout 0123456789ab</p>".len(), "nothing else changes");
141/// ```
142pub fn page_for(checkout: &str) -> String {
143 page().replacen(CHECKOUT_MARKER, checkout, 1)
144}
145
146/// Whether this shell may be offered as a link a reader can follow, in an environment
147/// where the surfaces are what [`crate::web::projection_routes`] resolved.
148///
149/// The console is served by a running process and nothing publishes it, so a published
150/// page must name it rather than link it. The answer comes from the surface's declared
151/// availability and from nowhere else: not from the page's own address, not from a build
152/// flag, not from a template that happens to know which site it is rendering.
153///
154/// ```
155/// use majordomus_cli::http::swagger;
156/// assert!(!swagger::offered_by_a_publication(), "a console needs the server behind it");
157/// ```
158pub fn offered_by_a_publication() -> bool {
159 crate::web::projection_routes()
160 .iter()
161 .find(|r| r.path == SWAGGER_PATH)
162 .is_some_and(|r| r.linkable())
163}
164
165/// The Content-Security-Policy the page is served with: the pinned CDN files (whose integrity
166/// the page also names) and the one inline script, by its digest; requests to this server
167/// alone; no frame may embed it. The page shares an origin with every write route, so a
168/// script it did not ship must not run on it.
169///
170/// ```
171/// use majordomus_cli::http::swagger;
172/// let policy = swagger::csp();
173/// assert!(policy.starts_with("default-src 'none'"));
174/// assert!(policy.contains("frame-ancestors 'none'"));
175/// assert!(policy.contains("script-src https://unpkg.com/swagger-ui-dist@"));
176/// assert!(!policy.contains("unsafe-eval"));
177/// ```
178pub fn csp() -> &'static str {
179 static POLICY: std::sync::OnceLock<String> = std::sync::OnceLock::new();
180 POLICY.get_or_init(|| {
181 use sha2::{Digest, Sha256};
182 let digest = crate::cockpit::base64(&Sha256::digest(BOOT.as_bytes()));
183 let cdn = format!("https://unpkg.com/swagger-ui-dist@{SWAGGER_UI_VERSION}/");
184 format!(
185 "default-src 'none'; script-src {cdn} 'sha256-{digest}'; \
186 style-src {cdn} 'unsafe-inline'; img-src 'self' data:; connect-src 'self'; \
187 font-src 'self'; form-action 'none'; base-uri 'none'; frame-ancestors 'none'"
188 )
189 })
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195
196 #[test]
197 fn the_shell_points_at_the_generated_document_and_carries_none_of_its_own() {
198 // the whole claim this module makes: what a reader sees is whatever the server
199 // generated from the registry at the moment of the request, never a copy
200 let shell = page();
201 assert!(shell.contains(&format!("url: \"{SPEC_PATH}\"")));
202 for embedded in ["\"paths\"", "\"openapi\"", "\"components\""] {
203 assert!(
204 !shell.contains(embedded),
205 "the shell embeds {embedded}, so it can disagree with the registry"
206 );
207 }
208 // and it is rendered once: two calls hand back the same allocation, not two
209 assert!(std::ptr::eq(shell.as_ptr(), page().as_ptr()));
210 }
211
212 #[test]
213 fn the_pinned_distribution_is_the_one_both_asset_urls_name() {
214 // a half-upgraded pin loads a stylesheet from one version and a bundle from another,
215 // which fails in the browser and nowhere else.
216 //
217 // The subject is the asset URLs, not every mention of the distribution: the widget
218 // also names it in `data-mj-foreign`, which is a declaration about whose component
219 // tree this is and not a thing the browser fetches. Counting bare occurrences
220 // conflated the two and made this test refuse a correct page, so it counts the
221 // fetched URLs, and separately holds every mention to the pinned version.
222 //
223 // master fixed the same failure in 4e04aa6ff by asking for 3 instead of 2. Both
224 // pass today; this one keeps passing when a fourth mention is added, and still
225 // refuses a pin that is half-upgraded wherever the stale version appears.
226 let shell = page();
227 let fetched = shell
228 .matches(&format!(
229 "https://unpkg.com/swagger-ui-dist@{SWAGGER_UI_VERSION}/"
230 ))
231 .count();
232 assert_eq!(
233 fetched, 2,
234 "the stylesheet and the bundle both come from the pinned version"
235 );
236 let pinned = shell.matches("swagger-ui-dist@").count();
237 assert_eq!(
238 shell
239 .matches(&format!("swagger-ui-dist@{SWAGGER_UI_VERSION}"))
240 .count(),
241 pinned,
242 "every mention of the distribution names the pinned version, not only the URLs"
243 );
244 }
245}