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.
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
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.
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(web): commentable prose diff with honest anchor state (spec-by6.3.3)
Reverses the diff view's founding rule. renderDocDiff skipped ChangeEqual
outright — "the review shows only what changed" — but any block of a proposed
document must be commentable, so unchanged blocks now render as collapsed,
dimmed context. Changed blocks keep their border, tint and full body, so the
page still reads as a diff at a glance rather than as a document dump. A
context block carrying a comment renders open. ChangeMoveIn now shows its text
too, since a comment control on invisible text is a control on nothing;
ChangeMoveOut stays a bare marker and is deliberately not commentable, because
the same paragraph is anchorable at its move-in position and two anchors for
one paragraph is the bug that avoids.
Every rendered block carries id="b-<16 hex>", hashed from the whole anchor
tuple. Not the page ordinal: an ordinal renumbers on any insertion above it, so
a saved link would silently scroll to a neighbouring paragraph, whereas
including the block hash makes a stale link resolve to nothing instead.
Threads are placed by anchor and by nothing else. Anything no rendered block
claims — an outdated anchor, an old-side anchor whose block the diff no longer
draws, a document the proposal no longer changes — is collected into a
page-level "comments that lost their anchor" area. Never dropped, never moved
onto a neighbour: a comment reads as authoritative about the block it sits
beside, so attaching it to the wrong one is worse than admitting it lost its
place. An edited anchor is drawn on its block and badged.
The comment form's anchor is built at submit time from the branch as it now
reads, through service.AnchorOf — hand-rolling the ordinal conversion here
would put the browser's comments on different blocks than the MCP tool's, which
is the one way two surfaces of one conversation disagree without either looking
broken. The form's block hash guards it: a block that moved while the page sat
open is a 409, not a comment attached to whatever took its place.
That hash is required rather than checked-when-present. Skipping the guard for
a form that omits it would let a later template refactor drop the hidden field
and disable the staleness check silently, with every test still green.
Authority is surfaced, not re-implemented: compose and resolve are the owner's
because service says so and ErrForbidden becomes a 403.
spec-by6.3.3
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1)
graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled
'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own
comment warned is how a corpus leaks when they drift. Define it once as
authn.Principal.CanRead and route all three through it.
coreauth.Derive keeps its own owner||agent test on purpose: it answers a
different question (is this an owner-backed identity to bridge to
AUTH_INTERNAL), and coupling it to the read ACL would misroute a future
read-only viewer kind to the owner's UserID.
Closes spec-ejq.1
feat(web): digest tracks 'since you last looked' via digest_mark (spec-mfm)
The policy-merged digest showed the last N auto-merges by count; the
design intends "what auto-merged since you last saw it", backed by the
digest_mark table that existed but nothing read. The inbox GET now reads
the mark to flag each digest row that merged after it as new, count them,
and draw a divider before the already-seen rows — staying a pure read.
Advancing the mark is a write, so it is an explicit POST /inbox/seen
behind the owner-only + same-origin guard approve/reject already use, not
a side-effecting GET. service.DigestMark/MarkDigestSeen wrap the store,
mapping "no mark yet" to (zero, false) so the first-ever view reads the
whole digest as new.
Closes spec-mfm
feat(web): review queue — inbox + policy-merged digest (Phase 4)
The backstop for work no link reached. /inbox lists every open proposal
on the instance ("waiting on you") and, below it, the digest of recently
policy-merged content — the firehose a human sees after the fact, which
is the whole reason approval=policy is kept distinct from human.
- service.InboxProposals / DigestProposals list instance-wide (one
reviewer, so a per-space inbox would make them hunt), mapping each
stored proposal's space_id back to a reference once from the space list.
- web/inbox.go + inbox.html render the two sections; the landing page
links the queue for a logged-in owner.
Follow-up: the digest currently shows recent policy-merges rather than
"since you last looked" — the digest_mark table exists to track that, but
advancing it is a write and GET stays pure. Filed separately.
feat(web): proposal review page — prose diff + approve/reject (Phase 4)
The browser review plane at /~owner/space/p/<id>, the stable URL every
write already returns. The owner opens the link an agent handed them,
reads a prose diff of each changed document, and approves (merges now) or
rejects.
- web/diff.go: the prose-diff HTML renderer, consuming prosediff's block
model (the package renders text only; HTML is the web layer's job). It
implements the Phase 0 verdict's hard requirement — inline word diffs
above 0.75 block similarity, a two-column old/new view below it, because
13% of real edits shred and are unreadable inline. All document content
is HTML-escaped; only the diff structure is markup.
- service/review.go: ProposalDiff reads each changed document's base and
proposed content for the page to diff (branch tip resolved to a sha, the
legitimate pinned-rev read, not the ReadDocumentAtRef bypass), and
MergeHuman fixes the approval kind so a browser approve is always human.
- web/proposal.go: the GET page and the approve/reject POSTs. Only the
owner may act (an agent is authenticated but has no more approval
authority than anyone); a cross-site guard on Origin/Referer is the CSRF
defense a form post needs when the session cookie is meta's. Post-
redirect-get back to the page. Stale/already-merged approve → 409.
- web.Reader gains the proposal reads and the two actions; the diff-view
styles go in scss/main.scss (inline marks, two-column, code line diffs).
Inbox and the policy-merged digest are the remaining Phase 4 surfaces.
refactor: make the filter-polarity trap inexpressible
service.SpaceFilter meant "empty membership selects nothing" — a newly
created project has no members — while search.Query.Spaces was a bare
[]core.SpaceRef whose empty case meant every space. Passing a project's
members into a query therefore turned an empty project into the whole
corpus: a silent scope inversion, invisible when it happens, and passing
every test written with a non-empty project.
The filter moves to core.SpaceFilter with unexported fields, and
search.Query takes it whole. There is no slice to hand over any more, so
the inversion cannot be written. Its three states are distinct: every
space (EverythingFilter), exactly these — possibly none — (SpacesFilter),
and the zero value, which is neither. Search refuses the zero value
rather than defaulting it, because both plausible defaults are wrong for
one of the two callers that can produce one, and returns no hits for a
filter that selects no space without asking the index.
service.SpaceFilter is now an alias for the core type, so ResolveProject
and its callers keep their names. Tests that built a Query without a
scope now say core.EverythingFilter(), which is what they always meant.
feat: service.Archive — one accessor, one tree walk, one link graph
web/ and mcpsrv/ both needed a *doc.Archive and each invented its own way
to get one. web/ handed sp.Repo to doc.Scan — reaching past service/ into
gitx, which the layering rule forbids — and then listed the documents a
second time for their bodies, two tree walks per page view. mcpsrv/
converted []service.Document back into []gitx.Document, rebuilding hashes
service/ had already stringified. Two workarounds around one missing
accessor is how three agent-facing surfaces stop being identical.
Service.Archive resolves the revision, walks the tree once, and returns
the archive with the bodies. ArchiveFrom is the same construction over a
Document set a caller already holds — the one place a hex object name is
converted back to a hash, malformed ids refused rather than zeroed.
doc.Page.Links and Page.WordCount were documented as "filled in by a
render pass" and nothing filled them, so Archive.Backlinks always
returned empty; web/ worked around that by re-rendering every document of
the space on each page view. doc.Archive.LinkPass now owns the pass and
the accessor runs it, so the link graph exists wherever the archive does.
web/ and mcpsrv/ no longer import gitx or go-git, in tests either.
doc.DirOf replaces the three copies of the same fromDir helper.
feat: web — the Phase 2 read plane UI and its SCSS entry
SourceHut chrome (nav service-switcher, login block, environment banner,
error page, embedded hashed static assets) ported from compare.sr.ht, plus
the read-plane pages: landing, space document tree, rendered document with
frontmatter/backlinks, and search.
The URL grammar is the design's pinned one: a document's address carries no
extension, ".md" is raw source, ".json" is metadata plus body, and ?rev=
pins any of the three to an immutable revision. Links rendered inside a
pinned page keep the pin, so following one does not silently land on the
approved head.
One human and no visibility levels, so the read ACL is one line: the owner
and its agents read, everyone else is redirected to meta's login (a browser)
or refused with 401 (a client asking for .md/.json).