~bigbes/sr-ht-spec

964716696bd588e1fe9d67c5a8b9ff2cd6a08613 — bigbes 27 days ago 0879325
feat(cmd): mount the read plane, MCP and GraphQL surfaces

The three Phase 2 surfaces were built but never served: each was written
under an instruction not to touch cmd/, so every one reported its mounting
call and none of them wired it. The daemon answered /healthz and 404'd
everything else.

They share one search.Index, because bleve is single-writer and a second
Open on the same directory is wrong rather than merely wasteful.

Route order is load-bearing: /mcp and /query register before the web UI
mounts at /, which would otherwise swallow them as document paths — the
router has no reason to think 'mcp' is not a space name.

The MCP handler takes the configured origin as its Host allowlist, which
is why Traefik must pass the Host header through.
1 files changed, 109 insertions(+), 16 deletions(-)

M cmd/specsrht/main.go
M cmd/specsrht/main.go => cmd/specsrht/main.go +109 -16
@@ 8,8 8,9 @@
//     and not in the hooks because bleve is single-writer and this process holds
//     the index, and because the push path and the API must not be able to
//     disagree about what is valid.
//   - an HTTP listener on -b (default localhost:5091). Phase 1 serves /healthz
//     and nothing else; the web UI is Phase 2 and mounts where mountWeb says.
//   - an HTTP listener on -b (default localhost:5091), serving /healthz, the
//     read UI, /mcp for agents and /query for GraphQL — one listener, so one
//     reverse-proxy route covers the service.
//   - the reconciler, at startup and then periodically, repairing the
//     divergence a killed daemon leaves between git refs, Postgres and the
//     index.


