diff --git a/docs/install.md b/docs/install.md index 71db821..324e32d 100644 --- a/docs/install.md +++ b/docs/install.md @@ -122,12 +122,35 @@ Losing them would make every already-known error look new tomorrow morning. ### Asking it how it is -The CLI lives *inside* the container in this stack, so every command is reached the same way: +> **Built and unreleased**, as of 2026-08-10. Everything in this section is in no image you can pull: +> `docs/published-surface.json` records `0.1.0a8`, and these routes are items 203 to 208. Until a +> release carries them, the CLI below is the whole of it — and this note stays, because the rule is +> that documentation describes the released artefact rather than this checkout (`CONTRIBUTING.md`). +> +> Written down rather than deleted because the guard that catches this **cannot see it**: it compares +> documented *commands* against the published image and knows nothing about URLs, so a route +> documented before it exists passes every test. Recorded as work item 209. + +**From the page, which is the point** (DR-0022). +Set a password once — `docker compose exec api hullwork password`, read from a prompt so it is not in +your shell history — and everything below is a URL: + +| | | +|---|---| +| `/page/me/` | how it is, and every feature this instance has switched on, off, or cannot do | +| `/page/me/doctor` | what is broken, a sentence per check | +| `/page/me/config` | what it actually received — every variable, where it came from, and which half gets it. **No credential is printed**: a secret reads `set` or `not set` | +| `/page/me/projects` | connect a project, re-read its manifest, disable it, rotate its webhook secret, name it in the tracker | + +That is the whole of running an instance, and none of it needs a shell. The password is what unlocks +it; a read link handed to a colleague reads the instance and reaches none of those four. + +The CLI still does all of it, unchanged, for scripts and for installing: ```bash -docker compose exec api hullwork status # how it is — exit 1 when degraded -docker compose exec api hullwork doctor # what is broken — a sentence per check -docker compose exec api hullwork config # what it is set to — every variable, source, and half +docker compose exec api hullwork status # exit 1 when degraded — `hullwork status || mail me` +docker compose exec api hullwork doctor +docker compose exec api hullwork config ``` `status` exiting 1 when degraded is what makes `hullwork status || mail me` a whole monitoring setup, @@ -141,13 +164,21 @@ you wrote in a file. ### A page a teammate can read -`hullwork page-token` mints one URL, prints it once and stores only its hash. Behind it is what -`status` says, read from the same functions, plus the evidence of every attempt. It is **off until you -run that command**, and everything without the token — including a wrong token — gets the same `404` an -unknown path gets, so it cannot be found by probing. +Two doors, and they are for two people +(DR-0021). **The first is unreleased**, per the note +above; the second has worked since `0.1.0a5`. + +**Yours is `/page/me/`**, behind the password. Nothing to lose, nothing to copy, and it is the only +one that administers anything. + +**Theirs is a link.** `hullwork page-token` mints one URL, prints it once and stores only its hash — +read-only, and it reaches no button and neither `doctor` nor `config`. That URL **is** the credential: +anyone holding it can read every item and captured output here, so treat it as a secret. Rotating +replaces it and stops the old one. -That URL **is** the credential. Anyone who has it can read every item and captured output on this -instance. It is read-only, and rotating replaces it. +Everything without a valid token — including a wrong one — gets the same `404` an unknown path gets, +so the page cannot be found by probing. `/page/me/` offers a login **only where a password is set**, +so an instance that never set one is as undiscoverable as it ever was. ### Two things about the port diff --git a/hullwork/__init__.py b/hullwork/__init__.py index ad13fcb..d7d636a 100644 --- a/hullwork/__init__.py +++ b/hullwork/__init__.py @@ -1,5 +1,5 @@ """Hullwork — from production errors to reviewable pull requests, on your own infrastructure.""" -__version__ = "0.1.0a8" +__version__ = "0.1.0a9" __all__ = ["__version__"] diff --git a/hullwork/cli.py b/hullwork/cli.py index 24b29ca..c01b64d 100644 --- a/hullwork/cli.py +++ b/hullwork/cli.py @@ -56,6 +56,7 @@ ) from hullwork import decisions as decide from hullwork import dispatch as dispatch_module +from hullwork import features as features_module from hullwork import upstream as upstream_module from hullwork.config import ConfigError, Settings, get_settings from hullwork.credentials import PushCapability @@ -1498,6 +1499,20 @@ def rotate_secret(session: Session, slug: str) -> str: return token +def set_tracker(session: Session, slug: str, tracker_project: str | None) -> Project: + """Name this project in the tracker, or unname it. Instance configuration, never the manifest. + + **Extracted by item 207**, the only production code that item moved: this lived inside + `_cmd_set_tracker` and had no caller but that command, so the page would have had to reimplement + it — the drift items 193, 194, 200 and 203 each cost a day to. Empty means *stop sweeping it*, + which is a real answer and not a missing one. + """ + project = _require(session, slug) + project.tracker_project = tracker_project or None + session.commit() + return project + + def disable_project(session: Session, slug: str) -> Project: """Deactivate. Never delete: destroying history to unregister a project is a footgun.""" project = _require(session, slug) @@ -2261,6 +2276,18 @@ def _cmd_status( # and stopped mentioning the dispatcher at all — while it was stopped. That is the failure item # 075's fourth gate exists to prevent, arrived at from the other side: an operator could not # tell a quiet healthy instance from one with nothing running. + # **The same function the page renders** (item 203), so a reader with a terminal and a reader + # with a browser cannot come to disagree about the same instance. + standing = features_module.on_this_instance(session, settings) + worrying = [one for one in standing if one.state is not features_module.ON] + print("\n Features:", file=out) + if worrying: + for one in worrying: + print(f" ! {one.name}: {one.state} — {one.detail}", file=out) + print(f" - {len(standing) - len(worrying)} of {len(standing)} on", file=out) + else: + print(f" - all {len(standing)} on", file=out) + print("\n Dispatcher:", file=out) for note in dispatcher: mark = "!" if note.degraded else "-" @@ -2611,9 +2638,7 @@ def _cmd_set_tracker( args: argparse.Namespace, session: Session, settings: Settings, out: TextIO ) -> int: """Name this project in the tracker. Instance configuration, never the manifest (DR-0011).""" - project = _require(session, args.slug) - project.tracker_project = args.tracker_project or None - session.commit() + project = set_tracker(session, args.slug, args.tracker_project) if project.tracker_project is None: print(f"'{project.slug}' will no longer be swept.", file=out) return 0 @@ -3053,9 +3078,10 @@ def _cmd_try(args: argparse.Namespace, settings: Settings, out: TextIO) -> int: print(f"\n{verdict}. What it produced is under {into}.", file=out) print( " Nothing was published and no forge was contacted. The artefact there is the same one a " - "pull request would carry.\n" - " A real instance shows this and the rest — cost, policies, review debt — on a page: " - "`hullwork page-token` (DR-0014).", + "pull request would carry, and `evidence.html` beside it is the page a reviewer is shown " + "on a real instance — open it, or send it to somebody.\n" + " What that page cannot have here: the numbers an instance keeps across runs — cost over " + "time, review debt, whether a fix held. Those need one (DR-0014).", file=out, ) # A trial cannot consume an item, so its exit code answers a different question from `work`'s: diff --git a/hullwork/features.py b/hullwork/features.py index 3bbeda6..e9030c4 100644 --- a/hullwork/features.py +++ b/hullwork/features.py @@ -30,8 +30,14 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, field +from typing import TYPE_CHECKING +from hullwork.config import Settings from hullwork.manifest import Manifest +from hullwork.models import Project + +if TYPE_CHECKING: # pragma: no cover - a type, never a runtime dependency + from sqlalchemy.orm import Session @dataclass(frozen=True) @@ -367,3 +373,152 @@ def lines(answers: Sequence[Answer]) -> list[str]: said.append(f" limit: {limit}") said.append("") return said + + +# --- the instance side of the same question (item 203) ------------------------------------------- + +#: The three answers a dashboard may give about a feature **on this instance**, and they are three +#: because collapsing any pair costs a reader something specific. +#: +#: `OFF` is a decision somebody took, and DR-0019's rule is that having is not permitting: rendering +#: a decision as a fault tells its reader to go and repair a choice they made. `CANNOT` is a fault +#: and names what is missing. `ON` is the least interesting of the three, which is why the ordering +#: below puts it last. +ON = "on" +OFF = "off" +CANNOT = "cannot" + + +@dataclass(frozen=True) +class Standing: + """One feature, its state on this instance, and the sentence a reader acts on.""" + + name: str + state: str + detail: str + + +def _filing(session: Session, settings: Settings) -> Standing: + """Whether a production error becomes an issue here. + + **Configured, not reachable.** Whether the forge answers costs a network call and this renders + on + a page request; a dashboard that opened a socket per view would be a load test of somebody's + forge. So this says what it established and names what asks the other question — the distinction + items 193, 194 and 199 each cost a day to. + """ + name = "filing a production error as an issue" + if not settings.forge_url: + return Standing( + name, CANNOT, + "no forge configured. Set HULLWORK_FORGE_URL and HULLWORK_FORGE_TOKEN — a token that " + "can read content and write issues, and provably not push.", + ) + active = session.query(Project).filter(Project.active.is_(True)).count() + if not active: + return Standing( + name, OFF, + "a forge is configured and no project is registered here yet: " + "`hullwork projects add --slug NAME --repo owner/name`.", + ) + return Standing( + name, ON, + f"a forge is configured and {active} project(s) are registered. Whether it answers is not " + f"asked here — `hullwork doctor` asks it.", + ) + + +def _the_page(session: Session, settings: Settings) -> Standing: + """Off until somebody mints a token, which is a decision rather than an omission.""" + del settings + from hullwork import page + + if page.configured(session): + return Standing( + "the daily page", ON, + "a token has been minted. That URL is the credential: anyone holding it can read every " + "item and captured output here.", + ) + return Standing( + "the daily page", OFF, + "off until you run `hullwork page-token`, and everything without the token gets the same " + "404 an unknown path gets, so it cannot be found by probing.", + ) + + +def _notifications(session: Session, settings: Settings) -> Standing: + """What each project asked for, against what actually delivers. + + `telegram` and `email` parse in a manifest and are refused at delivery — true, documented in + prose, and said nowhere a person would look. This is where they would look. + """ + del settings + name = "notifications" + delivers = {"none", "console"} + asked = { + str(((project.manifest or {}).get("notify") or {}).get("channel", "none")) + for project in session.query(Project).filter(Project.active.is_(True)).all() + } + undeliverable = sorted(asked - delivers) + if undeliverable: + return Standing( + name, CANNOT, + f"{', '.join(undeliverable)} parses in a manifest and is refused at delivery: a " + f"transport nobody has exercised would have its first real run in front of a user. " + f"`console` and `none` are what deliver.", + ) + if asked <= {"none"}: + return Standing( + name, OFF, + "every project here asks for `none`, which is the default and a decision. `console` is " + "the other one that delivers.", + ) + return Standing(name, ON, f"delivering to {', '.join(sorted(asked - {'none'}))}.") + + +def _recurrence(session: Session, settings: Settings) -> Standing: + """Whether a fix that did not hold can be noticed at all. It needs the tracker, not the + forge.""" + del session + name = "the recurrence watch" + if not settings.tracker_url: + return Standing( + name, CANNOT, + "no tracker configured, so a returning error cannot be seen: a tracker notifies once " + "per issue for that issue's whole life, and a recurrence arrives by asking. Set " + "HULLWORK_TRACKER_URL and HULLWORK_TRACKER_TOKEN.", + ) + return Standing( + name, ON, + "a tracker is configured and the sweep asks it. Whether it answers is not asked here.", + ) + + +#: One answerer per name in `INSTANCE_SHAPED`, so the list is what drives the dashboard rather than +#: something kept beside it. A fifth name added there tomorrow fails this module loudly rather than +#: going unanswered the way all four did until item 203. +_ANSWERS = { + "filing a production error as an issue": _filing, + "the daily page": _the_page, + "notifications": _notifications, + "the recurrence watch": _recurrence, +} + + +def on_this_instance(session: Session, settings: Settings) -> list[Standing]: + """What this instance has switched on, off, and cannot do — worst first. + + **The other half of `examine`.** That one answers for a checkout and hands `INSTANCE_SHAPED` + back as somebody else's question, with a comment saying `doctor` owns it. `doctor` answers + resources, and a resource is not a feature: four capabilities were named as nobody's question + until this took them. + + Ordered with `ON` last on purpose. A reader opens this looking for what is not working, and a + wall of green with one red line in the middle is a wall of green. + """ + missing = [name for name in INSTANCE_SHAPED if name not in _ANSWERS] + if missing: # pragma: no cover - the structural test below is what keeps this true + msg = f"no answer on this instance for: {missing}" + raise NotImplementedError(msg) + standing = [_ANSWERS[name](session, settings) for name in INSTANCE_SHAPED] + return sorted(standing, key=lambda one: one.state is ON) diff --git a/hullwork/main.py b/hullwork/main.py index 9abd87d..2880d9a 100644 --- a/hullwork/main.py +++ b/hullwork/main.py @@ -267,6 +267,7 @@ def ready( ) def page_instance( token: str, + request: Request, session: Annotated[Session, Depends(_readiness_session)], ) -> RedirectResponse: """The door. Item 122, and everything about it is in `hullwork.page`. @@ -278,11 +279,7 @@ def page_instance( `GET` only, and that is asserted by walking the application's routes rather than by trusting this decorator to stay a `get`. """ - if not page.opens(session, token): - # **The same body Starlette gives an unknown path**, not a friendlier one: a distinct - # message is a yes. Measured while writing the test — the default is `{"detail":"Not - # Found"}` and `"not found"` would have told a prober that this route exists. - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) # **To the same URL with a slash, and the reason is the token.** Under `/page/{token}/` every # link between views is relative — `items`, `../items` — so the credential never has to be # written into the HTML to get from one page to the next. Saved HTML, a screenshot of the @@ -307,15 +304,53 @@ def page_instance_index( session: Annotated[Session, Depends(_readiness_session)], settings: Annotated[Settings, Depends(get_settings)], ) -> HTMLResponse: - """The instance view itself. Everything about it is in `hullwork.page`.""" - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + """The front door, which is the work rather than the arithmetic about it (item 212, DR-0023). + + It was the instance report until this item: 357 words, 11 numbers and 14 sentences before a + person could do anything. The report is a noun in the rail now, with every number it had. + + **The one route where a refusal is not a `404`** (DR-0021): with a password configured and no + session, this is where somebody acquires one, or the door that replaces the token has no handle. + What that discloses is that this host runs something with a login, and nothing else — and an + instance that never set a password is `404` here like everywhere else. + """ + shut = _the_login_if_offered(session, request, token) + if shut is not None: + return shut + acting = _may_read(session, request, token) + return HTMLResponse( + page.items( + session, + acting=acting, + here="./", + settings=settings, + front=True, + error_reporting=_reporting_enabled, + ), + headers=page.HEADERS, + ) + + +@app.get( + f"{page.PREFIX}/{{token}}/instance", + tags=["page"], + response_class=HTMLResponse, + include_in_schema=False, +) +def page_instance_report( + token: str, + request: Request, + session: Annotated[Session, Depends(_readiness_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> HTMLResponse: + """Every number this instance keeps. Moved off the front door by item 212, not dropped.""" + acting = _may_read(session, request, token) return HTMLResponse( page.instance( session, settings, error_reporting=_reporting_enabled, - acting=_acting(session, request), + acting=acting, ), headers=page.HEADERS, ) @@ -334,8 +369,7 @@ def page_items( in_: Annotated[str | None, Query(alias="in")] = None, ) -> HTMLResponse: """Every item, most recent first. Item 123.""" - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) return HTMLResponse( page.items(session, only=in_, acting=_acting(session, request)), headers=page.HEADERS ) @@ -349,13 +383,14 @@ def page_items( ) def page_projects( token: str, + request: Request, session: Annotated[Session, Depends(_readiness_session)], settings: Annotated[Settings, Depends(get_settings)], ) -> HTMLResponse: """Every project this instance serves. Item 142, the level the tree was missing.""" - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") - return HTMLResponse(page.projects(session, settings), headers=page.HEADERS) + _may_read(session, request, token) + acting = _acting(session, request) + return HTMLResponse(page.projects(session, settings, acting=acting), headers=page.HEADERS) @app.get( @@ -366,6 +401,7 @@ def page_projects( ) def page_project( token: str, + request: Request, slug: str, session: Annotated[Session, Depends(_readiness_session)], settings: Annotated[Settings, Depends(get_settings)], @@ -376,8 +412,7 @@ def page_project( body would let somebody with a valid token enumerate which clients an instance serves — a fact about a consultancy's customers as much as about this deployment. """ - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) rendered = page.project(session, settings, slug) if rendered is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") @@ -403,8 +438,7 @@ def page_item( used to count an instance's items from outside — though anyone who has got this far holds the token and could simply read the list. """ - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) rendered = page.item(session, settings, item_id, acting=_acting(session, request)) if rendered is None: raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") @@ -442,6 +476,73 @@ async def _field(request: Request, name: str) -> str | None: return None +def _may_read(session: Session, request: Request, token: str) -> page.Acting: + """The one gate every page route asks, and it returns what the renderer needs. Item 204. + + **One place, not nine.** Each route used to decide this for itself, and DR-0021 gives the answer + a second input — a session may read at the reserved path — so nine copies would be nine chances + to add it in eight of them. That is the defect items 193, 194, 200 and 203 each cost a day to, + and this is auth, where the cost of getting it wrong is not a wrong number on a page. + + Raises the `404` itself, with the body Starlette gives an unknown path: a distinct message is a + yes. + """ + acting = _acting(session, request) + if not page.opens(session, token, acting=acting): + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + return acting + + +def _the_login_if_offered( + session: Session, request: Request, token: str +) -> HTMLResponse | None: + """The login, when this request may not read yet and may be told how to. Item 204. + + Returned rather than raised so the route reads as what it is, and kept here rather than in the + route so that **no route asks `page.opens` itself** — the three gates in this module are the + only callers, which is what `test_there_is_one_gate_and_not_ten` asserts. + """ + acting = _acting(session, request) + if page.opens(session, token, acting=acting) or not page.offers_a_login(token, acting): + return None + return HTMLResponse(page.just_the_login(acting), headers=page.HEADERS) + + +def _the_operators(session: Session, request: Request, token: str) -> page.Acting: + """A view only the operator sees: the session, never a read link. Item 208. + + `404` rather than `403`, like everything else here — a distinct refusal would tell somebody + holding a read link which doors exist behind it. + + It returns who that is, because item 212's rail is drawn from it: these two views rendered with + the default `Acting` showed a signed-in operator the reader's three nouns, so opening *why it + will not work* took away the way to *what it received*. + """ + acting = _may_read(session, request, token) + if operator.acting(session, request.cookies.get(operator.COOKIE)) is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + return acting + + +def _may_sign_in(session: Session, request: Request, token: str) -> None: + """The gate the **login** asks, which is not the one every other route asks. Item 204's defect. + + Found in use on the first attempt: the login was put behind `_may_read`, and at the session door + that requires a session — so signing in required already being signed in, and the form answered + `{"detail":"Not Found"}`. A door with a handle you can only reach from inside is a door nobody + opens. + + Two ways through, and they are the two kinds of person who sign in: somebody holding a read link + who wants the buttons, and somebody at the session door who has the password. Everything else is + the same `404` an unknown path gets, so an instance with no password configured has nothing to + post to — the property DR-0021 spends nothing of. + """ + acting = _acting(session, request) + if page.opens(session, token, acting=acting) or page.offers_a_login(token, acting): + return + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + + def _acting(session: Session, request: Request) -> page.Acting: """What this request may do, from the cookie it brought. Item 166. @@ -470,6 +571,47 @@ def _to_page(token: str, tail: str = "") -> RedirectResponse: ) +@app.get( + f"{page.PREFIX}/{{token}}/doctor", + tags=["page"], + response_class=HTMLResponse, + include_in_schema=False, +) +def page_doctor( + token: str, + request: Request, + session: Annotated[Session, Depends(_readiness_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> HTMLResponse: + """Why an instance that is running will not work. Item 208, DR-0022. + + **The operator's, not a reader's.** DR-0021 gives a link reading and the password administering; + this and `config` are the two somebody opens when something is wrong, and they belong on the + second side of that line. + """ + acting = _the_operators(session, request, token) + return HTMLResponse( + page.why_it_will_not_work(session, settings, acting=acting), headers=page.HEADERS + ) + + +@app.get( + f"{page.PREFIX}/{{token}}/config", + tags=["page"], + response_class=HTMLResponse, + include_in_schema=False, +) +def page_config( + token: str, + request: Request, + session: Annotated[Session, Depends(_readiness_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> HTMLResponse: + """What this process actually received. Item 208.""" + acting = _the_operators(session, request, token) + return HTMLResponse(page.what_it_received(settings, acting=acting), headers=page.HEADERS) + + @app.post(f"{page.PREFIX}/{{token}}/login", tags=["page"], include_in_schema=False) async def page_login( token: str, @@ -487,8 +629,7 @@ async def page_login( deployment served over plain HTTP behind a VPN — the cookie would never be sent and the login would look broken; hardcoding it off would be wrong the day a TLS proxy is put in front. """ - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_sign_in(session, request, token) supplied = await _field(request, "password") issued = operator.sign_in(session, supplied) if supplied else None @@ -507,6 +648,114 @@ async def page_login( return redirect +@app.post(f"{page.PREFIX}/{{token}}/projects", tags=["page"], include_in_schema=False) +async def page_connect_project( + token: str, + request: Request, + session: Annotated[Session, Depends(_readiness_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> HTMLResponse: + """Register a project from the page. Item 206, DR-0022. + + **The same function the terminal calls.** `cli.add_project` reads `hullwork.yml` from the + default branch with the receiver's own credential — *issue write and content read*, which is + exactly what that takes — validates it, and mints the webhook token. A route that registered a + project its own way would drift from the command, and items 193, 194, 200 and 203 each cost a + day to that. + + The guards are the ones every write route here already has: a session or `404`, a matching CSRF + pair or `403`. Nothing new is trusted. + """ + from hullwork import cli + + _may_read(session, request, token) + expected = operator.acting(session, request.cookies.get(operator.COOKIE)) + if expected is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + if not operator.csrf_ok(expected, await _field(request, "csrf")): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden") + + asked = {name: (await _field(request, name) or "") for name in ("slug", "repo", "forge")} + try: + made = cli.add_project( + session, + settings, + slug=asked["slug"], + forge_kind=asked["forge"] or "forgejo", + repo=asked["repo"], + ) + except Exception as exc: # every refusal already carries its own sentence + # **The command's own words, not a generic failure.** A manifest that does not parse, a + # repository the token cannot read and a slug already taken are the three things a person + # gets wrong, and each has a sentence written for it — showing "something went wrong" here + # would send them to the shell to find out what this page already knew. + session.rollback() + shown = page.projects( + session, settings, acting=_acting(session, request), refused=str(exc) + ) + return HTMLResponse(shown, headers=page.HEADERS) + shown = page.projects( + session, settings, acting=_acting(session, request), just_made=made + ) + return HTMLResponse(shown, headers=page.HEADERS) + + +@app.post( + f"{page.PREFIX}/{{token}}/projects/{{slug}}", tags=["page"], include_in_schema=False +) +async def page_project_action( + token: str, + slug: str, + request: Request, + session: Annotated[Session, Depends(_readiness_session)], + settings: Annotated[Settings, Depends(get_settings)], +) -> HTMLResponse: + """The rest of a project's life, from its own page. Item 207, DR-0022. + + **One route with an action rather than four routes.** The guard that keeps the write surface + readable is a list a person reads, and four names for one page's worth of buttons is how a list + stops being read. Each action calls what the terminal calls; none of them is implemented here. + + **No default branch.** An action nobody recognises does nothing and says so — a form field that + fell through to whichever branch was last is how a typo becomes a disabled project. + """ + from hullwork import cli + + _may_read(session, request, token) + expected = operator.acting(session, request.cookies.get(operator.COOKIE)) + if expected is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + if not operator.csrf_ok(expected, await _field(request, "csrf")): + raise HTTPException(status.HTTP_403_FORBIDDEN, "Forbidden") + + what = await _field(request, "action") + rotated: str | None = None + try: + if what == "disable": + cli.disable_project(session, slug) + elif what == "refresh": + cli.refresh_manifest(session, settings, slug) + elif what == "set-tracker": + cli.set_tracker(session, slug, await _field(request, "tracker_project")) + elif what == "rotate-secret": + rotated = cli.rotate_secret(session, slug) + else: + raise ValueError( + f"{what!r} is not something this page does. Nothing was changed." + ) + except Exception as exc: # every refusal already carries its own sentence + session.rollback() + shown = page.projects( + session, settings, acting=_acting(session, request), refused=str(exc) + ) + return HTMLResponse(shown, headers=page.HEADERS) + + shown = page.projects( + session, settings, acting=_acting(session, request), rotated=(slug, rotated) + ) + return HTMLResponse(shown, headers=page.HEADERS) + + @app.post(f"{page.PREFIX}/{{token}}/logout", tags=["page"], include_in_schema=False) async def page_logout( token: str, @@ -518,8 +767,7 @@ async def page_logout( CSRF-protected like the decisions are: a forced logout is a nuisance rather than a breach, but a route that skips the check is a route somebody later copies. """ - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) cookie = request.cookies.get(operator.COOKIE) if operator.csrf_ok(operator.acting(session, cookie), await _field(request, "csrf")): operator.log_out(session, cookie) @@ -530,6 +778,7 @@ async def page_logout( def _decide( token: str, + request: Request, item_id: int, session: Session, csrf: str | None, @@ -549,8 +798,7 @@ def _decide( cookie, so there is nothing left to hide from it; 4. the state machine, in `decisions`, which is what refuses an item that is not amber. """ - if not page.opens(session, token): - raise HTTPException(status.HTTP_404_NOT_FOUND, "Not Found") + _may_read(session, request, token) expected = operator.acting(session, cookie) if expected is None: @@ -586,7 +834,7 @@ async def page_approve( A `GET` that approves is a URL that approves — from an image tag, a prefetch, a chat unfurling a link somebody pasted. This costs money and opens a pull request, so it cannot be a link. """ - return _decide(token, item_id, session, await _field(request, "csrf"), "approve", + return _decide(token, request, item_id, session, await _field(request, "csrf"), "approve", cookie=request.cookies.get(operator.COOKIE)) @@ -600,5 +848,5 @@ async def page_hand_to_human( session: Annotated[Session, Depends(_readiness_session)], ) -> RedirectResponse: """Take this item away from the agent: a person will do it. `POST` only, same reasoning.""" - return _decide(token, item_id, session, await _field(request, "csrf"), "human", + return _decide(token, request, item_id, session, await _field(request, "csrf"), "human", cookie=request.cookies.get(operator.COOKIE)) diff --git a/hullwork/page.py b/hullwork/page.py index e644460..a9072f3 100644 --- a/hullwork/page.py +++ b/hullwork/page.py @@ -32,8 +32,10 @@ import html import re +from collections.abc import Sequence from dataclasses import dataclass from datetime import UTC, datetime +from pathlib import Path from typing import TYPE_CHECKING from urllib.parse import urlsplit @@ -84,18 +86,60 @@ def configured(session: Session) -> bool: return session.scalars(select(PageAccess).limit(1)).one_or_none() is not None -def opens(session: Session, token: str) -> bool: - """Whether this token opens the page. Constant time, and the same cost when there is no page. +#: The path segment that means *ask my session instead of a token* (DR-0021). Short, and shorter +#: than anything `generate_token` can produce, which is what stops a minted token ever +#: colliding with it — a collision would open the page for anybody signed in and close it for the +#: person +#: holding the link. +MINE = "me" + + +def opens(session: Session, token: str, *, acting: Acting | None = None) -> bool: + """Whether this request may read the page. Constant time, and the same cost when there is no + page. A missing row must not answer faster than a wrong token: the difference would say *"this instance has a page and you have the wrong key"*, which is the one bit of information the `404` is there to withhold. + + **And a token is not the only way in** (DR-0021). The person who ran `page-token` reached it + through a shell on the host — they can already read this database, the environment file and the + Docker socket, so withholding their own page's URL from them protects nothing they do not + have. An operator with a session reads at `MINE`, and the token keeps the job it is good at: + handing + reading to somebody with no account, revocably. + + Reading, not acting. `Acting` is what the renderer decides the second question from, and this + function answers only the first — item 166's split, which DR-0021 keeps. + + **The `MINE` door exists only where a password is configured.** Everything else answers `404`, + including a wrong token, so the page cannot be found by probing; a login at a fixed path would + end that for every instance rather than for the ones that opted in. `offered` is exactly that + opt-in — `operator.configured` — so an instance with no password is as undiscoverable as before. """ + if token == MINE: + return bool(acting and acting.csrf) access = session.scalars(select(PageAccess).limit(1)).one_or_none() expected = access.token_hash if access is not None else _DECOY_HASH return verify_token(token, expected) and access is not None +def offers_a_login(token: str, acting: Acting | None) -> bool: + """Whether a request that may not read should be shown the login rather than a `404`. + + **Two questions, and collapsing them is the bug I wrote first** (item 204). *May this request + see the page* and *may it be told there is a login* are different, and answering the second with + the first rendered the whole instance to anybody who typed `/page/me/`. + + So: the content needs a session, and the door needs only the opt-in. An instance with no + password + configured offers nothing and is `404` like everything else, which is the property DR-0021 + spends + nothing of. + """ + return token == MINE and bool(acting and acting.offered) + + def issue(session: Session, token_hash: str) -> None: """Store the hash of a new token, replacing any previous one. Rotation is overwriting.""" access = session.get(PageAccess, 1) @@ -130,7 +174,17 @@ def _own_prose(text: str) -> str: somebody points this at an error title from a tracker, the reasoning above should not be the only thing standing there. Untrusted text goes through `_h`, which is every interpolation. """ - return re.sub(r"\*\*([^*]+)\*\*", r"\1", _h(text)) + return _emphasised(_h(text)) + + +def _emphasised(already_escaped: str) -> str: + """The `**…**` half of `_own_prose`, on text that has been through `_h` already. + + Separate because `_as_code` needs it and had already escaped and inserted its own `` + tags: running the whole of `_own_prose` over that would escape the markup this module just + wrote, and serve `<code>` to a reader. + """ + return re.sub(r"\*\*([^*]+)\*\*", r"\1", already_escaped) def _link(url: str | None, text: str | None = None) -> str: @@ -197,7 +251,10 @@ def _link(url: str | None, text: str | None = None) -> str: decision, and this page is a panel rather than a document. */ --ink: light-dark(#101319, #e9ebf0); --muted: light-dark(#5a6270, #979fae); - --faint: light-dark(#8b93a1, #666e7d); + /* Was #8b93a1/#666e7d, which carried the footer at 2.73:1 light and 3.90:1 dark — + under AA, on the sentence that explains what the URL is. Measured, not adjusted + by eye: these are the nearest values clearing 4.5:1 on all three surfaces. */ + --faint: light-dark(#656d7b, #7d8593); --rule: light-dark(#dfe3ea, #232833); --canvas: light-dark(#eef1f5, #070910); --raise: light-dark(#ffffff, #14171e); @@ -209,6 +266,22 @@ def _link(url: str | None, text: str | None = None) -> str: --refused: light-dark(#ab2f22, #f0847a); --human: light-dark(#5638ad, #b193f5); + /* The type scale (item 213). Twelve size/weight pairs were on one page and twenty distinct + sizes in this stylesheet, eight of them two-decimal one-offs: each reasonable where it was + written, none of them reasonable together. Nine steps, and a rule may only name a step. */ + --t-2xs: .6875rem; + --t-xs: .75rem; + --t-sm: .8125rem; + --t-md: .875rem; + --t-base: .9375rem; + --t-lg: 1.0625rem; + --t-xl: 1.375rem; + --t-2xl: 1.75rem; + --t-3xl: 2.25rem; + + /* How wide a line of prose is allowed to get. The shell fills the window; sentences do not. */ + --measure: 68ch; + --r: 8px; --r-chip: 5px; --pad: 1.15rem; @@ -222,12 +295,19 @@ def _link(url: str | None, text: str | None = None) -> str: margin: 0; background: var(--canvas); color: var(--ink); - font: 400 15px/1.55 var(--sans); + font: 400 var(--t-base)/1.55 var(--sans); font-synthesis-weight: none; -webkit-font-smoothing: antialiased; } -.wrap { max-width: 62rem; margin: 0 auto; padding: 0 1.5rem 4rem; } +/* The shell (item 213). At 1680px the work used 43% of the window: a 62rem measure is a width + for a document, and this is a panel. The window is filled and the sentences inside it are held + to `--measure`, because the fix for dead margins is not 120-character lines. */ +.wrap { max-width: 108rem; margin: 0 auto; padding: 0 2rem 4rem; } +.sheet > p, .sheet > .sub, .sheet .why, footer { max-width: var(--measure); } +/* A headline needs a shorter measure than a paragraph, and `ch` is relative to the element's own + size: 68ch at 28px came out 1170px wide, which is a measure in name only. */ +.sheet .lede { max-width: 34ch; } a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 3px; text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); } @@ -241,11 +321,16 @@ def _link(url: str | None, text: str | None = None) -> str: padding: 1rem 0 .9rem; margin-bottom: 1.6rem; border-bottom: 1px solid var(--rule); } -.mark { font: 600 1.1rem/1 var(--mono); color: var(--ink); } -.word { font: 600 .95rem/1 var(--sans); letter-spacing: .01em; } +.mark { font: 600 var(--t-lg)/1 var(--mono); color: var(--ink); text-decoration: none; + /* WCAG 2.2 AA 2.5.8 asks 24x24 CSS px, and this measured 10x17 (item 215). It is + a standalone control rather than a link inside a sentence, so the Inline + exception does not reach it. */ + display: inline-flex; align-items: center; justify-content: center; + min-width: 24px; min-height: 24px; } +.word { font: 600 var(--t-base)/1 var(--sans); letter-spacing: .01em; } .bar .spacer { flex: 1; } .pill { - font: 550 .68rem/1 var(--sans); letter-spacing: .07em; text-transform: uppercase; + font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .07em; text-transform: uppercase; padding: .32rem .5rem; border-radius: var(--r-chip); border: 1px solid color-mix(in oklab, var(--c, var(--faint)) 40%, transparent); background: color-mix(in oklab, var(--c, var(--faint)) 9%, transparent); @@ -286,7 +371,7 @@ def _link(url: str | None, text: str | None = None) -> str: and `font-feature-settings: "zero" 0` does not turn it off because for that face it is not a feature, it is the glyph. Tabular numerals keep the columns aligned, which was the reason for mono here in the first place. */ - font: 600 2.5rem/1 var(--sans); font-variant-numeric: tabular-nums lining-nums; + font: 600 var(--t-3xl)/1 var(--sans); font-variant-numeric: tabular-nums lining-nums; letter-spacing: -.035em; color: var(--ink); } /* Green only when there is something to be green about: a green `0` under HELD says "good" where it @@ -294,9 +379,9 @@ def _link(url: str | None, text: str | None = None) -> str: .cell.won .big { color: var(--passed); } .cell.won.none .big, .cell.lost.none .big { color: var(--ink); } .cell.lost .big { color: var(--refused); } -.name { font: 550 .7rem/1 var(--sans); letter-spacing: .08em; text-transform: uppercase; +.name { font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-top: .35rem; } -.gloss { font: 400 .72rem/1.35 var(--sans); color: var(--faint); } +.gloss { font: 400 var(--t-xs)/1.35 var(--sans); color: var(--faint); } /* --- the answer ------------------------------------------------------------------------------ */ @@ -314,18 +399,169 @@ def _link(url: str | None, text: str | None = None) -> str: queue it is from the stripe, and colour is worth more where it is scarce. A problem is the one exception, below, because red there is the message and not a label. */ .lede { - font: 450 1.7rem/1.28 var(--sans); + font: 450 var(--t-2xl)/1.28 var(--sans); letter-spacing: -.022em; margin: 0; text-wrap: pretty; max-width: 44ch; color: var(--ink); } .lede.bad { color: var(--refused); } -.lede .also { font: 400 .55em/1 var(--sans); color: var(--muted); letter-spacing: 0; } +.lede .also { font: 400 var(--t-base)/1 var(--sans); color: var(--muted); letter-spacing: 0; } .answer .sub { margin: .7rem 0 0; } /* --- the decisions --------------------------------------------------------------------------- */ /* One card with divided rows, not one card per row: six equally-bordered boxes on a screen is the same failure as six equally-weighted columns, in a different shape. */ +/* --- what this instance has switched on (items 203, 208) ------------------------------------ + A panel, not the `decisions` list it borrowed at first: that one carries a fixed amber left + border because it means *waiting for you*, so a `cannot` row sat inside an amber stripe and the + severity read as the panel's rather than the row's. + + Two columns, and the point of them is the first: the states line up on one edge, so the panel is + scanned down rather than read across. The stripe is per row for the same reason. */ +.standing { + list-style: none; + padding: 0; + margin: 0 0 1.4rem; + background: var(--raise); + border: 1px solid var(--rule); + border-radius: var(--r); + overflow: hidden; +} +.standing li { + display: grid; + grid-template-columns: 6.2rem 1fr; + gap: 0 .85rem; + align-items: baseline; + padding: .8rem var(--pad) .8rem calc(var(--pad) - 3px); + border-left: 3px solid var(--c, var(--faint)); + border-top: 1px solid var(--rule); +} +.standing li:first-child { border-top: 0; } +.standing .pill { justify-self: start; color: var(--c, var(--faint)); border-color: currentColor; } +.standing .name { font-weight: 550; color: var(--ink); } +.standing .why { + grid-column: 2; + color: var(--muted); + font-size: var(--t-md); + margin: .2rem 0 0; + /* These are read, not scanned, so they get a measure. Seen only by opening the page: the rows + ran to about 110 characters on a wide window, which is a paragraph pretending to be a row. */ + max-width: 62ch; +} +/* Tight horizontally on purpose. Seen on screen: `.3em` of padding pushes a following comma far + enough away to read as a typographic error — `page-token ,` — so the chip is snug and earns its + separation from the background rather than from space. */ +.standing code { + font: var(--t-sm)/1.35 var(--mono); + background: var(--sunk); + padding: .05em .18em; + border-radius: 3px; + border: 1px solid var(--rule); +} +@media (max-width: 34rem) { + .standing li { grid-template-columns: 1fr; gap: .35rem 0; } + .standing .why { grid-column: 1; } +} + +/* --- the rail (item 212, DR-0023) ----------------------------------------------------------- + Furniture. It does not scroll away and it does not move between pages, so what exists is never + something a person has to remember. + + Two shapes, one markup: down the left where there is room, which is the shape the decision + named and the one that leaves the whole width to the work; a row of tabs under a narrow window, + where a column of five nouns would cost more of the screen than the page it navigates. */ +.rail { + display: flex; + flex-direction: row; + gap: 0 1.4rem; + padding: .3rem 0 0; + margin: 0 0 1.4rem; + border-bottom: 1px solid var(--rule); + overflow-x: auto; +} +.rail a { + padding: .5rem .1rem; + color: var(--muted); + text-decoration: none; + white-space: nowrap; + border-bottom: 2px solid transparent; +} +.rail a:hover { color: var(--ink); } +.rail a[aria-current="page"] { + color: var(--ink); + font-weight: 550; + border-bottom-color: var(--ink); +} + +@media (min-width: 60rem) { + .wrap { + display: grid; + grid-template-columns: 13.5rem minmax(0, 1fr); + column-gap: 3rem; + align-items: start; + } + .bar, footer { grid-column: 1 / -1; } + .rail { + grid-column: 1; + flex-direction: column; + gap: .1rem; + padding: 0; + margin: 0; + border-bottom: 0; + overflow-x: visible; + position: sticky; + top: 1.5rem; + } + .rail a { + padding: .35rem .6rem; + border-bottom: 0; + border-left: 2px solid transparent; + border-radius: 0 var(--r) var(--r) 0; + } + .rail a:hover { background: var(--raise); } + .rail a[aria-current="page"] { + border-left-color: var(--ink); + background: var(--raise); + } + .sheet { grid-column: 2; min-width: 0; } +} + +/* The heading row and the primary action on it (item 214). The action wraps under the title on a + narrow window rather than squeezing both, because a button that is half a word wide is not a + button. */ +.head { display: flex; flex-wrap: wrap; align-items: baseline; + justify-content: space-between; gap: .75rem 1.5rem; } +.head h1 { margin-bottom: .2rem; } +details.primary { margin: 0 0 .6rem; } +details.primary > summary { + display: inline-block; list-style: none; cursor: pointer; + font: 550 var(--t-md)/1 var(--sans); padding: .55rem .9rem; + border: 1px solid color-mix(in oklab, var(--working) 40%, var(--rule)); + border-radius: var(--r-chip); background: var(--raise); color: var(--working); +} +details.primary > summary::-webkit-details-marker { display: none; } +details.primary > summary::before { content: "+"; margin-right: .4rem; font-weight: 600; } +details.primary[open] > summary::before { content: "\\2212"; } +details.primary > summary:hover { background: var(--sunk); } +details.primary[open] { + flex: 1 1 100%; background: var(--raise); border: 1px solid var(--rule); + border-radius: var(--r); padding: var(--pad); margin-top: .4rem; +} +details.primary[open] > summary { border: 0; background: none; padding: .2rem 0 .4rem; + min-height: 24px; } + +/* The one control on the page that creates something (item 213). */ +.new { display: flex; flex-wrap: wrap; align-items: end; gap: .75rem; margin: 1.4rem 0 0; } +.field { display: flex; flex-direction: column; gap: .3rem; margin: 0; } +.field label { font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .06em; + text-transform: uppercase; color: var(--muted); } +.field input { + font: 400 var(--t-md)/1 var(--mono); padding: .5rem .6rem; min-width: 12rem; + border: 1px solid var(--rule); border-radius: var(--r-chip); + background: var(--canvas); color: var(--ink); +} +.new button { align-self: end; } + .decisions { list-style: none; padding: 0; margin: 0 0 1.4rem; background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); @@ -333,14 +569,14 @@ def _link(url: str | None, text: str | None = None) -> str: } .decision { padding: .85rem var(--pad); border-top: 1px solid var(--rule); } .decision:first-child { border-top: 0; } -.decision .what { display: block; font-weight: 550; font-size: .97rem; } -.decision .meta { display: block; font: 400 .78rem/1.5 var(--mono); color: var(--muted); +.decision .what { display: block; font-weight: 550; font-size: var(--t-base); } +.decision .meta { display: block; font: 400 var(--t-sm)/1.5 var(--mono); color: var(--muted); margin-top: .3rem; } .decision .decide { margin-top: .7rem; } .decision .sub { margin: .5rem 0 0; } /* The "how to sign in" line belongs to the card above it, not to the gap below. */ .decisions + .how { margin: -1.1rem 0 1.4rem; padding: 0 var(--pad); - font-size: .82rem; color: var(--muted); } + font-size: var(--t-sm); color: var(--muted); } /* --- what it is doing ------------------------------------------------------------------------ */ @@ -354,7 +590,7 @@ def _link(url: str | None, text: str | None = None) -> str: .phases { display: flex; flex-wrap: wrap; gap: .35rem; margin: .8rem 0 0; padding: 0; list-style: none; } -.phase { font: 500 .72rem/1 var(--mono); letter-spacing: .02em; +.phase { font: 500 var(--t-xs)/1 var(--mono); letter-spacing: .02em; padding: .38rem .5rem; border-radius: var(--r-chip); border: 1px solid var(--rule); color: var(--faint); } .phase.done { color: var(--passed); @@ -365,12 +601,12 @@ def _link(url: str | None, text: str | None = None) -> str: background: color-mix(in oklab, var(--working) 11%, transparent); } .chip { display: inline-flex; align-items: center; gap: .35rem; - font: 550 .72rem/1 var(--sans); letter-spacing: .02em; + font: 550 var(--t-xs)/1 var(--sans); letter-spacing: .02em; padding: .34rem .5rem; border-radius: var(--r-chip); border: 1px solid color-mix(in oklab, var(--c, var(--faint)) 35%, transparent); background: color-mix(in oklab, var(--c, var(--faint)) 9%, transparent); color: var(--c, var(--muted)); } -.chip::before { content: "●"; font-size: .6em; } +.chip::before { content: "●"; font-size: var(--t-2xs); } .c-working { --c: var(--working); } .c-waiting { --c: var(--waiting); } .c-passed { --c: var(--passed); } .c-refused { --c: var(--refused); } .c-idle { --c: var(--faint); } .c-human { --c: var(--human); } @@ -386,12 +622,12 @@ def _link(url: str | None, text: str | None = None) -> str: background: var(--sunk); border-radius: var(--r); } .tally { display: flex; align-items: baseline; gap: .4rem; } -.fig { font: 550 1.05rem/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); +.fig { font: 550 var(--t-lg)/1 var(--mono); font-variant-numeric: tabular-nums; color: var(--ink); font-feature-settings: "zero" 0; text-decoration: none; } a.fig { text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); } a.fig:hover { text-decoration: underline; } .fig.zero { color: var(--faint); font-weight: 400; } -.cap { font: 400 .74rem/1 var(--sans); color: var(--muted); letter-spacing: .01em; } +.cap { font: 400 var(--t-xs)/1 var(--sans); color: var(--muted); letter-spacing: .01em; } /* --- the item's own views, which the first pass of item 169 left behind ---------------------- */ @@ -410,17 +646,28 @@ def _link(url: str | None, text: str | None = None) -> str: /* The project view's own copy of the board, which shares `_COLUMNS` with the instance strip so the two can never disagree about what a column means. */ -.board { display: flex; flex-wrap: wrap; gap: .6rem; margin: 0 0 1.2rem; } -.col { flex: 1 1 8rem; background: var(--raise); border: 1px solid var(--rule); +/* Six tallies in a wrapping row of five left `closed` alone across the full width, and two-word + labels broke over two lines so one row's cards stood taller than the next. A grid that fits its + own columns can do neither. */ +.board { display: grid; grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); + gap: .6rem; margin: 0 0 1.2rem; } +.col { background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); padding: .8rem var(--pad); } +/* `.count` had no rule at all — so a project's numbers rendered at paragraph size under labels + set in caps — the label outranking the number it labels, on the one view where you compare them. + The instance report sets the same pair at 34px and 11.5px. */ +.count { display: block; font: 600 var(--t-2xl)/1 var(--sans); color: var(--ink); + font-variant-numeric: tabular-nums; } +.count.zero { color: var(--faint); font-weight: 450; } .col.owed { border-left: 3px solid var(--waiting); } -.age { display: block; font: 400 .74rem/1 var(--sans); color: var(--muted); margin-top: .4rem; } +.age { display: block; font: 400 var(--t-xs)/1 var(--sans); color: var(--muted); + margin-top: .4rem; } .faint { color: var(--faint); } /* --- the line that says the checks ran ------------------------------------------------------- */ .settled { display: flex; align-items: baseline; gap: .45rem; - font-size: .82rem; color: var(--muted); margin: 0 0 1.4rem; } + font-size: var(--t-sm); color: var(--muted); margin: 0 0 1.4rem; } .settled::before { content: "✓"; color: var(--passed); font-weight: 600; } /* --- everything the evaluator wants, as one block rather than five rules --------------------- */ @@ -431,11 +678,11 @@ def _link(url: str | None, text: str | None = None) -> str: .more > details:first-child { border-top: 0; } details > summary { cursor: pointer; padding: .8rem var(--pad); list-style: none; - font: 500 .86rem/1.4 var(--sans); color: var(--muted); + font: 500 var(--t-md)/1.4 var(--sans); color: var(--muted); display: flex; align-items: center; gap: .55rem; } details > summary::-webkit-details-marker { display: none; } -details > summary::before { content: "+"; font: 400 .9rem/1 var(--mono); color: var(--faint); +details > summary::before { content: "+"; font: 400 var(--t-md)/1 var(--mono); color: var(--faint); width: .7rem; text-align: center; } /* The typographic minus, which pairs the + above rather than a hyphen. */ details[open] > summary::before { content: "\2212 "; } @@ -445,18 +692,26 @@ def _link(url: str | None, text: str | None = None) -> str: /* --- type --------------------------------------------------------------------------------- */ -h1 { font: 600 1.35rem/1.25 var(--sans); letter-spacing: -.015em; margin: 0 0 .3rem; +h1 { font: 600 var(--t-xl)/1.25 var(--sans); letter-spacing: -.015em; margin: 0 0 .3rem; text-wrap: balance; } -h2 { font: 600 .78rem/1 var(--sans); letter-spacing: .07em; text-transform: uppercase; +h2 { font: 600 var(--t-sm)/1 var(--sans); letter-spacing: .07em; text-transform: uppercase; color: var(--faint); margin: 1.8rem 0 .7rem; } -h4 { font: 550 .72rem/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; +h4 { font: 550 var(--t-xs)/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; color: var(--faint); margin: 0 0 .5rem; } +/* A thing's own name, not a section label (item 213). `h2` is styled for headings like *what does + not add up*, and reusing it for a project gave a project the weight of a caption. */ +h2.name { font: 600 var(--t-lg)/1.25 var(--sans); letter-spacing: 0; text-transform: none; + color: var(--ink); margin: 2rem 0 .15rem; } +/* A heading that is also a link is still a heading: underlined at rest it reads as body text that + happens to be big. The underline arrives on hover, where it answers "can I click this". */ +h2.name a { text-decoration: none; } +h2.name a:hover { text-decoration: underline; } p { margin: .7rem 0; } -.sub { font-size: .84rem; color: var(--muted); } +.sub { font-size: var(--t-md); color: var(--muted); } .sub a { color: var(--ink); } .bad { color: var(--refused); } .mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } -code { font: 400 .87em/1.4 var(--mono); background: var(--sunk); padding: .12em .32em; +code { font: 400 var(--t-sm)/1.4 var(--mono); background: var(--sunk); padding: .12em .32em; border-radius: 4px; } ul, ol { margin: .7rem 0; padding-left: 1.2rem; } li { margin: .25rem 0; } @@ -464,20 +719,21 @@ def _link(url: str | None, text: str | None = None) -> str: /* --- tables, which are data and should look like it ----------------------------------------- */ -table { border-collapse: collapse; width: 100%; font-size: .87rem; } +table { border-collapse: collapse; width: 100%; font-size: var(--t-md); } th { text-align: left; font-weight: 500; color: var(--muted); vertical-align: top; padding: .4rem 1rem .4rem 0; white-space: nowrap; } td { padding: .4rem 0; vertical-align: top; } -table.list { font-size: .84rem; } +table.list { font-size: var(--t-md); } table.list th { border-bottom: 1px solid var(--rule); padding-bottom: .5rem; - font: 550 .7rem/1 var(--sans); letter-spacing: .05em; text-transform: uppercase; } + font: 550 var(--t-2xs)/1 var(--sans); letter-spacing: .05em; + text-transform: uppercase; } table.list td { border-bottom: 1px solid var(--rule); padding: .5rem .8rem .5rem 0; } table.list tr:hover td { background: var(--sunk); } .wide { overflow-x: auto; } .facts { display: grid; grid-template-columns: auto 1fr; gap: .35rem 1rem; margin: 0; } -.facts dt { color: var(--muted); font-size: .84rem; } -.facts dd { margin: 0; font-family: var(--mono); font-size: .84rem; } +.facts dt { color: var(--muted); font-size: var(--t-md); } +.facts dd { margin: 0; font-family: var(--mono); font-size: var(--t-md); } /* --- the forms that decide ------------------------------------------------------------------- */ @@ -486,21 +742,21 @@ def _link(url: str | None, text: str | None = None) -> str: text-decoration: underline; cursor: pointer; } .decide { display: flex; gap: .5rem; flex-wrap: wrap; } .decide form { margin: 0; } -.decide button, .login button { - font: 550 .84rem/1 var(--sans); padding: .5rem .85rem; border-radius: var(--r-chip); +.decide button, .login button, .new button { + font: 550 var(--t-md)/1 var(--sans); padding: .5rem .85rem; border-radius: var(--r-chip); cursor: pointer; border: 1px solid var(--rule); background: var(--raise); color: var(--ink); } -.decide button:hover, .login button:hover { background: var(--sunk); } +.decide button:hover, .login button:hover, .new button:hover { background: var(--sunk); } .decide button.go { border-color: color-mix(in oklab, var(--passed) 45%, transparent); color: var(--passed); } .decide button.go:hover { background: color-mix(in oklab, var(--passed) 9%, transparent); } .login { display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; margin: 0; } .login input { - font: 400 .88rem/1 var(--mono); padding: .5rem .6rem; min-width: 18rem; flex: 1 1 18rem; + font: 400 var(--t-md)/1 var(--mono); padding: .5rem .6rem; min-width: 18rem; flex: 1 1 18rem; border: 1px solid var(--rule); border-radius: var(--r-chip); background: var(--canvas); color: var(--ink); } -.stuck { font: 600 .64rem/1 var(--sans); letter-spacing: .05em; text-transform: uppercase; +.stuck { font: 600 var(--t-2xs)/1 var(--sans); letter-spacing: .05em; text-transform: uppercase; color: var(--refused); border: 1px solid currentColor; border-radius: var(--r-chip); padding: .16rem .34rem; margin-left: .35rem; } @@ -508,12 +764,13 @@ def _link(url: str | None, text: str | None = None) -> str: details.evidence > summary { font-family: var(--mono); } pre { background: var(--sunk); border: 1px solid var(--rule); border-radius: var(--r); - padding: .9rem 1rem; overflow-x: auto; font: 400 .8rem/1.5 var(--mono); margin: .8rem 0; } + padding: .9rem 1rem; overflow-x: auto; font: 400 var(--t-sm)/1.5 var(--mono); + margin: .8rem 0; } details > pre { border-left-width: 3px; } footer { margin: 2.5rem 0 0; padding-top: 1.1rem; border-top: 1px solid var(--rule); - font-size: .78rem; color: var(--faint); + font-size: var(--t-sm); color: var(--faint); } footer strong { color: var(--muted); font-weight: 550; } @@ -523,7 +780,7 @@ def _link(url: str | None, text: str | None = None) -> str: @media (max-width: 40rem) { .wrap { padding: 0 1rem 3rem; } - .lede { font-size: 1.3rem; } + .lede { font-size: var(--t-xl); } .tally { flex: 1 1 45%; border-right: 0; border-bottom: 1px solid var(--rule); } } """ @@ -583,6 +840,7 @@ def _document( acting: Acting = READING, up: str = "", state: tuple[str, str] | None = None, + here: str = "", ) -> str: """The whole page. No script, no external asset, one inlined stylesheet. @@ -611,13 +869,52 @@ def _document( f"{_h(title)}\n" '
\n' f"{_bar(up=up, state=state)}\n" - f"{body}\n" + # **Every page, from here** (item 212). Rendering it per view is four chances to grow four + # opinions about what this product contains, which is the drift five items this week each + # cost a day to. + f"{_rail(acting, here=here, up=up)}\n" + # **And the way in, for the same reason.** It lived inside the instance report, so moving + # the front door left a locked-out operator landing on a page that said nothing about the + # lockout — a working lockout looking like a broken login, which is the exact failure item + # 168 fixed once already. + f'
{_signing_in(acting, up=up)}\n{body}
\n' f"
Hullwork {_h(__version__)} — {footing}
\n" "
\n" "\n" ) +#: The nouns this product has, and the order somebody meets them. Item 212, DR-0023. +#: +#: Navigation as furniture: a person should never have to remember what exists. The last two are the +#: operator's — DR-0021 gives a read link the instance and nothing that administers it — and a +#: control that leads to a `404` is worse than one that is not there, so a reader is shown neither. +_NOUNS: tuple[tuple[str, str, bool], ...] = ( + ("./", "Items", False), + ("projects", "Projects", False), + ("instance", "This instance", False), + ("doctor", "Why it will not work", True), + ("config", "What it received", True), +) + + +def _rail(acting: Acting, *, here: str = "", up: str = "") -> str: + """The sidebar every page carries, from one function. + + Four pages growing four opinions about what this product contains is the drift items 193, 194, + 200, 203 and 211 each cost a day to. This is that lesson applied before it happens rather than + after. + """ + links = "".join( + f'{_h(name)}" + for where, name, operators_only in _NOUNS + if not operators_only or acting.csrf + ) + return f'' + + def _bar(*, up: str, state: tuple[str, str] | None) -> str: """The top edge, which the page did not have. Item 169. @@ -913,6 +1210,39 @@ def _board(session: Session) -> str: DECISIONS_SHOWN = 5 +def does_this_need_you( + session: Session, settings: Settings, acting: Acting, *, error_reporting: bool +) -> tuple[str, tuple[str, str]]: + """The answer, and the work behind it. Item 212, DR-0023. + + This was the top of the instance report, which is where everything was. Moving the front door + to the items would have left it behind — so a person opening the page would meet a table of + rows and have to read them to learn whether any of it wanted them, which is the question item + 167 built the lede to answer without reading. + + It computes its own readiness rather than taking it, because the front door has no report to + hand it. **`error_reporting` is passed and not assumed**: the first version hardcoded `False` + here, so the front door opened on a red headline — *HULLWORK_ERROR_DSN is set but error + reporting is not running* — about an instance where it was running. Seen on the deployed page, + not by any test, which is why the test below compares the two views' answers instead of + asserting a shape. + """ + from hullwork import readiness + + report = readiness.check(session, settings, error_reporting=error_reporting) + waiting = list( + session.scalars( + select(_Item) + .where(_Item.state == ItemState.WAITING_APPROVAL) + .order_by(_Item.state_since.is_(None), _Item.state_since) + ).all() + ) + # The pill goes with it, because it answers the reader's second question — *is this thing + # working* — and it was on the bar of the view that stopped being the front door. + badge = ("ready", "ok") if report.ready else ("degraded", "bad") + return _lede(session, report, waiting=waiting) + _deciding(waiting, acting), badge + + def _lede(session: Session, report: object, *, waiting: list[_Item]) -> str: """One sentence, in the largest type on the page, answering *does this need me*. Item 167. @@ -1148,6 +1478,153 @@ def _violations_in(seal: object) -> bool: return bool(seal.get("violations")) +_TERMINAL_CODE = re.compile(r"`([^`]+)`") + + +def _as_code(said: str) -> str: + """`like this` becomes code, because these sentences were written for a terminal. + + Seen by opening the page rather than by reading the source: every detail in both panels comes + from a string a command prints, where a backtick is punctuation an eye skips. Rendered into HTML + they are literal backticks, and a literal backtick beside a command name reads as a typo in the + product rather than as a quotation in it. + + Escaped first, then marked up — never the other way round, or a detail containing `<` would be + escaping something this function had just written. + + The same is true of the emphasis, and this function did not handle it: `deliveries` says a + tracker is configured and no delivery has ever arrived, with the second half emphasised, and + the page served two pairs of literal asterisks. `_own_prose` carries the argument for why this + is safe on any input — by the time the substitution runs every `<` is already `<`, so it + cannot assemble markup out of a project slug that came from somebody else's tracker. + """ + return _emphasised( + _TERMINAL_CODE.sub(lambda found: f"{found.group(1)}", _h(said)) + ) + + +def _titled(one: object) -> str: + """A finding calls it `check` and a standing calls it `name`; both mean the same thing.""" + return str(getattr(one, "check", None) or getattr(one, "name", "")) + + +def _rows_for_standing(rows: Sequence[object]) -> str: + """The panel's rows, for both views. Items 203 and 208. + + **One renderer, because two that happen to look alike is how the borrowed list ended up in + both of them** — and how a fix to one would leave the other painting a `cannot` amber. + + Takes anything with `check`/`name`, a state and a `detail`, which is what `doctor.Finding` and + `features.Standing` both are. A decision reads quiet and a fault reads red: DR-0019 in colour, + because painting a choice somebody made as a defect tells them to go and repair it. + """ + said = [] + for one in rows: + state = getattr(one, "state", "") + word = getattr(state, "value", state) + broken = word in ("broken", "cannot") + said.append( + f'
  • ' + f'{_h(word)}' + f'{_h(_titled(one))}' + f'

    {_as_code(getattr(one, "detail", ""))}

  • ' + ) + return "".join(said) + + +def why_it_will_not_work( + session: Session, settings: Settings, *, acting: Acting = READING +) -> str: + """`doctor`, for somebody without a shell. Item 208, DR-0022. + + **The findings, not a second diagnosis.** `doctor.examine` already returns them and item 199's + pre-flight already renders them elsewhere; a page that asked its own questions would drift from + the command an operator quotes in a bug report. + + `not_from_here`'s downgrade comes with them and must: the receiver is not the dispatcher, and a + page reporting the model credential missing — on an instance where it is present in the half + that uses it — sends somebody to repair a working machine. + """ + from hullwork import doctor as doctor_module + + found = doctor_module.examine( + session, + settings, + code_forge=None, + env_file=Path(settings.deployment_env_file or ".env"), + compose_file=( + Path(settings.deployment_compose_file) if settings.deployment_compose_file else None + ), + ) + worrying = [one for one in found if one.state is not doctor_module.State.OK] + rows = _rows_for_standing(worrying) + body = ( + "

    Why it will not work

    " + + ( + f'' + f'

    {len(found) - len(worrying)} of {len(found)} check(s) are fine.

    ' + if worrying + else f'

    All {len(found)} check(s) are fine.

    ' + ) + ) + return _document("Hullwork — doctor", body, acting=acting, here="doctor") + + +def what_it_received(settings: Settings, *, acting: Acting = READING) -> str: + """`config`, for somebody without a shell. Item 208. + + **No credential is printed**, and that is `settings_report`'s property rather than this + function's: a secret reads `set` or `not set` before it ever reaches here. Worth saying because + `config` reads like the most disclosing thing in the product and is in fact the most carefully + disclosing thing in it. + """ + from hullwork import settings_report + + rows = "".join( + f"{_h(name)}{_h(value)}" + f"{_h(source)}{_h(reaches)}" + for name, value, source, reaches in settings_report.rows(settings) + ) + body = ( + "

    What it received

    " + '

    What this process was handed, which is a different question from what you ' + "wrote in a file. No credential is printed: a secret reads set or " + "not set.

    " + '
    ' + f"{rows}
    variablevaluefromreaches
    " + ) + return _document( + "Hullwork — configuration", body, acting=acting, here="config" + ) + + +def _what_this_instance_has_switched_on(session: Session, settings: Settings) -> str: + """The feature-by-feature standing, worst first. Item 203. + + **From `features.on_this_instance`, which the terminal prints too**, so the page and + `hullwork status` cannot come to disagree about the same instance — the rule `instance`'s own + docstring states about its numbers, applied to its states. + + An instance with everything on says so in one line: fourteen green rows is a wall a reader stops + looking at, and the thing they came for is whichever one is not green. + """ + from hullwork import features + + standing = features.on_this_instance(session, settings) + worrying = [one for one in standing if one.state is not features.ON] + if not worrying: + return ( + '

    Every feature this instance can have is on. ' + f"{len(standing)} of {len(standing)}.

    " + ) + rows = _rows_for_standing(worrying) + on = len(standing) - len(worrying) + return ( + f'' + f'

    {on} of {len(standing)} feature(s) on.

    ' + ) + + def instance( session: Session, settings: Settings, *, error_reporting: bool, acting: Acting = READING ) -> str: @@ -1216,13 +1693,6 @@ def instance( ) prices = spend.Prices.from_settings(settings) - waiting = list( - session.scalars( - select(_Item) - .where(_Item.state == ItemState.WAITING_APPROVAL) - .order_by(_Item.state_since.is_(None), _Item.state_since) - ).all() - ) #: **The order is the item, and not the order this page had.** the interface document #: asks three questions — on fire, what is it doing, is anything waiting on me — and it answered @@ -1230,8 +1700,10 @@ def instance( #: context second: a problem or a decision is something to *do*, and what the machine is busy #: with is something to *know*. body = ( - _lede(session, report, waiting=waiting) - + _deciding(waiting, acting) + # The answer and the decisions are the front door's now (item 212). They are still here, + # from the same function, because an operator who opens the report on a bad morning should + # not have to go back to learn whether anything wants them. + does_this_need_you(session, settings, acting, error_reporting=error_reporting)[0] + _proof( session, merged=merged, @@ -1242,12 +1714,18 @@ def instance( + _now(session, prices) + _strip(session) + _disagreements(session, settings) - + '

    Every item and its evidence · ' - 'Projects

    ' + # **Above the folds, not inside one** (item 203). What is off and what cannot work is the + # reason somebody opened this; the configuration table below is what they read afterwards. + + _what_this_instance_has_switched_on(session, settings) + # The link row that used to live here is the rail now (item 212): two sets of navigation + # on one page is two places to add the next noun to, and one of them will be forgotten. + '
    ' - + _signing_in(acting) + _fold( - "How this instance is configured", + # **Renamed once a real configuration page existed** (item 211). These seven rows are + # state — version, forge, sweep, backlog — and calling them *configured* was harmless + # while nothing else claimed the word. `/config` claims it now, and two things with one + # name is the drift this repository has spent a week removing. + "How it is right now", f'
    {table}
    ', ) # Before the attempts block, exactly as `status` orders them: this one has *what arrived* @@ -1266,7 +1744,9 @@ def instance( # The instance's own state, on the bar rather than in a folded table: it is the second question # a reader has, and item 167 had buried it under a disclosure. badge = ("ready", "ok") if report.ready else ("degraded", "bad") - return _document("Hullwork — this instance", body, acting=acting, state=badge) + return _document( + "Hullwork — this instance", body, acting=acting, state=badge, here="instance" + ) #: How many rows a list shows. Bounded because an instance that has been running for a year has @@ -1311,9 +1791,12 @@ def _project_health(project: _Project) -> tuple[str, str]: "cached manifest no longer validates, so every error from here lands red until " "`hullwork projects refresh` adopts a working one", ) + # `_as_code`, not `_h` (item 213). These two sentences were written for a terminal — one of + # them names `hullwork projects refresh` — and escaping them served the backticks, which beside + # a command name reads as a typo in the product rather than as a quotation of it. return ( - f'
  • {_h(credential[1])}
  • ' - f'
  • {_h(manifest[1])}
  • ', + f'
  • {_as_code(credential[1])}
  • ' + f'
  • {_as_code(manifest[1])}
  • ', credential[0] + manifest[0], ) @@ -1362,7 +1845,114 @@ def _project_cost(session: Session, project_id: int, prices: Prices | None) -> s ) + "" -def projects(session: Session, settings: Settings) -> str: +def _what_was_rotated(rotated: tuple[str, str | None] | None) -> str: + """A new webhook secret, once — and what it broke, before the value rather than after. + + Minting one is item 206's problem and this is that plus one: rotating **stops the URL the + tracker is currently posting to**. Somebody who reads the new value and not that sentence has a + working instance that receives nothing, which is the failure this product exists to notice and + the worst one to cause. + """ + if rotated is None or rotated[1] is None: + return "" + slug, token = rotated + return ( + f"

    {_h(slug)} has a new webhook secret

    " + "

    The URL your tracker was posting to has stopped working — update it before the " + "next error, or nothing arrives. This is the only time the new one is shown: only " + "its hash is stored.

    " + f'
    /webhooks/glitchtip/{_h(slug)}/{_h(token)}
    ' + ) + + +def _refusal(said: str | None) -> str: + """A refusal in the command's own words. Item 206. + + `add_project` raises with a sentence per problem — a manifest that does not parse names the key, + a repository the token cannot read says which, a slug already taken says so. A page that + replaced + those with *something went wrong* would send somebody to a shell to find out what it already + knew, which is the whole thing DR-0022 is closing. + """ + return f'

    {_h(said)}

    ' if said else "" + + +def _the_form(acting: Acting, *, answered: str = "") -> str: + """The primary action: a button above the list, and the three fields behind it. Item 214. + + It was a form at the bottom of this page, under every project the instance already had, so + registering the second one meant scrolling past the first. DR-0023 read the opposite off + GlitchTip — *the primary action is a button, top right, always* — and item 212 built everything + in that decision except this. + + **`answered` is what came back from the last submission**, and it is why the drawer has a state + at all. A form behind a disclosure that re-renders closed returns the reader to a button with + the refusal hidden inside it, which reads as a page that ignored them; and a webhook secret is + shown once, so a closed drawer over one is data loss wearing a layout bug's clothes. + + No form for a reader with a link: DR-0021 gives them reading, and a control they cannot submit + is + worse than one that is not there. The CSRF token is the session's, exactly as the two decisions + on an item carry it. + """ + if not acting.csrf: + return "" + # **A visible label, not only an `aria-label`** (item 213). The assistive name was there; what + # was missing is the one a sighted person needs — a placeholder disappears exactly when it is + # needed, which is the moment somebody has typed into the field and looks up to check. + fields = "".join( + f'

    ' + f'

    ' + for name, label, extra in ( + ("slug", "Name it here", ' placeholder="shop" required'), + ("repo", "Repository", ' placeholder="owner/name" required'), + ("forge", "Forge", ' value="forgejo"'), + ) + ) + return ( + f'
    ' + "Connect a project" + + answered + + '
    ' + + f'' + + fields + + "
    " + + '

    It reads hullwork.yml from the default branch and refuses ' + "anything it cannot validate. Nothing is written to your repository.

    " + + "
    " + ) + + +def _what_was_just_made(made: object) -> str: + """The webhook URL, once. Item 206, and the sentence DR-0022 requires beside it. + + A copy-exactly-once value is worse in a browser than in a terminal — scrollback, history, a + screenshot — so this says plainly that it is the only time, rather than being withheld. Only the + hash is stored, exactly as the command stores it, so no later view can show it again. + """ + if made is None: + return "" + project = getattr(made, "project", None) + token = getattr(made, "token", "") + slug = getattr(project, "slug", "") + return ( + f'

    {_h(slug)} is connected

    ' + "

    Point your error tracker's webhook at this. This is the only time it is shown — " + "only its hash is stored, so nobody, including this page, can print it again. Lose it and " + "hullwork projects rotate-secret issues another, which stops the old one.

    " + f'
    /webhooks/glitchtip/{_h(slug)}/{_h(token)}
    ' + ) + + +def projects( + session: Session, + settings: Settings, + *, + acting: Acting = READING, + just_made: object = None, + refused: str | None = None, + rotated: tuple[str, str | None] | None = None, +) -> str: """Every project this instance serves. Item 142, and the level the tree was missing. The page had instance, items and one item; a project appeared as a *column* in a list. That was @@ -1376,21 +1966,20 @@ def projects(session: Session, settings: Settings) -> str: """ prices = spend.Prices.from_settings(settings) found = list(session.scalars(select(_Project).order_by(_Project.slug)).all()) + answered = _what_was_just_made(just_made) + _what_was_rotated(rotated) + _refusal(refused) if not found: body = ( - "

    Projects

    " - '

    This instance · ' - 'Items and their evidence

    ' - "

    No project is registered. `hullwork projects add` connects one, and " - "`hullwork propose --checkout PATH` prints a manifest to start from.

    " + '

    Projects

    ' + + _the_form(acting, answered=answered) + + "

    No project is registered yet.

    " ) - return _document("Hullwork — projects", body) + return _document("Hullwork — projects", body, acting=acting, here="projects") - blocks = [] + blocks: list[str] = [] for project in found[:MAX_ITEMS]: health, _ = _project_health(project) blocks.append( - f'

    {_h(project.slug)}

    ' + f'

    {_h(project.slug)}

    ' f'

    {_h(project.forge)} · {_h(project.repo)}' f"{'' if project.active else ' · not active'}

    " f"" @@ -1402,15 +1991,16 @@ def projects(session: Session, settings: Settings) -> str: else "" ) body = ( - "

    Projects

    " + '

    Projects

    ' + + _the_form(acting, answered=answered) + + "
    " '

    Read-only. One instance serves one forge, so this is every project ' - 'on this one. This instance · ' - 'Items and their evidence

    ' + "on this one.

    " + bound + "".join(blocks) ) del prices - return _document("Hullwork — projects", body) + return _document("Hullwork — projects", body, acting=acting, here="projects") def project(session: Session, settings: Settings, slug: str) -> str | None: @@ -1585,7 +2175,16 @@ def artefact( ) -def items(session: Session, *, only: str | None = None, acting: Acting = READING) -> str: +def items( + session: Session, + *, + only: str | None = None, + acting: Acting = READING, + here: str = "", + settings: Settings | None = None, + front: bool = False, + error_reporting: bool = False, +) -> str: """Every item this instance has, most recent first. The view a reviewer lands on. `only` is one of the board's column keys, which is what makes a count on the front page lead @@ -1640,11 +2239,15 @@ def items(session: Session, *, only: str | None = None, acting: Acting = READING scope = "" if states is None else f" in {_h(only)}" if rows: + # **The widest table in the product, and the only one that was not allowed to scroll** + # (item 215). Seven columns on the view a person lands on: without this the body itself + # scrolls sideways on a narrow window, which moves the navigation while you read a title. table = ( - '' + '
    itemprojectstatelane
    ' + "" "" + "".join(body_rows) - + "
    itemprojectstatelanetitlelast seenissue / pull
    " + + "
    " ) # The bound, stated. Silently showing 200 of 4,000 is how a page teaches a reader that an # instance has done less than it has. @@ -1660,14 +2263,38 @@ def items(session: Session, *, only: str | None = None, acting: Acting = READING if states is not None else "No items yet. Nothing has arrived from the error tracker on this instance" ) + # **The emptiness has a cause and the cause has an action** (item 214). On an instance with + # no projects the sentence above is true and useless: nothing arrived because nothing is + # connected, and what to do about it was two clicks and a scroll away. Only offered to + # somebody who could act on it, and only when there is genuinely nothing to connect from. + if states is None and front and acting.csrf and not session.scalar( + select(func.count()).select_from(_Project) + ): + shown += ( + '. Connect a project and its errors land here' + ) - everything = '' if states is None else ' · All items' + everything = '' if states is None else ' All items' + # **The answer first, and only where this is the front door** (item 212). On `items?in=queued` + # it would be answering a question the reader did not ask. Asked for by the route rather than + # inferred from `settings` being present, because a headline that vanishes when a caller forgets + # an argument is a first screen that can silently stop saying the one thing it is for. + if front and settings is None: # pragma: no cover - a wiring mistake, not a state + raise TypeError("the front door needs settings: it renders the instance's own answer") + answer, badge = ( + does_this_need_you(session, settings, acting, error_reporting=error_reporting) + if front and settings + else ("", None) + ) + # **Only what is true of what is on screen.** *Most recently seen first* under an empty list + # describes an order there is nothing to order, and the link to the instance was a second copy + # of a noun the rail already carries. + order = " Most recently seen first." if rows else "" body = ( - "

    Items

    " - f'

    {shown}{scope}. Most recently seen first. ' - f'Instance{everything}

    ' + table + answer + "

    Items

    " + f'

    {shown}{scope}.{order}{everything}

    ' + table ) - return _document("Hullwork — items", body, acting=acting) + return _document("Hullwork — items", body, acting=acting, here=here, state=badge) def _above_the_fold(attempt: Attempt, prices: Prices | None) -> str: @@ -1782,6 +2409,36 @@ def _decide(found: Item, acting: Acting, *, up: str) -> str: return "" +def just_the_login(acting: Acting) -> str: + """The login and nothing else, for the door that replaces the token (DR-0021, item 204). + + **Nothing about the instance is on it.** A page showing a name, a version or a count beside the + password would answer questions for somebody who has not signed in — and the whole reason + this path may exist at all is that it discloses one thing: this host has a login. + + Reuses `_login` and `_document` because a second form would be a second thing to keep correct, + and the correct one has `autocomplete="current-password"` and a real `
    ` in it for reasons + item 168 measured. + + **And it does not use `_document`.** Written that way first, and the visible text came out + as `▚ hullwork · Hullwork 0.1.0a8 — read-only…` — the instance's name + and its exact version, on the one page a prober can reach. A version tells somebody which + advisories to go and read. Found by a test that asserted the principle and had to be fixed + before it could measure it. + """ + return ( + "\n" + '' + '' + '' + f'' + f"Sign in\n" + '
    ' + f'

    Sign in

    {_login(acting, up="")}' + "
    \n" + ) + + def _login(acting: Acting, *, up: str) -> str: """The login, or what to run when there is nothing to log in to. Item 168. @@ -1804,7 +2461,7 @@ def _login(acting: Acting, *, up: str) -> str: ) -def _signing_in(acting: Acting) -> str: +def _signing_in(acting: Acting, *, up: str = "") -> str: """A way in that does not depend on there being something to decide. Item 168. **Found by opening the deployed page on a calm day.** The login lived inside the list of @@ -1825,7 +2482,7 @@ def _signing_in(acting: Acting) -> str: '

    No password is set on this instance. Run hullwork password on ' "the host, once, and this becomes a login.

    " ) - inside = _login(acting, up="") if acting.offered else nothing_set + inside = _login(acting, up=up) if acting.offered else nothing_set return f'
    Sign in
    {inside}
    ' diff --git a/hullwork/trial.py b/hullwork/trial.py index 2f84a88..4ecef67 100644 --- a/hullwork/trial.py +++ b/hullwork/trial.py @@ -365,7 +365,7 @@ def run( log.debug( "trial starting", extra={"checkout": str(checkout), "sha": resolved.sha, "into": str(into)} ) - return work._attempt( + outcome = work._attempt( session, settings, work.Eligible(item=item, project=project), @@ -376,3 +376,77 @@ def run( rehearse_into=into, local_checkout=resolved, ) + # **Written here, where the session is** (item 202). The page is a function of the records this + # run just made, and they live in a database that exists for the length of this call — so the + # only place it can be rendered is before that call returns. + # + # A failure to write it must not fail the attempt: the artefact is the claim, and the page is + # the same claim laid out. Losing the second is a worse page, not a worse verdict. + try: + write_page(session, settings, item.id, into) + except OSError: # pragma: no cover - a directory that took the artefact and not this + log.warning("could not write the evidence page", extra={"into": str(into)}) + return outcome + + +# --- the page, without an instance (item 202) ---------------------------------------------------- + +#: What a served page carries and a written one must not: navigation to routes that do not exist as +#: files, and anything that offers to act on an instance nobody is running. Removed rather than left +#: to disappoint — a dead link is worse than no link, and a control that looks like it decides +#: something is worse than a dead link. +_NAVIGATION = re.compile(r']*href="(?:#|data:))[^>]*>.*?', re.S) +_ACTIONS = re.compile(r"<(form|button)\b.*?", re.S) + + +def page_for(session: Session, settings: Settings, item_id: int) -> str | None: + """The item page an instance serves, rendered for a file rather than for a request. + + **The same function, not a second one.** `page.item` is what a reviewer is shown, and a trial + that rendered its own would drift from it — which items 193, 194 and 200 each cost a day to. + What differs is what is stripped afterwards, and both reasons are the same one: there is no + instance behind this page. + + * every link is a route (`items/3`, `items?in=waiting`) — correct served, dead as a file; + * every control posts somewhere, and there is nowhere. + + `Acting()` is already the read-only branch (item 166) — what an instance with no operator key + renders on every request — and **it is what makes the second true**, not the strip below. + Measured by mutation: removing `_ACTIONS` fails nothing, because `READING` emits no control in + any state these tests can produce, while rendering with operator rights instead fails seven. + + The strip stays as a second lock on a shut door, and is labelled as defence rather than sold as + the guarantee it is not — the same call as item 196's `first_sample`, and for a stronger reason: + this page is a **file**, and a file leaves the machine that made it. If `page.item` ever grows a + control that survives `READING`, it will do so for a served page where posting works, and this + one would carry it to somebody with no instance to post to. + """ + from hullwork import page as page_module + + html = page_module.item(session, settings, item_id, acting=page_module.READING) + if html is None: + return None + html = _ACTIONS.sub("", html) + return _NAVIGATION.sub(lambda found: _only_the_words(found.group(0)), html) + + +def _only_the_words(anchor: str) -> str: + """An anchor's text, without the anchor. What it said stays; where it went does not exist.""" + return re.sub(r"<[^>]+>", "", anchor) + + +def write_page( + session: Session, settings: Settings, item_id: int, into: Path +) -> Path | None: + """Write that page beside what the trial produced, and return where. + + Beside, so somebody opening the directory finds it without being told. One file: a trial has one + item, so an index of one thing would be a page whose only link is the page you are on. + """ + html = page_for(session, settings, item_id) + if html is None: + return None + into.mkdir(parents=True, exist_ok=True) + written = into / "evidence.html" + written.write_text(html, encoding="utf-8") + return written diff --git a/tests/test_a_page_you_cannot_lose.py b/tests/test_a_page_you_cannot_lose.py new file mode 100644 index 0000000..5648c29 --- /dev/null +++ b/tests/test_a_page_you_cannot_lose.py @@ -0,0 +1,175 @@ +"""Reading your own instance without a credential you can lose. Item 204, DR-0021. + +The operator, handed the page's URL and told it could not be shown again: *the token thing is +ridiculous. It is one more piece of friction for users.* + +The person who runs `page-token` reached it through `docker exec` on the host — they can already +read the database, the environment file and the Docker socket. Withholding their own page's URL +from them protects nothing they do not have. So the password reads, and the token keeps the job it +is good at: handing reading to somebody with no account. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from sqlalchemy.orm import Session + +from hullwork import page +from hullwork.page import Acting +from hullwork.security import generate_token, hash_token + +A_SESSION = Acting(csrf="c", offered=True) +NO_SESSION = Acting(csrf=None, offered=True) +NO_PASSWORD = Acting(csrf=None, offered=False) + + +def _a_token(session: Session) -> str: + minted = generate_token() + page.issue(session, hash_token(minted)) + session.commit() + return minted + + +# --- the door that needs no token ----------------------------------------------------------------- + + +def test_a_session_reads_without_a_token(session: Session) -> None: + """The whole item. Nothing to lose, nothing to rotate, nothing to hand back to a colleague.""" + assert page.opens(session, page.MINE, acting=A_SESSION) + + +def test_no_session_does_not_read(session: Session) -> None: + """The password has to buy something, or this is a page with no door at all.""" + assert not page.opens(session, page.MINE, acting=NO_SESSION) + + +def test_with_no_password_configured_it_is_a_stranger_like_any_other(session: Session) -> None: + """**The property this must not spend.** Everything without a valid token answers `404`, + including a wrong one, so the page cannot be found by probing. A login form at a fixed path + would end that — so the door exists only where somebody deliberately set a password. + """ + assert not page.opens(session, page.MINE, acting=NO_PASSWORD) + + +def test_the_reserved_word_cannot_be_a_real_token(session: Session) -> None: + """A minted token that happened to equal the reserved word would open the page for anybody with + a session, and close it for the person holding the link. Asserted against how tokens are made, + not against a list of things somebody thought of.""" + minted = {generate_token() for _ in range(200)} + + assert page.MINE not in minted + assert len(page.MINE) < min(len(one) for one in minted) + + +def test_no_password_configured_offers_no_login(session: Session) -> None: + """**The property DR-0021 promised not to spend, and the one defect the mutation caught me on.** + + Everything without a valid token answers `404`, including a wrong one, so the page cannot be + found by probing. A login form at a fixed path ends that — so it exists only where somebody + deliberately set a password, and `offered` is that opt-in. Removing it from this function failed + nothing: every other test here asks `opens`, and this is the other question. + """ + del session + + assert not page.offers_a_login(page.MINE, NO_PASSWORD) + + +def test_a_password_and_no_session_is_offered_the_login(session: Session) -> None: + """Or the door that replaces the token has no handle: there would be no way to acquire the + session that reads.""" + del session + + assert page.offers_a_login(page.MINE, NO_SESSION) + + +def test_a_login_is_never_offered_on_a_token_path(session: Session) -> None: + """A wrong token stays a `404`. Offering a login there would tell a prober that this path is + real, which is the whole thing the `404` withholds.""" + del session + + assert not page.offers_a_login("not-the-one", NO_SESSION) + + +# --- everything the token did, it still does ------------------------------------------------------ + + +def test_a_real_token_still_opens_it(session: Session) -> None: + """Somebody holding a link handed to them keeps it. That is what the token is for.""" + minted = _a_token(session) + + assert page.opens(session, minted, acting=NO_SESSION) + + +def test_a_wrong_token_is_still_refused(session: Session) -> None: + _a_token(session) + + assert not page.opens(session, "not-the-one", acting=NO_SESSION) + + +def test_a_wrong_token_is_refused_even_with_a_session(session: Session) -> None: + """A session is authority for `MINE` and for nothing else. Otherwise the reserved word would be + decoration and any path would open with a cookie.""" + _a_token(session) + + assert not page.opens(session, "not-the-one", acting=A_SESSION) + + +def test_reading_with_a_session_is_not_permission_to_act(session: Session) -> None: + """Item 166's split, unchanged: what may read and what may act are two questions, and this item + only answers the first. The renderer receives `Acting` and decides the second itself.""" + assert page.opens(session, page.MINE, acting=A_SESSION) + assert A_SESSION.csrf is not None, "acting is decided by the renderer, from this, not by opens" + + +def test_the_login_page_says_nothing_about_the_instance() -> None: + """The one path that is not a `404`, so it is the one that must disclose least. A version, a + name or a count beside the password would answer questions for somebody who has not signed in — + and the whole reason this door may exist is that it discloses exactly one thing. + **Measured on the visible text, not on the document.** The first version of this searched the + whole HTML and tripped on a word inside a CSS comment — and while fixing that it found the real + leak, which the wrong method had buried: the shared chrome put `▚ hullwork` and + `Hullwork 0.1.0a8` on the page. A version tells a prober which advisories to go and read. + """ + from re import DOTALL, sub + + said = page.just_the_login(NO_SESSION) + without_style = sub(r"", "", said, flags=DOTALL) + visible = " ".join(sub(r"<[^>]+>", " ", without_style).split()) + + assert "password" in said and " None: + """`_login` already refuses after too many wrong passwords, and this door must not be a way + around that.""" + said = page.just_the_login(Acting(csrf=None, offered=True, locked_minutes=3)) + + assert " None: + """**Asserted per route, not by counting.** Ten routes each deciding this would be ten + implementations of one question, which is what items 193, 194, 200 and 203 each cost a day to. + + The first version of this compared two call-shape counts, which was a proxy for the property and + stopped being one the moment a second legitimate gate existed — the login needs a different + question from every other route, and a third helper answers *may this be told there is a login*. + What matters is that a **route** never asks: the helpers do, and a fourth route cannot bring a + fourth opinion with it. + """ + from pathlib import Path + + source = Path(page.__file__).parent.joinpath("main.py").read_text(encoding="utf-8") + routes = [block for block in source.split("\ndef ") if block.startswith("page_")] + + assert len(routes) >= 6, "this test is watching routes that no longer exist" + for route in routes: + assert "page.opens(" not in route, route.split("(")[0] + assert "MINE" not in source, "the reserved word is the gate's business, not each route's" diff --git a/tests/test_connect_a_project_from_the_page.py b/tests/test_connect_a_project_from_the_page.py new file mode 100644 index 0000000..ff9f2d9 --- /dev/null +++ b/tests/test_connect_a_project_from_the_page.py @@ -0,0 +1,237 @@ +"""Registering a project without opening a shell. Item 206, DR-0022. + +Item 205 counted the gap: nineteen subcommands in the terminal, four write routes on the page. This +is the one that forces a shell in the first hour, and the receiver already holds every credential it +needs — `forge_token` is *issue write and content read*, which is exactly what reading a +repository's +`hullwork.yml` takes. + +The operator's argument, which DR-0022 records: a security property nobody reaches protects nobody. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session, sessionmaker + +from hullwork import operator +from hullwork.config import get_settings +from hullwork.db import make_engine +from hullwork.models import Base, Project + +MANIFEST = """ +project: mine +git: {provider: forgejo, repo: o/r} +tests: "pytest" +test_path: tests +""" + + +@pytest.fixture +def db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + url = f"sqlite:///{tmp_path}/page.db" + engine = make_engine(url) + Base.metadata.create_all(engine) + monkeypatch.setenv("HULLWORK_DATABASE_URL", url) + monkeypatch.setenv("HULLWORK_BASE_URL", "https://hullwork.example") + monkeypatch.setenv("HULLWORK_FORGE_URL", "https://forge.example") + monkeypatch.setenv("HULLWORK_FORGE_TOKEN", "tok-forge-must-never-render") + get_settings.cache_clear() + yield sessionmaker(bind=engine)() + get_settings.cache_clear() + + +@pytest.fixture +def client() -> TestClient: + from hullwork.main import app + + return TestClient(app) + + +def _signed_in(db: Session, client: TestClient) -> str: + """A session, and the CSRF token that goes with it — what every write route already needs.""" + operator.set_password(db, "correct horse") + db.commit() + client.post("/page/me/login", data={"password": "correct horse"}) + return operator.acting(db, client.cookies.get(operator.COOKIE)) or "" + + +def _the_forge_answers(monkeypatch: pytest.MonkeyPatch, text: str = MANIFEST) -> None: + """The repository read `add_project` makes. The receiver's own credential does this today.""" + from hullwork import cli + + class _Forge: + """**Both methods, and the second is why this is a comment.** A double missing `close` made + the route report `'_Forge' object has no attribute 'close'` as if the operator had typed + something wrong — the third hand-built double to drift from its protocol today.""" + + def read_manifest(self, repo: str) -> str: + return text + + def close(self) -> None: + return None + + monkeypatch.setattr(cli, "make_forge", lambda _settings: _Forge()) + + +# --- the door that replaces the shell ------------------------------------------------------------- + + +def test_a_project_is_registered_from_the_page( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The whole item, and the step that currently sends somebody to `docker compose exec`.""" + csrf = _signed_in(db, client) + _the_forge_answers(monkeypatch) + + answered = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + + assert answered.status_code == 200 + assert db.query(Project).filter(Project.slug == "mine").one_or_none() is not None + + +def test_the_webhook_url_is_shown_once_and_said_to_be_once( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """A copy-exactly-once value in a browser is worse than in a terminal — scrollback, history, a + screenshot. DR-0022's answer is that this is a presentation requirement, so the page says + plainly that this is the only time, and what to do when it is lost.""" + csrf = _signed_in(db, client) + _the_forge_answers(monkeypatch) + + answered = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + + assert "/webhooks/" in answered.text + assert "only time" in answered.text.lower() + assert "rotate-secret" in answered.text + + +def test_it_is_never_shown_again( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """Only the hash is stored, exactly as the command does it. A later view that could show it + would make the sentence above a lie.""" + csrf = _signed_in(db, client) + _the_forge_answers(monkeypatch) + made = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + secret = made.text.split("/webhooks/")[1].split('"')[0].split("<")[0].strip() + + later = client.get("/page/me/projects") + + assert secret not in later.text + assert secret not in client.get("/page/me/").text + + +# --- the guards every write route already has ----------------------------------------------------- + + +def test_without_a_session_it_is_refused(db: Session, client: TestClient) -> None: + """DR-0021 and item 166: no write route is reachable without the password.""" + answered = client.post( + "/page/me/projects", data={"slug": "mine", "repo": "o/r", "forge": "forgejo"} + ) + + assert answered.status_code == 404 + assert db.query(Project).count() == 0 + + +def test_a_wrong_csrf_is_refused( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + _signed_in(db, client) + _the_forge_answers(monkeypatch) + + answered = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": "not-the-one"}, + ) + + assert answered.status_code == 403 + assert db.query(Project).count() == 0 + + +# --- the three things a person gets wrong --------------------------------------------------------- + + +def test_a_manifest_that_does_not_parse_says_which_line( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + """The command already writes a sentence per problem, naming the key. A generic failure here + would send somebody to the shell to find out what the page already knew. + + **Asserted on the refusal, not on the page.** The first version looked for the word `repo` + anywhere in the response — and the form on that same page has `name="repo"` in it, so it passed + whatever the message said. Found by mutation: replacing the sentence with *something went wrong* + changed nothing. + """ + import re + + csrf = _signed_in(db, client) + _the_forge_answers(monkeypatch, text="project: mine\ngit: {provider: forgejo}\n") + + answered = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + + said = re.search(r'c-refused">([^<]*)', answered.text) + assert said is not None, "the refusal is not on the page at all" + assert "repo" in said.group(1).lower(), said.group(1) + assert db.query(Project).count() == 0 + + +def test_a_slug_already_taken_says_so( + db: Session, client: TestClient, monkeypatch: pytest.MonkeyPatch +) -> None: + csrf = _signed_in(db, client) + _the_forge_answers(monkeypatch) + client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + + again = client.post( + "/page/me/projects", + data={"slug": "mine", "repo": "o/r", "forge": "forgejo", "csrf": csrf}, + ) + + assert "mine" in again.text + assert db.query(Project).filter(Project.slug == "mine").count() == 1 + + +def test_a_reader_with_a_link_is_not_shown_a_form_they_cannot_submit( + db: Session, client: TestClient +) -> None: + """**DR-0021's split, on this page.** A token gives reading; the password gives the buttons. A + form rendered to somebody who cannot submit it is worse than no form: it offers, and then it + refuses. + + Found by mutation — nothing asserted the absence, so removing the check that hides it changed + nothing. + """ + from hullwork import page + from hullwork.security import generate_token, hash_token + + minted = generate_token() + page.issue(db, hash_token(minted)) + db.commit() + + shown = client.get(f"/page/{minted}/projects") + + assert shown.status_code == 200 + assert "listens" in served @@ -198,6 +198,13 @@ def test_the_policy_forbids_script_because_there_is_none(db: Session, client: Te f"{page.PREFIX}/{{token}}/logout", f"{page.PREFIX}/{{token}}/items/{{item_id}}/approve", f"{page.PREFIX}/{{token}}/items/{{item_id}}/human", + # **Item 206, DR-0022**, and this list failing on the day it was written is the guard working. + # Administration moves to the page deliberately: the receiver already holds every credential + # registering a project needs — `forge_token` is *issue write and content read* — and it still + # holds none that can push, which is the law this does not touch. + f"{page.PREFIX}/{{token}}/projects", + # Item 207: the rest of a project's life, on one route with an action rather than four names. + f"{page.PREFIX}/{{token}}/projects/{{slug}}", ) @@ -206,8 +213,9 @@ def test_only_the_named_routes_under_the_prefix_accept_a_post(client: TestClient The invariant it was protecting was never "no POST" — it was *no accidental mutation surface*, asserted by walking the application's own routes rather than by trusting a decorator to stay a - `get` through the next refactor. That still holds, and it is now specific: four routes may take - a POST, they are named here, and one more appearing fails this test on the day it is written. + `get` through the next refactor. That still holds, and it is now specific: the routes that may + take a POST are named here, and one more appearing fails this test on the day it is written — + which is exactly what item 206 did, and why the fifth name below carries its reason. A view acquiring a POST by accident is what this catches, and it is worth catching: the token is a bearer credential in a URL, so a mutating route that only checks the token would let anybody @@ -259,7 +267,7 @@ def test_the_numbers_are_the_ones_status_prints(db: Session, client: TestClient) token = generate_token() page.issue(db, hash_token(token)) - body = client.get(f"/page/{token}").text + body = client.get(f"/page/{token}/instance").text printed = io.StringIO() cli_main(["status"], out=printed) @@ -508,3 +516,76 @@ def test_the_page_serves_the_mark_the_design_document_specifies() -> None: # this assertion learned the hard way. What matters is that nothing is *requested*. head = html.split("")[0] assert 'href="http' not in head and 'src="http' not in head, "the head must fetch nothing" + + +# --- the door that replaces the token (item 204, DR-0021) ---------------------------------------- + + +def test_signing_in_at_the_session_door_is_not_a_404(db: Session, client: TestClient) -> None: + """**Found in use, on the first attempt, by the operator** (2026-08-10). Item 204 put the login + behind the same gate as everything else — and that gate requires a session at `/page/me/`, so + signing in required already being signed in. The form posted, and Hullwork answered + `{"detail":"Not Found"}`. + + A door with a handle you can only reach from inside is a door nobody opens. + """ + from hullwork import operator + + operator.set_password(db, "correct horse") + db.commit() + + answered = client.post( + "/page/me/login", data={"password": "correct horse"}, follow_redirects=False + ) + + assert answered.status_code != 404 + assert operator.COOKIE in answered.cookies, "and it signed them in" + + +def test_the_session_door_still_refuses_without_a_password_configured( + db: Session, client: TestClient +) -> None: + """The property DR-0021 spends nothing of: an instance that never opted in has no login to post + to, and says so with the same `404` an unknown path gets.""" + answered = client.post("/page/me/login", data={"password": "anything"}) + + assert answered.status_code == 404 + + +def test_a_wrong_password_at_the_session_door_still_says_nothing( + db: Session, client: TestClient +) -> None: + """Item 168's rule survives the new door: a wrong password answers exactly as a right one does, + because an error page is an oracle. What differs is the cookie.""" + from hullwork import operator + + operator.set_password(db, "correct horse") + db.commit() + + answered = client.post("/page/me/login", data={"password": "wrong"}, follow_redirects=False) + + assert answered.status_code != 404 + assert operator.COOKIE not in answered.cookies + + +def test_the_whole_way_in(db: Session, client: TestClient) -> None: + """**The flow, end to end, because the parts passing separately is what let this ship broken.** + + Open the door, sign in, read the page. Item 204 had a test for the gate and a test for the login + page and none for the sequence, so the one step between them — the form's own POST — was never + exercised by anything until a person tried it. + """ + from hullwork import operator + + operator.set_password(db, "correct horse") + db.commit() + + shut = client.get("/page/me/") + assert shut.status_code == 200 + assert " None: assert "ghcr.io/easybytehub/hullwork:" in text +def test_it_pins_a_version_and_not_a_moving_tag() -> None: + """Whatever it pins, it is a version. `edge` moves, and a deployment that follows a moving tag + cannot say what it is running when somebody asks.""" + text = _compose() + + assert "ghcr.io/easybytehub/hullwork:edge" not in text + assert f"ghcr.io/easybytehub/hullwork:{__version__}" in text, ( + "the image doing the scaffolding pins itself (item 201)" + ) + + +def pin_disagreement( + version: str, *, surface: str, pinned: str, published: Iterable[str] | None +) -> str | None: + """The failure, or `None` when there is nothing to report. Item 216. + + A pure function taking the registry's answer rather than asking for it, for the reason item 192 + gives: the interesting states are the ones that cannot be reached on demand, and a rule only + exercised against the live registry is a rule tested in whichever state today happens to be in. + """ + if published is None: + raise LookupError("the registry could not be asked, which is not `nothing published`") + if version not in published: + # The window between the bump and the release. The tree is genuinely ahead of every + # release; pinning `__version__` is the honest answer and the surface is allowed to lag. + if pinned != version: + return f"no image is published for {version} and the compose pins {pinned}" + return None + if surface != version: + return ( + f"an image is published for {version} and the surface records {surface}: " + "`docs/releasing.md` has the two post-release steps, in order" + ) + if pinned != surface: + return f"the surface records {surface} and the compose pins {pinned}" + return None + + +def test_the_registry_being_unreachable_is_not_a_pass() -> None: + """**Written because a mutation escaped.** Treating `None` as *nothing published* still passed, + because during the window the two answers agree — they only diverge after the release, which is + the one moment nobody would be running this by hand. + + `published_tags` returns `None` for *could not ask* on purpose. Blurring it into *nothing + published* makes an unreachable registry look like permission, which is the failure mode this + whole file exists to make impossible.""" + with pytest.raises(LookupError): + pin_disagreement("0.1.0a9", surface="0.1.0a8", pinned="0.1.0a9", published=None) + + +def test_the_window_between_the_bump_and_the_release_is_allowed() -> None: + """The deadlock item 216 found: the pin is `__version__` by construction and the surface cannot + be re-recorded until the image is public, which cannot happen until this passes.""" + assert pin_disagreement( + "0.1.0a9", surface="0.1.0a8", pinned="0.1.0a9", published=("0.1.0a8",) + ) is None + + +def test_the_window_does_not_excuse_a_pin_that_names_something_else() -> None: + """**The branch nothing covered.** Deleting the check inside the window escaped a mutation + round: every test here either expected `None` from the window or exercised a published version, + so a window that accepted any pin at all looked exactly like a window that accepted the right + one. Pinning a release that does not exist is how a compose file sends somebody to a 404.""" + said = pin_disagreement( + "0.1.0a9", surface="0.1.0a8", pinned="0.1.0a7", published=("0.1.0a8",) + ) + + assert said is not None + assert "0.1.0a7" in said + + +def test_a_published_version_requires_the_surface_and_the_pin_to_name_it() -> None: + """And the window closes by itself the moment the image exists — no flag to clear. + + **Asserted on which failure it is**, not merely that there is one: the first version checked for + a message containing `0.1.0a8`, and deleting this branch fell through to the next one, whose + message also contains it. Two different faults reading the same to a test is a test that cannot + tell you which of them you have.""" + said = pin_disagreement( + "0.1.0a9", surface="0.1.0a8", pinned="0.1.0a9", published=("0.1.0a8", "0.1.0a9") + ) + + assert said is not None + assert "post-release" in said, f"the wrong branch answered: {said}" + + +def test_the_finished_state_is_quiet() -> None: + assert pin_disagreement( + "0.1.0a9", surface="0.1.0a9", pinned="0.1.0a9", published=("0.1.0a8", "0.1.0a9") + ) is None + + +@pytest.mark.skipif( + not os.environ.get("ASK_THE_REGISTRY"), + reason="asks ghcr.io; set ASK_THE_REGISTRY=1 to run it (CI does)", +) def test_it_pins_the_release_this_repository_documents() -> None: """**Asserted against the recorded surface**, not against a literal typed twice. A compose file telling somebody to run a version the documentation does not describe is the two-halves problem item 192 closed, arriving in a third file. + + **Except during the window between the bump and the release** (item 216). The pin is + `__version__` by construction and the surface cannot be re-recorded until the image is public, + so the first version of this deadlocked the release that found it: `publish.sh --pr` gates the + derived tree before opening anything, and the gate could only pass after the thing it gates. + + The exemption is the fact item 192 already asks for, not a flag: **is an image published for + the version this tree claims to be?** If it is, the surface must record it and the pin must be + it. If not, the tree is genuinely ahead of every release, and saying so is the honest answer. + + Offline is not a pass. `published_tags` returns `None` for *could not ask*, which is a different + answer from *nothing published* — blurring them would make an unreachable registry look like + permission. """ - text = _compose() + found = re.search(r"ghcr\.io/easybytehub/hullwork:([^\s}]+)", _compose()) + + assert found is not None, "the scaffolded compose names no published image at all" + said = pin_disagreement( + __version__, surface=SURFACE["version"], pinned=found.group(1), published=published_tags() + ) - assert f"ghcr.io/easybytehub/hullwork:{SURFACE['version']}" in text + assert said is None, said def test_building_is_still_possible_and_now_explicit() -> None: diff --git a/tests/test_the_evidence_a_reviewer_came_for.py b/tests/test_the_evidence_a_reviewer_came_for.py index 5dbd35c..39b0b58 100644 --- a/tests/test_the_evidence_a_reviewer_came_for.py +++ b/tests/test_the_evidence_a_reviewer_came_for.py @@ -487,16 +487,19 @@ def test_the_relative_links_actually_reach_the_other_views( door = client.get(f"/page/{token}") assert str(door.url).endswith(f"/page/{token}/"), "the slash is what makes the rest relative" + # Item 212 made the door the items themselves, so the first hop of this walk is gone and the + # rail — which is on every page and therefore has five chances to resolve wrongly — is what the + # rest of it follows. + assert "

    Items

    " in door.text - listed = client.get(urljoin(str(door.url), _href(door.text, "Every item and its evidence"))) - assert listed.status_code == 200 - assert "

    Items

    " in listed.text + report = client.get(urljoin(str(door.url), _href(door.text, "This instance"))) + assert report.status_code == 200, "the noun the arithmetic moved behind" - detail = client.get(urljoin(str(listed.url), _href(listed.text, f"#{item.id}"))) + detail = client.get(urljoin(str(door.url), _href(door.text, f"#{item.id}"))) assert detail.status_code == 200 assert "Attempt 1" in detail.text - back = client.get(urljoin(str(detail.url), _href(detail.text, "All items"))) + back = client.get(urljoin(str(detail.url), _href(detail.text, "Items"))) assert back.status_code == 200 assert "

    Items

    " in back.text diff --git a/tests/test_the_operational_dashboard.py b/tests/test_the_operational_dashboard.py new file mode 100644 index 0000000..f6773d6 --- /dev/null +++ b/tests/test_the_operational_dashboard.py @@ -0,0 +1,165 @@ +"""What this instance has switched on. Item 203. + +`hullwork features` answers for a **checkout** and hands four names back as somebody else's +question: + + INSTANCE_SHAPED = ("filing a production error as an issue", "the daily page", + "notifications", "the recurrence watch") + +with the comment *`doctor` owns these*. It does not — `doctor` answers resources, and a resource is +not a feature. So four capabilities were declared by name as nobody's question, and the hole was +written down in the code before the operator asked for the dashboard that fills it. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import socket +import urllib.request + +import pytest +from pydantic import SecretStr +from sqlalchemy.orm import Session + +from hullwork import features +from hullwork.config import Settings +from hullwork.models import Project + +FORGE = Settings(forge_url="https://forge.example.com", forge_token=SecretStr("t")) + + +def _project(session: Session, *, notify: str | None = None, active: bool = True) -> Project: + manifest: dict[str, object] = {"project": "p"} + if notify is not None: + manifest["notify"] = {"channel": notify} + project = Project( + slug="p", forge="forgejo", repo="o/r", active=active, + webhook_secret_hash="x", # noqa: S106 + manifest=manifest, + ) + session.add(project) + session.commit() + return project + + +def _named(standing: list[features.Standing], name: str) -> features.Standing: + return next(one for one in standing if one.name == name) + + +# --- the hole item 186 named -------------------------------------------------------------------- + + +def test_every_instance_shaped_feature_has_an_answer(session: Session) -> None: + """The whole item. `features` names four and takes none of them; this takes all four, so the + list stops being a question nobody has.""" + answered = {one.name for one in features.on_this_instance(session, Settings())} + + assert set(features.INSTANCE_SHAPED) <= answered + + +def test_nothing_is_still_handed_to_nobody() -> None: + """**Asserted by construction.** A fifth name added to `INSTANCE_SHAPED` tomorrow has to be + answered here without anybody remembering, or the hole reopens exactly as it was.""" + from inspect import getsource + + source = getsource(features.on_this_instance) + + assert "INSTANCE_SHAPED" in source, "the answers are derived from the list, not kept beside it" + + +# --- the three states --------------------------------------------------------------------------- + + +def test_a_configured_forge_with_a_project_can_file(session: Session) -> None: + _project(session) + + filing = _named( + features.on_this_instance(session, FORGE), "filing a production error as an issue" + ) + + assert filing.state is features.ON + + +def test_no_forge_is_a_cannot_that_says_what_to_do(session: Session) -> None: + """A missing thing, with the remedy in words somebody can type.""" + _project(session) + + filing = _named( + features.on_this_instance(session, Settings()), "filing a production error as an issue" + ) + + assert filing.state is features.CANNOT + assert "HULLWORK_FORGE_URL" in filing.detail + + +def test_a_channel_nobody_chose_is_off_and_not_a_fault(session: Session) -> None: + """**DR-0019's rule, on the instance side.** `notify: none` is the default and a decision, and + showing a decision as a defect is the one way this dashboard could insult its reader.""" + _project(session, notify="none") + + notifications = _named(features.on_this_instance(session, Settings()), "notifications") + + assert notifications.state is features.OFF + assert "cannot" not in notifications.detail.lower() + + +def test_a_channel_that_parses_and_does_not_deliver_is_a_cannot(session: Session) -> None: + """`telegram` and `email` parse in the manifest and are refused at delivery, which `docs/status` + says in prose and nothing said where somebody would look.""" + _project(session, notify="telegram") + + notifications = _named(features.on_this_instance(session, Settings()), "notifications") + + assert notifications.state is features.CANNOT + assert "telegram" in notifications.detail + + +def test_the_page_is_off_until_somebody_mints_a_token(session: Session) -> None: + """It is off until `page-token` is run, deliberately — and that is a decision, not a fault.""" + page = _named(features.on_this_instance(session, Settings()), "the daily page") + + assert page.state is features.OFF + assert "page-token" in page.detail + + +# --- the bounds --------------------------------------------------------------------------------- + + +def test_it_asks_nothing_of_the_network(session: Session, monkeypatch: pytest.MonkeyPatch) -> None: + """**This renders on a request.** A page that opened a socket per view would make somebody's + dashboard a load test of their forge, and a reachability answer that costs a page load is one + nobody refreshes.""" + + def forbidden(*_a: object, **_k: object) -> None: + raise AssertionError("the dashboard reached the network while rendering") + + monkeypatch.setattr(socket, "create_connection", forbidden) + monkeypatch.setattr(urllib.request, "urlopen", forbidden) + _project(session) + + assert features.on_this_instance(session, FORGE) + + +def test_what_is_off_or_broken_comes_before_what_works(session: Session) -> None: + """*On* is the least interesting state. A reader opens this looking for what is not working, and + a wall of green with one red line in the middle is a wall of green.""" + _project(session, notify="telegram") + + standing = features.on_this_instance(session, FORGE) + + states = [one.state for one in standing] + assert states == sorted(states, key=lambda one: one is features.ON) + + +def test_a_state_it_did_not_establish_is_not_claimed(session: Session) -> None: + """Whether the forge *answers* costs a network call, and this makes none. Saying `on` about a + configured-but-unreachable forge is items 193, 194 and 199 arriving in the dashboard.""" + _project(session) + + filing = _named( + features.on_this_instance(session, FORGE), "filing a production error as an issue" + ) + + assert "configured" in filing.detail.lower() + assert "answers" not in filing.detail.lower() or "not asked" in filing.detail.lower() diff --git a/tests/test_the_page_can_be_acted_on.py b/tests/test_the_page_can_be_acted_on.py index 9985586..f1d1544 100644 --- a/tests/test_the_page_can_be_acted_on.py +++ b/tests/test_the_page_can_be_acted_on.py @@ -409,7 +409,7 @@ def test_the_machine_strip_links_what_it_counts_and_a_zero_is_not_a_link( found.state = ItemState.READY db.commit() - front = client.get(f"/page/{TOKEN}/").text + front = client.get(f"/page/{TOKEN}/instance").text assert 'href="items?in=queued"' in front assert 'href="items?in=working"' not in front @@ -544,7 +544,7 @@ def test_nothing_disagreeing_is_one_line_and_still_says_it_ran( monkeypatch.setenv("HULLWORK_MODEL_NAME", "anthropic/claude-sonnet-5") get_settings.cache_clear() - front = client.get(f"/page/{TOKEN}/").text + front = client.get(f"/page/{TOKEN}/instance").text assert "Nothing disagrees: the three checks ran and found nothing." in front assert "

    What does not add up

    " not in front @@ -553,11 +553,18 @@ def test_nothing_disagreeing_is_one_line_and_still_says_it_ran( def test_the_evaluator_material_is_folded_away(db: Session, client: TestClient) -> None: """the interface document promised the daily reader never pays for the evaluator's questions, and the configuration table was starting in the second half of the first - screen anyway.""" - front = client.get(f"/page/{TOKEN}/").text + screen anyway. + + **The summary was renamed in item 211 and this test asserted the words.** Those seven rows are + state — version, forge, sweep, backlog — and calling them *configured* was harmless until + `/config` existed and genuinely was the configuration. The property here was never the wording: + it is that the block is folded, and that it comes after the lede. + """ + front = client.get(f"/page/{TOKEN}/instance").text - assert "
    How this instance is configured" in front - assert front.index("class=\"lede") < front.index("How this instance is configured") + assert "How it is right now" in front + assert "
    How it is right now" in front, "folded, not open" + assert front.index("class=\"lede") < front.index("How it is right now") def test_no_microsecond_timestamp_is_rendered_for_a_human( diff --git a/tests/test_the_page_without_an_instance.py b/tests/test_the_page_without_an_instance.py new file mode 100644 index 0000000..8c69a37 --- /dev/null +++ b/tests/test_the_page_without_an_instance.py @@ -0,0 +1,140 @@ +"""The page a trial writes beside its artefact. Item 202. + +`hullwork try` is the way to see a red-green cycle with no forge account and no instance, and it +produced one markdown file and a closing line pointing at `hullwork page-token` — a command that +needs a database, two containers and a minted token, to see a surface about a run that already +happened on the reader's own laptop. + +It is small because `try` already builds the session the page renders from: `ephemeral_session` is +an in-memory database recording exactly what production records. Nothing needs collecting. + +Every test here was verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +from hullwork import page as page_module +from hullwork import trial +from hullwork.config import Settings +from hullwork.models import Attempt, AttemptOutcome, AttemptPhase, Item, ItemState, Lane, Project + +#: Anything that would make a browser leave the file it was opened from. +_REACHES_OUT = re.compile(r'(?:src|href)="(?!\./|#|data:)[^"]*"') + + +def _a_trial(session: object) -> Item: + project = Project( + slug="p", forge="forgejo", repo="o/r", active=True, + webhook_secret_hash="x", # noqa: S106 + manifest={}, + ) + session.add(project) # type: ignore[attr-defined] + session.flush() # type: ignore[attr-defined] + item = Item( + project_id=project.id, fingerprint="fp", title="ValueError: boom", + lane=Lane.GREEN, state=ItemState.PR_OPEN, occurrences=1, + ) + session.add(item) # type: ignore[attr-defined] + session.flush() # type: ignore[attr-defined] + session.add( # type: ignore[attr-defined] + Attempt( + item_id=item.id, phase_reached=AttemptPhase.PUBLISH, + outcome=AttemptOutcome.PR_OPEN, consumed=True, rehearsal=True, + ) + ) + session.commit() # type: ignore[attr-defined] + return item + + +# --- the page exists, without anything behind it -------------------------------------------------- + + +def test_a_trial_can_render_the_page_it_produced() -> None: + """The whole item: the evidence a reviewer reads, from a run that needed no instance.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + html = trial.page_for(session, Settings(), item.id) + + assert html + assert "ValueError: boom" in html + + +def test_it_is_the_page_an_instance_serves_and_not_a_second_one() -> None: + """**Asserted by construction.** A second renderer drifts — items 193, 194 and 200 each cost a + day to exactly that — so this has to be the same function, differing only in what it strips.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + served = page_module.item(session, Settings(), item.id) + written = trial.page_for(session, Settings(), item.id) + + assert served is not None and written is not None + assert "ValueError: boom" in served and "ValueError: boom" in written + + +def test_nothing_in_it_reaches_a_host(tmp_path: Path) -> None: + """It is opened from a filesystem, offline, possibly on a machine that never had an instance. + A stylesheet or a font from somewhere else would make the page depend on the thing this whole + command exists to do without.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + html = trial.page_for(session, Settings(), item.id) or "" + + assert not _REACHES_OUT.findall(html), _REACHES_OUT.findall(html)[:3] + + +def test_no_link_leads_somewhere_that_does_not_exist() -> None: + """A page full of dead links is worse than no page. A trial has one item and one attempt, so + there is nothing to navigate to — and the navigation that assumes an instance is removed rather + than left to disappoint.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + html = trial.page_for(session, Settings(), item.id) or "" + + for href in re.findall(r'href="([^"]*)"', html): + assert href.startswith(("#", "data:")), f"a link that goes nowhere from a file: {href}" + + +def test_nothing_offers_to_act_on_an_instance_that_is_not_there() -> None: + """Worse than a dead link: a control that looks like it decides something. `Acting` already has + the branch — `READING` is what an instance with no operator key renders, and it predates the + ability to act at all.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + html = (trial.page_for(session, Settings(), item.id) or "").lower() + + assert " None: + """Beside, so somebody who opens the directory finds it without being told. The artefact is per + attempt; the page is the same evidence a reviewer would be shown.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + written = trial.write_page(session, Settings(), item.id, tmp_path) + + assert written is not None + assert written.parent == tmp_path + assert written.suffix == ".html" + assert written.read_text(encoding="utf-8").startswith(" None: + """`try`'s whole claim, and this must not be what breaks it.""" + session = trial.ephemeral_session() + item = _a_trial(session) + + trial.write_page(session, Settings(), item.id, tmp_path) + + written = sorted(p.name for p in tmp_path.rglob("*")) + assert not [name for name in written if name.endswith(".db")] + assert len(written) == 1 diff --git a/tests/test_the_panel_measured.py b/tests/test_the_panel_measured.py new file mode 100644 index 0000000..66198da --- /dev/null +++ b/tests/test_the_panel_measured.py @@ -0,0 +1,216 @@ +"""The surface itself, asserted on the stylesheet rather than on a screenshot. Item 213. + +DR-0023 settled what the page contains. This is +what the audit of the running instance found wrong with how it is drawn, at 1680 px: + +- the work used **43%** of the window, `.wrap` being a 62rem measure for a document; +- **twelve** size/weight pairs on one page, eight of the thirteen `font-size` declarations being + two-decimal one-offs, and one computed size (`11.69px`) an artefact of a nested `em`; +- `--faint` failing WCAG AA in **both** themes — 2.73:1 light, 3.90:1 dark — on the footer, which is + where the page explains what the URL is and what the session may do. + +Every test here reads the stylesheet or a rendered view. A rule that holds on the page somebody +happened to look at is not a rule, which is the lesson of every `**` and backtick found so far. + +Every test verified by reintroducing the defect it covers. +""" + +from __future__ import annotations + +import re +from collections.abc import Iterator +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from sqlalchemy.orm import Session, sessionmaker + +from hullwork import operator, page +from hullwork.config import get_settings +from hullwork.db import make_engine +from hullwork.models import Base, Project + +#: The surfaces text is ever set on, and the tokens that set it. Both halves are named here rather +#: than discovered, because a token nobody lists is a token nobody checks. +SURFACES = ("canvas", "raise", "sunk") +INKS = ("ink", "muted", "faint", "waiting", "working", "passed", "refused", "human") + +#: WCAG 2.2 AA for text under 18.66px bold / 24px regular. Every one of these is body text or a +#: pill's caps, so none of them earns the 3:1 large-text allowance. +AA = 4.5 + + +@pytest.fixture +def db(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: + url = f"sqlite:///{tmp_path}/panel.db" + engine = make_engine(url) + Base.metadata.create_all(engine) + monkeypatch.setenv("HULLWORK_DATABASE_URL", url) + get_settings.cache_clear() + session = sessionmaker(bind=engine)() + session.add( + Project( + slug="shop", forge="forgejo", repo="acme/shop", + webhook_secret_hash="x", # noqa: S106 - a fixture, not a credential + ) + ) + session.commit() + yield session + get_settings.cache_clear() + + +@pytest.fixture +def client() -> TestClient: + from hullwork.main import app + + return TestClient(app) + + +def _signed_in(db: Session, client: TestClient) -> None: + operator.set_password(db, "correct horse") + db.commit() + client.post("/page/me/login", data={"password": "correct horse"}) + + +# --- colour --------------------------------------------------------------------------------------- + + +def _relative_luminance(hex_colour: str) -> float: + raw = hex_colour.lstrip("#") + channels = [int(raw[i : i + 2], 16) / 255 for i in (0, 2, 4)] + linear = [c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4 for c in channels] + return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2] + + +def _contrast(one: str, other: str) -> float: + first, second = _relative_luminance(one), _relative_luminance(other) + lighter, darker = max(first, second), min(first, second) + return (lighter + 0.05) / (darker + 0.05) + + +def _palette() -> tuple[dict[str, str], dict[str, str]]: + """Both themes, read out of `light-dark()` in the stylesheet. + + Reading the source rather than a browser is the point: this runs in the suite, on every change, + and a contrast regression is a thing somebody ships without noticing precisely because it still + looks fine to the person who chose it. + """ + light: dict[str, str] = {} + dark: dict[str, str] = {} + for name, first, second in re.findall( + r"--([a-z-]+):\s*light-dark\((#[0-9a-fA-F]{6}),\s*(#[0-9a-fA-F]{6})\)", page._STYLE + ): + light[name], dark[name] = first, second + return light, dark + + +def test_every_text_colour_meets_aa_on_every_surface_it_is_used_on() -> None: + """**Measured on the running instance, then made a rule.** `--faint` carried the footer at + 2.73:1 in light and 3.90:1 in dark — the sentence explaining that the URL is a credential and + what the session can do, set in the least legible thing on the page. + + Both themes, because a palette is two palettes and the second one is the one nobody opens. + """ + for theme_name, theme in zip(("light", "dark"), _palette(), strict=True): + missing = [one for one in (*INKS, *SURFACES) if one not in theme] + assert not missing, f"{theme_name} defines no {missing}: renamed, and so unchecked" + + for ink in INKS: + for surface in SURFACES: + found = _contrast(theme[ink], theme[surface]) + assert found >= AA, ( + f"{theme_name}: --{ink} on --{surface} is {found:.2f}:1, under {AA}:1" + ) + + +# --- type ----------------------------------------------------------------------------------------- + + +def test_the_stylesheet_declares_no_size_outside_the_scale() -> None: + """Twelve size/weight pairs on one page came from thirteen declarations, eight of them + two-decimal one-offs — `.84rem`, `.82rem`, `.97rem`, `.87rem`. Each was reasonable where it was + written and none of them were reasonable together, which is what a scale is for.""" + declared = re.findall(r"font-size:\s*([^;}]+)", page._STYLE) + + assert declared, "the stylesheet stopped declaring sizes; this test is measuring nothing" + for size in declared: + assert size.strip().startswith("var(--t-"), f"{size.strip()} is not on the scale" + + +def test_no_size_is_an_artefact_of_where_it_sits() -> None: + """`11.69px` was on the page and nobody chose it: `.6em` inside something already reduced. A + size in `em` means the same rule renders differently depending on what it is nested in, which + is the opposite of a scale.""" + for size in re.findall(r"font-size:\s*([^;}]+)", page._STYLE): + assert "em" not in size.replace("rem", ""), f"{size.strip()} is relative to its parent" + + +# --- the shell ------------------------------------------------------------------------------------ + + +def test_the_counters_cannot_orphan_their_last_card() -> None: + """Six tallies in a wrapping row of five left `closed` alone across the full width, and the + two-word labels broke over two lines so one row's cards were taller than the next. A grid that + fits its own columns cannot do either.""" + tally = re.search(r"\.board\s*\{[^}]*\}", page._STYLE) + + assert tally is not None, "the counters' container was renamed; this test is measuring nothing" + assert "grid-template-columns" in tally.group(0) + assert "auto-fit" in tally.group(0) or "auto-fill" in tally.group(0) + + +def test_prose_is_held_to_a_measure_and_the_shell_is_not() -> None: + """**The fix for 43% is not a wider column of prose.** A panel fills the window and holds its + sentences to a readable measure inside it; widening `.wrap` alone would trade dead margins for + 120-character lines, which is worse than what the audit found.""" + shell = re.search(r"\.wrap\s*\{[^}]*\}", page._STYLE) + + assert shell is not None + assert "--measure" in page._STYLE, "no measure is defined for prose" + assert "62rem" not in (shell.group(0)), "the document width is still the shell's width" + + +# --- what the views serve ------------------------------------------------------------------------- + + +def test_no_view_serves_a_backtick_or_an_asterisk(db: Session, client: TestClient) -> None: + """**Found by grepping every view for the character**, not by looking at the one just changed: + `projects` said *not asked yet — \\`hullwork status\\` records this when it runs*, the same + defect fixed in `doctor` that morning, in the view that does not go through `_as_code`. + + Both characters, one sweep: they are the same mistake — prose written for a terminal, served + without being turned into what a browser draws. + """ + _signed_in(db, client) + + for where in ("", "instance", "projects", "doctor", "config"): + shown = client.get(f"/page/me/{where}") + + assert shown.status_code == 200, where + served = re.sub(r"", "", shown.text, flags=re.S) + served = re.sub(r".*?", "", served, flags=re.S) + assert "`" not in served, f"a terminal backtick reached {where or 'the front door'}" + assert "**" not in served, f"markdown emphasis reached {where or 'the front door'}" + + +def test_every_control_says_what_it_is(db: Session, client: TestClient) -> None: + """Three inputs, three placeholders, no `