// Package api is spec.sr.ht's REST write plane: the HTTP surface an agent PUTs
// a whole document to in order to open or extend a proposal.
//
// It is one of the two agent-facing write surfaces — the other is mcpsrv's
// spec_propose — and the design's rule is that both call the same
// service.Propose rather than each implementing If-Match, provenance and
// auto-merge for themselves. This package therefore holds no proposal logic: it
// parses an HTTP request into a service.ProposeRequest, calls through, and maps
// the result and the service's error sentinels onto status codes. A rule decided
// here that service/ did not would be exactly the drift the shared layer exists
// to prevent.
//
// # The contract
//
// PUT /api/v1/spaces/~owner/name/docs/<path>
// If-Match: <base-rev-sha> # required: the approved head you read at
// X-Proposal: <id> # optional: add to this open proposal
//
// The body is the whole document. `If-Match` is the base B — the approved-head
// sha at the time the agent read — with one meaning shared across REST and MCP:
// opening cuts the branch from it, adding is validated against the proposal's
// fixed B. Title, rationale and the commit message ride in the query string, so
// the body stays the document and nothing else. Every response carries the
// proposal and its URL.
//
// # Who may write
//
// Proposing is agent-only; the human write path is native receive-pack. The
// endpoint installs authn's principal middleware so the bearer token resolves,
// and lets service.Propose refuse a non-agent — the ACL stays in service/,
// spelled once, rather than here and there.
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/service"
)
// maxBodyBytes caps the document a single PUT may carry. It is generous — a
// document is prose, not an upload — and exists only so a runaway or hostile
// client cannot make the daemon buffer an unbounded body into memory. gitx
// enforces its own per-blob limit on the commit; this is the earlier, cheaper
// refusal.
const maxBodyBytes = 5 << 20 // 5 MiB
// Writer is the write side of the orchestration layer this surface calls.
// *service.Service satisfies it, and it is the same method mcpsrv's spec_propose
// calls, which is what keeps the two write surfaces one implementation.
type Writer interface {
Propose(ctx context.Context, req service.ProposeRequest) (service.ProposeResult, error)
}
// Options is everything a Server needs. New reports which one is missing rather
// than failing later inside a handler.
type Options struct {
// Writer is the orchestration layer. *service.Service satisfies it.
Writer Writer
// Resolver turns an agent bearer token into a principal. Handler installs
// its middleware; Register does not.
Resolver *authn.Resolver
}
// Server is the REST write endpoint. It is built once at startup and is safe
// for concurrent use.
type Server struct {
writer Writer
resolver *authn.Resolver
}
// New assembles the server over the seams in opts.
func New(opts Options) (*Server, error) {
if opts.Writer == nil {
return nil, fmt.Errorf("api: Writer is required")
}
if opts.Resolver == nil {
return nil, fmt.Errorf("api: authn Resolver is required")
}
return &Server{writer: opts.Writer, resolver: opts.Resolver}, nil
}
// Handler returns the REST routes with panic recovery and authn's principal
// middleware installed, so it can be mounted on a router that has none:
//
// router.Mount("/api", api.Handler())
//
// A caller whose router already resolves a principal uses Register instead;
// installing the middleware twice is harmless — it is idempotent.
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.Recoverer)
r.Use(s.resolver.Middleware())
s.Register(r)
return r
}
// Register mounts the write routes onto r. It installs no middleware of its own;
// the router it is handed must already resolve a principal into the request
// context (authn.Resolver.Middleware), or every writer looks anonymous and is
// refused.
//
// The document path is a single trailing wildcard: the space is "~owner/name"
// and everything after "/docs/" is the document's path in the tree, decoded per
// segment so a percent-encoded separator inside a name is not mistaken for one.
func (s *Server) Register(r chi.Router) {
r.Put("/v1/spaces/~{owner}/{space}/docs/*", s.handlePut)
}
// proposeResponse is the JSON a successful write returns. It is the REST spelling
// of mcpsrv's proposeOutput — the same fields, so an agent switching surfaces
// reads the same answer — and it always carries the url, the whole point of the
// review plane's entry contract.
type proposeResponse struct {
Proposal int `json:"proposal"`
URL string `json:"url"`
Merged bool `json:"merged"`
State string `json:"state"`
Branch string `json:"branch"`
BaseRev string `json:"base_rev"`
}
// writeJSON writes v as the response body with the given status. A failure to
// encode is logged into the void here — the header is already sent — but cannot
// be helped, so it is deliberately not retried into a second WriteHeader.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
// writeError maps a service error onto a status code and a JSON body an agent
// can act on. The 4xx cases carry the service's message — the agent has to fix
// and retry, and "which document failed the schema" is the whole point — while
// a 5xx is a generic line, because an infrastructure failure's detail belongs in
// the daemon's log, not a client's error field.
func writeError(w http.ResponseWriter, err error) {
status := statusFor(err)
msg := err.Error()
if status >= 500 {
msg = "internal error"
}
writeJSON(w, status, map[string]string{"error": msg})
}
// statusFor maps the service sentinels onto HTTP. The staleness and
// already-merged cases are the design's 409; a malformed document is 422, kept
// distinct from the 403 of a principal that may not propose at all.
func statusFor(err error) int {
switch {
case errors.Is(err, service.ErrForbidden):
return http.StatusForbidden
case errors.Is(err, service.ErrInvalid):
return http.StatusUnprocessableEntity
case errors.Is(err, service.ErrStale),
errors.Is(err, service.ErrAlreadyMerged),
errors.Is(err, service.ErrProposalNotOpen):
return http.StatusConflict
case errors.Is(err, service.ErrNotFound):
return http.StatusNotFound
default:
return http.StatusInternalServerError
}
}