~bigbes/sr-ht-dolt

b525a62b754afa38cc1aee0995eddae5f8a37214 — Eugene Blikh a day ago 3523280
authn: name the grant scope dolt.sr.ht/DATABASES

api-meta.json published the scope as lowercase "repos" while every other
service on the instance publishes it upper case — git.sr.ht/REPOSITORIES,
todo.sr.ht/TRACKERS, paste.sr.ht/PASTES, builds.sr.ht/JOBS. Upstream derives
those from a GraphQL enum; this service has no @access directive to derive
from, so the spelling was free and drifted.

It matters because meta.sr.ht compares the string verbatim. Its oauth2
blueprint fetches every service's api-meta.json once at import time and
validates a requested grant with `scope in service_scopes[svc]` — no case
folding, no aliasing. So a grant typed by hand as dolt.sr.ht/REPOS:RO was
refused, by analogy with every neighbouring service.

DATABASES rather than REPOS: that is what the surface calls the object
everywhere a user meets it — the GraphQL databases connection, the web pages,
the docs. The storage layer underneath still says "repo"; renaming that is a
deeper change and is not what a token grant names.

No backward compatibility. auth.Grants.Has is a map lookup, so a PAT minted
against the old spelling is refused rather than quietly honoured, and two table
cases assert that instead of leaving it implied. Deploying this is two-sided:
meta.sr.ht has to be restarted before anyone can mint a token carrying the new
name.

The federation gateway is unaffected — it forwards the client's Authorization
header to each service and never reads api-meta.json.

sr-ht-dolt-xic
M README.md => README.md +17 -7
@@ 255,7 255,7 @@ Once the service is up and you are logged into meta:
   ```

   `--user` selects HTTP Basic auth; the password comes from
   `DOLT_REMOTE_PASSWORD`. A PAT with the `dolt.sr.ht/repos:RW` grant (or an
   `DOLT_REMOTE_PASSWORD`. A PAT with the `dolt.sr.ht/DATABASES:RW` grant (or an
   empty-grant personal token) can push; `:RO` or no grant can only read.

3. **Or clone/push with a dolt keypair (the git-SSH-key-like UX):**


@@ 315,7 315,7 @@ flow). Two tokens are accepted. A **tokens.sr.ht working token** must carry the
a read — and it works only on an instance that configures `[tokens.sr.ht]
origin`; without that section there is no daemon to verify it against, so it is
refused with 401 while everything else keeps working. A **meta personal access
token** is accepted with the same `dolt.sr.ht/repos:RO` scoping the clone path
token** is accepted with the same `dolt.sr.ht/DATABASES: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.


@@ 342,7 342,7 @@ 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` —
`dolt.sr.ht/DATABASES: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


@@ 353,10 353,20 @@ Listings page with the instance-standard opaque cursor:
`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.
(`DATABASES`), which is what lets meta.sr.ht offer `dolt.sr.ht/DATABASES:RO` on
its personal-token page. Upper case is not decoration: meta discovers the list
once at startup and then validates a requested grant against it verbatim, so the
name a user types has to match character for character — and every other service
on the instance names its scopes that way. **Deploying a change to it is
two-sided**: this service ships the new name, and meta.sr.ht has to be restarted
before anyone can mint a token carrying it.

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. The gateway does not read this file —
it forwards the client's `Authorization` header to each service and lets the
service decide — so the grant name concerns meta.sr.ht and hand-written grants,
not federation.

**`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,

M authn/bearer.go => authn/bearer.go +4 -4
@@ 29,7 29,7 @@ const bearerScheme = "Bearer"

// ErrMissingGrant is the sentinel wrapped by every "the credential is good, it
// does not cover this" refusal: a working token without core.GrantRead, or a
// meta PAT whose OAuth grants do not reach dolt.sr.ht repositories. It is a 403
// meta PAT whose OAuth grants do not reach this service's databases. It is a 403
// — retrying with the same token is pointless, and the holder needs to be told
// to ask for a wider grant rather than to authenticate again.
//


