web: draw the whole web tier from sr-ht-ecore
The chrome moved last time; this moves the four packages around it. pages
discovers the page templates and carries the shared error body, so the
hand-maintained page list, the render helper and error.html go; assets finds the
hashed stylesheet and serves the static tree, so the local regexp, the href glob
and handleStatic go; middleware brings the private-cache policy and a panic
guard that aborts a connection rather than appending an error page to a
truncated one; and ecoretest replaces the hand-built config and the TestMain
that minted its own keys.
The one that matters is csrf. The guard was a predicate three handlers
remembered to call, which made unprotected the default for any form added
later. It is now csrf.Require on the router, so it covers the routes that are
not written yet, it runs before routing — an unrouted POST is refused rather
than 404'd — and the refusal is the shared sentence. Host comparison becomes
case-insensitive, which is strictly more permissive and only for an operator
typo in the config.
threads.html becomes _threads.html, the partial spelling pages.Load discovers,
and the review page takes container-fluid: two prose columns do not fit the
centred container, which is what cover and dolt already concluded for their wide
views.
What stays here: the sentinel-to-status mapping in fail, and a renderError that
wraps ecore's error body in this service's view struct.
web: draw the chrome from sr-ht-ecore
The nav/service-switcher, the login block, the environment banner and the
brand were this service's own copy of code compare.sr.ht had already copied
from somewhere else. They come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome
now: one chrome.Service built at startup from the shared config.ini, one
chrome.Page per request, embedded in viewData so the shared partials find
their fields on the dot.
web/chrome.go is gone — buildNav, navItem, canonIndex, the login/logout/
profile URL building and the chrome half of viewData with it. The layout
renders srht-env-banner and srht-nav instead of the local markup, the landing
page's space list renders through srht-repo-list, and the template FuncMap
starts from chrome.Funcs() (the local shortsha was a duplicate of the shared
one). sameOrigin and the login redirect ask the chrome for our origin rather
than keeping a second copy that could disagree with the links on the page.
Three of ecore's policies differ from what this service did, and win, per that
package's own doc: [sr.ht]site-name defaults to "sr.ht" rather than
"sourcehut" and [sr.ht]environment to "development" rather than "production"
when the key is absent, and the brand carries a fixed 15rem min-width so the
switcher starts at the same x on every service. The instance's config.ini sets
both keys, so on it only the brand width is visible.
The nav tests that only restated ecore's rules — switcher order, the
paste/pages/hub exclusion, the shape of a login URL — are dropped; ecore tests
those. What is left covers this service's seam: that the identity authn
resolved is the one the chrome is handed.
authn: remove the local agent-token plane
The agent_token table and everything that minted, verified, listed or revoked
it. Agent credentials are tokens.sr.ht working tokens now: signed, expiring,
owned by a meta.sr.ht account and carrying grants, verified locally by
sr-ht-ecore's bearer package. One door, and nothing behind it — a credential the
instance plane refuses is refused, where it used to be offered to a second store
that might say yes.
DEPLOY GATE: do not deploy this until every agent configured with the shared
secret holds a tokens.sr.ht token with spec:propose (spec:read to read).
Migration 0005 drops the table, migrate-on-upgrade runs it on deploy, and
deploying early locks out every agent at once — including the SSH push path.
hooks/ no longer reads agent_token directly. The push path goes through the same
authn.Resolver the HTTP surfaces use and demands spec:propose, because a push by
an agent is a proposal by another transport. The refs rule and the provenance
requirement are untouched on both counts: a universal grant is still only an
agent to the receive path, and X-Agent / X-Agent-Session are still mandatory on
every agent write.
bearer.ErrNotOurs is now a permanent refusal. A meta.sr.ht PAT used to fall
through to the local store and miss there; with no store to fall through to it
earns a 401 rather than the 503 an unclassified error would.
[tokens.sr.ht] origin becomes a required config key: with no issuer there is no
credential to check, so the daemon fails startup instead of serving reads and
refusing every agent write one request at a time.
/tokens redirects to the daemon that issues (tokens SPEC ch. 7).
authn: accept tokens.sr.ht working tokens beside the agent token
A second agent credential plane, next to the existing one rather than in
place of it. The agent_token table, every agent configured with it, and
the refs rule and provenance requirement around it are untouched; the
local plane is removed in a later phase, not this one.
The resolver tries the instance plane first and falls back to the local
store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs.
spec's local token has no prefix to discriminate on — it is 32 random
bytes in base64, which is precisely what "did not decode as one of ours"
looks like — so the fallback replaces the shape test bench and cover can
afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a
withdrawn credential must not get a second chance at the old door, and an
unreachable daemon must not silently degrade into the legacy plane.
Grants ride on the principal and are checked where the action is known,
never in the middleware, which runs upstream of the router: spec:propose
in service.Propose, below both write surfaces, and spec:read in each read
surface's gate. /mcp checks per tool rather than at its Gate, because one
endpoint carries both kinds and a surface-wide read grant would refuse a
propose-only token at initialize. Principal.Authorize is a no-op off the
instance plane, which is what keeps the local token working.
The instance plane brings an owner where the local token had none, so a
working token belonging to anybody but [sr.ht] owner-name is refused
rather than admitted as a second identity: Principal.Owner is read by the
provenance committer, the refs rule's principal kind and the coreauth
AuthContext, all written for one human.
StatusFor is the one status table. ErrUnavailable is 503 and never 401 —
reading "I could not ask tokens.sr.ht" as "revoked" would refuse every
live instance token while a daemon that is deliberately off the hot path
restarts.
An instance with no [tokens.sr.ht] section builds no instance plane and
starts anyway, serving its own agent token as before.
web: git.sr.ht-style dashboard and unified nav brand
Adopt the family look the dolt service already has. The nav brand
becomes circle icon + site name + red service label (dropping the
hub-origin variant), matching every other service on the instance. The
logged-in index turns into the two-column dashboard: a sidebar with the
service blurb, review-queue/agent-tokens block buttons and the search
form, and the spaces as shared-theme event-list cards. The big in-page
h2 lives on only for anonymous visitors, where the nav has no user
context yet.
ci: cache the assembled scss tree
Move the cacher bootstrap to the first task and assemble the shared
sourcehut scss partials in ~/scss behind a cache keyed by
CORE_VER+BOOTSTRAP_REV. On a hit the build no longer talks to git.sr.ht
or github.com at all, so their outages can't fail it; a pin bump misses
and reassembles from source as before.
fix(apk): keep -modcacherw when overriding GOFLAGS
The GOFLAGS="-trimpath" passed to make clobbers abuild's default
GOFLAGS, dropping -modcacherw: the module cache then extracts read-only
and the CI cache tarball made from it fails to unpack on the next build
(mkdir into 0555 dirs, permission denied — warm build #267). The
poisoned gomod tarball was deleted by ad-hoc job #268.
ci: cache Go module and build dirs via cacher
Same scheme as sr-ht-dolt (dbd8638 there): restore ~/go/pkg/mod and
~/.cache/go-build from the Garage docker-cache bucket before abuild and
seed them back after, keyed by go.sum via cacher (go.bigb.es/cacher).
GOCACHE/GOMODCACHE are re-pinned inside build() because abuild redirects
them into its throwaway $tmpdir and an upstream typo in abuild.in
clobbers any pre-set GOMODCACHE with GOCACHE's value.
ci(apk): build CSS against core 0.84.5
Tracks the srht deployment's SRHT_CORE_VER bump (unified project nav
added scss/nav.scss and reworked dark.scss). BOOTSTRAP_REV unchanged —
the bootstrap submodule did not move between 0.83.8 and 0.84.5.
feat(web,service): the owner mints and revokes agent tokens in a browser
Issuing a credential required SSH to the host, which made the remote
agent write plane unusable from anywhere else: to hand an agent a token
the owner had to be at the machine. /tokens is that page — list, mint,
revoke — behind the same owner-only gate and same-origin guard as
approve/reject.
The mint is owner-only, and that rule is what revocation depends on: an
agent allowed to mint would survive having its own credential revoked by
issuing itself another, and "revoke the token" is the entire incident
response this design has. An agent asking for the page gets 403 rather
than the read plane's login redirect — it is authenticated already, so
bouncing it to meta would answer a question it did not ask.
The plaintext is rendered in the response to the POST rather than after a
redirect. A redirect would either drop the secret or carry it in a URL,
where it lands in history and in every proxy log on the way; the cost is
that a reload re-submits and mints a second token, which is one click to
revoke on that same page, whereas a lost token is not recoverable.
service.IssueAgentToken/ListAgentTokens/RevokeAgentToken hold the ACL and
the mint, and `specsrht token` now goes through them too, so the CLI and
the page cannot drift into two ideas of what issuing a token is.
spec-ejq.3
chore(beads): file spec-rsb and spec-ovo, the two admin commands
feat(cmd): agent tokens and host-side proposals get admin commands
Two entry points were missing, and both left a deployment unable to do
the thing it exists for.
`token create|list|revoke` — db/ has had the whole agent-token lifecycle
since Phase 1, but nothing called it: no CLI, no page. A fresh instance
therefore had no credential for the agent write plane, which refuses an
anonymous caller by design, and the only way to mint one was an operator
hand-writing an INSERT with a sha256 hash. The plaintext is printed once
and never logged; only its hash is stored, and the listing deliberately
omits the hash so nobody mistakes it for the credential.
`doc propose ~owner/space <file>...` — the two agent write surfaces are
remote and so need a bearer token. When the operator and the documents
are already on the host, that token is ceremony: the process can open
Postgres and the bare repositories directly, so it constructs the agent
principal itself rather than resolving one from an agent_token row.
Provenance is not waived — --agent and --session are recorded exactly as
a remote agent's are, so `git log` cannot tell the two apart, and neither
can a reviewer. It calls service.Propose, so If-Match, the branch cut,
the trailers and the auto-merge gate stay spelled once.
Flags parse before, after and between the positionals: Go's flag package
stops at the first non-flag, which would make `doc propose ~bigbes/rfcs
spec.md --title x` drop --title and fail one layer down complaining
about a missing title rather than the flag it ignored.
spec-rsb, spec-ovo
chore(beads): spec-ejq.2 re-index verified against repo.bigb.es
chore(beads): close spec-ejq.2, CI publish is green on build #251
chore(beads): spec-ejq.2 diagnosis resolved, the secret was never created
fix(web): a code fence whose language changed says so (spec-by6.4)
prosediff hashes a block's Info, so ```go becoming ```python pairs the two
fences as a modification — but Block.Lines holds a fence's contents without its
delimiters, so the line script came out entirely equal and every row rendered
as context. The page said the document changed and then showed nothing that
had, which is worse than either saying nothing or showing the change: the
reviewer looks for an edit that appears not to exist.
The fence's opening delimiter is not a row of this table and inventing a line
number for it would be a guess, so the change is stated as a marker row above
the fence's lines, in the same shape a move already uses. Only a code fence is
covered — Info also carries a list item's marker and a table's column count,
and neither is a language a reviewer would want announced.
fix(prosediff): an equal run's separator comes from whichever side has one (spec-by6.5)
spans() read Span.Space off toks[0], and for an equal run those tokens come
from the old side. An equal run exists in both revisions at once, and the two
sides can disagree about what precedes it: a block that gains words at its head
has nothing before its first old token and the insertion before its first new
one. The script then said Space=false and a renderer joining the spans wrote
"{+in practice+}the storage layer" with the words run together — a defect the
reader would read as the author's, because nothing in the output says a
separator went missing.
Not reachable from the review page today: web/diffrows.go's sideSpans already
carries a dropped span's separator onto the next kept one, and the per-line
merge never joins two spans across that boundary. It was wrong in the data all
the same, and the next consumer of the script would have inherited it.
Passing the flag into emit rather than deriving it also drops the
write-back that reached into out[len(out)-1] to clear a substitution's
separator, which would have edited the wrong span had emit ever skipped an
empty run.
feat(web): line-numbered unified prose diff replaces the block cards (spec-by6.3.5)
The block-card renderer was reviewed against a live proposal and rejected:
"ADDED PARAGRAPH" outweighed the content on every row, every block carried
identical chrome, and on a new file the whole page is one change, so the cards
added noise and no signal. Two columns of digits say the same thing and then
get out of the way, which is what a gutter is for.
Selection is by line, anchoring is by block. Lines are what the cursor lands
on; block hashes are what survive a reflow. The web layer maps a selected line
range onto its enclosing prosediff block and stores the existing
core.CommentAnchor unchanged — service/, db/ and core/ do not move and the POST
wire format is untouched. The composer states which block it will anchor to
before anything is typed, so the indirection is visible rather than magic.
A line number is never guessed. A modified prose block goes through
prosediff.WordsByLine, whose ok=false contract is honoured with a paired
old/new region stating a line RANGE; a block rewritten past the similarity
threshold takes that path too. An equal block that was rewrapped states an old
number only for the lines the old revision really holds — equal line counts
were the first rule and were not proof, which a property test over 2800
generated document/edit pairs found within seventeen cases.
The markup is a table because prose wraps and a number has to stay on the first
visual line of the line it names. One rail ground behind both number tracks
with a single hairline against the content; the change tint starts at the sign
column so the gutter never reads as part of the change; heading rows pin
themselves as the section readout, replacing the per-hunk breadcrumb that only
restated a heading three rows above.
Folding and commenting both work with JavaScript off — the fold is a checkbox,
and every block keeps a visible composer. With the script in, that per-block
composer is hidden and reached by selecting lines instead, because sixteen
identical "comment on this block" rows are the chrome this port removes. A
composer holding typed text is never hidden by anything.
Two pre-existing prosediff faults are fixed here because line numbers are what
made them visible: a thematic break reported line 1 for every rule in the
document, and a document whose entire content is "---" panicked in
splitFrontmatter.
feat(prosediff): recover the source line each word edit sits on (spec-by6.3.5)
The review UI is moving to a line-numbered unified diff, which needs to know
which line a word-level change happened on. The differ does not keep that.
Tokenize drops whitespace — "\n" and " " both collapse to Token.Space — and
that is precisely what makes a rewrapped paragraph produce a byte-identical
token stream and therefore no diff at all. The property is load-bearing, so the
line is recovered here rather than retained there.
It is recoverable because the script is ordered: the equal and deleted runs
reproduce the old block's tokens in sequence, and the equal and inserted ones
the new block's. Walking each side in step with that side's re-tokenized lines
says which line every token belongs to, and a run crossing a line break is cut
at the boundary.
The script supplies only the operation per token; text and spacing come from
re-tokenizing the source line. Taking text from the spans instead drops
separators — Span.Space is false on an insertion that directly replaces a
deletion, because in a combined rendering the deletion before it carried the
space, and split onto one side that deletion is gone. Caught by a test:
"delta CHANGED zeta" rendered as "deltaCHANGED zeta".
Reports ok=false rather than guessing when a block's Lines and Text disagree
about token count. A caller that cannot split falls back to rendering the block
as one old/new pair labelled by line range: a wrong line number is worse than
an honest range, because it invites a comment onto text that was never there.
spec-by6.3.5
chore(beads): file spec-ejq.2, CI publish blocked on missing apk-ci-s3 secret