aion-server 0.13.8

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
//! 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.
//!
//! [`OpsConsoleAssetSource::Embedded`]: crate::config::OpsConsoleAssetSource::Embedded

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 crate::{
    config::{OpsConsoleAssetSource, OpsConsoleConfig},
    error::ServerError,
};

#[derive(Clone)]
enum OpsConsoleAssets {
    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 filesystem bundle lacks
/// `index.html`, or when the embedded bundle was built without an index (which
/// should never happen, since the bundle is committed).
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> {
    match source {
        OpsConsoleAssetSource::FileSystem { asset_path } => {
            let index_path = asset_path.join("index.html");
            if !index_path.is_file() {
                return Err(ServerError::Config {
                    message: format!(
                        "ops-console asset bundle `{}` must contain index.html",
                        asset_path.display()
                    ),
                });
            }
            Ok(OpsConsoleAssets::FileSystem {
                root: asset_path.clone(),
            })
        }
        OpsConsoleAssetSource::Embedded => {
            if EmbeddedOpsConsole::get("index.html").is_none() {
                return Err(ServerError::Config {
                    message: "embedded ops-console bundle must contain index.html".to_owned(),
                });
            }
            Ok(OpsConsoleAssets::Embedded)
        }
    }
}

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

async fn path_asset(State(assets): State<OpsConsoleAssets>, uri: Uri) -> Response {
    let path = uri.path().trim_start_matches('/');
    if is_reserved_public_path(path) {
        return StatusCode::NOT_FOUND.into_response();
    }

    match sanitize_path(path) {
        Some(asset_path) => match read_asset(&assets, &asset_path).await {
            Some(asset) => asset_response(asset_path.as_ref(), asset),
            None => serve_asset(&assets, "index.html").await,
        },
        None => StatusCode::NOT_FOUND.into_response(),
    }
}

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

async fn read_asset(assets: &OpsConsoleAssets, path: &str) -> Option<Cow<'static, [u8]>> {
    match assets {
        OpsConsoleAssets::FileSystem { root } => {
            let bytes = fs::read(root.join(path)).await.ok()?;
            Some(Cow::Owned(bytes))
        }
        OpsConsoleAssets::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())
}

/// Whether an unmatched GET path belongs to the public API namespace and must
/// stay a plain 404 rather than serve the SPA (an API client probing a wrong
/// path should never receive HTML).
///
/// One carve-out: `workflows/{uuid}` is the console's OWN deep link
/// (`workflowDetailPath` in the SPA's route contract), so a browser refresh on
/// a workflow detail page serves `index.html`. Every workflow API route under
/// `/workflows/` is a fixed verb segment (`count`, `start`, `attempts`, …),
/// never a bare UUID, so the shapes cannot collide.
fn is_reserved_public_path(path: &str) -> bool {
    if let Some(rest) = path.strip_prefix("workflows/") {
        return uuid::Uuid::parse_str(rest).is_err();
    }
    path == "workflows" || path == "events" || path.starts_with("events/")
}

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(())
    }

    /// A deep SPA link falls back to the embedded `index.html` (200, HTML), so
    /// client-side routes load the real app rather than 404ing.
    #[tokio::test]
    async fn embedded_source_spa_fallback_serves_index_on_deep_links() -> TestResult {
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;
        let response = router
            .oneshot(
                Request::builder()
                    .uri("/workflows-view/deep/link")
                    .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/html"), "got {content_type}");
        let text = body_text(response).await?;
        assert!(text.contains("<title>Aion Ops Console</title>"));
        Ok(())
    }

    /// A browser refresh on the console's own workflow-detail deep link
    /// (`/workflows/{uuid}`) serves the SPA index — while API-shaped paths
    /// under `/workflows/` (fixed verb segments, non-UUID tails) stay a plain
    /// 404 so an API client probing a wrong path never receives HTML.
    #[tokio::test]
    async fn workflow_detail_deep_link_serves_index_but_api_paths_stay_reserved() -> TestResult {
        let config = OpsConsoleConfig {
            source: OpsConsoleAssetSource::Embedded,
        };
        let router = ops_console_router(&config)?;

        let detail = router
            .clone()
            .oneshot(
                Request::builder()
                    .uri("/workflows/141852b2-20b9-4e94-8361-7a1ea3d5f910")
                    .body(body::Body::empty())?,
            )
            .await?;
        assert_eq!(
            detail.status(),
            StatusCode::OK,
            "a workflow-detail deep link must load the SPA"
        );
        let text = body_text(detail).await?;
        assert!(text.contains("<title>Aion Ops Console</title>"));

        for reserved in ["/workflows", "/workflows/count", "/workflows/not-a-uuid"] {
            let response = router
                .clone()
                .oneshot(Request::builder().uri(reserved).body(body::Body::empty())?)
                .await?;
            assert_eq!(
                response.status(),
                StatusCode::NOT_FOUND,
                "{reserved} must stay reserved for the API"
            );
        }
        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(())
    }
}