~bigbes/sr-ht-spec

ref: 636dc7a8d00c0f988b5afa2d715ab1140fd6d912 sr-ht-spec/cmd/specsrht d---------
0a32fd7a — Eugene Blikh 9 days ago
logging: take the instance's log policy from ecore
643fa0b5 — Eugene Blikh 9 days ago
logging: log through slog and scribe rather than stdlib log

sr-ht-ecore's panic middleware now reports through log/slog, and it reports
through the *default* logger — nothing can hand a middleware in another module
this service's *slog.Logger. So the daemon's scribe handler becomes the one
install point, and the packages that were still calling log.Printf go through
the default logger too: the read plane's render and encode failures, its 5xx
mapping, and the credential resolver's fail-closed line. Each carries the fields
that used to be interpolated into the sentence — method, path, status, page,
doc — and the error itself through scribe.Err.

The handler grows what it was missing: file:line, because most of what reaches
it is a failure and 'which of the six render sites' is the first question;
colour dropped when stderr is not a terminal; and the masks. This daemon handles
the unified-login cookie and tokens.sr.ht working tokens, and a struct logged
whole is how a live credential outlives its own request in a log file — masking
in the handler covers the log line nobody reviewed as well as these.

cmd/specsrht-migrate keeps stdlib log on purpose: it is a one-shot CLI whose
'specsrht-migrate: ...' progress an operator reads at the terminal during an
upgrade, and log.Fatalf is its error exit.
a4d8cc52 — Eugene Blikh 9 days ago
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).
c7477607 — Eugene Blikh 10 days ago
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.
8219ede1 — Eugene Blikh 13 days ago
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
90eb06ec — Eugene Blikh 13 days ago
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
7cc652d5 — Eugene Blikh 25 days ago
fix(mcpsrv): gate read tools to owner+agents (spec-jjo)

The MCP read tools (spec_search/spec_read/spec_list) enforced no ACL:
anyone clearing the Host allowlist could read approved content. graph's
/query and the web UI gate reads to owner+agents; /mcp did not. Add the
same fail-closed gate to the whole MCP surface — initialize, tools/list
and tools/call — mounted inside the resolver middleware so it sees the
resolved principal. spec_propose was already fail-closed in service.Propose;
this makes the read tools match.

Pre-existing since Phase 2, cheap now that /mcp resolves a principal.

Closes spec-jjo
d8a5164f — Eugene Blikh 25 days ago
feat(webhooks): fire on proposal open/merge/reject (Phase 5a)

The firing half — proposal lifecycle events now deliver GraphQL-native
webhooks. Verified end to end against a live daemon: an agent REST
propose delivers a signed POST whose body is the subscription's stored
query executed against the ProposalEvent payload.

- service: an EventSink seam (service/events.go). Propose emits
  PROPOSAL_OPENED for a new proposal, mergeProposal emits PROPOSAL_MERGED
  (the single merge point — both auto-merge and the human approve reach
  it), Reject emits PROPOSAL_REJECTED. Nil-safe; a Service with no sink
  emits nothing.
- graph.NewProposalEvent builds the *model.ProposalEvent payload from a
  service.Proposal (reusing the existing service→graph→model mapping).
- cmd webhookEventSink: proposal events happen in the service layer,
  which has none of core-go's request context, so the sink enqueues a
  dowork task onto the webhook queue. The task runs in the queue's worker
  context (server+database+config, from WithQueues), adds the owner's
  INTERNAL auth, and calls Schedule — which renders each subscriber's
  query and delivers it Ed25519-signed. Fire-and-forget off the write
  path: a webhook never blocks or fails a proposal write.

Phase 5a (webhooks) is complete: DB, the authn→AuthContext bridge, the
GraphQL surface, the core-go server wiring, and firing.
021d955a — Eugene Blikh 25 days ago
feat(cmd,graph): wire /query onto core-go's server for webhooks (Phase 5a)

The faithful runtime wiring. /query moves from spec's anon-router handler
onto core-go's authenticated router, so the webhook engine gets the
auth/database/server context it requires.

- cmd: build the executable schema via graph.NewSchema and hand it to
  both webhooks.NewQueue and server.WithSchema (one schema, both). The
  server is coreserver.New().WithDefaultMiddleware() (core-go auth +
  database + server context + the delivery worker via WithQueues). web,
  MCP and REST stay on the anon router with spec's own authn — only
  /query changes. The owner "user" row is seeded at startup so core-go's
  LookupUser stays local.
- ownerOnly middleware on /query: 403s any non-owner (core-go auth admits
  any meta user; spec is single-owner) and remaps the owner to
  AUTH_INTERNAL so the webhook engine's NewAuthConfig/FilterWebhooks
  (which refuse AUTH_COOKIE) accept them.
- graph: NewSchema exposes the raw executable schema; the webhook
  resolver ACL now requires AUTH_INTERNAL (only the owner gets it, via
  ownerOnly) instead of spec's authn, which is no longer in the /query
  chain.

