frame-host 0.1.0

Frame host server — boots the frame-core host authority with an embedded liminal component and serves the built frame page
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
538
539
540
541
542
543
544
545
546
547
//! The shell server serves the bundle exactly: correct MIME types
//! (`application/wasm` included), typed loud 404s that are never an
//! index.html fallback, refused traversal, and the D3 config surface.

use std::collections::HashMap;
use std::net::SocketAddr;
use std::path::PathBuf;

use frame_host::error::HostError;
use frame_host::server::{ShellConfig, ShellServer};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpStream;

const LIMINAL_ENDPOINT: &str = "ws://liminal.test:9000/attach";
const AUTH_TOKEN: &str = "test-bearer-token";

/// The default test roster. With `channel: None` the shell applies the SDK's
/// default channel, so a coherent roster carries that SDK default name.
fn default_channels() -> Vec<String> {
    vec!["frame.demo.graph-view".to_owned()]
}

fn fixture_site() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
        .join("site")
}

fn any_local_addr() -> Result<SocketAddr, std::net::AddrParseError> {
    "127.0.0.1:0".parse()
}

/// A local address nothing listens on (bound then released) — the "liminal
/// health listener is down" stand-in for tests that never touch the proxy and
/// for the typed-502 path.
fn released_addr() -> Result<SocketAddr, Box<dyn std::error::Error>> {
    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
    let addr = listener.local_addr()?;
    drop(listener);
    Ok(addr)
}

struct HttpReply {
    status: u16,
    headers: HashMap<String, String>,
    body: Vec<u8>,
}

impl HttpReply {
    fn header(&self, name: &str) -> Option<&str> {
        self.headers
            .get(&name.to_ascii_lowercase())
            .map(String::as_str)
    }
}

async fn request(
    addr: SocketAddr,
    method: &str,
    path: &str,
) -> Result<HttpReply, Box<dyn std::error::Error>> {
    let mut stream = TcpStream::connect(addr).await?;
    let wire = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
    stream.write_all(wire.as_bytes()).await?;
    let mut raw = Vec::new();
    stream.read_to_end(&mut raw).await?;
    let split = raw
        .windows(4)
        .position(|window| window == b"\r\n\r\n")
        .ok_or("response had no header terminator")?;
    let head = std::str::from_utf8(&raw[..split])?;
    let body = raw[split + 4..].to_vec();
    let mut lines = head.split("\r\n");
    let status_line = lines.next().ok_or("response had no status line")?;
    let status = status_line
        .split(' ')
        .nth(1)
        .ok_or("status line had no code")?
        .parse::<u16>()?;
    let mut headers = HashMap::new();
    for line in lines {
        let (name, value) = line.split_once(':').ok_or("malformed header line")?;
        headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned());
    }
    Ok(HttpReply {
        status,
        headers,
        body,
    })
}

async fn start_server() -> Result<
    (
        SocketAddr,
        tokio::sync::oneshot::Sender<()>,
        tokio::task::JoinHandle<Result<(), HostError>>,
    ),
    Box<dyn std::error::Error>,
> {
    start_server_with_health(released_addr()?).await
}

async fn start_server_with_health(
    liminal_health: SocketAddr,
) -> Result<
    (
        SocketAddr,
        tokio::sync::oneshot::Sender<()>,
        tokio::task::JoinHandle<Result<(), HostError>>,
    ),
    Box<dyn std::error::Error>,
> {
    let server = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: None,
        channels: default_channels(),
        liminal_health,
    })
    .await?;
    let addr = server.local_addr();
    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
    let task = tokio::spawn(server.serve(async move {
        // Either an explicit stop or a dropped sender ends the test server.
        let _outcome = stop_rx.await;
    }));
    Ok((addr, stop_tx, task))
}

