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