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 /~/. 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 // ownerUserID caches the id of the owner's "user" row, seeded by // EnsureOwnerUser at startup. Zero until then. It is the user_id the // core-go webhook engine's user-scoped subscriptions FK against and the // UserID the coreauth bridge stamps on the owner's AuthContext. ownerUserID int // 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 }