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}