diff --git a/Cargo.lock b/Cargo.lock
index 37a8f7dfbc6fd72cd265dfa80d274e61412ccd0e..7873306995d1edd4189b91b14642f8951cbd3624 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -82,6 +82,53 @@
 ]
 
 [[package]]
+name = "askama"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f1bf825125edd887a019d0a3a837dcc5499a68b0d034cc3eb594070c3e18addc"
+dependencies = [
+ "askama_macros",
+ "itoa",
+ "percent-encoding",
+ "serde",
+ "serde_json",
+]
+
+[[package]]
+name = "askama_derive"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e1c7065972a130eafa84215f21352ae15b4a7393da48c1f5e103904490736738"
+dependencies = [
+ "askama_parser",
+ "memchr",
+ "proc-macro2",
+ "quote",
+ "rustc-hash",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "askama_macros"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0e23b1d2c4bd39a41971f6124cef4cc6fd0540913ecb90919b69ab3bbe44ae1a"
+dependencies = [
+ "askama_derive",
+]
+
+[[package]]
+name = "askama_parser"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7db09fde9143e7ac4513358fb32ee32847125b63b18ea715afd487956da715da"
+dependencies = [
+ "rustc-hash",
+ "unicode-ident",
+ "winnow",
+]
+
+[[package]]
 name = "atomic-waker"
 version = "1.1.2"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -1309,7 +1356,7 @@
 checksum = "19753d40da53d0ec41604750eeb969097a90fb2d7f7992730d904541c04e2c19"
 dependencies = [
  "bstr",
- "hashbrown 0.16.1",
+ "hashbrown 0.17.1",
 ]
 
 [[package]]
@@ -2769,6 +2816,12 @@
 ]
 
 [[package]]
+name = "rustc-hash"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
+
+[[package]]
 name = "rustc_version"
 version = "0.4.1"
 source = "registry+https://github.com/rust-lang/crates.io-index"
@@ -2887,6 +2940,19 @@
  "proc-macro2",
  "quote",
  "syn 3.0.3",
+]
+
+[[package]]
+name = "serde_json"
+version = "1.0.151"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
 ]
 
 [[package]]
