lmjtfy.git / apps / lmjtfy / src / lib.rs
lib.rsannotatedlib.rssource1060 lines · 47.6 KB · raw
1//! lmjtfy, the Worker: the page, the stream that answers a question, and the
2//! gate that runs as the visitor types.
3//!
4//! `POST /ask` answers with Datastar events. Each one is the whole transcript
5//! as it stands, so the browser holds no state: it morphs what it is sent.
6//!
7//! What happens to an input is decided by rules (`rules::RULES`). First Jev
8//! is asked, in one request, for every fact the rules want: can it judge the
9//! input, what kind of question is it, is it several, and its answer if it
10//! is one yes-or-no question. That alone ends most queries: a refusal, or a
11//! direct answer. Only a question that needs options or a scale written, or
12//! splitting up, goes to the LLM (Workers AI), and then Jev answers what it
13//! wrote, again in one request. Jev costs a hundredth of what the LLM does.
14//!
15//! No call is sent from here. Each goes to the archive
16//! (`archive.rs`), which returns the response it already holds for that
17//! exact request, or makes the call inside the day's budget and keeps it.
18
19use std::convert::Infallible;
20use std::time::Duration;
21
22use ::archive::{Ask, Called, Pot};
23use ::card::Card;
24use ask::{Judged, Kept, Outcome, Prepared, Wanted};
25use axum::Router;
26use axum::body::Body;
27use axum::extract::{Extension, Json, Path, Query, State};
28use axum::http::{HeaderMap, Response, header};
29use axum::routing::{get, post};
30use datastar::prelude::{ElementPatchMode, PatchElements, PatchSignals};
31use futures_channel::mpsc::{UnboundedSender, unbounded};
32use futures_util::StreamExt;
33use jev_client::Client;
34use jev_protocol::ModelId;
35use jev_worker::{FetchTransport, WorkerRuntime};
36use llm::Model;
37use rules::{Effect, End, Fact, Network, Next, Note, Value, Want};
38use serde::Deserialize;
39use tower_service::Service;
40use worker::{Context, Env, HttpRequest, event};
41
42mod ai;
43pub mod archive;
44mod browse;
45pub mod clone;
46mod diagram;
47pub mod events;
48mod host;
49mod meatproxy;
50pub mod meter;
51mod object;
52mod past;
53mod playground;
54mod release;
55pub mod view;
56
57use view::{Ending, JevCall, LlmCall, LlmOutcome, Tool, View};
58
59const DATASTAR: &str = include_str!("../../../ds-bundle/datastar.js");
60/// The pixel emoji the site draws (the online list's flags, the vote
61/// buttons): SerenityOS's, cut down to those (third-party/serenity-emoji).
62const EMOJI: &[u8] = include_bytes!("../../../third-party/serenity-emoji/emoji.woff2");
63
64/// The commit this Worker was built from (`build.rs`). Pages carry it, and the
65/// archive tells every page that connects what it is now.
66pub const BUILD: &str = env!("LMJTFY_BUILD");
67/// What this deploy changed, for the people on the site: the built commit's
68/// `Release-Note:` trailer, or empty (`build.rs`).
69pub const NOTE: &str = env!("LMJTFY_NOTE");
70
71/// The secret's name: lmjtfy's own Jev key, apart from the estate's general
72/// one, as jevstrudel has its own.
73const KEY: &str = "LMJTFY_TYPESAFE_API_KEY";
74const JEV_MODEL: &str = "JEV_MODEL";
75const LLM_MODEL: &str = "LLM_MODEL";
76const JEV_DOLLARS_PER_DAY: &str = "JEV_DOLLARS_PER_DAY";
77/// What one visitor may do in a minute: full answers, and the facts request
78/// that runs as they type. Counted by the budget object (`meter::admit`).
79/// Cloudflare's own rate limiting binding was tried first and never refused
80/// a request on the live site (2026-10-02: 45 asks in 40 seconds, limit 10).
81const ASKS_PER_MINUTE: u32 = 10;
82const GLANCES_PER_MINUTE: u32 = 60;
83
84#[derive(Clone)]
85struct App {
86    env: Env,
87}
88
89#[event(fetch)]
90async fn fetch(request: HttpRequest, env: Env, context: Context) -> worker::Result<release::Served> {
91    // A release file is answered here, before the router, and as the runtime's
92    // own response, so that its length survives (`release::Served`).
93    let host = request.headers().get(header::HOST).and_then(|host| host.to_str().ok()).unwrap_or_default();
94    if host::Host::of(host).serves_downloads()
95        && matches!(*request.method(), axum::http::Method::GET | axum::http::Method::HEAD)
96        && let Some(route) = release::route(request.uri().path()).filter(release::Route::is_file)
97    {
98        let event = events::of(&request);
99        return Ok(release::file(&env, event, route, request.method(), request.headers()).await);
100    }
101    route_request(request, env, context).await.map(release::Served::Http)
102}
103
104async fn route_request(mut request: HttpRequest, env: Env, context: Context) -> worker::Result<Response<Body>> {
105    // Each host has its own routes (`host.rs`): what an address does not
106    // serve is not there to be reached, rather than refused one by one.
107    let host = request.headers().get(header::HOST).and_then(|host| host.to_str().ok()).unwrap_or_default().to_owned();
108    let which = host::Host::of(&host);
109    let redirect = git_redirect(&env);
110    let state = App { env: env.clone() };
111    let routes = match which {
112        host::Host::Code => code_routes(),
113        // The site itself. With the redirect on, its repositories are at the
114        // code host and the gate below sends every path under one there; a
115        // dev server or staging, which have no code host, and the site
116        // before the redirect is switched on, serve them where they are.
117        host::Host::Home | host::Host::Elsewhere | host::Host::Other => site_routes(which.serves_git(redirect)),
118    };
119    // Release files are the code host's alone (and a dev server's or
120    // staging's, which have no code host): never the site's addresses.
121    let mut router = if which.serves_downloads() { with_downloads(routes) } else { routes }.with_state(state);
122    // The request as an event, before it is known what came of it
123    // (events.rs). A GET's is kept here, once it has a status, after the
124    // response has gone; a POST's by its handler, which knows how it ended.
125    let headers = request.headers().clone();
126    let event = events::of(&request);
127    let target = request.uri().path_and_query().map_or("/", |target| target.as_str());
128    let asked = host::Asked {
129        host: &host,
130        method: request.method(),
131        target,
132        socket: headers.contains_key(header::UPGRADE),
133        own: headers.get("sec-fetch-mode").is_some_and(|mode| mode != "navigate"),
134    };
135    match host::gate(&asked, redirect) {
136        host::Gate::Pass => {}
137        host::Gate::Moved { to, status } => {
138            let mut event = event.named("moved").with(&host);
139            event.status = f64::from(status.as_u16());
140            context.wait_until(async move { events::record(&env, event).await });
141            return Ok(Response::builder()
142                .status(status)
143                .header(header::LOCATION, to)
144                .header(header::CACHE_CONTROL, "public, max-age=86400")
145                .body(Body::empty())
146                .expect("static headers are valid"));
147        }
148        host::Gate::Refused(why) => {
149            let mut response = clone::refused(axum::http::StatusCode::METHOD_NOT_ALLOWED, &why);
150            response.headers_mut().insert(header::ALLOW, axum::http::HeaderValue::from_static("GET, HEAD"));
151            return Ok(response);
152        }
153    }
154    let got = event.clone().got();
155    request.extensions_mut().insert(event);
156    let mut response = router.call(request).await?;
157    if let Some(mut event) = got {
158        event.status = f64::from(response.status().as_u16());
159        // A page given to a browser is a visit, and says when it was counted.
160        let page = response.status().is_success() && response.headers().get(header::CONTENT_TYPE).is_some_and(|kind| kind.as_bytes().starts_with(b"text/html"));
161        if page
162            && let Some(cookie) = event.visit(&headers, js_sys::Date::now()).and_then(|cookie| axum::http::HeaderValue::from_str(&cookie).ok())
163        {
164            response.headers_mut().append(header::SET_COOKIE, cookie);
165        }
166        // Any page a browser is first given gives it its id, before it can
167        // ask anything, and that page's own event is the id's first: until
168        // 2026-10-03 only the home page gave one, and never said which.
169        if page
170            && event.browser.is_empty()
171            && event.client != "bot"
172            && let Some((id, cookie)) = new_browser()
173            && let Ok(cookie) = axum::http::HeaderValue::from_str(&cookie)
174        {
175            response.headers_mut().append(header::SET_COOKIE, cookie);
176            event.browser = id;
177        }
178        context.wait_until(async move { events::record(&env, event).await });
179    }
180    Ok(response)
181}
182
183/// What `lmjtfy.fun` serves. With `git`, the repositories too, as a dev
184/// server and staging do (`host::Host::Other`).
185fn site_routes(git: bool) -> Router<App> {
186    let router = Router::new()
187        .route("/", get(page))
188        .route("/datastar.js", get(datastar))
189        .route("/emoji.woff2", get(emoji))
190        .route("/live.js", get(live_js))
191        .route("/icons/{name}", get(icon))
192        .route("/card.png", get(card))
193        .route(meatproxy::CARD_PATH, get(meatproxy_card))
194        .route("/robots.txt", get(robots))
195        .route("/rules", get(rules_page))
196        .route("/rules.svg", get(rules_svg))
197        .route("/ask", post(ask))
198        .route("/gate", post(gate))
199        .route("/rate", post(rate))
200        .route("/comment", post(comment))
201        .route("/seen", post(seen))
202        .route("/feed", get(feed))
203        .route("/live", get(live));
204    if git { with_repositories(router) } else { router }
205}
206
207/// What `code.lmjtfy.fun` serves: the repositories, a front page listing
208/// them, and the little the code pages draw themselves with. No `/ask`, no
209/// archive, no `/live`, and no `/seen`: the pages there are told not to open
210/// sockets (`Frame::live`).
211fn code_routes() -> Router<App> {
212    with_repositories(
213        Router::new()
214            .route("/", get(clone::index))
215            .route("/emoji.woff2", get(emoji))
216            .route("/icons/{name}", get(icon))
217            .route("/card.png", get(code_card))
218            .route("/robots.txt", get(robots)),
219    )
220}
221
222/// A repository's landing page and the pages under it, and the two requests
223/// git makes.
224fn with_repositories(router: Router<App>) -> Router<App> {
225    router
226        .route("/{repo}", get(clone::landing))
227        .route("/{repo}/", get(clone::landing))
228        .route("/{repo}/{*path}", get(browse::path))
229        .route("/{repo}/info/refs", get(clone::refs))
230        .route("/{repo}/git-upload-pack", post(clone::upload_pack))
231}
232
233/// A repository's release files (`release.rs`): `/<name>/latest/<kind>`,
234/// `/<name>/releases/<tag>/<asset>` and the two pages. Everything under those
235/// two prefixes is the handler's, so what is not a route is its `404`.
236fn with_downloads(router: Router<App>) -> Router<App> {
237    router
238        .route("/{repo}/latest", get(release::serve))
239        .route("/{repo}/latest/", get(release::serve))
240        .route("/{repo}/latest/{*rest}", get(release::serve))
241        .route("/{repo}/releases", get(release::serve))
242        .route("/{repo}/releases/", get(release::serve))
243        .route("/{repo}/releases/{*rest}", get(release::serve))
244}
245
246#[derive(Deserialize)]
247struct Asked {
248    #[serde(default)]
249    q: String,
250    /// Which version of each call the page wants (`archive::Pins`).
251    #[serde(default)]
252    pins: String,
253}
254
255/// What `/card.png` is of: a question, or with `code`, the code.
256#[derive(Deserialize)]
257struct Pictured {
258    #[serde(default)]
259    q: String,
260    code: Option<String>,
261    /// With `code`, which repository: `lmjtfy.git` when absent.
262    repo: Option<String>,
263}
264
265/// `#[worker::send]`: asking the budget object is not `Send`, and an axum
266/// handler's future must be.
267#[worker::send]
268async fn page(State(app): State<App>, headers: HeaderMap, Query(asked): Query<Asked>) -> Response<Body> {
269    let budget = shared(&app.env).await;
270    let home = archive::home(&app.env).await;
271    let typed = ask::clean(&asked.q);
272    // For the link preview: what Jev said, if this was asked before.
273    let said = if typed.is_empty() { None } else { archive::answer(&app.env, &typed).await };
274    let declined = said.is_none() && !typed.is_empty() && archive::was_declined(&app.env, &typed).await;
275    let page = view::page(&typed, said.as_ref(), declined, &origin(&headers), &host_of(&headers).code_origin(&origin(&headers), git_redirect(&app.env)), budget, home.as_ref());
276    respond("text/html; charset=utf-8", "no-cache", page.into_string().into())
277}
278
279/// Whether the old addresses send the code to the code host (`GIT_REDIRECT`,
280/// `host::GitRedirect`). A value that is neither `on` nor `off` is logged as
281/// an error, on every request until it is fixed, and runs as `off`: the
282/// behaviour that cannot break a clone.
283fn git_redirect(env: &Env) -> host::GitRedirect {
284    let value = env.var(host::GitRedirect::VAR).ok().map(|var| var.to_string());
285    let (switch, complaint) = host::GitRedirect::resolve(value.as_deref());
286    if let Some(complaint) = complaint {
287        worker::console_error!("{complaint}");
288    }
289    switch
290}
291
292/// Which of the site's addresses the request came to (`host.rs`).
293fn host_of(headers: &HeaderMap) -> host::Host {
294    host::Host::of(headers.get(header::HOST).and_then(|value| value.to_str().ok()).unwrap_or_default())
295}
296
297/// Where this site is, for the preview's image address, which has to be
298/// whole. From the request, so the dev server previews itself.
299fn origin(headers: &HeaderMap) -> String {
300    let host = headers.get(header::HOST).and_then(|value| value.to_str().ok()).unwrap_or("localhost");
301    let scheme = if host.starts_with("localhost") || host.starts_with("127.") { "http" } else { "https" };
302    format!("{scheme}://{host}")
303}
304
305/// The picture a link unfurls with: the question and what Jev said, read
306/// from the archive. It asks nothing, so a preview bot spends nothing.
307#[worker::send]
308async fn card(State(app): State<App>, headers: HeaderMap, Query(asked): Query<Pictured>) -> Response<Body> {
309    // `?code=<commit>`: the code page's picture. The commit is only there to
310    // change the address; what is drawn is the latest one read.
311    if asked.code.is_some() {
312        let repo = asked.repo.as_deref().and_then(clone::Repo::named).unwrap_or(clone::Repo::Lmjtfy);
313        let latest = clone::latest(&app.env, repo).await;
314        let url = format!("{}/{}", origin(&headers), repo.served());
315        let meta = clone::meta(&app.env, repo).await;
316        let about = meta.as_ref().map(|meta| meta.about()).unwrap_or_default();
317        let card = Card::Code { name: repo.served(), url: &url, recurse: !repo.submodules().is_empty(), about, branch: repo.branch(), latest: latest.as_ref() };
318        return respond("image/png", "public, max-age=3600", ::card::png(card).into());
319    }
320    let typed = ask::clean(&asked.q);
321    let said = if typed.is_empty() { None } else { archive::answer(&app.env, &typed).await };
322    let declined = !typed.is_empty() && said.is_none() && archive::was_declined(&app.env, &typed).await;
323    let said = said.map(|entry| entry.answers).unwrap_or_default();
324    let card = if typed.is_empty() {
325        Card::Home
326    } else if declined {
327        Card::Declined { input: &typed }
328    } else {
329        Card::Question { input: &typed, said: &said }
330    };
331    respond("image/png", "public, max-age=3600", ::card::png(card).into())
332}
333
334/// `/card.png` at the code host: the code's picture only. A question's
335/// reads the archive, which that host does not.
336#[worker::send]
337async fn code_card(state: State<App>, headers: HeaderMap, Query(asked): Query<Pictured>) -> Response<Body> {
338    if asked.code.is_none() {
339        return clone::refused(axum::http::StatusCode::NOT_FOUND, "No such picture.");
340    }
341    card(state, headers, Query(asked)).await
342}
343
344/// `/live`: a page's socket, handed to the archive, which keeps it and
345/// tells it how many pages are open and what happens while it is.
346#[worker::send]
347async fn live(State(app): State<App>, mut request: axum::extract::Request) -> Response<Body> {
348    // Where Cloudflare placed the page, for the online list. The Worker sets
349    // these headers itself, over any the page sent, so a page cannot name its
350    // own place; the archive keeps them on the socket while it is open.
351    // Only a socket is handed on: the archive reads any other request as a
352    // message from this Worker, and a visitor's must never be one.
353    if !request.headers().get(header::UPGRADE).is_some_and(|upgrade| upgrade.as_bytes().eq_ignore_ascii_case(b"websocket")) {
354        return clone::refused(axum::http::StatusCode::UPGRADE_REQUIRED, "Not a socket.");
355    }
356    let place = request.extensions().get::<worker::Cf>().map(|cf| (cf.country(), cf.city()));
357    // Who connected, for the archive to keep (`events`).
358    let event = request.extensions().get::<events::Event>().and_then(|event| serde_json::to_string(event).ok());
359    let headers = request.headers_mut();
360    headers.remove(archive::COUNTRY);
361    headers.remove(archive::CITY);
362    headers.remove(archive::EVENT);
363    if let Some(event) = event.and_then(|event| axum::http::HeaderValue::from_str(&view::url_encoded(&event)).ok()) {
364        headers.insert(archive::EVENT, event);
365    }
366    headers.insert(archive::PLACED, axum::http::HeaderValue::from_static("1"));
367    if let Some((country, city)) = place {
368        for (name, value) in [(archive::COUNTRY, country), (archive::CITY, city)] {
369            if let Some(value) = value.and_then(|value| axum::http::HeaderValue::from_str(&view::url_encoded(&value)).ok()) {
370                headers.insert(name, value);
371            }
372        }
373    }
374    let request = match worker::Request::try_from(request) {
375        Ok(request) => request,
376        Err(_) => return clone::refused(axum::http::StatusCode::BAD_REQUEST, "Not a socket."),
377    };
378    match archive::live(&app.env, request).await {
379        Ok(response) => response.into(),
380        Err(_) => clone::refused(axum::http::StatusCode::BAD_GATEWAY, "The archive is unreachable."),
381    }
382}
383
384/// What is left of the day's shared budgets, for the page.
385async fn shared(env: &Env) -> Option<view::Shared> {
386    let status = meter::status(env).await?;
387    let jev_per_day = env.var(JEV_DOLLARS_PER_DAY).ok()?.to_string().parse().ok()?;
388    Some(view::Shared { status, neurons_per_day: llm::FREE_NEURONS_PER_DAY, jev_dollars_per_day: jev_per_day })
389}
390
391/// The rules as an SVG file, for the READMEs.
392async fn rules_svg() -> Response<Body> {
393    respond("image/svg+xml", "public, max-age=3600", diagram::standalone(&Network::lmjtfy()).into())
394}
395
396/// What crawlers are asked to leave alone. `/rules` with facts set is one
397/// page per combination of facts, each linking to more: on 2026-10-03 one
398/// crawler asked for 43,440 of them in a day. The page itself is welcome.
399/// Cloudflare puts its own lines above these.
400const ROBOTS: &str = "User-agent: *\nDisallow: /rules?\n";
401
402async fn robots() -> Response<Body> {
403    respond("text/plain; charset=utf-8", "public, max-age=3600", ROBOTS.into())
404}
405
406async fn datastar() -> Response<Body> {
407    respond("text/javascript; charset=utf-8", "public, max-age=86400", DATASTAR.into())
408}
409
410/// The SharedWorker that holds one `/live` socket for a browser's tabs. Its
411/// address carries the build, so a deploy's pages get a new one; the script
412/// itself does not change with it, so it may be kept.
413async fn live_js() -> Response<Body> {
414    respond("text/javascript; charset=utf-8", "public, max-age=3600", include_str!("live.js").into())
415}
416
417mod pictures {
418    include!(concat!(env!("OUT_DIR"), "/icons.rs"));
419}
420
421/// `/icons/<name>.png`: one of the explorer's pixel icons
422/// (third-party/jerrys-pixel-icons), from the table `build.rs` writes. They
423/// do not change under a name, so a browser may keep one for good.
424async fn icon(Path(name): Path<String>) -> Response<Body> {
425    let found = name.strip_suffix(".png").and_then(|name| pictures::ICONS.binary_search_by_key(&name, |(icon, _)| *icon).ok());
426    match found {
427        Some(at) => respond("image/png", "public, max-age=31536000, immutable", Body::from(pictures::ICONS[at].1)),
428        None => clone::refused(axum::http::StatusCode::NOT_FOUND, "No such icon."),
429    }
430}
431
432/// `/meatproxy/card`: the card image of the site the LLM suggestion links
433/// to, from here so the visitor's browser never asks that site for it.
434/// Nothing in the request is read.
435#[worker::send]
436async fn meatproxy_card() -> Response<Body> {
437    meatproxy::image().await
438}
439
440async fn emoji() -> Response<Body> {
441    respond("font/woff2", "public, max-age=604800", Body::from(EMOJI))
442}
443
444/// The rules with the facts the link sets. Nothing is asked of anyone.
445async fn rules_page(Query(params): Query<Vec<(String, String)>>) -> Response<Body> {
446    let network = Network::lmjtfy();
447    let play = playground::play(&network, &params);
448    let says = playground::says(&network, &play);
449    let page = view::playground(&network, &play.known, &play.fired, &says, |clicked| playground::link(&play.known, clicked));
450    respond("text/html; charset=utf-8", "no-cache", page.into_string().into())
451}
452
453/// Who is asking, for the visitor limit: Cloudflare's own header for the
454/// visitor's address, a key in the budget object's memory for a minute.
455fn visitor(headers: &HeaderMap) -> String {
456    headers.get("cf-connecting-ip").and_then(|value| value.to_str().ok()).unwrap_or("unknown").to_owned()
457}
458
459/// Datastar posts the page's signals as JSON; `q` is the search box.
460async fn ask(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(asked): Json<Asked>) -> Response<Body> {
461    let visitor = visitor(&headers);
462    let browser = browser(&headers);
463    let pins = ::archive::Pins::parse(&asked.pins);
464    stream(move |events| answer(app, asked.q, pins, visitor, browser, event.named("answer"), events))
465}
466
467/// A vote on Jev's answer: the search box still holds the question, and
468/// the button sets which way.
469#[derive(Deserialize)]
470struct Rated {
471    #[serde(default)]
472    q: String,
473    vote: String,
474}
475
476/// How many votes a visitor may cast a minute.
477const VOTES_PER_MINUTE: u32 = 30;
478
479/// `/rate`: a browser's vote on Jev's answer, and the votes after it, as the
480/// rating element. A browser with no id cannot vote; a question never
481/// answered has nothing to vote on.
482#[worker::send]
483async fn rate(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(rated): Json<Rated>) -> Response<Body> {
484    let input = ask::clean(&rated.q);
485    let browser = browser(&headers);
486    let vote = match rated.vote.as_str() {
487        "up" => Some(::archive::Vote::Up),
488        "down" => Some(::archive::Vote::Down),
489        _ => None,
490    };
491    let rating = match (&browser, vote) {
492        (Some(browser), Some(vote)) if !input.is_empty() && meter::admit(&app.env, "rate", visitor(&headers), VOTES_PER_MINUTE).await => {
493            archive::rate(&app.env, &input, browser, vote).await
494        }
495        _ => archive::rating(&app.env, &input, browser.as_deref().map(|browser| asker(browser, &input))).await,
496    };
497    events::record(&app.env, event.named("vote").with(rated.vote.chars().take(8).collect::<String>()).about(&input)).await;
498    let patch = PatchElements::new(view::rating(rating.as_ref(), browser.is_some()).into_string()).into_datastar_event();
499    // The comment box is open while there is a vote to comment on. A vote
500    // changed leaves what was typed, and it may be saved again for the new vote.
501    let open = PatchSignals::new(serde_json::json!({ "open": rating.is_some_and(|rating| rating.mine.is_some()), "saved": false }).to_string()).into_datastar_event();
502    respond("text/event-stream", "no-cache", Body::from(format!("{patch}{open}")))
503}
504
505/// A comment on the vote: the page's signals, `q` the search box and
506/// `comment` the textarea.
507#[derive(Deserialize)]
508struct Commented {
509    #[serde(default)]
510    q: String,
511    #[serde(default)]
512    comment: String,
513}
514
515/// How many comments a visitor may save a minute: fewer than votes, since
516/// each one is free text kept for the owner to read.
517const COMMENTS_PER_MINUTE: u32 = 5;
518
519/// `/comment`: more words on the browser's standing vote, kept beside it
520/// (`archive::comment`). Answered with the page's notes: thanks, or why not
521/// (fixed words), and never the text. What the visitor typed is only ever
522/// handed to the archive: it is in no log line and no event, which keep how
523/// it ended (`saved`, `empty`, ...) and nothing else.
524#[worker::send]
525async fn comment(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(said): Json<Commented>) -> Response<Body> {
526    use ::archive::Unsaid;
527    let input = ask::clean(&said.q);
528    let browser = browser(&headers);
529    let outcome = match (&browser, ::archive::clean_comment(&said.comment)) {
530        _ if !meter::admit(&app.env, "comment", visitor(&headers), COMMENTS_PER_MINUTE).await => Err(Unsaid::Limit),
531        (_, Err(unsaid)) => Err(unsaid),
532        (None, _) => Err(Unsaid::NoVote),
533        (_, Ok(_)) if input.is_empty() => Err(Unsaid::NoVote),
534        (Some(browser), Ok(clean)) => archive::comment(&app.env, &input, browser, clean).await,
535    };
536    let kept = match outcome {
537        Ok(()) => "saved",
538        Err(Unsaid::Empty) => "empty",
539        Err(Unsaid::TooLong) => "too-long",
540        Err(Unsaid::NoVote) => "no-vote",
541        Err(Unsaid::Limit) => "limit",
542        Err(Unsaid::Failed) => "failed",
543    };
544    events::record(&app.env, event.named("comment").with(kept).about(&input)).await;
545    let note = PatchElements::new(view::comment_error(outcome.err()).into_string()).into_datastar_event();
546    let saved = PatchSignals::new(serde_json::json!({ "saved": outcome.is_ok() }).to_string()).into_datastar_event();
547    respond("text/event-stream", "no-cache", Body::from(format!("{note}{saved}")))
548}
549
550/// How many reports a visitor's pages may make a minute.
551const REPORTS_PER_MINUTE: u32 = 120;
552
553/// `/seen`: a page's report of itself as it is left, or as a link off the
554/// site is followed (`page.js`): how long it was in view, how far down, the
555/// screen. Kept as a `read` or `out` event; the page is told nothing.
556#[worker::send]
557async fn seen(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, body: String) -> Response<Body> {
558    if let Ok(report) = serde_json::from_str::<::archive::Report>(&body)
559        && meter::admit(&app.env, "seen", visitor(&headers), REPORTS_PER_MINUTE).await
560    {
561        events::record(&app.env, event.seen(&report)).await;
562    }
563    Response::builder().status(axum::http::StatusCode::NO_CONTENT).body(Body::empty()).expect("no headers to be wrong")
564}
565
566/// The cookie that tells one browser from another, for counting each once
567/// per question. A random id the Worker gives a browser on its first page,
568/// kept a year. `askers` and `ratings` get only `asker` of it; `events`
569/// keeps it as it is, which is what ties one browser's rows together.
570const BROWSER: &str = "lmjtfy_browser";
571
572/// This browser's id, if it has one.
573fn browser(headers: &HeaderMap) -> Option<String> {
574    let cookies = headers.get(header::COOKIE)?.to_str().ok()?;
575    cookies
576        .split(';')
577        .filter_map(|pair| pair.trim().split_once('='))
578        .find(|(name, _)| *name == BROWSER)
579        .map(|(_, id)| id.to_owned())
580        .filter(|id| id.len() == 36 && id.bytes().all(|b| b.is_ascii_hexdigit() || b == b'-'))
581}
582
583/// A new browser id, as a `Set-Cookie` value, for a browser that has none.
584fn new_browser() -> Option<(String, String)> {
585    use wasm_bindgen::JsCast;
586    let crypto = js_sys::Reflect::get(&js_sys::global(), &"crypto".into()).ok()?;
587    let uuid = js_sys::Reflect::get(&crypto, &"randomUUID".into()).ok()?.dyn_into::<js_sys::Function>().ok()?.call0(&crypto).ok()?.as_string()?;
588    let cookie = format!("{BROWSER}={uuid}; Max-Age=31536000; Path=/; HttpOnly; Secure; SameSite=Lax");
589    Some((uuid, cookie))
590}
591
592/// The browser for one question: its id and the question, hashed together,
593/// so the archive can tell a browser asking again from a new one and can link
594/// nothing else.
595pub(crate) fn asker(browser: &str, input: &str) -> ::archive::Asker {
596    use sha2::{Digest, Sha256};
597    let digest = Sha256::new().chain_update(browser.as_bytes()).chain_update([0]).chain_update(input.as_bytes()).finalize();
598    ::archive::Asker(digest.iter().map(|byte| format!("{byte:02x}")).collect())
599}
600
601/// Where "asked lately" was scrolled to: the last line shown.
602#[derive(Deserialize)]
603struct Scrolled {
604    ms: f64,
605    q: String,
606}
607
608/// `/feed`: the page of "asked lately" after the last line shown, put before
609/// the feed's end, and a new end. It reads the archive and asks nothing.
610#[worker::send]
611async fn feed(State(app): State<App>, Query(scrolled): Query<Scrolled>) -> Response<Body> {
612    let after = ::archive::Cursor { asked_ms: scrolled.ms, input: scrolled.q };
613    // An archive that cannot be read ends the feed here, rather than asking
614    // again for as long as the end is in view.
615    let entries = archive::older(&app.env, after).await.unwrap_or_default();
616    let (lines, end) = view::older(&entries);
617    let end = PatchElements::new(end.into_string()).selector("#more").mode(ElementPatchMode::Replace);
618    let mut body = String::new();
619    if !entries.is_empty() {
620        let lines = PatchElements::new(lines.into_string()).selector("#more").mode(ElementPatchMode::Before);
621        body.push_str(&lines.into_datastar_event().to_string());
622    }
623    body.push_str(&end.into_datastar_event().to_string());
624    respond("text/event-stream", "no-cache", Body::from(body))
625}
626
627/// The same signals, while the visitor is still typing: the facts alone.
628async fn gate(State(app): State<App>, Extension(event): Extension<events::Event>, headers: HeaderMap, Json(asked): Json<Asked>) -> Response<Body> {
629    let visitor = visitor(&headers);
630    stream(move |events| glance(app, asked.q, visitor, event.named("gate"), events))
631}
632
633/// An event stream fed by `work`. Jev and Workers AI calls are not `Send`
634/// (they hold JS values) and an axum handler must be, so the work runs on the
635/// isolate's own executor and reaches the response through a channel. The
636/// open response body is what keeps the request alive while it runs.
637fn stream<W, F>(work: W) -> Response<Body>
638where
639    W: FnOnce(UnboundedSender<String>) -> F,
640    F: Future<Output = ()> + 'static,
641{
642    let (events, stream) = unbounded::<String>();
643    wasm_bindgen_futures::spawn_local(work(events));
644    respond("text/event-stream", "no-cache", Body::from_stream(stream.map(Ok::<_, Infallible>)))
645}
646
647fn respond(content_type: &'static str, cache: &'static str, body: Body) -> Response<Body> {
648    Response::builder()
649        .header(header::CONTENT_TYPE, content_type)
650        .header(header::CACHE_CONTROL, cache)
651        .body(body)
652        .expect("static headers are valid")
653}
654
655/// Sends markup to morph into the page. A closed channel means the visitor
656/// left; there is nobody to tell.
657fn patch(events: &UnboundedSender<String>, markup: maud::Markup) {
658    let event = PatchElements::new(markup.into_string()).into_datastar_event();
659    let _ = events.unbounded_send(event.to_string());
660}
661
662fn show(events: &UnboundedSender<String>, view: &View) {
663    patch(events, view::transcript(view));
664}
665
666/// Everything a Jev call needs, built once per request.
667pub(crate) struct Jev {
668    pub(crate) client: Client<FetchTransport, WorkerRuntime>,
669    pub(crate) model: ModelId,
670    pub(crate) dollars_per_day: f64,
671}
672
673/// Why no Jev call can be made at all.
674pub(crate) enum NoJev {
675    /// The Worker has no API key.
676    Offline,
677    Broken(String),
678}
679
680impl From<NoJev> for Ending {
681    fn from(none: NoJev) -> Self {
682        match none {
683            NoJev::Offline => Ending::Offline,
684            NoJev::Broken(error) => Ending::Failed { error },
685        }
686    }
687}
688
689/// The Jev client. The Worker builds one to prepare requests and to know the
690/// site is online; the archive builds one to send them.
691pub(crate) fn jev(env: &Env) -> Result<Jev, NoJev> {
692    let key = env.secret(KEY).map(|secret| secret.to_string()).unwrap_or_default();
693    if key.trim().is_empty() {
694        return Err(NoJev::Offline);
695    }
696    let var = |name: &str| env.var(name).map(|var| var.to_string()).map_err(|e| NoJev::Broken(format!("{name}: {e}")));
697    let model = ModelId::pinned(&var(JEV_MODEL)?).map_err(|e| NoJev::Broken(e.to_string()))?;
698    let dollars_per_day: f64 = var(JEV_DOLLARS_PER_DAY)?
699        .parse()
700        .map_err(|_| NoJev::Broken(format!("{JEV_DOLLARS_PER_DAY} is not a number")))?;
701    let client = Client::new(FetchTransport::api(), WorkerRuntime, model.clone(), key.trim())
702        .map_err(|e| NoJev::Broken(e.to_string()))?;
703    Ok(Jev { client, model, dollars_per_day })
704}
705
706fn spent(pot: Pot) -> Ending {
707    Ending::Spent {
708        what: match pot {
709            Pot::Jev => "Jev",
710            Pot::Llm => "the LLM",
711        },
712    }
713}
714
715/// Jev's answer to a prepared call, from the archive: the response it holds
716/// for this exact request, or the one it gets now. `Err` means the call was
717/// never made, so the page must not show it.
718///
719/// Which kept response, or whether to send again, is what the page's pins
720/// say for this request (`archive::Pins::pick`); the version it got comes
721/// back beside the answer.
722async fn judged_by_jev(
723    env: &Env,
724    jev: &Jev,
725    input: &str,
726    wanted: Wanted,
727    prepared: &Prepared,
728    pins: &::archive::Pins,
729) -> Result<(Outcome, Option<view::Versions>), Ending> {
730    let id = ::archive::call_id(jev_protocol::ENDPOINT, &prepared.request);
731    let ask = Ask::Jev { input: input.to_owned(), wanted, request: prepared.request.clone(), pick: pins.pick(&id) };
732    match archive::call(env, ask).await {
733        Ok(Called::Answered(record)) => Ok((
734            ask::read(
735            &jev.model,
736            prepared,
737            Kept {
738                body: &record.response,
739                sent: record.sent(),
740                took: Duration::from_secs_f64(record.took_ms / 1000.0),
741                attempts: record.attempts,
742                request_id: record.request_id,
743            },
744            ),
745            Some(view::Versions { id, version: record.version, versions: record.versions }),
746        )),
747        Ok(Called::Failed { error, request_id, took_ms }) => {
748            Ok((Outcome::Failed { error, request_id, took: Duration::from_secs_f64(took_ms / 1000.0), dollars: 0.0 }, None))
749        }
750        Ok(Called::Spent(pot)) => Err(spent(pot)),
751        Ok(Called::NotKept) => Err(Ending::NotKept { id }),
752        Err(error) => Err(Ending::Failed { error }),
753    }
754}
755
756/// Asks Jev for `facts` about the input, all in one request, and records
757/// what they turned out to be. This is the rules' backfill: every Jev fact
758/// any live rule is waiting on, together.
759async fn learn(
760    env: &Env,
761    jev: &Jev,
762    view: &mut View,
763    facts: Vec<Fact>,
764    events: Option<&UnboundedSender<String>>,
765) -> Result<(), Ending> {
766    let failed = |error: String| Ending::Failed { error };
767    let wanted = Wanted::Facts(facts.clone());
768    let prepared = ask::wanted(&jev.model, &view.input, &wanted).map_err(|e| failed(e.to_string()))?;
769    view.facts = Some(JevCall::pending(&prepared));
770    if let Some(events) = events {
771        show(events, view);
772    }
773    let outcome = match judged_by_jev(env, jev, &view.input, wanted, &prepared, &view.pins).await {
774        Ok((outcome, kept)) => {
775            view.facts.as_mut().expect("just set").kept = kept;
776            outcome
777        }
778        Err(ending) => {
779            view.facts = None;
780            return Err(ending);
781        }
782    };
783    // Several facts can be read from one answer: each finds its question's.
784    let learned = match &outcome {
785        Outcome::Answered { judged, .. } => facts
786            .iter()
787            .map(|fact| {
788                let id = ask::question_id(*fact);
789                prepared
790                    .parts
791                    .iter()
792                    .position(|part| part.id == id)
793                    .and_then(|index| judged.get(index))
794                    .and_then(|judged| ask::learned(*fact, judged))
795                    .map(|value| (*fact, value))
796                    .ok_or_else(|| failed(format!("Jev's answer for {} is not the type that was asked", fact.name())))
797            })
798            .collect::<Result<Vec<_>, _>>(),
799        Outcome::Failed { error, .. } => Err(failed(error.clone())),
800    };
801    view.facts.as_mut().expect("just set").outcome = Some(outcome);
802    for (fact, value) in learned? {
803        view.known.learn(fact, value);
804    }
805    Ok(())
806}
807
808/// Jev's probability that it can judge the input, once the facts are in.
809fn answerable(view: &View) -> Option<f64> {
810    match view.facts.as_ref()?.judged(ask::question_id(Fact::Answerable))? {
811        Judged::Noul(p_yes) => Some(*p_yes),
812        _ => None,
813    }
814}
815
816async fn answer(
817    app: App,
818    input: String,
819    pins: ::archive::Pins,
820    visitor: String,
821    browser: Option<String>,
822    mut event: events::Event,
823    events: UnboundedSender<String>,
824) {
825    let began = js_sys::Date::now();
826    let mut view = View::new(ask::clean(&input));
827    let browsing = pins.browsing();
828    view.pins = pins;
829    // Every ask counts towards the visitor's limit, an empty one too.
830    view.ending = Some(if !meter::admit(&app.env, "ask", visitor, ASKS_PER_MINUTE).await {
831        Ending::Slow
832    } else if view.input.is_empty() {
833        Ending::Empty
834    } else {
835        decide(&app.env, &mut view, &events).await
836    });
837    // The card of the suggestion's site, for the endings that make one.
838    if matches!(view.ending, Some(Ending::NotAQuestion { .. } | Ending::NoQuestion)) {
839        view.preview = meatproxy::head().await;
840        // So a link to this question can say what it is (`Card::Declined`).
841        archive::declined(&app.env, &view.input).await;
842    }
843    show(&events, &view);
844    // What was asked, how it ended and what it took. An old version looked
845    // at is an ask all the same, and says so.
846    let (sent, kept) = view.calls();
847    event.detail = if browsing { format!("{} (browsing)", events::ending(view.ending.as_ref())) } else { events::ending(view.ending.as_ref()).to_owned() };
848    (event.sent, event.kept) = (f64::from(sent), f64::from(kept));
849    event.llm = if view.llm.is_some() { 1.0 } else { 0.0 };
850    event.took_ms = js_sys::Date::now() - began;
851    events::record(&app.env, event.about(&view.input)).await;
852    // The page keeps the versions it is looking at, and no others.
853    let pinned = PatchSignals::new(serde_json::json!({ "pins": view.pinned(), "comment": "", "open": false, "saved": false }).to_string()).into_datastar_event();
854    let _ = events.unbounded_send(pinned.to_string());
855    // The budget and the feed are the home page's, and it is hidden while an
856    // answer is up (page.css), so neither is sent again here. An old version
857    // looked at is not what Jev says now, so it is not kept as the answer.
858    if matches!(view.ending, Some(Ending::NotAQuestion { .. } | Ending::NoQuestion)) {
859        // Declined was kept before the stream was shown, so there is a
860        // record to vote on (`archive::DECLINED`).
861        let who = browser.as_deref().map(|browser| asker(browser, &view.input));
862        let rating = archive::rating(&app.env, &view.input, who).await;
863        patch(&events, view::rating(rating.as_ref(), browser.is_some()));
864        let open = PatchSignals::new(serde_json::json!({ "open": rating.is_some_and(|rating| rating.mine.is_some()) }).to_string()).into_datastar_event();
865        let _ = events.unbounded_send(open.to_string());
866    }
867    if matches!(view.ending, Some(Ending::Answered)) && !browsing {
868        // The rules say whether it may be shown: Jev judged it fit.
869        let listed = Network::lmjtfy().notes(&view.known, Note::List);
870        let who = browser.as_deref().map(|browser| asker(browser, &view.input));
871        archive::asked(&app.env, &view.input, view.said(), view.llm.is_some(), listed, browser.as_deref()).await;
872        // The votes on the answer as it is now kept.
873        let rating = archive::rating(&app.env, &view.input, who).await;
874        patch(&events, view::rating(rating.as_ref(), browser.is_some()));
875        // A vote already cast on this answer has its box open.
876        let open = PatchSignals::new(serde_json::json!({ "open": rating.is_some_and(|rating| rating.mine.is_some()) }).to_string()).into_datastar_event();
877        let _ = events.unbounded_send(open.to_string());
878    }
879}
880
881/// Does what the rules say until they say the query has ended. The rules
882/// (`rules::RULES`) decide the order; this only carries each step out and
883/// records what it taught.
884async fn decide(env: &Env, view: &mut View, events: &UnboundedSender<String>) -> Ending {
885    let jev = match jev(env) {
886        Ok(jev) => jev,
887        Err(none) => return none.into(),
888    };
889    let network = Network::lmjtfy();
890    loop {
891        let (by, next) = network.decide(&view.known);
892        view.fired.extend(by);
893        let step = match next {
894            Next::Ask(facts) => learn(env, &jev, view, facts, Some(events)).await,
895            // One call writes everything the drafting rules that hold want.
896            Next::Do(Effect::Draft(_)) => drafted(env, view, &network.wants(&view.known), events).await,
897            Next::Do(Effect::Judge) => judge(env, &jev, view, events).await,
898            Next::End(End::NotAQuestion) => {
899                return match answerable(view) {
900                    Some(p_yes) => Ending::NotAQuestion { p_yes },
901                    None => Ending::Failed { error: "the rules refused an input Jev was not asked about".into() },
902                };
903            }
904            // Nothing is left to do. What to show is whatever the rules noted.
905            Next::Done if view.answered() => return Ending::Answered,
906            Next::Done => return Ending::NoQuestion,
907        };
908        if let Err(ending) = step {
909            return ending;
910        }
911        show(events, view);
912    }
913}
914
915/// Asks Jev every question the LLM wrote, in one request.
916async fn judge(env: &Env, jev: &Jev, view: &mut View, events: &UnboundedSender<String>) -> Result<(), Ending> {
917    let drafts: Vec<(String, llm::Draft)> =
918        view.tools.iter().filter_map(|tool| Some((tool.id.clone(), tool.draft()?.clone()))).collect();
919    let wanted = Wanted::Drafted(drafts);
920    let prepared =
921        ask::wanted(&jev.model, &view.input, &wanted).map_err(|e| Ending::Failed { error: e.to_string() })?;
922    view.judging = Some(JevCall::pending(&prepared));
923    show(events, view);
924    match judged_by_jev(env, jev, &view.input, wanted, &prepared, &view.pins).await {
925        Ok((outcome, kept)) => {
926            let judging = view.judging.as_mut().expect("just set");
927            judging.outcome = Some(outcome);
928            judging.kept = kept;
929        }
930        Err(ending) => {
931            view.judging = None;
932            return Err(ending);
933        }
934    }
935    view.known.learn(Fact::Judged, Value::Bool(view.any_judged()));
936    Ok(())
937}
938
939/// The LLM's questions for the input, from the archive, as the tool calls
940/// it made. A call Jev's protocol would refuse is kept, marked, and not sent.
941async fn drafted(env: &Env, view: &mut View, wants: &[Want], events: &UnboundedSender<String>) -> Result<(), Ending> {
942    let failed = |error: String| Ending::Failed { error };
943    let id = env.var(LLM_MODEL).map(|var| var.to_string()).map_err(|e| failed(format!("{LLM_MODEL}: {e}")))?;
944    let model = Model::find(&id).ok_or_else(|| failed(format!("{id} is not one of llm's candidates")))?;
945    let request = llm::request(&view.input, wants);
946    view.llm = Some(LlmCall { model: model.id, request: request.clone(), outcome: None, kept: None });
947    show(events, view);
948
949    let id = ::archive::call_id(model.id, &request);
950    let pick = view.pins.pick(&id);
951    let record = match archive::call(env, Ask::Llm { model: model.id.to_owned(), request, pick }).await {
952        Ok(Called::Answered(record)) => record,
953        Ok(Called::Failed { error, took_ms, .. }) => {
954            view.llm.as_mut().expect("just set").outcome = Some(LlmOutcome {
955                body: error.clone(),
956                neurons: 0.0,
957                took: Duration::from_secs_f64(took_ms / 1000.0),
958                dropped: 0,
959                sent: ask::Sent::Now,
960            });
961            return Err(failed(format!("the LLM: {error}")));
962        }
963        Ok(Called::Spent(pot)) => {
964            view.llm = None;
965            return Err(spent(pot));
966        }
967        Ok(Called::NotKept) => {
968            view.llm = None;
969            return Err(Ending::NotKept { id });
970        }
971        Err(error) => {
972            view.llm = None;
973            return Err(failed(error));
974        }
975    };
976    let parsed = llm::parse(&record.response);
977    view.llm.as_mut().expect("just set").kept = Some(view::Versions { id, version: record.version, versions: record.versions });
978    view.llm.as_mut().expect("just set").outcome = Some(LlmOutcome {
979        neurons: parsed.as_ref().map_or(0.0, |reply| model.neurons(reply.usage)),
980        took: Duration::from_secs_f64(record.took_ms / 1000.0),
981        dropped: parsed.as_ref().map_or(0, |reply| reply.dropped),
982        sent: record.sent(),
983        body: record.response,
984    });
985    let reply = parsed.map_err(|error| failed(format!("the LLM: {error}")))?;
986    view.tools = reply
987        .calls
988        .into_iter()
989        .enumerate()
990        .map(|(index, call)| {
991            let refused = call.draft.as_ref().ok().and_then(|draft| {
992                if !llm::takes(wants, draft) {
993                    // Jev has answered that reading itself, or the rules did not take it.
994                    return Some(format!("a {} was not what the rules asked the LLM for", draft.tool()));
995                }
996                ask::check(draft).err().map(|e| e.to_string())
997            });
998            // q1, q2, ...: the question's id in its request and on the page.
999            Tool { id: format!("q{}", index + 1), call, refused }
1000        })
1001        .collect();
1002    let any = view.tools.iter().any(|tool| tool.draft().is_some());
1003    view.known.learn(Fact::Drafted, Value::Bool(any));
1004    Ok(())
1005}
1006
1007/// The facts alone, for the line under the search box. It is the same
1008/// request `/ask` begins with, so what was typed is already kept by the time
1009/// it is asked.
1010async fn glance(app: App, input: String, visitor: String, event: events::Event, events: UnboundedSender<String>) {
1011    let mut view = View::new(ask::clean(&input));
1012    let admitted = !view.input.is_empty() && meter::admit(&app.env, "glance", visitor, GLANCES_PER_MINUTE).await;
1013    // What the feed has that this could be the start of, first: it asks
1014    // nobody anything, so it is there before Jev has answered. With
1015    // nothing typed, or nothing found, the list is emptied.
1016    let suggested = if admitted { archive::suggest(&app.env, &view.input).await } else { Vec::new() };
1017    patch(&events, view::suggested(&suggested));
1018    let seen = if !admitted {
1019        None
1020    } else {
1021        match (jev(&app.env), Network::lmjtfy().next(&view.known)) {
1022            (Ok(jev), Next::Ask(facts)) => learn(&app.env, &jev, &mut view, facts, None).await.ok(),
1023            _ => None,
1024        }
1025    };
1026    let (sent, kept) = view.calls();
1027    let mut event = event.with(if seen.is_some() { "seen" } else { "" });
1028    (event.sent, event.kept) = (f64::from(sent), f64::from(kept));
1029    events::record(&app.env, event.about(&view.input)).await;
1030    let glance = seen.and_then(|()| answerable(&view)).map(|answerable| view::Glance { answerable, kind: view.reading(), unfinished: Network::lmjtfy().notes(&view.known, Note::Unfinished) });
1031    patch(&events, view::live(glance));
1032}
1033
1034#[cfg(test)]
1035mod tests {
1036    use super::*;
1037
1038    #[test]
1039    fn a_browser_is_the_same_asker_of_one_question_and_unrelated_across_two() {
1040        let id = "0b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b";
1041        assert_eq!(asker(id, "is it?"), asker(id, "is it?"));
1042        assert_ne!(asker(id, "is it?"), asker(id, "is it not?"));
1043        assert_ne!(asker(id, "is it?"), asker("1b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b", "is it?"));
1044        assert_eq!(asker(id, "is it?").0.len(), 64);
1045        assert!(!asker(id, "is it?").0.contains(id));
1046    }
1047
1048    #[test]
1049    fn only_a_well_formed_browser_cookie_is_read() {
1050        let with = |cookie: &str| {
1051            let mut headers = HeaderMap::new();
1052            headers.insert(header::COOKIE, cookie.parse().unwrap());
1053            browser(&headers)
1054        };
1055        let id = "0b5f2a1e-7c3d-4e8f-9a6b-1c2d3e4f5a6b";
1056        assert_eq!(with(&format!("a=b; {BROWSER}={id}")).as_deref(), Some(id));
1057        assert_eq!(with(&format!("{BROWSER}=<script>")), None);
1058        assert_eq!(with("a=b"), None);
1059    }
1060}