~bigbes/sr-ht-ecore

86de8532f48731960a4595b12a92de1ecf3f1dc6 — Eugene Blikh 10 days ago 5ba967e
bearer: the shared working-token validator of SPEC ch. 6
5 files changed, 1194 insertions(+), 1 deletions(-)

M README.md
A bearer/bearer.go
A bearer/bearer_test.go
M go.mod
M go.sum
M README.md => README.md +40 -0
@@ 21,6 21,12 @@ copies of the same code.
  mints and every service that validates have to read one grammar — two
  parsers that disagree about what counts as a permission is a hole on the
  security path, not a cosmetic divergence.
- `bearer` — the shared working-token validator (SPEC ch. 6), one copy for
  every service that accepts a tokens.sr.ht token: verify the signature,
  decide whether the token is ours, check the grant, and — only for a
  registered token — ask tokens.sr.ht whether it is still live, behind a 60s
  cache. Every step that can refuse locally runs before the one that cannot,
  so a short token never touches the network at all.

## Usage: chrome



@@ 47,6 53,40 @@ The template dot must expose the `chrome.Page` fields — either a `Page`
itself, or a service view struct that embeds one (promoted fields resolve in
templates).

## Usage: bearer

```go
v, err := bearer.New(bearer.Options{
    Origin:   conf.Get("tokens.sr.ht", "origin"), // https://tokens.srht.bigb.es
    ClientID: "bench.sr.ht",                      // the CALLING service
    NodeID:   hostname,
})

tok, err := v.Validate(r.Context(), presented, "bench:upload")
switch {
case err == nil:
    // tok.Username, tok.Grants, tok.TokenID
case errors.Is(err, bearer.ErrNotOurs):
    // service policy: accept as a meta.sr.ht PAT, or refuse
case errors.Is(err, bearer.ErrForbidden):
    http.Error(w, "insufficient grants", http.StatusForbidden)
case errors.Is(err, bearer.ErrUnavailable):
    http.Error(w, "token service unavailable", http.StatusServiceUnavailable)
default: // ErrInvalid, ErrRevoked
    http.Error(w, "invalid token", http.StatusUnauthorized)
}
```

`crypto.InitCrypto` must have run first — the signing key and the network key
both live in that package's globals.

Two of those arms are the ones to get right. `ErrNotOurs` is deliberately not
decided by the validator: SPEC ch. 6 step 2 leaves it to each service whether a
foreign bearer token is a meta PAT to accept (dolt) or something to refuse
(bench, cover). And `ErrUnavailable` is **503, never 401** — reading an
unreachable daemon as "revoked" would refuse live tokens across the instance
for the length of a tokens.sr.ht restart.

## Policy: chrome

The chrome bakes in the instance-wide decisions instead of parameterizing

A bearer/bearer.go => bearer/bearer.go +504 -0
@@ 0,0 1,504 @@
// Package bearer validates a tokens.sr.ht working token, and is the one copy of
// that check for every service on the instance (SPEC ch. 6).
//
// A working token is an auth.BearerToken: the version, expiry, grants, client id
// and owner in BARE, HMAC-SHA256 over the lot, base64. It is the same format and
// the same key meta.sr.ht stamps its own personal access tokens with, so a
// service needs no new cryptographic stack to accept one — and, for the same
// reason, the signature alone does not say who issued the token. Only the client
// id does, which is why step 2 below exists at all.
//
// The four steps, in order, are SPEC ch. 6:
//
//  1. decode and verify (local, no network);
//  2. is this token ours? — if not, the *service's* policy decides;
//  3. does it grant the action being attempted?
//  4. if it is a registered token, is it still live? — the one step that talks
//     to tokens.sr.ht, cached, and skipped entirely by a short token.
//
// The order is not an implementation detail. Every step that can refuse locally
// runs before the one that cannot, so the network is touched only for a token
// that has already proved it is well-formed, ours, unexpired and sufficient for
// what its holder is doing. A forged or expired credential never reaches the
// daemon, and an instance under a flood of junk tokens does not turn that flood
// into traffic against tokens.sr.ht.
//
// Usage:
//
//	v, err := bearer.New(bearer.Options{
//	    Origin:   conf.Get("tokens.sr.ht", "origin"),
//	    ClientID: "bench.sr.ht",
//	    NodeID:   hostname,
//	})
//	...
//	tok, err := v.Validate(r.Context(), presented, "bench:upload")
//
// The process must have run crypto.InitCrypto before any of this: the signing
// key of step 1 and the network key that seals the internal authorization of
// step 4 both live in that package's globals. This is the same precondition
// every core-go authentication path carries and it is not checked here, because
// there is nothing this package could usefully do about it at request time.
//
// Resolving the owner is not this package's job. The token names a meta.sr.ht
// username; turning that into a local row is auth.LookupUser plus whatever the
// service's own plan does with a user it has never seen (SPEC ch. 6), and that
// differs per service in ways a shared validator should not have opinions about.
package bearer

import (
	"context"
	"encoding/json"
	"errors"
	"fmt"
	"io"
	"net/http"
	"net/url"
	"strconv"
	"strings"
	"sync"
	"time"

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

	"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
)

// TokensClientID is the ClientID tokens.sr.ht stamps into every working token it
// seals, and the only thing that distinguishes one from a meta.sr.ht PAT: the
// two share a signing key, so a valid signature says the instance made the
// token and not which part of it did (SPEC ch. 1).
const TokensClientID = "tokens.sr.ht"

// DefaultCacheTTL is how long a revocation answer is reused when Options leaves
// CacheTTL at zero. Sixty seconds, the figure SPEC ch. 6 step 4 names.
const DefaultCacheTTL = 60 * time.Second

