~bigbes/sr-ht-dolt

bd2d8a3680e62716440ea0165d2271d79f9d526e — Eugene Blikh 3 days ago ecfc6bb
doltsrht: serve /query and the api-meta.json beside it

The schema is mounted where /mcp is and for the same three reasons — before
web's same-origin group, before the cookie middleware, in a group of its own
— plus one of its own: it answers anonymous callers, so it cannot ride
core-go's WithSchema, whose router 401s an un-cookied request. It does need
the config and database middleware, which is why the group sits below them.

The file beside it is not optional. meta.sr.ht fetches api-meta.json from
every service it discovers and iterates each one's scopes to build the
personal-token page, so a service that mounts its own /query owes the
instance this too — and the scope list is what makes "dolt.sr.ht/repos:RO"
offerable there at all. The never-null rule that makes a mistake here a 500
for the WHOLE instance lives in ecore's apimeta; a test asserts the name this
service advertises is the one authn.RepoScope enforces.

README says what the surface serves and what it deliberately does not, and
that `hut graphql dolt` needs the patched hut — upstream segfaults on any
service outside its hard-coded list.
M README.md => README.md +44 -0
@@ 319,3 319,47 @@ token** is accepted with the same `dolt.sr.ht/repos:RO` scoping the clone path
applies. No credential at all is a normal caller: it reads what an anonymous
visitor reads, and a PRIVATE database it may not see is "not found" rather than
"forbidden", exactly as in the web UI.

## The GraphQL surface

`/query` on the same web listener (`https://dolt.srht.bigb.es/query`) serves a
read-only GraphQL schema, so everything on the instance that already speaks
SourceHut GraphQL can read this service too. It is `POST` only — a query in a
URL is a cross-origin-readable address for data that is often private — and
introspection is on, so a typed client can be generated against it.

```sh
curl -sS https://dolt.srht.bigb.es/query \
  -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"query":"{ databases { results { name visibility owner { canonicalName } } } }"}'
```

The schema answers what a database *is* — `databases`, `databasesByOwner` and
`database(owner:name:)`, each with its branches, commit log and table list, and
its ACL to the owner alone. It does **not** serve table rows or diffs: those
live on `/mcp`, where a read that had to be clipped says so in its own answer.
There are no mutations; creating, renaming and deleting a database stay behind
the web UI.