@@ 53,6 54,7 @@ import (
	"net/http"
	"os"
	"os/signal"
	"path/filepath"
	"strings"
	"syscall"
	"time"


@@ 67,8 69,12 @@ import (
	coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/graph"
	"sourcecraft.dev/bigbes/sr-ht-spec/hooks"
	"sourcecraft.dev/bigbes/sr-ht-spec/mcpsrv"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
	"sourcecraft.dev/bigbes/sr-ht-spec/web"
)

const (


@@ 79,6 85,10 @@ const (
	// defaultBind is the address core-go binds when no -b is given.
	defaultBind = "localhost:5091"

	// version is reported in the MCP handshake so a client listing several
	// SourceHut MCP endpoints can tell which build it is talking to.
	version = "dev"

	// pingTimeout bounds the startup connectivity check against Postgres.
	pingTimeout = 10 * time.Second



@@ 179,11 189,17 @@ func run(log *slog.Logger) error {
		return err
	}

	surf, err := newSurfaces(conf, cfg, svc, version)
	if err != nil {
		return err
	}
	defer surf.Close()

	// server.New parses -b/-d/-m/-p and runs crypto.InitCrypto(conf), whose
	// two required keys validateConfig already checked, so it cannot fatal
	// here for a reason we have not already reported.
	srv := coreserver.New(serviceName, defaultBind, conf, os.Args)
	mountRoutes(srv.AnonRouter(), conf)
	mountRoutes(srv.AnonRouter(), conf, surf)

	ctx, stop := context.WithCancel(context.Background())
	defer stop()


@@ 307,8 323,8 @@ func refreshHooks(ctx context.Context, log *slog.Logger, svc *service.Service, b
	return nil
}

// mountRoutes installs what Phase 1 serves over HTTP.
func mountRoutes(router chi.Router, conf ini.File) {
// mountRoutes installs what the daemon serves over HTTP.
func mountRoutes(router chi.Router, conf ini.File, surfaces *surfaces) {
	// server.New already froze the anonymous router for direct middleware
	// registration, so middleware and routes go in together inside a Group —
	// which chi permits on a fresh inline mux sharing the same routing tree.


@@ 321,20 337,97 @@ func mountRoutes(router chi.Router, conf ini.File) {
		})
	})

	mountWeb(router, conf)
	mountWeb(router, conf, surfaces)
}

// mountWeb is where Phase 2's web UI attaches.
// surfaces are the three Phase 2 read surfaces, assembled once at startup.
//
// It is left empty rather than stubbed with a placeholder route: the read
// plane is a milestone of its own, and a handler that renders nothing would be
// indistinguishable from one that is broken. The chain it will need is the one
// compare.sr.ht and dolt.sr.ht assemble by hand — RealIP, Recoverer, Logger,
// config.Middleware(conf, serviceName), database.Middleware(pool) and
// Service.Resolver().Middleware() — and deliberately not core-go's
// WithDefaultMiddleware, whose auth middleware 401s any un-cookied request and
// would make the read plane, which is anonymous-capable, unreachable.
func mountWeb(_ chi.Router, _ ini.File) {}
// They share one *search.Index deliberately: bleve is single-writer, so a second
// Open on the same directory is not merely wasteful but wrong.
type surfaces struct {
	index *search.Index
	web   *web.Server
	mcp   http.Handler
	gql   http.Handler
}

// newSurfaces opens the index and builds the three read surfaces over it.
//
// All three go through service/ and none of them re-derives addressing, the
// read contract or the project filter — that shared layer is the whole reason
// "read SPEC-0007" cannot mean three different things depending on which door
// you knock on.
func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, version string) (*surfaces, error) {
	indexPath := filepath.Join(cfg.Cache, "index")
	// A crashed rebuild leaves working directories beside the index; clearing
	// them before Open is always safe, since the index is a pure cache.
	if err := search.CleanStale(indexPath); err != nil {
		return nil, fmt.Errorf("clean stale index working dirs: %w", err)
	}
	index, err := search.Open(indexPath)
	if err != nil {
		return nil, fmt.Errorf("open search index at %s: %w", indexPath, err)
	}

	site, err := web.New(web.Options{
		Conf:     conf,
		Reader:   web.NewReader(svc),
		Searcher: index,
		Resolver: svc.Resolver(),
	})
	if err != nil {
		index.Close()
		return nil, fmt.Errorf("assemble the web UI: %w", err)
	}

	// The origin is the /mcp Host allowlist: the MCP SDK's own DNS-rebinding
	// guard cannot tell a reverse proxy from an attacker (both reach a loopback
	// listener with a non-loopback Host), so it is replaced by a check against
	// this value. Traefik must pass the Host header through or every call 403s.
	mcp, err := mcpsrv.Handler(mcpsrv.Backend{Docs: svc, Index: index}, version, cfg.Origin)
	if err != nil {
		index.Close()
		return nil, fmt.Errorf("assemble the MCP surface: %w", err)
	}

	gql, err := graph.New(graph.Options{
		Reader:   svc,
		Searcher: index,
		Resolver: svc.Resolver(),
	})
	if err != nil {
		index.Close()
		return nil, fmt.Errorf("assemble the GraphQL surface: %w", err)
	}

	return &surfaces{index: index, web: site, mcp: mcp, gql: gql.Handler()}, nil
}

func (s *surfaces) Close() error {
	if s == nil || s.index == nil {
		return nil
	}
	return s.index.Close()
}

// mountWeb attaches the three read surfaces.
//
// Order is load-bearing: /mcp and /query are registered before the web UI,
// which mounts at "/" and would otherwise swallow them as document paths —
// "spec" and "query" are legal space names as far as the router is concerned.
//
// Each surface installs its own authentication (they are fail-closed and agree
// on one ACL), so core-go's WithDefaultMiddleware is deliberately not used: its
// auth middleware 401s any un-cookied request, which would also block the agent
// bearer-token path these surfaces exist to serve.
func mountWeb(router chi.Router, _ ini.File, s *surfaces) {
	if s == nil {
		return
	}
	router.Handle("/mcp", s.mcp)
	router.Handle("/query", s.gql)
	router.Mount("/", s.web.Handler())
}

// pushNotifier is what the daemon does when a push lands.
//