// defaultHTTPTimeout bounds a revocation check when the caller supplies no
// client of its own.
//
// It has to be short. This request sits on the hot path of an upload, behind a
// context the caller may not have given a deadline to, and the endpoint it calls
// answers from one indexed row — a healthy daemon replies in single-digit
// milliseconds. Five seconds is generous for that and still short enough that a
// hung tokens.sr.ht turns into 503s rather than into request-handler goroutines
// piling up across every service on the instance.
const defaultHTTPTimeout = 5 * time.Second

// revocationPath is the endpoint of SPEC ch. 5, joined to Origin.
const revocationPath = "/api/v1/revocations/"

// maxCacheEntries bounds the revocation cache. See (*Validator).remember for
// what happens at the bound and why that is the right thing to happen.
const maxCacheEntries = 4096

// The refusals of SPEC ch. 6, and the status each one is for a service.
//
// They are separate sentinels rather than one error with a code because the
// mapping is not uniform, and the interesting cases are the two that are not
// 401:
//
//   - ErrInvalid — 401. The signature did not verify, the version is foreign, or
//     the token has expired.
//   - ErrNotOurs — the service's own policy, not a status. See Validate.
//   - ErrForbidden — 403. The credential is good; it does not cover this action.
//     Distinct from 401 because retrying with the same token is pointless and
//     the holder needs to be told to ask for a wider grant, not to log in again.
//   - ErrRevoked — 401. The credential was withdrawn. Deliberately not 403: the
//     token is no longer a credential at all, and a client that sees 403 will
//     keep presenting it.
//   - ErrUnavailable — 503. tokens.sr.ht could not be asked.
//
// The 503 is the one that has to be defended, because "I could not check" reads
// so naturally as "so I will not accept it". Reading an unreachable daemon as a
// revocation would refuse every registered token on the instance for as long as
// tokens.sr.ht is down — turning a restart of a service that is deliberately off
// the hot path into an instance-wide outage of uploads. SPEC ch. 6 step 4 says
// 503 for exactly that reason, following core-go, which answers the same way
// when meta.sr.ht cannot be reached. 503 also says the true thing to a client:
// come back, this is us and it is temporary.
//
// This is not a decision to fail open. A short token was never checked against
// the daemon in the first place, and a registered one whose revocation cannot be
// confirmed is refused — with a status that keeps the operator's attention on
// the daemon instead of on a thousand clients being told their credentials are
// bad.
var (
	// ErrInvalid: the presented string is not a token this instance sealed, or
	// no longer is one. 401.
	ErrInvalid = errors.New("bearer: token does not verify")

	// ErrNotOurs: a well-formed token from another issuer, almost certainly a
	// meta.sr.ht PAT. Returned together with the decoded token; the status is
	// the service's to choose.
	ErrNotOurs = errors.New("bearer: token was not issued by tokens.sr.ht")

	// ErrForbidden: our token, valid, but it does not carry the action. 403.
	ErrForbidden = errors.New("bearer: token does not grant this action")

	// ErrRevoked: a registered token whose row is gone, revoked or expired. 401.
	ErrRevoked = errors.New("bearer: token has been revoked")

	// ErrUnavailable: the revocation check could not be completed. 503, never
	// 401 — see above.
	ErrUnavailable = errors.New("bearer: tokens.sr.ht could not be reached")
)

// Options configures a Validator.
type Options struct {
	// Origin is where tokens.sr.ht answers, scheme and host, e.g.
	// "https://tokens.srht.bigb.es". Required.
	Origin string

	// ClientID identifies the *calling* service in the internal authorization of
	// step 4, e.g. "bench.sr.ht". Required — the daemon's guard refuses a blob
	// that names neither this nor NodeID.
	//
	// It is a label, not a credential: the guard admits every internal service
	// equally and this only decides what its log line says. Which is precisely
	// why it should be right; it is what an operator has to go on when the
	// revocation cache misbehaves.
	ClientID string

	// NodeID identifies the calling process or host, e.g. "bench-1". Required,
	// for the same reason and with the same weight as ClientID.
	NodeID string

	// HTTPClient performs the revocation check. Nil means a client with
	// defaultHTTPTimeout.
	HTTPClient *http.Client

	// CacheTTL is how long one revocation answer is reused. Zero means
	// DefaultCacheTTL; negative is refused.
	CacheTTL time.Duration

	// Now is the clock the cache ages entries against. Nil means time.Now.
	//
	// It does not move the expiry check of step 1: auth.DecodeBearerToken reads
	// the real clock itself and this package cannot reach inside it. A test that
	// wants an expired token has to mint one that is genuinely in the past.
	Now func() time.Time
}

// Validator runs the check of SPEC ch. 6 for one calling service. It is safe for
// concurrent use, which it has to be: a service holds exactly one and every
// request handler goes through it.
type Validator struct {
	origin   string
	clientID string
	nodeID   string
	client   *http.Client
	ttl      time.Duration
	now      func() time.Time

	mu    sync.Mutex
	cache map[int]verdict
}

// verdict is one cached answer from the revocation endpoint: whether the row was
// live, and when that answer stops being reusable.
//
// ErrUnavailable never becomes a verdict — a failure to ask is not an answer,
// and caching it would let one blip, one timeout, one restart pin every token
// that happened to be checked during it to failure for the whole TTL. That turns
// a moment of unavailability into a minute of it, and it does so silently,
// because the daemon is healthy again while the services are still refusing.
type verdict struct {
	alive bool
	until time.Time
}

