// Package httpapi exposes the verification-service's single endpoint: // POST /api/v1/verify/chain, per DEV-612. // // Request: // // { // "decision_id": "", // "approval_ref": "sha256:<64-hex>", // "transform_ref": "sha256:<64-hex>", // "payload_hash": "sha256:<64-hex>", // "sink_target": "sink:dummy-sink" // } // // Response: // // { // "valid": true|false, // "replay": true|false, // "decision_id": "", // "actors": ["", ""], // "approval_ref": "sha256:…", // "transform_ref": "sha256:…", // "reasons": ["", …] // } // // Status codes: // - 200 OK, valid=true — chain passed, decision_id reserved. // - 200 OK, valid=false — chain failed; `reasons` lists the // specific guards that fired. The sink maps `replay: true` to 409 // and the rest to 403; verification-service itself never signals // "forbidden" via a status code because a 403 would occlude the // structured reasons the sink needs. // - 400 Bad Request — malformed body. // - 502 Bad Gateway — TLog unavailable (transport failure). // - 500 Internal Server Error — unexpected internal error. package httpapi import ( "encoding/json" "errors" "io" "log/slog" "net/http" "strings" "basicstack.de/proofedmesh/services/verification-service/internal/chain" "basicstack.de/proofedmesh/services/verification-service/internal/tlog" ) // Server wires the chain verifier to the HTTP transport. type Server struct { Verifier *chain.Verifier Logger *slog.Logger MaxBody int64 // 0 → default 1 MiB } // Mount registers the endpoints on mux. func (s *Server) Mount(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/verify/chain", s.handleVerifyChain) mux.HandleFunc("GET /healthz", s.handleHealth) } func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte(`{"status":"ok"}`)) } type verifyRequest struct { DecisionID string `json:"decision_id"` ApprovalRef string `json:"approval_ref"` TransformRef string `json:"transform_ref"` PayloadHash string `json:"payload_hash"` // SinkTarget is the canonical field. Historical siblings (dummy-sink's // verify.Client) send `sink_target`; the DEV-612 scope description // used `target_sink`; we accept either on the wire for compatibility // and prefer `sink_target` when both are present. SinkTarget string `json:"sink_target,omitempty"` TargetSink string `json:"target_sink,omitempty"` } func (s *Server) handleVerifyChain(w http.ResponseWriter, r *http.Request) { logger := s.logger() max := s.MaxBody if max <= 0 { max = 1 << 20 } ct := r.Header.Get("Content-Type") if ct != "" && !strings.HasPrefix(ct, "application/json") { writeErr(w, http.StatusBadRequest, "content-type must be application/json") return } raw, err := io.ReadAll(io.LimitReader(r.Body, max+1)) if err != nil { writeErr(w, http.StatusBadRequest, "read body: "+err.Error()) return } if int64(len(raw)) > max { writeErr(w, http.StatusRequestEntityTooLarge, "body too large") return } var req verifyRequest if err := json.Unmarshal(raw, &req); err != nil { writeErr(w, http.StatusBadRequest, "decode: "+err.Error()) return } sink := req.SinkTarget if sink == "" { sink = req.TargetSink } verdict, err := s.Verifier.Verify(r.Context(), chain.Request{ DecisionID: req.DecisionID, ApprovalRef: req.ApprovalRef, TransformRef: req.TransformRef, PayloadHash: req.PayloadHash, SinkTarget: sink, }) if err != nil { if errors.Is(err, tlog.ErrTLogUnavailable) { logger.Warn("tlog unavailable", "err", err) writeErr(w, http.StatusBadGateway, "transparency-log unavailable") return } logger.Error("chain verify failed", "err", err) writeErr(w, http.StatusInternalServerError, "internal error") return } writeJSON(w, http.StatusOK, verdict) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } type errBody struct { Error string `json:"error"` } func writeErr(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, errBody{Error: msg}) } func (s *Server) logger() *slog.Logger { if s.Logger != nil { return s.Logger } return slog.Default() }