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, ¶ms); 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}