// Token is what validation yields: who the holder is, what the token permits,
// and how long it lasts.
type Token struct {
	// Username is the meta.sr.ht account the token was issued to. Resolving it
	// to a local row is the service's job (auth.LookupUser).
	Username string

	// Grants is the parsed grant set.
	//
	// It is the zero value — which admits nothing — when the error is
	// ErrNotOurs, and that is not an oversight. A foreign token's grant string is
	// in whatever vocabulary its issuer uses, and meta.sr.ht's is core-go's
	// auth.Grants ("git.sr.ht/OBJECTS:RW"), a different grammar that this one
	// would reject as malformed. A service that accepts meta PATs must decode
	// that string with auth.DecodeGrants; it can get at it by calling
	// auth.DecodeBearerToken on the presented string, which is local and cheap.
	Grants grants.Grants

	// TokenID is the row id from the grant string's id: member, or 0 for a
	// stateless token — one short enough that tokens.sr.ht never wrote it down
	// and that therefore has no revocation to check (SPEC ch. 2).
	TokenID int

	// Expires is when the signature stops being honoured.
	Expires time.Time
}

// Registered reports whether this token has a row at tokens.sr.ht — the
// difference between a credential its owner can revoke and one that can only be
// waited out.
func (t *Token) Registered() bool { return t.TokenID != 0 }

// New builds a Validator, refusing options that would only fail later, one
// request at a time, as an error about the network.
func New(opts Options) (*Validator, error) {
	if opts.Origin == "" {
		return nil, errors.New("bearer: Origin is required, e.g. https://tokens.srht.bigb.es")
	}
	u, err := url.Parse(opts.Origin)
	if err != nil {
		return nil, fmt.Errorf("bearer: Origin %q does not parse: %w", opts.Origin, err)
	}
	// An origin without a scheme and host is not one, and the failure it causes
	// otherwise is a request error on the first registered token some service
	// sees — days after the config was written, and reported as ErrUnavailable,
	// which points the operator at the daemon rather than at the typo.
	if (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
		return nil, fmt.Errorf(
			"bearer: Origin %q must be an absolute http(s) URL, e.g. https://tokens.srht.bigb.es",
			opts.Origin)
	}
	if opts.ClientID == "" {
		return nil, errors.New("bearer: ClientID is required: the internal guard refuses a blob without one")
	}
	if opts.NodeID == "" {
		return nil, errors.New("bearer: NodeID is required: the internal guard refuses a blob without one")
	}
	if opts.CacheTTL < 0 {
		return nil, fmt.Errorf("bearer: CacheTTL %s is negative; zero means %s", opts.CacheTTL, DefaultCacheTTL)
	}

	v := &Validator{
		origin:   strings.TrimSuffix(opts.Origin, "/"),
		clientID: opts.ClientID,
		nodeID:   opts.NodeID,
		client:   opts.HTTPClient,
		ttl:      opts.CacheTTL,
		now:      opts.Now,
		cache:    make(map[int]verdict),
	}
	if v.client == nil {
		v.client = &http.Client{Timeout: defaultHTTPTimeout}
	}
	if v.ttl == 0 {
		v.ttl = DefaultCacheTTL
	}
	if v.now == nil {
		v.now = time.Now
	}
	return v, nil
}

// Validate runs the four steps of SPEC ch. 6 against one presented token for one
// action, e.g. "bench:upload".
//
// presented is the bare credential, with any "Bearer " scheme already stripped.
//
// On success it returns the token and a nil error. On failure it returns one of
// this package's sentinels, wrapped with detail — test with errors.Is, and map
// it to a status with the table on those sentinels.
//
// The returned token is nil for every failure except ErrNotOurs. A token that
// failed validation is not a token, and handing one back invites a caller to use
// what it was just told not to; the exception is the whole point of step 2,
// where the token *did* validate and only the question of whose it is remains.
//
// # Step 2 is not decided here
//
// A token whose ClientID is not TokensClientID gets ErrNotOurs and the decoded
// token, and this package takes it no further. That is deliberate and SPEC ch. 6
// step 2 requires it: what to do with a foreign bearer token is per-service
// policy, not a property of the format. dolt accepts meta.sr.ht PATs today and
// must keep accepting them; bench and cover have no reason to. A validator that
// refused on its own behalf would break the first and look correct doing it,
// because the token really is not one of ours — it just is not this package's
// call.
//
// So a service that also accepts meta PATs writes:
//
//	tok, err := v.Validate(ctx, presented, "dolt:push")
//	if errors.Is(err, bearer.ErrNotOurs) {
//	    // ... its own meta-PAT path ...
//	}
//
// and one that does not turns ErrNotOurs into 401 alongside ErrInvalid.
func (v *Validator) Validate(ctx context.Context, presented, action string) (*Token, error) {
	// Step 1. Signature, version and expiry, all of it local.
	//
	// DecodeBearerToken returns nil for all three and distinguishes none of
	// them, which is the right amount of detail to give a client anyway. It
	// checks the expiry itself, against the real clock — so an expired token
	// costs one HMAC and never becomes a request to anybody. That property is
	// what makes it safe for this step to run before every other.
	bt := auth.DecodeBearerToken(presented)
	if bt == nil {
		return nil, fmt.Errorf("%w: signature, version or expiry", ErrInvalid)
	}

	// Step 2. Ours, or somebody else's? Not our decision — see the doc comment.
	if bt.ClientID != TokensClientID {
		return &Token{
			Username: bt.Username,
			Expires:  bt.Expires.Time(),
		}, fmt.Errorf("%w: ClientID is %q, not %q", ErrNotOurs, bt.ClientID, TokensClientID)
	}

	g, err := grants.Parse(bt.Grants)
	if err != nil {
		// Only tokens.sr.ht seals a token with our ClientID, and it writes the
		// grant string with the same parser that is failing here, so this is
		// either a version skew between daemon and service or a bug in one of
		// them. Either way it is not a credential this service can act on.
		return nil, fmt.Errorf("%w: grants %q do not parse: %s", ErrInvalid, bt.Grants, err)
	}

	tok := &Token{
		Username: bt.Username,
		Grants:   g,
		TokenID:  g.TokenID(),
		Expires:  bt.Expires.Time(),
	}

	// Step 3. Does it cover what is being attempted?
	if !g.Has(action) {
		return nil, fmt.Errorf("%w: %q is not in %q", ErrForbidden, action, g.String())
	}

	// Step 4. Only a registered token has a revocation to check. A stateless one
	// has no row, so there is nothing to ask about and nothing to reach for:
	// this is the common case and it completes without any network at all.
	if tok.TokenID == 0 {
		return tok, nil
	}
	if err := v.checkRevocation(ctx, tok.TokenID); err != nil {
		return nil, err
	}
	return tok, nil
}

