// 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/ // If-Match: # required: the approved head you read at // X-Proposal: # 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 } }