feat(web): delete the renderer — the server serves files and JSON, and nothing else (#540) - #553
Merged
Merged
Conversation
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #540. Based on
main— #551 is merged, so this one is not stacked.keel/web/render.pyis gone: 987 lines of server-side HTML, the seven page handlers that calledit, 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 isPOST /api/setup/<key>now, and the action set is a 0-linediff —
keel.commands.setup.ACTIONS, still only idempotent, non-destructive,MECHANICALsteps,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-Clientnow covers the write path._api_client_header_okrecordedthat 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 legitimatesubmission with no terminal to fall back to on the desktop bundle. The CSRF token moved into
X-Keel-CSRFfor the same reason: in a body it proves only that the sender could read it; in aheader it also proves the sender could set one.
A reversal, recorded rather than quietly applied
payload.setup_payloadused to argue against ever putting the token in a GET response. Thatargument is quoted in place above the new one. Two of its three concerns were already answered
(
/api/*isno-storeand #538's worker refuses to cache it; the server binds loopback), and thethird — 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.cookieon an origin whose session cookie is deliberatelyHttpOnly.Verification
ruff,mypyand the full suite are green (4825 passed, 3 skipped). Then driven in a realChromium against a first-run deployment with nothing set up, which is the path that matters:
config→database→rules— writing a realconfig.yaml, a realkeel.db, and 32 seeded candidate rules, each reporting the server's ownmessage. Re-running
configreports "already exists — left untouched — already done — nothingto change", which is the idempotency distinction the payload was built to carry.
type="password", theattestation's
backingandpays_yieldare<select>s with nothing pre-chosen.X-Keel-Client→ 403;POST /api/setup/enable_autonomy→ 404;POST /api/status→404; then a valid write → 200 and a read → 200.
/,/status,/setup,/gatesall serve the static shell;/api/*is JSON; an unknown path is a plain-text 404; and an unauthenticated navigation gets thereadable 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_bodyfixes it (anddisconnects 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_thinnessnamedrenderexplicitly so that deleting it would fail loudly ratherthan silently shrink the scanned set. It did exactly that, which is why the replacement had to be
chosen deliberately. The property
rendercarried — "a THIRD renderer over the same reports,never a second place that computes them" — belongs to the API layer now:
api,events,security,serverandstaticfilesare pinned where three modules were.keel/web/security.pyis 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 istrusted markup". That property got stronger:
textContentcannot interpret markup at all, sothere is no escaping to get right and no call site that can forget to escape.
test_no_client_module_can_write_markupcarries it, with the history in its docstring. Thepalette-agreement tests went the same way, exactly as their own docstring predicted they would.
🤖 Generated with Claude Code
https://claude.ai/code/session_01T6yA5khYnJ2qzheArRToQ2