// Package httpapi mounts the approval-service HTTP endpoints. package httpapi import ( "context" "encoding/base64" "encoding/json" "errors" "log/slog" "net/http" "time" "basicstack.de/proofedmesh/services/approval-service/internal/auth" "basicstack.de/proofedmesh/services/approval-service/internal/content" "basicstack.de/proofedmesh/services/approval-service/internal/service" "basicstack.de/proofedmesh/services/approval-service/internal/sessions" "basicstack.de/proofedmesh/services/approval-service/internal/signer" "basicstack.de/proofedmesh/services/approval-service/internal/store" "basicstack.de/proofedmesh/services/approval-service/internal/webauthn" pkgapi "basicstack.de/proofedmesh/services/approval-service/pkg/httpapi" ) // AuthResolver resolves the authenticated principal from the request. // Prod wires it to the Pocket-ID cookie middleware; tests inject a // fixed principal via a shim. type AuthResolver interface { Resolve(r *http.Request) (auth.Principal, error) } // AuthFunc adapts a plain function to AuthResolver. type AuthFunc func(r *http.Request) (auth.Principal, error) // Resolve calls f. func (f AuthFunc) Resolve(r *http.Request) (auth.Principal, error) { return f(r) } // Server is the HTTP handler for the approval-service. type Server struct { Auth AuthResolver Sessions *sessions.Manager Orchestrator *service.Orchestrator Logger *slog.Logger Now func() time.Time } // Mount registers the three API routes plus /healthz on mux. func (s *Server) Mount(mux *http.ServeMux) { mux.HandleFunc("POST /api/v1/sessions", s.handleCreateSession) mux.HandleFunc("POST /api/v1/approvals/preview", s.handlePreview) mux.HandleFunc("POST /api/v1/approvals/confirm", s.handleConfirm) 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"}`)) } func (s *Server) handleCreateSession(w http.ResponseWriter, r *http.Request) { p, err := s.resolve(r) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) return } // POST /sessions has no body in Phase 1; the Pocket-ID principal // alone identifies the user. sess, err := s.Sessions.StartSession(r.Context(), p.Sub) if err != nil { s.logError("start session failed", err) writeErr(w, http.StatusInternalServerError, "session_create_failed", err.Error()) return } writeJSON(w, http.StatusCreated, pkgapi.CreateSessionResponse{ SessionID: sess.ID, UserSub: sess.UserSub, ActiveUntil: sess.ActiveUntil, }) } func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { p, err := s.resolve(r) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) return } var req pkgapi.PreviewRequest if err := decodeJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.SessionID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "session_id is required") return } if len(req.Content) == 0 { writeErr(w, http.StatusBadRequest, "bad_request", "content is required") return } sess, err := s.Sessions.GetActiveSession(r.Context(), req.SessionID, p.Sub) switch { case errors.Is(err, store.ErrNotFound): writeErr(w, http.StatusNotFound, "session_not_found", "session_id unknown") return case errors.Is(err, sessions.ErrSessionExpired): writeErr(w, http.StatusForbidden, "session_expired", "approval session expired") return case err != nil: s.logError("session lookup failed", err) writeErr(w, http.StatusInternalServerError, "session_lookup_failed", err.Error()) return } bind, err := content.Bind(req.Content) if err != nil { writeErr(w, http.StatusBadRequest, "bad_request", "content: "+err.Error()) return } previewID, err := s.Orchestrator.NewID("pv") if err != nil { writeErr(w, http.StatusInternalServerError, "id_gen_failed", err.Error()) return } now := s.now() pv := store.Preview{ ID: previewID, SessionID: sess.ID, UserSub: p.Sub, ContentHash: bind.ContentHash, ContentCanonical: bind.ContentCanonical, Salt: bind.Salt, Nonce: bind.Nonce, VerificationDigits: bind.VerificationDigits, ExpiresAt: now.Add(s.Sessions.ChallengeTTL), CreatedAt: now, } if err := s.Orchestrator.Store.SavePreview(r.Context(), pv); err != nil { s.logError("save preview failed", err) writeErr(w, http.StatusInternalServerError, "save_preview_failed", err.Error()) return } writeJSON(w, http.StatusOK, pkgapi.PreviewResponse{ PreviewID: pv.ID, ContentHash: pv.ContentHash, VerificationDigits: pv.VerificationDigits, WebauthnChallenge: base64.RawURLEncoding.EncodeToString(bind.Challenge), ExpiresAt: pv.ExpiresAt, }) } func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { p, err := s.resolve(r) if err != nil { writeErr(w, http.StatusUnauthorized, "unauthorized", err.Error()) return } var req pkgapi.ConfirmRequest if err := decodeJSON(r, &req); err != nil { writeErr(w, http.StatusBadRequest, "bad_request", err.Error()) return } if req.PreviewID == "" { writeErr(w, http.StatusBadRequest, "bad_request", "preview_id is required") return } assertion := webauthn.Assertion(req.Assertion) res, err := s.Orchestrator.Confirm(r.Context(), req.PreviewID, assertion, p.Sub) if err != nil { s.mapConfirmError(w, err) return } writeJSON(w, http.StatusOK, pkgapi.ConfirmResponse{ ApprovalID: res.ApprovalID, AttestationRef: res.AttestationRef, LogIndex: res.LogIndex, TLogStatus: string(res.TLogStatus), }) } func (s *Server) mapConfirmError(w http.ResponseWriter, err error) { switch { case errors.Is(err, store.ErrNotFound): writeErr(w, http.StatusNotFound, "preview_not_found", "preview_id unknown") case errors.Is(err, store.ErrAlreadyConsumed): writeErr(w, http.StatusConflict, "preview_consumed", "preview already consumed") case errors.Is(err, service.ErrChallengeExpired): writeErr(w, http.StatusForbidden, "challenge_expired", "approval challenge expired") case errors.Is(err, service.ErrContentHashMismatch): writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error()) case errors.Is(err, webauthn.ErrChallengeMismatch): writeErr(w, http.StatusBadRequest, "content_hash_mismatch", err.Error()) case errors.Is(err, webauthn.ErrCredentialUnknown): writeErr(w, http.StatusUnauthorized, "credential_unknown", "credential_id not registered") case errors.Is(err, webauthn.ErrAssertionInvalid): writeErr(w, http.StatusUnauthorized, "assertion_invalid", "webauthn assertion verification failed") case errors.Is(err, signer.ErrSignerUnavailable): writeErr(w, http.StatusBadGateway, "signer_unavailable", err.Error()) default: s.logError("confirm failed", err) writeErr(w, http.StatusInternalServerError, "confirm_failed", err.Error()) } } func (s *Server) resolve(r *http.Request) (auth.Principal, error) { if s.Auth == nil { return auth.Principal{}, errors.New("no auth resolver configured") } return s.Auth.Resolve(r) } func (s *Server) now() time.Time { if s.Now != nil { return s.Now() } return time.Now() } func (s *Server) logError(msg string, err error) { if s.Logger != nil { s.Logger.Error("approval-service: "+msg, "err", err) } } func decodeJSON(r *http.Request, v any) error { dec := json.NewDecoder(http.MaxBytesReader(nil, r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(v); err != nil { return err } return nil } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeErr(w http.ResponseWriter, status int, code, msg string) { writeJSON(w, status, pkgapi.Error{Error: pkgapi.ErrorBody{Code: code, Message: msg}}) } // Context sanity: ensure we never return a canceled error as a 500 just // because the client disconnected mid-flight. var _ context.Context = context.Background()