Artifact b9bc604abfe701de6d335d362bfee227c04cbdeaa1ea2dab44c70916fdff098c:
- File
internal/api/router.go
— part of check-in
[31437ec328]
at
2026-08-22 16:11:52
on branch fossil-caps
— caps phases 2-3: ops declare fossil capabilities; router and web gate on them
RFC 0004 §4 table encoded on all ~45 ops; RouterWithCaps refines each Auth floor with EffectiveCaps; web gates and TrustedAuthor read caps with role fallback. Harness proves o/3/a separation end to end. (user: dmestas, size: 7706) [annotate] [blame] [check-ins using]
// router.go — the API consumer of the registry (0001 §9). Every registered // op is mounted on a stdlib ServeMux; input crosses through the shared // codecs and validator, output leaves as JSON. The generated OpenAPI // document is served from the same registry at /api/v1/openapi.json. package api import ( "craftdesign.group/quarry/internal/errlog" "encoding/json" "errors" "fmt" "log" "mime" "net/http" "net/url" "reflect" "strings" "craftdesign.group/quarry/internal/ops" ) // Verify checks one repo credential pair against the capability the op // requires. A nil Verify leaves every gated op open (single-user local // serving); the composition root supplies the OLTP-backed implementation. // need is the op's own ops.Auth, so one seam serves both the write ops // (Token) and the administrative ones (Admin) without the router // deciding what either means. type Verify func(repo, user, password string, need ops.Auth) Decision // CapSet is the expanded capability set for one (account, repo). It // mirrors oltp.CapSet without importing it — the router stays free of // storage types. type CapSet interface{ HasAny(letters string) bool } // CapsFor resolves effective capabilities. nil disables cap enforcement // in the router (the Auth floor still applies), which is what a forge // with no accounts plane wants. type CapsFor func(account, repo string) CapSet // Decision is what a credential check concluded. Three outcomes rather // than a bool because two of them are not the same thing and the wire // has distinct codes for them: a client told 401 retries with the same // credentials forever, when what it needed to hear was that the // credentials were fine and the capability was not. type Decision int const ( // Denied: absent or wrong credentials. 401, with a challenge. Denied Decision = iota // Insufficient: authenticated, but lacking the capability. 403, and // deliberately WITHOUT a challenge — re-authenticating cannot help. Insufficient // Allowed. Allowed ) // Router mounts every op in r plus the OpenAPI document. version flows // into the spec's info block. verify (optional) gates ops.Token ops // with HTTP Basic against it. func Router(r *ops.Registry, version string, verify ...Verify) (http.Handler, error) { return RouterWithCaps(r, version, nil, verify...) } // RouterWithCaps is Router plus capability enforcement (RFC 0004). func RouterWithCaps(r *ops.Registry, version string, capsFor CapsFor, verify ...Verify) (http.Handler, error) { var v Verify if len(verify) > 0 { v = verify[0] } mux := http.NewServeMux() for _, d := range r.All() { mux.Handle(d.Method+" "+d.Pattern, opHandler(r, d, v, capsFor)) } spec, err := ops.Spec(r, "quarry", version) if err != nil { return nil, err } specJSON, err := json.MarshalIndent(spec, "", " ") if err != nil { return nil, err } mux.HandleFunc("GET /api/v1/openapi.json", func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.Write(specJSON) }) // Unmatched API paths answer in the API's own dialect, never Go's // plain-text default. mux.HandleFunc("/api/", func(w http.ResponseWriter, _ *http.Request) { writeError(w, http.StatusNotFound, errors.New("no such endpoint")) }) return mux, nil } func opHandler(r *ops.Registry, d *ops.Descriptor, verify Verify, capsFor CapsFor) http.Handler { wild := ops.Wildcards(d.Pattern) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { // Resolve identity even for ANONYMOUS ops. A private repository // is readable by the people with a role on it, and a read op // that never looks at credentials cannot tell those people from // strangers — so its owner would be locked out of their own // repository. if d.Auth == ops.Anonymous && verify != nil { if user, pass, ok := req.BasicAuth(); ok && user != "" { if verify(req.PathValue("repo"), user, pass, ops.Anonymous) == Allowed { req = req.WithContext(ops.WithUser(req.Context(), user)) } } } if d.Auth != ops.Anonymous && verify != nil { user, pass, _ := req.BasicAuth() switch verify(req.PathValue("repo"), user, pass, d.Auth) { case Denied: w.Header().Set("WWW-Authenticate", `Basic realm="quarry"`) writeError(w, http.StatusUnauthorized, errors.New("authentication required")) return case Insufficient: writeError(w, http.StatusForbidden, errors.New("this account does not have the capability this request needs")) return } req = req.WithContext(ops.WithUser(req.Context(), user)) } // The capability refinement (RFC 0004 §4): Auth was the coarse // floor, Caps is what this SURFACE needs. Unwired (nil) means // caps are not enforced here — the floor still stands. if d.Caps != "" && capsFor != nil { if !capsFor(ops.User(req.Context()), req.PathValue("repo")).HasAny(d.Caps) { writeError(w, http.StatusForbidden, errors.New("this account does not have the capability this request needs")) return } } in, err := decodeIn(req, d, wild) if err != nil { writeError(w, http.StatusBadRequest, err) return } if err := ops.Validate(in); err != nil { writeError(w, http.StatusBadRequest, err) return } out, err := r.Invoke(req.Context(), d.Name, in) if errors.Is(err, ops.ErrNotFound) { writeError(w, http.StatusNotFound, err) return } if errors.Is(err, ops.ErrInvalid) { writeError(w, http.StatusBadRequest, err) return } if errors.Is(err, ops.ErrForbidden) { writeError(w, http.StatusForbidden, err) return } if errors.Is(err, ops.ErrUnavailable) { writeError(w, http.StatusServiceUnavailable, err) return } if err != nil { // The real error goes to the log AND the journal; the public // body never carries internals (subprocess invocations, SQL, // paths). The journal is what makes "details are in the // server log" a promise someone can actually collect on. log.Printf("api: %s: %v", d.Name, err) errlog.Default.Report("api", d.Name, err) writeError(w, http.StatusInternalServerError, errors.New("internal error")) return } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(out) }) } // decodeIn assembles the op's In struct from the request: query string // (GET) or body (POST, form or JSON by content type), with path wildcards // merged last so the routed path is always authoritative. func decodeIn(req *http.Request, d *ops.Descriptor, wild []string) (any, error) { inPtr := d.NewIn() switch { case d.Method == http.MethodGet: if err := ops.DecodeValues(req.URL.Query(), inPtr); err != nil { return nil, err } case isJSON(req): if err := json.NewDecoder(req.Body).Decode(inPtr); err != nil { return nil, fmt.Errorf("body: %w", err) } default: if err := req.ParseForm(); err != nil { return nil, fmt.Errorf("form: %w", err) } if err := ops.DecodeValues(req.PostForm, inPtr); err != nil { return nil, err } } if len(wild) > 0 { pathVals := url.Values{} for _, name := range wild { pathVals.Set(name, req.PathValue(name)) } if err := ops.OverlayValues(pathVals, inPtr); err != nil { return nil, err } } return deref(inPtr), nil } func deref(p any) any { return reflect.ValueOf(p).Elem().Interface() } func isJSON(req *http.Request) bool { ct, _, _ := mime.ParseMediaType(req.Header.Get("Content-Type")) return strings.HasSuffix(ct, "/json") || strings.HasSuffix(ct, "+json") } func writeError(w http.ResponseWriter, code int, err error) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) }