// Forget drops the cached revocation answer for one token id, so that the next
// validation asks the daemon again.
//
// It is for the case where a service learns out of band that an answer is stale
// — a webhook, an operator, a test — and wants the revocation to take effect now
// rather than at the end of the TTL. Forgetting an id that is not cached is a
// no-op, and forgetting one that is only costs a round trip.
func (v *Validator) Forget(id int) {
	v.mu.Lock()
	delete(v.cache, id)
	v.mu.Unlock()
}

// checkRevocation is step 4: ask GET {Origin}/api/v1/revocations/{id}, through
// the cache.
//
// 204 is live, 404 is not, and everything else — a 500, a timeout, a refused
// connection, a proxy's HTML error page — is ErrUnavailable. The endpoint has
// exactly two answers by design (SPEC ch. 5), so anything that is neither is not
// a third answer; it is the absence of one.
//
// Two concurrent validations of the same id will both issue a request when the
// entry is cold. That is a duplicated round trip and nothing worse: the answers
// agree, the second write to the cache is idempotent, and collapsing them would
// buy one saved request in exchange for a dependency and a shared failure mode
// where a single slow call holds up every goroutine waiting behind it.
func (v *Validator) checkRevocation(ctx context.Context, id int) error {
	if alive, ok := v.cached(id); ok {
		if alive {
			return nil
		}
		return fmt.Errorf("%w: token %d (cached)", ErrRevoked, id)
	}

	// The internal authorization is minted per request and cannot be cached: it
	// is a fernet blob the daemon accepts only for thirty seconds, which is what
	// stops a captured one being replayed for a week.
	blob, err := json.Marshal(auth.InternalAuth{ClientID: v.clientID, NodeID: v.nodeID})
	if err != nil {
		return fmt.Errorf("%w: sealing the internal authorization: %s", ErrUnavailable, err)
	}

	url := v.origin + revocationPath + strconv.Itoa(id)
	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
	if err != nil {
		return fmt.Errorf("%w: building the request for %s: %s", ErrUnavailable, url, err)
	}
	req.Header.Set("Authorization", "Internal "+string(crypto.Encrypt(blob)))

	resp, err := v.client.Do(req)
	if err != nil {
		return fmt.Errorf("%w: asking %s: %s", ErrUnavailable, url, err)
	}
	defer resp.Body.Close()
	// Both answers are empty bodies, but read to the end anyway so the
	// connection goes back to the pool instead of being dropped and redialled on
	// every upload.
	_, _ = io.Copy(io.Discard, resp.Body)

	switch resp.StatusCode {
	case http.StatusNoContent:
		v.remember(id, true)
		return nil
	case http.StatusNotFound:
		// 404 covers revoked, expired and unknown alike, and all three are
		// permanent: no id ever goes back to being live. Caching it is therefore
		// not a staleness risk in the way caching "live" is.
		v.remember(id, false)
		return fmt.Errorf("%w: token %d", ErrRevoked, id)
	default:
		return fmt.Errorf("%w: %s answered %s", ErrUnavailable, url, resp.Status)
	}
}

// cached returns a still-valid answer for id, if there is one.
func (v *Validator) cached(id int) (alive, ok bool) {
	now := v.now()

	v.mu.Lock()
	defer v.mu.Unlock()

	e, ok := v.cache[id]
	if !ok || !now.Before(e.until) {
		return false, false
	}
	return e.alive, true
}

// remember stores an answer for CacheTTL, and keeps the cache bounded.
//
// The TTL is the trade SPEC ch. 6 makes on purpose and it should be stated
// plainly: a revocation takes up to CacheTTL to take effect across the instance.
// The alternative is asking the daemon on every request, which puts tokens.sr.ht
// back on the hot path of every upload and makes its availability the
// instance's — the exact coupling SPEC ch. 1 removes. Sixty seconds of a revoked
// token still working is the price of that, and the operator revoking it should
// be told to expect it.
//
// The bound is a sweep, then a drop. At maxCacheEntries the expired entries go
// first; if that does not get under the bound, the whole map goes. No LRU, no
// eviction list — every entry here is worth exactly one HTTP round trip to
// rebuild and they all expire within CacheTTL anyway, so the cost of throwing
// away a full cache is bounded and small, while the cost of a map that only ever
// grows is a leak in a process meant to run for months. In practice the bound
// never fires: an entry can only be created by a token that already passed an
// HMAC check, so the id space here is the daemon's real rows and not something a
// caller can inflate.
func (v *Validator) remember(id int, alive bool) {
	now := v.now()

	v.mu.Lock()
	defer v.mu.Unlock()

	if len(v.cache) >= maxCacheEntries {
		for k, e := range v.cache {
			if !now.Before(e.until) {
				delete(v.cache, k)
			}
		}
		if len(v.cache) >= maxCacheEntries {
			v.cache = make(map[int]verdict, maxCacheEntries)
		}
	}
	v.cache[id] = verdict{alive: alive, until: now.Add(v.ttl)}
}