@@ 231,7 231,7 @@ func resolveWorkingToken(ctx context.Context, v InstanceValidator, presented str
	ac.AuthMethod = AuthMethodInstanceToken
	// BearerToken and Grants are deliberately left unset. They are core-go's
	// OAuth fields, and filling them would subject this caller to
	// TokenGrantsAllow — a gate demanding "dolt.sr.ht/repos:RO", which a
	// TokenGrantsAllow — a gate demanding "dolt.sr.ht/DATABASES:RO", which a
	// tokens.sr.ht grant string can never spell. A working token is scoped by
	// its own vocabulary, on BearerCaller.Grants, and by core.Allowed.



@@ 260,8 260,8 @@ func resolveMetaPAT(ctx context.Context, username, presented string) (*BearerCal
	// The whole surface is a read, so the gate can be applied once here rather
	// than per action. It is the same check the clone path applies.
	if !TokenGrantsAllow(ac, core.AccessRO) {
		return nil, fmt.Errorf("%w: %w: token grants do not permit %s on %s repositories",
			ErrInvalidToken, ErrMissingGrant, core.AccessRO, RepoScope)
		return nil, fmt.Errorf("%w: %w: token grants do not permit %s under %s",
			ErrInvalidToken, ErrMissingGrant, core.AccessRO, DatabaseScope)
	}

	return &BearerCaller{AuthContext: ac, InstanceToken: false}, nil

M authn/bearer_test.go => authn/bearer_test.go +5 -5
@@ 204,7 204,7 @@ func TestResolveBearer_WorkingToken_NoValidatorIsRefused(t *testing.T) {
	})

	t.Run("a meta PAT still resolves", func(t *testing.T) {
		pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
		pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour))
		bc, err := ResolveBearer(testCtx(), nil, pat)
		require.NoError(t, err)
		assert.False(t, bc.InstanceToken)


@@ 240,7 240,7 @@ func TestResolveBearer_MetaPAT_SufficientGrants(t *testing.T) {
		"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
	}})
	v := &fakeValidator{err: errBackendDown} // must not be consulted at all
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
	pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour))

	bc, err := ResolveBearer(testCtx(), v, pat)
	require.NoError(t, err)


@@ 284,7 284,7 @@ func TestResolveBearer_MetaPAT_InsufficientGrants(t *testing.T) {
}

func TestResolveBearer_MetaPAT_Revoked(t *testing.T) {
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
	pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour))
	withStubBackend(t, &stubBackend{
		users:   map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
		revoked: map[[64]byte]bool{sha512.Sum512([]byte(pat)): true},


@@ 300,7 300,7 @@ func TestResolveBearer_MetaPAT_BackendDownIsTransient(t *testing.T) {
		users:     map[string]auth.AuthContext{"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER)},
		revokeErr: errBackendDown,
	})
	pat := forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(time.Hour))
	pat := forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(time.Hour))

	_, err := ResolveBearer(testCtx(), nil, pat)
	require.Error(t, err)


