aion-server 0.30.0

Aion workflow server library: HTTP, gRPC, WebSocket, and worker endpoints. Run it with the `aion` binary from the aion-cli crate.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
//! Static ops-console bundle serving.
//!
//! The ops console is part of the product and is ALWAYS embedded in the binary.
//! [`rust_embed`] compiles the real built ops console from `ops-console-embed/`
//! (committed to the repo, regenerated with `cargo xtask build-ops-console`) into
//! the binary, and [`OpsConsoleAssetSource::Embedded`] serves it with SPA
//! fallback. There is no cargo feature and no "not embedded" state: a plain
//! `cargo build` / `cargo install` ships the real UI at `/`.
//!
//! The [`FileSystem`](OpsConsoleAssetSource::FileSystem) source remains for
//! operators who want to serve a bundle from disk (e.g. the Vite dev server's
//! output) instead of the embedded one.
//!
//! SPA fallback is an ALLOWLIST, not a set of exceptions: a path with no file
//! behind it is answered with `index.html` only when the bundle's own
//! `client-routes.json` says the console owns it (see [`client_routes`]).
//! Everything else is a plain 404, so an API client probing a wrong path is
//! never handed HTML.
//!
//! [`OpsConsoleAssetSource::Embedded`]: crate::config::OpsConsoleAssetSource::Embedded
//! [`client_routes`]: super::client_routes

use std::{borrow::Cow, path::PathBuf};

use axum::{
    Router,
    body::Body,
    extract::State,
    http::{HeaderValue, StatusCode, Uri, header},
    response::{IntoResponse, Response},
    routing::get,
};
use tokio::fs;

use super::client_routes::{CLIENT_ROUTES_MANIFEST, ClientRoutes};
use crate::{
    config::{OpsConsoleAssetSource, OpsConsoleConfig},
    error::ServerError,
};

/// A resolved bundle: where its bytes come from, and which URLs it owns.
///
/// The route set travels WITH the bundle rather than beside it in this crate,
/// because the bundle is what knows its own screens — the console declares them
/// once and its build writes them into the same directory as `index.html`.
#[derive(Clone)]
struct OpsConsoleAssets {
    bundle: OpsConsoleBundle,
    client_routes: ClientRoutes,
}

#[derive(Clone)]
enum OpsConsoleBundle {
    FileSystem {
        root: PathBuf,
    },
    /// The compile-time embedded bundle (`ops-console-embed/`).
    Embedded,
}

/// The real ops-console bundle, embedded unconditionally into the binary.
///
/// `ops-console-embed/` is committed to the repo and regenerated by
/// `cargo xtask build-ops-console` (which syncs the Vite output). [`rust_embed`]
/// reads it from disk in debug builds and embeds it in release builds; either way
/// the committed bundle is always present.
#[derive(rust_embed::RustEmbed)]
#[folder = "ops-console-embed"]
struct EmbeddedOpsConsole;

/// Build a router that serves the configured ops-console bundle.
///
/// The returned router contains only static asset routes and fallback behaviour;
/// callers must merge it after public API/WebSocket routes so assets cannot
/// shadow public contract endpoints.
///
/// # Errors
///
/// Returns [`ServerError::Config`] when the configured bundle lacks
/// `index.html`, or lacks a readable `client-routes.json`. Both are refused at
/// startup rather than defaulted: a bundle whose route set cannot be read is a
/// bundle whose screens 404 on a hard refresh, and the operator must meet that
/// as a startup error, not as a broken console.
pub fn ops_console_router(config: &OpsConsoleConfig) -> Result<Router, ServerError> {
    let assets = resolve_assets(&config.source)?;

    Ok(Router::new()
        .route("/", get(root_asset))
        .fallback(get(path_asset))
        .with_state(assets))
}

fn resolve_assets(source: &OpsConsoleAssetSource) -> Result<OpsConsoleAssets, ServerError> {
    let (bundle, description) = match source {
        OpsConsoleAssetSource::FileSystem { asset_path } => (
            OpsConsoleBundle::FileSystem {
                root: asset_path.clone(),
            },
            format!("ops-console asset bundle `{}`", asset_path.display()),
        ),
        OpsConsoleAssetSource::Embedded => (
            OpsConsoleBundle::Embedded,
            "embedded ops-console bundle".to_owned(),
        ),
    };

    if read_bundle_file(&bundle, "index.html").is_none() {
        return Err(ServerError::Config {
            message: format!("{description} must contain index.html"),
        });
    }

    let Some(manifest) = read_bundle_file(&bundle, CLIENT_ROUTES_MANIFEST) else {
        return Err(ServerError::Config {
            message: format!(
                "{description} must contain `{CLIENT_ROUTES_MANIFEST}` — the console's own route \
                 set, written by its build. Regenerate the bundle with \
                 `cargo xtask build-ops-console`."
            ),
        });
    };

    let client_routes =
        ClientRoutes::parse(manifest.as_ref()).map_err(|message| ServerError::Config {
            message: format!("{description}: {message}"),
        })?;

    Ok(OpsConsoleAssets {
        bundle,
        client_routes,
    })
}