Accepted trade-off: agents lose GraphQL /query reads (they keep MCP +
REST). New deploy requirement: WithDefaultMiddleware needs [mail]
smtp-from (core-go's notification queue).

Verified against a live daemon on Postgres: owner cookie creates and
lists webhooks (row stored INTERNAL/user_id 1); non-owner 403; unauth
401; web UI 200 on the anon router.
1476e065 — Eugene Blikh 26 days ago
feat(api): REST write plane — PUT a document to propose (Phase 3)

The other agent-facing write surface, PUT /api/v1/spaces/~owner/name/
docs/<path> with If-Match and X-Proposal headers, returning
{proposal, url}. Like spec_propose it holds no proposal logic: it parses
the request into a service.ProposeRequest, calls the same service.Propose,
and maps the result and the service sentinels onto status codes — 201 on
open, 200 on add, 403 non-agent, 422 malformed document, 409 stale /
already-merged, 404 missing. The body is the whole document; title,
rationale and message ride in the query string so the body stays the
document. The acting agent is resolved from the bearer token by the
principal middleware the endpoint installs, and service.Propose is the
one ACL — an anonymous caller is a 403 there.

Split the service error taxonomy this surface exposed: ErrInvalid (422,
a malformed document the agent must fix) is now distinct from
ErrForbidden (403, a principal that may not propose at all). Conflating
them answered "bad document" with "you are not allowed", which is exactly
the distinction the retrying agent needs.
42d4ee13 — Eugene Blikh 26 days ago
feat(mcpsrv): spec_propose write tool (Phase 3)

The agent-facing half of the write plane over MCP. spec_propose uploads
whole documents and returns {proposal, url, merged}, calling the same
service.Propose the REST PUT will — one implementation of If-Match,
provenance and auto-merge behind both surfaces, never two that drift.

- Writer is an optional Backend field: nil keeps the server read-only
  (the three read tools, unchanged), so a read-only deploy or a test
  needs no mutable backend. Set, it registers spec_propose with neither
  the read-only nor the idempotent hint — proposing twice opens two
  proposals.
- The acting agent is resolved from the bearer token on the tool call by
  the resolver middleware now installed on /mcp; the read tools never
  needed it. The ACL stays in service/: service.Propose refuses a
  non-agent, so an anonymous or owner caller is rejected there.
f8a7a174 — Eugene Blikh 26 days ago
feat(graph): wire the proposals read to service.ListProposals (Phase 3)

The Proposals port declared a proposal listing and left Options.Proposals
nil, so the `proposals` query failed loudly with "service/ exposes no
proposal listing yet". Phase 3 supplies it: an adapter maps
service.Proposal onto graph.Proposal at the edge — the two structs are
identical, but service/ must not import graph/, so the rename lives here
beside web.NewReader's equivalent — and cmd wires graph.NewProposals(svc)
into the schema. The `proposals` field now answers from service/.
4ad85257 — bigbes 27 days ago
feat(cmd): reindex a space when a push lands

The push notifier was a Phase 1 stub that logged 'reindex pending' because
bleve did not exist yet. It does now, so a pushed document was readable
but never searchable, and the reconciler reported permanent staleness.

Pushes touching only proposal branches skip the rebuild: the index holds
the approved revision, and making unreviewed text searchable is the same
leak as serving it from the read plane.

The index stamp is written last and only on success. Written earlier it
would assert the index reflects a revision it does not — the exact
staleness the reconciler exists to catch, and it would catch nothing.

The hook server is now built after the surfaces, since the notifier
reindexes through the same single-writer index they read from.
2928bf9d — bigbes 27 days ago
feat(cmd): specsrht space create/list

Spaces had no entry point at all: the read plane only reads, and the
proposal API operates on documents inside a space that already exists, so
a freshly deployed instance could not hold anything.

Creation installs the receive hooks itself rather than relying on the
daemon's startup refresh. A space created while the daemon runs would
otherwise accept unvalidated pushes until the next restart — the exact
fail-open the receive path exists to prevent.
96471669 — bigbes 27 days ago
feat(cmd): mount the read plane, MCP and GraphQL surfaces

The three Phase 2 surfaces were built but never served: each was written
under an instruction not to touch cmd/, so every one reported its mounting
call and none of them wired it. The daemon answered /healthz and 404'd
everything else.

They share one search.Index, because bleve is single-writer and a second
Open on the same directory is wrong rather than merely wasteful.

Route order is load-bearing: /mcp and /query register before the web UI
mounts at /, which would otherwise swallow them as document paths — the
router has no reason to think 'mcp' is not a space name.

The MCP handler takes the configured origin as its Host allowlist, which
is why Traefik must pass the Host header through.
c7e4f9f9 — Eugene Blikh 27 days ago
feat: hooks — the receive path, its daemon RPC, and the specsrht daemon

The hooks are thin shims that RPC into the running daemon over a unix
socket under the repos root, so validation lives in one place and the
push path cannot drift from the API. They fail closed: an unreachable
daemon rejects the push.

Three hooks are installed, not two, because of two properties of git
verified against 2.55 rather than inferred:

  - GIT_PUSH_OPTION_* reaches pre-receive and post-receive only; the
    update hook observably runs without them, so it cannot see
    --push-option=skip-validation on its own.
  - during pre-receive the pushed objects are still in receive-pack's
    quarantine and unreadable by any other process, so the daemon
    cannot validate there. git migrates them out before the first
    update hook, which makes update the earliest hook that works.

pre-receive therefore forwards the push options and the ref list; the
daemon records them keyed by (repository, receive-pack pid) and by the
exact ref update; update does the rejecting; post-receive notifies.
An update with no recorded pre-receive phase is refused rather than
assumed unskippable.

Each hook is a symlink to the specsrht binary, which dispatches on the
name git invoked it as. Install also sets receive.advertisePushOptions,
without which the escape hatch fails client-side. The daemon refreshes
every space's hooks at startup and treats a failure as fatal.

cmd/specsrht validates every config key at once before opening
anything, serves /healthz on -b (default localhost:5091), runs the
reconciler, and warm-shuts-down on SIGINT and SIGTERM alike by
bridging SIGTERM into the SIGINT core-go's server.Run waits for.

Tested with a real git push against a real bare repo with the hooks
installed: a valid push lands, bad frontmatter is rejected with a
readable message, skip-validation waives it, a force-push to the
approved branch is refused with or without skip-validation, a mistyped
push option is refused rather than ignored, and a push with no daemon
is rejected.