@@ 317,7 317,7 @@ func TestResolveBearer_UnusableCredentials(t *testing.T) {
	}{
		{"empty (the caller lost its own header)", ""},
		{"garbage", "this-is-not-a-token"},
		{"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/repos:RO", time.Now().Add(-time.Minute))},
		{"expired meta PAT", forgePAT("bigbes", "dolt.sr.ht/DATABASES:RO", time.Now().Add(-time.Minute))},
		{"expired working token", forgeWorkingToken("bigbes", time.Now().Add(-time.Minute))},
	}
	for _, tc := range cases {

M authn/token.go => authn/token.go +25 -7
@@ 12,11 12,25 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// RepoScope is the OAuth grant scope a meta.sr.ht personal access token must
// carry to act on dolt.sr.ht repositories: "dolt.sr.ht/repos". Reads require
// ":RO", pushes require ":RW". Personal tokens with no explicit grants are
// universal and pass unconditionally (auth.Grants.HasAll semantics).
const RepoScope = "dolt.sr.ht/repos"
// DatabaseScope is the OAuth grant scope a meta.sr.ht personal access token
// must carry to act on this service's databases: "dolt.sr.ht/DATABASES". Reads
// require ":RO", pushes require ":RW". Personal tokens with no explicit grants
// are universal and pass unconditionally (auth.Grants.HasAll semantics).
//
// The spelling is not free. meta.sr.ht discovers a service's scopes from its
// api-meta.json and then validates a requested grant with a plain `scope in
// scopes` — no case folding, no aliasing — so the string published there and
// the string checked here must match exactly, and a grant a user types by hand
// must match too. Every other service on the instance names its scopes in
// upper case (git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES),
// because upstream derives them from a GraphQL enum; this service has no
// @access directive to derive from, so it follows the convention deliberately.
//
// DATABASES rather than REPOS because that is what the surface calls the
// object everywhere a user meets it — the GraphQL `databases` connection, the
// web pages, the docs. The storage layer underneath still says "repo"; that is
// a separate, deeper rename and is not what a token grant names.
const DatabaseScope = "dolt.sr.ht/DATABASES"

// tokenCacheTTL bounds how long a positively-resolved Basic token is trusted
// without re-checking revocation on meta.sr.ht. A single push issues many RPCs;


@@ 123,7 137,7 @@ func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthCon

// TokenGrantsAllow reports whether the caller's token grants permit access at
// the given mode (core.AccessRO for browse/clone, core.AccessRW for push) on
// dolt.sr.ht repositories. It is the OAuth-grant gate that complements the ACL
// this service's databases. It is the OAuth-grant gate that complements the ACL
// decision in core.Allowed: a token must carry BOTH sufficient grants and a
// sufficient ACL/visibility to act.
//


@@ 131,6 145,10 @@ func ResolveBasic(ctx context.Context, username, password string) (*auth.AuthCon
// and are not scoped by them, so they pass this gate unconditionally; their
// access is decided solely by core.Allowed. Personal tokens with empty grants
// are universal and also pass.
//
// The match is exact, DatabaseScope and nothing else: auth.Grants.Has is a map
// lookup, so a token minted against an older spelling of this scope is refused
// here rather than quietly honoured. Re-minting it is the fix.
func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
	if ac == nil || ac.BearerToken == nil {
		return true


@@ 139,5 157,5 @@ func TokenGrantsAllow(ac *auth.AuthContext, mode core.AccessMode) bool {
	if mode == core.AccessRW {
		kind = auth.RW
	}
	return ac.Grants.Has(RepoScope, kind)
	return ac.Grants.Has(DatabaseScope, kind)
}

M authn/token_test.go => authn/token_test.go +12 -5
@@ 219,11 219,18 @@ func TestTokenGrantsAllow(t *testing.T) {
		{"cookie (no bearer) passes", &auth.AuthContext{AuthMethod: auth.AUTH_COOKIE}, core.AccessRW, true},
		{"empty grants RO", patAC(""), core.AccessRO, true},
		{"empty grants RW", patAC(""), core.AccessRW, true},
		{"repos:RO allows read", patAC("dolt.sr.ht/repos:RO"), core.AccessRO, true},
		{"repos:RO denies write", patAC("dolt.sr.ht/repos:RO"), core.AccessRW, false},
		{"repos:RW allows read", patAC("dolt.sr.ht/repos:RW"), core.AccessRO, true},
		{"repos:RW allows write", patAC("dolt.sr.ht/repos:RW"), core.AccessRW, true},
		{"unrelated scope denies read", patAC("git.sr.ht/repos:RW"), core.AccessRO, false},
		{"DATABASES:RO allows read", patAC("dolt.sr.ht/DATABASES:RO"), core.AccessRO, true},
		{"DATABASES:RO denies write", patAC("dolt.sr.ht/DATABASES:RO"), core.AccessRW, false},
		{"DATABASES:RW allows read", patAC("dolt.sr.ht/DATABASES:RW"), core.AccessRO, true},
		{"DATABASES:RW allows write", patAC("dolt.sr.ht/DATABASES:RW"), core.AccessRW, true},
		{"unrelated scope denies read", patAC("git.sr.ht/REPOSITORIES:RW"), core.AccessRO, false},
		// The scope was published lower case as "repos" before the instance
		// convention was followed. auth.Grants.Has is a map lookup, so a token
		// minted then is refused rather than silently honoured — the two cases
		// below are what "no backward compatibility" means, asserted rather
		// than assumed.
		{"legacy lower-case scope denies read", patAC("dolt.sr.ht/repos:RW"), core.AccessRO, false},
		{"legacy lower-case scope denies write", patAC("dolt.sr.ht/repos:RW"), core.AccessRW, false},
	}
	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {

M cmd/doltsrht/graphql.go => cmd/doltsrht/graphql.go +12 -7
@@ 16,13 16,18 @@ import (
// 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"
// databaseScopeName is the one grant this service defines, spelled as
// meta.sr.ht expects it: the part after the service name in
// authn.DatabaseScope ("dolt.sr.ht/DATABASES"). 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.
//
// meta reads this list once, at import time (metasrht/blueprints/oauth2.py),
// and validates a requested grant against it verbatim. So changing it is a
// two-sided deployment: this service ships the new name, and meta.sr.ht has to
// be restarted before anyone can mint a token carrying it.
const databaseScopeName = "DATABASES"

// graphBrowseOpener satisfies graph.BrowseOpener over browse.Open, as
// mcpBrowseOpener does for the MCP surface and web.BrowseAdapter for the pages:

M cmd/doltsrht/graphql_test.go => cmd/doltsrht/graphql_test.go +16 -5
@@ 4,6 4,7 @@ import (
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"

	"github.com/stretchr/testify/assert"


@@ 16,24 17,34 @@ import (

// 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
// api-meta.json, and authn.DatabaseScope 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)
	assert.Equal(t, authn.DatabaseScope, serviceName+"/"+databaseScopeName)
}

// The name is upper case because meta.sr.ht compares it verbatim — no case
// folding — and every other service on the instance names its scopes that way
// (git.sr.ht/REPOSITORIES, todo.sr.ht/TRACKERS, paste.sr.ht/PASTES). A lower
// case scope is accepted by meta and works, which is exactly why the drift
// survived: it breaks only the user who spells the grant by hand, by analogy
// with every neighbouring service.
func TestTheScopeIsSpelledLikeEveryOtherServices(t *testing.T) {
	assert.Equal(t, strings.ToUpper(databaseScopeName), databaseScopeName)
}

// 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) {
func TestAPIMetaAdvertisesTheDatabaseScope(t *testing.T) {
	rec := httptest.NewRecorder()
	apimeta.Handler(repoScopeName).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil))
	apimeta.Handler(databaseScopeName).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.Equal(t, []string{"DATABASES"}, got.Scopes)
	assert.NotContains(t, rec.Body.String(), "null")
}

M cmd/doltsrht/main.go => cmd/doltsrht/main.go +1 -1
@@ 355,7 355,7 @@ func mountRoutes(r chi.Router, s surfaces) error {
		// 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.Get(apimeta.Path, apimeta.Handler(databaseScopeName))
	})

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

M cmd/doltsrht/main_test.go => cmd/doltsrht/main_test.go +1 -1
@@ 505,6 505,6 @@ func TestTheAPIMetaFileIsServed(t *testing.T) {

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

M config.example.ini => config.example.ini +4 -2
@@ 61,8 61,10 @@ log-level=info
; 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.
; service defines the "DATABASES" grant, which is what makes
; "dolt.sr.ht/DATABASES:RO" offerable on the personal-token page. meta reads
; that file once at startup, so it has to be restarted after this service
; changes the name — until then it offers the old one and refuses the new.
;
; 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,

M docs/DESIGN.md => docs/DESIGN.md +4 -2
@@ 215,8 215,10 @@ Ops: OpBrowse, OpCloneRead, OpPush, OpAdmin.
| owner | all | all | all |

PRIVATE + unauthorized ⇒ NotFound (don't leak existence). Suspended users: reads
allowed, push/admin denied. Token grants: `dolt.sr.ht/repos:RO` for reads, `:RW` for
push (empty-grant personal tokens pass via `HasAll`).
allowed, push/admin denied. Token grants: `dolt.sr.ht/DATABASES:RO` for reads, `:RW` for
push (empty-grant personal tokens pass via `HasAll`). The grant name is matched
exactly — `auth.Grants.Has` is a map lookup — so the spelling published in
`/query/api-meta.json` and the one enforced here are asserted equal by a test.

## remotesapi subsystem


M graph/server.go => graph/server.go +1 -1
@@ 24,7 24,7 @@
// every field applies the same access matrix the web pages and the MCP tools do:
//
//   - The credential is the bearer plane /mcp already defines — a meta personal
//     access token scoped `dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token
//     access token scoped `dolt.sr.ht/DATABASES:RO`, or a tokens.sr.ht working token
//     carrying `dolt:read`. No cookie: an API client is not a browser, and this
//     endpoint is deliberately outside web's same-origin group.
//   - Anonymous is a normal caller. It reads what an anonymous visitor reads,

M mcpsrv/http_test.go => mcpsrv/http_test.go +1 -1
@@ 155,7 155,7 @@ func mountMCP(t *testing.T, tokensDaemon bool) (*httptest.Server, credentials) {
		aliceWorking: forgeWorkingToken("alice"),
		bobWorking:   forgeWorkingToken("bob"),
		withoutRead:  forgeWorkingToken("alice"),
		alicePAT:     forgePAT("alice", "dolt.sr.ht/repos:RO"),
		alicePAT:     forgePAT("alice", "dolt.sr.ht/DATABASES:RO"),
		foreignPAT:   forgePAT("alice", "git.sr.ht/repos:RW"),
		unverifiable: forgeWorkingToken("alice"),
		garbage:      "not-a-token",

M remoteapi/integration_test.go => remoteapi/integration_test.go +3 -3
@@ 200,7 200,7 @@ func TestRemoteAPIIntegration(t *testing.T) {
	privURL := fmt.Sprintf("http://%s/~alice/privdb", addr)
	roURL := fmt.Sprintf("http://%s/~alice/rodb", addr)

	pat := forgePAT("alice", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))
	pat := forgePAT("alice", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour))

	// (1) Anonymous clone of PUBLIC succeeds.
	t.Run("anonymous_public_clone", func(t *testing.T) {


@@ 324,7 324,7 @@ func TestRemoteAPIIntegration(t *testing.T) {

	// (7) ACL RO grantee (bob) can clone the private rodb but cannot push.
	t.Run("acl_ro_clone_not_push", func(t *testing.T) {
		bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))
		bobPAT := forgePAT("bob", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour))
		env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT)

		dir := t.TempDir()


@@ 387,7 387,7 @@ func TestRemoteAPIIntegration(t *testing.T) {
	// owner-only guard, which also avoids leaking a stranger's namespace.
	t.Run("push_to_create_foreign_namespace_denied", func(t *testing.T) {
		foreignURL := fmt.Sprintf("http://%s/~alice/bobtried", addr)
		bobPAT := forgePAT("bob", "dolt.sr.ht/repos:RW", time.Now().Add(time.Hour))
		bobPAT := forgePAT("bob", "dolt.sr.ht/DATABASES:RW", time.Now().Add(time.Hour))
		env := append(append([]string{}, baseEnv...), "DOLT_REMOTE_PASSWORD="+bobPAT)

		work := filepath.Join(t.TempDir(), "bobtried")

M remoteapi/interceptors.go => remoteapi/interceptors.go +1 -1
@@ 205,7 205,7 @@ func (i *interceptor) authenticate(ctx context.Context) (*auth.AuthContext, erro
//
//  1. classify the method (unknown ⇒ PermissionDenied);
//  2. OAuth grant gate (TokenGrantsAllow) — PAT callers must carry
//     dolt.sr.ht/repos:RO for reads / :RW for pushes; anonymous, cookie and
//     dolt.sr.ht/DATABASES:RO for reads / :RW for pushes; anonymous, cookie and
//     dolt-key callers pass trivially;
//  3. extract the repo path (Root with no path ⇒ unauthenticated-OK ping);
//  4. load the repository row and the caller's effective ACL;

M remoteapi/interceptors_test.go => remoteapi/interceptors_test.go +3 -3
@@ 299,20 299,20 @@ func TestAuthorizeGrantGate(t *testing.T) {

	t.Run("PAT with RO grant allowed on read of own repo", func(t *testing.T) {
		i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mGetMeta, req); err != nil {
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RO"), mGetMeta, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})

	t.Run("PAT with RO grant cannot push", func(t *testing.T) {
		i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		_, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RO"), mCommit, req)
		_, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RO"), mCommit, req)
		wantCode(t, err, codes.PermissionDenied)
	})

	t.Run("PAT with RW grant pushes own repo", func(t *testing.T) {
		i := testInterceptor(&stubStore{repo: repo(1, 42, core.VisibilityPublic)})
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/repos:RW"), mCommit, req); err != nil {
		if _, err := i.authorize(ctx, patCaller(t, 42, "dolt.sr.ht/DATABASES:RW"), mCommit, req); err != nil {
			t.Fatalf("unexpected error: %v", err)
		}
	})