Skip to main content

aion_server/ops_console/
client_routes.rs

1//! The URL patterns the ops console owns, read from the bundle being served.
2//!
3//! A single-page app has two servers in its life — the Vite dev server while it
4//! is being built, and this one once it ships — and they have to answer a
5//! document navigation the same way. When a browser asks for `/workflows` (a
6//! hard refresh, a bookmark, "open link in new tab") there is no file at that
7//! path and the only correct answer is `index.html`, so the app mounts and
8//! routes itself.
9//!
10//! They used to disagree. The dev proxy served `index.html` for every document
11//! navigation while this server kept an inverted list of paths it REFUSED to
12//! fall back for — a list that named `workflows`, which is where the console's
13//! main screen lives. The console's primary screen was a bare 404 on the
14//! shipped binary and perfect on every builder's machine.
15//!
16//! So neither side keeps a list. The console declares its routes once
17//! (`apps/aion-ops-console/src/app/clientRoutes.ts`), its build writes them into
18//! the bundle as `client-routes.json`, and this module reads that file out of
19//! the same bundle it is serving. A route added to the console reaches this
20//! server through `cargo xtask build-ops-console`, with nothing to edit here.
21//!
22//! Everything outside the declared set stays a plain 404 — the property the old
23//! reservation list existed to protect. An API client probing a wrong path must
24//! never be handed a page of HTML for its JSON parser to choke on.
25
26use serde::Deserialize;
27
28/// The manifest's name in the bundle, beside `index.html`.
29///
30/// Must equal `CLIENT_ROUTES_MANIFEST_FILENAME` in the console's
31/// `src/app/clientRoutes.ts`.
32pub const CLIENT_ROUTES_MANIFEST: &str = "client-routes.json";
33
34/// The parameter shape a pattern segment may carry.
35///
36/// One shape, because the console has one parameterised route:
37/// `/workflows/{uuid}`, whose parameter is a `WorkflowId` — a newtype over
38/// `Uuid`. Matching the SHAPE rather than "any segment" is what keeps
39/// `/workflows/count` and every other fixed API verb under `/workflows/` out of
40/// the console's route set.
41const UUID_PARAMETER: &str = "{uuid}";
42
43/// An AWL identifier segment — the definition route's workflow type. The
44/// shape (a letter or underscore, then letters, digits, underscores) is what
45/// keeps fixed verbs that might ever grow under `/definition/` from being
46/// swallowed by the SPA fallback the way bare "any segment" matching would.
47const NAME_PARAMETER: &str = "{name}";
48
49/// A content-hash segment — 64 hex characters, an archive's own identity.
50const HASH_PARAMETER: &str = "{hash}";
51
52/// One segment of a declared client-route pattern.
53#[derive(Clone, Debug, PartialEq, Eq)]
54enum RouteSegment {
55    /// A segment that must match exactly.
56    Literal(String),
57    /// A segment that must parse as a UUID.
58    Uuid,
59    /// A segment that must be an AWL identifier.
60    Name,
61    /// A segment that must be a 64-hex content hash.
62    Hash,
63}
64
65/// The manifest as the console writes it.
66#[derive(Deserialize)]
67struct ClientRoutesManifest {
68    routes: Vec<String>,
69}
70
71/// The console's client route set, parsed from a bundle's manifest.
72#[derive(Clone, Debug)]
73pub struct ClientRoutes {
74    patterns: Vec<Vec<RouteSegment>>,
75}
76
77impl ClientRoutes {
78    /// Parse a bundle's `client-routes.json`.
79    ///
80    /// # Errors
81    ///
82    /// Returns a human-readable message when the file is not JSON of the
83    /// expected shape, when it declares no routes, or when a pattern carries a
84    /// parameter shape this server does not implement. All three are refused
85    /// rather than defaulted: a bundle whose route set cannot be read is a
86    /// bundle whose screens would 404, and guessing at it would hide exactly
87    /// the defect this file exists to prevent.
88    pub fn parse(bytes: &[u8]) -> Result<Self, String> {
89        let manifest: ClientRoutesManifest = serde_json::from_slice(bytes)
90            .map_err(|error| format!("`{CLIENT_ROUTES_MANIFEST}` is not readable: {error}"))?;
91
92        if manifest.routes.is_empty() {
93            return Err(format!("`{CLIENT_ROUTES_MANIFEST}` declares no routes"));
94        }
95
96        let mut patterns = Vec::with_capacity(manifest.routes.len());
97        for route in &manifest.routes {
98            patterns.push(parse_pattern(route)?);
99        }
100        Ok(Self { patterns })
101    }
102
103    /// Whether a request path is one of the console's own routes.
104    ///
105    /// Matching is segment-wise, so a trailing slash is the same route and a
106    /// path with more or fewer segments is not. This is the same algorithm the
107    /// console's `matchesClientRoute` implements over the same manifest, and
108    /// both are driven with the same table of cases so they cannot drift on an
109    /// answer.
110    #[must_use]
111    pub fn matches(&self, path: &str) -> bool {
112        let requested: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
113        self.patterns
114            .iter()
115            .any(|pattern| pattern_matches(pattern, &requested))
116    }
117}
118
119fn parse_pattern(route: &str) -> Result<Vec<RouteSegment>, String> {
120    route
121        .split('/')
122        .filter(|part| !part.is_empty())
123        .map(|part| {
124            if part == UUID_PARAMETER {
125                Ok(RouteSegment::Uuid)
126            } else if part == NAME_PARAMETER {
127                Ok(RouteSegment::Name)
128            } else if part == HASH_PARAMETER {
129                Ok(RouteSegment::Hash)
130            } else if part.starts_with('{') {
131                Err(format!(
132                    "`{CLIENT_ROUTES_MANIFEST}` route `{route}` carries parameter shape `{part}`, \
133                     which this server does not implement"
134                ))
135            } else {
136                Ok(RouteSegment::Literal(part.to_owned()))
137            }
138        })
139        .collect()
140}
141
142fn pattern_matches(pattern: &[RouteSegment], requested: &[&str]) -> bool {
143    if pattern.len() != requested.len() {
144        return false;
145    }
146    pattern
147        .iter()
148        .zip(requested)
149        .all(|(segment, value)| match segment {
150            RouteSegment::Literal(literal) => literal == value,
151            RouteSegment::Uuid => uuid::Uuid::parse_str(value).is_ok(),
152            RouteSegment::Name => is_awl_identifier(value),
153            RouteSegment::Hash => is_content_hash(value),
154        })
155}
156
157/// Whether a segment is shaped like an AWL identifier.
158fn is_awl_identifier(value: &str) -> bool {
159    let mut chars = value.chars();
160    chars
161        .next()
162        .is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
163        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
164}
165
166/// Whether a segment is shaped like a content hash: exactly 64 hex characters.
167///
168/// Case-insensitive for the same reason the UUID arm is: the console renders
169/// lowercase, but a hand-edited URL differing only in case names the same
170/// archive, and serving the SPA there beats a bare server 404.
171fn is_content_hash(value: &str) -> bool {
172    value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178
179    /// The declaration as the console's build writes it, so these arms are
180    /// driven by the real shape rather than a hand-rolled one.
181    const MANIFEST: &str = r#"{
182      "routes": [
183        "/",
184        "/workflows",
185        "/workflows/{uuid}",
186        "/studio",
187        "/launch",
188        "/triage",
189        "/settings",
190        "/kit",
191        "/definition/{name}/{hash}"
192      ]
193    }"#;
194
195    type TestResult = Result<(), Box<dyn std::error::Error>>;
196
197    /// 🔴 THE SAME TABLE THE CONSOLE'S `clientRoutes.test.ts` DRIVES.
198    ///
199    /// Two implementations of one algorithm over one declaration only stay
200    /// honest if both are asked the same questions, so the questions are
201    /// written here and there in the same order with the same answers.
202    #[test]
203    fn client_routes_match_the_console() -> TestResult {
204        let routes = ClientRoutes::parse(MANIFEST.as_bytes())?;
205        let cases: [(&str, bool); 20] = [
206            ("/", true),
207            ("/workflows", true),
208            ("/workflows/", true),
209            ("/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910", true),
210            ("/studio", true),
211            ("/launch", true),
212            ("/triage", true),
213            ("/settings", true),
214            ("/kit", true),
215            ("/workflows/count", false),
216            ("/workflows/not-a-uuid", false),
217            ("/workflows/list/extra", false),
218            ("/whoami", false),
219            ("/events", false),
220            ("/no-such-console-screen", false),
221            ("/studio/deep/link", false),
222            (
223                "/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
224                true,
225            ),
226            ("/definition/grade/abc123", false),
227            (
228                "/definition/9grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
229                false,
230            ),
231            (
232                "/definition/grade/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/extra",
233                false,
234            ),
235        ];
236        for (path, owned) in cases {
237            assert_eq!(routes.matches(path), owned, "{path}");
238        }
239        Ok(())
240    }
241
242    /// A manifest this server cannot read is refused, never defaulted: the
243    /// console's screens would 404 and the operator would meet the defect
244    /// instead of the startup error.
245    #[test]
246    fn an_unreadable_manifest_is_refused() {
247        let malformed = ClientRoutes::parse(b"not json");
248        assert!(malformed.is_err(), "malformed JSON must be refused");
249
250        let empty = ClientRoutes::parse(br#"{"routes": []}"#);
251        assert!(empty.is_err(), "an empty route set must be refused");
252
253        let unknown = ClientRoutes::parse(br#"{"routes": ["/runs/{slug}"]}"#);
254        assert!(
255            unknown.is_err(),
256            "an unimplemented parameter shape must be refused"
257        );
258        let message = unknown.err().unwrap_or_default();
259        assert!(
260            message.contains("{slug}"),
261            "the refusal must name the shape it cannot implement, got: {message}"
262        );
263    }
264}