diff --git a/README.md b/README.md index 827d764ba45a5287e37b4407b9868bd6f889f728..7d145063a40d78914a2ab45ad3718e016d2ccfb1 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/doltsrht/graphql.go b/cmd/doltsrht/graphql.go new file mode 100644 index 0000000000000000000000000000000000000000..c748482f3e251497677e06e05011a9b4c243d846 --- /dev/null +++ b/cmd/doltsrht/graphql.go @@ -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, + }) +} diff --git a/cmd/doltsrht/graphql_test.go b/cmd/doltsrht/graphql_test.go new file mode 100644 index 0000000000000000000000000000000000000000..d06a69fb0666841fcde8d37a36b0e91bf22679af --- /dev/null +++ b/cmd/doltsrht/graphql_test.go @@ -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") +} diff --git a/cmd/doltsrht/main.go b/cmd/doltsrht/main.go index fd454be08696d3c521d0ebff10cee4f6fad0d114..1c4cfb6384d8ddef642de19e4464ffec16d4bc24 100644 --- a/cmd/doltsrht/main.go +++ b/cmd/doltsrht/main.go @@ -41,6 +41,7 @@ "sourcecraft.dev/bigbes/sr-ht-core/config" "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 @@ if err != nil { 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 @@ cfg: cfg, stores: stores, git: git, mcp: agents, + gql: gql, }); err != nil { fatal("mounting the web routes", err) } @@ -282,6 +292,7 @@ cfg settings 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 @@ // Mount would rewrite the routing path to the empty remainder and would also // 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 diff --git a/cmd/doltsrht/main_test.go b/cmd/doltsrht/main_test.go index 6c4f87cee06b614b33ddcfb6000669db81d35579..d029f66137aeeb39e6918f835774672ed7c80b5c 100644 --- a/cmd/doltsrht/main_test.go +++ b/cmd/doltsrht/main_test.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/json" "io" "net/http" "net/http/httptest" @@ -18,6 +19,7 @@ "github.com/vaughan0/go-ini" "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 @@ 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 @@ conf: conf, cfg: cfg, stores: noStores{}, mcp: agents, + gql: gql, })) }) @@ -430,3 +436,75 @@ require.Equal(t, http.StatusUnauthorized, resp.StatusCode, body) 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) +} diff --git a/config.example.ini b/config.example.ini index 931317efb12bd565f24a3ef0d69a22b4b5dc3248..d6af128ddb6abc5d9078c3840422de8ab83b645e 100644 --- a/config.example.ini +++ b/config.example.ini @@ -17,6 +17,14 @@ ; audience the Bearer (dolt creds) auth flow checks against, so it must match ; 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 @@ -47,6 +55,14 @@ ; This is the persistent setting. For one run, $LOG_LEVEL overrides it and the ; -d flag overrides both — and both apply from the first line of startup, before ; 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,