The credential plane is `/mcp`'s exactly — a meta personal access token scoped
`dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token carrying `dolt:read` —
and no credential at all is a normal caller that reads what an anonymous visitor
reads. A database the caller may not see resolves to `null` rather than to an
authorization error, so its existence cannot be read out of the shape of the
refusal.

Listings page with the instance-standard opaque cursor:
`databases(filter: {count: 10})` returns a `cursor`, and passing it back as
`databases(cursor: "…")` continues the walk.

Beside it, `/query/api-meta.json` publishes the one grant this service defines
(`repos`), which is what lets meta.sr.ht offer `dolt.sr.ht/repos:RO` on its
personal-token page. To federate the schema into `api.sr.ht`, give the gateway's
config a `[dolt.sr.ht] api-origin=` line pointing here and SIGHUP it; nothing in
this service depends on the gateway existing.

**`hut graphql dolt` needs a patched hut.** Upstream v0.8.0 carries a hard-coded
list of the ten sr.ht services and dereferences a nil entry for anything else,
so it segfaults on every custom service. The fix is small and lives at
`git.srht.bigb.es/~bigbes/hut`; with it, `hut graphql dolt` resolves this
endpoint from the instance origin like any other service.

A cmd/doltsrht/graphql.go => cmd/doltsrht/graphql.go +62 -0
@@ 0,0 1,62 @@
package main

import (
	"context"

	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/graph"
	"sourcecraft.dev/bigbes/sr-ht-dolt/web"
)

// queryRoute is where the GraphQL schema answers. It is core-go's own path,
// because that is where every SourceHut client — hut, api.sr.ht, meta's
// personal-token page — already looks. The file beside it is served by
// ecore's apimeta, at apimeta.Path.
const queryRoute = "/query"

// repoScopeName is the one grant this service defines, spelled as meta.sr.ht
// expects it: the part after the service name in authn.RepoScope
// ("dolt.sr.ht/repos"). meta prefixes the service name itself. The two
// spellings are the same fact written twice, so a test asserts them equal —
// a drift would let a user mint a token meta calls valid and this service does
// not honour.
const repoScopeName = "repos"

// graphBrowseOpener satisfies graph.BrowseOpener over browse.Open, as
// mcpBrowseOpener does for the MCP surface and web.BrowseAdapter for the pages:
// one *browse.DB answers all three method sets, and each package declares the
// seam it consumes rather than importing another's.
type graphBrowseOpener struct{}

var _ graph.BrowseOpener = graphBrowseOpener{}

func (graphBrowseOpener) Open(ctx context.Context, diskPath string) (graph.BrowseSession, error) {
	dbh, err := browse.Open(ctx, diskPath)
	if err != nil {
		return nil, err
	}
	return dbh, nil
}

// newGraphServer assembles /query over the seams the daemon already has: the
// same request-scoped metadata adapter the web pages and the MCP tools read
// through, and the same bare-store reader.
//
// It shares /mcp's credential plane, validator included, so an instance that
// runs no tokens.sr.ht refuses a working token on both surfaces and accepts
// meta PATs and anonymous callers on both. Its failures are fatal for the same
// reason /mcp's are: a surface that answers every query "could not be read"
// because a seam was never wired is a daemon that starts and does not work.
func newGraphServer(conf ini.File) (*graph.Server, error) {
	validator, err := newBearerValidator(conf)
	if err != nil {
		return nil, err
	}
	return graph.New(graph.Options{
		Repos:     web.DBAdapter{},
		Browse:    graphBrowseOpener{},
		Validator: validator,
	})
}

A cmd/doltsrht/graphql_test.go => cmd/doltsrht/graphql_test.go +39 -0
@@ 0,0 1,39 @@
package main

import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
)

// The scope this service advertises and the scope it enforces are the same fact
// written twice: meta.sr.ht prefixes the service name to what it reads from
// api-meta.json, and authn.RepoScope is what a presented token is checked
// against. A drift would let a user mint a token meta calls valid and the clone
// path does not honour, which is a support ticket rather than an error.
func TestTheAdvertisedScopeIsTheEnforcedOne(t *testing.T) {
	assert.Equal(t, authn.RepoScope, serviceName+"/"+repoScopeName)
}

// The wiring, not the package: ecore's apimeta owns the never-null rule, and
// this asserts dolt.sr.ht actually declares the grant it enforces rather than
// serving an empty list that would leave meta with no checkbox to offer.
func TestAPIMetaAdvertisesTheRepoScope(t *testing.T) {
	rec := httptest.NewRecorder()
	apimeta.Handler(repoScopeName).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil))

	require.Equal(t, http.StatusOK, rec.Code)

	var got apimeta.Meta
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
	assert.Equal(t, []string{"repos"}, got.Scopes)
	assert.NotContains(t, rec.Body.String(), "null")
}

M cmd/doltsrht/main.go => cmd/doltsrht/main.go +27 -0
@@ 41,6 41,7 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	"sourcecraft.dev/bigbes/sr-ht-core/server"

	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chimw"
	"sourcecraft.dev/bigbes/sr-ht-ecore/instconf"



@@ 227,6 228,14 @@ func main() {
		fatal("building the mcp surface", err)
	}

	// The GraphQL surface, built here for the same reason: its seams and its
	// credential plane come out of the config, so a wiring mistake stops the
	// boot rather than answering every query 500 later.
	gql, err := newGraphServer(conf)
	if err != nil {
		fatal("building the graphql surface", err)
	}

	srv.AnonRouter().Group(func(r chi.Router) {
		if err := mountRoutes(r, surfaces{
			conf:   conf,


@@ 235,6 244,7 @@ func main() {
			stores: stores,
			git:    git,
			mcp:    agents,
			gql:    gql,
		}); err != nil {
			fatal("mounting the web routes", err)
		}


@@ 282,6 292,7 @@ type surfaces struct {
	stores web.StoreManager
	git    web.GitDescriber
	mcp    http.Handler
	gql    http.Handler
}

// mountRoutes installs the web listener's surfaces on r: /mcp for agents, and


@@ 329,6 340,22 @@ func mountRoutes(r chi.Router, s surfaces) error {
	// claim /mcp/*, a subtree this surface does not serve.
	r.Group(func(r chi.Router) {
		r.Handle(mcpRoute, s.mcp)

		// /query is here for every reason /mcp is — before web's same-origin
		// CSRF group, before the cookie middleware, in a Group of its own — and
		// for one more: this schema answers anonymous callers, so it cannot be
		// mounted the way core-go's WithSchema mounts a schema (on the
		// authenticated router, whose auth.Middleware 401s an un-cookied
		// request). A public database is public on this endpoint too.
		//
		// It does need the config and database middleware installed above,
		// which is why the group is here rather than higher: every resolver
		// reads the metadata store through the request-scoped adapter.
		r.Handle(queryRoute, s.gql)
		// The file meta.sr.ht reads to learn what this service can be granted.
		// A service that mounts its own /query owes the instance this too —
		// core-go serves it only for the schemas it hosts itself.
		r.Get(apimeta.Path, apimeta.Handler(repoScopeName))
	})

	r.Use(authn.OptionalCookieMiddleware()) // never 401s; anonymous stays anonymous

M cmd/doltsrht/main_test.go => cmd/doltsrht/main_test.go +78 -0
@@ 2,6 2,7 @@ package main

import (
	"context"
	"encoding/json"
	"io"
	"net/http"
	"net/http/httptest"


@@ 18,6 19,7 @@ import (

	"sourcecraft.dev/bigbes/sr-ht-core/auth"

	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"


@@ 302,6 304,9 @@ func boot(t *testing.T, conf ini.File) *httptest.Server {
	agents, err := newMCPServer(conf, cfg)
	require.NoError(t, err)

	gql, err := newGraphServer(conf)
	require.NoError(t, err)

	root := chi.NewRouter()
	root.Group(func(r chi.Router) {
		require.NoError(t, mountRoutes(r, surfaces{


@@ 309,6 314,7 @@ func boot(t *testing.T, conf ini.File) *httptest.Server {
			cfg:    cfg,
			stores: noStores{},
			mcp:    agents,
			gql:    gql,
		}))
	})



@@ 430,3 436,75 @@ func TestTheDaemonBootsWithoutATokensSection(t *testing.T) {
	assert.Contains(t, resp.Header.Get("WWW-Authenticate"), "Bearer",
		"a refused credential is told how to present a better one")
}

// --- the GraphQL surface ------------------------------------------------------

// postQuery sends a GraphQL query to the booted daemon, as a client does:
// POST, JSON, and an Origin only when one is being tested.
func postQuery(t *testing.T, srv *httptest.Server, origin, query string) (*http.Response, string) {
	t.Helper()

	body, err := json.Marshal(map[string]any{"query": query})
	require.NoError(t, err)

	req, err := http.NewRequest(http.MethodPost, srv.URL+queryRoute, strings.NewReader(string(body)))
	require.NoError(t, err)
	req.Header.Set("Content-Type", "application/json")
	if origin != "" {
		req.Header.Set("Origin", origin)
	}

	resp, err := srv.Client().Do(req)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })

	raw, err := io.ReadAll(resp.Body)
	require.NoError(t, err)
	return resp, string(raw)
}

// The schema answers on the web listener, mounted where /mcp is. Introspection
// is the one query that needs neither Postgres nor a store, so it is what the
// boot test asks: a wiring mistake shows up here rather than in production.
func TestTheGraphQLSurfaceAnswersOnTheWebListener(t *testing.T) {
	srv := boot(t, bootConf(t))

	resp, body := postQuery(t, srv, "", `{ __schema { queryType { name } } }`)

	require.Equal(t, http.StatusOK, resp.StatusCode, body)
	assert.Contains(t, body, `"name":"Query"`, body)
}

// /query is above the same-origin group for /mcp's reason: an API client sends
// no Origin, which is exactly the request that guard refuses. A cross-site
// Origin is answered too — this surface's protection is its credential and the
// access matrix, not the group it is not in.
func TestTheCSRFGuardDoesNotReachTheGraphQLSurface(t *testing.T) {
	srv := boot(t, bootConf(t))

	resp, body := postQuery(t, srv, "", `{ __typename }`)
	require.Equal(t, http.StatusOK, resp.StatusCode, "an API client sends no Origin at all")

	resp, body = postQuery(t, srv, "https://evil.example", `{ __typename }`)
	assert.Equal(t, http.StatusOK, resp.StatusCode, body)
}

// The file meta.sr.ht reads, served on the daemon rather than merely built:
// core-go serves it only for the schemas it hosts itself, so a service that
// mounts its own /query has to remember this route.
func TestTheAPIMetaFileIsServed(t *testing.T) {
	srv := boot(t, bootConf(t))

	resp, err := srv.Client().Get(srv.URL + apimeta.Path)
	require.NoError(t, err)
	t.Cleanup(func() { _ = resp.Body.Close() })
	require.Equal(t, http.StatusOK, resp.StatusCode)

	raw, err := io.ReadAll(resp.Body)
	require.NoError(t, err)

	var got apimeta.Meta
	require.NoError(t, json.Unmarshal(raw, &got))
	assert.Equal(t, []string{repoScopeName}, got.Scopes)
	assert.NotEmpty(t, got.WebhookPubkey)
}

M config.example.ini => config.example.ini +16 -0
@@ 17,6 17,14 @@
; the host clients pass to `dolt login --auth-endpoint`.
origin=https://dolt.srht.bigb.es
;
; The URL this service's API is served at. It is what api.sr.ht federates:
; the gateway appends "/query" to it, fetches the schema, and merges dolt's
; types into the instance-wide endpoint. There is no second listener — /query
; rides the web one — so this is normally the origin above, and it is only
; worth setting when the API is reachable at a different name. Omit it and
; nothing breaks except federation, which core-go's GetAPI then cannot resolve.
;api-origin=https://dolt.srht.bigb.es
;
; PostgreSQL connection string for the dolt.sr.ht metadata database (users
; mirror, repositories, ACLs, dolt keys).
connection-string=postgresql://doltsrht@localhost/dolt.sr.ht?sslmode=disable


@@ 48,6 56,14 @@ migrate-on-upgrade=yes
; the config below has been read.
log-level=info
;
; The GraphQL read schema is served at /query on the same web listener, and has
; no key of its own either, for the same reason. What it answers is decided by
; the credential presented — the bearer plane /mcp defines, or none at all for
; an anonymous caller reading public databases — and by the visibility rules the
; web UI applies. Beside it, /query/api-meta.json tells meta.sr.ht that this
; service defines the "repos" grant, which is what makes
; "dolt.sr.ht/repos:RO" offerable on the personal-token page.
;
; There is deliberately no mcp-enabled key. The read-only MCP surface an agent
; calls (docs/DESIGN.mcp.md) is served at /mcp on the web listener above,
; wherever this daemon runs: a surface that is off in production and on in a