A bearer/bearer_test.go => bearer/bearer_test.go +599 -0
@@ 0,0 1,599 @@
package bearer

import (
	"context"
	"encoding/json"
	"errors"
	"net/http"
	"net/http/httptest"
	"strings"
	"sync"
	"testing"
	"testing/fstest"
	"time"

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

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// The identity this validator claims when it calls the daemon — the calling
// service, not tokens.sr.ht.
const (
	callerClientID = "bench.sr.ht"
	callerNodeID   = "bench-1"
)

// TestMain initialises the process-global core-go crypto state this package
// depends on and never sets up itself.
//
// Both keys are load-bearing here and for different steps. [webhooks]private-key
// is what auth.BearerToken.Encode signs with and what step 1 verifies against —
// it is derived into the HMAC key, not used directly. [sr.ht]network-key is the
// fernet key that seals the Internal authorization of step 4, so without it the
// revocation tests would not get past the request. The values are the ones
// core-go's own tests use.
func TestMain(m *testing.M) {
	config.FS = fstest.MapFS{
		"config.ini": &fstest.MapFile{Data: []byte(`
[webhooks]
private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=

[sr.ht]
network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=
`)},
	}
	crypto.InitCrypto(config.LoadConfig())
	m.Run()
}

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

// seal mints a token exactly the way tokens.sr.ht's service.seal does, so that
// what these tests present is byte-for-byte the shape a real one has.
func seal(username, clientID, grantString string, expires time.Time) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grantString,
		ClientID: clientID,
		Username: username,
	}
	return bt.Encode()
}

// ourToken is a live working token from this instance's daemon.
func ourToken(grantString string) string {
	return seal("bigbes", TokensClientID, grantString, time.Now().Add(time.Hour))
}

// daemon is a stand-in for tokens.sr.ht's revocation endpoint. It records every
// request so a test can assert not only what came back but whether anything was
// asked at all — which for step 4 is half of what there is to check.
//
// statuses are consumed one per request, and the last one repeats forever, so
// "fail once then recover" is written as newDaemon(t, 500, 204).
type daemon struct {
	server *httptest.Server

	mu       sync.Mutex
	statuses []int
	hits     int
	paths    []string
	auths    []string
}

func newDaemon(t *testing.T, statuses ...int) *daemon {
	t.Helper()
	require.NotEmpty(t, statuses, "a daemon must be told what to answer")

	d := &daemon{statuses: statuses}
	d.server = httptest.NewServer(http.HandlerFunc(d.serve))
	t.Cleanup(d.server.Close)
	return d
}

func (d *daemon) serve(w http.ResponseWriter, r *http.Request) {
	d.mu.Lock()
	defer d.mu.Unlock()

	d.paths = append(d.paths, r.URL.Path)
	d.auths = append(d.auths, r.Header.Get("Authorization"))

	status := d.statuses[len(d.statuses)-1]
	if d.hits < len(d.statuses) {
		status = d.statuses[d.hits]
	}
	d.hits++
	w.WriteHeader(status)
}

func (d *daemon) count() int {
	d.mu.Lock()
	defer d.mu.Unlock()
	return d.hits
}

func (d *daemon) lastPath(t *testing.T) string {
	t.Helper()
	d.mu.Lock()
	defer d.mu.Unlock()
	require.NotEmpty(t, d.paths, "the daemon was never asked anything")
	return d.paths[len(d.paths)-1]
}

func (d *daemon) lastAuth(t *testing.T) string {
	t.Helper()
	d.mu.Lock()
	defer d.mu.Unlock()
	require.NotEmpty(t, d.auths, "the daemon was never asked anything")
	return d.auths[len(d.auths)-1]
}

// fakeClock drives the cache's ageing. It does not drive step 1's expiry check:
// auth.DecodeBearerToken reads the real clock and nothing here can reach it.
type fakeClock struct {
	mu sync.Mutex
	t  time.Time
}

func newClock() *fakeClock {
	return &fakeClock{t: time.Date(2026, 8, 8, 12, 0, 0, 0, time.UTC)}
}

func (c *fakeClock) now() time.Time {
	c.mu.Lock()
	defer c.mu.Unlock()
	return c.t
}

func (c *fakeClock) advance(d time.Duration) {
	c.mu.Lock()
	defer c.mu.Unlock()
	c.t = c.t.Add(d)
}

func newValidator(t *testing.T, origin string, now func() time.Time) *Validator {
	t.Helper()
	v, err := New(Options{
		Origin:   origin,
		ClientID: callerClientID,
		NodeID:   callerNodeID,
		Now:      now,
	})
	require.NoError(t, err)
	return v
}

// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------

func TestNewRefusesOptionsThatWouldOnlyFailLater(t *testing.T) {
	good := Options{Origin: "https://tokens.srht.bigb.es", ClientID: callerClientID, NodeID: callerNodeID}

	for name, mutate := range map[string]func(*Options){
		"no origin":       func(o *Options) { o.Origin = "" },
		"no scheme":       func(o *Options) { o.Origin = "tokens.srht.bigb.es" },
		"wrong scheme":    func(o *Options) { o.Origin = "ftp://tokens.srht.bigb.es" },
		"no host":         func(o *Options) { o.Origin = "https://" },
		"no client id":    func(o *Options) { o.ClientID = "" },
		"no node id":      func(o *Options) { o.NodeID = "" },
		"negative ttl":    func(o *Options) { o.CacheTTL = -time.Second },
		"unparseable url": func(o *Options) { o.Origin = "https://%zz" },
	} {
		t.Run(name, func(t *testing.T) {
			opts := good
			mutate(&opts)
			v, err := New(opts)
			require.Error(t, err, "a config mistake must not become a request-time 503")
			assert.Nil(t, v)
		})
	}
}