@@ -3231,6 +3297,7 @@
 name = "tit-cde"
 version = "0.1.0"
 dependencies = [
+ "askama",
  "axum",
  "clap",
  "gix",
@@ -3691,6 +3758,9 @@
 version = "1.0.4"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
+dependencies = [
+ "memchr",
+]
 
 [[package]]
 name = "wnaf"
@@ -3797,3 +3867,9 @@
 version = "0.6.6"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5"
+
+[[package]]
+name = "zmij"
+version = "1.0.23"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
diff --git a/Cargo.toml b/Cargo.toml
index fa8df52d4574b23ca67d44cd5107d908d82c546a..6ef7aa7f00b7505d260e786c5913200f9451e545 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,6 +11,7 @@
 path = "src/main.rs"
 
 [dependencies]
+askama = { version = "0.16", default-features = false, features = ["derive", "std"] }
 axum = { version = "0.8", default-features = false, features = ["http1", "query", "tokio"] }
 clap = { version = "4.6", default-features = false, features = ["derive", "error-context", "help", "std", "usage"] }
 gix = { version = "0.84", default-features = false, features = ["blame", "parallel", "sha1", "sha256"] }
diff --git a/assets/style.css b/assets/style.css
new file mode 100644
index 0000000000000000000000000000000000000000..758074ee633fc66dc97c28112024e35562e9f05e
--- /dev/null
+++ b/assets/style.css
@@ -1,0 +1,104 @@
+:root {
+  color-scheme: light dark;
+  font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
+  line-height: 1.5;
+  background: Canvas;
+  color: CanvasText;
+}
+
+* {
+  box-sizing: border-box;
+}
+
+body {
+  width: min(100% - 2rem, 64rem);
+  margin: 0 auto;
+}
+
+.site-header {
+  display: flex;
+  align-items: baseline;
+  justify-content: space-between;
+  gap: 1rem;
+  padding: 1rem 0;
+  border-bottom: 1px solid GrayText;
+}
+
+.brand {
+  color: inherit;
+  font-size: 1.5rem;
+  font-weight: 700;
+  text-decoration: none;
+}
+
+main {
+  min-height: 65vh;
+  padding: 2rem 0;
+}
+
+footer {
+  padding: 1rem 0;
+  border-top: 1px solid GrayText;
+}
+
+form {
+  max-width: 32rem;
+}
+
+.field {
+  margin-block: 1rem;
+}
+
+label {
+  display: block;
+  margin-bottom: 0.25rem;
+  font-weight: 700;
+}
+
+input,
+button {
+  max-width: 100%;
+  padding: 0.6rem 0.75rem;
+  border: 1px solid GrayText;
+  border-radius: 0.25rem;
+  background: Canvas;
+  color: CanvasText;
+  font: inherit;
+}
+
+input {
+  width: 100%;
+}
+
+button {
+  cursor: pointer;
+  font-weight: 700;
+}
+
+a {
+  color: LinkText;
+}
+
+:focus-visible {
+  outline: 0.2rem solid Highlight;
+  outline-offset: 0.2rem;
+}
+
+.error {
+  padding: 0.75rem;
+  border-left: 0.25rem solid #b42318;
+}
+
+.skip-link {
+  position: absolute;
+  left: -10000px;
+  top: auto;
+}
+
+.skip-link:focus {
+  left: 1rem;
+  top: 1rem;
+  z-index: 1;
+  padding: 0.5rem;
+  background: Canvas;
+}
diff --git a/scripts/check-m2-4 b/scripts/check-m2-4
new file mode 100755
index 0000000000000000000000000000000000000000..fc26afee2a831a8032c0cccfea4fb46eaa83c19a
--- /dev/null
+++ b/scripts/check-m2-4
@@ -1,0 +1,5 @@
+#!/bin/sh
+set -eu
+
+./scripts/check
+cargo test --locked --release --test web_shell
diff --git a/src/http/mod.rs b/src/http/mod.rs
new file mode 100644
index 0000000000000000000000000000000000000000..772d5fbd758e4306844065b6644bdbb7cce272d0
--- /dev/null
+++ b/src/http/mod.rs
@@ -1,0 +1,299 @@
+use std::net::SocketAddr;
+
+use askama::Template;
+use axum::Router;
+use axum::body::{Body, HttpBody};
+use axum::extract::{Extension, RawQuery, Request};
+use axum::http::{HeaderName, HeaderValue, Method, StatusCode, header};
+use axum::middleware::{self, Next};
+use axum::response::Response;
+use axum::routing::get;
+use thiserror::Error;
+use tokio::net::TcpListener;
+use tokio::sync::oneshot;
+use tokio::task::JoinHandle;
+
+use crate::auth::validate_username;
+use crate::domain::repository::validate_slug;
+
+const STYLE: &str = include_str!("../../assets/style.css");
+const MAX_LOCATION_QUERY_BYTES: usize = 512;
+const CONTENT_SECURITY_POLICY: &str = "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'";
+
+pub(crate) struct RunningWebServer {
+    address: SocketAddr,
+    shutdown: oneshot::Sender<()>,
+    task: JoinHandle<std::io::Result<()>>,
+}
+
+impl RunningWebServer {
+    pub(crate) async fn start(address: SocketAddr) -> Result<Self, WebError> {
+        let listener = TcpListener::bind(address).await?;
+        let address = listener.local_addr()?;
+        let (shutdown, receiver) = oneshot::channel();
+        let task = tokio::spawn(async move {
+            axum::serve(listener, router())
+                .with_graceful_shutdown(async {
+                    let _ = receiver.await;
+                })
+                .await
+        });
+        Ok(Self {
+            address,
+            shutdown,
+            task,
+        })
+    }
+
+    pub(crate) fn address(&self) -> SocketAddr {
+        self.address
+    }
+
+    pub(crate) async fn shutdown(self) -> Result<(), WebError> {
+        let _ = self.shutdown.send(());
+        self.task.await.map_err(|_| WebError::Join)??;
+        Ok(())
+    }
+}
+
+pub(crate) fn router() -> Router {
+    Router::new()
+        .route("/", get(home))
+        .route("/go", get(go_to_repository))
+        .route("/assets/style.css", get(style))
+        .fallback(not_found)
+        .method_not_allowed_fallback(method_not_allowed)
+        .layer(middleware::from_fn(response_policy))
+}
+
+async fn home(Extension(request_id): Extension<RequestId>) -> Response {
+    render_home(StatusCode::OK, &request_id.0, "", "", "")
+}
+
+async fn go_to_repository(
+    Extension(request_id): Extension<RequestId>,
+    RawQuery(query): RawQuery,
+) -> Response {
+    match parse_location_query(query.as_deref()) {
+        Ok((owner, repository)) => {
+            let location = format!("/{owner}/{repository}");
+            Response::builder()
+                .status(StatusCode::FOUND)
+                .header(header::LOCATION, location)
+                .header(header::CACHE_CONTROL, "no-store")
+                .body(Body::empty())
+                .expect("the repository redirect is valid")
+        }
+        Err(LocationQueryError { owner, repository }) => render_home(
+            StatusCode::BAD_REQUEST,
+            &request_id.0,
+            &owner,
+            &repository,
+            "Enter a valid lowercase owner and repository.",
+        ),
+    }
+}
+
+async fn style() -> Response {
+    Response::builder()
+        .status(StatusCode::OK)
+        .header(header::CONTENT_TYPE, "text/css; charset=utf-8")
+        .header(header::CACHE_CONTROL, "public, max-age=3600")
+        .body(Body::from(STYLE))
+        .expect("the embedded CSS response is valid")
+}
+
+async fn not_found(Extension(request_id): Extension<RequestId>) -> Response {
+    render_error(
+        StatusCode::NOT_FOUND,
+        &request_id.0,
+        "Page not found",
+        "The requested page does not exist.",
+    )
+}
+
+async fn method_not_allowed(Extension(request_id): Extension<RequestId>) -> Response {
+    let mut response = render_error(
+        StatusCode::METHOD_NOT_ALLOWED,
+        &request_id.0,
+        "Method not allowed",
+        "This page does not accept the request method.",
+    );
+    response
+        .headers_mut()
+        .insert(header::ALLOW, HeaderValue::from_static("GET, HEAD"));
+    response
+}
+
+async fn response_policy(mut request: Request, next: Next) -> Response {
+    let request_id = RequestId(format!("{:032x}", rand::random::<u128>()));
+    let is_head = request.method() == Method::HEAD;
+    request.extensions_mut().insert(request_id.clone());
+    let mut response = next.run(request).await;
+    let headers = response.headers_mut();
+    headers.insert(
+        HeaderName::from_static("content-security-policy"),
+        HeaderValue::from_static(CONTENT_SECURITY_POLICY),
+    );
+    headers.insert(
+        HeaderName::from_static("x-content-type-options"),
+        HeaderValue::from_static("nosniff"),
+    );
+    headers.insert(
+        HeaderName::from_static("x-frame-options"),
+        HeaderValue::from_static("DENY"),
+    );
+    headers.insert(
+        HeaderName::from_static("referrer-policy"),
+        HeaderValue::from_static("no-referrer"),
+    );
+    headers.insert(
+        HeaderName::from_static("permissions-policy"),
+        HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=(), usb=()"),
+    );
+    headers.insert(
+        HeaderName::from_static("cross-origin-opener-policy"),
+        HeaderValue::from_static("same-origin"),
+    );
+    headers.insert(
+        HeaderName::from_static("x-request-id"),
+        HeaderValue::from_str(&request_id.0).expect("the generated request ID is a header value"),
+    );
+
+    if is_head {
+        let length = response.body().size_hint().exact();
+        *response.body_mut() = Body::empty();
+        if let Some(length) = length
+            && !response.headers().contains_key(header::CONTENT_LENGTH)
+        {
+            response.headers_mut().insert(
+                header::CONTENT_LENGTH,
+                HeaderValue::from_str(&length.to_string())
+                    .expect("a content length is a header value"),
+            );
+        }
+    }
+    response
+}
+
+fn render_home(
+    status: StatusCode,
+    request_id: &str,
+    owner: &str,
+    repository: &str,
+    error: &str,
+) -> Response {
+    render(
+        status,
+        &HomeTemplate {
+            request_id,
+            owner,
+            repository,
+            error,
+            has_error: !error.is_empty(),
+        },
+    )
+}
+
+fn render_error(status: StatusCode, request_id: &str, heading: &str, message: &str) -> Response {
+    render(
+        status,
+        &ErrorTemplate {
+            request_id,
+            status: heading,
+            message,
+        },
+    )
+}
+
+fn render(status: StatusCode, template: &impl Template) -> Response {
+    match template.render() {
+        Ok(body) => Response::builder()
+            .status(status)
+            .header(header::CONTENT_TYPE, "text/html; charset=utf-8")
+            .header(header::CACHE_CONTROL, "no-store")
+            .body(Body::from(body))
+            .expect("the HTML response is valid"),
+        Err(_) => Response::builder()
+            .status(StatusCode::INTERNAL_SERVER_ERROR)
+            .header(header::CONTENT_TYPE, "text/plain; charset=utf-8")
+            .header(header::CACHE_CONTROL, "no-store")
+            .body(Body::from("Template rendering failed.\n"))
+            .expect("the template error response is valid"),
+    }
+}
+
+fn parse_location_query(query: Option<&str>) -> Result<(String, String), LocationQueryError> {
+    let query = query.ok_or_else(LocationQueryError::default)?;
+    if query.len() > MAX_LOCATION_QUERY_BYTES || !valid_percent_encoding(query.as_bytes()) {
+        return Err(LocationQueryError::default());
+    }
+    let mut owner = None;
+    let mut repository = None;
+    for (name, value) in url::form_urlencoded::parse(query.as_bytes()) {
+        match name.as_ref() {
+            "owner" if owner.is_none() => owner = Some(value.into_owned()),
+            "repository" if repository.is_none() => repository = Some(value.into_owned()),
+            _ => return Err(LocationQueryError::default()),
+        }
+    }
+    let owner = owner.unwrap_or_default();
+    let repository = repository.unwrap_or_default();
+    if validate_username(&owner).is_err() || validate_slug(&repository).is_err() {
+        return Err(LocationQueryError { owner, repository });
+    }
+    Ok((owner, repository))
+}
+
+fn valid_percent_encoding(input: &[u8]) -> bool {
+    let mut index = 0;
+    while index < input.len() {
+        if input[index] == b'%' {
+            if index + 2 >= input.len()
+                || !input[index + 1].is_ascii_hexdigit()
+                || !input[index + 2].is_ascii_hexdigit()
+            {
+                return false;
+            }
+            index += 3;
+        } else {
+            index += 1;
+        }
+    }
+    true
+}
+
+#[derive(Clone)]
+struct RequestId(String);
+
+#[derive(Default)]
+struct LocationQueryError {
+    owner: String,
+    repository: String,
+}
+
+#[derive(Template)]
+#[template(path = "home.html")]
+struct HomeTemplate<'a> {
+    request_id: &'a str,
+    owner: &'a str,
+    repository: &'a str,
+    error: &'a str,
+    has_error: bool,
+}
+
+#[derive(Template)]
+#[template(path = "error.html")]
+struct ErrorTemplate<'a> {
+    request_id: &'a str,
+    status: &'a str,
+    message: &'a str,
+}
+
+#[derive(Debug, Error)]
+pub(crate) enum WebError {
+    #[error("HTTP listener error: {0}")]
+    Io(#[from] std::io::Error),
+    #[error("HTTP server task failed")]
+    Join,
+}
diff --git a/src/main.rs b/src/main.rs
index 241a2e8bab2cbce04eff128b245f5339c7e9a7ce..5a8247d54ae29a5ac9a8cc778ab61057116a9dfe 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -10,6 +10,11 @@
 mod domain;
 #[allow(dead_code, reason = "M1C proves Git reads before the CLI serves them")]
 mod git;
+#[allow(
+    dead_code,
+    reason = "M2.4 establishes the HTTP shell before tit serve calls it"
+)]
+mod http;
 mod instance;
 #[allow(dead_code, reason = "M1B proves the SSH server before M2 calls it")]
 mod ssh;
