diff --git a/keel/web/api.py b/keel/web/api.py index 7f0da22..587681a 100644 --- a/keel/web/api.py +++ b/keel/web/api.py @@ -40,6 +40,7 @@ from typing import TYPE_CHECKING, Any from keel.web import payload +from keel.web.security import csrf_token if TYPE_CHECKING: # pragma: no cover - typing only from keel.web.server import ServeConfig @@ -157,7 +158,8 @@ def read_status(cfg: ServeConfig, _query: Query, _state: Any, now_ts: int) -> di def read_setup(cfg: ServeConfig, _query: Query, state: Any, _now_ts: int) -> dict[str, Any]: """The first-run checklist -- the ONE deployment-reading endpoint that must answer when there is no deployment, because it is the thing that says how to make one. `needs_database=False` - for that reason, and `server.needs_database` serves the same page in HTML for the same one. + for that reason: an endpoint that refused to answer until a deployment existed would refuse + precisely the operator who has none. `state` is the `DeploymentState` the envelope already read for `engine`, passed in rather than re-inspected: one 3.6 ms probe per response, not two.""" @@ -165,7 +167,15 @@ def read_setup(cfg: ServeConfig, _query: Query, state: Any, _now_ts: int) -> dic from keel.commands.setup import ACTIONS, NOT_AUTOMATED_YET return payload.setup_payload( - state, actions=ACTIONS, not_automated=NOT_AUTOMATED_YET, job=jobs.status() + state, + actions=ACTIONS, + not_automated=NOT_AUTOMATED_YET, + job=jobs.status(), + # The write token for this session, on the one endpoint whose view performs writes. Scoped + # to that endpoint rather than put on `/api/config` (which every view reads at boot) so it + # travels only to the page that needs it -- see `payload.setup_payload` for why it is in a + # body at all, which was a reversal. + csrf=csrf_token(cfg.token), ) @@ -607,6 +617,38 @@ def refusal_document(status: int, title: str, detail: str) -> dict[str, Any]: return payload.error_envelope(int(time.time()), status=status, title=title, detail=detail) +def action_document(result: Any) -> dict[str, Any]: + """One completed setup action, as JSON (#540). + + **`changed` is the field the client actually needs**, and it is not a success flag: it is the + difference between "created" and "already there". `keel.commands.setup`'s own note on it calls + it "the property that makes a double-click safe" -- every action is idempotent, so a repeated + submission succeeds and reports `changed: false`, which is a true statement about the + deployment rather than a soft failure. + + Judged here, in the serialiser, exactly as every other payload value is: the client is handed + `display` and `state` and never decides what a result means. + """ + return payload.envelope( + int(time.time()), + # `running=True` is a statement of fact rather than a probe: this document is built only + # after an action has RUN in this process, so the engine's presence is not in question and + # re-inspecting the deployment to say so would be one disk probe spent on a known answer. + running=True, + data={ + "step_key": str(getattr(result, "step_key", "")), + "changed": payload.flag( + bool(getattr(result, "changed", False)), + on="done", + off="already done — nothing to change", + on_state=payload.GOOD, + off_state=payload.NEUTRAL, + ), + "message": payload.label(str(getattr(result, "message", ""))), + }, + ) + + def sortable_columns() -> Mapping[str, Sequence[str]]: """The declared sort surface, for a test to read rather than restate.""" return {path: route.sortable for path, route in API_ROUTES.items() if route.sortable} diff --git a/keel/web/payload.py b/keel/web/payload.py index ce190c5..b7dff07 100644 --- a/keel/web/payload.py +++ b/keel/web/payload.py @@ -1479,6 +1479,16 @@ def _action_input_payload(field: Any) -> dict[str, Any]: # a form that can be submitted safely and one that leaks its own contents into browser # history. The client is told the answer rather than deriving it from the field's name. "secret": flag(field.secret, on="never echoed back", off="shown as typed"), + # The SAME fact as `secret`, in the form the client needs to act on rather than to show. + # + # Two keys for one boolean looks like duplication and is the opposite: `secret` is a + # `Field`, and a `Field` is a thing the client DISPLAYS -- Rule 3 of the client's pins + # forbids `render.js` from reading `.value` at all, precisely so no view can start + # deciding what a payload means. Choosing between `type="password"` and `type="text"` is + # not a judgement about a value, it is a rendering instruction, and the server is the one + # that gives it. Without this the client would have had to read `secret.value`, which is + # the whole rule. + "kind": "secret" if field.secret else "text", # A closed set of answers, rendered with NOTHING pre-selected: an action that could fill in # a field the operator left blank is one that could record something they never supplied. "choices": list(field.choices), @@ -1526,22 +1536,42 @@ def setup_payload( actions: Sequence[Any] = (), not_automated: Mapping[str, str] | None = None, job: Any = None, + csrf: str = "", ) -> dict[str, Any]: """`keel.commands.setup.inspect`'s `DeploymentState`, as JSON. - **No CSRF token, and that is the point rather than an omission.** `render_setup` takes one - because it emits `
" - ) - - -def render_gates(gates: Sequence[Any], capabilities: Sequence[Any]) -> str: - """The capability inventory (#436), rendered from `keel/capabilities.py`. - - This page is the reason a browser view can be honest about its own limits. The read surface - here cannot reach a single one of these actions -- the server implements no write verb at all - -- and the page says so, next to the list of what it cannot do and who can.""" - parts = [ - 'every action that increases what keel can do without ' - "asking again
", - 'Fails closed against {esc(gate.fails_closed_against)}.
' - f'Implemented once, at {esc(gate.implementation)}.
{esc(cap.invocation)} {mirror}",
- esc(cap.increases),
- f"{esc(cap.module)}.{esc(cap.function)}",
- )
- )
- parts.append(
- table(
- (("surface", False), ("action", False), ("grants", False), ("call site", False)),
- rows,
- )
- )
- return "".join(parts)
-
-
-def render_message(heading: str, detail: str) -> str:
- return f'{esc(detail)}
' diff --git a/keel/web/security.py b/keel/web/security.py index f52da20..36298b3 100644 --- a/keel/web/security.py +++ b/keel/web/security.py @@ -58,6 +58,18 @@ #: a name that silently disables the cookie is worse than a plain name that works. SESSION_COOKIE = "keel_session" +#: The request header carrying the CSRF token on a write (#540). +#: +#: A HEADER rather than a body field, and the difference is the whole reason this layer still +#: earns its place now that the write surface is JSON. The token used to ride in a `