Artifact ea78e120aaafc60f259be09ec7d32e1bc60a4a953fc53ca2dd09ece0cc1b8857:
- File
internal/api/router.go
— part of check-in
[86880118d5]
at
2026-08-18 02:09:54
on branch trunk
— account: manage identity through the API, and stop lying about why
Six ops (account.list/add/passwd/delete/grant/revoke, admin) plus quarry accounts on top of them. Creating a login or granting someone read on a repository no longer needs shell access on the server.
quarry account still opens OLTP directly and has to: the FIRST account must exist before any server runs or any admin exists to authenticate as. Everything after that is the API, so the CLI has no privileged path.
The verifier never crosses the seam in either direction, and account.add /passwd scrub the password out of any error before it travels โ an error is the one value that reaches a log, an HTTP body and a terminal at once.
Verify returns a three-valued Decision instead of a bool. Denied and Insufficient are not the same thing and the wire has separate codes for them: an authenticated client short of a capability was being told 401 with a challenge, which invites it to retry the same password forever. Now 403, and deliberately without a challenge. A harness test pinned the old behaviour and was updated with the reason. (user: dmestas, size: 5697) [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 ( "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 // 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) { 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)) } 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) http.Handler { wild := ops.Wildcards(d.Pattern) return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { 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)) } 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.ErrUnavailable) { writeError(w, http.StatusServiceUnavailable, err) return } if err != nil { // The real error goes to the log; the public body never // carries internals (subprocess invocations, SQL, paths). log.Printf("api: %s: %v", 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()}) }