diff --git a/templates/base.html b/templates/base.html
new file mode 100644
index 0000000000000000000000000000000000000000..d5831452ce0396a46b6782fe3024afdbce3c136a
--- /dev/null
+++ b/templates/base.html
@@ -1,0 +1,24 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>{% block title %}tit{% endblock %}</title>
+  <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <nav aria-label="Primary">
+      <a href="/">Home</a>
+    </nav>
+  </header>
+  <main id="main">
+{% block content %}{% endblock %}
+  </main>
+  <footer>
+    <small>Request <code>{{ request_id }}</code></small>
+  </footer>
+</body>
+</html>
diff --git a/templates/error.html b/templates/error.html
new file mode 100644
index 0000000000000000000000000000000000000000..33badb7d08085ab63e03e784fb1c14c65e54c471
--- /dev/null
+++ b/templates/error.html
@@ -1,0 +1,7 @@
+{% extends "base.html" %}
+{% block title %}{{ status }} · tit{% endblock %}
+{% block content %}
+  <h1>{{ status }}</h1>
+  <p>{{ message }}</p>
+  <p><a href="/">Return home</a></p>
+{% endblock %}
diff --git a/templates/home.html b/templates/home.html
new file mode 100644
index 0000000000000000000000000000000000000000..95483a140113aa6b1e7cccbae219dfadbec36584
--- /dev/null
+++ b/templates/home.html
@@ -1,0 +1,20 @@
+{% extends "base.html" %}
+{% block title %}Repositories · tit{% endblock %}
+{% block content %}
+  <h1>Repositories</h1>
+  <p>Enter an owner and a repository to open its public page.</p>
+{% if has_error %}
+  <p class="error" role="alert">{{ error }}</p>
+{% endif %}
+  <form action="/go" method="get">
+    <div class="field">
+      <label for="owner">Owner</label>
+      <input id="owner" name="owner" value="{{ owner }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+      <label for="repository">Repository</label>
+      <input id="repository" name="repository" value="{{ repository }}" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <button type="submit">Open repository</button>
+  </form>
+{% endblock %}
diff --git a/tests/snapshots/web/bad-request.html b/tests/snapshots/web/bad-request.html
new file mode 100644
index 0000000000000000000000000000000000000000..acb84f9ae18f6eb8df326b21a2f8d23361e6a0f3
--- /dev/null
+++ b/tests/snapshots/web/bad-request.html
@@ -1,0 +1,41 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Repositories · tit</title>
+  <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <nav aria-label="Primary">
+      <a href="/">Home</a>
+    </nav>
+  </header>
+  <main id="main">
+
+  <h1>Repositories</h1>
+  <p>Enter an owner and a repository to open its public page.</p>
+
+  <p class="error" role="alert">Enter a valid lowercase owner and repository.</p>
+
+  <form action="/go" method="get">
+    <div class="field">
+      <label for="owner">Owner</label>
+      <input id="owner" name="owner" value="&#60;script&#62;" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+      <label for="repository">Repository</label>
+      <input id="repository" name="repository" value="example" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <button type="submit">Open repository</button>
+  </form>
+
+  </main>
+  <footer>
+    <small>Request <code><request-id></code></small>
+  </footer>
+</body>
+</html>
diff --git a/tests/snapshots/web/home.html b/tests/snapshots/web/home.html
new file mode 100644
index 0000000000000000000000000000000000000000..cf76f68a79f138037014bf8603ee173eef55993f
--- /dev/null
+++ b/tests/snapshots/web/home.html
@@ -1,0 +1,39 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Repositories · tit</title>
+  <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <nav aria-label="Primary">
+      <a href="/">Home</a>
+    </nav>
+  </header>
+  <main id="main">
+
+  <h1>Repositories</h1>
+  <p>Enter an owner and a repository to open its public page.</p>
+
+  <form action="/go" method="get">
+    <div class="field">
+      <label for="owner">Owner</label>
+      <input id="owner" name="owner" value="" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <div class="field">
+      <label for="repository">Repository</label>
+      <input id="repository" name="repository" value="" required autocomplete="off" autocapitalize="none" spellcheck="false">
+    </div>
+    <button type="submit">Open repository</button>
+  </form>
+
+  </main>
+  <footer>
+    <small>Request <code><request-id></code></small>
+  </footer>
+</body>
+</html>
diff --git a/tests/snapshots/web/method-not-allowed.html b/tests/snapshots/web/method-not-allowed.html
new file mode 100644
index 0000000000000000000000000000000000000000..581566f0464896f1e7e672f122f7fd9c87d5f188
--- /dev/null
+++ b/tests/snapshots/web/method-not-allowed.html
@@ -1,0 +1,28 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Method not allowed · tit</title>
+  <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <nav aria-label="Primary">
+      <a href="/">Home</a>
+    </nav>
+  </header>
+  <main id="main">
+
+  <h1>Method not allowed</h1>
+  <p>This page does not accept the request method.</p>
+  <p><a href="/">Return home</a></p>
+
+  </main>
+  <footer>
+    <small>Request <code><request-id></code></small>
+  </footer>
+</body>
+</html>
diff --git a/tests/snapshots/web/not-found.html b/tests/snapshots/web/not-found.html
new file mode 100644
index 0000000000000000000000000000000000000000..f80436b71f826ac8c081443fb99c908c09530d73
--- /dev/null
+++ b/tests/snapshots/web/not-found.html
@@ -1,0 +1,28 @@
+<!doctype html>
+<html lang="en">
+<head>
+  <meta charset="utf-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1">
+  <title>Page not found · tit</title>
+  <link rel="stylesheet" href="/assets/style.css">
+</head>
+<body>
+  <a class="skip-link" href="#main">Skip to content</a>
+  <header class="site-header">
+    <a class="brand" href="/" aria-label="tit home">tit</a>
+    <nav aria-label="Primary">
+      <a href="/">Home</a>
+    </nav>
+  </header>
+  <main id="main">
+
+  <h1>Page not found</h1>
+  <p>The requested page does not exist.</p>
+  <p><a href="/">Return home</a></p>
+
+  </main>
+  <footer>
+    <small>Request <code><request-id></code></small>
+  </footer>
+</body>
+</html>
diff --git a/tests/web_shell.rs b/tests/web_shell.rs
new file mode 100644
index 0000000000000000000000000000000000000000..d50e9f84fcb5e475f52667d6f772aaac06dc7154
--- /dev/null
+++ b/tests/web_shell.rs
@@ -1,0 +1,270 @@
+#[allow(dead_code, reason = "the Web shell test uses only username validation")]
+#[path = "../src/auth.rs"]
+mod auth;
+#[allow(
+    dead_code,
+    reason = "the Web shell test uses only repository slug validation"
+)]
+#[path = "../src/domain/mod.rs"]
+mod domain;
+#[path = "../src/http/mod.rs"]
+mod http;
+
+use std::collections::BTreeMap;
+use std::io::{Read, Write};
+use std::net::{Ipv4Addr, SocketAddr, TcpStream};
+
+use http::RunningWebServer;
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn serves_the_semantic_shell_without_javascript() {
+    let server = start().await;
+
+    let home = request(server.address(), "GET", "/", &[]);
+    assert_eq!(home.status, 200);
+    assert_eq!(home.header("content-type"), "text/html; charset=utf-8");
+    assert_eq!(home.header("cache-control"), "no-store");
+    assert!(home.body.contains("<header class=\"site-header\">"));
+    assert!(home.body.contains("<nav aria-label=\"Primary\">"));
+    assert!(home.body.contains("<main id=\"main\">"));
+    assert!(home.body.contains("<footer>"));
+    assert!(home.body.contains("<form action=\"/go\" method=\"get\">"));
+    assert!(home.body.contains("name=\"owner\""));
+    assert!(home.body.contains("name=\"repository\""));
+    assert!(!home.body.to_ascii_lowercase().contains("<script"));
+    assert_security_policy(&home);
+    assert_snapshot(&home, include_str!("snapshots/web/home.html"));
+
+    let request_id = home.header("x-request-id");
+    assert_request_id(request_id);
+    assert!(home.body.contains(&format!("<code>{request_id}</code>")));
+
+    let head = request(server.address(), "HEAD", "/", &[]);
+    assert_eq!(head.status, 200);
+    assert!(head.body.is_empty());
+    assert_eq!(head.header("content-length"), home.body.len().to_string());
+    assert_security_policy(&head);
+
+    let css = request(server.address(), "GET", "/assets/style.css", &[]);
+    assert_eq!(css.status, 200);
+    assert_eq!(css.header("content-type"), "text/css; charset=utf-8");
+    assert_eq!(css.header("cache-control"), "public, max-age=3600");
+    assert_eq!(css.body, include_str!("../assets/style.css"));
+    assert_security_policy(&css);
+
+    let css_head = request(server.address(), "HEAD", "/assets/style.css", &[]);
+    assert_eq!(css_head.status, 200);
+    assert!(css_head.body.is_empty());
+    assert_eq!(
+        css_head.header("content-length"),
+        css.body.len().to_string()
+    );
+
+    server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn submits_the_repository_form_with_plain_http() {
+    let server = start().await;
+
+    let redirect = request(
+        server.address(),
+        "GET",
+        "/go?owner=alice&repository=example",
+        &[],
+    );
+    assert_eq!(redirect.status, 302);
+    assert_eq!(redirect.header("location"), "/alice/example");
+    assert_eq!(redirect.header("cache-control"), "no-store");
+    assert!(redirect.body.is_empty());
+    assert_security_policy(&redirect);
+
+    for path in [
+        "/go",
+        "/go?owner=Alice&repository=example",
+        "/go?owner=alice&repository=../example",
+        "/go?owner=alice&owner=bob&repository=example",
+        "/go?owner=alice&repository=example&extra=value",
+        "/go?owner=alice&repository=%",
+    ] {
+        let response = request(server.address(), "GET", path, &[]);
+        assert_eq!(response.status, 400, "unexpected status for {path}");
+        assert!(response.body.contains("role=\"alert\""));
+        assert!(
+            response
+                .body
+                .contains("Enter a valid lowercase owner and repository.")
+        );
+        assert_security_policy(&response);
+    }
+
+    let injection = request(
+        server.address(),
+        "GET",
+        "/go?owner=%3Cscript%3E&repository=example",
+        &[],
+    );
+    assert_eq!(injection.status, 400);
+    assert!(injection.body.contains("value=\"&#60;script&#62;\""));
+    assert!(!injection.body.contains("value=\"<script>\""));
+    assert!(!injection.body.to_ascii_lowercase().contains("<script"));
+    assert_snapshot(&injection, include_str!("snapshots/web/bad-request.html"));
+
+    let oversized = format!("/go?owner={}&repository=example", "a".repeat(512));
+    assert_eq!(
+        request(server.address(), "GET", &oversized, &[]).status,
+        400
+    );
+
+    server.shutdown().await.expect("stop the Web server");
+}
+
+#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+async fn serves_useful_errors_and_owns_request_ids() {
+    let server = start().await;
+
+    let missing = request(server.address(), "GET", "/missing", &[]);
+    assert_eq!(missing.status, 404);
+    assert!(missing.body.contains("<h1>Page not found</h1>"));
+    assert!(missing.body.contains("The requested page does not exist."));
+    assert_security_policy(&missing);
+    assert_snapshot(&missing, include_str!("snapshots/web/not-found.html"));
+
+    let missing_head = request(server.address(), "HEAD", "/missing", &[]);
+    assert_eq!(missing_head.status, 404);
+    assert!(missing_head.body.is_empty());
+    assert_eq!(
+        missing_head.header("content-length"),
+        missing.body.len().to_string()
+    );
+
+    let method = request(server.address(), "POST", "/", &[]);
+    assert_eq!(method.status, 405);
+    assert_eq!(method.header("allow"), "GET, HEAD");
+    assert!(method.body.contains("<h1>Method not allowed</h1>"));
+    assert!(
+        method
+            .body
+            .contains("This page does not accept the request method.")
+    );
+    assert_security_policy(&method);
+    assert_snapshot(
+        &method,
+        include_str!("snapshots/web/method-not-allowed.html"),
+    );
+
+    let first = request(
+        server.address(),
+        "GET",
+        "/",
+        &[("X-Request-ID", "attacker-controlled")],
+    );
+    let second = request(server.address(), "GET", "/", &[]);
+    assert_request_id(first.header("x-request-id"));
+    assert_request_id(second.header("x-request-id"));
+    assert_ne!(first.header("x-request-id"), "attacker-controlled");
+    assert_ne!(first.header("x-request-id"), second.header("x-request-id"));
+
+    server.shutdown().await.expect("stop the Web server");
+}
+
+async fn start() -> RunningWebServer {
+    RunningWebServer::start(SocketAddr::from((Ipv4Addr::LOCALHOST, 0)))
+        .await
+        .expect("start the Web server")
+}
+
+fn request(
+    address: SocketAddr,
+    method: &str,
+    path: &str,
+    headers: &[(&str, &str)],
+) -> HttpResponse {
+    let mut stream = TcpStream::connect(address).expect("connect to the Web server");
+    let mut request =
+        format!("{method} {path} HTTP/1.1\r\nHost: {address}\r\nConnection: close\r\n");
+    for (name, value) in headers {
+        request.push_str(&format!("{name}: {value}\r\n"));
+    }
+    request.push_str("Content-Length: 0\r\n\r\n");
+    stream
+        .write_all(request.as_bytes())
+        .expect("write an HTTP request");
+    let mut bytes = Vec::new();
+    stream
+        .read_to_end(&mut bytes)
+        .expect("read an HTTP response");
+    HttpResponse::parse(&bytes)
+}
+
+fn assert_security_policy(response: &HttpResponse) {
+    assert_eq!(
+        response.header("content-security-policy"),
+        "default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'"
+    );
+    assert_eq!(response.header("x-content-type-options"), "nosniff");
+    assert_eq!(response.header("x-frame-options"), "DENY");
+    assert_eq!(response.header("referrer-policy"), "no-referrer");
+    assert_eq!(
+        response.header("permissions-policy"),
+        "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
+    );
+    assert_eq!(response.header("cross-origin-opener-policy"), "same-origin");
+    assert_request_id(response.header("x-request-id"));
+}
+
+fn assert_request_id(value: &str) {
+    assert_eq!(value.len(), 32);
+    assert!(value.bytes().all(|byte| byte.is_ascii_hexdigit()));
+    assert_eq!(value, value.to_ascii_lowercase());
+}
+
+fn assert_snapshot(response: &HttpResponse, expected: &str) {
+    let normalized = response
+        .body
+        .replace(response.header("x-request-id"), "<request-id>");
+    assert_eq!(normalized, expected.strip_suffix('\n').unwrap_or(expected));
+}
+
+struct HttpResponse {
+    status: u16,
+    headers: BTreeMap<String, String>,
+    body: String,
+}
+
+impl HttpResponse {
+    fn parse(bytes: &[u8]) -> Self {
+        let split = bytes
+            .windows(4)
+            .position(|window| window == b"\r\n\r\n")
+            .expect("an HTTP response header terminator");
+        let head = std::str::from_utf8(&bytes[..split]).expect("UTF-8 HTTP response headers");
+        let mut lines = head.split("\r\n");
+        let status = lines
+            .next()
+            .expect("an HTTP status line")
+            .split_whitespace()
+            .nth(1)
+            .expect("an HTTP status code")
+            .parse()
+            .expect("a numeric HTTP status code");
+        let headers = lines
+            .map(|line| {
+                let (name, value) = line.split_once(':').expect("a valid HTTP response header");
+                (name.to_ascii_lowercase(), value.trim().to_owned())
+            })
+            .collect();
+        let body = String::from_utf8(bytes[split + 4..].to_vec()).expect("a UTF-8 response body");
+        Self {
+            status,
+            headers,
+            body,
+        }
+    }
+
+    fn header(&self, name: &str) -> &str {
+        self.headers
+            .get(name)
+            .unwrap_or_else(|| panic!("missing {name} response header"))
+    }
+}