/// Read one file out of a bundle synchronously, for the startup checks that run
/// before the router exists.
fn read_bundle_file(bundle: &OpsConsoleBundle, path: &str) -> Option<Cow<'static, [u8]>> {
    match bundle {
        OpsConsoleBundle::FileSystem { root } => {
            Some(Cow::Owned(std::fs::read(root.join(path)).ok()?))
        }
        OpsConsoleBundle::Embedded => Some(EmbeddedOpsConsole::get(path)?.data),
    }
}

async fn root_asset(State(assets): State<OpsConsoleAssets>) -> Response {
    serve_asset(&assets.bundle, "index.html").await
}

/// Answer an unmatched path: the file if the bundle has one, `index.html` if
/// the console owns the route, and a plain 404 otherwise.
///
/// The file lookup comes first so a real asset is never shadowed by a route
/// pattern, and the 404 is last so no path outside the console's declared set
/// can be answered with HTML.
async fn path_asset(State(assets): State<OpsConsoleAssets>, uri: Uri) -> Response {
    let path = uri.path();
    if let Some((asset_path, asset)) =
        bundle_asset(&assets.bundle, path.trim_start_matches('/')).await
    {
        return asset_response(asset_path.as_ref(), asset);
    }

    if assets.client_routes.matches(path) {
        return serve_asset(&assets.bundle, "index.html").await;
    }

    StatusCode::NOT_FOUND.into_response()
}

/// The bundle's file at `path`, if the path is safe and the file exists.
async fn bundle_asset(
    bundle: &OpsConsoleBundle,
    path: &str,
) -> Option<(String, Cow<'static, [u8]>)> {
    let asset_path = sanitize_path(path)?;
    let asset = read_asset(bundle, &asset_path).await?;
    Some((asset_path, asset))
}

async fn serve_asset(bundle: &OpsConsoleBundle, path: &str) -> Response {
    read_asset(bundle, path)
        .await
        .map_or_else(index_missing_response, |asset| asset_response(path, asset))
}

async fn read_asset(bundle: &OpsConsoleBundle, path: &str) -> Option<Cow<'static, [u8]>> {
    match bundle {
        OpsConsoleBundle::FileSystem { root } => {
            let bytes = fs::read(root.join(path)).await.ok()?;
            Some(Cow::Owned(bytes))
        }
        OpsConsoleBundle::Embedded => Some(EmbeddedOpsConsole::get(path)?.data),
    }
}

fn asset_response(path: &str, asset: Cow<'static, [u8]>) -> Response {
    let mut response = Body::from(asset.into_owned()).into_response();
    response
        .headers_mut()
        .insert(header::CONTENT_TYPE, content_type(path));
    response
}

fn sanitize_path(path: &str) -> Option<String> {
    if path.is_empty()
        || path
            .split('/')
            .any(|component| component.is_empty() || component == "." || component == "..")
    {
        return None;
    }
    Some(path.to_owned())
}

fn content_type(path: &str) -> HeaderValue {
    let extension = std::path::Path::new(path)
        .extension()
        .and_then(std::ffi::OsStr::to_str);
    if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("html")) {
        HeaderValue::from_static("text/html; charset=utf-8")
    } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("js")) {
        HeaderValue::from_static("text/javascript; charset=utf-8")
    } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("css")) {
        HeaderValue::from_static("text/css; charset=utf-8")
    } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("json")) {
        HeaderValue::from_static("application/json")
    } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("svg")) {
        HeaderValue::from_static("image/svg+xml")
    } else if extension.is_some_and(|ext| ext.eq_ignore_ascii_case("wasm")) {
        // `WebAssembly.instantiateStreaming` refuses any other content type,
        // and the authoring page's tree-sitter worker loads its grammar wasm
        // that way — octet-stream renders the editor unhighlighted.
        HeaderValue::from_static("application/wasm")
    } else {
        HeaderValue::from_static("application/octet-stream")
    }
}

fn index_missing_response() -> Response {
    (
        StatusCode::INTERNAL_SERVER_ERROR,
        "ops-console index missing",
    )
        .into_response()
}

#[cfg(test)]
mod tests {
    use axum::body;
    use axum::http::{Request, StatusCode};
    use tower::ServiceExt;

    use super::*;

    type TestResult = Result<(), Box<dyn std::error::Error>>;

    async fn body_text(response: Response) -> Result<String, Box<dyn std::error::Error>> {
        let bytes = body::to_bytes(response.into_body(), usize::MAX).await?;
        Ok(String::from_utf8(bytes.to_vec())?)
    }

