[{"data":1,"prerenderedAt":4},["ShallowReactive",2],{"XCSsQmTX5l":3},"# LeanTEA\n\n**Lean 4 + TEA ([The Elm Architecture](https://guide.elm-lang.org/architecture/))**.\nA tiny full-stack framework — and a handful of apps built on top of it\n(a functional spreadsheet with an MCP endpoint, a board-game SPA, a\nChrome-CDP MCP server, and several other AI-driving MCP servers).\n\n[![ci](https://github.com/Verilean/lean-tea/actions/workflows/ci.yml/badge.svg)](https://github.com/Verilean/lean-tea/actions/workflows/ci.yml)\n[![pages](https://github.com/Verilean/lean-tea/actions/workflows/pages.yml/badge.svg)](https://github.com/Verilean/lean-tea/actions/workflows/pages.yml)\n[![Discord](https://img.shields.io/badge/discord-LeanTEA-5865F2?logo=discord&logoColor=white)](https://discord.gg/94Xueve8WD)\n\n📖 **[Read the docs — the LeanTEA book](https://verilean.github.io/lean-tea/)** · [HTTP benchmark trend](https://verilean.github.io/lean-tea/bench/)\n\nQuestions, design discussion, and weekly progress threads live in the Discord channel above.\n\n- **Pure Lean** stack: HTTP server, WebSocket client, and SQLite live\n  in Lean.\n- **No Node.js, no Python at runtime** (Python is used only by a few\n  build helpers in `tools/`).\n- **SQLite is vendored** — `c/sqlite3.c` is the amalgamation, linked\n  into the binary, so deployment doesn't need `-lsqlite3`.\n- The browser sees plain HTML + inlined CSS, plus one small runtime\n  JS file served from the same origin (`/runtime.js`). Beyond the\n  standard `fetch` / History / DOM APIs any SPA needs, the Web Speech\n  API is the only domain-specific browser API used.\n- **Matches (slightly beats) tuned nginx** on hello-world / 5-field\n  JSON: the `LeanTea.Net.ReactorServer` backend is a\n  `c/leantea_reactor.c` non-blocking event loop\n  (kqueue on macOS/BSD, epoll on Linux) driving a Lean `ByteArray →\n  IO ByteArray` callback per request. **72 k RPS at c=128** vs\n  nginx 69 k on the same box. Full numbers in `docs/BENCHMARKS.md`.\n\n## Inspirations\n\nThe name **LeanTEA** is **Lean 4 + TEA** — *The Elm Architecture*. The\nframework borrows ideas across the Elm + Haskell-flavoured ecosystem\nand ports them into Lean 4:\n\n- **[The Elm Architecture (TEA)](https://guide.elm-lang.org/architecture/)** —\n  `Model / Msg / update / view` on both the TUI and the in-browser\n  runtimes. Lean's structures + dependent types let `Msg` be an\n  inductive that the compiler exhaustiveness-checks for you.\n- **[Yesod](https://www.yesodweb.com/)** — the \"full-stack typed web\n  framework\" framing: routing, sessions, OAuth, and templates as\n  first-class Lean values rather than stringly-typed configuration.\n- **[Persistent](https://www.yesodweb.com/book/persistent)** — the\n  `Entity / Repo` typeclasses under `LeanTea.Persist.*` are\n  Persistent-style: define a record, derive a backend-agnostic store,\n  swap SQLite / MySQL / in-memory at the call site.\n- **[Servant](https://docs.servant.dev/)** — the typed-RPC layer\n  (`LeanTea.Rpc`) treats the API surface as a Lean type the server\n  and client share. No hand-written JSON wrangling, no schema drift.\n\n> Not affiliated with or endorsed by the Elm, Yesod, Persistent, or\n> Servant projects.\n\n### Sibling projects in the Lean 4 ecosystem\n\n- **[Verso](https://github.com/leanprover/verso)** — a Lean-native\n  authoring tool for documentation and books (Scribble / Sphinx\n  lineage). **Complementary to LeanTEA, not overlapping**: Verso\n  generates static documents; LeanTEA serves dynamic web apps.\n  We're considering migrating `docs/` to Verso once the framework\n  stabilises so the code snippets in the book are actually\n  type-checked.\n\n## Secure by Construction\n\nLeanTEA's headline property is that whole classes of vulnerabilities\n**can't be expressed in user code that compiles**. **Nine primitives\nship today**; one more is planned. The shipped set covers most of\nthe IPA 「安全なウェブサイトの作り方」 11 categories and OWASP Top 10\n2021. See [SECURITY.md](SECURITY.md) for the design + threat model,\n[docs/11](docs/11-secure-by-construction.md) for the walk-through,\nand [ROADMAP.md](ROADMAP.md) for sequencing.\n\n| Vulnerability class | LeanTEA primitive | IPA / OWASP | Status |\n|---|---|---|---|\n| Authorization bypass / IDOR | `LeanTea.Auth.Proof` (`Proof c` + `Capability` lattice + dependent `Proof (.owner id)`) | IPA §3.7 / A01 | ✅ shipped — [walk](docs/11-secure-by-construction.md#2--authproof--authorization-that-cant-be-forgotten) · [demo](examples/Tests/PersistSpec.lean) |\n| SQL injection | `LeanTea.Persist.SafeQuery` (typed `Where` / `Select` / `Update` / `Delete` + `.trusted decl_name%` audit) | IPA §3.1 / A03 | ✅ shipped — [walk](docs/11-secure-by-construction.md#3--safequery--sql-injection-that-cannot-be-expressed) · [demo](examples/Tests/PersistSpec.lean) |\n| XSS (URL scheme + event-handler names) | `LeanTea.Html.SafeAttr` (private `mk` + URL allow-list + `on*` rejection) | IPA §3.5 / A03 | ✅ shipped — [walk](docs/11-secure-by-construction.md#4--safehtml--xss-that-cant-be-introduced) · [demo](examples/Tests/SecuritySpec.lean) |\n| Path traversal | `LeanTea.Net.SafePath` (workspace-relative + `..` / NUL / sibling-prefix reject) | IPA §3.4 / A01 | ✅ shipped — [walk](docs/11-secure-by-construction.md#5--safepath--paths-that-cant-escape-their-workspace) · [demo](examples/Tests/SecuritySpec.lean) |\n| OS command injection | `LeanTea.Os.SafeCmd` (`args : List String` + shell-name allow-list reject + grep-able `SafeCmd.shell` audit) | IPA §3.3 / A03 | ✅ shipped — [walk](docs/11-secure-by-construction.md#6--safecmd--ioprocessrun-that-cant-get-shell-injected) · [demo](examples/Tests/SecuritySpec.lean) |\n| HTTP header injection | `Response.setHeader` (CR / LF / NUL reject) | IPA §3.6 / A03 | ✅ shipped — [walk](docs/11-secure-by-construction.md#7--responsesetheader--defaultsecurityheaders--header-injection--clickjacking) · [demo](examples/Tests/SecuritySpec.lean) |\n| Clickjacking + MIME sniffing | `Response.defaultSecurityHeaders` (XFO / nosniff / Referrer-Policy / Permissions-Policy) | IPA §3.10 / A05 | ✅ shipped — [walk](docs/11-secure-by-construction.md#7--responsesetheader--defaultsecurityheaders--header-injection--clickjacking) · [demo](examples/Tests/SecuritySpec.lean) |\n| Open redirect | `LeanTea.Net.SafeRedirect` (allow-listed origin + relative-path-only mode + scheme reject + sibling-prefix reject) | IPA §3.9 / A01 | ✅ shipped — [walk](docs/11-secure-by-construction.md#8--saferedirect--open-redirect-that-needs-an-allow-list) · [demo](examples/Tests/SecuritySpec.lean) |\n| CSP typos / misconfiguration | `LeanTea.Net.Csp` (typed `CspSrc` directives — a mistyped source or directive doesn't compile) | IPA §3.5 / A05 | ✅ shipped — [demo](examples/Tests/SecuritySpec.lean) |\n| Invalid state transitions | `OrderState` / `Transition s s'` style proofs | — | 🚧 planned |\n\n### Snippet — `SafeQuery` rejects string-shaped SQL at compile time\n\n```lean\n-- ✅ Compiles — typed builders, positionally bound:\nlet rows ← SafeQuery.run users\n  { where_ := .and (UserCols.email.eq \"alice@x.com\")\n                   (.not (UserCols.deleted.eq true)) }\n\n-- ❌ Compile error — `Where.eq` is `private` to SafeQuery.lean.\n--   The framework gives no path from a raw String to a `Where` clause.\nlet bad := Where.eq \"email\" rawUserInput\n-- error: Unknown constant `LeanTea.Persist.SafeQuery.Where.eq`\n```\n\n### Snippet — `Auth.Proof` enforces the auth check in the type signature\n\n```lean\n-- The admin handler demands an unforgeable `Proof .admin`:\ndef handleAdminDelete (proof : Proof .admin) (req : Request) : IO Response := …\n\n-- Removing the `proof` parameter breaks the route registration:\ndef handleAdminDelete (req : Request) : IO Response := …\n-- error: Type mismatch in route registration\n--   expected: Proof .admin → Request → IO Response\n--   got:      Request → IO Response\n```\n\nThe proof's `mk` is `private` to the auth module — only `Proof.issue`\n(which checks the session) can mint one. Forgetting the auth check\nis now a build failure, not a CVE.\n\nTo close the \"added a route but forgot to guard it at all\" gap,\ncollect routes as a `SecureRoute` list: every entry must be either\n`.needs c` (proof enforced) or `.anyone` (explicitly public). There's\nno unstated third case, so an unguarded endpoint is a visible,\ngreppable `.anyone` rather than an accidental omission.\n\nFor the full walk-through (capability lattice, dependent\n`Proof (.owner id)`, the `.trusted decl_name%` audit-grep escape, the\n~480-LOC trusted core across the shipped primitives), see\n**[docs/11-secure-by-construction.md](docs/11-secure-by-construction.md)**.\n\n## Layout\n\n```\nLeanTea/\n├── Cmd.lean Sub.lean Runtime.lean    -- TUI Elm runtime\n├── Web.lean Html.lean Css.lean Js.lean -- WebApp (Model/Msg/update/view) + DSLs\n├── Template.lean                     -- {{var}} / {{#each}} / {{#if}} / {{#include}}\n├── Rpc.lean JsonRpc.lean             -- Servant-style typed RPC + JSON-RPC envelope\n├── Mcp.lean                          -- MCP Handler (stdio + HTTP transports)\n├── Markdown.lean Markdown/           -- CommonMark-ish parser\n├── Json/                             -- terse Json accessors (.getStrD etc.)\n├── Net/\n│   ├── Http.lean Server.lean         -- HTTP/1.1 server + Request/Response/Handler\n│   ├── HttpClient.lean               -- pure-Lean HTTP/1.1 client\n│   ├── WebSocket.lean                -- pure-Lean RFC 6455 client (handshake, masking)\n│   ├── Desktop.lean Memcached.lean   -- OS desktop FFI, memcached client\n├── Persist/\n│   ├── Sqlite.lean Mysql.lean        -- backend FFI\n│   ├── Store.lean Query.lean Backend.lean Migrate.lean -- Entity / Repo / migration\n│   └── SafeQuery.lean                -- typed Where / Select / Update / Delete (no `String → SQL`)\n├── Auth.lean                         -- session store\n│   ├── OAuth2.lean Saml.lean Passkey.lean Security.lean\n│   └── Proof.lean                    -- Capability + Proof.issue (Authorization)\n├── Crypto/                           -- Base64 / SHA-1 / SHA-256 / HMAC / PBKDF2 / JWT\n├── Browser.lean Comfy.lean Diffuse.lean -- 3rd-party tool bridges\n├── Llm/Openai.lean                   -- streaming OpenAI-compatible client (LM Studio)\n├── Agent/                            -- run history, replayable scripts\n├── LSpec.lean                        -- tiny test runner (group / it / lspecIO)\n└── assets/runtime.js styles.css      -- embedded client runtime\n\nLeanJs/                               -- Lean-subset → JavaScript compiler\n├── Ast.lean Parser.lean JsParser.lean\n├── Check.lean                        -- arity + record-field guard\n├── Codegen.lean Eval.lean LeanEmit.lean Includes.lean\n\nc/\n├── sqlite3.c sqlite3.h               -- SQLite amalgamation (vendored, ~9 MB)\n├── leantea_sqlite.c                  -- SQLite FFI wrapper\n├── leantea_mysql.c                   -- MySQL FFI (opt-in via LEANTEA_MYSQL=1)\n├── leantea_crypto.c                  -- OpenSSL bindings (opt-in via LEANTEA_CRYPTO=1)\n└── leantea_desktop.c                 -- macOS Quartz bindings (opt-in via LEANTEA_DESKTOP=1)\n\nexamples/\n├── Counter/ Quiz/ CounterWeb/         -- TUI + browser TEA demos (~50 lines each)\n├── Sheet/                             -- functional spreadsheet + /mcp (typed Rpc + Persist + Mcp)\n├── Reversi/                           -- board game (.leanjs client, Lean server)\n├── Gpu/                               -- WebGPU demo\n├── ChromeCdpMcp/                      -- real-Chrome driver via CDP (10 tools)\n├── BrowserMcp/ BrowserAgent/          -- Playwright-driven browser + LLM agent\n├── ComfyuiMcp/                        -- ComfyUI HTTP/WebSocket driver\n├── DesktopMcp/                        -- OS-level mouse + screenshot (macOS Quartz)\n├── ImageMcp/                          -- HTML/CSS → PNG compositor\n├── UiScript/ UiReport/                -- AI-driven E2E test runner + HTML report\n├── Smoke/                             -- subsystem smoke tests (one per area)\n├── Tests/                             -- LeanJs spec runner\n├── Tools/                             -- gen_site + leanjs_{compile,interp,run} CLIs\n└── Docs/                              -- runnable doc examples\n\ntools/\n├── dev.py                             -- file watcher + auto reload for the dev loop\n├── browser-bridge/                    -- node + Playwright (used by BrowserMcp)\n└── run-tests.sh run-docs.sh           -- CI entry points\n```\n\n## Build and run\n\n```sh\n# Build everything (~3 min cold, seconds incrementally)\nlake build\n\n# In-browser counter (TEA in 50 lines)\n./.lake/build/bin/counter_web --port 8001\nopen http://127.0.0.1:8001/\n\n# Multi-user SVG editor + MCP endpoint at /mcp\n./.lake/build/bin/sheet_serve --port 8002 --db ../.leantea-state/sheet.sqlite\nopen http://127.0.0.1:8002/\n\n# Board game (Reversi) — client logic is a .leanjs file compiled at startup\n./.lake/build/bin/reversi_serve --port 8005\n\n# Chrome-CDP MCP server (drives your already-open Chrome)\n# 1. Launch Chrome with: --remote-debugging-port=9222 --user-data-dir=/tmp/chrome-cdp\n# 2. Then:\n./.lake/build/bin/chrome_cdp_mcp_serve --stdio\n```\n\nTests are organised into two consolidated LSpec runners plus a\nhandful of subsystem smokes:\n\n- **`persist_spec`** — Store roundtrips + Query DSL + Migration\n  runner + Auth.Proof dispatch + SafeQuery typed SQL (32\n  assertions, one binary, one CI step).\n- **`security_spec`** — SafeHtml + SafePath + SafeCmd + SafeHeader\n  + SafeRedirect construction-time guarantees (60 assertions, one\n  binary, one CI step).\n- **`template_smoke` / `crypto_smoke` / `http_*_smoke`** — narrower\n  per-module subsystem smokes that don't yet have an LSpec runner.\n\nRead the runner source under `examples/Tests/` as the shortest\n\"this is what works\" demonstration of any given area.\n\n## Architecture\n\n```\n[Browser]\n  index.html  ←──── GET /, /styles.css, /runtime.js\n  runtime.js  ──┐\n                │ fetch('/api/step?msg=...', X-Model: \u003Cencoded>)\n                ▼\n[Lean: sheet_serve]\n  Net.Server (Std.Internal.Async.TCP)\n    ↓\n  SheetServe.handler\n    ├─ \"/\"                → render the toolbar + SVG host from Template\n    ├─ \"/cells\"          → SVG fragment built from Persist.Store.shapes\n    ├─ \"/api/*\"           → Rpc.dispatch (typed Endpoint records)\n    ├─ \"/mcp\"  (POST)     → LeanTea.Mcp.handleMcp (text / image content)\n    └─ everything else    → 404\n[Lean: Persist.Store (SQLite via FFI)]\n  shapes (id, kind, x, y, w, h, text, color, page_id)\n  pages  (id, name)\n  audit  (id, action, ts)\n```\n\nThe client encodes the current `Model` in the `X-Model` header on\nevery action; the server runs `WebApp.step` (pure) and ships the new\nmodel back the same way. SQLite is for things that need to outlive a\nrestart (shape DB, sessions, audit). **No middleware stack, no\nimplicit context** — every clause in `handler` is one function from a\n`Request` to a `Response`.\n\n## Persistent-style typed DB API\n\n```lean\nstructure CellRow where\n  kind  : String  -- \"rect\" / \"ellipse\" / \"text\" / \"sticky\" / \"pen\"\n  x y   : Int\n  w h   : Int\n  text  : String\n  color : String\n\ninstance : Entity CellRow where\n  table   := \"shapes\"\n  ddl     := \"CREATE TABLE IF NOT EXISTS shapes(...)\"\n  columns := [\"kind\", \"x\", \"y\", \"w\", \"h\", \"text\", \"color\"]\n  toRow s   := #[s.kind, toString s.x, ..., s.color]\n  fromRow r := ...\n\n-- Usage — SafeQuery makes SQL injection unrepresentable:\nnamespace CellCols\n  open LeanTea.Persist.SafeQuery\n  def kind : Col CellRow String := ⟨\"kind\"⟩\n  def x    : Col CellRow Int    := ⟨\"x\"⟩\nend CellCols\n\nlet shapes : Repo CellRow := Repo.new db\nshapes.migrate\nlet _ ← shapes.insert { kind := \"rect\", x := 0, y := 0, w := 80, h := 40,\n                        text := \"hello\", color := \"#38bdf8\" }\nlet rects ← SafeQuery.run shapes\n  { where_ := .and (CellCols.kind.eq \"rect\")\n                   (CellCols.x.gt 100) }\n```\n\n`Where`'s value-leaf constructors are `private` to the SafeQuery\nmodule — there's no path from a raw `String` into a `Where`, so an\nLLM-generated `Where.eq \"email\" userInput` is a compile error.\n\n## Sheet app + MCP server\n\n`examples/Sheet/` is a small functional spreadsheet:\n\n```sh\nlake build sheet_serve\n./.lake/build/bin/sheet_serve --port 8002 --db ../.leantea-state/sheet.sqlite\nopen http://127.0.0.1:8002/\n```\n\n- SVG rendering for rect / ellipse / text / sticky / freehand pen\n- Click to select, drag to move, drag corner handles to resize,\n  double-click for in-place text editing (foreignObject editor),\n  separate ✏️ Pen tool, color picker and W / H inputs\n- State lives in SQLite (`cells` table)\n\n### MCP (Model Context Protocol) support\n\n`POST /mcp` is a minimal JSON-RPC 2.0 endpoint so Claude or other\nclients can edit cells directly:\n\n```sh\n# handshake\ncurl -X POST http://127.0.0.1:8002/mcp \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}'\n\n# list tools\ncurl -X POST http://127.0.0.1:8002/mcp \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/list\"}'\n\n# add a shape\ncurl -X POST http://127.0.0.1:8002/mcp \\\n  -H 'content-type: application/json' \\\n  -d '{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\n       \"params\":{\"name\":\"set_cell\",\n                 \"arguments\":{\"kind\":\"rect\",\"x\":100,\"y\":200,\"text\":\"Hello\"}}}'\n```\n\nTools exposed:\n\n- `add_shape(kind, x, y, w?, h?, text?, color?)` → new id\n- `move_cell(id, x, y)`, `resize_shape(id, w, h)`\n- `set_text(id, text)`, `set_color(id, color)`\n- `delete_shape(id)`, `list_shapes()`, `clear_all()`\n\nWhen wiring this into Claude Desktop / Claude Code, point its MCP\nclient at `transport: \"http\"`, `url: \"http://localhost:8002/mcp\"`.\n\n## Typed RPC (Servant-style, checked on both sides)\n\n`LeanTea.Rpc.Typed` makes the request and response *types* part of the\nendpoint. One declaration is the single source of truth, and both the\nserver and the browser client are checked against it — a wrong field\nis a **compile error**, not a runtime surprise.\n\n```lean\nstructure SetCellReq  where ref : String; formula : String\n  deriving Lean.ToJson, Lean.FromJson\nstructure SetCellResp where ok : Bool;   value : String\n  deriving Lean.ToJson, Lean.FromJson\n\ndef setCell : Endpoint SetCellReq SetCellResp :=\n  { name := \"apiSetCell\", path := \"/api/set\",\n    reqType := \"SetCellReq\", respType := \"SetCellResp\" }\n```\n\n**Server.** `serve setCell (fun (r : SetCellReq) => …)` — the handler\nis `SetCellReq → IO SetCellResp`. Dispatch decodes the body to\n`SetCellReq` via the derived codec and encodes the result; a malformed\nor wrong-shape body becomes a `400` at runtime (the wire carries\nuntrusted bytes, so that check is unavoidable), while the handler code\nitself only ever sees a valid, typed request.\n\n**Client.** The same endpoint generates the browser JS\n(`Endpoint.clientFn` → `fetch` + `JSON.stringify(req)` + `r.json()`)\n*and* a type-check stub. A `.leanjs` client that builds the request or\nreads the response is checked against the very same `SetCellReq` /\n`SetCellResp`: accessing a field the type doesn't have fails to\ncompile. LeanJs has no type system of its own, so rather than\nreinvent one, `LeanJs.TypeCheck` emits the client to Lean and lets\nLean's own elaborator do the checking (`async`→`do`, `await`→`←`); the\nshared types are `import`ed, not re-declared, so there is exactly one\ndefinition.\n\n```text\ncorrect client            → type-checks ✓\nresp.value → resp.valueX  → REJECTED ✗  (SetCellResp.valueX doesn't exist)\n```\n\nSee `examples/Tests/TypedRpcSpec.lean` for the end-to-end proof (server\ndispatch + client accept/reject + generated JS). The legacy\nstringly-typed `LeanTea.Rpc` (`Handler := List String → IO String`)\nremains for back-compat.\n\n## CSS / JS DSLs\n\nBoth CSS and JS are also small ASTs with a `render` step.\n\n```lean\nopen LeanTea.Css in\ndef sheetStyles : Sheet := [\n  rule \".btn\" [(\"background\", \"#0284c7\"), (\"color\", \"#fff\")],\n  rule \".btn:hover\" [(\"background\", \"#0369a1\")],\n  keyframes \"ripple\" [\n    (\"0%,100%\", [(\"box-shadow\", \"0 0 0 8px rgba(239,68,68,0.25)\")]),\n    (\"50%\",     [(\"box-shadow\", \"0 0 0 12px rgba(239,68,68,0.2)\")])\n  ]\n]\n```\n\n```lean\nopen LeanTea.Js LeanTea.Js.E LeanTea.Js.S LeanTea.Js.Dom in\ndef helloFn : Stmt :=\n  afn \"hello\" [] [\n    constV \"btn\" (getById \"btn-hello\"),\n    doE (addEventListener (i \"btn\") \"click\"\n      (aarrow [] [doE (await_ (call (i \"alert\") [s \"hi\"]))]))\n  ]\n```\n\n`Block.render` produces compact (one-line) JavaScript — readable but\nnot pretty. Used by `LeanTea.Rpc.clientLib` to emit the typed RPC\nclient functions.\n\n## Google OAuth login\n\n`LeanTea.Auth` is plugged into the example servers (`sheet_serve`,\n`reversi_serve`, …) and activates **only when the environment\nvariables below are set** so local development is unaffected.\n\n| Variable                | Required | Example                                      |\n| ----------------------- | -------- | -------------------------------------------- |\n| `GOOGLE_CLIENT_ID`      | yes      | `123-abc.apps.googleusercontent.com`         |\n| `GOOGLE_CLIENT_SECRET`  | yes      | `GOCSPX-...`                                 |\n| `BASE_URL`              | yes      | `https://your-app.fly.dev` (for redirect_uri) |\n| `COOKIE_SECURE`         | no       | `1` to flag cookies `Secure` on HTTPS        |\n| `ALLOWED_EMAILS`        | no       | comma-separated email allowlist              |\n\nWhen both `*_ID` and `*_SECRET` are present:\n\n- `GET /auth/google/login` → mints a CSRF state and 302-redirects to Google\n- `GET /auth/google/callback?code=&state=` → posts to Google's `/token` and `/userinfo` via `curl(1)`, mints a session, sets an HttpOnly cookie\n- `GET /auth/logout` → drops the cookie and the DB row\n- `/mcp` and a few static asset paths are on a public allowlist\n- API paths under `/api/*` return 401 when unauthenticated; UI paths 302 to the login page\n- Sessions live in the `sessions` SQLite table, CSRF state in `oauth_states`\n\n### Google Cloud Console setup\n\n1. Create an OAuth 2.0 Client ID (Web application) at\n   https://console.cloud.google.com/apis/credentials.\n2. Add `${BASE_URL}/auth/google/callback` to \"Authorized redirect URIs\".\n3. Approve `email`, `profile`, `openid` on the consent screen.\n4. Push the secrets to Fly:\n\n   ```sh\n   flyctl secrets set \\\n     GOOGLE_CLIENT_ID=… \\\n     GOOGLE_CLIENT_SECRET=… \\\n     BASE_URL=https://your-app.fly.dev \\\n     COOKIE_SECURE=1 \\\n     ALLOWED_EMAILS=you@example.com\n   ```\n\n### Implementation notes\n\n- HTTPS calls to Google go through `curl(1)` (the Lean stdlib has no\n  TLS). The runtime image already bundles curl.\n- Session tokens are 32 random bytes from `/dev/urandom`, hex-encoded\n  (`Auth.randomToken`). **Important**: use `IO.FS.Handle.mk` +\n  `read 32` — `IO.FS.readBinFile` reads to EOF, and `/dev/urandom`\n  never EOFs, so it spirals into an OOM. Caught the hard way.\n- `Auth.gate cfg store publicPaths inner` is a `Handler → Handler`\n  wrapper. The inner handler is typed as `Session → Handler` so the\n  logged-in user is available without leaking through globals.\n\n## Cloud deployment\n\n### Docker (works on any container host)\n\n`Dockerfile` and `fly.toml` live at the repo root. The multi-stage\nbuild installs the Lean toolchain on Debian Bookworm, builds the\nbinary, then copies just the binary into a slim runtime image\n(~170 MB).\n\n```sh\ndocker build -t leantea-sheet .\ndocker run -d --name leantea \\\n    -p 8080:8080 \\\n    -v leantea_data:/data \\\n    leantea-sheet\nopen http://127.0.0.1:8080/\n```\n\n`/data` holds the SQLite file, so a container restart keeps history.\n\n### Fly.io (free tier)\n\nThe hobby tier is free for shared-cpu-1x × 3 machines (256 MB each)\nwith up to 3 GB of persistent volume. `auto_stop_machines` is on, so\nmachines park themselves when idle and there's a 10–20 s cold start\non the next request — perfect for a personal app.\n\n```sh\ncurl -L https://fly.io/install.sh | sh\nflyctl auth login\n\nflyctl launch --no-deploy          # picks app name + region\nflyctl volumes create sheet_data --region nrt --size 1\nflyctl deploy\nflyctl open\n```\n\n### Other options\n\n- **Oracle Cloud Free Tier** — Always-free ARM Ampere instances\n  (4 vCPU, 24 GB RAM). SSH in and run the binary, or deploy the same\n  Docker image.\n- **Render** — Free web service tier (sleeps after 15 min idle).\n  Dockerfile works out of the box; persistent disk requires a paid\n  plan.\n- **Google Cloud Run** — Pay-per-use, 2 M requests/month free. The\n  Lean binary runs fine; persistence has to move to Firestore or\n  Cloud SQL since the FS is ephemeral.\n\n## Development & testing\n\nLeanTEA's testing story has three layers, by intent:\n\n| Layer | Tool | Purpose | Status |\n|---|---|---|---|\n| **Type-level proofs** (the negative space) | The compiler itself + `Proof of Authorization` + `SafeQuery` | \"Could this auth bypass / SQL injection / XSS *ever* happen?\" — answered statically. **What the compiler proves, you don't have to test.** | ✅ shipped |\n| **Unit & smoke** | [`LeanTea.LSpec`](LeanTea/LSpec.lean) — a tiny LSpec-shaped runner with `group` / `it` + tree output | Pure-function business logic, `update : Msg → Model → Model`, codec round-trips, render output. Used by every `examples/Smoke/*` binary. | ✅ shipped |\n| **E2E (LLM-driven, exploratory)** | [`examples/UiScript`](examples/UiScript/Run.lean) + `browser_mcp_serve` | Declarative JSON scripts (click → screenshot → LLM classify) that survive DOM refactors because the LLM reasons about *intent*. Pairs with [`examples/BrowserAgent`](examples/BrowserAgent/Run.lean) for record-once / replay-many. | ✅ shipped |\n| **E2E (typed, deterministic)** | `LeanTea.WebSpec` (planned v0.2) — `do`-notation over `ChromeCdpMcp` | CI/CD regression tests with `group \"login flow\" [ it \"rejects bad password\" do … ]`. Same mental model as LSpec, but with `navigate` / `fill` / `click` / `expectText` primitives. | 🚧 planned |\n\nWhat this means in practice:\n\n- Security tests **don't exist** in a LeanTEA codebase — the compiler\n  already enforced the property. Your test suite is just the\n  happy-path business logic, which is drastically smaller than the\n  equivalent Rails / Django suite (most of which exists to assert\n  \"the framework didn't let this bad input through\").\n- For UI regressions today, write a `UiScript` JSON and let the LLM\n  drive it. For deterministic golden tests, wait for v0.2 or hand-roll\n  the `chrome_cdp_*` calls in a smoke binary — the surface area is\n  already there.\n\n### Dev loop\n\n`tools/dev.py` is a tiny stdlib-only file watcher: it `lake build`s on\nsave, restarts the dev server with `DEV_MODE=1`, and the page polls\n`GET /_dev/ping` once a second so the browser auto-reloads after a\nsuccessful build.\n\n```sh\npython3 tools/dev.py --app sheet --port 8801\n```\n\n## Community\n\n- **Discord**: [https://discord.gg/94Xueve8WD](https://discord.gg/94Xueve8WD)\n  — design discussion, weekly progress threads, beginner Q&A.\n- **GitHub issues**: bug reports + feature requests on this repo.\n- **Roadmap & security model**: [ROADMAP.md](ROADMAP.md) +\n  [SECURITY.md](SECURITY.md).\n\n## License\n\n`c/sqlite3.c` / `c/sqlite3.h` are public domain\n(https://www.sqlite.org/copyright.html). Everything else is MIT.\n",1784558046916]