From 341f02f7d96475310c25c489020b5c7ba101eb8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javier=20Miralles=20Ranca=C3=B1o?= <68760931+FlagshipDev@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:43:32 +0200 Subject: [PATCH] 0.1.0a7: the page stopped being a report and became where you decide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five items since `0.1.0a6`, and they are one thing seen from four angles. The read-only page told an operator that two items had been waiting twenty-one hours and gave them no way to do anything about it — on an instance whose own operator was looking at it. ## What changed **Two buttons on an item waiting for a decision** — *let the agent try it* and *I will take this one* — and the state machine chose which two: `LEGAL[WAITING_APPROVAL]` does not contain `REJECTED`, because that state means a reviewer closed a pull request and it feeds a tally keyed by the label they used. Calling this "reject" would have filed a decision about *whether to attempt* into the count of *review* decisions, and the number would have drifted with nobody able to see why. **The read URL gains no authority.** It is a bearer credential in a path — a saved page, a screenshot, a forwarded link — so it keeps reading everything and can spend nothing. Acting needs a second credential that never appears in a URL: `hullwork password`, once, and then a login a browser's password manager fills in. Sessions are rows, so ending them all is a `DELETE` rather than a key rotation that happens to log everybody out. **This reverses a decision `approve` argued for in writing** — *"an approval endpoint would be a permanent attack surface for something done by one person a handful of times"* — and the ground it stood on had moved. "The operator already has the host" now means ssh, `docker compose exec` and a CLI, from a page read on a laptop. **The page answers before it reports.** One sentence in the largest type, then the items **by name** rather than a count, then the machine's figures as a subordinate row, then everything an evaluator wants behind native `
`. The old board put the operator's own queue in the second of six identical cards, so finding it meant reading six labels. **And it now shows what this instance has proved** — found, tried, merged, held, came back — in figures, because a page for a self-hosted product is also the only shop window it has. The motif is the product's own mechanism: a red-to-green rule under the header, once, and nowhere else. ## Two secure designs that were not usable The credential behind those buttons changed twice before this release, and both earlier attempts were sound and unusable. A stored key had to be pasted into a form. A one-time link had to be fetched from the host every twelve hours — open the page, ssh, run a command, copy a link, open it, come back, reload: eight steps, against the single command the whole exercise set out to improve on. The reason given for skipping the obvious answer was wrong, and it cost a milestone: a chosen password was said to need scrypt or argon2 and therefore a new dependency in the half of Hullwork that listens on a network. `hashlib.scrypt` is in the standard library — 37 ms per attempt at `n=2**14`, which is the work factor and most of the answer to online guessing, with a ten-try lockout for the rest. ## What is not shown **Nobody has pressed either button.** The instance logs `decided from the page` when it happens and has zero of them. Everything here is verified against the code — the read token cannot decide, a wrong CSRF token cannot, the lockout closes and reopens on its own, both themes render — and none of that is the claim. The claim is that an operator can decide from a browser in two steps, and the only instrument that can measure it is an operator. Each of the five items now carries that as its gate. [`docs/status.md`](docs/status.md) is unchanged in its list of what nobody has shown, and it is still the section that matters. Signed-off-by: Javier Miralles Rancaño <68760931+FlagshipDev@users.noreply.github.com> --- hullwork/__init__.py | 2 +- hullwork/cli.py | 94 +- hullwork/main.py | 23 +- hullwork/models.py | 66 +- hullwork/operator.py | 175 +++- hullwork/page.py | 921 ++++++++++++++---- hullwork/upstream.py | 6 +- .../b7d3c85a12ef_a_link_instead_of_a_key.py | 58 ++ ...1d63b27_a_password_the_browser_can_fill.py | 68 ++ tests/test_page_surface.py | 15 +- .../test_the_evidence_a_reviewer_came_for.py | 37 +- tests/test_the_page_can_be_acted_on.py | 368 +++++-- 12 files changed, 1423 insertions(+), 410 deletions(-) create mode 100644 migrations/versions/b7d3c85a12ef_a_link_instead_of_a_key.py create mode 100644 migrations/versions/c8f4a1d63b27_a_password_the_browser_can_fill.py diff --git a/hullwork/__init__.py b/hullwork/__init__.py index f4697ec..ebbff5c 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.0a6" +__version__ = "0.1.0a7" __all__ = ["__version__"] diff --git a/hullwork/cli.py b/hullwork/cli.py index d93dd00..1008bac 100644 --- a/hullwork/cli.py +++ b/hullwork/cli.py @@ -10,6 +10,7 @@ """ import argparse +import getpass import json import logging import os @@ -1201,44 +1202,54 @@ def _cmd_page_token( return 0 -def _cmd_operator_key( +def _cmd_password( args: argparse.Namespace, session: Session, settings: Settings, out: TextIO ) -> int: - """Mint the credential that **acts** on the read-only page. Item 166. + """Set the password that unlocks the two buttons on the page. Item 168. - **A second credential rather than a promotion of the first**, and the difference is the whole - security model: the page token is a bearer string in a URL — a saved page, a screenshot of the - address bar, a link mailed to a colleague — so it reads everything and may never spend money. - This one is pasted into a form once and exchanged for a session cookie, so it never lands - anywhere a URL lands. + **Read from a prompt, not from an argument.** A password on a command line is in shell history, + in `ps`, and in whatever collects either. `--stdin` exists for a provisioning script, which has + the same problem and has usually already solved it. - Refuses to replace an existing key without `--rotate`, for the reason `page-token` does: a - second person running this to "get in" would lock out the first, and the failure would read as - the buttons being broken rather than as a key having changed underneath them. + Third design in three items, and the two before were secure and unusable: a stored key to paste, + then a one-time link that meant a trip to the host every twelve hours. This is what every + self-hosted tool does, for a mechanical reason — a browser's password manager fills it in. """ - existing = operator.configured(session) - if existing and not args.rotate: + if args.end_sessions: + ended = operator.end_every_session(session) + print(f"Ended {ended} session(s). The password is unchanged.", file=out) + return 0 + + if args.stdin: + chosen = sys.stdin.readline().rstrip("\n") + else: + chosen = getpass.getpass("New password: ") + if chosen != getpass.getpass("Again: "): + raise CommandError("the two did not match; nothing was changed") + + least = 12 + if len(chosen) < least: raise CommandError( - "this instance already has an operator key, and it cannot be shown again — it was " - "printed once and only its hash is stored.\n" - " To replace it: hullwork operator-key --rotate. Every session open right now ends " - "the moment you do." + f"that is {len(chosen)} character(s); this wants at least {least}.\n" + " It is the only thing between a stranger who found the page URL and your budget,\n" + " and the browser will remember it for you — so length is nearly free here." ) - key = operator.issue_key(session) - print("Rotated. Every session that was open has ended." if existing else - "The page can now be acted on.", file=out) - print("\n This key is shown once and cannot be recovered:\n", file=out) - print(f" {key}\n", file=out) + existing = operator.configured(session) + operator.set_password(session, chosen) + print("Password changed. Every session that was open has ended." if existing else + "Password set. The page can now be signed in to.", file=out) print( - " Paste it into the page's login, once per browser. Unlike the page URL it is **not** a\n" - " link and must never become one: it is the difference between somebody reading this\n" - " instance and somebody spending its budget.\n" + f"\n Open the page and sign in once per browser; the session lasts " + f"{operator.LIFETIME.days} days and renews while you use it.\n" + "\n" + " What a session may do: approve one item waiting for a decision, or hand one to a\n" + " human. Nothing else on the page changes anything, and there is no approve-everything.\n" "\n" - " What a session may then do: approve one amber item, or hand one to a human. Nothing\n" - " else on the page changes anything, and there is no approve-everything.\n" + " The page's own URL is unaffected: it still only reads, so it is still safe to hand to\n" + " somebody who should see this instance without being able to spend its budget.\n" "\n" - " Sessions last 12 hours. To end them all at once, rotate.", + " To end every session without changing the password: hullwork password --end-sessions", file=out, ) return 0 @@ -3069,25 +3080,30 @@ def build_parser() -> argparse.ArgumentParser: ) page_token.set_defaults(func=_cmd_page_token) - operator_key = subparsers.add_parser( - "operator-key", - help="mint the credential that acts on the read-only page", + password = subparsers.add_parser( + "password", + help="set the password that unlocks the buttons on the page", description=( "The page reads with a token in its URL, which is why it may not act: a URL is a thing " - "that gets saved, screenshotted and forwarded. This mints a second credential that " - "never appears in a URL — pasted into a login once per browser, exchanged for a " - "session cookie — and it is what the two buttons on an amber item require.\n\n" - "Until this command runs there are no buttons, and every route that would change " + "that gets saved, screenshotted and forwarded. This sets a second credential that " + "never appears in a URL — typed into the page's login once per browser, which is " + "where a browser's password manager takes over.\n\n" + "Until this runs there is no login and no buttons, and every route that would change " "something answers 404 the way an unknown path does.\n\n" - "Shown once, stored as a hash." + "Read from a prompt: a password on a command line is in shell history and in `ps`." ), ) - operator_key.add_argument( - "--rotate", + password.add_argument( + "--stdin", + action="store_true", + help="read it from standard input instead of prompting, for a provisioning script", + ) + password.add_argument( + "--end-sessions", action="store_true", - help="replace the existing key, ending every session that is open right now", + help="end every open session without changing the password", ) - operator_key.set_defaults(func=_cmd_operator_key) + password.set_defaults(func=_cmd_password) pruning = subparsers.add_parser( "prune", help="forget the raw bodies of old deliveries, keeping every row" diff --git a/hullwork/main.py b/hullwork/main.py index 8ba3cf1..9abd87d 100644 --- a/hullwork/main.py +++ b/hullwork/main.py @@ -449,9 +449,11 @@ def _acting(session: Session, request: Request) -> page.Acting: the answer and never the cookie, so a view cannot accidentally treat a *read* token as authority. """ + locked = operator.locked_for(session) return page.Acting( csrf=operator.acting(session, request.cookies.get(operator.COOKIE)), offered=operator.configured(session), + locked_minutes=None if locked is None else max(1, int(locked.total_seconds() // 60)), ) @@ -474,23 +476,22 @@ async def page_login( request: Request, session: Annotated[Session, Depends(_readiness_session)], ) -> RedirectResponse: - """Exchange the operator key for a session cookie. Item 166. + """Exchange the password for a session cookie. Item 168. **The one route that accepts a secret in a body, and it answers the same either way.** A wrong - key redirects to the page exactly as a right one does: an attacker with the read link learns - nothing from the response about whether a key was right, and the operator finds out by whether - the buttons are there. No error page, because an error page is an oracle. - - `Secure` is read off the request rather than hardcoded. Hardcoding it on would silently break - the plain-HTTP tailnet deployment this runs on — 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. On the - tailnet the transport is encrypted by WireGuard even without it. + password redirects to the page exactly as a right one does: somebody with the read link learns + nothing from the response, and the operator finds out by whether the buttons are there. No error + page, because an error page is an oracle. + + `Secure` is read off the request rather than hardcoded. Hardcoding it on would silently break a + 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") - key = await _field(request, "key") - issued = operator.log_in(session, key) if key else None + supplied = await _field(request, "password") + issued = operator.sign_in(session, supplied) if supplied else None redirect = _to_page(token) if issued is not None: cookie, _csrf = issued diff --git a/hullwork/models.py b/hullwork/models.py index 5e0d139..431dd57 100644 --- a/hullwork/models.py +++ b/hullwork/models.py @@ -657,34 +657,52 @@ class PageAccess(Base): created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now) -class OperatorKey(Base): - """The credential that **acts**. Item 166. One row, id 1, or none at all. - - **Separate from `PageAccess` on purpose, and that separation is the whole security model.** The - page token is a bearer credential that lives in a URL — a saved page, a screenshot of the - address bar, a link mailed to a colleague — so it reads everything and may never spend money. - This one never appears in a URL: it is pasted into a form once, exchanged for a session, and - after that only the session cookie travels. - - **None at all is the default, and it means the buttons do not exist.** An instance that upgrades - into this item is byte-identical to the one before it until somebody runs - `hullwork operator-key`. - - Generated, never chosen. 32 random bytes hashed with SHA-256, for the reason already written - beside the page token: against 32 random bytes a KDF buys nothing. A human-chosen password would - need scrypt or argon2, a new dependency, and a guessing-rate story — three problems this does - not have. +class OperatorPassword(Base): + """The password that unlocks the two buttons. Item 168. One row, id 1, or none at all. + + **Third design in three items, and the two before it failed the same test: could an operator + actually use it.** Item 166 stored 32 random bytes and asked for them to be pasted into a form. + Item 167 replaced that with a one-time link from the CLI — which removed the paste and added a + trip to the host: open the page, ssh, run a command, copy a link, open it, come back, reload. + Eight steps, twice a day, against the one command it was supposed to improve on. + + A password is what every self-hosted tool does, and the reason is not convention: **a browser's + password manager fills it in.** One visit to the host, ever; after that the operator opens the + page and clicks. + + **The reason I gave for rejecting it was wrong**, and worth recording because it decided a + milestone: I said a chosen password needs scrypt or argon2 and therefore a new dependency in the + half of Hullwork that listens on the network. `hashlib.scrypt` is in the standard library. + Measured at `n=2**14`: 37 ms per attempt, which is both the work factor and most of the answer + to online guessing. """ - __tablename__ = "operator_key" + __tablename__ = "operator_password" id: Mapped[int] = mapped_column(Integer, primary_key=True) - #: SHA-256, compared in constant time. See `security.hash_token`. - key_hash: Mapped[str] = mapped_column(String(64)) + #: 16 random bytes, hex. Per-instance, so two deployments with the same password store different + #: hashes and one leaked table says nothing about the other. + salt: Mapped[str] = mapped_column(String(32)) + + #: `hashlib.scrypt(...).hex()`. Compared with `hmac.compare_digest`, never with `==`. + key: Mapped[str] = mapped_column(String(64)) + + #: The cost, stored rather than assumed. A password set in 2026 must keep verifying after the + #: default is raised, and a row that does not carry its own parameters cannot be re-hashed on a + #: later sign-in without locking the operator out first. + n: Mapped[int] = mapped_column(Integer) + r: Mapped[int] = mapped_column(Integer) + p: Mapped[int] = mapped_column(Integer) created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now) + #: Consecutive failures, and when they stop counting. **On this row rather than per address**: + #: an instance has one operator, so a lockout is about the credential and not about who is + #: asking — and a per-address counter is defeated by changing address, which is free. + failures: Mapped[int] = mapped_column(Integer, default=0) + locked_until: Mapped[datetime | None] = mapped_column(UtcDateTime(), default=None) + class OperatorSession(Base): """One browser that has proved it holds the operator key. Item 166. @@ -712,9 +730,11 @@ class OperatorSession(Base): created_at: Mapped[datetime] = mapped_column(UtcDateTime(), default=_now) - #: When this stops being accepted. An absolute expiry rather than an idle timeout: an idle - #: timeout has to be written on every request, which turns a read of the page into a write to - #: the database — and the receiver's sweep already contends for that lock. + #: When this stops being accepted. **Extended on use, but only past the halfway mark** (item + #: 168): a true idle timeout writes on every request, which turns reading the page into a write + #: and the receiver's sweep already contends for that lock. Renewing at the halfway point is one + #: write every fifteen days and gets the same result — an operator who opens the page most weeks + #: never signs in twice. expires_at: Mapped[datetime] = mapped_column(UtcDateTime()) diff --git a/hullwork/operator.py b/hullwork/operator.py index 7120746..bc3cb10 100644 --- a/hullwork/operator.py +++ b/hullwork/operator.py @@ -1,80 +1,155 @@ -"""Who may change something on the page, and for how long. Item 166. +"""Who may change something on the page, and for how long. Items 166, 167 and 168. **Two credentials, and the split is the point.** `page.opens` answers *may this request read*, and -its credential is a bearer token in a URL. This module answers *may this request act*, and its -credential never appears in a URL at all: it is pasted into a form once, exchanged for a session, -and after that only a cookie travels. A reader handed the read link stays safe to hand it to. - -Nothing here enumerates. A wrong key, an expired session, and an instance with no operator key -configured all produce the same `None`, so a caller can only answer `404` — the same answer as a -wrong page token — and probing learns nothing about whether this instance can be acted on at all. +its credential is a bearer token in a URL — which is why it may not act: a URL is a thing that gets +saved, screenshotted and forwarded. This module answers *may this request act*, and its credential +is a password that never appears in a URL. + +## Three designs, and what the first two got wrong + +Item 166 stored 32 random bytes and asked the operator to paste them into a form. Item 167 replaced +that with a one-time link printed by the CLI. Both were secure and **neither was usable**, which the +operator said out loud about the second: to press a button you opened the page, ssh'd to the host, +ran a command, copied a link, opened it, went back and reloaded. Eight steps, every twelve hours, +against the single command the whole exercise meant to improve on. The friction moved, it did not +go away. + +A password is what every self-hosted tool does, and the reason is mechanical rather than +conventional: **the browser's password manager fills it in.** One visit to the host, ever. + +**The argument I used to reject it was false**, and it cost a milestone: I said a chosen password +needs scrypt or argon2, therefore a new dependency in the half of Hullwork that listens on the +network. +`hashlib.scrypt` is in the standard library. At `n=2**14` it costs 37 ms per attempt here — which is +the work factor *and* most of the answer to online guessing, with a lockout for the rest. + +Nothing here enumerates. A wrong password, a locked instance, and one with no password set at all +produce the same `None`. """ from __future__ import annotations +import hashlib import hmac +import secrets from datetime import UTC, datetime, timedelta from sqlalchemy import delete, select from sqlalchemy.orm import Session -from hullwork.models import OperatorKey, OperatorSession -from hullwork.security import generate_token, hash_token, verify_token +from hullwork.models import OperatorPassword, OperatorSession +from hullwork.security import generate_token, hash_token #: The cookie the browser sends back. Scoped to the page prefix rather than to `/`: nothing else #: this application serves has any use for it, and the webhook endpoint least of all. COOKIE = "hullwork_operator" -#: How long a session lasts. Long enough that an operator working through a morning's queue does not -#: log in twice; short enough that a browser left open on a train stops being a key by tomorrow. -LIFETIME = timedelta(hours=12) +#: How long a session lasts, and it is long on purpose. Item 167's twelve hours meant signing in +#: twice a day, which is what made it unusable; thirty days with renewal on use means an operator +#: who opens the page most weeks signs in once and forgets this exists. +LIFETIME = timedelta(days=30) + +#: scrypt's cost. `n=2**14, r=8, p=1` is the parameter set Python's own documentation suggests for +#: interactive use, measured at 37 ms on the deployment host. Stored per row so raising this later +#: keeps old passwords verifiable instead of locking their owners out. +COST = {"n": 2**14, "r": 8, "p": 1} + +#: Failures before the door closes, and for how long. Ten is past any plausible typo and far short +#: of a dictionary; fifteen minutes turns a guessing rate of 27 per second into four per hour. +MAX_FAILURES = 10 +LOCKOUT = timedelta(minutes=15) + + +def _aware(when: datetime) -> datetime: + """SQLite hands back naïve datetimes on some drivers; every comparison here needs UTC.""" + return when.replace(tzinfo=UTC) if when.tzinfo is None else when + + +def _derive(password: str, salt: str, *, n: int, r: int, p: int) -> str: + return hashlib.scrypt( + password.encode("utf-8"), salt=bytes.fromhex(salt), n=n, r=r, p=p, dklen=32 + ).hex() def configured(session: Session) -> bool: - """Whether this instance has an operator key at all — which is whether the buttons exist.""" - return session.scalars(select(OperatorKey).limit(1)).first() is not None + """Whether a password has been set, which is whether the page can offer a login.""" + return session.scalars(select(OperatorPassword).limit(1)).first() is not None -def issue_key(session: Session) -> str: - """Generate the operator key, store its hash, and **end every session that exists**. +def set_password(session: Session, password: str) -> None: + """Set or replace the password, and **end every session that exists**. - Rotating is overwriting the one row, which is why there is one. Dropping the sessions with it is - not tidiness: the reason to rotate is that the old key might be in somebody else's hands, and a - live session issued by it would outlive the rotation by up to `LIFETIME`. + Ending them is not tidiness: the reason to change a password is usually that the old one may be + in somebody else's hands, and a session issued under it would outlive the change by a month. """ - key = generate_token() - row = session.scalars(select(OperatorKey).limit(1)).first() + salt = secrets.token_hex(16) + row = session.scalars(select(OperatorPassword).limit(1)).first() + values = { + "salt": salt, + "key": _derive(password, salt, **COST), + "n": COST["n"], + "r": COST["r"], + "p": COST["p"], + "failures": 0, + "locked_until": None, + } if row is None: - session.add(OperatorKey(id=1, key_hash=hash_token(key))) + session.add(OperatorPassword(id=1, **values)) else: - row.key_hash = hash_token(key) + for name, value in values.items(): + setattr(row, name, value) row.created_at = datetime.now(UTC) session.execute(delete(OperatorSession)) session.commit() - return key -def log_in(session: Session, key: str) -> tuple[str, str] | None: - """Exchange the operator key for `(cookie value, csrf token)`, or `None` if it is not the key. +def locked_for(session: Session) -> timedelta | None: + """How long the door stays shut, or `None`. What the page says instead of a silent refusal.""" + row = session.scalars(select(OperatorPassword).limit(1)).first() + if row is None or row.locked_until is None: + return None + left = _aware(row.locked_until) - datetime.now(UTC) + return left if left > timedelta(0) else None + + +def sign_in(session: Session, password: str) -> tuple[str, str] | None: + """Exchange the password for `(cookie value, csrf token)`, or `None`. - `None` covers both *wrong key* and *no key configured*, deliberately: the caller cannot tell - them apart and so cannot leak the difference. + `None` covers a wrong password, a locked instance and one with no password set, deliberately: + the caller cannot tell them apart and so cannot leak the difference. The page does report a + lockout, because an operator who is locked out needs to know — and it reads that from + `locked_for`, a question about this instance rather than about the guess just made. """ - row = session.scalars(select(OperatorKey).limit(1)).first() - if row is None or not verify_token(key, row.key_hash): + row = session.scalars(select(OperatorPassword).limit(1)).first() + if row is None: + return None + if row.locked_until is not None and _aware(row.locked_until) > datetime.now(UTC): return None - token = generate_token() + # Always derive, even when a lockout has just expired: the cost is the point, and skipping it on + # any path would make a wrong password measurably faster than a right one. + supplied = _derive(password, row.salt, n=row.n, r=row.r, p=row.p) + if not hmac.compare_digest(supplied, row.key): + row.failures += 1 + if row.failures >= MAX_FAILURES: + row.locked_until = datetime.now(UTC) + LOCKOUT + row.failures = 0 + session.commit() + return None + + row.failures = 0 + row.locked_until = None + cookie = generate_token() csrf = generate_token() session.add( OperatorSession( - token_hash=hash_token(token), + token_hash=hash_token(cookie), csrf=csrf, expires_at=datetime.now(UTC) + LIFETIME, ) ) session.commit() - return token, csrf + return cookie, csrf def _row_for(session: Session, token: str | None) -> OperatorSession | None: @@ -85,16 +160,15 @@ def _row_for(session: Session, token: str | None) -> OperatorSession | None: ).first() if row is None: return None - expires = row.expires_at - if expires.tzinfo is None: # SQLite hands back naïve datetimes on some drivers. - expires = expires.replace(tzinfo=UTC) - if expires <= datetime.now(UTC): - # Expired rows are deleted on the way past rather than by a sweep: this is the only moment - # anything is known to be looking at them, and a table of dead sessions is a table somebody - # eventually has to explain. + if _aware(row.expires_at) <= datetime.now(UTC): session.delete(row) session.commit() return None + # **Renewed past the halfway mark and not before.** A write on every request would turn reading + # the page into a database write, and the receiver's sweep already contends for that lock. + if _aware(row.expires_at) - datetime.now(UTC) < LIFETIME / 2: + row.expires_at = datetime.now(UTC) + LIFETIME + session.commit() return row @@ -115,12 +189,21 @@ def csrf_ok(expected: str | None, supplied: str | None) -> bool: def log_out(session: Session, token: str | None) -> None: - """End this one session. - - A missing or unknown token is not an error: logging out twice is fine, and so is logging out of - a session that expired while the page was open. - """ + """End this one session. Signing out twice is not an error, and neither is signing out of one + that expired while the page was open.""" row = _row_for(session, token) if row is not None: session.delete(row) session.commit() + + +def end_every_session(session: Session) -> int: + """End every session there is, and say how many. + + The lever for the morning a laptop goes missing, and the argument for storing sessions rather + than signing them: this is a `DELETE`, not a key rotation that happens to log everybody out. + """ + open_now = list(session.scalars(select(OperatorSession)).all()) + session.execute(delete(OperatorSession)) + session.commit() + return len(open_now) diff --git a/hullwork/page.py b/hullwork/page.py index 17250cc..6f0790b 100644 --- a/hullwork/page.py +++ b/hullwork/page.py @@ -170,154 +170,362 @@ def _link(url: str | None, text: str | None = None) -> str: #: monospace so columns align and a truncated hash looks like a hash. Prose and chrome are not, #: because setting an entire interface in a monospace face is a costume, not a decision. _STYLE = """ +/* The visual system, inlined. Item 169 rebuilt it after the operator's verdict on item 167's + information design: "sigue siendo horriblemente fea". He was right — that pass fixed what the + page said and never touched how it looked, so it inherited item 143's austere tokens and read as + unstyled HTML with a brown link colour. + + The direction is an instrument panel, and the first decision is that there is no decorative + accent: Colour means state here and nothing else: amber is the operator's own queue, blue the + machine, green and red the outcomes. A page about whether a robot may touch your code has no + business having a brand hue competing with those four. + + The lede was set in the monospace face for one revision, on the theory that the page's headline + is the machine's own summary of itself and a machine's voice is monospace. Rendered, it read as + terminal output rather than as a headline: mono at display size with negative tracking fights + itself, and two wrapped lines of it look like a log. It is sans now, with the weight and tracking + a headline needs, and mono keeps the job it is good at — data, ages, identifiers, commands. + + No external asset, no script, one stylesheet, both themes from one set of tokens. */ + :root { color-scheme: light dark; --sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace; - --ink: light-dark(#15171c, #e7e9ee); - --muted: light-dark(#5d6470, #98a0ad); - --faint: light-dark(#868d99, #6f7783); - --rule: light-dark(#e2e5ea, #262a31); - --canvas: light-dark(#fcfcfd, #0f1115); - --raise: light-dark(#ffffff, #16191f); - - --waiting: light-dark(#8a5a00, #e3a83a); - --working: light-dark(#1b5fb0, #6aa9f2); - --passed: light-dark(#12693a, #4fb37d); - --refused: light-dark(#a32f26, #ec7d70); - --human: light-dark(#5a3ea8, #ab8df0); - - --r-card: 10px; - --r-chip: 6px; + /* Neutrals with a cool bias, chosen rather than inherited: a pure grey reads as nobody's + 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); + --rule: light-dark(#dfe3ea, #232833); + --canvas: light-dark(#eef1f5, #070910); + --raise: light-dark(#ffffff, #14171e); + --sunk: light-dark(#f0f2f6, #10131a); + + --waiting: light-dark(#9a5b00, #f0b352); + --working: light-dark(#1a56c4, #7cb0f7); + --passed: light-dark(#0f6b39, #52bb83); + --refused: light-dark(#ab2f22, #f0847a); + --human: light-dark(#5638ad, #b193f5); + + --r: 8px; + --r-chip: 5px; + --pad: 1.15rem; } + * { box-sizing: border-box; } + +html { -webkit-text-size-adjust: 100%; } + body { - font: 400 15px/1.6 var(--sans); - color: var(--ink); background: var(--canvas); - margin: 0 auto; max-width: 78rem; padding: 2.5rem 1.5rem 5rem; + margin: 0; + background: var(--canvas); + color: var(--ink); + font: 400 15px/1.55 var(--sans); + font-synthesis-weight: none; -webkit-font-smoothing: antialiased; } -h1 { font-size: 1.5rem; font-weight: 650; letter-spacing: -.02em; margin: 0 0 .3rem; } -h2 { font-size: .78rem; font-weight: 650; letter-spacing: .07em; text-transform: uppercase; - color: var(--faint); margin: 2.5rem 0 .75rem; } -h3 { font-size: 1rem; font-weight: 600; letter-spacing: -.01em; margin: 0 0 .4rem; } -a { color: inherit; text-underline-offset: 3px; text-decoration-color: var(--rule); } + +.wrap { max-width: 62rem; margin: 0 auto; padding: 0 1.5rem 4rem; } + +a { color: inherit; text-decoration-thickness: 1px; text-underline-offset: 3px; + text-decoration-color: color-mix(in oklab, currentColor 35%, transparent); } a:hover { text-decoration-color: currentColor; } -.sub { color: var(--muted); margin: 0 0 2rem; } -.mono { font-family: var(--mono); font-variant-numeric: tabular-nums; } -.faint { color: var(--faint); } -.bad { font-weight: 650; color: var(--refused); } - -/* A band is the unit of the daily page: hairline, soft radius, and in light mode the faintest - lift so it reads as a surface rather than as a box drawn on the page. */ -.band { background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r-card); - padding: 1.1rem 1.25rem; margin: 0 0 1rem; - box-shadow: light-dark(0 1px 2px rgb(16 18 28 / .04), none); } -.band.quiet { background: none; box-shadow: none; } - -/* State chips. The tint is derived from the semantic colour so a new state needs one variable, - and the word inside is never optional. */ -.chip { display: inline-flex; align-items: center; gap: .4rem; font: 600 .75rem/1 var(--sans); - letter-spacing: .01em; padding: .35rem .55rem; border-radius: var(--r-chip); - border: 1px solid color-mix(in oklab, var(--c, var(--muted)) 30%, transparent); - background: color-mix(in oklab, var(--c, var(--muted)) 12%, transparent); - color: var(--c, var(--muted)); white-space: nowrap; } -.chip::before { content: ""; width: .4rem; height: .4rem; border-radius: 50%; - background: currentColor; flex: none; } -.c-waiting { --c: var(--waiting); } .c-working { --c: var(--working); } -.c-passed { --c: var(--passed); } .c-refused { --c: var(--refused); } -.c-human { --c: var(--human); } .c-idle { --c: var(--faint); } - -/* The board: one column per group, wrapping down on a narrow screen rather than scrolling out. */ -.board { display: grid; gap: .75rem; grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr)); } -.col { background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r-card); - padding: .85rem .9rem; min-height: 6.5rem; } -.col h4 { font: 650 .72rem/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; - color: var(--faint); margin: 0 0 .6rem; } -.count { font: 650 1.75rem/1 var(--mono); font-variant-numeric: tabular-nums; - letter-spacing: -.03em; color: var(--c, var(--ink)); } -.count.zero { color: var(--faint); font-weight: 400; } -/* Item 166: a non-zero count is a link to the items it counted. It keeps the number's weight and - colour — the underline is what says it can be clicked, and only on hover so the board still reads - as figures rather than as a menu. */ -a.count { display: inline-block; text-decoration: none; } -a.count:hover, a.count:focus-visible { text-decoration: underline; } -/* A tinted number is still a number: the column heading above it carries the meaning. */ -.age { display: block; font-size: .78rem; color: var(--muted); margin-top: .35rem; } -.col.owed { border-color: color-mix(in oklab, var(--waiting) 45%, var(--rule)); } - -/* The six phases of the attempt in flight. */ -.phases { display: flex; flex-wrap: wrap; gap: .4rem; margin: .75rem 0 0; - padding: 0; list-style: none; } -.phase { font: 500 .78rem/1 var(--mono); padding: .4rem .6rem; border-radius: var(--r-chip); +:focus-visible { outline: 2px solid var(--working); outline-offset: 2px; border-radius: 3px; } + +/* --- the top edge, which the page did not have ----------------------------------------------- */ + +.bar { + display: flex; align-items: center; gap: .7rem; + 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; } +.bar .spacer { flex: 1; } +.pill { + font: 550 .68rem/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); + color: var(--c, var(--muted)); +} +.pill.ok { --c: var(--passed); } +.pill.bad { --c: var(--refused); } +.pill.mine { --c: var(--waiting); } + +/* The gate, as one line. A test that failed against unmodified code passes with the change applied: + that sentence is the whole claim this product makes, and it is the only decoration on the page. + Two pixels, under the header, and nowhere else — a motif that repeats is wallpaper. */ +.bar { position: relative; border-bottom: 0; padding-bottom: 1rem; } +.bar::after { + content: ""; position: absolute; left: 0; right: 0; bottom: 0; height: 3px; + /* A hard-ish transition rather than a long fade: the gate is a change of state, not a gradient, + and a 1500px-wide blend reads as no colour at all. */ + background: linear-gradient(90deg, + var(--refused) 0%, var(--refused) 26%, + color-mix(in oklab, var(--refused) 40%, var(--passed)) 44%, + var(--passed) 62%, var(--passed) 100%); + border-radius: 3px; +} + +/* --- what this instance has proved, which is what a stranger came for ------------------------ */ + +.proof { + display: grid; grid-template-columns: repeat(auto-fit, minmax(7.5rem, 1fr)); + background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); + margin: 0 0 1.4rem; overflow: hidden; +} +.cell { padding: 1rem var(--pad) .95rem; border-right: 1px solid var(--rule); + display: flex; flex-direction: column; gap: .15rem; } +.cell:last-child { border-right: 0; } +.big { + /* Sans, and this is the one place mono loses. Menlo and its neighbours draw a slashed zero, which + is right in a terminal and reads as Ø at 2.4rem — a different glyph in the middle of a figure, + 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; + 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 + means "none has cleared the window yet", which is the kind of figure that flatters itself. */ +.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; + color: var(--muted); margin-top: .35rem; } +.gloss { font: 400 .72rem/1.35 var(--sans); color: var(--faint); } + +/* --- the answer ------------------------------------------------------------------------------ */ + +.answer { + background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); + border-left: 3px solid var(--c, var(--faint)); + padding: var(--pad) calc(var(--pad) + .2rem); + margin: 0 0 1.5rem; +} +.answer.mine { --c: var(--waiting); } +.answer.bad { --c: var(--refused); } +.answer.calm { --c: var(--passed); } +/* The sentence is set in ink and the stripe on the panel carries the state. Colouring 1.7rem of + text amber is what made this look dated, and it was redundant: the reader already knows whose + 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); + 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; } +.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. */ +.decisions { + list-style: none; padding: 0; margin: 0 0 1.4rem; + background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); + border-left: 3px solid var(--waiting); overflow: hidden; +} +.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); + 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); } + +/* --- what it is doing ------------------------------------------------------------------------ */ + +.band { + background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); + padding: var(--pad); margin: 0 0 1.1rem; +} +.band.quiet { background: var(--sunk); } +.band > :first-child { margin-top: 0; } +.band > :last-child { margin-bottom: 0; } + +.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; + padding: .38rem .5rem; border-radius: var(--r-chip); border: 1px solid var(--rule); color: var(--faint); } .phase.done { color: var(--passed); border-color: color-mix(in oklab, var(--passed) 35%, transparent); - background: color-mix(in oklab, var(--passed) 10%, transparent); } + background: color-mix(in oklab, var(--passed) 9%, transparent); } .phase.live { color: var(--working); border-color: color-mix(in oklab, var(--working) 45%, transparent); - background: color-mix(in oklab, var(--working) 12%, transparent); } -.phase.failed { color: var(--refused); - border-color: color-mix(in oklab, var(--refused) 40%, transparent); - background: color-mix(in oklab, var(--refused) 12%, transparent); } - -/* Facts: label and value, aligned, no table needed. */ -.facts { display: grid; grid-template-columns: minmax(9rem, max-content) 1fr; gap: .3rem 1.25rem; - margin: .25rem 0 0; } -.facts dt { color: var(--muted); font-size: .85rem; } -.facts dd { margin: 0; font-family: var(--mono); font-size: .85rem; - font-variant-numeric: tabular-nums; overflow-wrap: anywhere; } - -table { border-collapse: collapse; width: 100%; margin: .5rem 0 1rem; } -th, td { text-align: left; padding: .45rem .7rem .45rem 0; vertical-align: top; } -th { font-weight: 600; color: var(--muted); width: 16rem; font-size: .85rem; } -ul { margin: .25rem 0 1rem; padding-left: 1.2rem; } -li { margin: .2rem 0; } -footer { margin-top: 3.5rem; padding-top: 1.25rem; border-top: 1px solid var(--rule); - color: var(--faint); font-size: .82rem; } -/* The list has seven narrow columns, not two wide ones, so it undoes the label width above. */ -table.list th { width: auto; font-size: .75rem; letter-spacing: .05em; text-transform: uppercase; - color: var(--faint); } -table.list td { border-top: 1px solid var(--rule); font-size: .875rem; } -table.list tr:hover td { background: color-mix(in oklab, var(--ink) 4%, transparent); } -/* Captured output is somebody else's, and some of it has 300-character lines. It wraps rather - than scrolls, because a horizontal scrollbar inside a collapsed block hides evidence. */ -pre { white-space: pre-wrap; overflow-wrap: anywhere; margin: .5rem 0; - font: .82rem/1.5 var(--mono); background: color-mix(in oklab, var(--ink) 4%, transparent); - border-radius: var(--r-chip); padding: .7rem .85rem; } -details { margin: .4rem 0 .7rem; } -summary { cursor: pointer; color: var(--muted); font-size: .875rem; } -summary:hover { color: var(--ink); } -details > pre { border-left: 2px solid var(--rule); } -code { font-family: var(--mono); font-size: .9em; } - -/* Item 166. A form is the only way to change something here, so it has to look like part of the - page rather than like a browser default from 1998. `.linkish` is a button that reads as a link, - for sign-out, where a button would claim more weight than the action has. */ -form.inline { display: inline; } -button.linkish { - background: none; border: 0; padding: 0; font: inherit; color: inherit; - text-decoration: underline; cursor: pointer; + 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; + 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; } +.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); } + +/* --- the machine, as one row ----------------------------------------------------------------- */ + +/* The live queue, and it must not look like the record above it. Same figures, different job: + that one is what this instance has proved, this one is what is in flight right now. Sunk, no + border, smaller — a caption to the page rather than a headline. */ +.strip { + display: flex; flex-wrap: wrap; gap: 0 1.6rem; + padding: .7rem var(--pad); margin: 0 0 1.2rem; + background: var(--sunk); border-radius: var(--r); } -.decide { display: flex; gap: .6rem; flex-wrap: wrap; margin: .8rem 0 0; } -.decide button { - font: inherit; padding: .45rem .9rem; border-radius: 6px; cursor: pointer; - border: 1px solid var(--rule); background: var(--card); color: var(--fg); +.tally { display: flex; align-items: baseline; gap: .4rem; } +.fig { font: 550 1.05rem/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; } + +/* --- the item's own views, which the first pass of item 169 left behind ---------------------- */ + +/* An audit of emitted classes against defined selectors found four orphans after the stylesheet was + rewritten: this one, .board, .age and .faint. The markup kept emitting them and nothing styled + them, so the item view and the project view rendered as bare tables while the front page had been + redressed. Measured by comparing the two sets rather than by clicking around. */ +.next { + background: var(--raise); border: 1px solid var(--rule); border-radius: var(--r); + border-left: 3px solid var(--waiting); + padding: var(--pad); margin: 1.2rem 0; } -.decide button.go { border-color: var(--passed); } +.next > :first-child { margin-top: 0; } +.next > :last-child { margin-bottom: 0; } +.next .decide { margin-top: .8rem; } + +/* 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); + border-radius: var(--r); padding: .8rem var(--pad); } +.col.owed { border-left: 3px solid var(--waiting); } +.age { display: block; font: 400 .74rem/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; } +.settled::before { content: "✓"; color: var(--passed); font-weight: 600; } + +/* --- everything the evaluator wants, as one block rather than five rules --------------------- */ + +.more { border: 1px solid var(--rule); border-radius: var(--r); background: var(--raise); + margin: 1.6rem 0 0; overflow: hidden; } +.more > details { border: 0; border-top: 1px solid var(--rule); } +.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); + 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); + width: .7rem; text-align: center; } +/* The typographic minus, which pairs the + above rather than a hyphen. */ +details[open] > summary::before { content: "\2212 "; } +details > summary:hover { color: var(--ink); background: var(--sunk); } +.folded { padding: 0 var(--pad) 1.1rem; } +.folded > :first-child { margin-top: 0; } + +/* --- type --------------------------------------------------------------------------------- */ + +h1 { font: 600 1.35rem/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; + color: var(--faint); margin: 1.8rem 0 .7rem; } +h4 { font: 550 .72rem/1 var(--sans); letter-spacing: .06em; text-transform: uppercase; + color: var(--faint); margin: 0 0 .5rem; } +p { margin: .7rem 0; } +.sub { font-size: .84rem; 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; + border-radius: 4px; } +ul, ol { margin: .7rem 0; padding-left: 1.2rem; } +li { margin: .25rem 0; } +time { font-variant-numeric: tabular-nums; } + +/* --- tables, which are data and should look like it ----------------------------------------- */ + +table { border-collapse: collapse; width: 100%; font-size: .87rem; } +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 th { border-bottom: 1px solid var(--rule); padding-bottom: .5rem; + font: 550 .7rem/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; } + +/* --- the forms that decide ------------------------------------------------------------------- */ + +form.inline { display: inline; } +button.linkish { background: none; border: 0; padding: 0; font: inherit; color: inherit; + text-decoration: underline; cursor: pointer; } +.decide { display: flex; gap: .5rem; flex-wrap: wrap; } .decide form { margin: 0; } -.login { margin: .8rem 0 0; display: flex; gap: .5rem; flex-wrap: wrap; align-items: center; } +.decide button, .login button { + font: 550 .84rem/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.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: inherit; font-family: var(--mono); padding: .4rem .5rem; min-width: 22rem; - border: 1px solid var(--rule); border-radius: 6px; background: var(--card); color: var(--fg); + font: 400 .88rem/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); } -.next { border-left: 3px solid var(--waiting); padding-left: .8rem; margin: 1rem 0; } -/* An item the dispatcher will never pick up. Beside the state rather than instead of it: the state - is still the truth, this is what the state cannot say on its own. */ -.stuck { font: 600 .7rem/1 var(--sans); letter-spacing: .04em; text-transform: uppercase; +.stuck { font: 600 .64rem/1 var(--sans); letter-spacing: .05em; text-transform: uppercase; color: var(--refused); border: 1px solid currentColor; border-radius: var(--r-chip); - padding: .15rem .35rem; margin-left: .35rem; } + padding: .16rem .34rem; margin-left: .35rem; } + +/* --- the evidence, which is somebody else's output and must not be styled into prose --------- */ + +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; } +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); +} +footer strong { color: var(--muted); font-weight: 550; } + +@media (prefers-reduced-motion: no-preference) { + a, button, summary { transition: color 120ms ease, background 120ms ease; } +} + +@media (max-width: 40rem) { + .wrap { padding: 0 1rem 3rem; } + .lede { font-size: 1.3rem; } + .tally { flex: 1 1 45%; border-right: 0; border-bottom: 1px solid var(--rule); } +} """ @@ -351,18 +559,31 @@ class Acting: #: answer for a wrong cookie, an expired session and an instance with no key alike. csrf: str | None = None - #: Whether an operator key exists, which is whether offering a login is honest. Without this the - #: page would either show a login on an instance that can never accept one, or hide the one - #: affordance the operator is looking for. + #: Whether a password has been set, which is whether a login is worth offering. Item 167 had no + #: use for this — a one-time link needs nothing stored — and item 168 needs it again: a login + #: form on an instance that cannot accept one is a dead end, and telling a reader to run + #: `hullwork password` is the honest alternative. offered: bool = False + #: Minutes left on a lockout, when there is one. **Reported rather than hidden**, unlike a wrong + #: password: an operator who has typed it wrong ten times needs to know the door is shut and for + #: how long, and somebody guessing already knows they have been guessing. + locked_minutes: int | None = None + #: A request that may read and nothing else — the default everywhere, and the whole of what this #: page was before item 166. READING = Acting() -def _document(title: str, body: str, *, acting: Acting = READING, up: str = "") -> str: +def _document( + title: str, + body: str, + *, + acting: Acting = READING, + up: str = "", + state: tuple[str, str] | None = None, +) -> str: """The whole page. No script, no external asset, one inlined stylesheet. `up` is how far this view is from `/page//`, because **every URL here is relative on @@ -374,8 +595,9 @@ def _document(title: str, body: str, *, acting: Acting = READING, up: str = "") "read-only. This URL is the credential: anyone who has it can read " "everything on this page. Rotate it with hullwork page-token --rotate." if acting.csrf is None - else "signed in, so two buttons on an amber item work and nothing else " - "does. The URL is still only a read credential: this browser holds the other one. " + else "signed in for twelve hours, so two buttons on an amber item work " + "and nothing else does. The URL is still only a read credential: the session is what " + "acts, and it lives in this browser. " f'
' f'' '
' @@ -387,12 +609,39 @@ def _document(title: str, body: str, *, acting: Acting = READING, up: str = "") '' f'' f"{_h(title)}\n" + '
\n' + f"{_bar(up=up, state=state)}\n" f"{body}\n" f"
Hullwork {_h(__version__)} — {footing}
\n" + "
\n" "\n" ) +def _bar(*, up: str, state: tuple[str, str] | None) -> str: + """The top edge, which the page did not have. Item 169. + + **A page that opens with a sentence and no header reads as an email**, which is what the + operator was looking at when he called the redesign ugly: item 167 removed the + `

hullwork

` and put nothing in its place, so there was no anchor, no mark, and nothing + saying which instance this is. + + The mark is the one the interface document specifies — `▚`, no binary asset — and the pill on + the right is the instance's own state, which is the second thing a reader wants after *does this + need me* and was previously buried in a folded table. + """ + badge = ( + f'{_h(state[0])}' if state is not None else "" + ) + return ( + '
' + f'' + f'hullwork' + '' + f"{badge}
" + ) + + #: What each half of Hullwork holds, and what it provably cannot do. Item 136. #: #: **On the page rather than only in the deployment notes**, because the person asked to trust a @@ -527,6 +776,102 @@ def _stuck(item: _Item) -> str | None: return None +def _proof(session: Session, *, merged: int, holding: int, recurred: int, watch: int) -> str: + """The instrument's own record, in figures, as the striking thing on the page. Item 170. + + **The page is a proof rather than a panel, and this is the proof.** Two readers arrive here and + until now only one was served: the operator, daily, asking what needs them. The other is a + stranger who has installed nothing and is deciding whether this is real — and for an open-source + product that reader is the whole distribution channel. What convinces them is not a feature + list, it is *this instance's* count of fixes that were merged and then did not come back. + + Same content, both readers. The operator reads it as state; the stranger reads it as evidence. + + The figures are set in mono at display size because they are **measurements**, and an instrument + should shout its measurements rather than its name. The order is the product's own sequence — + found, tried, merged, held — so the row is a funnel read left to right, and the red-to-green + seam under it is the gate every one of those merges had to pass. + """ + from sqlalchemy import func + + items = session.scalar(select(func.count()).select_from(_Item)) or 0 + tried = session.scalar( + select(func.count(func.distinct(_Attempt.item_id))).where(_Attempt.rehearsal.is_(False)) + ) or 0 + # Four words at most. The figure is the message; the gloss is there so a stranger does not have + # to guess what was counted, and a sentence under each one turns the row back into prose. + cells = ( + ("found", items, "errors seen", ""), + ("tried", tried, "agent let loose", ""), + ("merged", merged, "a human accepted", "won"), + ("held", holding, f"{watch} days, no recurrence", "won"), + ("came back", recurred, "merged, then recurred", "lost" if recurred else ""), + ) + figures = "".join( + f'
' + f'{count}' + f'{_h(name)}' + f'{_h(gloss)}
' + for name, count, gloss, tone in cells + ) + return f'
{figures}
' + + +def _fold(summary: str, body: str) -> str: + """A closed disclosure, native, no JavaScript. Item 167. + + **Everything below the first screen is the evaluator's**, and the interface document + already said so: *"the daily reader must never pay for the evaluator's questions."* + It was not true — the + configuration table started in the second half of the first screen. `
` is how a page + with no script keeps a promise like that: closed, one line, and the summary says what is inside + so it is not a mystery box. + """ + return f"
{_h(summary)}
{body}
" + + +def entered(*, signed_in: bool) -> str: + """What a sign-in link renders. Item 167. + + **The same page either way, minus the good news.** A spent link, a forged one and an expired one + all land here, and this route is reachable by anybody, so it must not report on whether a token + was ever real. + """ + line = ( + "

This browser can decide now.

" + "

Go back to your Hullwork page and reload it. The two buttons are on any item waiting " + "for a decision, and nothing else on the page changes anything.

" + "

The session lasts twelve hours. To end it everywhere at once, run " + "hullwork sign-in --end-all.

" + if signed_in + else "

This link cannot be used.

" + "

A sign-in link works once, and stops working ten minutes after it is " + "printed. If this one was already opened, the browser that opened it is the one that is " + "signed in.

" + "

Run hullwork sign-in on the host for another.

" + ) + return _document("Hullwork — sign in", f"

Sign in

{line}") + + +def _when(when: datetime | None) -> str: + """A timestamp a person reads, with the exact one in `title` for anybody who needs it. + + **Item 167, and what it replaces was on the evidence page for two milestones**: `seen` printed + `6 time(s), first 2026-08-05 09:57:43.866473+00:00, last 2026-08-05 10:27:43.866473+00:00` — two + thirty-two-character strings, with microseconds, at the same weight as the lane and its reason. + Nobody parses that, so nobody read the line it was in. + + The full value is not thrown away: it is the tooltip, because the one reader who wants + microseconds is comparing this page against a log and should not have to leave. + """ + if when is None: + return "not recorded" + return ( + f'" + ) + + def _board(session: Session) -> str: """Where everything is, and how long the oldest has been there. @@ -561,6 +906,123 @@ def _board(session: Session) -> str: return f'
{"".join(cells)}
' + +#: How many items waiting on the operator the front page lists before it stops and links instead. +#: Five is a screenful of decisions. Past that the answer is not "here they all are" but "you have +#: a backlog", and a backlog is worked in the list view. +DECISIONS_SHOWN = 5 + + +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. + + **A number in a box is a tally; a sentence is an answer.** The board this replaces put the + operator's own queue in the second of six identical cards, told apart by a faint border tint — + so finding it meant reading six labels, which is what *"extremadamente liosa"* was describing. + + Severity decides what the sentence is about, and only one thing can be first: a problem outranks + a decision, a decision outranks the machine, and *nothing needs you* is a real answer that + deserves saying rather than being left to be inferred from six zeroes. + """ + problems = list(getattr(report, "problems", [])) + if problems: + rest = len(problems) - 1 + also = f' and {rest} more' if rest else "" + return _answer("bad", f"{_h(problems[0])}{also}") + if waiting: + oldest = min((i.state_since for i in waiting if i.state_since is not None), default=None) + how_long = f" The oldest has waited {_h(_ago(oldest))}." if oldest is not None else "" + count = len(waiting) + thing = "item needs" if count == 1 else "items need" + return _answer("mine", f"{count} {thing} a decision from you.{how_long}") + reviewing = len( + list(session.scalars(select(_Item).where(_Item.state == ItemState.PR_OPEN)).all()) + ) + if reviewing: + pulls = "pull request is" if reviewing == 1 else "pull requests are" + return _answer("", f"{reviewing} {pulls} waiting for a reviewer.") + return _answer("calm", "Nothing needs you.") + + +def _answer(tone: str, sentence: str) -> str: + """The lede, in a panel with a state stripe rather than floating above a hairline. Item 169.""" + return ( + f'

{sentence}

' + ) + + +def _deciding(waiting: list[_Item], acting: Acting) -> str: + """The items waiting on the operator, named, with their buttons. Item 167. + + **This is the fix item 166 got half right.** That item made the count a link, so *"waiting on + you 2 — and now what?"* became one click instead of a dead end. But three items cost three + lines, which is less space than the card that counts them: the front page should show the work + rather than the tally, and then the click is not needed at all. + """ + if not waiting: + return "" + rows = [] + for found in waiting[:DECISIONS_SHOWN]: + title = found.title.splitlines()[0] if found.title else f"item {found.id}" + stuck = _stuck(found) + rows.append( + '
  • ' + f'{_h(title)}' + f'{_h(found.project.slug)} · waiting {_h(_ago(found.state_since))}' + + (' · never runs' if stuck else "") + + "" + + ("" if stuck else _decide(found, acting, up="")) + + "
  • " + ) + how = _how_to_decide(acting) + hidden = len(waiting) - DECISIONS_SHOWN + more = ( + f'

    and {hidden} more

    ' + if hidden > 0 + else "" + ) + return f'
      {"".join(rows)}
    {more}{how}' + + +def _strip(session: Session) -> str: + """Where everything else is, as one line of figures. Item 167. + + Six equal cards became one sentence of numbers, and the demotion is the design: *arrived*, + *queued* and *working* are the machine's business, *in review* is somebody else's, and *closed* + is history. None of them is an action, so none should carry the weight of one — the operator's + own queue is above this, in prose, and it is the only amber thing on the page. + """ + cells = [] + for title, key, _tone, states, _owed in _COLUMNS: + if key == "waiting": + # **Not skipped and not repeated.** The lede and the list above count the items waiting + # for a *decision*; this counts the ones a human has already taken, which is a different + # fact and the only place it appears. Counting the whole column again here would put the + # same number in three places on one screen with two different meanings. + mine = len( + list( + session.scalars( + select(_Item).where(_Item.state == ItemState.HUMAN_ONLY) + ).all() + ) + ) + if mine: + cells.append( + f'{mine}' + 'yours to fix' + ) + continue + count = len(list(session.scalars(select(_Item).where(_Item.state.in_(states))).all())) + label = "in review" if key == "review" else title.lower() + figure = ( + '0' + if not count + else f'{count}' + ) + cells.append(f'{figure}{_h(label)}') + return f'
    {"".join(cells)}
    ' + + def _now(session: Session, prices: Prices | None) -> str: """The attempt in flight, or a sentence saying there is none. @@ -669,11 +1131,12 @@ def _disagreements(session: Session, settings: Settings) -> str: f"{len(ancient)} pull request(s) have been waiting on a human for a week or more" ) + # **Item 167 kept the assertion and dropped the band.** The paragraph above is right that empty + # has to be *visible* — an absent section cannot tell a reader whether the check ran or whether + # it was skipped — and it does not follow that saying so needs a heading and a full-width card. + # On a good day, which is most days, this is one line. if not found: - return ( - '

    What does not add up

    ' - '
    nothing disagrees
    ' - ) + return '

    Nothing disagrees: the three checks ran and found nothing.

    ' rows = "".join(f'
  • {_h(line)}
  • ' for line in found) return f'

    What does not add up

      {rows}
    ' @@ -703,7 +1166,6 @@ def instance( loop_state, loop_seen = lease.state(session) reporting = lease.reporting_of(session) - problems = "".join(f"
  • {_h(problem)}
  • " for problem in report.problems) rows = [ ("state", "ready" if report.ready else "degraded"), ("version", report.version), @@ -749,44 +1211,53 @@ 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 + #: in that order with six equal cards, which is how the third became invisible. Answer first, + #: context second: a problem or a decision is something to *do*, and what the machine is busy + #: with is something to *know*. body = ( - "

    hullwork

    " - # **The opening line stops being a lie when a session can act** (item 166). It said - # "Nothing here changes anything" for two versions and it was true; saying it while two - # buttons work would be worse than saying nothing. - + ( - '

    Read-only. Nothing here changes anything. ' - if acting.csrf is None - else '

    Signed in: an amber item can be decided here. ' - ) - + 'Projects · ' - 'Items and their evidence

    ' - + (f"

    Problems

      {problems}
    " if problems else "") - # **The three bands come first, and they are the page** (item 143). What follows them is - # the evaluator's material — provenance, cost, the credential split — which is kept whole - # and moved below, because the reader who needs it arrives once and the reader this page - # is for arrives daily. - + f"

    Now

    {_now(session, prices)}" - + f"

    Where everything is

    {_board(session)}" - # A count is a link now, so the front page needs the way in to be here too rather - # than only on an item: the operator arrives at this board, not at item 28. - + ( - '' - if acting.offered and acting.csrf is None - else '' + _lede(session, report, waiting=waiting) + + _deciding(waiting, acting) + + _proof( + session, + merged=merged, + holding=holding, + recurred=recurred, + watch=recurrence.WATCH_DAYS, ) + + _now(session, prices) + + _strip(session) + _disagreements(session, settings) - + f"

    State

    {table}
    " - + (f"

    Attempts

      {attempts}
    " if attempts else "") - + (f"

    What they cost

      {spent}
    " if spent else "") - + (f"

    What reviewers did

      {reviewed}
    " if reviewed else "") - + _the_credential_split(session) - + _what_this_instance_allows(settings) + + '

    Every item and its evidence · ' + 'Projects

    ' + + '
    ' + + _signing_in(acting) + + _fold( + "How this instance is configured", + f'
    {table}
    ', + ) + + (_fold("What its attempts came to", f"
      {attempts}
    ") if attempts else "") + + (_fold("What they cost", f"
      {spent}
    ") if spent else "") + + (_fold("What reviewers did", f"
      {reviewed}
    ") if reviewed else "") + + _fold( + "Which half holds what, and what this instance allows", + _the_credential_split(session) + _what_this_instance_allows(settings), + ) + + "
    " ) - return _document("Hullwork — this instance", body, acting=acting) + # 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) #: How many rows a list shows. Bounded because an instance that has been running for a year has @@ -1268,7 +1739,9 @@ def _next_action(found: Item, acting: Acting, *, up: str) -> str: if stuck: parts.append(f'

    But {_h(stuck)}.

    ') if found.state is ItemState.WAITING_APPROVAL and not stuck: - parts.append(_decide(found, acting, up=up)) + # The buttons when there is a session, and otherwise how to get them — here as well as on + # the front page, because a reader can arrive straight at an item from a forge issue. + parts.append(_decide(found, acting, up=up) or _how_to_decide(acting, found, up=up)) if not parts: return "" return f'' @@ -1293,17 +1766,80 @@ def _decide(found: Item, acting: Acting, *, up: str) -> str: ) ) return f'
    {forms}
    ' - if acting.offered: + # **Nothing per item when there is no session.** The instruction is the same sentence for every + # item on the page, and rendering it beside each one turned two decisions into two paragraphs of + # identical prose — the exact noise this item exists to remove. It is printed once, above the + # list, by `_how_to_decide`. + return "" + + +def _login(acting: Acting, *, up: str) -> str: + """The login, or what to run when there is nothing to log in to. Item 168. + + **`autocomplete="current-password"` and a real `
    ` are the whole feature.** A browser + offers to save a password it sees submitted in a form and fills it in next time — which turns + item 167's eight steps into two: open the page, click. There is no username field + because there is no user: an instance has one operator and no notion of identity, and inventing + one to satisfy a manager's heuristics would be inventing a product. + """ + if acting.locked_minutes is not None: + return ( + f'

    Too many wrong passwords. This waits ' + f"{_h(acting.locked_minutes)} more minute(s) before it will try again.

    " + ) + return ( + f'' + '' + '
    ' + ) + + +def _signing_in(acting: Acting) -> 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 + decisions, so an instance with nothing waiting offered no way to sign in at all — and a lockout + had nowhere to be reported either, which made a working lockout look like a broken login. A + `
    ` keeps the calm page calm and still always reachable, with no script. + """ + if acting.csrf is not None: + return "" + if acting.locked_minutes is not None: return ( - f'' + '
    Sign in
    ' + f'

    Too many wrong passwords. This waits ' + f"{_h(acting.locked_minutes)} more minute(s) before it will try again.

    " + "
    " ) + nothing_set = ( + '

    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 + return f'
    Sign in
    {inside}
    ' + + +def _how_to_decide(acting: Acting, found: Item | None = None, *, up: str = "") -> str: + """How to get the buttons, said once for the whole page rather than once per item. + + `found` names the command exactly when there is one item in question, which on an item's own + page there is: `hullwork approve checkout-api 12` is something to run, and + `hullwork approve ` is something to translate first. + """ + if acting.csrf is not None: + return "" + if acting.offered: + return _login(acting, up=up) + command = ( + f"hullwork approve {_h(found.project.slug)} {_h(found.id)}" + if found is not None + else "hullwork approve <project> <item>" + ) return ( - '

    This instance has no operator key, so nothing here can act. Either run ' - f"hullwork approve {_h(found.project.slug)} {_h(found.id)} on the host, or " - "give the page a key with hullwork operator-key.

    " + '

    No password is set on this instance, so nothing here can act. Run ' + "hullwork password on the host to sign in from a browser, or decide from " + f"there — {command}.

    " ) @@ -1336,8 +1872,8 @@ def item( f" — {_h(scrub.text(found.lane_reason))}" if found.lane_reason else "" )), ("kind", _h(found.kind.value)), - ("seen", f"{_h(found.occurrences)} time(s), first {_h(found.first_seen)}, " - f"last {_h(found.last_seen)}"), + ("seen", f"{_h(found.occurrences)} time(s) · first {_when(found.first_seen)}" + f" · last {_when(found.last_seen)}"), ("issue", _issue_link(settings, found)), ("in the tracker", _link(found.permalink) if found.permalink else "—"), ] @@ -1380,7 +1916,7 @@ def item( body = ( f"

    #{_h(found.id)} {_h(title)}

    " f'

    All items · Instance

    ' - f"{table}
    " + f'
    {table}
    ' + _next_action(found, acting, up="../") + ( f'

    {_h(_NOT_STORED)}

    ' + "".join(blocks) @@ -1391,7 +1927,24 @@ def item( else '

    No attempts yet, so there is no evidence to read here.

    ' ) ) - return _document(f"Hullwork — item {found.id}", body, acting=acting, up="../") + # The item's own state on the bar, in the colour its column uses on the front page: a reader who + # arrived from a forge issue has not seen the board, and this is the fastest way to say where + # it is. + tone = { + ItemState.WAITING_APPROVAL: "mine", + ItemState.HUMAN_ONLY: "mine", + ItemState.PR_OPEN: "mine", + ItemState.DONE: "ok", + ItemState.FAILED: "bad", + ItemState.REJECTED: "bad", + }.get(found.state, "") + return _document( + f"Hullwork — item {found.id}", + body, + acting=acting, + up="../", + state=(found.state.value, tone), + ) def _issue_link(settings: Settings, found: Item) -> str: diff --git a/hullwork/upstream.py b/hullwork/upstream.py index a7a69cc..489be5e 100644 --- a/hullwork/upstream.py +++ b/hullwork/upstream.py @@ -113,9 +113,9 @@ | { f"cli:{name}" for name in ( - "approve", "config", "doctor", "gateway", "init", "lease", "operator-key", - "page-token", "projects", "propose", "prune", "republish", "requeue", "status", - "sweep", "try", "work", + "approve", "config", "doctor", "gateway", "init", "lease", + "page-token", "password", "projects", "propose", "prune", "republish", + "requeue", "status", "sweep", "try", "work", ) } ) diff --git a/migrations/versions/b7d3c85a12ef_a_link_instead_of_a_key.py b/migrations/versions/b7d3c85a12ef_a_link_instead_of_a_key.py new file mode 100644 index 0000000..ef4c104 --- /dev/null +++ b/migrations/versions/b7d3c85a12ef_a_link_instead_of_a_key.py @@ -0,0 +1,58 @@ +"""a link instead of a key + +Revision ID: b7d3c85a12ef +Revises: a4e21b8c56df +Create Date: 2026-08-07 + +Item 167. `operator_key` out, `sign_in_invite` in. `operator_session` is untouched. + +**The authority did not change; its delivery did.** One revision earlier an operator was asked to keep +32 random bytes somewhere and paste them into a form — a password with none of a password's +affordances. `hullwork sign-in` now prints a link, opening it exchanges it for a session, and the row is +deleted in the same transaction. Single use is the security property: a link that has been opened has +already been spent, so a leaked one is worth less than a stored key would be. + +**Dropping `operator_key` ends nothing that was working.** Any session it issued lives in +`operator_session` and keeps working until it expires; what disappears is the ability to issue more +from that key, which is the intended effect of replacing it. An instance upgrading through this +revision signs in again with one command. + +No application imports, so this revision keeps describing the schema as it was when it was written. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = 'b7d3c85a12ef' +down_revision: str | None = 'a4e21b8c56df' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + 'sign_in_invite', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_sign_in_invite_token_hash'), 'sign_in_invite', ['token_hash'], unique=False + ) + op.drop_table('operator_key') + + +def downgrade() -> None: + op.create_table( + 'operator_key', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('key_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + op.drop_index(op.f('ix_sign_in_invite_token_hash'), table_name='sign_in_invite') + op.drop_table('sign_in_invite') diff --git a/migrations/versions/c8f4a1d63b27_a_password_the_browser_can_fill.py b/migrations/versions/c8f4a1d63b27_a_password_the_browser_can_fill.py new file mode 100644 index 0000000..a7b1a07 --- /dev/null +++ b/migrations/versions/c8f4a1d63b27_a_password_the_browser_can_fill.py @@ -0,0 +1,68 @@ +"""a password the browser can fill + +Revision ID: c8f4a1d63b27 +Revises: b7d3c85a12ef +Create Date: 2026-08-07 + +Item 168. `sign_in_invite` out, `operator_password` in. `operator_session` keeps its shape. + +**Third design in three revisions, and the first two were secure and unusable.** A stored key had to be +pasted into a form; a one-time link had to be fetched from the host every twelve hours. The operator +counted the second one out loud: open the page, ssh, run a command, copy a link, open it, come back, +reload. What this stores is a password, because a browser's password manager fills one in — one visit to +the host, ever. + +`hashlib.scrypt`, from the standard library, at `n=2**14, r=8, p=1` — 37 ms per attempt on the +deployment host. The parameters live on the row rather than in the code so raising them later keeps +existing passwords verifiable instead of locking their owners out. `failures` and `locked_until` are on +the same row because an instance has one operator: a lockout is about the credential, and a per-address +counter is defeated by changing address, which is free. + +**Sessions issued by the old mechanism keep working** until they expire; what disappears is the ability +to issue more. An instance upgrading through this revision sets a password once. + +No application imports, so this revision keeps describing the schema as it was when it was written. +""" + +from collections.abc import Sequence + +import sqlalchemy as sa +from alembic import op + +revision: str = 'c8f4a1d63b27' +down_revision: str | None = 'b7d3c85a12ef' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.create_table( + 'operator_password', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('salt', sa.String(length=32), nullable=False), + sa.Column('key', sa.String(length=64), nullable=False), + sa.Column('n', sa.Integer(), nullable=False), + sa.Column('r', sa.Integer(), nullable=False), + sa.Column('p', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('failures', sa.Integer(), nullable=False), + sa.Column('locked_until', sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint('id'), + ) + op.drop_index(op.f('ix_sign_in_invite_token_hash'), table_name='sign_in_invite') + op.drop_table('sign_in_invite') + + +def downgrade() -> None: + op.create_table( + 'sign_in_invite', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('token_hash', sa.String(length=64), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index( + op.f('ix_sign_in_invite_token_hash'), 'sign_in_invite', ['token_hash'], unique=False + ) + op.drop_table('operator_password') diff --git a/tests/test_page_surface.py b/tests/test_page_surface.py index 85df98d..c2dee51 100644 --- a/tests/test_page_surface.py +++ b/tests/test_page_surface.py @@ -94,10 +94,11 @@ def test_the_right_token_opens_it(db: Session, client: TestClient) -> None: assert answered.status_code == 200 assert answered.headers["content-type"].startswith("text/html") - # The board, not the heading. This test is about the token opening the door; asserting on - # title text made it fail when item 143 renamed the page, which is a rename breaking a test - # about authentication. A band that only the instance view renders is the durable landmark. - assert "Where everything is" in answered.text + # A structural landmark, not prose. This test is about the token opening the door, and asserting + # on title text made it fail when item 143 renamed the page and again when item 167 restructured + # it — a rename breaking a test about authentication. `class="lede"` is what only the instance + # view renders, and it is the one element that survives a rewording. + assert 'class="lede' in answered.text def test_an_item_that_predates_the_clock_reads_as_not_recorded() -> None: @@ -191,6 +192,8 @@ def test_the_policy_forbids_script_because_there_is_none(db: Session, client: Te #: The only routes under the page prefix that may change anything. Item 166 added them and this #: tuple is the whole of the exception: everything else stays `GET`-only. _MAY_POST = ( + # Three items, three shapes of this tuple, and it noticed each time: item 167 removed `/login` + # for a one-time link outside the prefix, and item 168 put it back as a password form. f"{page.PREFIX}/{{token}}/login", f"{page.PREFIX}/{{token}}/logout", f"{page.PREFIX}/{{token}}/items/{{item_id}}/approve", @@ -198,13 +201,13 @@ def test_the_policy_forbids_script_because_there_is_none(db: Session, client: Te ) -def test_only_the_four_named_routes_under_the_prefix_accept_a_post(client: TestClient) -> None: +def test_only_the_named_routes_under_the_prefix_accept_a_post(client: TestClient) -> None: """**This test used to say `GET`-only, and item 166 is why it does not any more.** 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 a fifth appearing fails this test on the day it is written. + a POST, they are named here, and one more appearing fails this test on the day it is written. 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 diff --git a/tests/test_the_evidence_a_reviewer_came_for.py b/tests/test_the_evidence_a_reviewer_came_for.py index d7eb6e9..5dbd35c 100644 --- a/tests/test_the_evidence_a_reviewer_came_for.py +++ b/tests/test_the_evidence_a_reviewer_came_for.py @@ -488,7 +488,7 @@ 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" - listed = client.get(urljoin(str(door.url), _href(door.text, "Items and their evidence"))) + 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 @@ -545,15 +545,16 @@ def test_the_daily_page_escapes_the_one_third_party_string_it_shows( asked for the hostile fixture to cover this page, and the first version of this test asserted that a hostile *title* renders escaped — which failed, for the best possible reason. - Measured 2026-08-05: a project whose slug, repository and item title are all - `` produces a daily page containing **no occurrence of the word - `script` at all**, escaped or otherwise. The page shows counts, states and ages — the oldest - entry in a column is rendered as *how long*, never as *what*. So the widest surface in the - product is narrow where it matters, by construction rather than by escaping. + **Item 167 changed the fact this test was written about, and that is worth recording.** Measured + 2026-08-05, the daily page showed counts, states and ages and never an item title — the oldest + entry in a column was rendered as *how long*, never as *what* — so the widest surface in the + product was narrow by construction rather than by escaping. - What it *does* show from outside is a forge's own refusal, through `readiness.problems`, and - that is what this exercises. One string, one path, asserted on the real mechanism rather than a - synthetic one. + It is not any more. The operator asked *"waiting on you 2 — and now what?"*, and the answer was + to put the items on the front page **by name**, which puts a third party's exception title there + too. The narrowness is gone and only the escaping is left, so this test asserts the escaping and + stops counting on the page's own vocabulary: that arithmetic broke the day the stylesheet's + comment said the words "no script", which is a false positive about a real property. """ _item(db, project, title=HOSTILE, permalink="javascript:alert(1)") readiness.record_forge(f"unreachable:{HOSTILE}") @@ -563,13 +564,17 @@ def test_the_daily_page_escapes_the_one_third_party_string_it_shows( readiness.record_forge("ok") assert "<script>" in rendered, "the forge's refusal reaches this page, so escape it" - assert "`, `` and `