element for the search engine icon.
+type Image struct {
+ Height int `xml:"height,attr"`
+ Width int `xml:"width,attr"`
+ Type string `xml:"type,attr"`
+ Value string `xml:",chardata"`
+}
+
+const xmlNS = "http://a9.com/-/spec/opensearch/1.1/"
+
+// DescriptionForProvider builds an OSD document that points the browser
+// directly at the upstream provider — useful when the user wants to add
+// e.g. urbandictionary as a standalone search engine.
+func DescriptionForProvider(p Provider) OpenSearchDescription {
+ osd := OpenSearchDescription{
+ XMLNS: xmlNS,
+ ShortName: p.Name,
+ Description: p.Description,
+ InputEncoding: "UTF-8",
+ URLs: []URL{{
+ Type: "text/html",
+ // OpenSearch uses {searchTerms}; convert from our internal {q}.
+ Template: strings.ReplaceAll(p.SearchURL, "{q}", "{searchTerms}"),
+ }},
+ SearchForm: p.HomeURL,
+ }
+ if p.IconURL != "" {
+ osd.Image = &Image{Height: 16, Width: 16, Type: "image/x-icon", Value: p.IconURL}
+ }
+ return osd
+}
+
+// DescriptionForRouter builds an OSD document that points the browser at
+// this service's own /search endpoint, so prefix routing ("ud foo") works
+// from the browser's address bar.
+//
+// publicURL must be the externally-visible base URL (no trailing slash).
+func DescriptionForRouter(publicURL string) OpenSearchDescription {
+ publicURL = strings.TrimRight(publicURL, "/")
+ return OpenSearchDescription{
+ XMLNS: xmlNS,
+ ShortName: "huntsman",
+ Description: "Multi-provider search router (ud, gh, steam)",
+ InputEncoding: "UTF-8",
+ URLs: []URL{{
+ Type: "text/html",
+ Template: fmt.Sprintf("%s/search?q={searchTerms}", publicURL),
+ }},
+ SearchForm: publicURL + "/",
+ }
+}
+
+// Marshal serializes an OSD document with the standard XML prolog.
+func Marshal(osd OpenSearchDescription) ([]byte, error) {
+ body, err := xml.MarshalIndent(osd, "", " ")
+ if err != nil {
+ return nil, err
+ }
+ return append([]byte(xml.Header), body...), nil
+}
diff --git a/internal/domain/search/opensearch_test.go b/internal/domain/search/opensearch_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..3014fc8d361d0b35f6aa7c446b1524cb961d86f0
--- /dev/null
+++ b/internal/domain/search/opensearch_test.go
@@ -0,0 +1,72 @@
+package search
+
+import (
+ "encoding/xml"
+ "strings"
+ "testing"
+)
+
+func TestMarshalEmitsXMLProlog(t *testing.T) {
+ osd := DescriptionForRouter("https://example.com")
+ body, err := Marshal(osd)
+ if err != nil {
+ t.Fatalf("Marshal: %v", err)
+ }
+ if !strings.HasPrefix(string(body), " 0 {
+ prefix := strings.ToLower(trimmed[:i])
+ if p, ok := providers[prefix]; ok {
+ return p, strings.TrimSpace(trimmed[i+1:]), nil
+ }
+ }
+
+ // "ud foo bar" → prefix "ud", rest "foo bar"
+ if i := strings.IndexByte(trimmed, ' '); i > 0 {
+ prefix := strings.ToLower(trimmed[:i])
+ if p, ok := providers[prefix]; ok {
+ return p, strings.TrimSpace(trimmed[i+1:]), nil
+ }
+ }
+
+ // Bare prefix like "ud" with no query — go to that provider's homepage.
+ if p, ok := providers[strings.ToLower(trimmed)]; ok {
+ return p, "", nil
+ }
+
+ return def, trimmed, nil
+}
diff --git a/internal/domain/search/providers_test.go b/internal/domain/search/providers_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..7f224f22e41d684527213349758129d72fb5847d
--- /dev/null
+++ b/internal/domain/search/providers_test.go
@@ -0,0 +1,87 @@
+package search
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestRoute(t *testing.T) {
+ providers := providerByID()
+
+ cases := []struct {
+ name string
+ input string
+ wantID string
+ wantQuery string
+ }{
+ {"empty falls back to default", "", "gh", ""},
+ {"unknown prefix becomes part of query", "foo bar", "gh", "foo bar"},
+ {"space prefix routes to ud", "ud meme", "ud", "meme"},
+ {"colon prefix routes to gh", "gh:repo language:go", "gh", "repo language:go"},
+ {"steam prefix with multi-word query", "steam half life", "steam", "half life"},
+ {"bare prefix returns provider with empty query", "ud", "ud", ""},
+ {"prefix is case-insensitive", "UD bar", "ud", "bar"},
+ {"leading whitespace is stripped", " gh foo", "gh", "foo"},
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ got, q, err := Route(tc.input, providers, "gh")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if got.ID != tc.wantID {
+ t.Errorf("provider ID = %q, want %q", got.ID, tc.wantID)
+ }
+ if q != tc.wantQuery {
+ t.Errorf("query = %q, want %q", q, tc.wantQuery)
+ }
+ })
+ }
+}
+
+func TestRouteUnknownDefault(t *testing.T) {
+ if _, _, err := Route("anything", providerByID(), "nope"); err == nil {
+ t.Fatal("expected error for unknown default provider")
+ }
+}
+
+func TestSearchURLFor(t *testing.T) {
+ p, ok := Lookup("ud")
+ if !ok {
+ t.Fatal("ud provider missing")
+ }
+ url := p.SearchURLFor("hello world")
+ if !strings.Contains(url, "term=hello+world") {
+ t.Errorf("expected URL to contain encoded query, got %q", url)
+ }
+
+ if got := p.SearchURLFor(""); got != p.HomeURL {
+ t.Errorf("empty query should return HomeURL, got %q", got)
+ }
+}
+
+func TestDescriptionForRouter(t *testing.T) {
+ osd := DescriptionForRouter("https://example.com/")
+ if len(osd.URLs) != 1 {
+ t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
+ }
+ want := "https://example.com/search?q={searchTerms}"
+ if osd.URLs[0].Template != want {
+ t.Errorf("template = %q, want %q", osd.URLs[0].Template, want)
+ }
+}
+
+func TestDescriptionForProvider(t *testing.T) {
+ p, _ := Lookup("gh")
+ osd := DescriptionForProvider(p)
+ if len(osd.URLs) != 1 {
+ t.Fatalf("expected 1 URL element, got %d", len(osd.URLs))
+ }
+ if !strings.Contains(osd.URLs[0].Template, "{searchTerms}") {
+ t.Errorf("template should contain {searchTerms}, got %q", osd.URLs[0].Template)
+ }
+ if strings.Contains(osd.URLs[0].Template, "{q}") {
+ t.Errorf("template should not contain internal {q} placeholder")
+ }
+}
diff --git a/internal/domain/search/service.go b/internal/domain/search/service.go
new file mode 100644
index 0000000000000000000000000000000000000000..1fc6cac60e66de74ed8de6b2b4f87d2976d24d6f
--- /dev/null
+++ b/internal/domain/search/service.go
@@ -0,0 +1,59 @@
+package search
+
+// Service holds the registered providers and routing defaults.
+//
+// It's stateless apart from configuration, so a single instance is shared
+// across all HTTP handlers.
+type Service struct {
+ providers map[string]Provider
+ defaultProvider string
+ publicURL string
+}
+
+// NewService constructs a search service with the built-in provider set.
+//
+// defaultProvider must be the ID of one of the built-in providers; the
+// loader-time validator guarantees this, but we re-check at startup so a
+// programmer mistake fails loudly rather than silently picking something.
+func NewService(defaultProvider, publicURL string) (*Service, error) {
+ providers := providerByID()
+ if _, ok := providers[defaultProvider]; !ok {
+ return nil, ErrUnknownProvider{ID: defaultProvider}
+ }
+ return &Service{
+ providers: providers,
+ defaultProvider: defaultProvider,
+ publicURL: publicURL,
+ }, nil
+}
+
+// Providers returns all registered providers in the canonical order.
+func (s *Service) Providers() []Provider {
+ return AllProviders()
+}
+
+// Lookup returns a provider by ID, or false if not registered.
+func (s *Service) Lookup(id string) (Provider, bool) {
+ p, ok := s.providers[id]
+ return p, ok
+}
+
+// Route resolves a raw query into a (provider, cleaned-query) pair.
+func (s *Service) Route(raw string) (Provider, string, error) {
+ return Route(raw, s.providers, s.defaultProvider)
+}
+
+// PublicURL returns the externally-visible URL of this service, used to
+// build self-referential OpenSearch description documents.
+func (s *Service) PublicURL() string {
+ return s.publicURL
+}
+
+// ErrUnknownProvider is returned when a provider ID is referenced but not
+// registered. It's a typed error so callers can distinguish it from
+// validation failures higher up the stack.
+type ErrUnknownProvider struct{ ID string }
+
+func (e ErrUnknownProvider) Error() string {
+ return "unknown provider: " + e.ID
+}
diff --git a/internal/domain/search/service_test.go b/internal/domain/search/service_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..d39555840ceeb6155a8c9e3de474b1fd57ea621e
--- /dev/null
+++ b/internal/domain/search/service_test.go
@@ -0,0 +1,56 @@
+package search
+
+import (
+ "errors"
+ "testing"
+)
+
+func TestNewServiceUnknownDefault(t *testing.T) {
+ _, err := NewService("yahoo", "https://example.com")
+ if err == nil {
+ t.Fatal("expected error for unknown default provider")
+ }
+ var unk ErrUnknownProvider
+ if !errors.As(err, &unk) {
+ t.Fatalf("expected ErrUnknownProvider, got %T: %v", err, err)
+ }
+ if unk.ID != "yahoo" {
+ t.Errorf("ID = %q", unk.ID)
+ }
+ if unk.Error() != "unknown provider: yahoo" {
+ t.Errorf("Error() = %q", unk.Error())
+ }
+}
+
+func TestServiceLookupAndProviders(t *testing.T) {
+ svc, err := NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ if got := svc.PublicURL(); got != "https://example.com" {
+ t.Errorf("PublicURL = %q", got)
+ }
+ if len(svc.Providers()) != 3 {
+ t.Errorf("expected 3 providers")
+ }
+ if _, ok := svc.Lookup("ud"); !ok {
+ t.Errorf("Lookup ud should succeed")
+ }
+ if _, ok := svc.Lookup("yahoo"); ok {
+ t.Errorf("Lookup yahoo should fail")
+ }
+}
+
+func TestServiceRouteDelegates(t *testing.T) {
+ svc, err := NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ p, q, err := svc.Route("ud foo")
+ if err != nil {
+ t.Fatalf("Route: %v", err)
+ }
+ if p.ID != "ud" || q != "foo" {
+ t.Errorf("got (%q, %q), want (ud, foo)", p.ID, q)
+ }
+}
diff --git a/internal/pkg/apierror/error.go b/internal/pkg/apierror/error.go
new file mode 100644
index 0000000000000000000000000000000000000000..f8f3156fc28bc0556e6cd0e1a87c094bd2eef58f
--- /dev/null
+++ b/internal/pkg/apierror/error.go
@@ -0,0 +1,97 @@
+// Package apierror models RFC 7807 Problem Details responses.
+//
+// Errors returned from handlers flow through FromError, which inspects
+// typed sentinel values and culpa details to build a Problem with the
+// right HTTP status. Anything unrecognized becomes a 500 with the
+// original message stripped from the response body.
+package apierror
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+
+ "go.bigb.es/auxilia/culpa"
+)
+
+// Problem implements RFC 7807 Problem Details for HTTP APIs.
+type Problem struct {
+ Type string `json:"type"`
+ Title string `json:"title"`
+ Status int `json:"status"`
+ Detail string `json:"detail,omitempty"`
+ Code string `json:"code,omitempty"`
+}
+
+// Error makes Problem usable as an error itself for chained handling.
+func (p Problem) Error() string { return p.Title }
+
+// Write encodes the Problem as application/problem+json and writes it.
+func (p Problem) Write(w http.ResponseWriter) {
+ w.Header().Set("Content-Type", "application/problem+json")
+ w.WriteHeader(p.Status)
+ _ = json.NewEncoder(w).Encode(p)
+}
+
+// FromError maps any error onto a Problem. Internal errors (anything not
+// matched by a typed predicate below) collapse to a generic 500 so we
+// don't leak implementation details to the client.
+//
+// If the error chain carries culpa CodeDetail / PublicDetail, those
+// override the defaults so call sites can attach machine codes and
+// user-safe messages without defining new typed errors.
+func FromError(err error) Problem {
+ var p Problem
+ if errors.As(err, &p) {
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ var nf NotFoundError
+ if errors.As(err, &nf) {
+ p = Problem{Type: "about:blank", Title: "Not Found", Status: 404, Detail: nf.Error(), Code: "NOT_FOUND"}
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ var br BadRequestError
+ if errors.As(err, &br) {
+ p = Problem{Type: "about:blank", Title: "Bad Request", Status: 400, Detail: br.Error(), Code: "BAD_REQUEST"}
+ applyCulpaOverrides(err, &p)
+ return p
+ }
+
+ return Problem{Type: "about:blank", Title: "Internal Server Error", Status: 500}
+}
+
+// applyCulpaOverrides mutates p with any CodeDetail/PublicDetail attached
+// to the error chain. Public messages are intended for end users, so when
+// present they replace whatever the typed error produced.
+func applyCulpaOverrides(err error, p *Problem) {
+ var code culpa.CodeDetail
+ if culpa.FindDetail(err, &code) {
+ if s, ok := code.Code.(string); ok && s != "" {
+ p.Code = s
+ }
+ }
+ var pub culpa.PublicDetail
+ if culpa.FindDetail(err, &pub) && pub.Message != "" {
+ p.Detail = pub.Message
+ }
+}
+
+// NotFoundError signals a missing resource and maps to HTTP 404.
+type NotFoundError struct{ Resource string }
+
+func (e NotFoundError) Error() string { return e.Resource + " not found" }
+
+// NotFound is a convenience constructor for NotFoundError.
+func NotFound(resource string) error { return NotFoundError{Resource: resource} }
+
+// BadRequestError signals invalid input and maps to HTTP 400.
+type BadRequestError struct{ Detail string }
+
+func (e BadRequestError) Error() string { return e.Detail }
+
+// BadRequest is a convenience constructor for BadRequestError.
+func BadRequest(detail string) error { return BadRequestError{Detail: detail} }
diff --git a/internal/pkg/apierror/error_test.go b/internal/pkg/apierror/error_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..bc74cd4d257a5a16e82ce1c3808648bd50489756
--- /dev/null
+++ b/internal/pkg/apierror/error_test.go
@@ -0,0 +1,105 @@
+package apierror
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestFromErrorPassesThroughProblem(t *testing.T) {
+ original := Problem{Title: "Teapot", Status: 418, Code: "TEAPOT"}
+ got := FromError(original)
+ if got != original {
+ t.Errorf("FromError(Problem) lost data: got %+v want %+v", got, original)
+ }
+}
+
+func TestFromErrorWrappedProblem(t *testing.T) {
+ original := Problem{Title: "Teapot", Status: 418}
+ wrapped := fmt.Errorf("outer: %w", original)
+ got := FromError(wrapped)
+ if got.Status != 418 {
+ t.Errorf("status = %d, want 418", got.Status)
+ }
+}
+
+func TestFromErrorNotFound(t *testing.T) {
+ p := FromError(NotFound("widget"))
+ if p.Status != 404 {
+ t.Errorf("status = %d, want 404", p.Status)
+ }
+ if p.Code != "NOT_FOUND" {
+ t.Errorf("code = %q", p.Code)
+ }
+ if p.Detail != "widget not found" {
+ t.Errorf("detail = %q", p.Detail)
+ }
+}
+
+func TestFromErrorBadRequest(t *testing.T) {
+ p := FromError(BadRequest("missing q"))
+ if p.Status != 400 {
+ t.Errorf("status = %d, want 400", p.Status)
+ }
+ if p.Code != "BAD_REQUEST" {
+ t.Errorf("code = %q", p.Code)
+ }
+ if p.Detail != "missing q" {
+ t.Errorf("detail = %q", p.Detail)
+ }
+}
+
+func TestFromErrorUnknownCollapsesTo500(t *testing.T) {
+ p := FromError(errors.New("secret database password leaked"))
+ if p.Status != 500 {
+ t.Errorf("status = %d, want 500", p.Status)
+ }
+ if p.Detail != "" {
+ t.Errorf("detail should be empty to avoid leaks, got %q", p.Detail)
+ }
+ if p.Title != "Internal Server Error" {
+ t.Errorf("title = %q", p.Title)
+ }
+}
+
+func TestProblemError(t *testing.T) {
+ p := Problem{Title: "Bad"}
+ if p.Error() != "Bad" {
+ t.Errorf("Error() = %q", p.Error())
+ }
+}
+
+func TestProblemWrite(t *testing.T) {
+ rr := httptest.NewRecorder()
+ p := Problem{Title: "Not Found", Status: 404, Detail: "thing missing", Code: "NOT_FOUND"}
+ p.Write(rr)
+
+ if rr.Code != 404 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("content-type = %q", got)
+ }
+
+ var decoded Problem
+ if err := json.Unmarshal(rr.Body.Bytes(), &decoded); err != nil {
+ t.Fatalf("decode body: %v", err)
+ }
+ if decoded != p {
+ t.Errorf("decoded = %+v, want %+v", decoded, p)
+ }
+}
+
+func TestNotFoundErrorMessage(t *testing.T) {
+ if got := (NotFoundError{Resource: "thing"}).Error(); got != "thing not found" {
+ t.Errorf("Error() = %q", got)
+ }
+}
+
+func TestBadRequestErrorMessage(t *testing.T) {
+ if got := (BadRequestError{Detail: "nope"}).Error(); got != "nope" {
+ t.Errorf("Error() = %q", got)
+ }
+}
diff --git a/internal/pkg/httputil/response.go b/internal/pkg/httputil/response.go
new file mode 100644
index 0000000000000000000000000000000000000000..2bc72a5745ab41eda15714d387e0e9bd7ea865fd
--- /dev/null
+++ b/internal/pkg/httputil/response.go
@@ -0,0 +1,36 @@
+// Package httputil contains thin JSON-response and error-routing helpers.
+package httputil
+
+import (
+ "encoding/json"
+ "log/slog"
+ "net/http"
+
+ "go.bigb.es/auxilia/scribe"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
+)
+
+// JSON writes a JSON response with the given status code.
+func JSON(w http.ResponseWriter, status int, data any) {
+ w.Header().Set("Content-Type", "application/json")
+ w.WriteHeader(status)
+ if data != nil {
+ _ = json.NewEncoder(w).Encode(data)
+ }
+}
+
+// OK is the common 200 path.
+func OK(w http.ResponseWriter, data any) { JSON(w, http.StatusOK, data) }
+
+// Error converts err into a Problem response. Internal (5xx) errors are
+// logged with the original error message; client errors are not, since
+// they're not actionable for the operator.
+func Error(w http.ResponseWriter, r *http.Request, err error) {
+ problem := apierror.FromError(err)
+ if problem.Status >= 500 {
+ //nolint:gosec // G706: slog handlers escape attribute values, so r.URL.Path cannot inject newlines into log output.
+ slog.Error("internal error", "path", r.URL.Path, scribe.Err(err))
+ }
+ problem.Write(w)
+}
diff --git a/internal/pkg/httputil/response_test.go b/internal/pkg/httputil/response_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..3ce51da6215069e489aecc2431b15663c4cf5d40
--- /dev/null
+++ b/internal/pkg/httputil/response_test.go
@@ -0,0 +1,75 @@
+package httputil
+
+import (
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/pkg/apierror"
+)
+
+func TestJSONStatusAndBody(t *testing.T) {
+ rr := httptest.NewRecorder()
+ JSON(rr, 201, map[string]int{"n": 7})
+
+ if rr.Code != 201 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/json" {
+ t.Errorf("content-type = %q", got)
+ }
+ var body map[string]int
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["n"] != 7 {
+ t.Errorf("body = %+v", body)
+ }
+}
+
+func TestJSONNilDataWritesNoBody(t *testing.T) {
+ rr := httptest.NewRecorder()
+ JSON(rr, 204, nil)
+ if rr.Code != 204 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if rr.Body.Len() != 0 {
+ t.Errorf("body should be empty, got %q", rr.Body.String())
+ }
+}
+
+func TestOK(t *testing.T) {
+ rr := httptest.NewRecorder()
+ OK(rr, map[string]string{"hello": "world"})
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+}
+
+func TestErrorMapsClientError(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
+ Error(rr, req, apierror.NotFound("widget"))
+ if rr.Code != 404 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); got != "application/problem+json" {
+ t.Errorf("content-type = %q", got)
+ }
+}
+
+func TestErrorMapsInternalError(t *testing.T) {
+ rr := httptest.NewRecorder()
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/foo", http.NoBody)
+ Error(rr, req, errors.New("boom"))
+ if rr.Code != 500 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ // Internal detail should not leak in the body.
+ if got := rr.Body.String(); strings.Contains(got, "boom") {
+ t.Errorf("response body leaks internal error: %q", got)
+ }
+}
diff --git a/internal/platform/health/health.go b/internal/platform/health/health.go
new file mode 100644
index 0000000000000000000000000000000000000000..3606d4763cc3abf75089a8ed906ebf84aab67691
--- /dev/null
+++ b/internal/platform/health/health.go
@@ -0,0 +1,78 @@
+// Package health implements simple liveness and readiness probes.
+//
+// huntsman has no real backing services, so readiness == liveness
+// today, but the Health struct keeps the door open for plugging in real
+// checkers later (e.g. an upstream API ping) without touching the router.
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "sync"
+ "time"
+)
+
+// Checker reports the current health of one subsystem.
+type Checker interface {
+ Check(ctx context.Context) error
+}
+
+// CheckerFunc adapts an ordinary function to the Checker interface.
+type CheckerFunc func(ctx context.Context) error
+
+// Check satisfies the Checker interface.
+func (f CheckerFunc) Check(ctx context.Context) error { return f(ctx) }
+
+// Health aggregates named checkers and exposes them as HTTP handlers.
+type Health struct {
+ mu sync.RWMutex
+ checkers map[string]Checker
+}
+
+// New constructs an empty Health registry.
+func New() *Health {
+ return &Health{checkers: make(map[string]Checker)}
+}
+
+// Register adds (or replaces) a named checker.
+func (h *Health) Register(name string, c Checker) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.checkers[name] = c
+}
+
+// Healthz is a liveness probe — does the process exist and respond?
+func (h *Health) Healthz(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok"))
+}
+
+// Readyz runs all registered checkers with a 5s timeout. Any failure
+// flips the response to 503 and lists per-checker status in the body.
+func (h *Health) Readyz(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
+ defer cancel()
+
+ h.mu.RLock()
+ defer h.mu.RUnlock()
+
+ results := make(map[string]string, len(h.checkers))
+ allOK := true
+ for name, checker := range h.checkers {
+ if err := checker.Check(ctx); err != nil {
+ results[name] = err.Error()
+ allOK = false
+ } else {
+ results[name] = "ok"
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if allOK {
+ w.WriteHeader(http.StatusOK)
+ } else {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ }
+ _ = json.NewEncoder(w).Encode(results)
+}
diff --git a/internal/platform/health/health_test.go b/internal/platform/health/health_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..e6317cc48e0e8d97a8efc78a41f111b0bd095df0
--- /dev/null
+++ b/internal/platform/health/health_test.go
@@ -0,0 +1,96 @@
+package health
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestHealthzAlwaysOK(t *testing.T) {
+ h := New()
+ rr := httptest.NewRecorder()
+ h.Healthz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/healthz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ if rr.Body.String() != "ok" {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+}
+
+func TestReadyzNoCheckersIsOK(t *testing.T) {
+ h := New()
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if len(body) != 0 {
+ t.Errorf("expected empty results, got %v", body)
+ }
+}
+
+func TestReadyzAllPass(t *testing.T) {
+ h := New()
+ h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
+ h.Register("cache", CheckerFunc(func(_ context.Context) error { return nil }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 200 {
+ t.Errorf("status = %d", rr.Code)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["db"] != "ok" || body["cache"] != "ok" {
+ t.Errorf("body = %v", body)
+ }
+}
+
+func TestReadyzOneFails(t *testing.T) {
+ h := New()
+ h.Register("db", CheckerFunc(func(_ context.Context) error { return nil }))
+ h.Register("upstream", CheckerFunc(func(_ context.Context) error { return errors.New("dns fail") }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+
+ if rr.Code != 503 {
+ t.Errorf("status = %d, want 503", rr.Code)
+ }
+ var body map[string]string
+ if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if body["db"] != "ok" {
+ t.Errorf("db = %q", body["db"])
+ }
+ if body["upstream"] != "dns fail" {
+ t.Errorf("upstream = %q", body["upstream"])
+ }
+}
+
+func TestRegisterReplaces(t *testing.T) {
+ h := New()
+ h.Register("svc", CheckerFunc(func(_ context.Context) error { return errors.New("first") }))
+ h.Register("svc", CheckerFunc(func(_ context.Context) error { return nil }))
+
+ rr := httptest.NewRecorder()
+ h.Readyz(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/readyz", http.NoBody))
+ if rr.Code != 200 {
+ t.Errorf("status = %d, want 200 after replace", rr.Code)
+ }
+}
diff --git a/internal/platform/observability/logging.go b/internal/platform/observability/logging.go
new file mode 100644
index 0000000000000000000000000000000000000000..d67951b2df625e660dfb2ef1fc79b6863dbe66a8
--- /dev/null
+++ b/internal/platform/observability/logging.go
@@ -0,0 +1,44 @@
+// Package observability wires up logging.
+package observability
+
+import (
+ "log/slog"
+ "os"
+ "strings"
+
+ "go.bigb.es/auxilia/scribe"
+)
+
+// NewLogger builds a slog.Logger backed by an auxilia/scribe handler.
+//
+// "human" produces a colorized TintHandler suitable for terminals; "json"
+// produces a structured handler suitable for log aggregators. Unknown
+// formats fall back to "human" so a typo doesn't break logging entirely.
+func NewLogger(level, format string) *slog.Logger {
+ lvl := parseLevel(level)
+ opts := []scribe.Option{
+ scribe.WithWriter(os.Stdout),
+ scribe.WithLevel(lvl),
+ }
+
+ var handler slog.Handler
+ if strings.EqualFold(format, "json") {
+ handler = scribe.NewJSONHandler(opts...)
+ } else {
+ handler = scribe.NewTintHandler(opts...)
+ }
+ return slog.New(handler)
+}
+
+func parseLevel(level string) slog.Level {
+ switch strings.ToLower(level) {
+ case "debug":
+ return slog.LevelDebug
+ case "warn":
+ return slog.LevelWarn
+ case "error":
+ return slog.LevelError
+ default:
+ return slog.LevelInfo
+ }
+}
diff --git a/internal/platform/observability/logging_test.go b/internal/platform/observability/logging_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..934b682af515043dae5347c9fd6af5300bfdeb71
--- /dev/null
+++ b/internal/platform/observability/logging_test.go
@@ -0,0 +1,57 @@
+package observability
+
+import (
+ "log/slog"
+ "testing"
+)
+
+func TestNewLoggerLevels(t *testing.T) {
+ cases := []struct {
+ level string
+ want slog.Level
+ }{
+ {"debug", slog.LevelDebug},
+ {"DEBUG", slog.LevelDebug},
+ {"info", slog.LevelInfo},
+ {"warn", slog.LevelWarn},
+ {"error", slog.LevelError},
+ {"unknown", slog.LevelInfo}, // fallback
+ {"", slog.LevelInfo}, // fallback
+ }
+
+ for _, tc := range cases {
+ t.Run(tc.level, func(t *testing.T) {
+ lg := NewLogger(tc.level, "human")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+ // Probe whether the chosen level is enabled.
+ if !lg.Enabled(t.Context(), tc.want) {
+ t.Errorf("level %v should be enabled", tc.want)
+ }
+ // And one level below should be filtered out.
+ if tc.want > slog.LevelDebug {
+ if lg.Enabled(t.Context(), tc.want-4) {
+ t.Errorf("level below %v should be filtered", tc.want)
+ }
+ }
+ })
+ }
+}
+
+func TestNewLoggerFormatFallback(t *testing.T) {
+ // Any unrecognized format should not panic and should still return a logger.
+ lg := NewLogger("info", "yaml")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+}
+
+func TestNewLoggerJSONFormat(t *testing.T) {
+ // Just exercise the JSON path; we can't easily inspect the handler type
+ // without adapter, but we ensure it constructs without panic.
+ lg := NewLogger("info", "json")
+ if lg == nil {
+ t.Fatal("logger is nil")
+ }
+}
diff --git a/internal/server/middleware/logging.go b/internal/server/middleware/logging.go
new file mode 100644
index 0000000000000000000000000000000000000000..c629790982fed33d4398fe8d6d935b806ff3dcaf
--- /dev/null
+++ b/internal/server/middleware/logging.go
@@ -0,0 +1,46 @@
+package middleware
+
+import (
+ "log/slog"
+ "net/http"
+ "time"
+)
+
+type responseWriter struct {
+ http.ResponseWriter
+ status int
+ size int
+}
+
+func (rw *responseWriter) WriteHeader(status int) {
+ rw.status = status
+ rw.ResponseWriter.WriteHeader(status)
+}
+
+func (rw *responseWriter) Write(b []byte) (int, error) {
+ n, err := rw.ResponseWriter.Write(b)
+ rw.size += n
+ return n, err
+}
+
+// Logging emits a single structured log line per request once the handler
+// returns. The status code defaults to 200 if the handler never explicitly
+// calls WriteHeader.
+func Logging(logger *slog.Logger) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ start := time.Now()
+ rw := &responseWriter{ResponseWriter: w, status: http.StatusOK}
+ next.ServeHTTP(rw, r)
+
+ logger.Info("http request",
+ "method", r.Method,
+ "path", r.URL.Path,
+ "status", rw.status,
+ "size", rw.size,
+ "duration", time.Since(start),
+ "request_id", GetRequestID(r.Context()),
+ )
+ })
+ }
+}
diff --git a/internal/server/middleware/logging_test.go b/internal/server/middleware/logging_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..9d06dc9137ce5a8bc736f0ee70390cf2651c1183
--- /dev/null
+++ b/internal/server/middleware/logging_test.go
@@ -0,0 +1,62 @@
+package middleware
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestLoggingEmitsLineWithStatusAndPath(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(201)
+ _, _ = w.Write([]byte("hello"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "POST", "/widgets", http.NoBody))
+
+ logged := buf.String()
+ for _, want := range []string{"http request", "method=POST", "path=/widgets", "status=201", "size=5"} {
+ if !strings.Contains(logged, want) {
+ t.Errorf("log line missing %q\nfull line: %s", want, logged)
+ }
+ }
+}
+
+func TestLoggingDefaultsStatusTo200(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ // No explicit WriteHeader: should default to 200 in our log line.
+ _, _ = w.Write([]byte("ok"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if !strings.Contains(buf.String(), "status=200") {
+ t.Errorf("expected status=200 in log, got %q", buf.String())
+ }
+}
+
+func TestLoggingIncludesRequestID(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := RequestID(Logging(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ })))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ req.Header.Set("X-Request-ID", "abc-123")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if !strings.Contains(buf.String(), "request_id=abc-123") {
+ t.Errorf("expected request_id in log, got %q", buf.String())
+ }
+}
diff --git a/internal/server/middleware/recovery.go b/internal/server/middleware/recovery.go
new file mode 100644
index 0000000000000000000000000000000000000000..cbefabdce6df3fd6685d3278ca93e6abe90f168d
--- /dev/null
+++ b/internal/server/middleware/recovery.go
@@ -0,0 +1,35 @@
+package middleware
+
+import (
+ "fmt"
+ "log/slog"
+ "net/http"
+
+ "go.bigb.es/auxilia/culpa"
+ "go.bigb.es/auxilia/scribe"
+)
+
+// Recovery catches panics from downstream handlers, logs them with a stack
+// trace via culpa, and returns a generic 500 so a single bad handler can't
+// crash the process.
+func Recovery(logger *slog.Logger) func(http.Handler) http.Handler {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ if rec := recover(); rec != nil {
+ err, ok := rec.(error)
+ if !ok {
+ err = fmt.Errorf("%v", rec)
+ }
+ err = culpa.Wrap(err, "panic recovered")
+ logger.Error("panic recovered",
+ "path", r.URL.Path,
+ scribe.Err(err),
+ )
+ http.Error(w, "internal server error", http.StatusInternalServerError)
+ }
+ }()
+ next.ServeHTTP(w, r)
+ })
+ }
+}
diff --git a/internal/server/middleware/recovery_test.go b/internal/server/middleware/recovery_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..b140cef67537369a89f2cc5005188cd679d610ec
--- /dev/null
+++ b/internal/server/middleware/recovery_test.go
@@ -0,0 +1,57 @@
+package middleware
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+)
+
+func TestRecoveryCatchesPanic(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Recovery(logger)(http.HandlerFunc(func(_ http.ResponseWriter, _ *http.Request) {
+ panic("boom")
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/explode", http.NoBody))
+
+ if rr.Code != http.StatusInternalServerError {
+ t.Errorf("status = %d, want 500", rr.Code)
+ }
+ if !strings.Contains(rr.Body.String(), "internal server error") {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+ logged := buf.String()
+ if !strings.Contains(logged, "panic recovered") {
+ t.Errorf("expected log to mention 'panic recovered', got %q", logged)
+ }
+ if !strings.Contains(logged, "/explode") {
+ t.Errorf("expected log to include path, got %q", logged)
+ }
+}
+
+func TestRecoveryPassesThroughWhenNoPanic(t *testing.T) {
+ var buf bytes.Buffer
+ logger := slog.New(slog.NewTextHandler(&buf, nil))
+ h := Recovery(logger)(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusTeapot)
+ _, _ = w.Write([]byte("hi"))
+ }))
+
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if rr.Code != http.StatusTeapot {
+ t.Errorf("status = %d, want 418", rr.Code)
+ }
+ if rr.Body.String() != "hi" {
+ t.Errorf("body = %q", rr.Body.String())
+ }
+ if buf.Len() != 0 {
+ t.Errorf("nothing should have been logged, got %q", buf.String())
+ }
+}
diff --git a/internal/server/middleware/requestid.go b/internal/server/middleware/requestid.go
new file mode 100644
index 0000000000000000000000000000000000000000..ed392d56534db51e9f9c7bbaa588765e59ba1524
--- /dev/null
+++ b/internal/server/middleware/requestid.go
@@ -0,0 +1,36 @@
+// Package middleware contains chi-compatible HTTP middleware for the server.
+package middleware
+
+import (
+ "context"
+ "net/http"
+
+ "github.com/google/uuid"
+)
+
+type ctxKey string
+
+// RequestIDKey is the context key under which the request ID is stored.
+const RequestIDKey ctxKey = "request_id"
+
+// RequestID propagates an X-Request-ID header through the request, generating
+// a UUID when the client didn't send one. The header is always echoed back.
+func RequestID(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ id := r.Header.Get("X-Request-ID")
+ if id == "" {
+ id = uuid.NewString()
+ }
+ ctx := context.WithValue(r.Context(), RequestIDKey, id)
+ w.Header().Set("X-Request-ID", id)
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+}
+
+// GetRequestID returns the request ID stored in ctx, or "" if absent.
+func GetRequestID(ctx context.Context) string {
+ if id, ok := ctx.Value(RequestIDKey).(string); ok {
+ return id
+ }
+ return ""
+}
diff --git a/internal/server/middleware/requestid_test.go b/internal/server/middleware/requestid_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..60f0a9ef770ddd219b8d64f1ce1518f8e0622a23
--- /dev/null
+++ b/internal/server/middleware/requestid_test.go
@@ -0,0 +1,64 @@
+package middleware
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestRequestIDGeneratesWhenAbsent(t *testing.T) {
+ var seenInHandler string
+ h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ seenInHandler = GetRequestID(r.Context())
+ }))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ got := rr.Header().Get("X-Request-ID")
+ if got == "" {
+ t.Fatal("X-Request-ID header should be set")
+ }
+ if got != seenInHandler {
+ t.Errorf("response header %q != context value %q", got, seenInHandler)
+ }
+ // Generated UUID should be 36 chars (8-4-4-4-12).
+ if len(got) != 36 {
+ t.Errorf("generated id length = %d, want 36", len(got))
+ }
+}
+
+func TestRequestIDPreservesIncoming(t *testing.T) {
+ const incoming = "trace-abc-123"
+ var seen string
+ h := RequestID(http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
+ seen = GetRequestID(r.Context())
+ }))
+
+ req := httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody)
+ req.Header.Set("X-Request-ID", incoming)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+
+ if seen != incoming {
+ t.Errorf("context id = %q, want %q", seen, incoming)
+ }
+ if got := rr.Header().Get("X-Request-ID"); got != incoming {
+ t.Errorf("response id = %q, want %q", got, incoming)
+ }
+}
+
+func TestGetRequestIDEmptyContext(t *testing.T) {
+ if got := GetRequestID(context.Background()); got != "" {
+ t.Errorf("expected empty string, got %q", got)
+ }
+}
+
+func TestGetRequestIDWrongType(t *testing.T) {
+ ctx := context.WithValue(context.Background(), RequestIDKey, 42) // not a string
+ if got := GetRequestID(ctx); got != "" {
+ t.Errorf("expected empty string, got %q", got)
+ }
+}
diff --git a/internal/server/router.go b/internal/server/router.go
new file mode 100644
index 0000000000000000000000000000000000000000..7637b12e28863ccfb536c2782b1d31c49538b80c
--- /dev/null
+++ b/internal/server/router.go
@@ -0,0 +1,65 @@
+package server
+
+import (
+ "log/slog"
+ "net/http"
+
+ "github.com/go-chi/chi/v5"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/health"
+ "sourcecraft.dev/bigbes/huntsman/internal/server/middleware"
+)
+
+// Routes wires every HTTP route the service exposes.
+//
+// Keeping route registration in one file makes it easy to audit the
+// public surface area without grepping every handler package.
+func Routes(logger *slog.Logger, h *health.Health, searchHandler *search.Handler) http.Handler {
+ r := chi.NewRouter()
+
+ r.Use(middleware.RequestID)
+ r.Use(middleware.Recovery(logger))
+ r.Use(middleware.Logging(logger))
+
+ r.Get("/healthz", h.Healthz)
+ r.Get("/readyz", h.Readyz)
+
+ r.Get("/", indexHandler)
+
+ searchHandler.RegisterRoutes(r)
+
+ return r
+}
+
+// indexHandler advertises the unified OpenSearch document via a
+// tag, so browsers offer to add the engine when
+// the user visits the root URL.
+func indexHandler(w http.ResponseWriter, _ *http.Request) {
+ const body = `
+
+
+
+ huntsman
+
+
+
+ huntsman
+ Multi-provider search router. Use prefixes:
+
+ ud <query> — Urban Dictionary
+ gh <query> — GitHub
+ steam <query> — Steam
+
+ Endpoints: /search?q=..., /providers,
+ /opensearch.xml,
+ /opensearch/{provider}.xml.
+
+
+`
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ _, _ = w.Write([]byte(body))
+}
diff --git a/internal/server/router_test.go b/internal/server/router_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..6b56c51dc297929ae2f26094006bde1f8321dd43
--- /dev/null
+++ b/internal/server/router_test.go
@@ -0,0 +1,87 @@
+package server
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "sourcecraft.dev/bigbes/huntsman/internal/domain/search"
+ "sourcecraft.dev/bigbes/huntsman/internal/platform/health"
+)
+
+func newTestRouter(t *testing.T) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ svc, err := search.NewService("gh", "https://example.com")
+ if err != nil {
+ t.Fatalf("NewService: %v", err)
+ }
+ return Routes(logger, health.New(), search.NewHandler(svc))
+}
+
+func TestIndexHandler(t *testing.T) {
+ h := newTestRouter(t)
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, httptest.NewRequestWithContext(t.Context(), "GET", "/", http.NoBody))
+
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d", rr.Code)
+ }
+ if got := rr.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
+ t.Errorf("content-type = %q", got)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, `