    /// `/` serves the embedded REAL ops-console index: 200, an HTML document, the
    /// `<title>Aion Ops Console</title>` from the built bundle, and a reference to
    /// a hashed `/assets/index-*.js` bundle. This is the no-flag default and
    /// proves the committed bundle is the real UI, not a placeholder.
    #[tokio::test]
    async fn embedded_source_serves_real_ops_console_index() -> TestResult {
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;
        let response = router
            .oneshot(Request::builder().uri("/").body(body::Body::empty())?)
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
        let text = body_text(response).await?;
        assert!(text.contains("<!doctype html>") || text.contains("<!DOCTYPE html>"));
        assert!(text.contains("<title>Aion Ops Console</title>"));
        assert!(
            text.contains("/assets/index-"),
            "embedded index must reference the built asset bundle, got: {text}"
        );
        assert!(
            !text.contains("AION_EMBED_PLACEHOLDER"),
            "embedded index must be the real bundle, not the placeholder stub"
        );
        Ok(())
    }

    /// 🔴 EVERY SCREEN THE CONSOLE OWNS SURVIVES A HARD REFRESH ON THIS SERVER.
    ///
    /// `/workflows` is the console's primary screen and the target of `/`'s
    /// redirect, so the address bar reads `/workflows` the moment the console
    /// opens. It once answered a bare 404 here — the SPA fallback kept a list
    /// of paths it REFUSED to serve the index for, and `workflows` was on it —
    /// while the Vite dev proxy served `index.html` for any document
    /// navigation. ⌘R on the console's own main screen was a 404 on every
    /// shipped binary and perfect on every builder's machine.
    ///
    /// The route set now travels with the bundle: the console declares it once
    /// and its build writes `client-routes.json` beside `index.html`. This arm
    /// walks the declaration itself, so a screen added to the console is
    /// covered here without this file changing.
    #[tokio::test]
    async fn every_declared_client_route_serves_the_spa() -> TestResult {
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;
        let Some(manifest) = EmbeddedOpsConsole::get(CLIENT_ROUTES_MANIFEST) else {
            return Err("the embedded bundle carries its client-route manifest".into());
        };
        let declared: serde_json::Value = serde_json::from_slice(manifest.data.as_ref())?;
        let Some(declared_routes) = declared.get("routes").and_then(serde_json::Value::as_array)
        else {
            return Err("the manifest declares a `routes` array".into());
        };
        assert!(
            declared_routes.len() >= 2,
            "the console declares more than its root redirect"
        );

        let mut checked = 0_usize;
        for route in declared_routes {
            let Some(pattern) = route.as_str() else {
                return Err("every declared route is a string".into());
            };
            // Each parameter shape, filled with a real-shaped value.
            let path = pattern
                .replace("{uuid}", "141852b2-20b9-4e94-8361-7a1ea3d5f910")
                .replace("{name}", "grade")
                .replace(
                    "{hash}",
                    "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
                );
            let response = router
                .clone()
                .oneshot(Request::builder().uri(&path).body(body::Body::empty())?)
                .await?;
            assert_eq!(
                response.status(),
                StatusCode::OK,
                "{path} is a declared console route and must load the SPA"
            );
            let content_type = response
                .headers()
                .get(header::CONTENT_TYPE)
                .and_then(|value| value.to_str().ok())
                .unwrap_or_default()
                .to_owned();
            assert!(content_type.starts_with("text/html"), "got {content_type}");
            let text = body_text(response).await?;
            assert!(text.contains("<title>Aion Ops Console</title>"), "{path}");
            checked += 1;
        }
        assert_eq!(
            checked,
            declared_routes.len(),
            "every declared route was requested"
        );
        assert!(
            declared_routes
                .iter()
                .any(|route| route.as_str() == Some("/workflows")),
            "the console's primary screen is in the declaration"
        );
        Ok(())
    }

    /// A path the console does NOT own stays a plain 404, so an API client
    /// probing a wrong path never receives HTML its JSON parser will choke on.
    /// That was the whole purpose of the old reservation list, and it survives
    /// the move to an allowlist.
    #[tokio::test]
    async fn a_path_the_console_does_not_own_stays_a_plain_404() -> TestResult {
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;

        for unowned in [
            "/workflows/count",
            "/workflows/not-a-uuid",
            "/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910/attempts",
            "/events",
            "/whoami",
            "/workflows-view/deep/link",
        ] {
            let response = router
                .clone()
                .oneshot(Request::builder().uri(unowned).body(body::Body::empty())?)
                .await?;
            assert_eq!(
                response.status(),
                StatusCode::NOT_FOUND,
                "{unowned} is not a console route and must not be answered with the SPA"
            );
        }
        Ok(())
    }

