Skip to content

feat(web): delete the renderer — the server serves files and JSON, and nothing else (#540) - #553

Merged
eaitbrahim merged 1 commit into
mainfrom
feat-540-delete-render
Aug 25, 2026
Merged

feat(web): delete the renderer — the server serves files and JSON, and nothing else (#540)#553
eaitbrahim merged 1 commit into
mainfrom
feat-540-delete-render

Conversation

@eaitbrahim

Copy link
Copy Markdown
Contributor

Closes #540. Based on main#551 is merged, so this one is not stacked.

keel/web/render.py is gone: 987 lines of server-side HTML, the seven page handlers that called
it, and its inline stylesheet. The client moved from /static/ to / and is the application now.
Net −896 lines.

The write surface moved, and that is what made a security layer real

/setup/* was an HTML form. It is POST /api/setup/<key> now, and the action set is a 0-line
diff
keel.commands.setup.ACTIONS, still only idempotent, non-destructive, MECHANICAL steps,
still asserted disjoint from the eleven capability-increasing actions. A browser can set a
deployment up. It still cannot arm a rule, attest an asset or enable autonomy.

What changed is that X-Keel-Client now covers the write path. _api_client_header_ok recorded
that widening as "#536's call to make, once (and only once) the forms it replaces are gone"
because a <form> cannot set a header, and gating it would have refused every legitimate
submission with no terminal to fall back to on the desktop bundle. The CSRF token moved into
X-Keel-CSRF for the same reason: in a body it proves only that the sender could read it; in a
header it also proves the sender could set one.

A reversal, recorded rather than quietly applied

payload.setup_payload used to argue against ever putting the token in a GET response. That
argument is quoted in place above the new one. Two of its three concerns were already answered
(/api/* is no-store and #538's worker refuses to cache it; the server binds loopback), and the
third — an operator pasting API output — does not survive the observation that settles it: this
token authorises nothing without the session cookie
, and anyone holding that cookie can mint it
themselves. The alternative, a script-readable cookie, would have moved a live token into
document.cookie on an origin whose session cookie is deliberately HttpOnly.

Verification

ruff, mypy and the full suite are green (4825 passed, 3 skipped). Then driven in a real
Chromium against a first-run deployment with nothing set up, which is the path that matters:

  • The whole ceremony from the browser — configdatabaserules — writing a real
    config.yaml, a real keel.db, and 32 seeded candidate rules, each reporting the server's own
    message. Re-running config reports "already exists — left untouched — already done — nothing
    to change"
    , which is the idempotency distinction the payload was built to carry.
  • Seven action forms render with the right controls: the CDP secret is type="password", the
    attestation's backing and pays_yield are <select>s with nothing pre-chosen.
  • Security matrix, all on reused connections: no CSRF header → 403; wrong CSRF → 403; no
    X-Keel-Client403; POST /api/setup/enable_autonomy404; POST /api/status
    404; then a valid write → 200 and a read → 200.
  • No server-generated HTML anywhere: /, /status, /setup, /gates all serve the static shell;
    /api/* is JSON; an unknown path is a plain-text 404; and an unauthenticated navigation gets the
    readable sentence, not an envelope.

Two bugs the suite could not have found, and one it now can

A refused write poisoned the next request on the same connection. HTTP/1.1 keep-alive: a POST
refused before its body was read left those bytes in the socket, and the stdlib parsed them as
the next request line — a 501 on the request after the one that was correctly refused. It
became possible in this commit, because moving the CSRF token to a header put the refusal in front
of the body read. Every test in this suite opens a fresh connection, so none could see it; I found
it driving a browser through five refusals in a row. _drain_request_body fixes it (and
disconnects rather than draining an oversized body, which would hand back the memory-exhaustion
primitive the cap exists to deny), and there is now a test that drives two requests down one
socket.

An action's outcome was never visible. My first version wrote the server's message into the
card and then repainted the view, destroying it. My second restored it after the action's own
repaint — and the 15-second poll wiped it mid-sentence. Outcomes are session state now, re-applied
in rebuildInto, which every path that replaces the view goes through.

The pin transferred, and it covers more than before

test_console_thinness named render explicitly so that deleting it would fail loudly rather
than silently shrink the scanned set. It did exactly that, which is why the replacement had to be
chosen deliberately. The property render carried — "a THIRD renderer over the same reports,
never a second place that computes them"
— belongs to the API layer now: api, events,
security, server and staticfiles are pinned where three modules were.

keel/web/security.py is untouched; all five layers are as they were.

Deleted tests, each a decision

tests/web/test_render.py (125 lines) tested one security property — "nothing reaching the page is
trusted markup". That property got stronger: textContent cannot interpret markup at all, so
there is no escaping to get right and no call site that can forget to escape.
test_no_client_module_can_write_markup carries it, with the history in its docstring. The
palette-agreement tests went the same way, exactly as their own docstring predicted they would.

🤖 Generated with Claude Code

https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2

…nd nothing else (#540)

`keel/web/render.py` is gone: 987 lines of server-side HTML, the seven page
handlers that called it, and its inline stylesheet. The client moved from
`/static/` to `/` and is the application now. Net -896 lines.

── THE WRITE SURFACE MOVED, AND THAT IS WHAT MADE A LAYER REAL ────────────────

`/setup/*` was an HTML form. It is `POST /api/setup/<key>` now, and the action
SET is a 0-line diff: `keel.commands.setup.ACTIONS`, still only idempotent,
non-destructive, `MECHANICAL` steps, still asserted disjoint from the eleven
capability-increasing actions. A browser can set a deployment up; it still
cannot arm a rule, attest an asset or enable autonomy.

What changed is that `X-Keel-Client` now covers the write path.
`_api_client_header_ok` recorded that widening as "#536's call to make, once
(and only once) the forms it replaces are gone" -- because a `<form>` cannot set
a header, and gating it would have refused every legitimate submission with no
terminal to fall back to on the desktop bundle. The forms are gone.

The CSRF token moved into `X-Keel-CSRF` for the same reason: in a body it would
prove only that the sender could READ it; in a header it also proves the sender
could set one, which a cross-origin form cannot do at all.

── A REVERSAL, RECORDED RATHER THAN QUIETLY APPLIED ───────────────────────────

`payload.setup_payload` used to argue against ever putting the token in a GET
response: "minting a live write credential into a GET would put it into every
cached copy, every proxy log and every paste". Two of those were already
answered -- `/api/*` is `no-store` and #538's worker refuses to cache it, and the
server binds loopback. The third does not survive the observation that settles
it: this token authorises NOTHING without the session cookie, and anyone holding
that cookie can mint it themselves. The old argument is quoted in place.

── TWO BUGS THE SUITE COULD NOT HAVE FOUND ────────────────────────────────────

**A refused write poisoned the next request on the same connection.** HTTP/1.1
keep-alive; a POST refused before its body was read left those bytes in the
socket, and the stdlib parsed them as the next request line -- 501, on the
request AFTER the one that was correctly refused. It became possible in this
commit: moving the token to a header put the refusal in front of the body read.
Every test in the suite opens a fresh connection, so none could see it. Found by
driving a browser through five refusals in a row. `_drain_request_body` fixes it
and disconnects rather than draining an oversized body, and a test now drives two
requests down one socket.

**An action's outcome was never visible.** The first version wrote the server's
message into the card and then repainted the view, destroying it. The second
restored it after the action's own repaint -- and the 15-second poll wiped it
mid-sentence. Outcomes are session state now, re-applied in `rebuildInto`, which
every path that replaces the view goes through.

── THE PIN TRANSFERRED, AND IT IS BIGGER THAN BEFORE ──────────────────────────

`test_console_thinness` named `render` explicitly so its deletion would fail
LOUDLY rather than shrink the scanned set. It did exactly that. The property --
"a THIRD renderer over the same reports, never a second place that computes
them" -- belongs to the API layer now: `api`, `events`, `security`, `server` and
`staticfiles` are pinned where three were.

`keel/web/security.py`'s five layers are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2
@eaitbrahim
eaitbrahim merged commit de5c326 into main Aug 25, 2026
4 checks passed
@eaitbrahim
eaitbrahim deleted the feat-540-delete-render branch August 25, 2026 21:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Delete render.py's HTML generation and the HTML routes

1 participant