Skip to main content

assay_workflow/api/
whitelabel.rs

1//! Dashboard whitelabel configuration.
2//!
3//! Lets operators rebrand the embedded `/workflow` dashboard per
4//! deployment without forking the binary. Every knob is optional —
5//! an unset env var falls back to assay's own identity, so the
6//! standalone experience is unchanged.
7//!
8//! # Env vars
9//!
10//! | Variable                        | Purpose                                              | Default                      |
11//! | ------------------------------- | ---------------------------------------------------- | ---------------------------- |
12//! | `ASSAY_WHITELABEL_NAME`         | Text in the sidebar header + footer                  | `Assay`                      |
13//! | `ASSAY_WHITELABEL_LOGO_URL`     | Image URL rendered before the brand text             | — (no image)                 |
14//! | `ASSAY_WHITELABEL_PAGE_TITLE`   | Browser tab title                                    | `Assay Workflow Dashboard`   |
15//! | `ASSAY_WHITELABEL_PARENT_URL`   | When set, adds a back-link in the sidebar footer     | — (hidden)                   |
16//! | `ASSAY_WHITELABEL_PARENT_NAME`  | Label for the back-link                              | `Back`                       |
17//! | `ASSAY_WHITELABEL_API_DOCS_URL` | Override (or hide) the API Docs sidebar link         | `/api/v1/docs`               |
18//! | `ASSAY_WHITELABEL_CSS_URL`      | Extra stylesheet loaded after assay's own CSS        | — (none)                     |
19//! | `ASSAY_WHITELABEL_SUBTITLE`     | Small muted line shown under the brand name          | — (none)                     |
20//! | `ASSAY_WHITELABEL_MARK`         | Single glyph for the always-visible badge square     | First char of NAME (upper)   |
21//! | `ASSAY_WHITELABEL_FAVICON_URL`  | Replace the browser-tab icon                         | Built-in `A`-mark SVG        |
22//! | `ASSAY_WHITELABEL_DEFAULT_NAMESPACE` | Namespace the dashboard opens on                | `main`                       |
23//!
24//! ## Hiding vs overriding
25//!
26//! For `ASSAY_WHITELABEL_API_DOCS_URL` we distinguish "unset" from
27//! "set to empty string": an unset env var keeps the default link
28//! pointing at the built-in OpenAPI UI, whereas explicitly setting it
29//! to `""` hides the link entirely. This matters when an embedding
30//! app's ingress doesn't route the OpenAPI path and you'd rather not
31//! show a dead link.
32//!
33//! Hosting the logo: if assay is mounted on the same origin as the
34//! embedding app (e.g. behind a reverse proxy at `/workflow/*`), a
35//! path-absolute URL like `/static/my-logo.svg` loads from the host
36//! app with no CORS plumbing.
37//!
38//! ## Theming via `ASSAY_WHITELABEL_CSS_URL`
39//!
40//! Assay's dashboard styles are token-driven — every colour, radius,
41//! and shadow is a CSS custom property on `:root`. An extra stylesheet
42//! loaded at the end of `<head>` can therefore override any design
43//! token without touching the assay source:
44//!
45//! ```css
46//! :root {
47//!   --bg:      hsl(0 0% 98%);
48//!   --surface: hsl(0 0% 100%);
49//!   --accent:  #009999;
50//!   --accent-hover: #007a7a;
51//!   --text:    hsl(222 84% 5%);
52//!   --border:  hsl(214 32% 91%);
53//! }
54//! ```
55//!
56//! Tokens documented in `docs/modules/workflow.md#dashboard-whitelabel`.
57//! Operators can additionally override any assay selector in the same
58//! file — specificity + source order ensures the later sheet wins.
59
60use std::sync::LazyLock;
61
62/// Shared config read once from env on first dashboard request. The
63/// `LazyLock` ensures we never re-read env mid-flight if operators
64/// restart without changing values, and matches the pattern used by
65/// `ASSET_VERSION` in the dashboard module.
66pub static WHITELABEL: LazyLock<WhitelabelConfig> = LazyLock::new(WhitelabelConfig::from_env);
67
68/// Operator-configurable dashboard identity. Construct via
69/// [`WhitelabelConfig::from_env`] in production; tests can build
70/// instances directly.
71#[derive(Debug, Clone)]
72pub struct WhitelabelConfig {
73    pub name: String,
74    /// Single-letter (or short) glyph rendered inside the always-visible
75    /// badge square at the top of the sidebar. Derived from the first
76    /// character of `name` unless `ASSAY_WHITELABEL_MARK` overrides.
77    pub mark: String,
78    /// Muted subtitle rendered beneath the brand name (empty → no
79    /// subtitle line). Gives operators the canonical two-line brand
80    /// block without needing a custom logo SVG.
81    pub subtitle: String,
82    pub logo_url: Option<String>,
83    pub page_title: String,
84    pub parent_url: Option<String>,
85    pub parent_name: String,
86    /// `Some(url)` → render the link pointing at `url`.
87    /// `None`      → hide the link entirely.
88    pub api_docs_url: Option<String>,
89    /// Optional stylesheet URL loaded after assay's own CSS. Operators
90    /// use this to re-skin the dashboard by overriding CSS custom
91    /// properties or specific selectors without touching the source.
92    pub css_url: Option<String>,
93    /// Optional favicon URL. When `None` the built-in SVG `A`-mark is
94    /// served at `/workflow/favicon.svg`; when `Some(url)` the template
95    /// points `<link rel="icon">` at the operator's URL instead.
96    pub favicon_url: Option<String>,
97    /// Namespace the dashboard opens on by default. Operators running
98    /// assay single-tenant (all workflows in one non-`main` namespace)
99    /// shouldn't force every user to change the dropdown on first load.
100    pub default_namespace: String,
101}
102
103impl WhitelabelConfig {
104    /// `true` when any operator-facing identity field has been overridden
105    /// from the default. Drives the "Powered by …" prefix in the footer
106    /// version line — attribution without burying the engine on the
107    /// standalone dashboard.
108    pub fn is_customised(&self) -> bool {
109        self.name != "Assay"
110            || !self.subtitle.is_empty()
111            || self.logo_url.is_some()
112            || self.css_url.is_some()
113            || self.favicon_url.is_some()
114    }
115}
116
117impl WhitelabelConfig {
118    /// Read every knob from env, applying defaults and the
119    /// "set-to-empty means hide" convention for `api_docs_url`.
120    pub fn from_env() -> Self {
121        let name =
122            std::env::var("ASSAY_WHITELABEL_NAME").unwrap_or_else(|_| "Assay".to_string());
123        // MARK falls back to the first character of NAME (uppercased) so
124        // operators who only set NAME get a sensible badge glyph
125        // automatically. Explicit override handles cases where the name
126        // doesn't begin with the intended letter (e.g. NAME="Acme Inc",
127        // MARK="A" is the auto-default anyway; NAME="SIMONS Command
128        // Center", MARK="S" is fine; NAME="The Platform", MARK="P"
129        // needs the override to drop the article).
130        let mark = std::env::var("ASSAY_WHITELABEL_MARK")
131            .ok()
132            .filter(|s| !s.is_empty())
133            .unwrap_or_else(|| {
134                name.chars()
135                    .next()
136                    .map(|c| c.to_uppercase().to_string())
137                    .unwrap_or_else(|| "A".to_string())
138            });
139        let subtitle =
140            std::env::var("ASSAY_WHITELABEL_SUBTITLE").unwrap_or_default();
141        let logo_url = std::env::var("ASSAY_WHITELABEL_LOGO_URL")
142            .ok()
143            .filter(|s| !s.is_empty());
144        let page_title = std::env::var("ASSAY_WHITELABEL_PAGE_TITLE")
145            .unwrap_or_else(|_| "Assay Workflow Dashboard".to_string());
146        let parent_url = std::env::var("ASSAY_WHITELABEL_PARENT_URL")
147            .ok()
148            .filter(|s| !s.is_empty());
149        let parent_name =
150            std::env::var("ASSAY_WHITELABEL_PARENT_NAME").unwrap_or_else(|_| "Back".to_string());
151        let api_docs_url = match std::env::var("ASSAY_WHITELABEL_API_DOCS_URL") {
152            Ok(s) if s.is_empty() => None,
153            Ok(s) => Some(s),
154            Err(_) => Some("/api/v1/docs".to_string()),
155        };
156        let css_url = std::env::var("ASSAY_WHITELABEL_CSS_URL")
157            .ok()
158            .filter(|s| !s.is_empty());
159        let favicon_url = std::env::var("ASSAY_WHITELABEL_FAVICON_URL")
160            .ok()
161            .filter(|s| !s.is_empty());
162        let default_namespace = std::env::var("ASSAY_WHITELABEL_DEFAULT_NAMESPACE")
163            .ok()
164            .filter(|s| !s.is_empty())
165            .unwrap_or_else(|| "main".to_string());
166        Self {
167            name,
168            mark,
169            subtitle,
170            logo_url,
171            page_title,
172            parent_url,
173            parent_name,
174            api_docs_url,
175            css_url,
176            favicon_url,
177            default_namespace,
178        }
179    }
180}
181
182/// Escape a value destined for HTML text or attributes. Every
183/// whitelabel value comes from operator-controlled env, so if someone
184/// puts a `"` or `<` in a brand name we don't want to break the page.
185fn html_escape(s: &str) -> String {
186    s.replace('&', "&amp;")
187        .replace('<', "&lt;")
188        .replace('>', "&gt;")
189        .replace('"', "&quot;")
190        .replace('\'', "&#39;")
191}
192
193/// Render the dashboard HTML template with the operator's whitelabel
194/// values substituted in. The template contains placeholders like
195/// `__BRAND_NAME__` / `__PARENT_BACK_LINK__` that this function fills
196/// in (or replaces with empty strings for optional bits).
197///
198/// Separated from the HTTP handler so unit tests can assert the
199/// substitutions without booting the server.
200pub fn render_index(template: &str, asset_version: &str, wl: &WhitelabelConfig) -> String {
201    let back_link = match &wl.parent_url {
202        Some(url) => format!(
203            r#"<a href="{}" class="nav-link nav-link-back" title="{}">
204          <span class="nav-icon">&larr;</span> <span class="nav-label">{}</span>
205        </a>"#,
206            html_escape(url),
207            html_escape(&wl.parent_name),
208            html_escape(&wl.parent_name)
209        ),
210        None => String::new(),
211    };
212
213    let api_docs_link = match &wl.api_docs_url {
214        Some(url) => format!(
215            r#"<a href="{}" class="nav-link nav-link-grow" target="_blank">
216          <span class="nav-icon">&#128196;</span> <span class="nav-label">API Docs</span>
217        </a>"#,
218            html_escape(url)
219        ),
220        None => String::new(),
221    };
222
223    let logo_img = match &wl.logo_url {
224        Some(url) => format!(
225            r#"<img class="logo-img" src="{}" alt="{}" />"#,
226            html_escape(url),
227            html_escape(&wl.name)
228        ),
229        None => String::new(),
230    };
231
232    // Subtitle is rendered as a distinct span under the brand name so
233    // operators get a real, accessible, translatable line of text —
234    // not a baked-into-SVG image.
235    let subtitle = if wl.subtitle.is_empty() {
236        String::new()
237    } else {
238        format!(
239            r#"<span class="logo-subtitle">{}</span>"#,
240            html_escape(&wl.subtitle)
241        )
242    };
243
244    // Footer version-line: vanilla dashboards keep the current
245    // "Assay Workflow Engine vX.Y.Z" wording. Whitelabel deployments
246    // get a "Powered by Assay vX.Y.Z" line — short, not redundant with
247    // a subtitle that may already say "Workflow Engine", still links
248    // to assay.rs for discovery. The version span is populated by the
249    // existing dashboard JS in both variants.
250    let engine_footer = if wl.is_customised() {
251        r#"Powered by <a class="assay-attribution" href="https://assay.rs" target="_blank" rel="noopener noreferrer">Assay</a> <span id="status-version">—</span>"#.to_string()
252    } else {
253        r#"Assay Workflow Engine <span id="status-version">—</span>"#.to_string()
254    };
255
256    // Favicon — operator-supplied URL when set, otherwise assay's own
257    // inline SVG served from /workflow/favicon.svg. Emitted as a
258    // full <link> tag so the operator URL can be absolute, a path,
259    // or a data: URI without the template having to know.
260    let favicon_link = match &wl.favicon_url {
261        Some(url) => format!(
262            r#"<link rel="icon" href="{}">"#,
263            html_escape(url)
264        ),
265        None => r#"<link rel="icon" type="image/svg+xml" href="/workflow/favicon.svg">"#.to_string(),
266    };
267
268    // Default namespace — threaded into the template as a data-attribute
269    // the dashboard JS picks up on first load. Operators running a
270    // single-tenant assay-as-a-product shouldn't force every user to
271    // change the namespace dropdown on first visit.
272    let default_namespace_attr = format!(
273        r#" data-default-namespace="{}""#,
274        html_escape(&wl.default_namespace)
275    );
276
277    // Emitted at the end of <head>, after assay's own theme.css + style.css,
278    // so operator overrides win on source order + specificity. Asset-version
279    // is appended to the URL so a redeploy that changes the stylesheet
280    // forces a browser re-fetch (same pattern as assay's own assets).
281    let extra_css = match &wl.css_url {
282        Some(url) => {
283            let sep = if url.contains('?') { '&' } else { '?' };
284            format!(
285                r#"<link rel="stylesheet" href="{}{}v={}">"#,
286                html_escape(url),
287                sep,
288                asset_version
289            )
290        }
291        None => String::new(),
292    };
293
294    template
295        .replace("__ASSETV__", asset_version)
296        .replace("__PAGE_TITLE__", &html_escape(&wl.page_title))
297        .replace("__BRAND_NAME__", &html_escape(&wl.name))
298        .replace("__BRAND_MARK__", &html_escape(&wl.mark))
299        .replace("__BRAND_LOGO_IMG__", &logo_img)
300        .replace("__BRAND_SUBTITLE__", &subtitle)
301        .replace("__ENGINE_FOOTER__", &engine_footer)
302        .replace("__PARENT_BACK_LINK__", &back_link)
303        .replace("__API_DOCS_LINK__", &api_docs_link)
304        .replace("__EXTRA_CSS_LINK__", &extra_css)
305        .replace("__FAVICON_LINK__", &favicon_link)
306        .replace("__DEFAULT_NAMESPACE_ATTR__", &default_namespace_attr)
307}
308
309#[cfg(test)]
310mod tests {
311    use super::*;
312
313    const TEMPLATE: &str = r#"<title>__PAGE_TITLE__</title>
314<head>__FAVICON_LINK__ __EXTRA_CSS_LINK__</head>
315<body__DEFAULT_NAMESPACE_ATTR__>
316<span>__BRAND_NAME__</span><span>__BRAND_MARK__</span>
317__BRAND_SUBTITLE__
318__BRAND_LOGO_IMG__
319__PARENT_BACK_LINK__
320__API_DOCS_LINK__
321<footer>__ENGINE_FOOTER__</footer>
322v=__ASSETV__
323</body>"#;
324
325    fn default_cfg() -> WhitelabelConfig {
326        WhitelabelConfig {
327            name: "Assay".into(),
328            mark: "A".into(),
329            subtitle: String::new(),
330            logo_url: None,
331            page_title: "Assay Workflow Dashboard".into(),
332            parent_url: None,
333            parent_name: "Back".into(),
334            api_docs_url: Some("/api/v1/docs".into()),
335            css_url: None,
336            favicon_url: None,
337            default_namespace: "main".into(),
338        }
339    }
340
341    #[test]
342    fn default_render_matches_standalone_identity() {
343        let out = render_index(TEMPLATE, "42", &default_cfg());
344        assert!(out.contains("<title>Assay Workflow Dashboard</title>"));
345        assert!(out.contains("<span>Assay</span><span>A</span>"));
346        // Optional bits are absent/empty by default.
347        assert!(!out.contains("nav-link-back"));
348        assert!(!out.contains("logo-img"));
349        assert!(!out.contains("logo-subtitle"));
350        // Vanilla footer — no "Powered by" prefix, no attribution link.
351        assert!(out.contains("Assay Workflow Engine"));
352        assert!(!out.contains("Powered by"));
353        assert!(!out.contains("assay-attribution"));
354        // Default API Docs link present and pointing at the engine path.
355        assert!(out.contains("href=\"/api/v1/docs\""));
356        // Default favicon points at the built-in SVG.
357        assert!(out.contains(r#"href="/workflow/favicon.svg""#));
358        // Default namespace attr exposes "main" for the dashboard JS.
359        assert!(out.contains(r#"data-default-namespace="main""#));
360        // Asset version substitution still works.
361        assert!(out.contains("v=42"));
362    }
363
364    #[test]
365    fn subtitle_renders_as_muted_span_when_set() {
366        let mut cfg = default_cfg();
367        cfg.name = "SIMONS".into();
368        cfg.subtitle = "Command Center".into();
369        let out = render_index(TEMPLATE, "v", &cfg);
370        assert!(out.contains(r#"<span class="logo-subtitle">Command Center</span>"#));
371    }
372
373    #[test]
374    fn subtitle_unset_emits_nothing() {
375        let out = render_index(TEMPLATE, "v", &default_cfg());
376        assert!(!out.contains("logo-subtitle"));
377    }
378
379    #[test]
380    fn mark_override_wins_over_name_initial() {
381        let mut cfg = default_cfg();
382        cfg.name = "The Platform".into();
383        cfg.mark = "P".into();
384        let out = render_index(TEMPLATE, "v", &cfg);
385        // Mark is rendered explicitly, not auto-derived.
386        assert!(out.contains("<span>The Platform</span><span>P</span>"));
387    }
388
389    #[test]
390    fn whitelabel_footer_includes_powered_by_and_attribution_link() {
391        // Any customised identity flips the footer to the attributed
392        // variant — a simple NAME change is enough.
393        let mut cfg = default_cfg();
394        cfg.name = "Acme Workflows".into();
395        cfg.mark = "A".into();
396        let out = render_index(TEMPLATE, "v", &cfg);
397        assert!(out.contains("Powered by"));
398        assert!(out.contains(r#">Assay</a>"#), "short 'Assay' attribution text");
399        assert!(!out.contains("Workflow Engine</a>"), "should not say 'Assay Workflow Engine' in link");
400        assert!(out.contains(r#"href="https://assay.rs""#));
401        assert!(out.contains(r#"target="_blank""#));
402        assert!(out.contains(r#"rel="noopener noreferrer""#));
403        // Version span is still emitted for the existing JS populator.
404        assert!(out.contains(r#"<span id="status-version">—</span>"#));
405    }
406
407    #[test]
408    fn favicon_url_override_emits_operator_link_tag() {
409        let mut cfg = default_cfg();
410        cfg.favicon_url = Some("/static/acme-favicon.ico".into());
411        let out = render_index(TEMPLATE, "v", &cfg);
412        assert!(out.contains(r#"href="/static/acme-favicon.ico""#));
413        assert!(!out.contains(r#"href="/workflow/favicon.svg""#));
414    }
415
416    #[test]
417    fn default_namespace_override_threads_into_data_attr() {
418        let mut cfg = default_cfg();
419        cfg.default_namespace = "deployments".into();
420        let out = render_index(TEMPLATE, "v", &cfg);
421        assert!(out.contains(r#"data-default-namespace="deployments""#));
422    }
423
424    #[test]
425    fn favicon_override_flips_footer_attribution_too() {
426        // A favicon override alone counts as "customised" — the footer
427        // should switch to the "Powered by Assay" variant.
428        let mut cfg = default_cfg();
429        cfg.favicon_url = Some("/f.ico".into());
430        let out = render_index(TEMPLATE, "v", &cfg);
431        assert!(out.contains("Powered by"));
432    }
433
434    #[test]
435    fn customised_detection_requires_more_than_defaults() {
436        let base = default_cfg();
437        assert!(!base.is_customised(), "stock defaults must not read as customised");
438
439        let mut with_name = default_cfg();
440        with_name.name = "Acme".into();
441        assert!(with_name.is_customised());
442
443        let mut with_subtitle = default_cfg();
444        with_subtitle.subtitle = "Something".into();
445        assert!(with_subtitle.is_customised());
446
447        let mut with_logo = default_cfg();
448        with_logo.logo_url = Some("/l.svg".into());
449        assert!(with_logo.is_customised());
450
451        let mut with_css = default_cfg();
452        with_css.css_url = Some("/t.css".into());
453        assert!(with_css.is_customised());
454    }
455
456    #[test]
457    fn whitelabel_name_and_mark_are_substituted() {
458        let mut cfg = default_cfg();
459        cfg.name = "Command Center".into();
460        cfg.mark = "C".into();
461        let out = render_index(TEMPLATE, "v", &cfg);
462        assert!(out.contains("<span>Command Center</span><span>C</span>"));
463    }
464
465    #[test]
466    fn logo_url_renders_img_tag() {
467        let mut cfg = default_cfg();
468        cfg.logo_url = Some("/static/siemens-logo.png".into());
469        cfg.name = "CC".into();
470        let out = render_index(TEMPLATE, "v", &cfg);
471        assert!(out.contains(r#"src="/static/siemens-logo.png""#));
472        assert!(out.contains(r#"alt="CC""#));
473    }
474
475    #[test]
476    fn parent_url_renders_back_link() {
477        let mut cfg = default_cfg();
478        cfg.parent_url = Some("https://command.example/".into());
479        cfg.parent_name = "Command Center".into();
480        let out = render_index(TEMPLATE, "v", &cfg);
481        assert!(out.contains(r#"href="https://command.example/""#));
482        assert!(out.contains("Command Center"));
483        assert!(out.contains("nav-link-back"));
484    }
485
486    #[test]
487    fn api_docs_empty_hides_the_link() {
488        let mut cfg = default_cfg();
489        cfg.api_docs_url = None;
490        let out = render_index(TEMPLATE, "v", &cfg);
491        assert!(!out.contains("API Docs"));
492        assert!(!out.contains("/api/v1/docs"));
493    }
494
495    #[test]
496    fn api_docs_override_retargets_the_link() {
497        let mut cfg = default_cfg();
498        cfg.api_docs_url = Some("https://docs.example/api".into());
499        let out = render_index(TEMPLATE, "v", &cfg);
500        assert!(out.contains(r#"href="https://docs.example/api""#));
501        assert!(out.contains("API Docs"));
502    }
503
504    #[test]
505    fn css_url_unset_emits_no_extra_stylesheet() {
506        let out = render_index(TEMPLATE, "42", &default_cfg());
507        assert!(
508            !out.contains("rel=\"stylesheet\""),
509            "no extra stylesheet should render when ASSAY_WHITELABEL_CSS_URL is unset"
510        );
511    }
512
513    #[test]
514    fn css_url_emits_cache_busted_link_tag() {
515        let mut cfg = default_cfg();
516        cfg.css_url = Some("/static/cc-theme.css".into());
517        let out = render_index(TEMPLATE, "42", &cfg);
518        assert!(out.contains(r#"<link rel="stylesheet" href="/static/cc-theme.css?v=42">"#));
519    }
520
521    #[test]
522    fn css_url_with_existing_query_string_uses_ampersand() {
523        // Operators may want to version their stylesheet themselves
524        // (`?rev=abc`) — we still tack the asset-version on without
525        // breaking the query string.
526        let mut cfg = default_cfg();
527        cfg.css_url = Some("/static/cc-theme.css?rev=abc".into());
528        let out = render_index(TEMPLATE, "42", &cfg);
529        assert!(out.contains("href=\"/static/cc-theme.css?rev=abc&v=42\""));
530    }
531
532    #[test]
533    fn html_in_brand_name_is_escaped() {
534        let mut cfg = default_cfg();
535        cfg.name = "Acme <Inc>".into();
536        let out = render_index(TEMPLATE, "v", &cfg);
537        assert!(out.contains("Acme &lt;Inc&gt;"));
538        assert!(!out.contains("<Inc>"), "raw angle brackets must not land in the HTML");
539    }
540}