    /// A hashed asset path resolves out of the embedded bundle with the correct
    /// JS content type (not the SPA HTML fallback).
    #[tokio::test]
    async fn embedded_source_serves_hashed_js_asset() -> TestResult {
        let Some(index) = EmbeddedOpsConsole::get("index.html") else {
            return Err("embedded index present".into());
        };
        let html = String::from_utf8(index.data.into_owned())?;
        // Pull a hashed JS asset path out of the built index.
        let marker = "/assets/index-";
        let Some(start) = html.find(marker) else {
            return Err("index references an asset bundle".into());
        };
        let tail = &html[start + 1..]; // skip leading '/'
        let Some(js_at) = tail.find(".js") else {
            return Err(".js asset present".into());
        };
        let asset_path = &tail[..js_at + ".js".len()];

        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;
        let response = router
            .oneshot(
                Request::builder()
                    .uri(format!("/{asset_path}"))
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
        let content_type = response
            .headers()
            .get(header::CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default()
            .to_owned();
        assert!(
            content_type.starts_with("text/javascript"),
            "got {content_type}"
        );
        Ok(())
    }

    /// Embedded `.wasm` assets serve as `application/wasm` — the type
    /// `WebAssembly.instantiateStreaming` requires. Served as octet-stream, the
    /// authoring page's tree-sitter worker dies and the editor renders
    /// unhighlighted (found live, 2026-07-12).
    #[tokio::test]
    async fn embedded_source_serves_wasm_with_the_streaming_compile_content_type() -> TestResult {
        let Some(wasm_path) = EmbeddedOpsConsole::iter().find(|path| path.ends_with(".wasm"))
        else {
            return Err("embedded bundle contains a wasm asset".into());
        };
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;
        let response = router
            .oneshot(
                Request::builder()
                    .uri(format!("/{wasm_path}"))
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(response.status(), StatusCode::OK);
        let content_type = response
            .headers()
            .get(header::CONTENT_TYPE)
            .and_then(|value| value.to_str().ok())
            .unwrap_or_default()
            .to_owned();
        assert_eq!(content_type, "application/wasm", "got {content_type}");
        Ok(())
    }

    /// PACKAGING-INTEGRITY GUARD (#153).
    ///
    /// The ops console is served ENTIRELY from the compile-time embedded bundle,
    /// so a plain `cargo install` must ship an `index.html` plus every hashed
    /// asset that `index.html` references, each with real bytes. If the committed
    /// `ops-console-embed/` bundle is ever dropped from the packaged crate (e.g. a
    /// stray `exclude` in Cargo.toml, or the assets slipping out of git tracking so
    /// cargo omits them from the `.crate`) then `rust_embed` compiles an incomplete
    /// bundle and an installed binary serves an API-only / broken console. That
    /// failure is invisible to the other embed tests, which only touch
    /// `index.html`, and to `cargo xtask verify-ops-console`, which needs `bun` and
    /// so never runs on an end user's machine.
    ///
    /// This test walks the embedded index's own asset references and asserts each
    /// referenced `/assets/...` resolves to non-empty embedded bytes — i.e. the
    /// exact bundle the installed binary would serve is complete and self-consistent.
    #[test]
    fn embedded_bundle_ships_every_asset_index_references() -> TestResult {
        let index = EmbeddedOpsConsole::get("index.html")
            .ok_or("embedded ops-console bundle is missing index.html")?;
        let html = String::from_utf8(index.data.into_owned())?;

        // Extract every `/assets/<file>` reference from src=".."/href=".." attrs.
        let mut referenced = Vec::new();
        let mut rest = html.as_str();
        while let Some(pos) = rest.find("/assets/") {
            let tail = &rest[pos + 1..]; // drop the leading '/' for the embed key
            let end = tail
                .find(['"', '\''])
                .ok_or("unterminated /assets reference in embedded index.html")?;
            referenced.push(tail[..end].to_owned());
            rest = &tail[end..];
        }

        assert!(
            !referenced.is_empty(),
            "embedded index.html references no /assets bundle — the embedded ops \
             console is empty or a placeholder, so `cargo install` would ship an \
             API-only binary"
        );

        for asset in &referenced {
            let embedded = EmbeddedOpsConsole::get(asset).ok_or_else(|| {
                format!(
                    "embedded index.html references `{asset}` but it is NOT in the \
                     embedded bundle — the packaged crate dropped ops-console \
                     assets, so `cargo install` serves a broken console. Ensure \
                     `crates/aion-server/ops-console-embed/**` is git-tracked and \
                     not excluded from the package."
                )
            })?;
            assert!(
                !embedded.data.is_empty(),
                "embedded ops-console asset `{asset}` is present but empty"
            );
        }

        Ok(())
    }
}