Skip to main content

zeyn/
server.rs

1//! HTTP server: page shell, screen, grid, form, language and agent endpoints.
2//!
3//! The protocol is described in `docs/runtime-protocol.md`.
4
5use crate::action::{ActionMeta, DataQuery, Error};
6use crate::ctx::{Ctx, Identity, Service, Services};
7use crate::feature::{Feature, ScreenEntry};
8use crate::grid::DataGridView;
9use crate::i18n::{I18n, Text};
10use crate::platform::{Config, License, LicenseSource, Tenant};
11use crate::render::{Render, escape};
12use crate::screen::{GridSource, LoadedPage, Screen, ScreenDef};
13use crate::security::{
14    IpRange, MemoryRateStore, RateKey, RateLimit, RateStore, client_ip, parse_ranges,
15};
16use crate::time::{DateTime, TimeZone};
17use crate::ui::NodePath;
18use axum::Router;
19use axum::extract::{Path, Query, State};
20use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
21use axum::response::{Html, IntoResponse, Redirect, Response};
22use axum::routing::{get, post};
23use serde::{Deserialize, Serialize};
24use serde_json::{Map, Value as Json, json};
25use std::collections::HashMap;
26use std::future::Future;
27use std::sync::{Arc, Mutex};
28use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
29
30#[cfg(feature = "accounts")]
31pub(crate) mod accounts;
32mod agent;
33mod auth;
34#[cfg(feature = "assistant")]
35use agent::{Tool, ToolTarget};
36mod charts;
37mod data;
38mod files;
39mod grid;
40mod guard;
41mod locale;
42mod media;
43mod modules;
44mod ops;
45mod seo;
46mod session;
47mod telemetry;
48mod theme;
49
50pub use theme::Theme;
51#[cfg(feature = "email")]
52mod mail;
53#[cfg(feature = "pdf")]
54mod pdf;
55#[cfg(feature = "xlsx")]
56mod xlsx;
57
58pub use ops::{READY_CACHE, build_id, probe};
59
60/// The browser runtime every page loads, served as one module at `/_zeyn/zeyn.js`. The source is
61/// one file per concern under `assets/zeyn/`, joined here in this order (later parts use
62/// functions and constants of earlier ones):
63///
64/// - `core.js`: constants, requests, toasts, effects, windows, the loader of the grid runtime
65/// - `menus.js`: menus, popover placement, relative times
66/// - `page.js`: shortcuts, forms, export, language, time zone and theme, WebMCP tools
67/// - `events.js`: event delegation on `document` and start up
68const JS: &str = concat!(
69    include_str!("../assets/zeyn/core.js"),
70    include_str!("../assets/zeyn/menus.js"),
71    include_str!("../assets/zeyn/page.js"),
72    include_str!("../assets/zeyn/events.js"),
73);
74
75/// The grid runtime, a module at `/_zeyn/grid.js` that `zeyn.js` imports the first time a page
76/// holds a grid, so pages without one never download it. `grid-api.js` takes what it uses from
77/// `zeyn.js` (`install`), `grid-exports.js` lists what `zeyn.js` calls through `G`; between them:
78///
79/// - `grid.js`: grid setup, loading, paging and virtual scrolling
80/// - `grid-rows.js`: selection, bulk actions, inline edits, row items
81/// - `grid-columns.js`: sort, saved views, header, pinning, resizing, column menus
82/// - `grid-filters.js`: filter controls
83/// - `grid-cells.js`: row details, cell ranges, copy and paste, column windowing, branches,
84///   row keyboard
85const GRID_JS: &str = concat!(
86    include_str!("../assets/zeyn/grid-api.js"),
87    include_str!("../assets/zeyn/grid.js"),
88    include_str!("../assets/zeyn/grid-rows.js"),
89    include_str!("../assets/zeyn/grid-columns.js"),
90    include_str!("../assets/zeyn/grid-filters.js"),
91    include_str!("../assets/zeyn/grid-cells.js"),
92    include_str!("../assets/zeyn/grid-exports.js"),
93);
94const CSS: &str = include_str!("../assets/zeyn.css");
95const NAV: &str = include_str!("../assets/nav.js");
96
97#[cfg(feature = "assistant")]
98mod assistant;
99#[cfg(feature = "islands")]
100mod islands;
101mod pro;
102#[cfg(feature = "realtime")]
103pub mod realtime;
104mod site;
105mod ui;
106
107/// Finds out who is calling, usually from a session cookie or an `Authorization` header.
108pub trait Authenticator: Send + Sync + 'static {
109    fn authenticate(&self, headers: &HeaderMap) -> Option<Identity>;
110
111    /// Async work before `authenticate`, once per request, such as fetching an issuer's signing
112    /// keys. `None` (the default) when there is nothing to wait for, which costs nothing.
113    fn prepare<'a>(
114        &'a self,
115        _headers: &'a HeaderMap,
116    ) -> Option<std::pin::Pin<Box<dyn Future<Output = ()> + Send + 'a>>> {
117        None
118    }
119
120    /// True for development only authenticators; the server prints a warning at startup.
121    fn is_development(&self) -> bool {
122        false
123    }
124}
125
126impl<F> Authenticator for F
127where
128    F: Fn(&HeaderMap) -> Option<Identity> + Send + Sync + 'static,
129{
130    fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
131        self(headers)
132    }
133}
134
135/// Identity from a single sign on proxy in front of the app (Authentik, Keycloak with
136/// oauth2-proxy, Azure Application Proxy and similar): the proxy signs the user in against the
137/// central login and passes who they are in headers.
138///
139/// **Only use this when every request goes through that proxy.** Anyone who can reach the app
140/// directly can send these headers themselves. Bind the app to a private address or check the
141/// proxy's shared secret header with [`ForwardAuth::require_header`].
142///
143/// ```ignore
144/// ForwardAuth::new("x-forwarded-user")
145///     .groups("x-forwarded-groups")
146///     .require_header("x-proxy-secret", secret.expose())
147///     .role("stock-admins", ["stock.product.read", "stock.product.write"])
148/// ```
149pub struct ForwardAuth {
150    user: &'static str,
151    groups: Option<&'static str>,
152    tenant: Option<&'static str>,
153    time_zone: Option<&'static str>,
154    auth_time: Option<&'static str>,
155    shared: Option<(&'static str, String)>,
156    roles: HashMap<String, Vec<String>>,
157}
158
159impl ForwardAuth {
160    pub fn new(user_header: &'static str) -> Self {
161        ForwardAuth {
162            user: user_header,
163            groups: None,
164            tenant: None,
165            time_zone: None,
166            auth_time: None,
167            shared: None,
168            roles: HashMap::new(),
169        }
170    }
171
172    /// Header with the user's groups, comma separated.
173    pub fn groups(mut self, header: &'static str) -> Self {
174        self.groups = Some(header);
175        self
176    }
177
178    /// Header with the user's tenant.
179    pub fn tenant(mut self, header: &'static str) -> Self {
180        self.tenant = Some(header);
181        self
182    }
183
184    /// Header with the user's time zone.
185    pub fn time_zone(mut self, header: &'static str) -> Self {
186        self.time_zone = Some(header);
187        self
188    }
189
190    /// Header with the time the user signed in, as Unix seconds or RFC 3339. Needed by actions
191    /// marked `reauth`.
192    pub fn auth_time(mut self, header: &'static str) -> Self {
193        self.auth_time = Some(header);
194        self
195    }
196
197    /// Requests must carry this header with this exact value, set by the proxy.
198    pub fn require_header(mut self, header: &'static str, value: &str) -> Self {
199        self.shared = Some((header, value.to_string()));
200        self
201    }
202
203    /// Permissions granted to members of a group.
204    pub fn role<I, S>(mut self, group: &str, permissions: I) -> Self
205    where
206        I: IntoIterator<Item = S>,
207        S: Into<String>,
208    {
209        self.roles
210            .entry(group.to_string())
211            .or_default()
212            .extend(permissions.into_iter().map(Into::into));
213        self
214    }
215}
216
217impl Authenticator for ForwardAuth {
218    fn authenticate(&self, headers: &HeaderMap) -> Option<Identity> {
219        let text = |name: &str| {
220            headers
221                .get(name)
222                .and_then(|v| v.to_str().ok())
223                .map(str::trim)
224                .filter(|v| !v.is_empty())
225        };
226        if let Some((name, value)) = &self.shared
227            && !text(name).is_some_and(|v| same(v, value))
228        {
229            return None;
230        }
231        let mut identity = Identity::user(text(self.user)?);
232        if let Some(groups) = self.groups.and_then(text) {
233            for group in groups.split(',').map(str::trim) {
234                if let Some(perms) = self.roles.get(group) {
235                    identity
236                        .permissions
237                        .extend(perms.iter().cloned());
238                }
239            }
240        }
241        identity.tenant = self
242            .tenant
243            .and_then(text)
244            .map(str::to_string);
245        identity.time_zone = self
246            .time_zone
247            .and_then(text)
248            .map(str::to_string);
249        identity.authenticated_at = self
250            .auth_time
251            .and_then(text)
252            .and_then(|t| match t.parse::<i64>() {
253                Ok(secs) => secs
254                    .checked_mul(1000)
255                    .map(DateTime::from_unix_ms),
256                Err(_) => DateTime::parse(t),
257            });
258        Some(identity)
259    }
260}
261
262/// Finds the tenant of a request. The returned id is checked with `Tenant::new`.
263pub trait TenantResolver: Send + Sync + 'static {
264    fn resolve(&self, headers: &HeaderMap) -> Option<String>;
265}
266
267impl<F> TenantResolver for F
268where
269    F: Fn(&HeaderMap) -> Option<String> + Send + Sync + 'static,
270{
271    fn resolve(&self, headers: &HeaderMap) -> Option<String> {
272        self(headers)
273    }
274}
275
276/// Tenant from a request header, such as `x-tenant`. Use only behind a gateway that sets it.
277pub struct HeaderTenant(pub &'static str);
278
279impl TenantResolver for HeaderTenant {
280    fn resolve(&self, headers: &HeaderMap) -> Option<String> {
281        headers
282            .get(self.0)
283            .and_then(|v| v.to_str().ok())
284            .map(|v| v.trim().to_lowercase())
285    }
286}
287
288/// Tenant from the first label of the host: `acme.app.example.com` is `acme` for base
289/// `app.example.com`.
290pub struct SubdomainTenant(pub &'static str);
291
292impl TenantResolver for SubdomainTenant {
293    fn resolve(&self, headers: &HeaderMap) -> Option<String> {
294        let host = headers
295            .get(header::HOST)?
296            .to_str()
297            .ok()?
298            .split(':')
299            .next()?
300            .to_lowercase();
301        let label = host
302            .strip_suffix(self.0)?
303            .strip_suffix('.')?;
304        (!label.contains('.')).then(|| label.to_string())
305    }
306}
307
308/// Treats every request as the same identity. Never use it in production.
309pub struct DevAuth(Identity);
310
311impl DevAuth {
312    /// Every request is a superuser with this name.
313    pub fn superuser(name: &str) -> Self {
314        DevAuth(Identity::superuser(name))
315    }
316
317    pub fn identity(identity: Identity) -> Self {
318        DevAuth(identity)
319    }
320}
321
322impl Authenticator for DevAuth {
323    fn authenticate(&self, _headers: &HeaderMap) -> Option<Identity> {
324        let mut identity = self.0.clone();
325        identity
326            .authenticated_at
327            .get_or_insert_with(DateTime::now);
328        Some(identity)
329    }
330
331    fn is_development(&self) -> bool {
332        true
333    }
334}
335
336/// Kind of call an interceptor sees.
337#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
338#[serde(rename_all = "snake_case")]
339pub enum CallKind {
340    Page,
341    Screen,
342    DataGrid,
343    Form,
344    Api,
345    /// A masked value shown in full on request.
346    Reveal,
347    /// Rows imported from a CSV file.
348    Import,
349    /// A media file or a saved play position; `screen` holds the media source name.
350    Media,
351    /// A PDF document rendered from an action's data.
352    Document,
353    /// A live channel opened (a WebSocket handshake); `screen` holds the document kind, or is
354    /// empty for change topics.
355    Live,
356    /// A file uploaded to a file store; `screen` holds the store name. Downloads are `Media`.
357    Upload,
358}
359
360/// What is being called.
361#[derive(Clone, Debug, Serialize)]
362pub struct Call {
363    pub kind: CallKind,
364    pub screen: &'static str,
365    pub action: Option<ActionMeta>,
366    pub via_agent: bool,
367}
368
369/// How a call ended.
370#[derive(Clone, Debug)]
371pub struct CallOutcome {
372    pub ok: bool,
373    /// Error code such as `forbidden` or `validation`.
374    pub error: Option<&'static str>,
375    pub duration: Duration,
376}
377
378/// Runs around every screen, grid and form call, after access checks.
379pub trait Interceptor: Send + Sync + 'static {
380    /// Name in traces; the type name unless overridden.
381    fn name(&self) -> &'static str {
382        std::any::type_name::<Self>()
383    }
384
385    /// Returning an error stops the call.
386    fn before(&self, _ctx: &Ctx, _call: &Call) -> crate::Result<()> {
387        Ok(())
388    }
389
390    fn after(&self, _ctx: &Ctx, _call: &Call, _outcome: &CallOutcome) {}
391}
392
393/// One audit record.
394#[derive(Clone, Debug, Serialize)]
395pub struct AuditEntry {
396    pub at_unix_ms: u128,
397    pub user: Option<String>,
398    pub kind: CallKind,
399    pub screen: &'static str,
400    pub action: Option<&'static str>,
401    pub ok: bool,
402    pub error: Option<&'static str>,
403    pub via_agent: bool,
404    pub millis: u128,
405    /// Model of the saved record, such as `Product`.
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub model: Option<&'static str>,
408    /// Key of the saved record as text.
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub record: Option<String>,
411    /// Changed fields with old and new values. Old values come from the record the server loaded.
412    #[serde(skip_serializing_if = "Vec::is_empty")]
413    pub changes: Vec<crate::audit::FieldChange>,
414    /// Masked values the user was shown in full.
415    #[serde(skip_serializing_if = "Vec::is_empty")]
416    pub revealed: Vec<crate::audit::FieldRef>,
417    /// Sensitive records the user was shown.
418    #[serde(skip_serializing_if = "Vec::is_empty")]
419    pub reads: Vec<crate::audit::RecordRef>,
420    /// Reason the user gave for opening a screen that asks for one.
421    #[serde(skip_serializing_if = "Option::is_none")]
422    pub reason: Option<String>,
423    /// Workflow move: name, from, to and the user's comment.
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub transition: Option<crate::workflow::TransitionRecord>,
426    /// Export of sensitive records: model, format, row count and query.
427    #[serde(skip_serializing_if = "Option::is_none")]
428    pub export: Option<crate::audit::ExportRecord>,
429}
430
431/// Where audit records go.
432pub trait AuditSink: Send + Sync + 'static {
433    fn record(&self, entry: AuditEntry);
434}
435
436/// Keeps audit records in memory. For tests and demos.
437#[derive(Clone, Default)]
438pub struct MemoryAudit(Arc<Mutex<Vec<AuditEntry>>>);
439
440impl MemoryAudit {
441    pub fn entries(&self) -> Vec<AuditEntry> {
442        self.0
443            .lock()
444            .map(|e| e.clone())
445            .unwrap_or_default()
446    }
447}
448
449impl AuditSink for MemoryAudit {
450    fn record(&self, entry: AuditEntry) {
451        if let Ok(mut e) = self.0.lock() {
452            e.push(entry);
453        }
454    }
455}
456
457/// Writes audit records to standard error as JSON lines.
458pub struct StderrAudit;
459
460impl AuditSink for StderrAudit {
461    fn record(&self, entry: AuditEntry) {
462        if let Ok(line) = serde_json::to_string(&entry) {
463            eprintln!("{line}");
464        }
465    }
466}
467
468/// Interceptor that records every form submit, every call to an action marked `audit` and every
469/// call made by an AI agent.
470pub struct AuditLog<S: AuditSink>(pub S);
471
472impl<S: AuditSink> Interceptor for AuditLog<S> {
473    fn after(&self, ctx: &Ctx, call: &Call, outcome: &CallOutcome) {
474        let trail = ctx.trail();
475        let audited = call.via_agent
476            || call.kind == CallKind::Form
477            || call.kind == CallKind::Reveal
478            || call.kind == CallKind::Import
479            || call.kind == CallKind::Upload
480            || call.action.is_some_and(|a| a.audit)
481            || !trail.revealed.is_empty()
482            || !trail.reads.is_empty()
483            || trail.transition.is_some()
484            || trail.export.is_some();
485        if !audited {
486            return;
487        }
488        self.0.record(AuditEntry {
489            model: trail.record.as_ref().map(|r| r.model),
490            record: trail.record.and_then(|r| r.id),
491            changes: trail.changes,
492            revealed: trail.revealed,
493            reads: trail.reads,
494            reason: trail.reason,
495            transition: trail.transition,
496            export: trail.export,
497            at_unix_ms: SystemTime::now()
498                .duration_since(UNIX_EPOCH)
499                .map(|d| d.as_millis())
500                .unwrap_or(0),
501            user: ctx.identity().user.clone(),
502            kind: call.kind,
503            screen: call.screen,
504            action: call.action.map(|a| a.name),
505            ok: outcome.ok,
506            error: outcome.error,
507            via_agent: call.via_agent,
508            millis: outcome.duration.as_millis(),
509        });
510    }
511}
512
513/// Lets outside AI agents use the app: the MCP endpoint `/_zeyn/mcp` (JSON-RPC, needs an
514/// `Authorization` header) and WebMCP tools in the browser (`GET /_zeyn/tools`).
515///
516/// Off unless set with [`App::mcp`]. The tools are built from the app itself: one search tool per
517/// grid and one save tool per form of every routed screen, plus every published API action, with
518/// descriptions from the `///` doc comments. Agents act as the signed in user: same permissions,
519/// guards, interceptors and rate limits, every call audited with `via_agent`, masked fields always
520/// masked. Actions and screens marked `ai = false` are never offered, and actions marked `confirm`
521/// cannot be called over `/_zeyn/mcp` because nobody there can confirm them.
522///
523/// ```ignore
524/// App::new("Shop").mcp(Mcp::read_only())          // agents may read
525/// App::new("Shop").mcp(Mcp::on().webmcp(false))   // read and write, server endpoint only
526/// ```
527#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
528pub struct Mcp {
529    writes: bool,
530    server: bool,
531    browser: bool,
532}
533
534impl Mcp {
535    /// No MCP endpoint and no WebMCP tools. The default.
536    pub fn off() -> Self {
537        Mcp::default()
538    }
539
540    /// Agents may search grids and call API actions that only read, not save forms.
541    pub fn read_only() -> Self {
542        Mcp {
543            writes: false,
544            server: true,
545            browser: true,
546        }
547    }
548
549    /// Agents may also save forms and call API actions that write. Audit records mark these calls.
550    pub fn on() -> Self {
551        Mcp {
552            writes: true,
553            server: true,
554            browser: true,
555        }
556    }
557
558    /// Same as [`Mcp::on`].
559    pub fn read_write() -> Self {
560        Mcp::on()
561    }
562
563    /// `false` turns off the `/_zeyn/mcp` endpoint and keeps the browser tools.
564    pub fn server(mut self, on: bool) -> Self {
565        self.server = on;
566        self
567    }
568
569    /// `false` turns off the WebMCP tools (`GET /_zeyn/tools`) and keeps the endpoint.
570    pub fn webmcp(mut self, on: bool) -> Self {
571        self.browser = on;
572        self
573    }
574
575    /// Whether agents may write.
576    pub fn writes(&self) -> bool {
577        self.writes
578    }
579
580    /// Whether anything is on.
581    pub fn is_on(&self) -> bool {
582        self.server || self.browser
583    }
584}
585
586/// Checks a user name and password, for HTTP basic authentication of the API documentation.
587/// Implement it to read the password from a database, a vault or anywhere else.
588pub trait BasicCredentials: Send + Sync + 'static {
589    fn verify(&self, user: &str, password: &str) -> crate::BoxFuture<bool>;
590}
591
592/// One fixed user name and password, compared in constant time.
593pub struct StaticCredentials {
594    user: String,
595    password: crate::Secret<String>,
596}
597
598impl StaticCredentials {
599    pub fn new(user: &str, password: crate::Secret<String>) -> Self {
600        StaticCredentials {
601            user: user.to_string(),
602            password,
603        }
604    }
605}
606
607impl BasicCredentials for StaticCredentials {
608    fn verify(&self, user: &str, password: &str) -> crate::BoxFuture<bool> {
609        let ok = same(user, &self.user) & same(password, self.password.expose());
610        Box::pin(async move { ok })
611    }
612}
613
614/// Who may read the API documentation (`openapi.json` and the reference page).
615pub enum ApiDocs {
616    /// Anyone who can reach the server.
617    Open,
618    /// HTTP basic authentication. Use only over HTTPS and together with a rate limit.
619    Basic(Arc<dyn BasicCredentials>),
620    /// Signed in users with this permission.
621    Permission(&'static str),
622}
623
624impl ApiDocs {
625    pub fn open() -> Self {
626        ApiDocs::Open
627    }
628
629    pub fn basic(credentials: impl BasicCredentials) -> Self {
630        ApiDocs::Basic(Arc::new(credentials))
631    }
632
633    pub fn permission(permission: &'static str) -> Self {
634        ApiDocs::Permission(permission)
635    }
636}
637
638/// Decodes standard base64; `None` on any invalid input.
639fn base64_decode(s: &str) -> Option<Vec<u8>> {
640    let value = |c: u8| -> Option<u32> {
641        match c {
642            b'A'..=b'Z' => Some(u32::from(c - b'A')),
643            b'a'..=b'z' => Some(u32::from(c - b'a') + 26),
644            b'0'..=b'9' => Some(u32::from(c - b'0') + 52),
645            b'+' => Some(62),
646            b'/' => Some(63),
647            _ => None,
648        }
649    };
650    let bytes = s
651        .trim()
652        .trim_end_matches('=')
653        .as_bytes();
654    let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
655    for chunk in bytes.chunks(4) {
656        let mut n = 0u32;
657        for (i, c) in chunk.iter().enumerate() {
658            n |= value(*c)? << (18 - 6 * i);
659        }
660        let take = match chunk.len() {
661            4 => 3,
662            3 => 2,
663            2 => 1,
664            _ => return None,
665        };
666        out.extend_from_slice(&n.to_be_bytes()[1..1 + take]);
667    }
668    Some(out)
669}
670
671/// A reusable bundle of app settings: a company's login, configuration sources, license server,
672/// audit sink, interceptors, services and features, shipped as one crate and added in one line.
673///
674/// ```ignore
675/// pub struct AcmeStandard { pub config: Config }
676/// impl Plugin for AcmeStandard {
677///     fn install(self, app: App) -> App {
678///         app.config(self.config.clone())
679///             .auth(ForwardAuth::new("x-forwarded-user").groups("x-forwarded-groups"))
680///             .interceptor(AuditLog(AcmeAuditSink::new(&self.config)))
681///             .time_policy(TimePolicy::Organization(TimeZone::named("Europe/Istanbul").unwrap()))
682///     }
683/// }
684/// App::new("Billing").plugin(AcmeStandard { config }).feature(billing::feature())
685/// ```
686pub trait Plugin {
687    fn install(self, app: App) -> App;
688}
689
690/// Which time zone moments are shown and read in.
691#[derive(Clone, Copy, Debug)]
692pub enum TimePolicy {
693    /// The zone the user's browser reports, or `default` until it does.
694    User { default: TimeZone },
695    /// One zone for everyone, such as the organization's: an office in Istanbul shows Istanbul
696    /// time even to staff connecting from abroad.
697    Organization(TimeZone),
698}
699
700/// Whether the browser loads screens ahead of a click, see [`App::prefetch`].
701#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
702pub enum Prefetch {
703    /// Nothing is loaded ahead.
704    #[default]
705    Off,
706    /// A screen loads when the pointer rests on its link, the link gets focus or is touched.
707    Moderate,
708}
709
710/// Whether a request was sent ahead of a click: `Sec-Purpose: prefetch` from the browser, or
711/// `x-zeyn-prefetch: 1` from the fallback of `nav.js`.
712fn is_prefetch(headers: &HeaderMap) -> bool {
713    headers
714        .get("sec-purpose")
715        .and_then(|v| v.to_str().ok())
716        .is_some_and(|v| v.contains("prefetch"))
717        || headers
718            .get("x-zeyn-prefetch")
719            .is_some_and(|v| v == "1")
720}
721
722/// A Zeyn application.
723///
724/// ```ignore
725/// App::new("Inventory")
726///     .service(ProductStore::default())
727///     .feature(stock::feature())
728///     .serve("127.0.0.1:3000")
729///     .await
730/// ```
731pub struct App {
732    name: Text,
733    i18n: I18n,
734    services: Services,
735    features: Vec<Feature>,
736    auth: Option<Arc<dyn Authenticator>>,
737    interceptors: Vec<Arc<dyn Interceptor>>,
738    timeout: Duration,
739    secure_cookies: bool,
740    mcp: Mcp,
741    home: Option<&'static str>,
742    errors: ErrorScreens,
743    llms_txt: Option<&'static str>,
744    csp: crate::csp::Csp,
745    robots_txt: Option<&'static str>,
746    site_url: String,
747    time: TimePolicy,
748    fixed_now: Option<DateTime>,
749    zone_picker: bool,
750    api_prefix: String,
751    config: Config,
752    tenancy: Option<Arc<dyn TenantResolver>>,
753    license: Option<(Arc<dyn LicenseSource>, Duration)>,
754    login_url: Option<String>,
755    cors: Vec<String>,
756    api_docs: Option<ApiDocs>,
757    theme: Option<Theme>,
758    base_path: String,
759    public_path: Option<String>,
760    rate: Option<RateLimit>,
761    write_rate: Option<RateLimit>,
762    rate_store: Arc<dyn RateStore>,
763    trusted: Vec<IpRange>,
764    deny: Vec<IpRange>,
765    allow: Vec<IpRange>,
766    setup_problems: Vec<String>,
767    views: Option<Arc<dyn crate::grid::DataGridViewStore>>,
768    native_menu: bool,
769    locale_paths: bool,
770    palette_keys: String,
771    client_prefs: crate::prefs::ClientPrefs,
772    prefetch: Prefetch,
773    extras: ops::Extras,
774    sign_in: auth::Extras,
775    #[cfg(feature = "assistant")]
776    assistant: Option<Arc<crate::assistant::Assistant>>,
777    looks: theme::Settings,
778    #[cfg(feature = "islands")]
779    islands_dir: Option<std::path::PathBuf>,
780    #[cfg(feature = "realtime")]
781    realtime: Option<realtime::Hub>,
782}
783
784impl App {
785    pub fn new(name: impl Into<Text>) -> Self {
786        App {
787            name: name.into(),
788            i18n: I18n::new("en"),
789            services: Services::default(),
790            features: Vec::new(),
791            auth: None,
792            interceptors: Vec::new(),
793            timeout: Duration::from_secs(30),
794            secure_cookies: false,
795            mcp: Mcp::off(),
796            home: None,
797            errors: ErrorScreens::default(),
798            llms_txt: None,
799            csp: crate::csp::Csp::new(),
800            robots_txt: None,
801            site_url: String::new(),
802            time: TimePolicy::User {
803                default: TimeZone::UTC,
804            },
805            fixed_now: None,
806            zone_picker: false,
807            api_prefix: "/api/v1".into(),
808            config: Config::default(),
809            tenancy: None,
810            license: None,
811            login_url: None,
812            cors: Vec::new(),
813            api_docs: None,
814            theme: None,
815            base_path: String::new(),
816            public_path: None,
817            rate: None,
818            write_rate: None,
819            rate_store: Arc::new(MemoryRateStore::default()),
820            trusted: Vec::new(),
821            deny: Vec::new(),
822            allow: Vec::new(),
823            setup_problems: Vec::new(),
824            views: None,
825            native_menu: true,
826            locale_paths: false,
827            palette_keys: crate::command::PALETTE_SHORTCUT.into(),
828            client_prefs: crate::prefs::ClientPrefs::default(),
829            prefetch: Prefetch::Off,
830            extras: ops::Extras::default(),
831            sign_in: auth::Extras::default(),
832            #[cfg(feature = "assistant")]
833            assistant: None,
834            looks: theme::Settings::default(),
835            #[cfg(feature = "islands")]
836            islands_dir: None,
837            #[cfg(feature = "realtime")]
838            realtime: None,
839        }
840    }
841
842    /// Turns on the realtime channel: collaborative documents and change topics over WebSocket
843    /// (see `docs/realtime.md`). The hub is also registered as a service, so actions can take
844    /// `hub: Hub` and call `hub.notify(&ctx, "topic")`.
845    #[cfg(feature = "realtime")]
846    pub fn realtime(mut self, realtime: realtime::Realtime) -> Self {
847        let hub = realtime.install();
848        self.realtime = Some(hub.clone());
849        self.service(hub)
850    }
851
852    /// Serves the WASM islands that `zeyn build-islands` wrote to `dir` (usually `dist/islands`).
853    /// Pages that hold an [`Island`](crate::ui::Island) get the loader and a policy that allows
854    /// WebAssembly; other pages are unchanged. When `dir` has no manifest, islands show their
855    /// fallback and the server logs a warning.
856    #[cfg(feature = "islands")]
857    pub fn islands(mut self, dir: impl Into<std::path::PathBuf>) -> Self {
858        self.islands_dir = Some(dir.into());
859        self
860    }
861
862    /// `false` stops the browser's own context menu everywhere except in text inputs, text areas
863    /// and editable text. This protects nothing (any user can still view the source, use the
864    /// developer tools or turn scripts off) and it takes away spell check, copy link, translate
865    /// and accessibility tools. Keep the default (`true`) unless a kiosk or a strict brief asks
866    /// for it; Zeyn's own menus work either way.
867    pub fn browser_context_menu(mut self, allow: bool) -> Self {
868        self.native_menu = allow;
869        self
870    }
871
872    /// Puts the language in the URL: the default language stays at `/orders`, every other
873    /// offered language is served at `/tr/orders`, `/ar/orders`. The path alone decides the
874    /// language (not the cookie or `Accept-Language`), so search engines and shared links get the
875    /// same page; menu links keep the language and the picker opens the same page in another
876    /// language without a page load. Screens keep one route each and read the language from
877    /// `ctx.locale()`. See `examples/docs/pages/time-and-languages.md`.
878    ///
879    /// ```
880    /// let app = zeyn::App::new("Shop").locale_paths();
881    /// ```
882    pub fn locale_paths(mut self) -> Self {
883        self.locale_paths = true;
884        self
885    }
886
887    /// The shortcut that opens the command palette, `Alt+Shift+P` by default. Any shortcut works,
888    /// also one the browser uses such as `Ctrl+Shift+P` or `F1` (choosing it here is the opt in);
889    /// `App::check` refuses ones the browser never passes to the page, such as `Ctrl+T`.
890    pub fn palette_shortcut(mut self, keys: &str) -> Self {
891        self.palette_keys = keys.to_string();
892        self
893    }
894
895    /// Registers the app's client preference keys and its consent hook (see [`crate::ClientPref`]).
896    /// Components bound to a key that is not registered are not remembered.
897    ///
898    /// ```
899    /// use std::time::Duration;
900    /// use zeyn::prelude::*;
901    /// use zeyn::{ClientPref, Choice, Consent, Purpose};
902    ///
903    /// pub const VIDEO_NOTICE: ClientPref<Consent> =
904    ///     ClientPref::consent("video-notice", Duration::from_secs(86_400), Purpose::Necessary);
905    /// pub const ORDER_TAB: ClientPref<Choice> =
906    ///     ClientPref::choice("order-tab", Duration::from_secs(30 * 86_400), Purpose::Preference);
907    ///
908    /// let app = App::new("Shop").client_prefs(|p| {
909    ///     p.key(VIDEO_NOTICE)
910    ///         .key(ORDER_TAB)
911    ///         .allowed_when(|h| h.get("cookie").and_then(|c| c.to_str().ok()).is_some_and(|c| c.contains("consent=yes")))
912    /// });
913    /// assert_eq!(app.client_prefs_table().len(), 2);
914    /// ```
915    pub fn client_prefs(
916        mut self,
917        build: impl FnOnce(crate::prefs::ClientPrefs) -> crate::prefs::ClientPrefs,
918    ) -> Self {
919        self.client_prefs = build(std::mem::take(&mut self.client_prefs));
920        self
921    }
922
923    /// Lets the browser load screens ahead of a click, off by default. With
924    /// [`Prefetch::Moderate`] `nav.js` fetches the partial screen of a link after 100 ms of
925    /// hover, on keyboard focus or on touch, keeps it 10 seconds for the click, runs at most two
926    /// such fetches at once and cancels one when the pointer leaves first. For pages where the
927    /// script is missing, Chromium gets Speculation Rules (prefetch only, never prerender, so no
928    /// script of the next page runs early) through a `Speculation-Rules` header pointing to
929    /// `/_zeyn/speculation.json`, so the CSP stays as it is; the rules leave out pages the
930    /// script runs on, so nothing is fetched twice. Only links to path routed screens of the app
931    /// are prefetched; never `/_zeyn/*`, links with `data-zeyn-reload` (sign in and out),
932    /// `download` or a `target`, and never screens marked `#[screen(no_prefetch)]`, which the
933    /// server refuses before they run. Nothing is prefetched with Save-Data,
934    /// `prefers-reduced-data`, a 2g connection or in a hidden tab. Screens see it in
935    /// [`Ctx::is_prefetch`], so they can skip counting views; the page a prefetch served
936    /// counts as a prefetch when the user then opens it.
937    pub fn prefetch(mut self, prefetch: Prefetch) -> Self {
938        self.prefetch = prefetch;
939        self
940    }
941
942    /// The registered client preference keys with their kind, purpose and lifetime, for a
943    /// privacy page. The browser removes all of them with `zeyn.prefs.clear()` or an element
944    /// with `data-zeyn-prefs-clear`.
945    pub fn client_prefs_table(&self) -> &[crate::prefs::PrefInfo] {
946        &self.client_prefs.keys
947    }
948
949    /// Saves each signed in user's grid layout, sort and filters on the server, so they come
950    /// back on every device. Without a store the browser keeps them in `localStorage`.
951    pub fn datagrid_views(mut self, store: impl crate::grid::DataGridViewStore) -> Self {
952        self.views = Some(Arc::new(store));
953        self
954    }
955
956    /// The organization's look. See [`Theme`] and `docs/theming.md`.
957    pub fn theme(mut self, theme: Theme) -> Self {
958        self.theme = Some(theme);
959        self
960    }
961
962    /// Serves the app under a path such as `/inventory`, like a base URL in Vue. Links, redirects,
963    /// assets, runtime requests and cookies all use it, so several apps can share one host.
964    pub fn base_path(mut self, path: &str) -> Self {
965        self.base_path = normalize_base(path);
966        self
967    }
968
969    /// For proxies that remove the prefix before forwarding (such as Traefik `StripPrefix`): the app
970    /// listens at `/` but builds every URL and cookie path with this prefix.
971    pub fn public_path(mut self, path: &str) -> Self {
972        self.public_path = Some(normalize_base(path));
973        self
974    }
975
976    /// Limit for every request except static assets.
977    pub fn rate_limit(mut self, limit: RateLimit) -> Self {
978        self.rate = Some(limit);
979        self
980    }
981
982    /// Extra, usually stricter, limit for form submits, API calls and MCP calls.
983    pub fn write_limit(mut self, limit: RateLimit) -> Self {
984        self.write_rate = Some(limit);
985        self
986    }
987
988    /// Where rate counters live. In memory by default (one server).
989    pub fn rate_store(mut self, store: impl RateStore) -> Self {
990        self.rate_store = Arc::new(store);
991        self
992    }
993
994    fn ranges<I, S>(&mut self, what: &str, ranges: I) -> Vec<IpRange>
995    where
996        I: IntoIterator<Item = S>,
997        S: AsRef<str>,
998    {
999        parse_ranges(ranges).unwrap_or_else(|e| {
1000            self.setup_problems
1001                .push(format!("{what}: {e}"));
1002            Vec::new()
1003        })
1004    }
1005
1006    /// Proxies and load balancers whose `X-Forwarded-For` header is believed. Without this the
1007    /// header is ignored, because any client can write it.
1008    pub fn trusted_proxies<I, S>(mut self, ranges: I) -> Self
1009    where
1010        I: IntoIterator<Item = S>,
1011        S: AsRef<str>,
1012    {
1013        self.trusted = self.ranges("trusted_proxies", ranges);
1014        self
1015    }
1016
1017    /// Addresses or ranges that are always refused.
1018    pub fn deny_ips<I, S>(mut self, ranges: I) -> Self
1019    where
1020        I: IntoIterator<Item = S>,
1021        S: AsRef<str>,
1022    {
1023        self.deny = self.ranges("deny_ips", ranges);
1024        self
1025    }
1026
1027    /// Only these addresses or ranges may connect, such as an internal network.
1028    pub fn allow_only_ips<I, S>(mut self, ranges: I) -> Self
1029    where
1030        I: IntoIterator<Item = S>,
1031        S: AsRef<str>,
1032    {
1033        self.allow = self.ranges("allow_only_ips", ranges);
1034        self
1035    }
1036
1037    /// Publishes `{api_prefix}/openapi.json` (import it in Postman, Swagger UI, Scalar or a client
1038    /// generator) and a readable reference at `{api_prefix}/docs`. Off by default, because it shows
1039    /// every endpoint and the permission it needs.
1040    ///
1041    /// ```ignore
1042    /// let docs = match config.get("docs.mode") {
1043    ///     Some("open") => Some(ApiDocs::open()),
1044    ///     Some("basic") => Some(ApiDocs::basic(StaticCredentials::new("docs", config.secret("docs.password")?))),
1045    ///     _ => None,
1046    /// };
1047    /// ```
1048    pub fn api_docs(mut self, access: ApiDocs) -> Self {
1049        self.api_docs = Some(access);
1050        self
1051    }
1052
1053    /// Lets browser apps on these exact origins (such as `https://app.example.com`) call the API
1054    /// with an `Authorization` header. Cookies are never sent cross origin, so screens stay
1055    /// protected by their CSRF token.
1056    pub fn cors<I, S>(mut self, origins: I) -> Self
1057    where
1058        I: IntoIterator<Item = S>,
1059        S: Into<String>,
1060    {
1061        self.cors
1062            .extend(origins.into_iter().map(Into::into));
1063        self
1064    }
1065
1066    /// Configuration loaded at startup (see `Config::load`). Available as `ctx.config()` and as an
1067    /// injectable `Config` service.
1068    pub fn config(mut self, config: Config) -> Self {
1069        self.services.insert(config.clone());
1070        self.config = config;
1071        self
1072    }
1073
1074    /// Finds the tenant of each request. A signed in user must belong to the resolved tenant
1075    /// ([`Identity::member_of`]); otherwise the request runs signed out, so public screens still
1076    /// work and nothing else does. Without a resolved tenant the user's own tenant applies.
1077    pub fn tenancy(mut self, resolver: impl TenantResolver) -> Self {
1078        self.tenancy = Some(Arc::new(resolver));
1079        self
1080    }
1081
1082    /// Loads licenses from a source and reloads them every `refresh`. When a reload fails the last
1083    /// license is kept, so a short license server outage does not stop work.
1084    pub fn license(mut self, source: impl LicenseSource, refresh: Duration) -> Self {
1085        self.license = Some((Arc::new(source), refresh));
1086        self
1087    }
1088
1089    /// Central login page. Users who are not signed in and open a protected page are sent there
1090    /// with `return_to` set to the page they wanted.
1091    pub fn login_url(mut self, url: impl Into<String>) -> Self {
1092        self.login_url = Some(url.into());
1093        self
1094    }
1095
1096    /// Path prefix of API endpoints and of `openapi.json`. Default `/api/v1`.
1097    pub fn api_prefix(mut self, prefix: impl Into<String>) -> Self {
1098        self.api_prefix = prefix
1099            .into()
1100            .trim_end_matches('/')
1101            .to_string();
1102        self
1103    }
1104
1105    /// Shows a time zone picker in the menu so users can choose a zone by hand. Only under
1106    /// `TimePolicy::User`.
1107    pub fn time_zone_picker(mut self) -> Self {
1108        self.zone_picker = true;
1109        self
1110    }
1111
1112    /// Time zone policy. Default: the user's browser zone, UTC until it is known.
1113    pub fn time_policy(mut self, policy: TimePolicy) -> Self {
1114        self.time = policy;
1115        self
1116    }
1117
1118    /// Fixes the clock for every request. For tests and demos.
1119    pub fn fixed_now(mut self, now: DateTime) -> Self {
1120        self.fixed_now = Some(now);
1121        self
1122    }
1123
1124    /// Screen that `/` redirects to. It must have a route.
1125    pub fn home<S: ScreenDef>(mut self, _screen: S) -> Self {
1126        self.home = Some(S::META.name);
1127        self
1128    }
1129
1130    /// Screen shown inside the shell, with status 404, for unknown paths and for screens that
1131    /// answer `Error::NotFound`. It takes no params and never learns what was asked for; make it
1132    /// `public` so everyone sees it. Without one, a short built in message is shown.
1133    ///
1134    /// ```
1135    /// use zeyn::prelude::*;
1136    ///
1137    /// #[screen(public)]
1138    /// pub fn missing() -> Screen { Screen::new("Nothing here") }
1139    ///
1140    /// let app = App::new("Shop").not_found(missing);
1141    /// ```
1142    pub fn not_found<S: ScreenDef<Params = ()>>(mut self, _screen: S) -> Self {
1143        self.errors.not_found = Some(crate::feature::ScreenEntry::of::<S>());
1144        self
1145    }
1146
1147    /// Screen shown inside the shell, with status 500 (or 504 for a timeout), when a handler
1148    /// fails or panics. The error's details are logged on the server and never reach it or the
1149    /// client. Without one, a short built in message is shown.
1150    pub fn server_error<S: ScreenDef<Params = ()>>(mut self, _screen: S) -> Self {
1151        self.errors.server_error = Some(crate::feature::ScreenEntry::of::<S>());
1152        self
1153    }
1154
1155    /// Serves `text` at `/llms.txt` (see llmstxt.org): a short Markdown guide for language models
1156    /// that read the site. Only public facts belong here; it is served to everyone.
1157    ///
1158    /// ```
1159    /// # use zeyn::App;
1160    /// let app = App::new("Shop").llms_txt("# Shop\n\n> Orders and stock.\n");
1161    /// ```
1162    pub fn llms_txt(mut self, text: &'static str) -> Self {
1163        self.llms_txt = Some(text);
1164        self
1165    }
1166
1167    /// Opens the page's content security policy for named `https` hosts, per directive. See
1168    /// [`crate::Csp`] for what is accepted; `App::check` reports anything else.
1169    ///
1170    /// ```
1171    /// # use zeyn::App;
1172    /// let app = App::new("Shop").csp(|c| c.frame_src(["https://www.youtube-nocookie.com"]));
1173    /// ```
1174    pub fn csp(mut self, policy: impl FnOnce(crate::csp::Csp) -> crate::csp::Csp) -> Self {
1175        self.csp = policy(std::mem::take(&mut self.csp));
1176        self
1177    }
1178
1179    /// Serves `text` at `/robots.txt` as plain text, to everyone.
1180    ///
1181    /// ```
1182    /// # use zeyn::App;
1183    /// let app = App::new("Shop").robots_txt("User-agent: *\nAllow: /\nSitemap: https://shop.example/sitemap.xml\n");
1184    /// ```
1185    pub fn robots_txt(mut self, text: &'static str) -> Self {
1186        self.robots_txt = Some(text);
1187        self
1188    }
1189
1190    /// The `https` origin the site is reached at, such as `https://shop.example`. App paths in
1191    /// canonical URLs, preview images and the sitemap are written behind it (and the base path),
1192    /// because search engines need absolute URLs. Without it, head tags keep app paths and the
1193    /// sitemap leaves them out.
1194    pub fn site_url(mut self, url: &str) -> Self {
1195        let url = url.trim_end_matches('/');
1196        if crate::seo::valid_url(url)
1197            && url.starts_with("https://")
1198            && !url["https://".len()..].contains('/')
1199        {
1200            self.site_url = url.to_string();
1201        } else {
1202            self.setup_problems.push(format!(
1203                "site_url `{}` is not an https origin\n  = help: pass only the scheme and host, such as `https://shop.example`",
1204                url.chars().take(80).collect::<String>()
1205            ));
1206        }
1207        self
1208    }
1209
1210    /// Applies a plugin. Settings made after this line override the plugin's.
1211    pub fn plugin(self, plugin: impl Plugin) -> Self {
1212        plugin.install(self)
1213    }
1214
1215    /// Message catalogs and default language.
1216    pub fn i18n(mut self, i18n: I18n) -> Self {
1217        self.i18n = i18n;
1218        self
1219    }
1220
1221    pub fn service<T: Service>(mut self, service: T) -> Self {
1222        self.services.insert(service);
1223        self
1224    }
1225
1226    pub fn auth(mut self, auth: impl Authenticator) -> Self {
1227        self.auth = Some(Arc::new(auth));
1228        self
1229    }
1230
1231    pub fn interceptor(mut self, interceptor: impl Interceptor) -> Self {
1232        self.interceptors
1233            .push(Arc::new(interceptor));
1234        self
1235    }
1236
1237    pub fn feature(mut self, feature: Feature) -> Self {
1238        self.features.push(feature);
1239        self
1240    }
1241
1242    /// Longest time a screen, grid or form call may take. 30 seconds unless set.
1243    pub fn timeout(mut self, timeout: Duration) -> Self {
1244        self.timeout = timeout;
1245        self
1246    }
1247
1248    /// Marks cookies `Secure`. Turn on whenever the app is served over HTTPS.
1249    pub fn secure_cookies(mut self, secure: bool) -> Self {
1250        self.secure_cookies = secure;
1251        self
1252    }
1253
1254    /// Turns on the MCP endpoint and the WebMCP tools for outside AI agents. Off unless set; see
1255    /// [`Mcp`] for what agents get and why it is off by default.
1256    pub fn mcp(mut self, mcp: Mcp) -> Self {
1257        self.mcp = mcp;
1258        self
1259    }
1260
1261    /// Turns on the embedded AI assistant: a chat on every page (and wherever a screen places
1262    /// `AssistantChat`) that answers with the app's own data and acts through the same tools as
1263    /// MCP, as the signed in user. See `docs/assistant.md`.
1264    #[cfg(feature = "assistant")]
1265    pub fn assistant(mut self, assistant: crate::assistant::Assistant) -> Self {
1266        self.assistant = Some(Arc::new(assistant));
1267        self
1268    }
1269
1270    /// Checks the link shortcuts of a built screen (`Chips::shortcut`, `PrevNext::shortcut`)
1271    /// against each other, the commands, the palette and the links of the site headers: the
1272    /// page level half of [`App::check`], which cannot build screens because they need params.
1273    /// Call it in a test with the params the screen gets.
1274    ///
1275    /// ```
1276    /// use zeyn::prelude::*;
1277    ///
1278    /// #[screen(route = "/", public)]
1279    /// pub fn home() -> Screen {
1280    ///     Screen::new("Home").body(
1281    ///         Chips::links("Topics")
1282    ///             .link("One", home, ())
1283    ///             .shortcut("Alt+Shift+N")
1284    ///             .link("Two", home, ())
1285    ///             .shortcut("Alt+Shift+N"),
1286    ///     )
1287    /// }
1288    ///
1289    /// let app = App::new("Shop").feature(Feature::new("shop").screen(home));
1290    /// let problems = app.check_page(&home::run()).unwrap_err();
1291    /// assert!(problems[0].contains("is also used by the link at node 0"));
1292    /// ```
1293    pub fn check_page(&self, screen: &Screen) -> Result<(), Vec<String>> {
1294        let commands: Vec<(&str, &crate::command::Command)> = self
1295            .features
1296            .iter()
1297            .flat_map(|f| {
1298                f.commands()
1299                    .iter()
1300                    .map(move |c| (f.name(), c))
1301            })
1302            .collect();
1303        let mut found = Vec::new();
1304        for f in &self.features {
1305            if let Some(shell) = f.site_shell() {
1306                for (s, nodes) in shell.header.slots.iter().enumerate() {
1307                    for (i, node) in nodes.iter().enumerate() {
1308                        node.all_shortcuts(format!("h{s}.{i}"), &mut found);
1309                    }
1310                }
1311            }
1312        }
1313        for (i, node) in screen.nodes().iter().enumerate() {
1314            node.all_shortcuts(i.to_string(), &mut found);
1315        }
1316        let links: Vec<(String, &str)> = found
1317            .into_iter()
1318            .map(|(path, keys)| {
1319                let place = match path.strip_prefix('h') {
1320                    Some(p) => format!("in the site header (node {p})"),
1321                    None => format!("at node {path}"),
1322                };
1323                (place, keys)
1324            })
1325            .collect();
1326        let problems = crate::command::link_problems(&links, &commands, &self.palette_keys);
1327        if problems.is_empty() {
1328            Ok(())
1329        } else {
1330            Err(problems)
1331        }
1332    }
1333
1334    /// Checks routes, screen names, menus and that every service a screen needs is registered.
1335    pub fn check(&self) -> Result<(), Vec<String>> {
1336        let mut problems = self.setup_problems.clone();
1337        problems.extend(self.dev_auth_check());
1338        problems.extend(self.csp.problems().iter().cloned());
1339        let mut paths: HashMap<String, &str> = HashMap::new();
1340        let mut names: HashMap<&str, &str> = HashMap::new();
1341        let sitemaps: Vec<_> = self
1342            .features
1343            .iter()
1344            .filter_map(|f| {
1345                f.sitemap_source()
1346                    .map(|s| (f.name(), s))
1347            })
1348            .collect();
1349        if sitemaps.len() > 1 {
1350            problems.push(
1351                "more than one feature has a sitemap\n  = help: keep one `Feature::sitemap` that lists every URL"
1352                    .into(),
1353            );
1354        }
1355        for (feature, s) in &sitemaps {
1356            if !matches!(s.meta().access, crate::action::Access::Public) {
1357                problems.push(format!(
1358                    "the sitemap action `{}` of feature `{feature}` is not public\n  = help: `/sitemap.xml` is read by search engines without a session; mark the action `#[action(public)]`",
1359                    s.meta().name
1360                ));
1361            }
1362        }
1363        for f in &self.features {
1364            if let Err(p) = f.check() {
1365                problems.extend(
1366                    p.into_iter()
1367                        .map(|p| format!("feature `{}`: {p}", f.name())),
1368                );
1369            }
1370            for s in f.screens() {
1371                if let Some(other) = names.insert(s.meta.name, f.name())
1372                    && other != f.name()
1373                {
1374                    problems.push(format!(
1375                        "screen `{}` is registered in features `{other}` and `{}`",
1376                        s.meta.name,
1377                        f.name()
1378                    ));
1379                }
1380                if let Some(path) = f.path_of(&s.meta) {
1381                    let shape: String = path
1382                        .split('/')
1383                        .map(|p| if p.starts_with('{') { "{}" } else { p })
1384                        .collect::<Vec<_>>()
1385                        .join("/");
1386                    if let Some(other) = paths.insert(shape, s.meta.name)
1387                        && other != s.meta.name
1388                    {
1389                        problems.push(format!(
1390                            "route `{path}` is used by screens `{other}` and `{}`",
1391                            s.meta.name
1392                        ));
1393                    }
1394                }
1395                for dep in (s.deps)() {
1396                    if !self.services.contains(dep.id) {
1397                        problems.push(format!(
1398                            "screen `{}` needs service `{}`, which is not registered\n  = help: add `.service(...)` to the App",
1399                            s.meta.name, dep.name
1400                        ));
1401                    }
1402                }
1403            }
1404        }
1405        for f in &self.features {
1406            for (i, a) in f.apis().iter().enumerate() {
1407                if f.apis()[..i]
1408                    .iter()
1409                    .any(|b| b.meta.name == a.meta.name)
1410                {
1411                    problems.push(format!(
1412                        "api `{}` is published twice in feature `{}`",
1413                        a.meta.name,
1414                        f.name()
1415                    ));
1416                }
1417                for dep in (a.deps)() {
1418                    if !self.services.contains(dep.id) {
1419                        problems.push(format!(
1420                            "api `{}` needs service `{}`, which is not registered\n  = help: add `.service(...)` to the App",
1421                            a.meta.name, dep.name
1422                        ));
1423                    }
1424                }
1425            }
1426        }
1427        let mut media: HashMap<&str, &str> = HashMap::new();
1428        for f in &self.features {
1429            for m in f.media_sources() {
1430                if let Some(other) = media.insert(m.name, f.name())
1431                    && other != f.name()
1432                {
1433                    problems.push(format!(
1434                        "media source `{}` is registered in features `{other}` and `{}`",
1435                        m.name,
1436                        f.name()
1437                    ));
1438                }
1439            }
1440        }
1441        for s in [self.errors.not_found, self.errors.server_error]
1442            .into_iter()
1443            .flatten()
1444        {
1445            for dep in (s.deps)() {
1446                if !self.services.contains(dep.id) {
1447                    problems.push(format!(
1448                        "error screen `{}` needs service `{}`, which is not registered
1449  = help: add `.service(...)` to the App",
1450                        s.meta.name, dep.name
1451                    ));
1452                }
1453            }
1454        }
1455        for f in &self.features {
1456            let Some(shell) = f.site_shell() else {
1457                continue;
1458            };
1459            for screen in shell.linked_screens() {
1460                if !names.contains_key(screen) {
1461                    problems.push(format!(
1462                        "feature `{}`: the site footer links to screen `{screen}`, which is not registered",
1463                        f.name()
1464                    ));
1465                }
1466            }
1467            for url in shell.bad_urls() {
1468                problems.push(format!(
1469                    "feature `{}`: the site footer link `{url}` is not a path, https, http or mailto address",
1470                    f.name()
1471                ));
1472            }
1473        }
1474        if let Some(home) = self.home {
1475            if !self.features.iter().any(|f| {
1476                f.screens()
1477                    .iter()
1478                    .any(|s| s.meta.name == home && s.meta.route.is_some())
1479            }) {
1480                problems.push(format!(
1481                    "home screen `{home}` is not registered or has no route"
1482                ));
1483            }
1484            if self
1485                .features
1486                .iter()
1487                .flat_map(|f| f.screens())
1488                .any(|s| s.meta.name == home && crate::screen::route_has_segments(s.meta.route))
1489            {
1490                problems.push(format!(
1491                    "home screen `{home}` has path segments in its route
1492  = help: `/` cannot fill them; make a screen without segments the home screen"
1493                ));
1494            }
1495        }
1496        let commands: Vec<(&str, &crate::command::Command)> = self
1497            .features
1498            .iter()
1499            .flat_map(|f| {
1500                f.commands()
1501                    .iter()
1502                    .map(move |c| (f.name(), c))
1503            })
1504            .collect();
1505        problems.extend(crate::command::problems(
1506            &commands,
1507            false,
1508            &self.palette_keys,
1509        ));
1510        for f in &self.features {
1511            let Some(shell) = f.site_shell() else {
1512                continue;
1513            };
1514            let mut links = Vec::new();
1515            for (s, nodes) in shell.header.slots.iter().enumerate() {
1516                let mut found = Vec::new();
1517                for (i, node) in nodes.iter().enumerate() {
1518                    node.all_shortcuts(format!("{s}.{i}"), &mut found);
1519                }
1520                links.extend(found.into_iter().map(|(path, keys)| {
1521                    (
1522                        format!(
1523                            "in the {} slot of the site header of feature `{}` (node {path})",
1524                            crate::site::SLOT_NAMES[s],
1525                            f.name()
1526                        ),
1527                        keys,
1528                    )
1529                }));
1530            }
1531            problems.extend(crate::command::link_problems(
1532                &links,
1533                &commands,
1534                &self.palette_keys,
1535            ));
1536        }
1537        match crate::command::normalize_shortcut(&self.palette_keys) {
1538            None => problems.push(format!(
1539                "the palette shortcut `{}` cannot be read
1540  = help: write modifiers and one key joined by `+`, such as `Alt+Shift+P`",
1541                self.palette_keys
1542            )),
1543            Some(k)
1544                if crate::command::browser_shortcut(&k)
1545                    == Some(crate::command::BrowserShortcut::Reserved) =>
1546            {
1547                problems.push(format!(
1548                    "the palette shortcut `{k}` is kept by the browser and never reaches the page
1549  = help: pick a free shortcut such as `Alt+Shift+P`"
1550                ))
1551            }
1552            _ => {}
1553        }
1554        problems.extend(self.theme_problems());
1555        problems.extend(self.client_prefs.problems());
1556        problems.dedup();
1557        if problems.is_empty() {
1558            Ok(())
1559        } else {
1560            Err(problems)
1561        }
1562    }
1563
1564    /// Machine readable description of the app, used by `zeyn mcp` and `zeyn routes`.
1565    pub fn describe(&self) -> Json {
1566        json!({
1567            "name": self.i18n.text(self.i18n.default_locale(), &self.name),
1568            "locales": self.i18n.locales(),
1569            "default_locale": self.i18n.default_locale(),
1570            "mcp": self.mcp.is_on().then(|| json!({
1571                "writes": self.mcp.writes,
1572                "server": self.mcp.server,
1573                "webmcp": self.mcp.browser,
1574            })),
1575            "features": self.features.iter().map(|f| json!({
1576                "name": f.name(),
1577                "guards": f.guards().iter().map(|g| g.name()).collect::<Vec<_>>(),
1578                "screens": f.screens().iter().map(|s| json!({
1579                    "name": s.meta.name,
1580                    "route": s.meta.route,
1581                    "path": f.path_of(&s.meta),
1582                    "access": s.meta.access,
1583                    "deps": (s.deps)().iter().map(|d| d.name).collect::<Vec<_>>(),
1584                })).collect::<Vec<_>>(),
1585                "apis": f.apis().iter().map(|a| json!({
1586                    "action": a.meta.name,
1587                    "path": format!("{}/{}/{}", self.api_prefix, f.name(), a.meta.name),
1588                    "access": a.meta.access,
1589                })).collect::<Vec<_>>(),
1590                "menu": f.menu_groups().iter().map(|g| json!({
1591                    "label": self.i18n.text(self.i18n.default_locale(), &g.label),
1592                    "icon": g.icon,
1593                    "items": g.items.iter().map(|i| json!({
1594                        "label": self.i18n.text(self.i18n.default_locale(), &i.label),
1595                        "screen": i.screen,
1596                    })).collect::<Vec<_>>(),
1597                })).collect::<Vec<_>>(),
1598                "commands": f.commands().iter().map(crate::command::Command::schema).collect::<Vec<_>>(),
1599            })).collect::<Vec<_>>(),
1600            "missing_translations": self.i18n.missing_keys(),
1601            "problems": self.check().err().unwrap_or_default(),
1602        })
1603    }
1604
1605    /// Builds the HTTP router. Fails when `check` finds problems.
1606    pub fn router(mut self) -> Result<Router, Vec<String>> {
1607        self.check()?;
1608        let docs_enabled = self.api_docs.is_some();
1609        let mount = self.base_path.clone();
1610        let url_prefix = self
1611            .public_path
1612            .clone()
1613            .unwrap_or_else(|| self.base_path.clone());
1614        auth::resolve(&mut self.sign_in, &url_prefix, &mut self.login_url);
1615        let mut screens = HashMap::new();
1616        let mut routes = HashMap::new();
1617        for (fi, f) in self.features.iter().enumerate() {
1618            for s in f.screens() {
1619                screens.insert(s.meta.name, (fi, *s));
1620                if let Some(path) = f.path_of(&s.meta) {
1621                    routes.insert(s.meta.name, path);
1622                }
1623            }
1624        }
1625        let default_locale = self
1626            .locale_paths
1627            .then(|| Arc::from(self.i18n.default_locale()));
1628        self.services
1629            .insert(crate::screen::Routes(Arc::new(routes), default_locale));
1630        self.services
1631            .insert(self.client_prefs.clone());
1632        let look = self.look();
1633        #[cfg(feature = "islands")]
1634        let islands = self
1635            .islands_dir
1636            .as_deref()
1637            .and_then(|dir| match islands::Islands::load(dir, &url_prefix) {
1638                Ok(i) => Some(i),
1639                Err(e) => {
1640                    tracing::warn!("islands are not served, so they show their fallback: {e}");
1641                    None
1642                }
1643            });
1644        let state = Arc::new(AppState {
1645            errors: self.errors,
1646            name: self.name,
1647            i18n: Arc::new(self.i18n),
1648            services: Arc::new(self.services),
1649            features: self.features,
1650            screens,
1651            auth: self.auth,
1652            interceptors: self.interceptors,
1653            timeout: self.timeout,
1654            secure_cookies: self.secure_cookies,
1655            mcp: self.mcp,
1656            csp_header: HeaderValue::from_str(&self.csp.header(false))
1657                .unwrap_or(HeaderValue::from_static(guard::DEFAULT_CSP)),
1658            #[cfg(feature = "islands")]
1659            island_csp: HeaderValue::from_str(&self.csp.header(true))
1660                .unwrap_or(HeaderValue::from_static(islands::CSP)),
1661            csp: Arc::new(self.csp),
1662            time: self.time,
1663            fixed_now: self.fixed_now,
1664            zone_picker: self.zone_picker,
1665            api_prefix: self.api_prefix,
1666            config: self.config,
1667            tenancy: self.tenancy,
1668            license: self.license,
1669            licenses: Mutex::new(HashMap::new()),
1670            login_url: self.login_url,
1671            rate: self.rate,
1672            write_rate: self.write_rate,
1673            rate_store: self.rate_store,
1674            trusted: self.trusted,
1675            deny: self.deny,
1676            allow: self.allow,
1677            api_docs: self.api_docs,
1678            prefix: url_prefix,
1679            site_url: self.site_url,
1680            look,
1681            views: self.views,
1682            native_menu: self.native_menu,
1683            locale_paths: self.locale_paths,
1684            prefetch: self.prefetch,
1685            palette_keys: crate::command::normalize_shortcut(&self.palette_keys)
1686                .unwrap_or_else(|| crate::command::PALETTE_SHORTCUT.into()),
1687            extras: self.extras,
1688            sign_in: self.sign_in,
1689            #[cfg(feature = "assistant")]
1690            assistant: self.assistant,
1691            #[cfg(feature = "islands")]
1692            islands,
1693            #[cfg(feature = "realtime")]
1694            realtime: self.realtime,
1695        });
1696        ops::start_jobs(&state);
1697        let mut router = Router::new();
1698        for f in &state.features {
1699            for s in f.screens() {
1700                if let Some(path) = f.path_of(&s.meta) {
1701                    let name = s.meta.name;
1702                    router = if crate::screen::route_has_segments(Some(&path)) {
1703                        router.route(
1704                            &path,
1705                            get(
1706                                move |st: State<Arc<AppState>>,
1707                                      h: HeaderMap,
1708                                      uri: axum::http::Uri,
1709                                      Path(segments): Path<Vec<(String, String)>>,
1710                                      q: Query<ParamsQuery>| {
1711                                    page(st, h, uri, segments, q, name)
1712                                },
1713                            ),
1714                        )
1715                    } else {
1716                        router.route(
1717                            &path,
1718                            get(
1719                                move |st: State<Arc<AppState>>,
1720                                      h: HeaderMap,
1721                                      uri: axum::http::Uri,
1722                                      q: Query<ParamsQuery>| {
1723                                    page(st, h, uri, Vec::new(), q, name)
1724                                },
1725                            ),
1726                        )
1727                    };
1728                }
1729            }
1730        }
1731        for (fi, f) in state.features.iter().enumerate() {
1732            for (ai, a) in f.apis().iter().enumerate() {
1733                let path = format!("{}/{}/{}", state.api_prefix, f.name(), a.meta.name);
1734                router = router.route(
1735                    &path,
1736                    post(
1737                        move |st: State<Arc<AppState>>, h: HeaderMap, body: axum::body::Bytes| {
1738                            agent::api_call(st, h, body, fi, ai)
1739                        },
1740                    ),
1741                );
1742            }
1743        }
1744        let cors = self.cors;
1745        if docs_enabled {
1746            router = router
1747                .route(
1748                    &format!("{}/openapi.json", state.api_prefix),
1749                    get(agent::openapi),
1750                )
1751                .route(
1752                    &format!("{}/docs", state.api_prefix),
1753                    get(agent::api_reference),
1754                );
1755        }
1756        let home = self.home.and_then(|name| {
1757            state.features.iter().find_map(|f| {
1758                f.screens()
1759                    .iter()
1760                    .find(|s| s.meta.name == name)
1761                    .and_then(|s| f.path_of(&s.meta))
1762            })
1763        });
1764        if let Some(path) = home.filter(|p| p != "/") {
1765            let target = format!("{}{path}", state.prefix);
1766            router = router.route("/", get(move || async move { Redirect::to(&target) }));
1767        }
1768        if let Some(text) = self.llms_txt {
1769            router = router.route(
1770                "/llms.txt",
1771                get(move || async move { asset(text, "text/plain; charset=utf-8") }),
1772            );
1773        }
1774        if let Some(text) = self.robots_txt {
1775            router = router.route(
1776                "/robots.txt",
1777                get(move || async move { asset(text, "text/plain; charset=utf-8") }),
1778            );
1779        }
1780        let router = seo::routes(router, &state);
1781        let router = router
1782            .route(
1783                "/_zeyn/zeyn.js",
1784                get(|| async { asset(JS, "text/javascript; charset=utf-8") }),
1785            )
1786            .route(
1787                "/_zeyn/grid.js",
1788                get(|| async { asset(GRID_JS, "text/javascript; charset=utf-8") }),
1789            )
1790            .route(
1791                "/_zeyn/zeyn.css",
1792                get(|| async { asset(CSS, "text/css; charset=utf-8") }),
1793            )
1794            .route(
1795                "/_zeyn/nav.js",
1796                get(|| async { asset(NAV, "text/javascript; charset=utf-8") }),
1797            )
1798            .route("/_zeyn/theme.css", get(theme_css))
1799            .route("/favicon.ico", get(|| async { StatusCode::NO_CONTENT }))
1800            .route("/_zeyn/csrf", get(csrf_token))
1801            .route("/_zeyn/screen/{screen}", get(screen_fragment))
1802            .route("/_zeyn/route/{screen}", get(route_redirect))
1803            .route("/_zeyn/menu", post(menu_call))
1804            .route("/_zeyn/form", post(form_call))
1805            .route("/_zeyn/locale", post(set_locale))
1806            .route("/_zeyn/timezone", post(set_time_zone));
1807        #[cfg(feature = "assistant")]
1808        let router = assistant::routes(router);
1809        let router = auth::routes(router, &state);
1810        #[cfg(feature = "accounts")]
1811        let router = accounts::routes(router, &state);
1812        #[cfg(feature = "islands")]
1813        let router = islands::routes(router);
1814        #[cfg(feature = "realtime")]
1815        let router = realtime::routes(router);
1816        #[cfg(feature = "pdf")]
1817        let router = pdf::routes(router);
1818        let router = modules::routes(site::routes(charts::routes(files::routes(router))));
1819        let router = theme::routes(pro::routes(media::routes(ui::routes(session::routes(
1820            data::routes(ops::routes(agent::routes(grid::routes(router)))),
1821        )))))
1822        .fallback(not_found);
1823        #[cfg(feature = "accounts")]
1824        let router = if state.sign_in.accounts.is_some() {
1825            router.layer(axum::middleware::from_fn_with_state(
1826                state.clone(),
1827                accounts::layer,
1828            ))
1829        } else {
1830            router
1831        };
1832        let router = auth::layers(router, &state);
1833        let router = router
1834            .layer(axum::middleware::from_fn_with_state(
1835                state.clone(),
1836                catch_panics,
1837            ))
1838            .layer(axum::middleware::from_fn_with_state(
1839                state.clone(),
1840                guard::guard_request,
1841            ))
1842            .layer(axum::middleware::from_fn(static_files))
1843            .layer(compression())
1844            .layer(axum::middleware::map_request(identity_for_ranges))
1845            .layer(axum::middleware::map_response_with_state(
1846                state.clone(),
1847                guard::security_headers,
1848            ))
1849            .layer(axum::middleware::map_response(ops::stamp))
1850            .layer(axum::middleware::from_fn(guard::trace_request))
1851            .with_state(state.clone());
1852        let router = if state.locale_paths {
1853            Router::new()
1854                .fallback_service(router)
1855                .layer(axum::middleware::from_fn_with_state(state, locale::layer))
1856        } else {
1857            router
1858        };
1859        let router = if mount.is_empty() {
1860            router
1861        } else {
1862            Router::new().nest(&mount, router)
1863        };
1864        if cors.is_empty() {
1865            return Ok(router);
1866        }
1867        let origins: Vec<HeaderValue> = cors
1868            .iter()
1869            .filter_map(|o| HeaderValue::from_str(o).ok())
1870            .collect();
1871        let layer = tower_http::cors::CorsLayer::new()
1872            .allow_origin(origins)
1873            .allow_methods([axum::http::Method::GET, axum::http::Method::POST])
1874            .allow_headers([
1875                header::AUTHORIZATION,
1876                header::CONTENT_TYPE,
1877                header::ACCEPT_LANGUAGE,
1878            ])
1879            .max_age(Duration::from_secs(600));
1880        Ok(router.layer(layer))
1881    }
1882
1883    /// Checks the app and serves it. With `ZEYN_DESCRIBE=1` it prints `describe()` and exits instead.
1884    pub async fn serve(self, addr: &str) -> std::io::Result<()> {
1885        if std::env::var_os("ZEYN_DESCRIBE").is_some() {
1886            println!(
1887                "{}",
1888                serde_json::to_string_pretty(&self.describe()).unwrap_or_default()
1889            );
1890            return Ok(());
1891        }
1892        let flush = self.start_telemetry();
1893        let router = self.router().map_err(|problems| {
1894            for p in &problems {
1895                tracing::error!("{p}");
1896            }
1897            std::io::Error::other("the app has problems; see the errors above")
1898        })?;
1899        let listener = tokio::net::TcpListener::bind(addr).await.map_err(|e| match e.kind() {
1900            std::io::ErrorKind::AddrInUse | std::io::ErrorKind::PermissionDenied => std::io::Error::new(
1901                e.kind(),
1902                format!(
1903                    "cannot listen on {addr}: {e}. Another program uses the port, or the system reserves it \
1904                     (on Windows see `netsh interface ipv4 show excludedportrange protocol=tcp`). \
1905                     Choose another address with `server.addr` in the configuration."
1906                ),
1907            ),
1908            _ => e,
1909        })?;
1910        tracing::info!("Zeyn is listening on http://{}", listener.local_addr()?);
1911        let served = axum::serve(
1912            listener,
1913            router.into_make_service_with_connect_info::<std::net::SocketAddr>(),
1914        )
1915        .await;
1916        if let Some(f) = flush {
1917            f.shutdown().await;
1918        }
1919        served
1920    }
1921
1922    /// Installs the default log and trace subscriber (see `server::telemetry`) and logs the
1923    /// startup warnings.
1924    fn start_telemetry(&self) -> Option<telemetry::Flush> {
1925        let flush = telemetry::init(
1926            &self.config,
1927            &self
1928                .i18n
1929                .text(self.i18n.default_locale(), &self.name),
1930        );
1931        for (locale, key) in self.i18n.missing_keys() {
1932            tracing::warn!("translation `{key}` is missing in `{locale}`");
1933        }
1934        #[cfg(debug_assertions)]
1935        if let Some(d) = self.extras.latency {
1936            tracing::warn!(
1937                "every call is delayed by {} ms (dev_latency)",
1938                d.as_millis()
1939            );
1940        }
1941        if self
1942            .auth
1943            .as_ref()
1944            .is_some_and(|a| a.is_development())
1945        {
1946            tracing::warn!(
1947                "DevAuth is on; every request has full access. Do not use it in production."
1948            );
1949        }
1950        flush
1951    }
1952}
1953
1954/// A tenant's license as last read, with the time it was read.
1955type CachedLicense = (Instant, Option<Arc<License>>);
1956
1957struct AppState {
1958    errors: ErrorScreens,
1959    name: Text,
1960    i18n: Arc<I18n>,
1961    services: Arc<Services>,
1962    features: Vec<Feature>,
1963    screens: HashMap<&'static str, (usize, ScreenEntry)>,
1964    auth: Option<Arc<dyn Authenticator>>,
1965    interceptors: Vec<Arc<dyn Interceptor>>,
1966    timeout: Duration,
1967    secure_cookies: bool,
1968    mcp: Mcp,
1969    /// The `Content-Security-Policy` of every page, built once from `App::csp`.
1970    csp_header: HeaderValue,
1971    /// The same policy for pages that hold a WASM island.
1972    #[cfg(feature = "islands")]
1973    island_csp: HeaderValue,
1974    csp: Arc<crate::csp::Csp>,
1975    time: TimePolicy,
1976    fixed_now: Option<DateTime>,
1977    zone_picker: bool,
1978    api_prefix: String,
1979    config: Config,
1980    tenancy: Option<Arc<dyn TenantResolver>>,
1981    license: Option<(Arc<dyn LicenseSource>, Duration)>,
1982    licenses: Mutex<HashMap<String, CachedLicense>>,
1983    login_url: Option<String>,
1984    rate: Option<RateLimit>,
1985    write_rate: Option<RateLimit>,
1986    rate_store: Arc<dyn RateStore>,
1987    trusted: Vec<IpRange>,
1988    deny: Vec<IpRange>,
1989    allow: Vec<IpRange>,
1990    api_docs: Option<ApiDocs>,
1991    prefix: String,
1992    site_url: String,
1993    look: theme::Look,
1994    views: Option<Arc<dyn crate::grid::DataGridViewStore>>,
1995    native_menu: bool,
1996    locale_paths: bool,
1997    prefetch: Prefetch,
1998    palette_keys: String,
1999    extras: ops::Extras,
2000    sign_in: auth::Extras,
2001    #[cfg(feature = "assistant")]
2002    assistant: Option<Arc<crate::assistant::Assistant>>,
2003    #[cfg(feature = "islands")]
2004    islands: Option<islands::Islands>,
2005    #[cfg(feature = "realtime")]
2006    realtime: Option<realtime::Hub>,
2007}
2008
2009/// Decodes `%XX` sequences in a cookie value.
2010fn percent_decode(s: &str) -> String {
2011    let bytes = s.as_bytes();
2012    let mut out = Vec::with_capacity(bytes.len());
2013    let mut i = 0;
2014    while i < bytes.len() {
2015        if bytes[i] == b'%'
2016            && i + 2 < bytes.len()
2017            && let Ok(b) = u8::from_str_radix(&s[i + 1..i + 3], 16)
2018        {
2019            out.push(b);
2020            i += 3;
2021            continue;
2022        }
2023        out.push(bytes[i]);
2024        i += 1;
2025    }
2026    String::from_utf8_lossy(&out).into_owned()
2027}
2028
2029/// The app's own screens for 404 and server errors (`App::not_found`, `App::server_error`).
2030#[derive(Clone, Copy, Default)]
2031struct ErrorScreens {
2032    not_found: Option<ScreenEntry>,
2033    server_error: Option<ScreenEntry>,
2034}
2035
2036/// Failure of a call: an application error, a timeout, or a refusal with its own status and code
2037/// that the runtime acts on (such as `reason_required`).
2038enum Failure {
2039    App(Error),
2040    Timeout,
2041    Status(StatusCode, &'static str, Text),
2042    /// The screen answered [`Screen::moved_to`] (permanent) or [`Screen::redirect`]: a redirect
2043    /// to this screen and params.
2044    Moved(&'static str, Json, bool),
2045}
2046
2047impl From<Error> for Failure {
2048    /// A refusal for a missing second factor becomes `mfa_required`, which starts a step up sign in.
2049    fn from(e: Error) -> Self {
2050        match e {
2051            Error::Denied(Text::Key { ref key, .. }) if key == crate::auth::MFA_REQUIRED => {
2052                Failure::Status(
2053                    StatusCode::UNAUTHORIZED,
2054                    "mfa_required",
2055                    Text::key(crate::auth::MFA_REQUIRED),
2056                )
2057            }
2058            e => Failure::App(e),
2059        }
2060    }
2061}
2062
2063#[derive(Deserialize)]
2064struct ParamsQuery {
2065    params: Option<String>,
2066    /// `?return_to=` as sign in redirects and the session runtime send it; it becomes the
2067    /// params `{"return_to": ...}` of a page opened without `params`.
2068    return_to: Option<String>,
2069}
2070
2071impl ParamsQuery {
2072    fn value(&self) -> Result<Json, Failure> {
2073        match &self.params {
2074            None if self.return_to.is_some() => Ok(json!({ "return_to": self.return_to })),
2075            None => Ok(Json::Null),
2076            Some(p) => serde_json::from_str(p).map_err(|_| Failure::App(Error::NotFound)),
2077        }
2078    }
2079}
2080
2081pub(crate) fn cookie<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
2082    headers
2083        .get_all(header::COOKIE)
2084        .iter()
2085        .filter_map(|v| v.to_str().ok())
2086        .flat_map(|v| v.split(';'))
2087        .filter_map(|pair| pair.trim().split_once('='))
2088        .find(|(k, _)| *k == name)
2089        .map(|(_, v)| v)
2090}
2091
2092/// Compares two strings in time that does not depend on where they differ.
2093pub(crate) fn same(a: &str, b: &str) -> bool {
2094    a.len() == b.len()
2095        && a.bytes()
2096            .zip(b.bytes())
2097            .fold(0u8, |acc, (x, y)| acc | (x ^ y))
2098            == 0
2099}
2100
2101/// Compares two byte strings in time that does not depend on where they differ.
2102pub(crate) fn same_bytes(a: &[u8], b: &[u8]) -> bool {
2103    a.len() == b.len()
2104        && a.iter()
2105            .zip(b)
2106            .fold(0u8, |acc, (x, y)| acc | (x ^ y))
2107            == 0
2108}
2109
2110fn new_token() -> String {
2111    let mut bytes = [0u8; 32];
2112    getrandom::getrandom(&mut bytes).expect("the operating system random source must work");
2113    bytes
2114        .iter()
2115        .map(|b| format!("{b:02x}"))
2116        .collect()
2117}
2118
2119fn valid_token(t: &str) -> bool {
2120    t.len() == 64 && t.bytes().all(|b| b.is_ascii_hexdigit())
2121}
2122
2123/// The value of one hex digit, or `None`.
2124fn hex_digit(b: u8) -> Option<u8> {
2125    match b {
2126        b'0'..=b'9' => Some(b - b'0'),
2127        b'a'..=b'f' => Some(b - b'a' + 10),
2128        b'A'..=b'F' => Some(b - b'A' + 10),
2129        _ => None,
2130    }
2131}
2132
2133/// The 32 bytes of a 64 digit hex string, or `None`.
2134fn hex32(s: &[u8]) -> Option<[u8; 32]> {
2135    if s.len() != 64 {
2136        return None;
2137    }
2138    let mut out = [0u8; 32];
2139    for (i, pair) in s.as_chunks::<2>().0.iter().enumerate() {
2140        out[i] = hex_digit(pair[0])? << 4 | hex_digit(pair[1])?;
2141    }
2142    Some(out)
2143}
2144
2145/// The CSRF token as it is written into a page or a response: a fresh random pad followed by the
2146/// token XOR the pad, 128 hex digits. Every response carries different bytes for the same token,
2147/// so compression cannot leak it (BREACH). A token that is already masked is masked again with a
2148/// new pad; an empty token stays empty.
2149fn mask_token(token: &str) -> String {
2150    let Some(raw) = unmask_token(token).or_else(|| hex32(token.as_bytes())) else {
2151        return String::new();
2152    };
2153    let mut pad = [0u8; 32];
2154    getrandom::getrandom(&mut pad).expect("the operating system random source must work");
2155    let mut out = String::with_capacity(128);
2156    for b in pad.into_iter().chain(
2157        raw.into_iter()
2158            .zip(pad)
2159            .map(|(r, p)| r ^ p),
2160    ) {
2161        use std::fmt::Write;
2162        let _ = write!(out, "{b:02x}");
2163    }
2164    out
2165}
2166
2167/// The token bytes of a masked token (pad followed by token XOR pad), or `None` when it is not one.
2168fn unmask_token(masked: &str) -> Option<[u8; 32]> {
2169    let b = masked.as_bytes();
2170    if b.len() != 128 {
2171        return None;
2172    }
2173    let (pad, x) = (hex32(&b[..64])?, hex32(&b[64..])?);
2174    let mut raw = [0u8; 32];
2175    for i in 0..32 {
2176        raw[i] = pad[i] ^ x[i];
2177    }
2178    Some(raw)
2179}
2180
2181/// Whether a token sent with a request matches the `zeyn_csrf` cookie, in constant time. The
2182/// token may be masked ([`mask_token`]) or raw, as pages from before masking send it.
2183pub(crate) fn csrf_matches(cookie: &str, sent: &str) -> bool {
2184    let Some(want) = hex32(cookie.as_bytes()) else {
2185        return false;
2186    };
2187    match unmask_token(sent).or_else(|| hex32(sent.as_bytes())) {
2188        Some(got) => same_bytes(&want, &got),
2189        None => false,
2190    }
2191}
2192
2193/// Most tenants whose license is kept in memory. The tenant may come from a header or the host
2194/// name, so a client can make ids up; when the cache is full, stale entries go first and, if every
2195/// entry is fresh, the cache starts over.
2196const MAX_LICENSES: usize = 4096;
2197
2198impl AppState {
2199    /// URL of an app path, with the base path in front.
2200    fn url(&self, path: &str) -> String {
2201        format!("{}{path}", self.prefix)
2202    }
2203
2204    /// URL of a routed screen with these params, in the path form when its route has segments
2205    /// (see [`crate::screen_url`]). `None` for an unknown screen, one without a route, or params
2206    /// that do not fill the route's segments.
2207    fn page_url(&self, screen: &str, params: &Json) -> Option<String> {
2208        let (fi, entry) = self.screens.get(screen)?;
2209        let path = self.features[*fi].path_of(&entry.meta)?;
2210        crate::screen::screen_url(&path, params).map(|u| self.url(&u))
2211    }
2212
2213    /// Params of a page request: the JSON `params` query with the route's path segments read
2214    /// into the fields of the same name. A segment that does not parse answers 404.
2215    fn with_segments(
2216        &self,
2217        screen: &str,
2218        params: Json,
2219        segments: &[(String, String)],
2220    ) -> Result<Json, Failure> {
2221        if segments.is_empty() {
2222            return Ok(params);
2223        }
2224        let (_, entry) = self
2225            .screens
2226            .get(screen)
2227            .ok_or(Failure::App(Error::NotFound))?;
2228        let mut params = if params.is_null() {
2229            Json::Object(Map::new())
2230        } else {
2231            params
2232        };
2233        let fields = params
2234            .as_object_mut()
2235            .ok_or(Failure::App(Error::NotFound))?;
2236        for (name, raw) in segments {
2237            let value = (entry.path_value)(name, raw).ok_or(Failure::App(Error::NotFound))?;
2238            fields.insert(name.clone(), value);
2239        }
2240        Ok(params)
2241    }
2242
2243    /// Cookie `Path` attribute for this app.
2244    fn cookie_path(&self) -> &str {
2245        if self.prefix.is_empty() {
2246            "/"
2247        } else {
2248            &self.prefix
2249        }
2250    }
2251
2252    fn ctx(&self, headers: &HeaderMap) -> Ctx {
2253        let mut identity = self
2254            .auth
2255            .as_ref()
2256            .and_then(|a| a.authenticate(headers))
2257            .unwrap_or_default();
2258        auth::adjust(self, headers, &mut identity);
2259        let accept = headers
2260            .get(header::ACCEPT_LANGUAGE)
2261            .and_then(|v| v.to_str().ok());
2262        let locale = if self.locale_paths {
2263            headers
2264                .get(locale::HEADER)
2265                .and_then(|v| v.to_str().ok())
2266                .filter(|l| {
2267                    self.i18n
2268                        .locales()
2269                        .iter()
2270                        .any(|o| o.as_str() == *l)
2271                })
2272                .unwrap_or(self.i18n.default_locale())
2273                .to_string()
2274        } else {
2275            self.i18n
2276                .negotiate(cookie(headers, "zeyn_locale"), accept)
2277        };
2278        let tz = match self.time {
2279            TimePolicy::Organization(zone) => zone,
2280            TimePolicy::User { default } => identity
2281                .time_zone
2282                .as_deref()
2283                .and_then(TimeZone::named)
2284                .or_else(|| {
2285                    cookie(headers, "zeyn_tz_manual")
2286                        .and_then(|z| TimeZone::named(&percent_decode(z)))
2287                })
2288                .or_else(|| {
2289                    cookie(headers, "zeyn_tz").and_then(|z| TimeZone::named(&percent_decode(z)))
2290                })
2291                .unwrap_or(default),
2292        };
2293        let resolved = self
2294            .tenancy
2295            .as_ref()
2296            .and_then(|r| r.resolve(headers))
2297            .and_then(|t| Tenant::new(&t));
2298        let tenant = match resolved {
2299            Some(t) if identity.is_signed_in() && !identity.member_of(t.id()) => {
2300                tracing::debug!(
2301                    tenant = t.id(),
2302                    "the user is not a member of the request's tenant; signed out"
2303                );
2304                identity = Identity::anonymous();
2305                Some(t)
2306            }
2307            Some(t) => Some(t),
2308            None => identity
2309                .tenant
2310                .as_deref()
2311                .and_then(Tenant::new),
2312        };
2313        let ctx = Ctx::for_request(
2314            identity,
2315            locale,
2316            self.services.clone(),
2317            self.i18n.clone(),
2318            tz,
2319            tenant,
2320            self.config.clone(),
2321            self.look
2322                .pick(cookie(headers, "zeyn_theme")),
2323            self.extras.session.reauth_within,
2324            self.csp.clone(),
2325        );
2326        data::read_reason(&ctx, headers);
2327        let ctx = if is_prefetch(headers) {
2328            ctx.as_prefetch()
2329        } else {
2330            ctx
2331        };
2332        match self.fixed_now {
2333            Some(now) => ctx.with_now(now),
2334            None => ctx,
2335        }
2336    }
2337
2338    /// Context with the license that applies to the request, loading or refreshing it when due.
2339    async fn licensed(&self, ctx: Ctx) -> Ctx {
2340        let Some((source, refresh)) = &self.license else {
2341            return ctx;
2342        };
2343        let key = ctx
2344            .tenant()
2345            .map(|t| t.id().to_string())
2346            .unwrap_or_default();
2347        let cached = self
2348            .licenses
2349            .lock()
2350            .ok()
2351            .and_then(|c| c.get(&key).cloned());
2352        if let Some((at, license)) = &cached
2353            && at.elapsed() < *refresh
2354        {
2355            return ctx.with_license(license.clone());
2356        }
2357        let license = match source.load(ctx.tenant()).await {
2358            Ok(l) => l.map(Arc::new),
2359            Err(e) => {
2360                tracing::warn!("license source failed, keeping the last license: {e}");
2361                cached.and_then(|(_, l)| l)
2362            }
2363        };
2364        if let Ok(mut c) = self.licenses.lock() {
2365            if c.len() >= MAX_LICENSES && !c.contains_key(&key) {
2366                c.retain(|_, (at, _)| at.elapsed() < *refresh);
2367                if c.len() >= MAX_LICENSES {
2368                    c.clear();
2369                }
2370            }
2371            c.insert(key, (Instant::now(), license.clone()));
2372        }
2373        ctx.with_license(license)
2374    }
2375
2376    /// Existing CSRF token from the cookie, or a new one with the cookie to set.
2377    fn csrf(&self, headers: &HeaderMap) -> (String, Option<String>) {
2378        match cookie(headers, "zeyn_csrf").filter(|t| valid_token(t)) {
2379            Some(t) => (t.to_string(), None),
2380            None => {
2381                let t = new_token();
2382                let secure = if self.secure_cookies { "; Secure" } else { "" };
2383                let c = format!(
2384                    "zeyn_csrf={t}; Path={}; HttpOnly; SameSite=Strict{secure}",
2385                    self.cookie_path()
2386                );
2387                (t, Some(c))
2388            }
2389        }
2390    }
2391
2392    fn check_csrf(&self, headers: &HeaderMap) -> Result<(), Failure> {
2393        let header = headers
2394            .get("x-zeyn-csrf")
2395            .and_then(|v| v.to_str().ok())
2396            .unwrap_or("");
2397        match cookie(headers, "zeyn_csrf") {
2398            Some(c) if csrf_matches(c, header) => Ok(()),
2399            _ => Err(Failure::App(Error::Denied(Text::key("zeyn.error.csrf")))),
2400        }
2401    }
2402
2403    /// Runs application code (an action, a data source, a screen builder) with the app's time
2404    /// limit, inside a `run` span.
2405    async fn timed<T>(&self, fut: impl Future<Output = crate::Result<T>>) -> Result<T, Failure> {
2406        use tracing::Instrument;
2407        match tokio::time::timeout(self.timeout, fut)
2408            .instrument(tracing::info_span!("run"))
2409            .await
2410        {
2411            Ok(r) => r.map_err(Failure::App),
2412            Err(_) => Err(Failure::Timeout),
2413        }
2414    }
2415
2416    /// Finds, checks and builds a screen: access, then feature guards.
2417    async fn open(
2418        &self,
2419        ctx: &Ctx,
2420        name: &str,
2421        params: Json,
2422    ) -> Result<(&Feature, ScreenEntry, Screen), Failure> {
2423        let (fi, entry) = *self
2424            .screens
2425            .get(name)
2426            .ok_or(Failure::App(Error::NotFound))?;
2427        let feature = &self.features[fi];
2428        if !ctx.identity().can(&entry.meta.access) {
2429            return Err(Failure::App(Error::Forbidden));
2430        }
2431        feature.check_guards(ctx)?;
2432        data::check_reason(ctx, &entry.meta)?;
2433        let only_return_to = params
2434            .as_object()
2435            .is_some_and(|o| o.len() == 1 && o.contains_key("return_to"));
2436        let mut screen = match self
2437            .timed((entry.build)(ctx.clone(), params))
2438            .await
2439        {
2440            Err(_) if only_return_to => {
2441                self.timed((entry.build)(ctx.clone(), Json::Null))
2442                    .await?
2443            }
2444            other => other?,
2445        };
2446        if let Some((to, params, permanent)) = screen.take_moved() {
2447            return Err(Failure::Moved(to, params, permanent));
2448        }
2449        Ok((feature, entry, screen))
2450    }
2451
2452    fn before(&self, ctx: &Ctx, call: &Call) -> Result<(), Failure> {
2453        let span = tracing::Span::current();
2454        if !span.is_disabled() {
2455            span.record("kind", tracing::field::debug(call.kind));
2456            span.record("screen", call.screen);
2457            if let Some(a) = &call.action {
2458                span.record("action", a.name);
2459            }
2460            if let Some(user) = ctx.identity().user.as_deref() {
2461                span.record("user", user);
2462            }
2463            if let Some(tenant) = ctx.tenant() {
2464                span.record("tenant", tenant.id());
2465            }
2466        }
2467        session::check_reauth(self, ctx, call)?;
2468        for i in &self.interceptors {
2469            let _span =
2470                tracing::debug_span!("interceptor", name = i.name(), stage = "before").entered();
2471            i.before(ctx, call)?;
2472        }
2473        Ok(())
2474    }
2475
2476    #[cfg_attr(not(feature = "realtime"), allow(dead_code))]
2477    /// Runs the interceptors for a call that runs no application code itself, such as a socket
2478    /// handshake: `before`, then `after` with its outcome.
2479    fn admit_call(&self, ctx: &Ctx, call: &Call) -> Result<(), Failure> {
2480        let started = Instant::now();
2481        let checked = self.before(ctx, call);
2482        self.after(ctx, call, started, &checked);
2483        checked
2484    }
2485
2486    /// Runs `work` between the interceptors' `before` and `after`, so no endpoint can run
2487    /// application code without them: `before` records the call on the span, checks
2488    /// re-authentication and runs every interceptor; `work` starts only when they all pass;
2489    /// `after` sees the outcome, also when `before` refused the call. `work` is a closure so that
2490    /// nothing it builds (form validation, an action's input) runs before the interceptors.
2491    async fn intercept<T, F>(
2492        &self,
2493        ctx: &Ctx,
2494        call: &Call,
2495        work: impl FnOnce() -> F,
2496    ) -> Result<T, Failure>
2497    where
2498        F: Future<Output = Result<T, Failure>>,
2499    {
2500        let started = Instant::now();
2501        let result = async {
2502            self.before(ctx, call)?;
2503            work().await
2504        }
2505        .await;
2506        self.after(ctx, call, started, &result);
2507        result
2508    }
2509
2510    fn after<T>(&self, ctx: &Ctx, call: &Call, started: Instant, result: &Result<T, Failure>) {
2511        let outcome = CallOutcome {
2512            ok: result.is_ok(),
2513            error: match result {
2514                Ok(_) => None,
2515                Err(Failure::Timeout) => Some("timeout"),
2516                Err(Failure::App(e)) => Some(e.code()),
2517                Err(Failure::Status(_, code, _)) => Some(*code),
2518                Err(Failure::Moved(..)) => Some("moved"),
2519            },
2520            duration: started.elapsed(),
2521        };
2522        let span = tracing::Span::current();
2523        if !span.is_disabled() {
2524            span.record("ok", outcome.ok);
2525            span.record("error", outcome.error);
2526        }
2527        tracing::debug!(
2528            kind = ?call.kind,
2529            screen = call.screen,
2530            action = call.action.as_ref().map(|a| a.name),
2531            user = ctx.identity().user.as_deref(),
2532            tenant = ctx.tenant().map(|t| t.id()),
2533            ok = outcome.ok,
2534            error = outcome.error,
2535            ms = outcome.duration.as_millis() as u64,
2536            "call finished"
2537        );
2538        for i in &self.interceptors {
2539            let _span =
2540                tracing::debug_span!("interceptor", name = i.name(), stage = "after").entered();
2541            i.after(ctx, call, &outcome);
2542        }
2543    }
2544
2545    /// Key of a saved grid view: tenant, screen and node, so tenants never share views.
2546    fn view_key(ctx: &Ctx, screen: &str, node: &NodePath) -> String {
2547        format!(
2548            "{}:{screen}#{node}",
2549            ctx.tenant()
2550                .map(|t| t.id().to_string())
2551                .unwrap_or_default()
2552        )
2553    }
2554
2555    /// The signed in user's saved view of a grid, cleaned by the grid. `None` without a store,
2556    /// without a user or when nothing is saved; a failing store is logged and ignored.
2557    async fn saved_view(
2558        &self,
2559        ctx: &Ctx,
2560        grid: &dyn GridSource,
2561        screen: &str,
2562        node: &NodePath,
2563    ) -> Option<DataGridView> {
2564        let store = self.views.as_ref()?;
2565        let user = ctx.identity().user.clone()?;
2566        match self
2567            .timed(store.load(&user, &Self::view_key(ctx, screen, node)))
2568            .await
2569        {
2570            Ok(view) => view.map(|v| grid.clean_view(v)),
2571            Err(_) => {
2572                tracing::warn!("grid view store failed to load a view");
2573                None
2574            }
2575        }
2576    }
2577
2578    /// Loads the first page of every grid the user may read, in the user's saved view.
2579    async fn preload(
2580        &self,
2581        ctx: &Ctx,
2582        nodes: Vec<(NodePath, &crate::screen::Node)>,
2583        name: &str,
2584    ) -> HashMap<String, LoadedPage> {
2585        let mut loaded = HashMap::new();
2586        let saved = self.views.is_some() && ctx.identity().is_signed_in();
2587        for (i, node) in nodes {
2588            let Some(grid) = node.grid() else { continue };
2589            if !ctx
2590                .identity()
2591                .can(&grid.action().access)
2592            {
2593                continue;
2594            }
2595            let view = self
2596                .saved_view(ctx, grid, name, &i)
2597                .await;
2598            if let Ok(mut page) = self
2599                .timed(grid.fetch_at(ctx.clone(), grid.first_query(view.as_ref()), Vec::new()))
2600                .await
2601            {
2602                page.view = view;
2603                page.saved_views = saved;
2604                loaded.insert(i.to_string(), page);
2605            }
2606        }
2607        loaded
2608    }
2609
2610    fn error_response(&self, ctx: &Ctx, failure: Failure) -> Response {
2611        let (status, code, message) = match &failure {
2612            Failure::Timeout => (
2613                StatusCode::GATEWAY_TIMEOUT,
2614                "timeout",
2615                Text::key("zeyn.error.timeout"),
2616            ),
2617            Failure::Status(status, code, message) => (*status, *code, message.clone()),
2618            Failure::Moved(..) => (
2619                StatusCode::NOT_FOUND,
2620                "not_found",
2621                Text::key("zeyn.error.not_found"),
2622            ),
2623            Failure::App(e) => {
2624                let status = match e {
2625                    Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
2626                    Error::NotFound => StatusCode::NOT_FOUND,
2627                    Error::Conflict => StatusCode::CONFLICT,
2628                    Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
2629                    Error::Internal(detail) => {
2630                        tracing::error!("{detail}");
2631                        StatusCode::INTERNAL_SERVER_ERROR
2632                    }
2633                };
2634                (status, e.code(), e.message())
2635            }
2636        };
2637        if let Failure::App(Error::Validation(errors)) = failure {
2638            let list: Vec<Json> = errors
2639                .errors
2640                .iter()
2641                .map(|e| json!({ "field": e.field, "rule": e.rule, "message": ctx.t(&e.message) }))
2642                .collect();
2643            return (status, axum::Json(json!({ "error": code, "errors": list }))).into_response();
2644        }
2645        (
2646            status,
2647            axum::Json(json!({ "error": code, "message": ctx.t(&message) })),
2648        )
2649            .into_response()
2650    }
2651
2652    /// The built in error page in the shell, for sign in flows. Pages use [`Self::error_screen`].
2653    #[cfg(any(feature = "oidc", feature = "totp"))]
2654    fn error_page(&self, ctx: &Ctx, failure: Failure, csrf: &str) -> Response {
2655        let (status, message) = Self::page_status(failure);
2656        let (title, body) = Self::builtin_error(ctx, &message);
2657        (status, Html(self.shell(ctx, csrf, &title, &body, None))).into_response()
2658    }
2659
2660    /// Status and message of a failed page. Internal details are logged here and go no further.
2661    fn page_status(failure: Failure) -> (StatusCode, Text) {
2662        match failure {
2663            Failure::Timeout => (StatusCode::GATEWAY_TIMEOUT, Text::key("zeyn.error.timeout")),
2664            Failure::Status(status, _, message) => (status, message),
2665            Failure::Moved(..) => (StatusCode::NOT_FOUND, Text::key("zeyn.error.not_found")),
2666            Failure::App(e) => {
2667                let status = match &e {
2668                    Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
2669                    Error::NotFound => StatusCode::NOT_FOUND,
2670                    Error::Conflict => StatusCode::CONFLICT,
2671                    Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
2672                    Error::Internal(detail) => {
2673                        tracing::error!("{detail}");
2674                        StatusCode::INTERNAL_SERVER_ERROR
2675                    }
2676                };
2677                (status, e.message())
2678            }
2679        }
2680    }
2681
2682    /// Title and body of the built in error page: the message alone.
2683    fn builtin_error(ctx: &Ctx, message: &Text) -> (String, String) {
2684        let title = ctx.t(message);
2685        let body = format!(
2686            r#"<section class="z-screen z-error" aria-labelledby="z-error-title"><h1 id="z-error-title">{}</h1></section>"#,
2687            escape(&title)
2688        );
2689        (title, body)
2690    }
2691
2692    /// The error page of a page request: the app's screen for 404 or server errors
2693    /// (`App::not_found`, `App::server_error`) when it has one the user may open, else the built
2694    /// in message. A partial request gets only the screen, so the runtime swaps it in without a
2695    /// page load; a full request gets it inside the shell. Both carry the failure's status.
2696    async fn error_screen(
2697        &self,
2698        ctx: &Ctx,
2699        failure: Failure,
2700        csrf: &str,
2701        partial: bool,
2702    ) -> Response {
2703        let (status, message) = Self::page_status(failure);
2704        let entry = match status {
2705            StatusCode::NOT_FOUND => self.errors.not_found,
2706            s if s.is_server_error() => self.errors.server_error,
2707            _ => None,
2708        };
2709        let own = match entry {
2710            Some(e) if ctx.identity().can(&e.meta.access) => self.render_error_screen(ctx, e).await,
2711            _ => None,
2712        };
2713        let (title, body) = own.unwrap_or_else(|| Self::builtin_error(ctx, &message));
2714        let mut response = if partial {
2715            partial_response(&format!("{title} · {}", ctx.t(&self.name)), body)
2716        } else {
2717            Html(self.shell(ctx, csrf, &title, &body, None)).into_response()
2718        };
2719        *response.status_mut() = status;
2720        response
2721    }
2722
2723    /// Builds and renders an error screen; `None` when it fails or panics itself, so the built in
2724    /// message is shown instead of a second error.
2725    async fn render_error_screen(&self, ctx: &Ctx, entry: ScreenEntry) -> Option<(String, String)> {
2726        let built = catch_panic(self.timed((entry.build)(ctx.clone(), Json::Null))).await;
2727        let Ok(Ok(screen)) = built else {
2728            tracing::error!("error screen `{}` failed", entry.meta.name);
2729            return None;
2730        };
2731        std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
2732            let r = user_render(entry.meta.name, ctx);
2733            (
2734                ctx.t(screen.title()),
2735                screen.render(&r, &entry.meta, &Json::Null, &HashMap::new()),
2736            )
2737        }))
2738        .ok()
2739    }
2740
2741    /// Menu links of the features in one shell (the site shell when `site`) whose guards the user
2742    /// passes, per group: the group's icon, its label and its `<li>` links.
2743    fn menu_links(
2744        &self,
2745        ctx: &Ctx,
2746        r: &Render,
2747        current: Option<&str>,
2748        site: bool,
2749    ) -> Vec<(String, String, Vec<String>)> {
2750        let mut groups = Vec::new();
2751        for f in &self.features {
2752            if f.site_shell().is_some() != site || f.check_guards(ctx).is_err() {
2753                continue;
2754            }
2755            for g in f.menu_groups() {
2756                let items: Vec<String> = g
2757                    .items
2758                    .iter()
2759                    .filter_map(|item| {
2760                        let entry = f
2761                            .screens()
2762                            .iter()
2763                            .find(|s| s.meta.name == item.screen)?;
2764                        if !ctx.identity().can(&entry.meta.access) {
2765                            return None;
2766                        }
2767                        let path = f.path_of(&entry.meta)?;
2768                        let aria = if current == Some(item.screen) {
2769                            r#" aria-current="page""#
2770                        } else {
2771                            ""
2772                        };
2773                        Some(format!(
2774                            r#"<li><a href="{}"{aria}>{}</a></li>"#,
2775                            escape(&self.url(&locale::path(self, ctx, &path))),
2776                            r.text(&item.label)
2777                        ))
2778                    })
2779                    .collect();
2780                if !items.is_empty() {
2781                    groups.push((g.icon.html(), r.text(&g.label), items));
2782                }
2783            }
2784        }
2785        groups
2786    }
2787
2788    /// Adds the `Speculation-Rules` header of `App::prefetch` to a full page.
2789    fn speculation(&self, mut page: Response) -> Response {
2790        if self.prefetch == Prefetch::Moderate
2791            && let Ok(v) = HeaderValue::from_str(&format!(
2792                r#""{}/_zeyn/speculation.json""#,
2793                self.prefix.trim_end_matches('/')
2794            ))
2795        {
2796            page.headers_mut()
2797                .insert("speculation-rules", v);
2798        }
2799        page
2800    }
2801
2802    /// The language picker, when the app has more than one language.
2803    fn locale_picker(&self, ctx: &Ctx, r: &Render) -> String {
2804        let locales = self.i18n.locales();
2805        if locales.len() < 2 {
2806            return String::new();
2807        }
2808        if self.locale_paths {
2809            return locale::picker(self, ctx, r);
2810        }
2811        let options: String = locales
2812            .iter()
2813            .map(|l| {
2814                let selected = if l == ctx.locale() { " selected" } else { "" };
2815                let name = self
2816                    .i18n
2817                    .lookup(l, "zeyn.locale.name")
2818                    .unwrap_or(l);
2819                format!(
2820                    r#"<option value="{}"{selected}>{}</option>"#,
2821                    escape(l),
2822                    escape(name)
2823                )
2824            })
2825            .collect();
2826        format!(
2827            r#"<div class="z-locale"><span id="z-locale-label">{}</span><div class="z-select" data-text-none=""><select id="z-locale" data-zeyn-locale>{options}</select></div></div>"#,
2828            r.text(&Text::key("zeyn.shell.language"))
2829        )
2830    }
2831
2832    /// Full page around a screen: the app shell (side menu) or, for screens of a feature that
2833    /// chose it, the site shell; toasts and assets.
2834    fn shell(
2835        &self,
2836        ctx: &Ctx,
2837        csrf: &str,
2838        title: &str,
2839        main: &str,
2840        current: Option<&str>,
2841    ) -> String {
2842        let r = Render::new("shell", ctx.locale(), ctx.i18n())
2843            .with_tz(ctx.time_zone())
2844            .with_identity(ctx.identity());
2845        let site_shell = current
2846            .and_then(|n| self.screens.get(n))
2847            .and_then(|(fi, _)| {
2848                self.features[*fi]
2849                    .site_shell()
2850                    .map(|s| (*fi, s))
2851            });
2852        let theme = ctx.theme().unwrap_or("system");
2853        let app = r.text(&self.name);
2854        let (palette, commands) = pro::shell_parts(self, ctx, &r);
2855        let (frame, site_head) = match site_shell {
2856            Some((fi, shell)) => (
2857                site::frame(self, ctx, &r, shell, fi, main, current),
2858                site::head(&escape(&self.prefix)),
2859            ),
2860            None => (
2861                self.app_frame(ctx, &r, theme, &app, &palette, main, current),
2862                String::new(),
2863            ),
2864        };
2865        let (theme_attrs, scheme) = self.look.html(theme);
2866        #[cfg(feature = "assistant")]
2867        let (ai_head, ai_launcher) = (
2868            assistant::head(self, ctx, &escape(&self.prefix)),
2869            assistant::launcher(self, ctx, &r),
2870        );
2871        #[cfg(not(feature = "assistant"))]
2872        let (ai_head, ai_launcher) = (String::new(), String::new());
2873        let base = escape(&self.prefix);
2874        let components = format!(
2875            "{}{}{}{}{}{site_head}{ai_head}",
2876            ui::head(&base),
2877            media::head(&base),
2878            pro::head(&base),
2879            files::head(&base),
2880            charts::head(&base)
2881        );
2882        let native_menu = if self.native_menu {
2883            ""
2884        } else {
2885            r#" data-zeyn-native-menu="off""#
2886        };
2887        let html_attrs = format!("{theme_attrs}{native_menu}");
2888        format!(
2889            r##"<!doctype html><html lang="{lang}"{dir}{html_attrs}><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="color-scheme" content="{scheme}"><meta name="zeyn-csrf" content="{csrf}"><meta name="zeyn-tz" content="{tz}"><meta name="zeyn-base" content="{base}"><title>{title} · {app}</title><link rel="stylesheet" href="{base}/_zeyn/zeyn.css{v}"><script type="module" src="{base}/_zeyn/zeyn.js{v}"></script><script type="module" src="{base}/_zeyn/nav.js{v}"></script>{components}{theme_link}{extra}{prefetch}</head><body><a class="z-skip" href="#z-main">{skip}</a>{frame}<div class="z-toasts" role="status" aria-live="polite" data-text-dismiss="{dismiss}"></div>{commands}{ai_launcher}</body></html>"##,
2890            lang = escape(ctx.locale()),
2891            dir = if locale::rtl(ctx.locale()) {
2892                r#" dir="rtl""#
2893            } else {
2894                ""
2895            },
2896            csrf = mask_token(csrf),
2897            tz = escape(ctx.time_zone().name()),
2898            v = ops::asset_version(),
2899            theme_link = if self.look.css.is_empty() {
2900                String::new()
2901            } else {
2902                format!(
2903                    r#"<link rel="stylesheet" href="{}/_zeyn/theme.css?v={}">"#,
2904                    escape(&self.prefix),
2905                    self.look.version
2906                )
2907            },
2908            title = escape(title),
2909            extra = ops::head(self, ctx),
2910            prefetch = if self.prefetch == Prefetch::Moderate {
2911                r#"<meta name="zeyn-prefetch" content="moderate">"#
2912            } else {
2913                ""
2914            },
2915            skip = r.text(&Text::key("zeyn.shell.skip")),
2916            dismiss = r.text(&Text::key("zeyn.ui.toast.dismiss")),
2917        )
2918    }
2919
2920    /// The app shell: a side menu with the app's name, the palette button, the menus of the
2921    /// features without a site shell and the pickers, beside the screen. Below 800 px the menu
2922    /// folds behind a menu button under the app's name (W3C APG disclosure); `nav.js` shows the
2923    /// button, so without the script the menu stays open and nothing becomes unreachable.
2924    #[allow(clippy::too_many_arguments)]
2925    fn app_frame(
2926        &self,
2927        ctx: &Ctx,
2928        r: &Render,
2929        theme: &str,
2930        app: &str,
2931        palette: &str,
2932        main: &str,
2933        current: Option<&str>,
2934    ) -> String {
2935        let r = *r;
2936        let mut nav = String::new();
2937        for (icon, label, items) in self.menu_links(ctx, &r, current, false) {
2938            nav.push_str(&format!(
2939                r#"<div class="z-nav-group"><h2>{icon}{label}</h2><ul>{}</ul></div>"#,
2940                items.join("")
2941            ));
2942        }
2943        nav.push_str(&self.locale_picker(ctx, &r));
2944        nav.push_str(&self.look.picker(&r, theme));
2945        if self.zone_picker && matches!(self.time, TimePolicy::User { .. }) {
2946            let current = ctx.time_zone().name();
2947            let auto = r.text(&Text::key("zeyn.shell.time_zone_auto"));
2948            let mut options = format!(r#"<option value="">{auto}</option>"#);
2949            let listed = TimeZone::names().any(|n| n == current);
2950            for name in TimeZone::names().chain((!listed).then_some(current)) {
2951                let selected = if name == current { " selected" } else { "" };
2952                options.push_str(&format!(
2953                    r#"<option value="{name}"{selected}>{}</option>"#,
2954                    name.replace('_', " ")
2955                ));
2956            }
2957            nav.push_str(&format!(
2958                r#"<div class="z-locale"><span id="z-timezone-label">{}</span><div class="z-select" data-text-none="{auto}"><select id="z-timezone" data-zeyn-timezone>{options}</select></div></div>"#,
2959                r.text(&Text::key("zeyn.shell.time_zone"))
2960            ));
2961        }
2962        format!(
2963            r#"<div class="z-app" data-zeyn-shell="app"><nav class="z-nav" aria-label="{menu}"><div class="z-nav-bar"><p class="z-app-name">{app}</p><button type="button" class="z-nav-toggle" aria-expanded="false" aria-controls="z-nav-body" data-zeyn-nav-toggle hidden><span class="z-nav-burger" aria-hidden="true"></span><span class="z-sr-only">{menu}</span></button></div><div class="z-nav-body" id="z-nav-body">{palette}{nav}</div></nav><main class="z-main" id="z-main">{main}</main></div>"#,
2964            menu = r.text(&Text::key("zeyn.shell.menu")),
2965        )
2966    }
2967}
2968
2969/// `inventory/`, `/inventory` and `/inventory/` all become `/inventory`; `/` and empty become empty.
2970/// Renders for the request's user: their language, time zone and permissions, with masked fields
2971/// masked and reads of sensitive rows recorded for the audit.
2972fn user_render<'a>(scope: &'a str, ctx: &'a Ctx) -> Render<'a> {
2973    Render::new(scope, ctx.locale(), ctx.i18n())
2974        .with_tz(ctx.time_zone())
2975        .with_identity(ctx.identity())
2976        .with_ctx(ctx)
2977}
2978
2979fn normalize_base(path: &str) -> String {
2980    let trimmed = path.trim().trim_matches('/');
2981    if trimmed.is_empty() {
2982        String::new()
2983    } else {
2984        format!("/{trimmed}")
2985    }
2986}
2987
2988/// Framework files that are kept compressed, at most this many.
2989const STATIC_CACHE: usize = 64;
2990
2991/// The ETag and the gzip bytes of a framework file, by the address and length of its bytes.
2992type StaticEntry = (HeaderValue, Option<axum::body::Bytes>);
2993
2994/// The framework files kept compressed, by the address and length of their bytes.
2995type StaticCache = std::sync::Mutex<std::collections::HashMap<(usize, usize), Arc<StaticEntry>>>;
2996
2997static STATIC_FILES: std::sync::OnceLock<StaticCache> = std::sync::OnceLock::new();
2998
2999/// Revalidation and compression for the framework's own scripts and styles (`/_zeyn/*.js` and
3000/// `*.css`, not media or uploads): `Cache-Control: public, max-age=31536000, immutable` when the
3001/// URL carries the running build ([`ops::asset_version`]), a strong ETag over the bytes, `304 Not Modified` for a
3002/// matching `If-None-Match`, and gzip when the browser accepts it. Each file is hashed and
3003/// compressed once and kept ([`STATIC_CACHE`] files at most); files under 1 KB, such as the
3004/// theme, get an ETag but no compression.
3005async fn static_files(req: axum::extract::Request, next: axum::middleware::Next) -> Response {
3006    let path = req.uri().path();
3007    let framework = path.contains("/_zeyn/")
3008        && (path.ends_with(".js") || path.ends_with(".css"))
3009        && !path.contains("/_zeyn/media/")
3010        && !path.contains("/_zeyn/files/");
3011    if req.method() != axum::http::Method::GET || !framework {
3012        return next.run(req).await;
3013    }
3014    let gzip = req
3015        .headers()
3016        .get_all(header::ACCEPT_ENCODING)
3017        .iter()
3018        .filter_map(|v| v.to_str().ok())
3019        .flat_map(|v| v.split(','))
3020        .any(|e| {
3021            let mut p = e.split(';');
3022            p.next()
3023                .is_some_and(|n| n.trim().eq_ignore_ascii_case("gzip"))
3024                && !p.any(|q| {
3025                    q.trim()
3026                        .strip_prefix("q=")
3027                        .is_some_and(|v| {
3028                            v.trim()
3029                                .parse::<f32>()
3030                                .is_ok_and(|v| v == 0.0)
3031                        })
3032                })
3033        });
3034    let headers = req.headers().clone();
3035    let current = req.uri().query() == Some(&ops::asset_version()[1..]);
3036    let mut response = next.run(req).await;
3037    if current && response.status() == StatusCode::OK {
3038        response
3039            .headers_mut()
3040            .insert(header::CACHE_CONTROL, HeaderValue::from_static(IMMUTABLE));
3041    }
3042    if response.status() != StatusCode::OK
3043        || response
3044            .headers()
3045            .contains_key(header::ETAG)
3046        || response
3047            .headers()
3048            .contains_key(header::CONTENT_ENCODING)
3049    {
3050        return response;
3051    }
3052    let (mut parts, body) = response.into_parts();
3053    let Ok(bytes) = axum::body::to_bytes(body, 4 << 20).await else {
3054        return StatusCode::INTERNAL_SERVER_ERROR.into_response();
3055    };
3056    let key = (bytes.as_ptr() as usize, bytes.len());
3057    let cache = STATIC_FILES.get_or_init(Default::default);
3058    let known = cache
3059        .lock()
3060        .ok()
3061        .and_then(|c| c.get(&key).cloned());
3062    let entry = known.unwrap_or_else(|| {
3063        let text = std::str::from_utf8(&bytes).unwrap_or("");
3064        let tag =
3065            HeaderValue::from_str(&etag("s", &[text])).unwrap_or(HeaderValue::from_static("\"s\""));
3066        let packed = (bytes.len() >= 1024).then(|| {
3067            use std::io::Write;
3068            let mut z = flate2::write::GzEncoder::new(
3069                Vec::with_capacity(bytes.len() / 3),
3070                flate2::Compression::new(6),
3071            );
3072            z.write_all(&bytes).ok();
3073            z.finish()
3074                .ok()
3075                .map(axum::body::Bytes::from)
3076        });
3077        let entry = Arc::new((tag, packed.flatten()));
3078        if let Ok(mut c) = cache.lock()
3079            && c.len() < STATIC_CACHE
3080        {
3081            c.insert(key, entry.clone());
3082        }
3083        entry
3084    });
3085    let (tag, packed) = &*entry;
3086    parts
3087        .headers
3088        .insert(header::ETAG, tag.clone());
3089    parts
3090        .headers
3091        .insert(header::VARY, HeaderValue::from_static("accept-encoding"));
3092    if etag_matches(&headers, tag.to_str().unwrap_or("")) {
3093        parts.status = StatusCode::NOT_MODIFIED;
3094        parts
3095            .headers
3096            .remove(header::CONTENT_LENGTH);
3097        return Response::from_parts(parts, axum::body::Body::empty());
3098    }
3099    match packed {
3100        Some(z) if gzip => {
3101            parts
3102                .headers
3103                .insert(header::CONTENT_ENCODING, HeaderValue::from_static("gzip"));
3104            parts
3105                .headers
3106                .insert(header::CONTENT_LENGTH, HeaderValue::from(z.len()));
3107            Response::from_parts(parts, axum::body::Body::from(z.clone()))
3108        }
3109        _ => {
3110            parts
3111                .headers
3112                .insert(header::CONTENT_LENGTH, HeaderValue::from(bytes.len()));
3113            Response::from_parts(parts, axum::body::Body::from(bytes))
3114        }
3115    }
3116}
3117
3118/// Content types sent as they are: media, files that are compressed already, and streams that
3119/// must reach the browser event by event.
3120const UNCOMPRESSED: &[&str] = &[
3121    "video/",
3122    "audio/",
3123    "application/octet-stream",
3124    "text/event-stream",
3125    "application/x-ndjson",
3126    "application/grpc",
3127    "application/zip",
3128    "application/gzip",
3129    "application/pdf",
3130    "application/vnd.openxmlformats",
3131    "font/woff",
3132];
3133
3134/// Gzip for pages, partial screens, grid blocks and other dynamic responses of 1 KB or more, at the
3135/// level 2 (a 362 KB grid block becomes 37 KB for 0.8 ms of CPU; level 1 gives 54 KB for 0.6 ms
3136/// and level 6 35 KB for 1.4 ms). Responses that
3137/// already have a `Content-Encoding` (the framework assets from [`static_files`]) or a
3138/// `Content-Range` pass as they are, and so do the types in [`UNCOMPRESSED`], images other than
3139/// SVG and protocol switches. Streamed bodies (exports) are compressed as they stream.
3140fn compression()
3141-> tower_http::compression::CompressionLayer<impl tower_http::compression::Predicate> {
3142    use tower_http::compression::{
3143        CompressionLayer, CompressionLevel, Predicate, predicate::SizeAbove,
3144    };
3145    fn compressible(
3146        status: StatusCode,
3147        _: axum::http::Version,
3148        headers: &HeaderMap,
3149        _: &axum::http::Extensions,
3150    ) -> bool {
3151        let kind = headers
3152            .get(header::CONTENT_TYPE)
3153            .and_then(|v| v.to_str().ok())
3154            .unwrap_or("");
3155        status != StatusCode::SWITCHING_PROTOCOLS
3156            && (!kind.starts_with("image/") || kind.starts_with("image/svg+xml"))
3157            && !UNCOMPRESSED
3158                .iter()
3159                .any(|p| kind.starts_with(p))
3160    }
3161    CompressionLayer::new()
3162        .quality(CompressionLevel::Precise(2))
3163        .compress_when(SizeAbove::new(1024).and(compressible))
3164}
3165
3166/// Drops `Accept-Encoding` from requests with a `Range` header, so a byte range always refers to
3167/// the bytes of the file and is never compressed.
3168async fn identity_for_ranges(mut req: axum::extract::Request) -> axum::extract::Request {
3169    if req
3170        .headers()
3171        .contains_key(header::RANGE)
3172    {
3173        req.headers_mut()
3174            .remove(header::ACCEPT_ENCODING);
3175    }
3176    req
3177}
3178
3179fn asset(body: &'static str, content_type: &'static str) -> Response {
3180    (
3181        [
3182            (header::CONTENT_TYPE, content_type),
3183            (header::CACHE_CONTROL, "no-cache"),
3184        ],
3185        body,
3186    )
3187        .into_response()
3188}
3189
3190/// `GET /_zeyn/theme.css`: the app's theme. Its URL carries a hash of the text (`?v=`), so the
3191/// browser keeps it for a year when the hash matches; any other request revalidates.
3192async fn theme_css(State(st): State<Arc<AppState>>, uri: axum::http::Uri) -> Response {
3193    let css = st.look.css.clone();
3194    let current = uri
3195        .query()
3196        .and_then(|q| q.strip_prefix("v="))
3197        .is_some_and(|v| v == st.look.version);
3198    let cache = if current { IMMUTABLE } else { "no-cache" };
3199    (
3200        [
3201            (header::CONTENT_TYPE, "text/css; charset=utf-8"),
3202            (header::CACHE_CONTROL, cache),
3203        ],
3204        css,
3205    )
3206        .into_response()
3207}
3208
3209/// `Cache-Control` of a framework file asked for by the URL of the running build.
3210const IMMUTABLE: &str = "public, max-age=31536000, immutable";
3211
3212fn with_cookie(mut response: Response, cookie: Option<String>) -> Response {
3213    if let Some(c) = cookie.and_then(|c| HeaderValue::from_str(&c).ok()) {
3214        response
3215            .headers_mut()
3216            .append(header::SET_COOKIE, c);
3217    }
3218    response
3219}
3220
3221async fn page(
3222    State(st): State<Arc<AppState>>,
3223    headers: HeaderMap,
3224    uri: axum::http::Uri,
3225    segments: Vec<(String, String)>,
3226    Query(q): Query<ParamsQuery>,
3227    name: &'static str,
3228) -> Response {
3229    let ctx = st.licensed(st.ctx(&headers)).await;
3230    let (ctx, theme_cookie) = theme::remember(&st, ctx, &headers).await;
3231    let meta = st
3232        .screens
3233        .get(name)
3234        .map(|(_, e)| e.meta);
3235    if ctx.is_prefetch() && meta.is_none_or(|m| m.no_prefetch) {
3236        return (
3237            StatusCode::SERVICE_UNAVAILABLE,
3238            [(header::CACHE_CONTROL, "no-store")],
3239        )
3240            .into_response();
3241    }
3242    let open = !ctx.identity().is_signed_in()
3243        && meta.is_some_and(|m| matches!(m.access, crate::action::Access::Public));
3244    let (csrf, set) = if open {
3245        (String::new(), None)
3246    } else {
3247        st.csrf(&headers)
3248    };
3249    let cache = meta.map_or(0, |m| {
3250        m.cache
3251            .min(crate::screen::MAX_SCREEN_CACHE)
3252    });
3253    let started = Instant::now();
3254    let call = Call {
3255        kind: CallKind::Page,
3256        screen: name,
3257        action: None,
3258        via_agent: false,
3259    };
3260    let result = async {
3261        let params = st.with_segments(name, q.value()?, &segments)?;
3262        st.before(&ctx, &call)?;
3263        let (_, entry, screen) = st
3264            .open(&ctx, name, params.clone())
3265            .await?;
3266        data::note_screen_read(&ctx, &entry.meta, &params);
3267        let loaded = st
3268            .preload(&ctx, screen.walk(), name)
3269            .await;
3270        let r = user_render(name, &ctx);
3271        let html = screen.render(&r, &entry.meta, &params, &loaded);
3272        let head = screen.head_html(&r, &st.site_url, &st.prefix);
3273        Ok::<_, Failure>((ctx.t(screen.title()), html, head))
3274    }
3275    .await;
3276    st.after(&ctx, &call, started, &result);
3277    let partial = headers
3278        .get("x-zeyn-partial")
3279        .is_some_and(|v| v == "1");
3280    let response = match result {
3281        Ok((title, html, head)) if partial => {
3282            let full = format!("{title} · {}", ctx.t(&st.name));
3283            #[cfg(feature = "islands")]
3284            let holds_island = islands::holds(&st, &html);
3285            #[cfg(feature = "realtime")]
3286            let holds_live = realtime::holds(&st, &html);
3287            let html = if head.is_empty() {
3288                html
3289            } else {
3290                format!("<template data-zeyn-head>{head}</template>{html}")
3291            };
3292            let tag = (open && cache > 0).then(|| etag("p", &[&full, &html]));
3293            #[allow(unused_mut)]
3294            let mut r = partial_response(&full, html);
3295            let h = r.headers_mut();
3296            let site = st
3297                .screens
3298                .get(name)
3299                .is_some_and(|(fi, _)| st.features[*fi].site_shell().is_some());
3300            h.insert(
3301                "x-zeyn-shell",
3302                HeaderValue::from_static(if site { "site" } else { "app" }),
3303            );
3304            #[cfg(feature = "islands")]
3305            if holds_island {
3306                h.insert("x-zeyn-islands", HeaderValue::from_static("1"));
3307            }
3308            #[cfg(feature = "realtime")]
3309            if holds_live {
3310                h.insert("x-zeyn-live", HeaderValue::from_static("1"));
3311            }
3312            match tag {
3313                Some(tag) => public_cache(r, &headers, &tag, cache),
3314                None => r,
3315            }
3316        }
3317        Ok((title, html, head)) => {
3318            let mut page = st.shell(&ctx, &csrf, &title, &html, Some(name));
3319            if !head.is_empty() {
3320                page = page.replacen("</head>", &format!("{head}</head>"), 1);
3321            }
3322            if html.contains("data-zeyn-grid") {
3323                let preload = format!(
3324                    r#"<link rel="modulepreload" href="{}/_zeyn/grid.js{}"></head>"#,
3325                    escape(&st.prefix),
3326                    ops::asset_version()
3327                );
3328                page = page.replacen("</head>", &preload, 1);
3329            }
3330            #[cfg(feature = "realtime")]
3331            realtime::head(&st, &html, &mut page);
3332            let tag = (open && cache > 0).then(|| etag("f", &[&page]));
3333            #[cfg(feature = "islands")]
3334            let page = islands::page(&st, page, &html);
3335            #[cfg(not(feature = "islands"))]
3336            let page = Html(page).into_response();
3337            let page = st.speculation(page);
3338            match tag {
3339                Some(tag) => public_cache(page, &headers, &tag, cache),
3340                None if cache > 0 => private_page(page),
3341                None => page,
3342            }
3343        }
3344        Err(Failure::App(Error::Forbidden | Error::Denied(_)))
3345            if !ctx.identity().is_signed_in() && st.login_url.is_some() =>
3346        {
3347            let here = st.url(
3348                uri.path_and_query()
3349                    .map_or(uri.path(), |p| p.as_str()),
3350            );
3351            let target = st.login_url.clone().unwrap_or_default();
3352            let sep = if target.contains('?') { '&' } else { '?' };
3353            Redirect::to(&format!("{target}{sep}return_to={}", encode(&here))).into_response()
3354        }
3355        Err(Failure::Status(_, "mfa_required", _)) if st.sign_in.mfa_url.is_some() => {
3356            let here = st.url(
3357                uri.path_and_query()
3358                    .map_or(uri.path(), |p| p.as_str()),
3359            );
3360            let target = st
3361                .sign_in
3362                .mfa_url
3363                .clone()
3364                .unwrap_or_default();
3365            let sep = if target.contains('?') { '&' } else { '?' };
3366            Redirect::to(&format!("{target}{sep}return_to={}", encode(&here))).into_response()
3367        }
3368        Err(Failure::Status(_, "reason_required", _)) => data::reason_page(&st, &ctx, name, &csrf),
3369        Err(Failure::Moved(to, params, true)) => match st.page_url(to, &params) {
3370            Some(url) => (StatusCode::MOVED_PERMANENTLY, [(header::LOCATION, url)]).into_response(),
3371            None => {
3372                st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3373                    .await
3374            }
3375        },
3376        Err(Failure::Moved(to, params, false)) => match st.page_url(to, &params) {
3377            Some(url) => (
3378                StatusCode::SEE_OTHER,
3379                [
3380                    (header::LOCATION, url),
3381                    (header::CACHE_CONTROL, "no-store".to_string()),
3382                ],
3383            )
3384                .into_response(),
3385            None => {
3386                st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3387                    .await
3388            }
3389        },
3390        Err(f) => {
3391            st.error_screen(&ctx, f, &csrf, partial)
3392                .await
3393        }
3394    };
3395    let mut response = response;
3396    if let Ok(lang) = HeaderValue::from_str(ctx.locale()) {
3397        response
3398            .headers_mut()
3399            .insert(header::CONTENT_LANGUAGE, lang);
3400    }
3401    with_cookie(with_cookie(response, set), theme_cookie)
3402}
3403
3404/// Strong ETag over the parts of a rendered body; `kind` keeps full pages and partial screens
3405/// apart. The hash is SipHash with fixed keys, the same in every process of one build.
3406fn etag(kind: &str, parts: &[&str]) -> String {
3407    use std::hash::Hasher;
3408    let mut h = std::hash::DefaultHasher::new();
3409    let mut len = 0;
3410    for p in parts {
3411        h.write(p.as_bytes());
3412        h.write_u8(0xff);
3413        len += p.len();
3414    }
3415    format!("\"{kind}{len:x}-{:016x}\"", h.finish())
3416}
3417
3418/// Whether `If-None-Match` names the ETag (or `*`). Weak tags compare by their value.
3419fn etag_matches(headers: &HeaderMap, tag: &str) -> bool {
3420    headers
3421        .get_all(header::IF_NONE_MATCH)
3422        .iter()
3423        .filter_map(|v| v.to_str().ok())
3424        .flat_map(|v| v.split(','))
3425        .any(|t| {
3426            let t = t.trim();
3427            t == "*" || t.strip_prefix("W/").unwrap_or(t) == tag
3428        })
3429}
3430
3431/// Marks the page of a public screen opened without a session as cacheable: `Cache-Control:
3432/// public, max-age`, the ETag, and `Vary` on what the page is rendered from. A matching
3433/// `If-None-Match` gets `304 Not Modified` without the body.
3434fn public_cache(mut response: Response, headers: &HeaderMap, tag: &str, seconds: u32) -> Response {
3435    let Ok(tag_value) = HeaderValue::from_str(tag) else {
3436        return private_page(response);
3437    };
3438    if etag_matches(headers, tag) {
3439        response = StatusCode::NOT_MODIFIED.into_response();
3440    }
3441    let h = response.headers_mut();
3442    h.insert(header::ETAG, tag_value);
3443    if let Ok(v) = HeaderValue::from_str(&format!("public, max-age={seconds}")) {
3444        h.insert(header::CACHE_CONTROL, v);
3445    }
3446    h.insert(
3447        header::VARY,
3448        HeaderValue::from_static("x-zeyn-partial, Accept-Language, Cookie"),
3449    );
3450    response
3451}
3452
3453/// A cacheable screen opened with a session: never stored.
3454fn private_page(mut response: Response) -> Response {
3455    response.headers_mut().insert(
3456        header::CACHE_CONTROL,
3457        HeaderValue::from_static("private, no-store"),
3458    );
3459    response
3460}
3461
3462/// `GET /_zeyn/csrf`: the CSRF token for pages that were sent without one (public screens opened
3463/// without a session), setting the `zeyn_csrf` cookie when the browser has none. Only same
3464/// origin requests get it.
3465async fn csrf_token(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Response {
3466    let site = headers
3467        .get("sec-fetch-site")
3468        .and_then(|v| v.to_str().ok());
3469    if site.is_some_and(|s| s != "same-origin" && s != "none") {
3470        let ctx = st.ctx(&headers);
3471        return st.error_response(&ctx, Failure::App(Error::Forbidden));
3472    }
3473    let (token, set) = st.csrf(&headers);
3474    let mut r = axum::Json(json!({ "token": mask_token(&token) })).into_response();
3475    r.headers_mut()
3476        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
3477    with_cookie(r, set)
3478}
3479
3480async fn screen_fragment(
3481    State(st): State<Arc<AppState>>,
3482    Path(name): Path<String>,
3483    headers: HeaderMap,
3484    Query(q): Query<ParamsQuery>,
3485) -> Response {
3486    let ctx = st.licensed(st.ctx(&headers)).await;
3487    let Some((&name, _)) = st.screens.get_key_value(name.as_str()) else {
3488        return st.error_response(&ctx, Failure::App(Error::NotFound));
3489    };
3490    let call = Call {
3491        kind: CallKind::Screen,
3492        screen: name,
3493        action: None,
3494        via_agent: false,
3495    };
3496    let started = Instant::now();
3497    let result = async {
3498        let params = q.value()?;
3499        st.before(&ctx, &call)?;
3500        let (_, entry, screen) = st
3501            .open(&ctx, name, params.clone())
3502            .await?;
3503        data::note_screen_read(&ctx, &entry.meta, &params);
3504        let loaded = st
3505            .preload(&ctx, screen.walk(), entry.meta.name)
3506            .await;
3507        let r = user_render(entry.meta.name, &ctx);
3508        Ok::<_, Failure>(screen.render(&r, &entry.meta, &params, &loaded))
3509    }
3510    .await;
3511    st.after(&ctx, &call, started, &result);
3512    match result {
3513        Ok(html) => Html(html).into_response(),
3514        Err(f) => st.error_response(&ctx, f),
3515    }
3516}
3517
3518async fn route_redirect(
3519    State(st): State<Arc<AppState>>,
3520    Path(name): Path<String>,
3521    headers: HeaderMap,
3522    Query(q): Query<ParamsQuery>,
3523) -> Response {
3524    let ctx = st.ctx(&headers);
3525    let Some((fi, entry)) = st.screens.get(name.as_str()) else {
3526        return st.error_response(&ctx, Failure::App(Error::NotFound));
3527    };
3528    if st.features[*fi]
3529        .path_of(&entry.meta)
3530        .is_none()
3531    {
3532        return st.error_response(&ctx, Failure::App(Error::NotFound));
3533    }
3534    let params = match &q.params {
3535        Some(p) => match serde_json::from_str::<Json>(p) {
3536            Ok(v) => v,
3537            Err(_) => return st.error_response(&ctx, Failure::App(Error::NotFound)),
3538        },
3539        None => Json::Null,
3540    };
3541    match st.page_url(entry.meta.name, &params) {
3542        Some(target) => Redirect::to(&target).into_response(),
3543        None => st.error_response(&ctx, Failure::App(Error::NotFound)),
3544    }
3545}
3546
3547/// A screen answering a partial request: its HTML with the page title for the runtime, never cached.
3548fn partial_response(full_title: &str, html: String) -> Response {
3549    let mut r = Html(html).into_response();
3550    let h = r.headers_mut();
3551    h.insert("x-zeyn-partial", HeaderValue::from_static("1"));
3552    if let Ok(v) = HeaderValue::from_str(&encode(full_title)) {
3553        h.insert("x-zeyn-title", v);
3554    }
3555    h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
3556    h.insert(header::VARY, HeaderValue::from_static("x-zeyn-partial"));
3557    r
3558}
3559
3560/// Runs a future and turns a panic while it is polled into `Err`, without boxing it.
3561async fn catch_panic<F: Future>(fut: F) -> std::thread::Result<F::Output> {
3562    let mut fut = std::pin::pin!(fut);
3563    std::future::poll_fn(|cx| {
3564        match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| fut.as_mut().poll(cx))) {
3565            Ok(std::task::Poll::Ready(v)) => std::task::Poll::Ready(Ok(v)),
3566            Ok(std::task::Poll::Pending) => std::task::Poll::Pending,
3567            Err(e) => std::task::Poll::Ready(Err(e)),
3568        }
3569    })
3570    .await
3571}
3572
3573/// Percent encodes a query value.
3574fn encode(s: &str) -> String {
3575    s.bytes()
3576        .map(|b| match b {
3577            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
3578                (b as char).to_string()
3579            }
3580            _ => format!("%{b:02X}"),
3581        })
3582        .collect()
3583}
3584
3585/// Body of a streamed export: chunks from a channel.
3586pub(super) struct Chunks(
3587    pub(super) tokio::sync::mpsc::Receiver<Result<axum::body::Bytes, std::io::Error>>,
3588);
3589
3590impl futures_core::Stream for Chunks {
3591    type Item = Result<axum::body::Bytes, std::io::Error>;
3592
3593    fn poll_next(
3594        mut self: std::pin::Pin<&mut Self>,
3595        cx: &mut std::task::Context<'_>,
3596    ) -> std::task::Poll<Option<Self::Item>> {
3597        self.0.poll_recv(cx)
3598    }
3599}
3600
3601#[derive(Deserialize)]
3602struct MenuCall {
3603    screen: String,
3604    #[serde(default)]
3605    params: Json,
3606    /// Path of the item in the screen's context menu, such as `"2.1"`.
3607    item: String,
3608}
3609
3610#[derive(Deserialize)]
3611struct FormCall {
3612    screen: String,
3613    #[serde(default)]
3614    params: Json,
3615    node: NodePath,
3616    #[serde(default)]
3617    values: Map<String, Json>,
3618    #[serde(default)]
3619    via_agent: bool,
3620    /// Sent by the WebMCP runtime after the user confirmed a `confirm` action in the page.
3621    #[serde(default)]
3622    confirmed: bool,
3623}
3624
3625impl AppState {
3626    /// Runs an action item of a screen's context menu: the screen is built again with its params,
3627    /// the item is taken from that menu, its access is checked and interceptors run.
3628    async fn run_menu(&self, ctx: &Ctx, call: MenuCall) -> Result<Json, Failure> {
3629        if call.item.len() > 32 {
3630            return Err(Failure::App(Error::NotFound));
3631        }
3632        let (_, entry, screen) = self
3633            .open(ctx, &call.screen, call.params)
3634            .await?;
3635        let item = screen
3636            .menu_call(&call.item)
3637            .ok_or(Failure::App(Error::NotFound))?;
3638        if !ctx.identity().can(&item.meta.access) {
3639            return Err(Failure::App(Error::Forbidden));
3640        }
3641        let info = Call {
3642            kind: CallKind::Screen,
3643            screen: entry.meta.name,
3644            action: Some(item.meta),
3645            via_agent: ctx.via_agent(),
3646        };
3647        let result = self
3648            .intercept(ctx, &info, || self.timed((item.run)(ctx.clone())))
3649            .await;
3650        result?;
3651        Ok(json!({ "effects": [] }))
3652    }
3653
3654    async fn run_form(&self, ctx: &Ctx, call: FormCall) -> Result<Json, Failure> {
3655        let (_, entry, screen) = self
3656            .open(ctx, &call.screen, call.params)
3657            .await?;
3658        let form = screen
3659            .node(&call.node)
3660            .and_then(|n| n.form())
3661            .ok_or(Failure::App(Error::NotFound))?;
3662        let action = form.action();
3663        if !ctx.identity().can(&action.access) {
3664            return Err(Failure::App(Error::Forbidden));
3665        }
3666        if ctx.via_agent() {
3667            agent::agent_policy(&entry.meta.ai, &action, call.confirmed)?;
3668        }
3669        let info = Call {
3670            kind: CallKind::Form,
3671            screen: entry.meta.name,
3672            action: Some(action),
3673            via_agent: ctx.via_agent(),
3674        };
3675        let result = self
3676            .intercept(ctx, &info, || {
3677                self.timed(form.submit(ctx.clone(), call.values))
3678            })
3679            .await;
3680        let effects = result?;
3681        let r = user_render(entry.meta.name, ctx);
3682        Ok(json!({ "effects": effects.iter().map(|e| e.to_json(&r)).collect::<Vec<_>>() }))
3683    }
3684}
3685
3686async fn menu_call(
3687    State(st): State<Arc<AppState>>,
3688    headers: HeaderMap,
3689    axum::Json(call): axum::Json<MenuCall>,
3690) -> Response {
3691    let ctx = st.licensed(st.ctx(&headers)).await;
3692    if let Err(f) = st.check_csrf(&headers) {
3693        return st.error_response(&ctx, f);
3694    }
3695    match st.run_menu(&ctx, call).await {
3696        Ok(body) => axum::Json(body).into_response(),
3697        Err(f) => st.error_response(&ctx, f),
3698    }
3699}
3700
3701async fn form_call(
3702    State(st): State<Arc<AppState>>,
3703    headers: HeaderMap,
3704    axum::Json(call): axum::Json<FormCall>,
3705) -> Response {
3706    let mut ctx = st.licensed(st.ctx(&headers)).await;
3707    if let Err(f) = st.check_csrf(&headers) {
3708        return st.error_response(&ctx, f);
3709    }
3710    if call.via_agent {
3711        if !(st.mcp.browser && st.mcp.writes) {
3712            return st.error_response(&ctx, Failure::App(Error::Forbidden));
3713        }
3714        ctx = ctx.as_agent();
3715    }
3716    match st.run_form(&ctx, call).await {
3717        Ok(body) => axum::Json(body).into_response(),
3718        Err(f) => st.error_response(&ctx, f),
3719    }
3720}
3721
3722#[derive(Deserialize)]
3723struct LocaleCall {
3724    locale: String,
3725}
3726
3727async fn set_locale(
3728    State(st): State<Arc<AppState>>,
3729    headers: HeaderMap,
3730    axum::Json(call): axum::Json<LocaleCall>,
3731) -> Response {
3732    let ctx = st.ctx(&headers);
3733    if let Err(f) = st.check_csrf(&headers) {
3734        return st.error_response(&ctx, f);
3735    }
3736    if !st.i18n.locales().contains(&call.locale) {
3737        return st.error_response(&ctx, Failure::App(Error::NotFound));
3738    }
3739    let secure = if st.secure_cookies { "; Secure" } else { "" };
3740    let c = format!(
3741        "zeyn_locale={}; Path={}; Max-Age=31536000; SameSite=Lax{secure}",
3742        call.locale,
3743        st.cookie_path()
3744    );
3745    with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c))
3746}
3747
3748/// RFC 9457 problem details response.
3749fn problem(ctx: &Ctx, failure: Failure) -> Response {
3750    let (status, code, message) = match &failure {
3751        Failure::Timeout => (
3752            StatusCode::GATEWAY_TIMEOUT,
3753            "timeout",
3754            Text::key("zeyn.error.timeout"),
3755        ),
3756        Failure::Status(status, code, message) => (*status, *code, message.clone()),
3757        Failure::Moved(..) => (
3758            StatusCode::NOT_FOUND,
3759            "not_found",
3760            Text::key("zeyn.error.not_found"),
3761        ),
3762        Failure::App(e) => (
3763            match e {
3764                Error::Forbidden | Error::Denied(_) => StatusCode::FORBIDDEN,
3765                Error::NotFound => StatusCode::NOT_FOUND,
3766                Error::Conflict => StatusCode::CONFLICT,
3767                Error::Validation(_) => StatusCode::UNPROCESSABLE_ENTITY,
3768                Error::Internal(detail) => {
3769                    tracing::error!("{detail}");
3770                    StatusCode::INTERNAL_SERVER_ERROR
3771                }
3772            },
3773            e.code(),
3774            e.message(),
3775        ),
3776    };
3777    let mut body = json!({
3778        "type": format!("urn:zeyn:problem:{code}"),
3779        "title": status.canonical_reason().unwrap_or(code),
3780        "status": status.as_u16(),
3781        "detail": ctx.t(&message),
3782    });
3783    if let Failure::App(Error::Validation(errors)) = failure {
3784        body["errors"] = errors
3785            .errors
3786            .iter()
3787            .map(|e| json!({ "field": e.field, "rule": e.rule, "message": ctx.t(&e.message) }))
3788            .collect();
3789    }
3790    let mut response = (status, body.to_string()).into_response();
3791    response.headers_mut().insert(
3792        header::CONTENT_TYPE,
3793        HeaderValue::from_static("application/problem+json"),
3794    );
3795    response
3796}
3797
3798#[derive(Deserialize)]
3799struct ZoneCall {
3800    /// Empty clears the manual choice and returns to the browser's zone.
3801    zone: String,
3802}
3803
3804async fn set_time_zone(
3805    State(st): State<Arc<AppState>>,
3806    headers: HeaderMap,
3807    axum::Json(call): axum::Json<ZoneCall>,
3808) -> Response {
3809    let ctx = st.ctx(&headers);
3810    if let Err(f) = st.check_csrf(&headers) {
3811        return st.error_response(&ctx, f);
3812    }
3813    let secure = if st.secure_cookies { "; Secure" } else { "" };
3814    if call.zone.is_empty() {
3815        let c = format!(
3816            "zeyn_tz_manual=; Path={}; Max-Age=0; SameSite=Lax{secure}",
3817            st.cookie_path()
3818        );
3819        return with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c));
3820    }
3821    let Some(zone) = TimeZone::named(&call.zone) else {
3822        return st.error_response(&ctx, Failure::App(Error::NotFound));
3823    };
3824    let c = format!(
3825        "zeyn_tz_manual={}; Path={}; Max-Age=31536000; SameSite=Lax{secure}",
3826        encode(zone.name()),
3827        st.cookie_path()
3828    );
3829    with_cookie(StatusCode::NO_CONTENT.into_response(), Some(c))
3830}
3831
3832async fn not_found(State(st): State<Arc<AppState>>, headers: HeaderMap) -> Response {
3833    let ctx = st.ctx(&headers);
3834    let (csrf, set) = st.csrf(&headers);
3835    let partial = headers
3836        .get("x-zeyn-partial")
3837        .is_some_and(|v| v == "1");
3838    with_cookie(
3839        st.error_screen(&ctx, Failure::App(Error::NotFound), &csrf, partial)
3840            .await,
3841        set,
3842    )
3843}
3844
3845/// Turns a panic in any handler into a 500 without details: the app's server error page for a
3846/// page request, the JSON error for framework endpoints and the API. The panic message stays in
3847/// the server log.
3848async fn catch_panics(
3849    State(st): State<Arc<AppState>>,
3850    req: axum::extract::Request,
3851    next: axum::middleware::Next,
3852) -> Response {
3853    let headers = req.headers().clone();
3854    let page = req.method() == axum::http::Method::GET && {
3855        let path = req.uri().path();
3856        !path.contains("/_zeyn/") && !path.starts_with(&st.api_prefix)
3857    };
3858    match catch_panic(next.run(req)).await {
3859        Ok(response) => response,
3860        Err(_) => {
3861            tracing::error!("a request handler panicked");
3862            let ctx = st.ctx(&headers);
3863            let failure = Failure::App(Error::Internal("a request handler panicked".into()));
3864            if !page {
3865                return st.error_response(&ctx, failure);
3866            }
3867            let (csrf, set) = st.csrf(&headers);
3868            let partial = headers
3869                .get("x-zeyn-partial")
3870                .is_some_and(|v| v == "1");
3871            with_cookie(
3872                st.error_screen(&ctx, failure, &csrf, partial)
3873                    .await,
3874                set,
3875            )
3876        }
3877    }
3878}