func TestNewFillsTheDefaults(t *testing.T) {
	v, err := New(Options{Origin: "https://tokens.srht.bigb.es/", ClientID: callerClientID, NodeID: callerNodeID})
	require.NoError(t, err)

	assert.Equal(t, DefaultCacheTTL, v.ttl, "a zero TTL means the 60s of SPEC ch. 6")
	assert.NotNil(t, v.client)
	assert.Equal(t, defaultHTTPTimeout, v.client.Timeout, "the default client must not be able to hang")
	assert.NotNil(t, v.now)
	assert.Equal(t, "https://tokens.srht.bigb.es", v.origin,
		"a trailing slash must not become a double slash in the path")
}

// ---------------------------------------------------------------------------
// Step 1: decode and verify
// ---------------------------------------------------------------------------

func TestTamperedTokenIsInvalid(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	good := ourToken("bench:upload id:42")

	// Flip one character of the base64. The payload still decodes; the HMAC does
	// not verify, which is the whole of what makes the format worth anything.
	tampered := []byte(good)
	if tampered[3] == 'A' {
		tampered[3] = 'B'
	} else {
		tampered[3] = 'A'
	}

	for name, presented := range map[string]string{
		"tampered":    string(tampered),
		"empty":       "",
		"not base64":  "!!!not-a-token!!!",
		"too short":   "aGVsbG8",
		"grants only": "bench:upload",
	} {
		t.Run(name, func(t *testing.T) {
			tok, err := v.Validate(context.Background(), presented, "bench:upload")
			require.Error(t, err)
			assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
			assert.Nil(t, tok, "a token that failed validation must not be handed back")
		})
	}
	assert.Zero(t, d.count(), "nothing that fails to verify may become a request to the daemon")
}

// The expiry is checked by auth.DecodeBearerToken against the real clock, which
// is why step 1 needs no network and no clock of ours.
func TestExpiredTokenIsInvalidWithoutAskingAnybody(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	expired := seal("bigbes", TokensClientID, "bench:upload id:42", time.Now().Add(-time.Minute))

	tok, err := v.Validate(context.Background(), expired, "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrInvalid), "want ErrInvalid, got %v", err)
	assert.Nil(t, tok)
	assert.Zero(t, d.count(), "an expired token is refused locally, before any network")
}

// ---------------------------------------------------------------------------
// Step 2: is it ours?
// ---------------------------------------------------------------------------

// A meta.sr.ht PAT is signed with the same key and verifies perfectly. Only the
// ClientID tells the two apart, and what to do about it is the service's policy
// — so this returns the token along with the refusal.
func TestForeignClientIDIsNotOursAndComesBackWithTheToken(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	expires := time.Now().Add(time.Hour)
	// Meta's grant vocabulary, not ours: this string does not parse under
	// grants.Parse, so a validator that checked ClientID after parsing would
	// answer ErrInvalid here and a service like dolt would lose the ability to
	// tell a foreign token from a broken one.
	pat := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", expires)

	tok, err := v.Validate(context.Background(), pat, "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrNotOurs), "want ErrNotOurs, got %v", err)

	require.NotNil(t, tok, "the caller has to be able to look at the token it may still accept")
	assert.Equal(t, "bigbes", tok.Username)
	assert.Equal(t, expires.UTC().Truncate(time.Second), tok.Expires)
	assert.True(t, tok.Grants.Empty(),
		"a foreign grant string is in a foreign vocabulary; this one must admit nothing")
	assert.Zero(t, tok.TokenID)
	assert.Zero(t, d.count(), "a token that is not ours has no revocation of ours to check")
}

// ---------------------------------------------------------------------------
// Step 3: the grant
// ---------------------------------------------------------------------------

func TestMissingGrantIsForbiddenAndStopsBeforeTheNetwork(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	// Registered, so step 4 would have work to do — and must not get the chance.
	tok, err := v.Validate(context.Background(), ourToken("bench:read id:42"), "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrForbidden), "want ErrForbidden, got %v", err)
	assert.Nil(t, tok)
	assert.Zero(t, d.count(),
		"step 3 refuses locally; a token that cannot do the thing must not cost a round trip")
}

func TestUniversalGrantAdmitsTheAction(t *testing.T) {
	v := newValidator(t, "https://tokens.srht.bigb.es", nil)

	tok, err := v.Validate(context.Background(), ourToken("*"), "bench:upload")
	require.NoError(t, err)
	assert.True(t, tok.Grants.All())
}

// ---------------------------------------------------------------------------
// Step 4: revocation
// ---------------------------------------------------------------------------

// The common case: a short token was never written down, so there is nothing to
// ask and nobody to ask it of. This is the property that keeps tokens.sr.ht off
// the hot path (SPEC ch. 1), so the assertion that matters is the request count.
func TestStatelessTokenNeverTouchesTheNetwork(t *testing.T) {
	d := newDaemon(t, http.StatusNotFound) // would refuse, if it were ever asked
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload cover:read"), "bench:upload")
	require.NoError(t, err)

	assert.Equal(t, "bigbes", tok.Username)
	assert.Zero(t, tok.TokenID)
	assert.False(t, tok.Registered())
	assert.True(t, tok.Grants.Has("cover:read"))
	assert.Zero(t, d.count(), "a token with no id: must not reach the daemon at all")
}

func TestRegisteredTokenValidatesAgainstA204(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.NoError(t, err)

	assert.Equal(t, 42, tok.TokenID)
	assert.True(t, tok.Registered())
	assert.Equal(t, 1, d.count())
	assert.Equal(t, "/api/v1/revocations/42", d.lastPath(t))
}

func TestRevokedTokenIsRefused(t *testing.T) {
	d := newDaemon(t, http.StatusNotFound)
	v := newValidator(t, d.server.URL, nil)

	tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.Error(t, err)
	assert.True(t, errors.Is(err, ErrRevoked), "want ErrRevoked, got %v", err)
	assert.Nil(t, tok)
	assert.Equal(t, 1, d.count())
}

// A daemon that cannot answer is not a daemon that answered "revoked". Getting
// this wrong would refuse every registered token on the instance for the length
// of a tokens.sr.ht restart, which is why each case asserts what the error is
// *not* as well as what it is.
func TestADaemonThatCannotAnswerIsUnavailableAndNeverRevoked(t *testing.T) {
	t.Run("500", func(t *testing.T) {
		d := newDaemon(t, http.StatusInternalServerError)
		v := newValidator(t, d.server.URL, nil)

		tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked), "unknown is not revoked")
		assert.Nil(t, tok)
	})

	t.Run("401 from the internal guard", func(t *testing.T) {
		// A misconfigured internal-ipnet, or a network key that does not match.
		// It is a deployment fault and the operator has to see it as one, not as
		// every user's token suddenly being revoked.
		d := newDaemon(t, http.StatusUnauthorized)
		v := newValidator(t, d.server.URL, nil)

		_, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked))
	})

	t.Run("nothing listening", func(t *testing.T) {
		dead := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
		origin := dead.URL
		dead.Close() // the port is now refusing connections

		v := newValidator(t, origin, nil)

		tok, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked), "a transport failure is not an answer")
		assert.Nil(t, tok)
	})

	t.Run("context cancelled", func(t *testing.T) {
		d := newDaemon(t, http.StatusNoContent)
		v := newValidator(t, d.server.URL, nil)

		ctx, cancel := context.WithCancel(context.Background())
		cancel()

		_, err := v.Validate(ctx, ourToken("bench:upload id:42"), "bench:upload")
		require.Error(t, err)
		assert.True(t, errors.Is(err, ErrUnavailable), "want ErrUnavailable, got %v", err)
		assert.False(t, errors.Is(err, ErrRevoked))
	})
}