#[tokio::test]
async fn serves_shell_with_exact_mime_types() -> Result<(), Box<dyn std::error::Error>> {
    let (addr, stop, task) = start_server().await?;

    let index = request(addr, "GET", "/").await?;
    assert_eq!(index.status, 200);
    assert_eq!(
        index.header("content-type"),
        Some("text/html; charset=utf-8")
    );
    assert!(String::from_utf8(index.body.clone())?.contains("frame demo fixture shell"));

    let script = request(addr, "GET", "/assets/app.js").await?;
    assert_eq!(script.status, 200);
    assert_eq!(
        script.header("content-type"),
        Some("text/javascript; charset=utf-8")
    );

    let wasm = request(addr, "GET", "/assets/app.wasm").await?;
    assert_eq!(wasm.status, 200);
    assert_eq!(wasm.header("content-type"), Some("application/wasm"));
    assert_eq!(
        wasm.body,
        std::fs::read(fixture_site().join("assets/app.wasm"))?
    );

    let css = request(addr, "GET", "/assets/style.css").await?;
    assert_eq!(css.status, 200);
    assert_eq!(css.header("content-type"), Some("text/css; charset=utf-8"));

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn missing_asset_is_a_typed_404_never_index_fallback()
-> Result<(), Box<dyn std::error::Error>> {
    let (addr, stop, task) = start_server().await?;

    let missing = request(addr, "GET", "/assets/not-built.js").await?;
    assert_eq!(missing.status, 404);
    assert_eq!(
        missing.header("content-type"),
        Some("text/html; charset=utf-8")
    );
    let body = String::from_utf8(missing.body)?;
    assert!(
        body.contains("ASSET NOT FOUND"),
        "404 page must be typed and loud"
    );
    assert!(
        !body.contains("frame demo fixture shell"),
        "404 must NEVER serve index.html content"
    );

    // A directory is not a servable file either.
    let directory = request(addr, "GET", "/assets").await?;
    assert_eq!(directory.status, 404);

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn config_endpoint_matches_the_shell_contract() -> Result<(), Box<dyn std::error::Error>> {
    let (addr, stop, task) = start_server().await?;

    let config = request(addr, "GET", "/frame/config.json").await?;
    assert_eq!(config.status, 200);
    assert_eq!(
        config
            .header("content-type")
            .map(|value| value.split(';').next()),
        Some(Some("application/json"))
    );
    // The exact shape the shell's config.ts validates: non-empty
    // liminalEndpoint, string authToken, and channel ABSENT when the
    // operator did not provide one (the shell then applies its SDK default).
    let parsed: serde_json::Value = serde_json::from_slice(&config.body)?;
    assert_eq!(
        parsed
            .get("liminalEndpoint")
            .and_then(serde_json::Value::as_str),
        Some(LIMINAL_ENDPOINT)
    );
    assert_eq!(
        parsed.get("authToken").and_then(serde_json::Value::as_str),
        Some(AUTH_TOKEN)
    );
    assert!(
        parsed.get("channel").is_none(),
        "channel must be omitted when not provided, never invented by the host"
    );
    // `channels` — the full configured roster — is ALWAYS served.
    assert_eq!(
        parsed.get("channels"),
        Some(&serde_json::json!(["frame.demo.graph-view"])),
        "the served config must carry the full channels roster"
    );

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn config_endpoint_carries_a_provided_channel_and_open_auth()
-> Result<(), Box<dyn std::error::Error>> {
    let server = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: String::new(),
        channel: Some("frame-demo-feed".to_owned()),
        channels: vec!["frame-demo-feed".to_owned(), "telemetry.east".to_owned()],
        liminal_health: released_addr()?,
    })
    .await?;
    let addr = server.local_addr();
    let (stop_tx, stop_rx) = tokio::sync::oneshot::channel::<()>();
    let task = tokio::spawn(server.serve(async move {
        let _outcome = stop_rx.await;
    }));

    let config = request(addr, "GET", "/frame/config.json").await?;
    assert_eq!(config.status, 200);
    let parsed: serde_json::Value = serde_json::from_slice(&config.body)?;
    // Explicit empty authToken is the operator's legal "open server" statement.
    assert_eq!(
        parsed.get("authToken").and_then(serde_json::Value::as_str),
        Some("")
    );
    assert_eq!(
        parsed.get("channel").and_then(serde_json::Value::as_str),
        Some("frame-demo-feed")
    );
    // The roster rides along verbatim, in configured order, primary included.
    assert_eq!(
        parsed.get("channels"),
        Some(&serde_json::json!(["frame-demo-feed", "telemetry.east"])),
        "the served config must carry the full channels roster in configured order"
    );

    stop_tx.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn shell_refused_config_shapes_are_refused_at_bind() -> Result<(), Box<dyn std::error::Error>>
{
    // Empty provided channel: the shell would refuse it with CONFIG_INVALID,
    // so the host refuses at bind time (same doctrine as asset-dir checks).
    let empty_channel = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: Some(String::new()),
        channels: default_channels(),
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(
        empty_channel,
        Err(HostError::ConfigContract { .. })
    ));

    // Empty liminal endpoint: same doctrine.
    let empty_endpoint = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: String::new(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: None,
        channels: default_channels(),
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(
        empty_endpoint,
        Err(HostError::ConfigContract { .. })
    ));

    // An empty channels roster: the console subscribes every roster entry, so
    // a roster with none is refused at bind (never served).
    let empty_roster = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: None,
        channels: Vec::new(),
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(
        empty_roster,
        Err(HostError::ConfigContract { .. })
    ));

    // A primary channel outside the roster: the console refuses that pair with
    // CONFIG_INVALID, so the host refuses it at bind.
    let foreign_primary = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site(),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: Some("telemetry.west".to_owned()),
        channels: vec!["telemetry.stream".to_owned()],
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(
        foreign_primary,
        Err(HostError::ConfigContract { .. })
    ));
    Ok(())
}

