beck_rt/
http.rs

1//! HTTP: the first paint, the assets, the probes, and the websocket upgrade.
2//!
3//! "First paint is free SSR: evaluate pure `view` against the current accumulator, ship HTML."
4//! The document below is therefore not a shell that fetches data — it *is* the data, rendered, and
5//! the socket that opens afterwards resumes from the `seq` the render reflects. There is no
6//! loading state anywhere in a Beck program because there is nothing to load.
7
8use std::net::SocketAddr;
9use std::sync::Arc;
10
11use anyhow::Result;
12use http_body_util::Full;
13use hyper::body::{Bytes, Incoming};
14use hyper::header::{
15    HeaderValue, CACHE_CONTROL, CONNECTION, CONTENT_TYPE, ORIGIN, SEC_WEBSOCKET_ACCEPT,
16    SEC_WEBSOCKET_KEY, UPGRADE,
17};
18use hyper::service::service_fn;
19use hyper::{Method, Request, Response, StatusCode};
20use hyper_util::rt::TokioIo;
21use tokio::net::TcpListener;
22use tokio_tungstenite::tungstenite::handshake::derive_accept_key;
23use tokio_tungstenite::tungstenite::protocol::{Role, WebSocketConfig};
24use tokio_tungstenite::WebSocketStream;
25
26use crate::app::App;
27use crate::dash::Dashboard;
28
29pub async fn serve(
30    app: Arc<App>,
31    addr: SocketAddr,
32    shutdown: tokio::sync::watch::Receiver<bool>,
33) -> Result<()> {
34    serve_with_dashboard(app, addr, shutdown, None).await
35}
36
37/// Serve, optionally with the dashboard mounted under `/_beck`.
38///
39/// Optional because the dashboard needs the infrastructure graph, which `beck-rt` cannot build —
40/// it does not depend on `beck-infra`, and should not: the runtime does not know what Kubernetes
41/// is. Whoever assembles the process knows both, and passes one in.
42pub async fn serve_with_dashboard(
43    app: Arc<App>,
44    addr: SocketAddr,
45    mut shutdown: tokio::sync::watch::Receiver<bool>,
46    dashboard: Option<Arc<Dashboard>>,
47) -> Result<()> {
48    let listener = TcpListener::bind(addr).await?;
49    tracing::info!(%addr, store = app.store_kind(), "listening");
50
51    loop {
52        let accepted = tokio::select! {
53            accepted = listener.accept() => accepted,
54            _ = shutdown.changed() => {
55                if *shutdown.borrow() {
56                    tracing::info!("draining: no longer accepting connections");
57                    // And the ones already accepted: a subscription is a task of its own, and a
58                    // server that only stops *accepting* leaves every open socket up for as long
59                    // as the process lives (§5.2's third clause, `App::drain`).
60                    app.drain();
61                    return Ok(());
62                }
63                continue;
64            }
65        };
66
67        let (stream, _peer) = accepted?;
68        stream.set_nodelay(true)?;
69        let app = app.clone();
70        let dashboard = dashboard.clone();
71        tokio::spawn(async move {
72            let service = service_fn(move |req| route(app.clone(), dashboard.clone(), req));
73            if let Err(e) = hyper::server::conn::http1::Builder::new()
74                .serve_connection(TokioIo::new(stream), service)
75                .with_upgrades()
76                .await
77            {
78                tracing::debug!(error = %e, "connection closed");
79            }
80        });
81    }
82}
83
84/// The port the local listener actually bound, for tests that ask for port 0.
85pub async fn bind(addr: SocketAddr) -> Result<TcpListener> {
86    Ok(TcpListener::bind(addr).await?)
87}
88
89async fn route(
90    app: Arc<App>,
91    dashboard: Option<Arc<Dashboard>>,
92    req: Request<Incoming>,
93) -> Result<Response<Full<Bytes>>> {
94    let path = req.uri().path().to_string();
95    if req.method() == Method::GET {
96        if let Some(d) = &dashboard {
97            if let Some((content_type, body)) = d.route(&path, &app) {
98                return Ok(asset(&body, content_type));
99            }
100        }
101    }
102    match (req.method(), path.as_str()) {
103        (&Method::GET, "/healthz") | (&Method::GET, "/readyz") => Ok(text("ok")),
104        (&Method::GET, "/beck-patch.js") => Ok(asset(crate::PATCH_CLIENT, "text/javascript")),
105        (&Method::GET, "/beck-thin.js") => Ok(asset(crate::THIN_CLIENT, "text/javascript")),
106        (&Method::GET, "/beck-mode-b.js") => Ok(asset(crate::MODE_B_CLIENT, "text/javascript")),
107        // The worker's cache is keyed by the program it is caching, so the id is substituted here
108        // rather than fetched by the worker: a worker that had to ask the server which program it
109        // was would be asking the one thing it exists to survive the absence of.
110        (&Method::GET, "/beck-sw.js") => Ok(asset(
111            &crate::SERVICE_WORKER.replace("%WIRE%", app.runtime().wire_id()),
112            "text/javascript",
113        )),
114        // The component's slice, for a browser that renders it (§5.1's Mode B). Derived from the
115        // running program rather than read from disk, so a tab can never load a bundle the server
116        // is not itself executing.
117        (&Method::GET, "/beck-bundle.bpk") => Ok(bytes(
118            beck_core::Bundle::of(app.runtime().placed()).to_bytes(),
119            "application/octet-stream",
120        )),
121        (&Method::GET, "/beck-kernel.wasm") => Ok(kernel()),
122        // The program's own sheet: one rule per class its pages can carry (`docs/104` §104.4),
123        // derived at startup from the program this process is executing.
124        (&Method::GET, "/beck.css") => Ok(asset(app.stylesheet(), "text/css")),
125        (&Method::GET, "/beck-devtools.js") => Ok(asset(crate::DEVTOOLS_CLIENT, "text/javascript")),
126        // What the panel draws: the program's own signal graph and the dataflow the compiler made
127        // of its view. Derived from the running program, so a panel cannot describe a version of
128        // the program this process is not executing.
129        (&Method::GET, "/beck-signals.json") => Ok(asset(
130            crate::signals::document(app.runtime().placed(), app.runtime().plan()),
131            "application/json",
132        )),
133        (&Method::GET, LOGIN_PATH) => login(app, &req).await,
134        (&Method::GET, CALLBACK_PATH) => callback(app, &req).await,
135        (&Method::GET, LOGOUT_PATH) => Ok(logout()),
136        (&Method::GET, "/socket") => upgrade(app, req),
137        // Every other GET is the application, at that route. A Beck program has one page and that
138        // page is a function of `session.path`, so there is nothing here to match a route against:
139        // the *program* decides what `/done` means, and this decides only that `/done` is a page
140        // rather than a missing file. Which is what makes a deep link and a reload work — the
141        // route is established by the request that renders, and not by a script afterwards.
142        (&Method::GET, _) => document(app, req).await,
143        _ => Ok(not_found()),
144    }
145}
146
147/// A path this process answers itself, so a program's routes cannot be shadowed by one.
148///
149/// The list is derived from the `match` above rather than written twice — `route_is_reserved` is
150/// what the gate holds it to. It exists as a function because a program's author needs to know
151/// which routes are not theirs, and "read the router" is not an answer.
152pub fn reserved_routes() -> &'static [&'static str] {
153    &[
154        "/healthz",
155        "/readyz",
156        "/socket",
157        "/beck.css",
158        "/beck-patch.js",
159        "/beck-thin.js",
160        "/beck-mode-b.js",
161        "/beck-devtools.js",
162        "/beck-sw.js",
163        "/beck-bundle.bpk",
164        "/beck-kernel.wasm",
165        "/beck-signals.json",
166        LOGIN_PATH,
167        CALLBACK_PATH,
168        LOGOUT_PATH,
169    ]
170}
171
172/// Where the login flow lives. Fixed paths rather than configurable ones: they are registered with
173/// an identity provider as a redirect URI, and a path an operator can move is a path that stops
174/// matching what was registered.
175pub const LOGIN_PATH: &str = "/auth/login";
176pub const CALLBACK_PATH: &str = "/auth/callback";
177pub const LOGOUT_PATH: &str = "/auth/logout";
178
179/// The cookie carrying the credential a verified connection is identified by.
180///
181/// Under [`crate::oidc`] its value is the **ID token itself** — the issuer's, not one this process
182/// made — so every connection re-verifies the issuer's signature rather than a local session's.
183const SESSION_COOKIE: &str = "beck_id";
184/// The cookie carrying the sealed login transaction, alive only between `/auth/login` and
185/// `/auth/callback`.
186const TRANSACTION_COOKIE: &str = "beck_login";
187
188/// What this request *claims* to be: the session cookie, then a bearer credential, then
189/// `?actor=alice`, then nothing.
190///
191/// A claim, not an actor. [`crate::identity`] is what turns one into the other, and this function
192/// deliberately does no defaulting: "nobody said" and "somebody said `dev`" are different facts,
193/// and the provider is what decides whether the first is acceptable.
194///
195/// The cookie comes first because it is the one a **browser** sends by itself, and the query
196/// parameter comes last because a credential in a URL is a credential in a log file — it stays
197/// because it is what `beck run` on a laptop has always used, and under a verifying provider it
198/// carries a token nobody would put there.
199fn claimed_actor(headers: &hyper::HeaderMap, query: Option<&str>) -> String {
200    if let Some(cookie) = cookie(headers, SESSION_COOKIE) {
201        return cookie;
202    }
203    if let Some(bearer) = headers
204        .get(hyper::header::AUTHORIZATION)
205        .and_then(|v| v.to_str().ok())
206        .and_then(|v| v.strip_prefix("Bearer "))
207    {
208        return bearer.trim().to_string();
209    }
210    query
211        .and_then(|q| {
212            crate::oidc::query_params(q)
213                .into_iter()
214                .find(|(k, _)| k == "actor")
215                .map(|(_, v)| v)
216        })
217        .unwrap_or_default()
218}
219
220/// One cookie's value out of a `Cookie` header, or nothing.
221fn cookie(headers: &hyper::HeaderMap, name: &str) -> Option<String> {
222    headers
223        .get_all(hyper::header::COOKIE)
224        .iter()
225        .filter_map(|v| v.to_str().ok())
226        .flat_map(|v| v.split(';'))
227        .filter_map(|pair| pair.trim().split_once('='))
228        .find(|(k, _)| *k == name)
229        .map(|(_, v)| v.to_string())
230}
231
232/// A cookie a script cannot read and a cross-site request does not send.
233///
234/// `HttpOnly` because the value is a credential and §5.1's thin client has no reason to read it.
235/// `SameSite=Lax` rather than `Strict` because the identity provider redirects the browser back to
236/// `/auth/callback` and `Strict` would withhold the transaction cookie on exactly that navigation.
237/// `Secure` is **not** set: §6.5's gateway terminates TLS in front of a plaintext hop, so setting
238/// it would make the cookie unusable in the deployment this project generates — the same reason
239/// `same_origin` does not compare schemes, and it is recorded in `docs/48` §48.13 rather than left
240/// to be discovered.
241fn set_cookie(name: &str, value: &str, max_age: i64) -> String {
242    format!("{name}={value}; Path=/; HttpOnly; SameSite=Lax; Max-Age={max_age}")
243}
244
245/// Send the browser somewhere, setting or clearing cookies on the way.
246fn redirect(to: &str, cookies: &[String]) -> Response<Full<Bytes>> {
247    let mut builder = Response::builder()
248        .status(StatusCode::FOUND)
249        .header(hyper::header::LOCATION, to)
250        .header(CACHE_CONTROL, HeaderValue::from_static("no-store"));
251    for cookie in cookies {
252        builder = builder.header(hyper::header::SET_COOKIE, cookie);
253    }
254    builder
255        .body(Full::new(Bytes::new()))
256        .expect("a redirect builds")
257}
258
259/// `/auth/login` — begin the authorization-code flow.
260async fn login(app: Arc<App>, req: &Request<Incoming>) -> Result<Response<Full<Bytes>>> {
261    let Some(party) = app.identity().login() else {
262        return Ok(not_found());
263    };
264    let return_to = req
265        .uri()
266        .query()
267        .and_then(|q| {
268            crate::oidc::query_params(q)
269                .into_iter()
270                .find(|(k, _)| k == "next")
271                .map(|(_, v)| v)
272        })
273        .unwrap_or_else(|| "/".to_string());
274    match party.begin_login(&return_to) {
275        Ok(begun) => Ok(redirect(
276            &begun.url,
277            &[set_cookie(
278                TRANSACTION_COOKIE,
279                &begun.transaction,
280                crate::oidc::LOGIN_WINDOW_MS / 1_000,
281            )],
282        )),
283        Err(why) => {
284            tracing::warn!(why, "a login could not be started");
285            Ok(unavailable())
286        }
287    }
288}
289
290/// `/auth/callback` — the browser is back from the identity provider.
291async fn callback(app: Arc<App>, req: &Request<Incoming>) -> Result<Response<Full<Bytes>>> {
292    if app.identity().login().is_none() {
293        return Ok(not_found());
294    }
295    let transaction = cookie(req.headers(), TRANSACTION_COOKIE).unwrap_or_default();
296    let query = req.uri().query().unwrap_or_default().to_string();
297    // Synchronous, and `beck_core::net::Outbound` is deliberately synchronous too (a tree-walker
298    // cannot await), so the token exchange goes on a blocking thread rather than on a worker
299    // serving pages.
300    let holder = app.clone();
301    let completed = tokio::task::spawn_blocking(move || match holder.identity().login() {
302        Some(party) => party.complete_login(&query, &transaction),
303        None => Err("this process has no relying party".to_string()),
304    })
305    .await;
306
307    match completed {
308        Ok(Ok(done)) => {
309            tracing::info!(actor = %done.verified.subject, "a login completed");
310            let seconds =
311                (done.verified.expires_at_millis - app.clock().now_millis()).max(0) / 1_000;
312            Ok(redirect(
313                &done.return_to,
314                &[
315                    set_cookie(SESSION_COOKIE, &done.id_token, seconds),
316                    // The transaction is spent. Clearing it is not tidiness: a state and a PKCE
317                    // verifier that outlive their one use are a replayable login.
318                    set_cookie(TRANSACTION_COOKIE, "", 0),
319                ],
320            ))
321        }
322        Ok(Err(why)) => {
323            // Specific to the operator, and the browser is told it was refused and nothing else.
324            tracing::warn!(why, "a login did not complete");
325            crate::telemetry::telemetry().unauthenticated.incr();
326            Ok(Response::builder()
327                .status(StatusCode::UNAUTHORIZED)
328                .header(
329                    hyper::header::SET_COOKIE,
330                    set_cookie(TRANSACTION_COOKIE, "", 0),
331                )
332                .body(Full::new(Bytes::from_static(b"unauthenticated")))?)
333        }
334        Err(e) => {
335            tracing::warn!(error = %e, "the token exchange panicked");
336            Ok(unavailable())
337        }
338    }
339}
340
341/// `/auth/logout` — forget the credential.
342///
343/// Local only: it clears this app's cookie and does not call the issuer's end-session endpoint, so
344/// the browser is still signed in to the identity provider and `/auth/login` will complete without
345/// another password. That is the ordinary meaning of "log out of this app" and `docs/48` §48.13 says
346/// so rather than leaving somebody to find out.
347fn logout() -> Response<Full<Bytes>> {
348    redirect(
349        "/",
350        &[
351            set_cookie(SESSION_COOKIE, "", 0),
352            set_cookie(TRANSACTION_COOKIE, "", 0),
353        ],
354    )
355}
356
357fn not_found() -> Response<Full<Bytes>> {
358    Response::builder()
359        .status(StatusCode::NOT_FOUND)
360        .body(Full::new(Bytes::from_static(b"not found")))
361        .expect("static response builds")
362}
363
364fn unavailable() -> Response<Full<Bytes>> {
365    Response::builder()
366        .status(StatusCode::SERVICE_UNAVAILABLE)
367        .body(Full::new(Bytes::from_static(b"identity is unavailable")))
368        .expect("static response builds")
369}
370
371async fn document(app: Arc<App>, req: Request<Incoming>) -> Result<Response<Full<Bytes>>> {
372    // The server-rendered document is a *view*, so it is behind the same question the socket is:
373    // rendering a page for whoever asked would leak exactly what a per-session view exists to keep
374    // separate. Under `DevIdentity` a claim of `dev` is what an unauthenticated laptop gets, and
375    // that default lives in one place.
376    let claimed = claimed_actor(req.headers(), req.uri().query());
377    let claimed = if claimed.is_empty() && !app.identity().verifies() {
378        "dev".to_string()
379    } else {
380        claimed
381    };
382    let actor = match app.identity().verify(&claimed) {
383        Ok(a) => a,
384        Err(why) => {
385            tracing::warn!(
386                reason = why.reason(),
387                "identity refused for a document request"
388            );
389            crate::telemetry::telemetry().unauthenticated.incr();
390            // A provider that can run a login flow sends the browser to it rather than answering
391            // 401: a person who is not signed in has somewhere to go, and a person whose token has
392            // expired has the same somewhere.
393            if app.identity().login().is_some() {
394                return Ok(redirect(LOGIN_PATH, &[set_cookie(SESSION_COOKIE, "", 0)]));
395            }
396            return Ok(Response::builder()
397                .status(StatusCode::UNAUTHORIZED)
398                .body(Full::new(Bytes::from_static(b"unauthenticated")))?);
399        }
400    };
401    let seq = app.head();
402    // The route this document is *of*. First paint is a render of the page at this path, so a deep
403    // link and a reload produce the page the client would have navigated to rather than the root's
404    // page followed by a correction — which is the whole difference between a router and a
405    // redirect.
406    let who = crate::program::At {
407        who: actor,
408        path: std::sync::Arc::from(req.uri().path()),
409    };
410    let body = app.render(&who).await?.render();
411    let actor = &who.who;
412    // The claims go into the document because Mode B's client renders the same view against the
413    // same `Session`, and it has no provider to ask: a client left to fill in a blank map would
414    // show a different page than the one it is hydrating. They are what the server already
415    // verified and already rendered against, so the document is not telling the browser anything
416    // the page it carries does not — and the browser's copy is advice, exactly as its `validate`
417    // is: the server verifies the token again on the socket and every command goes through the
418    // chokepoint there (§3.5).
419    let claims = serde_json::to_string(
420        &actor
421            .claims()
422            .iter()
423            .map(|(k, v)| (k.as_ref(), v.as_ref()))
424            .collect::<std::collections::BTreeMap<&str, &str>>(),
425    )
426    .unwrap_or_else(|_| "{}".into());
427    let actor = beck_core::html::escape_attr(actor.name());
428    let claims = beck_core::html::escape_attr(&claims);
429
430    let html = shell(
431        &app.runtime().placed().program.name,
432        seq,
433        &actor,
434        &claims,
435        &body,
436        // Which residue this page needs is the component's rendering mode, and the server is the
437        // one that knows it: a Mode B document that loaded the thin client would sit waiting for
438        // DOM patches the server is never going to send.
439        match app.runtime().placed().render.mode {
440            beck_core::render::Mode::Server => "/beck-thin.js",
441            beck_core::render::Mode::Client => "/beck-mode-b.js",
442        },
443    );
444    Ok(Response::builder()
445        .header(
446            CONTENT_TYPE,
447            HeaderValue::from_static("text/html; charset=utf-8"),
448        )
449        .header(CACHE_CONTROL, HeaderValue::from_static("no-store"))
450        .body(Full::new(Bytes::from(html)))?)
451}
452
453/// The document around a rendered page: what the browser needs in order to become a client of it.
454///
455/// A function rather than a `format!` inside [`document`] so that the attributes it writes can be
456/// held against the attributes the served JavaScript reads, in both directions
457/// (`the_document_carries_every_attribute_the_residue_reads_off_it`). An attribute a client reads
458/// and this does not write is a page that never connects, and nothing else in this workspace would
459/// say so.
460///
461/// `seq` is the position the page reflects, so the first socket message either finds nothing to do
462/// or is exactly the gap — that is what made hydration free in Phase 0. `actor` and `claims` are
463/// already escaped: they are the identity provider's strings, and the caller is where the value to
464/// escape exists.
465///
466/// `#b-root` is the subscription's frame: a patch path is child indices *from it*, so it has to be
467/// an element of its own rather than the body — whose other children are the two script tags below,
468/// which an insertion at the frame's root would otherwise be counted against.
469fn shell(title: &str, seq: u64, actor: &str, claims: &str, body: &str, client: &str) -> String {
470    format!(
471        "<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\">\
472         <meta name=\"viewport\" content=\"width=device-width,initial-scale=1\">\
473         <title>{title}</title><link rel=\"stylesheet\" href=\"/beck.css\">\
474         </head><body>\
475         <div id=\"b-root\" data-b-seq=\"{seq}\" data-b-actor=\"{actor}\" \
476         data-b-claims=\"{claims}\">{body}</div>\
477         <script src=\"/beck-patch.js\" defer></script>\
478         <script src=\"{client}\" defer></script></body></html>"
479    )
480}
481
482/// Is this upgrade coming from a page the server itself served?
483///
484/// `Origin` is a header a **browser** sets and a script cannot forge, so it answers exactly one
485/// question: is the page asking for this socket the one this server rendered? The check is
486/// `Origin`'s authority against `Host`, and it is what stops a page on any other host opening a
487/// socket to a Beck app with whatever ambient credentials the visitor's browser carries
488/// ([`docs/42`](../../../../../docs/42-security-assurance.md) §42.6, third bullet).
489///
490/// Three decisions, because each could have gone the other way:
491///
492/// * **An absent `Origin` is allowed.** Non-browser clients do not send one — `beck test`, a
493///   script, a load generator — and the attack this defends against needs a browser, which always
494///   sends one. Refusing an absent header would break every non-browser client for no security
495///   gain, since an attacker running their own client is not subject to a browser's rules anyway.
496/// * **The scheme is not compared.** Behind a TLS-terminating gateway — which is what
497///   [`docs/06`](../../../../../docs/06-kubernetes-and-packaging.md) §6.5's HTTPRoute is — the page
498///   is `https://app.example` and the request arriving here is plain HTTP. Comparing schemes would
499///   refuse every deployment this project generates.
500/// * **There is no allowlist.** A Beck app serves its own page (§5.2's first paint), so same-origin
501///   is not a policy choice but a description. A deployment that genuinely needs a cross-origin
502///   client has nothing to configure yet, and §42.6 is where that is recorded.
503///
504/// Takes the headers rather than the request so it can be tested as what it is — a function of two
505/// strings — rather than through a socket.
506pub(crate) fn same_origin(headers: &hyper::HeaderMap) -> bool {
507    let Some(origin) = headers.get(ORIGIN) else {
508        return true;
509    };
510    let Ok(origin) = origin.to_str() else {
511        return false;
512    };
513    // `Origin: null` — a sandboxed iframe or a `file://` page — has no authority and matches
514    // nothing, which is the answer it should get.
515    let authority = origin
516        .split_once("://")
517        .map(|(_, rest)| rest)
518        .unwrap_or(origin);
519    headers
520        .get(hyper::header::HOST)
521        .and_then(|h| h.to_str().ok())
522        .is_some_and(|host| host == authority)
523}
524
525/// What a client may send, in numbers this project chose.
526///
527/// [`docs/42`](../../../../../docs/42-security-assurance.md) §42.6's second bullet: the upgrade
528/// passed `None`, so the limits were tungstenite's defaults — 64 MiB a message and 16 MiB a frame.
529/// Bounded, but by somebody else's judgement. These are the arguments for these numbers:
530///
531/// * **256 KiB a message, and the same a frame.** A client sends two things: a `hello` naming a
532///   subscription and an actor, and a `Cmd` carrying one value of the program's own `union
533///   Command`. The largest field either can hold is text a person typed into a form, and 256 KiB
534///   is around a hundred pages of it. Nothing legitimate approaches it and 64 MiB is 256× further
535///   away.
536/// * **8 KiB of read buffer**, down from 128 KiB. It is **eagerly allocated per connection**, and
537///   §5.3 makes per-subscriber memory a number this project reports rather than hopes about — the
538///   library's own default is tuned for high read load, and a Beck client sends a few hundred
539///   bytes when somebody clicks something.
540/// * **8 MiB of write buffer at most**, down from unbounded. It only grows past
541///   `write_buffer_size` when writes are failing, so this is backpressure against a client that
542///   has stopped reading rather than a limit on what a healthy one is sent.
543///
544/// Outgoing patches are unaffected: `max_message_size` and `max_frame_size` bound what is *read*.
545fn socket_limits() -> WebSocketConfig {
546    WebSocketConfig::default()
547        .read_buffer_size(8 * 1024)
548        .max_write_buffer_size(8 << 20)
549        .max_message_size(Some(256 << 10))
550        .max_frame_size(Some(256 << 10))
551}
552
553fn upgrade(app: Arc<App>, mut req: Request<Incoming>) -> Result<Response<Full<Bytes>>> {
554    if !same_origin(req.headers()) {
555        // Coarse to the caller on purpose, and the same shape `docs/48` §48.2 chose for a refused
556        // identity: a cross-origin page learns that it was refused and nothing about why.
557        return Ok(Response::builder()
558            .status(StatusCode::FORBIDDEN)
559            .body(Full::new(Bytes::from_static(b"forbidden")))?);
560    }
561    let key = req
562        .headers()
563        .get(SEC_WEBSOCKET_KEY)
564        .map(|k| derive_accept_key(k.as_bytes()));
565    let Some(accept) = key else {
566        return Ok(Response::builder()
567            .status(StatusCode::BAD_REQUEST)
568            .body(Full::new(Bytes::from_static(
569                b"expected a websocket upgrade",
570            )))?);
571    };
572
573    // A browser's credential is in a cookie, which the `hello` frame cannot see: the document may
574    // not contain the token, because a script that reads the document reads the token. So the
575    // *upgrade* is where a cookie-carrying connection is identified, and the frame's `actor` is
576    // then not consulted at all (`crate::session::run_as`).
577    //
578    // A connection with no cookie is not refused here — `beck test`, a script and the corpus
579    // harnesses all connect without one — it is passed on as `None` and `session::run_as` asks the
580    // provider about the `hello` frame's claim exactly as before.
581    let verified = match cookie(req.headers(), SESSION_COOKIE) {
582        Some(claim) => match app.identity().verify(&claim) {
583            Ok(actor) => Some(actor),
584            Err(why) => {
585                tracing::warn!(reason = why.reason(), "identity refused at the upgrade");
586                crate::telemetry::telemetry().unauthenticated.incr();
587                return Ok(Response::builder()
588                    .status(StatusCode::UNAUTHORIZED)
589                    .header(hyper::header::SET_COOKIE, set_cookie(SESSION_COOKIE, "", 0))
590                    .body(Full::new(Bytes::from_static(b"unauthenticated")))?);
591            }
592        },
593        None => None,
594    };
595
596    tokio::spawn(async move {
597        match hyper::upgrade::on(&mut req).await {
598            Ok(upgraded) => {
599                let socket = WebSocketStream::from_raw_socket(
600                    TokioIo::new(upgraded),
601                    Role::Server,
602                    Some(socket_limits()),
603                )
604                .await;
605                if let Err(e) = crate::session::run_as(app, socket, verified).await {
606                    tracing::debug!(error = %e, "subscription ended");
607                }
608            }
609            Err(e) => tracing::debug!(error = %e, "upgrade failed"),
610        }
611    });
612
613    Ok(Response::builder()
614        .status(StatusCode::SWITCHING_PROTOCOLS)
615        .header(CONNECTION, HeaderValue::from_static("Upgrade"))
616        .header(UPGRADE, HeaderValue::from_static("websocket"))
617        .header(SEC_WEBSOCKET_ACCEPT, accept)
618        .body(Full::new(Bytes::new()))?)
619}
620
621fn text(s: &str) -> Response<Full<Bytes>> {
622    Response::builder()
623        .header(CONTENT_TYPE, HeaderValue::from_static("text/plain"))
624        .body(Full::new(Bytes::from(s.to_string())))
625        .expect("static response builds")
626}
627
628fn asset(body: &str, mime: &'static str) -> Response<Full<Bytes>> {
629    Response::builder()
630        .header(CONTENT_TYPE, HeaderValue::from_static(mime))
631        .header(
632            CACHE_CONTROL,
633            HeaderValue::from_static("public, max-age=60"),
634        )
635        .body(Full::new(Bytes::from(body.to_string())))
636        .expect("static response builds")
637}
638
639fn bytes(body: Vec<u8>, mime: &'static str) -> Response<Full<Bytes>> {
640    Response::builder()
641        .header(CONTENT_TYPE, HeaderValue::from_static(mime))
642        .header(
643            CACHE_CONTROL,
644            HeaderValue::from_static("public, max-age=60"),
645        )
646        .body(Full::new(Bytes::from(body)))
647        .expect("static response builds")
648}
649
650/// Where the Mode B kernel is looked for.
651///
652/// `BECK_KERNEL` names the module; without it, the path `cargo build -p beck-wasm --release
653/// --target wasm32-unknown-unknown` writes to, relative to the working directory. The kernel is a
654/// *build artefact of this workspace* rather than something compiled into the binary, because
655/// building it needs a target the compiler's own build does not: making `beck` depend on a wasm
656/// toolchain to serve a Mode A page would be the wrong trade.
657pub fn kernel_path() -> std::path::PathBuf {
658    std::env::var_os("BECK_KERNEL").map_or_else(
659        || std::path::PathBuf::from("target/wasm32-unknown-unknown/release/beck_wasm.wasm"),
660        std::path::PathBuf::from,
661    )
662}
663
664/// The kernel, or a refusal that says what to do about it.
665fn kernel() -> Response<Full<Bytes>> {
666    let path = kernel_path();
667    match std::fs::read(&path) {
668        Ok(module) => bytes(module, "application/wasm"),
669        Err(e) => {
670            tracing::error!(path = %path.display(), error = %e, "no Mode B kernel to serve");
671            Response::builder()
672                .status(StatusCode::NOT_FOUND)
673                .body(Full::new(Bytes::from(format!(
674                    "no kernel at {}: build it with `cargo build -p beck-wasm --release \
675                     --target wasm32-unknown-unknown`, or set BECK_KERNEL",
676                    path.display()
677                ))))
678                .expect("static response builds")
679        }
680    }
681}
682
683#[cfg(test)]
684mod tests {
685    /// The frame root the served JavaScript looks for, and the attributes it reads off it.
686    ///
687    /// Both clients open with `document.getElementById("b-root")` and give up if it is missing, so
688    /// a document without it is a page that never connects — and nothing would have said so,
689    /// because no test in this workspace runs JavaScript (`docs/94` §94.15).
690    #[test]
691    fn the_document_carries_the_frame_root_the_residue_looks_for() {
692        for client in [crate::THIN_CLIENT, crate::MODE_B_CLIENT] {
693            assert!(
694                client.contains(r#"getElementById("b-root")"#),
695                "a client that does not look for the frame root"
696            );
697        }
698        for read in ["dataset.bActor", "dataset.bSeq"] {
699            assert!(
700                crate::THIN_CLIENT.contains(read) || crate::MODE_B_CLIENT.contains(read),
701                "nothing reads {read}"
702            );
703        }
704    }
705
706    /// The other direction, which is the one that bites: **every attribute a client reads is one
707    /// the document writes.**
708    ///
709    /// The test above says something reads each attribute somebody thought to list; it cannot fail
710    /// when a client starts reading an attribute the server never writes. That failure is silent —
711    /// `dataset.bClaims` on a document without it is `undefined`, so the Mode B kernel would build
712    /// a `Session` with no claims and refuse commands the server accepts — and it is exactly the
713    /// shape of the defect Mode B's own `#b-root` finding was (`docs/94` §94.15).
714    ///
715    /// So the list is derived from the residue rather than written down: whatever `dataset.bFoo`
716    /// the shipped JavaScript reads, `data-b-foo` has to be in the shell.
717    #[test]
718    fn the_document_carries_every_attribute_the_residue_reads_off_it() {
719        let page = super::shell("t", 7, "ana", "{}", "<p>hi</p>", "/beck-thin.js");
720        let mut checked = 0;
721        for client in [crate::THIN_CLIENT, crate::MODE_B_CLIENT] {
722            for (i, _) in client.match_indices("dataset.b") {
723                let name: String = client[i + "dataset.".len()..]
724                    .chars()
725                    .take_while(|c| c.is_ascii_alphanumeric())
726                    .collect();
727                // `bActor` is `data-b-actor`; a second capital would be a second dash.
728                let mut attr = String::from("data-");
729                for c in name.chars() {
730                    if c.is_ascii_uppercase() {
731                        attr.push('-');
732                        attr.push(c.to_ascii_lowercase());
733                    } else {
734                        attr.push(c);
735                    }
736                }
737                assert!(
738                    page.contains(&format!("{attr}=")),
739                    "the residue reads `dataset.{name}` and the document has no `{attr}`"
740                );
741                checked += 1;
742            }
743        }
744        // A rename that made the loop match nothing would otherwise pass in silence.
745        assert!(checked >= 3, "only {checked} attribute reads were found");
746    }
747
748    use super::*;
749    use hyper::HeaderMap;
750
751    fn headers(pairs: &[(hyper::header::HeaderName, &str)]) -> HeaderMap {
752        let mut h = HeaderMap::new();
753        for (k, v) in pairs {
754            h.insert(k.clone(), HeaderValue::from_str(v).expect("a legal header"));
755        }
756        h
757    }
758
759    #[test]
760    fn a_page_this_server_served_may_open_a_socket() {
761        assert!(same_origin(&headers(&[
762            (ORIGIN, "http://app.example"),
763            (hyper::header::HOST, "app.example"),
764        ])));
765        // …including on a port, which is what `beck run` on a laptop looks like.
766        assert!(same_origin(&headers(&[
767            (ORIGIN, "http://localhost:8080"),
768            (hyper::header::HOST, "localhost:8080"),
769        ])));
770    }
771
772    #[test]
773    fn a_page_on_another_host_may_not() {
774        assert!(!same_origin(&headers(&[
775            (ORIGIN, "https://evil.example"),
776            (hyper::header::HOST, "app.example"),
777        ])));
778        // A different port is a different origin, which is the browser's rule and not ours.
779        assert!(!same_origin(&headers(&[
780            (ORIGIN, "http://app.example:9999"),
781            (hyper::header::HOST, "app.example:8080"),
782        ])));
783        // A prefix is not an authority: `app.example.evil.test` must not pass as `app.example`.
784        assert!(!same_origin(&headers(&[
785            (ORIGIN, "https://app.example.evil.test"),
786            (hyper::header::HOST, "app.example"),
787        ])));
788    }
789
790    /// `Origin: null` is what a sandboxed iframe and a `file://` page send, and it is a value with
791    /// no authority — so it matches no host and is refused rather than treated as absent.
792    #[test]
793    fn a_null_origin_is_refused_rather_than_ignored() {
794        assert!(!same_origin(&headers(&[
795            (ORIGIN, "null"),
796            (hyper::header::HOST, "app.example"),
797        ])));
798    }
799
800    /// The decision that lets every non-browser client keep working.
801    ///
802    /// `beck test`, a script and a load generator send no `Origin`; the attack this defends against
803    /// needs a browser, and a browser always sends one.
804    #[test]
805    fn a_client_that_is_not_a_browser_sends_no_origin_and_is_allowed() {
806        assert!(same_origin(&headers(&[(
807            hyper::header::HOST,
808            "app.example"
809        )])));
810        assert!(same_origin(&HeaderMap::new()));
811    }
812
813    /// The scheme is deliberately not compared: behind a TLS-terminating gateway the page is
814    /// `https://` and the request arriving here is not.
815    #[test]
816    fn the_scheme_is_not_part_of_the_comparison() {
817        assert!(same_origin(&headers(&[
818            (ORIGIN, "https://app.example"),
819            (hyper::header::HOST, "app.example"),
820        ])));
821    }
822
823    /// The numbers, asserted so that changing one is a decision rather than an edit.
824    ///
825    /// `docs/82` §82.3 is the argument for each; this is what stops the file drifting back to
826    /// somebody else's defaults without the argument moving too.
827    #[test]
828    fn the_socket_limits_are_the_numbers_this_project_chose() {
829        let c = socket_limits();
830        assert_eq!(c.max_message_size, Some(256 << 10));
831        assert_eq!(c.max_frame_size, Some(256 << 10));
832        assert_eq!(c.read_buffer_size, 8 * 1024);
833        assert_eq!(c.max_write_buffer_size, 8 << 20);
834        // …and every one of them is tighter than the library's, which is the point.
835        let d = WebSocketConfig::default();
836        assert!(c.max_message_size < d.max_message_size);
837        assert!(c.max_frame_size < d.max_frame_size);
838        assert!(c.read_buffer_size < d.read_buffer_size);
839        assert!(c.max_write_buffer_size < d.max_write_buffer_size);
840    }
841}