// The internal authorization is the credential that gets past the daemon's
// guard, and it has to name the *calling* service. If it named something else
// the guard would still admit it — it admits every internal service equally —
// and the daemon's log would quietly attribute every check to the wrong caller.
func TestTheInternalBlobNamesTheConfiguredCaller(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	_, err := v.Validate(context.Background(), ourToken("bench:upload id:42"), "bench:upload")
	require.NoError(t, err)

	scheme, blob, found := strings.Cut(d.lastAuth(t), " ")
	require.True(t, found, "the header must be %q, got %q", "Internal <blob>", d.lastAuth(t))
	assert.Equal(t, "Internal", scheme)

	// Decrypted the way authn.InternalGuard does it, expiry window included: the
	// blob is minted per request precisely so that this window is meaningful.
	payload := crypto.DecryptWithExpiration([]byte(blob), 30*time.Second)
	require.NotNil(t, payload, "the blob must verify under the instance's network key, and be fresh")

	var ia auth.InternalAuth
	require.NoError(t, json.Unmarshal(payload, &ia))
	assert.Equal(t, callerClientID, ia.ClientID)
	assert.Equal(t, callerNodeID, ia.NodeID)
}

// ---------------------------------------------------------------------------
// The cache
// ---------------------------------------------------------------------------

func TestARevocationAnswerIsCachedWithinTheTTL(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")
	for i := 0; i < 5; i++ {
		_, err := v.Validate(context.Background(), token, "bench:upload")
		require.NoError(t, err)
	}
	assert.Equal(t, 1, d.count(), "five validations inside the TTL are one question")

	clock.advance(DefaultCacheTTL - time.Second)
	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 1, d.count(), "still inside the TTL")

	clock.advance(2 * time.Second) // now past it
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 2, d.count(), "past the TTL the daemon must be asked again")
}

// The trade SPEC ch. 6 makes on purpose, written down as a test so that nobody
// has to rediscover it during an incident: a revocation takes up to CacheTTL to
// be honoured by a service that already asked.
func TestARevocationTakesUpToTheTTLToTakeEffect(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent, http.StatusNotFound)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")

	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)

	// The owner revokes it here. The daemon would now answer 404 — but it is not
	// being asked.
	clock.advance(DefaultCacheTTL / 2)
	_, err = v.Validate(context.Background(), token, "bench:upload")
	assert.NoError(t, err, "inside the TTL the cached 'live' still stands")
	assert.Equal(t, 1, d.count())

	clock.advance(DefaultCacheTTL)
	_, err = v.Validate(context.Background(), token, "bench:upload")
	assert.True(t, errors.Is(err, ErrRevoked), "past the TTL it must be refused; got %v", err)
	assert.Equal(t, 2, d.count())
}

// ErrUnavailable must never be cached. If it were, a single blip would pin every
// token checked during it to failure for a full TTL — turning a moment of
// unavailability into a minute of it, with the daemon healthy the whole time.
func TestUnavailableIsNotCachedWhileALiveAnswerIs(t *testing.T) {
	d := newDaemon(t, http.StatusInternalServerError, http.StatusNoContent)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	token := ourToken("bench:upload id:42")

	_, err := v.Validate(context.Background(), token, "bench:upload")
	require.True(t, errors.Is(err, ErrUnavailable), "got %v", err)
	assert.Equal(t, 1, d.count())

	// Immediately afterwards, no clock movement at all: the failure left nothing
	// behind, so the daemon is asked again and the recovery is picked up at once.
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err, "a failure to ask must not be remembered as an answer")
	assert.Equal(t, 2, d.count())

	// And the answer that *is* an answer is cached.
	_, err = v.Validate(context.Background(), token, "bench:upload")
	require.NoError(t, err)
	assert.Equal(t, 2, d.count(), "the 204 must be cached")
}

