package service
import (
"errors"
"fmt"
"net/url"
"path/filepath"
"strings"
"time"
"github.com/vaughan0/go-ini"
"sourcecraft.dev/bigbes/sr-ht-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/db"
)
// ConfigSection is our config section, the literal "spec.sr.ht". The ".sr.ht"
// suffix is what puts us in the nav network list and what other services look
// us up by, so it is taken from authn rather than re-spelled here.
const ConfigSection = authn.ConfigSection
// Sentinel errors. Callers compare with errors.Is. Every error leaving this
// package carries one of these in addition to the gitx or db class it came
// from, so api/, mcpsrv/, graph/ and web/ can map a failure to a status code
// without importing the layers below service.
var (
// ErrIncompleteConfig is returned by LoadConfig when the instance config
// omits a key this service needs. It is a startup failure: the daemon
// should print it and exit rather than serve a request that will fail
// deeper down with a worse message.
ErrIncompleteConfig = errors.New("service: incomplete configuration")
// ErrNotFound marks a missing space, revision, document or row.
ErrNotFound = errors.New("service: not found")
// ErrBadReadRev rejects a revision that is not an immutable object name.
ErrBadReadRev = errors.New("service: revision must be an object name")
// ErrSpaceExists marks a create that would clobber an existing space,
// either its repository on disk or its row.
ErrSpaceExists = errors.New("service: space already exists")
// ErrProjectExists marks a create that would clobber an existing project.
ErrProjectExists = errors.New("service: project already exists")
// ErrPushRejected marks a push the update hook must refuse. Type-assert to
// *PushRejection for the message to print to the pushing client.
ErrPushRejected = errors.New("service: push rejected")
// ErrForbidden marks a write attempted by a principal that may not make it:
// a non-agent trying to propose, the write plane's 403. Proposing is an
// agent-only act — the human write path is native receive-pack — so this is
// a refusal of the principal, not of the request.
ErrForbidden = errors.New("service: forbidden")
// ErrInvalid marks a write the principal may make but the request itself is
// malformed: a document whose frontmatter does not parse or fails the
// schema, a path outside the tree, two uploads claiming one id, an open with
// no title, a write with no documents. It is the write plane's 400/422, kept
// apart from ErrForbidden so a surface does not answer "bad document" with
// "you are not allowed" — a distinction that matters most to the agent that
// has to fix and retry.
ErrInvalid = errors.New("service: invalid request")
// ErrStale marks a proposal whose base moved under it: the write plane's
// 409. It wraps a gitx staleness reason, so a caller that wants to tell the
// agent which document went stale type-asserts to *gitx.StaleError; one that
// only needs the status code matches this. Refetch the approved head and
// re-propose.
ErrStale = errors.New("service: proposal base is stale")
// ErrAlreadyMerged marks a merge of a proposal whose commits are already an
// ancestor of the approved head — it merged, and this is a repeat. It is a
// distinct answer from a staleness 409 (see the design's "already-merged
// proposals need an ancestry check, not a staleness check"): the proposal
// succeeded, so the caller should read the outcome rather than re-propose.
ErrAlreadyMerged = errors.New("service: proposal already merged")
// ErrProposalNotOpen marks a write to, or a resolution of, a proposal that
// has already merged or been rejected. The state machine is terminal in one
// direction, so this is never a retryable condition.
ErrProposalNotOpen = errors.New("service: proposal is not open")
)
// Config is everything service/ needs from the instance config.ini. It is a
// value so the daemon can build it once, log it, and hand copies around.
type Config struct {
// Repos is [spec.sr.ht] repos: the root under which every space's bare
// repository lives as <repos>/~<owner>/<space>. Must be absolute — gitx
// keys its per-space write lock by directory, and two spellings of one
// directory would be two locks that do not exclude each other.
Repos string
// Cache is [spec.sr.ht] cache: the bleve index and the blob-sha-keyed
// render cache. Pure cache, safe to delete at any time; Phase 2 owns what
// goes in it, Phase 1 only insists it is configured and absolute.
Cache string
// Origin is [spec.sr.ht] origin, without a trailing slash. It is the base
// of every proposal URL an agent hands a human, and the host half of it is
// where authn derives the synthetic agent mailbox from.
Origin string
// ConnectionString is [spec.sr.ht] connection-string. This package does not
// open the pool — the daemon does, so core-go's database middleware and the
// reconciler share one — but a service whose DSN is missing cannot work at
// all, so it is validated here with the rest.
ConnectionString string
// Instance carries [sr.ht] owner-name / owner-email and the derived agent
// mailbox: the identities stamped on every commit this service makes.
Instance authn.Instance
}
// LoadConfig reads and validates every key service/ needs out of the instance
// config, reporting all missing keys at once.
//
// Reporting them together is deliberate, and copied from compare.sr.ht's
// validateConfig: an operator fixes the config in one pass instead of
// discovering each gap on a separate restart.
//
// It validates only what this package reads. The daemon is still responsible
// for the keys core-go itself fatals on — [sr.ht] network-key and [webhooks]
// private-key, both required by crypto.InitCrypto — because those belong to
// server.New's contract, not to ours, and duplicating them here would give the
// instance two lists to keep in sync.
func LoadConfig(conf ini.File) (Config, error) {
var missing []string
get := func(section, key string) string {
v, ok := conf.Get(section, key)
if v = strings.TrimSpace(v); !ok || v == "" {
missing = append(missing, fmt.Sprintf("[%s] %s", section, key))
return ""
}
return v
}
cfg := Config{
Repos: get(ConfigSection, "repos"),
Cache: get(ConfigSection, "cache"),
Origin: get(ConfigSection, "origin"),
ConnectionString: get(ConfigSection, "connection-string"),
}
// One canonical spelling of the origin, so a proposal URL built from it
// never grows a double slash and never differs between two callers.
cfg.Origin = strings.TrimSuffix(cfg.Origin, "/")
// Read for their presence only; authn.InstanceFromConfig is what turns them
// into identities, and it must not be reached with a key missing or it
// reports one gap where we want to report all of them.
get("sr.ht", "owner-name")
get("sr.ht", "owner-email")
if len(missing) > 0 {
return Config{}, fmt.Errorf("%w; missing required keys:\n\t%s",
ErrIncompleteConfig, strings.Join(missing, "\n\t"))
}
inst, err := authn.InstanceFromConfig(conf)
if err != nil {
return Config{}, fmt.Errorf("%w: %w", ErrIncompleteConfig, err)
}
cfg.Instance = inst
if err := cfg.Validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
// Validate reports whether the configuration is usable. It is exported so a
// daemon that builds a Config from somewhere other than an ini file — a test,
// or a future flag — is held to the same rules.
func (c Config) Validate() error {
var problems []string
requireAbs := func(key, path string) {
switch {
case path == "":
problems = append(problems, fmt.Sprintf("[%s] %s is empty", ConfigSection, key))
case !filepath.IsAbs(path):
problems = append(problems, fmt.Sprintf("[%s] %s must be an absolute path, got %q",
ConfigSection, key, path))
}
}
requireAbs("repos", c.Repos)
requireAbs("cache", c.Cache)
switch u, err := url.Parse(c.Origin); {
case c.Origin == "":
problems = append(problems, fmt.Sprintf("[%s] origin is empty", ConfigSection))
case err != nil:
problems = append(problems, fmt.Sprintf("[%s] origin %q is not a URL: %v",
ConfigSection, c.Origin, err))
case u.Hostname() == "":
problems = append(problems, fmt.Sprintf("[%s] origin %q has no host", ConfigSection, c.Origin))
case u.Scheme != "http" && u.Scheme != "https":
problems = append(problems, fmt.Sprintf("[%s] origin %q must be http or https",
ConfigSection, c.Origin))
}
if c.ConnectionString == "" {
problems = append(problems, fmt.Sprintf("[%s] connection-string is empty", ConfigSection))
}
if err := c.Instance.Validate(); err != nil {
problems = append(problems, err.Error())
}
if len(problems) > 0 {
return fmt.Errorf("%w:\n\t%s", ErrIncompleteConfig, strings.Join(problems, "\n\t"))
}
return nil
}
// Service is the orchestration layer. One per daemon; safe for concurrent use.
type Service struct {
cfg Config
q db.Querier
store *db.Store
tokens *TokenStore
resolver *authn.Resolver
// grace is how long a proposal row with no branch is left alone before the
// reconciler deletes it. See DefaultReconcileGrace.
grace time.Duration
// now is the clock, injectable so the reconciler's grace window is
// testable without sleeping.
now func() time.Time
}
// New assembles a Service over a database handle.
//
// q is normally the *sql.DB the daemon opened from Config.ConnectionString and
// handed to core-go's database middleware, so request-scoped queries and the
// reconciler's background queries share one pool. A nil handle is refused
// rather than tolerated: every agent token would then resolve as unknown, which
// looks exactly like a mass revocation and is a miserable thing to debug.
func New(cfg Config, q db.Querier) (*Service, error) {
if err := cfg.Validate(); err != nil {
return nil, err
}
if q == nil {
return nil, errors.New("service: nil database handle")
}
store := db.NewStore(q)
tokens := NewTokenStore(store)
resolver, err := authn.NewResolver(cfg.Instance.OwnerName, tokens)
if err != nil {
return nil, fmt.Errorf("service: build resolver: %w", err)
}
return &Service{
cfg: cfg,
q: q,
store: store,
tokens: tokens,
resolver: resolver,
grace: DefaultReconcileGrace,
now: time.Now,
}, nil
}
// Config returns the configuration this service was built from.
func (s *Service) Config() Config { return s.cfg }
// ReposRoot is [spec.sr.ht] repos, the root every space's bare repository lives
// under.
func (s *Service) ReposRoot() string { return s.cfg.Repos }
// CacheDir is [spec.sr.ht] cache. Phase 2 owns its contents.
func (s *Service) CacheDir() string { return s.cfg.Cache }
// Origin is our external origin, without a trailing slash.
func (s *Service) Origin() string { return s.cfg.Origin }
// Instance returns the commit identities: the instance owner and the derived
// agent mailbox.
func (s *Service) Instance() authn.Instance { return s.cfg.Instance }
// Store exposes the persistence layer. It is here for the daemon's own
// bookkeeping (token minting, migrations tooling); handlers above this layer
// call Service methods instead, because the dependency rule says nothing above
// service/ may touch db/ directly.
func (s *Service) Store() *db.Store { return s.store }
// Resolver turns a request into an authn.Principal. The daemon installs
// Resolver().Middleware() on its router.
func (s *Service) Resolver() *authn.Resolver { return s.resolver }