ProofedMesh/services/verification-service/internal/httpapi/server.go

154 lines
4.4 KiB
Go
Raw Permalink Normal View History

// Package httpapi exposes the verification-service's single endpoint:
// POST /api/v1/verify/chain, per DEV-612.
//
// Request:
//
// {
// "decision_id": "<caller-chosen opaque 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": "<echoed>",
// "actors": ["<approval key_id>", "<transform key_id>"],
// "approval_ref": "sha256:…",
// "transform_ref": "sha256:…",
// "reasons": ["<reason code>", …]
// }
//
// 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()
}