func TestForgetDropsOneCachedAnswer(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent, http.StatusNoContent, http.StatusNotFound)
	clock := newClock()
	v := newValidator(t, d.server.URL, clock.now)

	first := ourToken("bench:upload id:42")
	second := ourToken("bench:upload id:43")

	_, err := v.Validate(context.Background(), first, "bench:upload")
	require.NoError(t, err)
	_, err = v.Validate(context.Background(), second, "bench:upload")
	require.NoError(t, err)
	require.Equal(t, 2, d.count())

	v.Forget(42)

	// 42 is asked again — and the daemon has moved on to answering 404.
	_, err = v.Validate(context.Background(), first, "bench:upload")
	assert.True(t, errors.Is(err, ErrRevoked), "got %v", err)
	assert.Equal(t, 3, d.count())

	// 43 was not forgotten and is still answered from the cache.
	_, err = v.Validate(context.Background(), second, "bench:upload")
	assert.NoError(t, err)
	assert.Equal(t, 3, d.count())

	v.Forget(9999) // forgetting what was never cached is a no-op
}

// The cache must not be a leak in a process that runs for months. The bound is
// a sweep of the expired entries, then — if that was not enough — dropping the
// lot, because every entry costs exactly one round trip to rebuild.
func TestTheCacheIsBounded(t *testing.T) {
	clock := newClock()
	v := newValidator(t, "https://tokens.srht.bigb.es", clock.now)

	for id := 1; id <= maxCacheEntries*2; id++ {
		v.remember(id, true)
	}

	v.mu.Lock()
	size := len(v.cache)
	v.mu.Unlock()
	assert.LessOrEqual(t, size, maxCacheEntries, "the cache must not grow without limit")
	assert.NotZero(t, size, "and it must still be a cache afterwards")
}

// A service holds one Validator and every request handler goes through it, so
// "safe for concurrent use" is a requirement rather than a nicety. Worth running
// under -race.
func TestValidatorIsSafeForConcurrentUse(t *testing.T) {
	d := newDaemon(t, http.StatusNoContent)
	v := newValidator(t, d.server.URL, nil)

	tokens := []string{
		ourToken("bench:upload id:42"),
		ourToken("bench:upload id:43"),
		ourToken("bench:upload"),
	}

	var wg sync.WaitGroup
	for i := 0; i < 32; i++ {
		wg.Add(1)
		go func(i int) {
			defer wg.Done()
			for j := 0; j < 8; j++ {
				_, err := v.Validate(context.Background(), tokens[(i+j)%len(tokens)], "bench:upload")
				assert.NoError(t, err)
			}
			v.Forget(42)
		}(i)
	}
	wg.Wait()
}

M go.mod => go.mod +8 -0
@@ 9,7 9,15 @@ require (
)

require (
	git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 // indirect
	github.com/99designs/gqlgen v0.17.36 // indirect
	github.com/Masterminds/squirrel v1.5.4 // indirect
	github.com/davecgh/go-spew v1.1.1 // indirect
	github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee // indirect
	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect
	github.com/lib/pq v1.10.9 // indirect
	github.com/pmezard/go-difflib v1.0.0 // indirect
	github.com/vektah/gqlparser/v2 v2.5.8 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect
)

M go.sum => go.sum +43 -1
@@ 1,13 1,55 @@
git.sr.ht/~sircmpwn/getopt v0.0.0-20191230200459-23622cc906b3/go.mod h1:wMEGFFFNuPos7vHmWXfszqImLppbc0wEhh6JBfJIUgw=
git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9 h1:Ahny8Ud1LjVMMAlt8utUFKhhxJtwBAualvsbc/Sk7cE=
git.sr.ht/~sircmpwn/go-bare v0.0.0-20210406120253-ab86bc2846d9/go.mod h1:BVJwbDfVjCjoFiKrhkei6NdGcZYpkDkdyCdg1ukytRA=
github.com/99designs/gqlgen v0.17.36 h1:u/o/rv2SZ9s5280dyUOOrkpIIkr/7kITMXYD3rkJ9go=
github.com/99designs/gqlgen v0.17.36/go.mod h1:6RdyY8puhCoWAQVr2qzF2OMVfudQzc8ACxzpzluoQm4=
github.com/DATA-DOG/go-sqlmock v1.5.0 h1:Shsta01QNfFxHCfpW6YH2STWB0MudeXXEWMr20OEh60=
github.com/DATA-DOG/go-sqlmock v1.5.0/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM=
github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM=
github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10=
github.com/agnivade/levenshtein v1.1.1/go.mod h1:veldBMzWxcCG2ZvUTKD2kJNRdCk5hVbJomOvKkmgYbo=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee h1:v6Eju/FhxsACGNipFEPBZZAzGr1F/jlRQr1qiBw2nEE=
github.com/fernet/fernet-go v0.0.0-20211208181803-9f70042a33ee/go.mod h1:2H9hjfbpSMHwY503FclkV/lZTBh2YlOmLLSda12uL8c=
github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw=
github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk=
github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8=
github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec h1:DGmKwyZwEB8dI7tbLt/I/gQuP559o/0FrAkHKlQM/Ks=
github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec/go.mod h1:owBmyHYMLkxyrugmfwE/DLJyW8Ro9mkphwuVErQ0iUw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
github.com/vektah/gqlparser/v2 v2.5.8 h1:pm6WOnGdzFOCfcQo9L3+xzW51mKrlwTEg4Wr7AH1JW4=
github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swDbpDqjJmk6ME=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=