/// A fake liminal health listener: answers each accepted connection with the
/// canned response for the requested path, written exactly as liminal's
/// hand-rolled health server writes responses (Content-Length + Connection:
/// close + optional Content-Type).
fn fake_health_listener() -> Result<SocketAddr, Box<dyn std::error::Error>> {
    use std::io::{Read, Write};
    let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
    let addr = listener.local_addr()?;
    std::thread::spawn(move || {
        while let Ok((mut stream, _)) = listener.accept() {
            let mut request = [0_u8; 2048];
            let Ok(bytes_read) = stream.read(&mut request) else {
                continue;
            };
            let request = String::from_utf8_lossy(&request[..bytes_read]).to_string();
            let path = request
                .lines()
                .next()
                .and_then(|line| line.split_whitespace().nth(1))
                .unwrap_or_default()
                .to_owned();
            let (status, content_type, body) = match path.as_str() {
                "/health" => (
                    "200 OK",
                    "application/json",
                    r#"{"status":"healthy","message":null}"#,
                ),
                "/ready" => (
                    "503 Service Unavailable",
                    "application/json",
                    r#"{"ready":false,"unmet_conditions":["listener_bound"]}"#,
                ),
                "/metrics" => (
                    "200 OK",
                    "text/plain; version=0.0.4",
                    "liminal_connections_active 2\nliminal_publishes_total 41\nliminal_deliveries_total 40\n",
                ),
                _ => ("404 Not Found", "application/json", ""),
            };
            let mut response = format!(
                "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\nContent-Type: {content_type}\r\n\r\n",
                body.len()
            );
            response.push_str(body);
            let Ok(()) = stream.write_all(response.as_bytes()) else {
                continue;
            };
        }
    });
    Ok(addr)
}

#[tokio::test]
async fn liminal_proxy_routes_pass_status_type_and_body_through_verbatim()
-> Result<(), Box<dyn std::error::Error>> {
    let upstream = fake_health_listener()?;
    let (addr, stop, task) = start_server_with_health(upstream).await?;

    let health = request(addr, "GET", "/frame/liminal/health").await?;
    assert_eq!(health.status, 200);
    assert_eq!(health.header("content-type"), Some("application/json"));
    assert_eq!(
        String::from_utf8(health.body)?,
        r#"{"status":"healthy","message":null}"#
    );

    // A drain-window 503 from /ready stays a 503 in the page — pass-through,
    // never softened to a 200.
    let ready = request(addr, "GET", "/frame/liminal/ready").await?;
    assert_eq!(ready.status, 503);
    let ready_body: serde_json::Value = serde_json::from_slice(&ready.body)?;
    assert_eq!(
        ready_body.get("ready").and_then(serde_json::Value::as_bool),
        Some(false)
    );

    let metrics = request(addr, "GET", "/frame/liminal/metrics").await?;
    assert_eq!(metrics.status, 200);
    assert_eq!(
        metrics.header("content-type"),
        Some("text/plain; version=0.0.4")
    );
    let metrics_text = String::from_utf8(metrics.body)?;
    assert!(metrics_text.contains("liminal_connections_active 2"));
    assert!(metrics_text.contains("liminal_publishes_total 41"));
    assert!(metrics_text.contains("liminal_deliveries_total 40"));

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn liminal_proxy_reports_a_down_listener_as_typed_502_json()
-> Result<(), Box<dyn std::error::Error>> {
    // The default test server targets a released address — liminal "down".
    let (addr, stop, task) = start_server().await?;

    let down = request(addr, "GET", "/frame/liminal/health").await?;
    assert_eq!(
        down.status, 502,
        "a down health listener is a 502, never a hang or a fake 200"
    );
    assert_eq!(
        down.header("content-type"),
        Some("application/json; charset=utf-8")
    );
    let body: serde_json::Value = serde_json::from_slice(&down.body)?;
    assert_eq!(
        body.get("error").and_then(serde_json::Value::as_str),
        Some("LIMINAL_UNREACHABLE")
    );
    assert_eq!(
        body.get("upstream_path")
            .and_then(serde_json::Value::as_str),
        Some("/health")
    );
    assert!(
        body.get("detail")
            .and_then(serde_json::Value::as_str)
            .is_some_and(|detail| !detail.is_empty()),
        "the 502 body must carry the exact failure detail"
    );

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[test]
fn missing_config_flag_is_a_startup_refusal_naming_the_flag()
-> Result<(), Box<dyn std::error::Error>> {
    use clap::Parser;
    // The full frame server takes one flag: `--config <frame.toml>`. Every
    // deployment value now lives in that file, so parsing without it must
    // refuse and name the missing flag.
    let error = frame_host::Cli::try_parse_from(["frame-host"])
        .err()
        .ok_or("parsing without --config must refuse")?
        .to_string();
    assert!(
        error.contains("--config"),
        "the refusal must name the missing flag, got: {error}"
    );
    Ok(())
}

#[tokio::test]
async fn traversal_and_bad_methods_are_refused() -> Result<(), Box<dyn std::error::Error>> {
    let (addr, stop, task) = start_server().await?;

    let encoded_traversal = request(addr, "GET", "/%2e%2e/Cargo.toml").await?;
    assert_eq!(encoded_traversal.status, 400);

    let doubled = request(addr, "GET", "//index.html").await?;
    assert_eq!(doubled.status, 400);

    let posted = request(addr, "POST", "/").await?;
    assert_eq!(posted.status, 405);

    stop.send(()).map_err(|()| "server stopped early")?;
    task.await??;
    Ok(())
}

#[tokio::test]
async fn unusable_asset_roots_are_refused_at_bind() -> Result<(), Box<dyn std::error::Error>> {
    let absent = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site().join("does-not-exist"),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: None,
        channels: default_channels(),
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(absent, Err(HostError::AssetRoot { .. })));

    let no_index = ShellServer::bind(ShellConfig {
        bind: any_local_addr()?,
        asset_root: fixture_site().join("assets"),
        liminal_endpoint: LIMINAL_ENDPOINT.to_owned(),
        auth_token: AUTH_TOKEN.to_owned(),
        channel: None,
        channels: default_channels(),
        liminal_health: released_addr()?,
    })
    .await;
    assert!(matches!(no_index, Err(HostError::MissingIndex { .. })));
    Ok(())
}