Skip to content
quarry Artifact [bcaabb8120]
Login

Artifact [bcaabb8120]

Artifact bcaabb81208de006ae338aaad5c5f4bf032f16a596e773bae5bdce55777d9f8f:


package api

import (
	"context"
	"encoding/json"
	"fmt"
	"log"
	"reflect"
	"strings"

	"craftdesign.group/quarry/internal/ops"
)

// An agent never owns a repository (RFC 0021, amended 2026-10-21): it
// serves the person who answers for it, and may do everything they can
// except the deed's own acts. Those it may ASK for. An agent that asks
// to delete, un-serve or hand on a repository its owner holds gets a
// request back instead of a refusal; the owner approves or denies it,
// and approving runs exactly the request, as the owner's own act.

// signOffOps are the deed acts an agent may ask its owner for.
var signOffOps = map[string]bool{"repo.delete": true, "repo.remove": true, "repo.claim": true}

// SignOffRequest is one request as the agent and the owner see it.
type SignOffRequest struct {
	ID        string `json:"id"`
	Agent     string `json:"agent"`
	Owner     string `json:"owner"`
	Op        string `json:"op"`
	Repo      string `json:"repo"`
	Code      string `json:"-"`
	Input     string `json:"-"`
	Summary   string `json:"summary"`
	State     string `json:"state"`
	Requested string `json:"requested"`
	Decided   string `json:"decided,omitempty"`
	Err       string `json:"err,omitempty"`
}

// SignOffStore keeps the requests. RequestSignOff returns the open one
// when the same request is already waiting; DecideSignOff closes a
// pending request exactly once, and errors on any other.
type SignOffStore interface {
	RequestSignOff(SignOffRequest) (SignOffRequest, error)
	SignOffsFor(owner string, limit int) ([]SignOffRequest, error)
	SignOffByID(id string) (SignOffRequest, bool, error)
	DecideSignOff(id, owner string, approve bool) (SignOffRequest, error)
	FailSignOff(id, why string) error
}

// signOffOwner is the owner an agent would ask about repo: the deed
// holder, when they are the person answering for the caller (its
// operator; for a forge agent, its answerable admin). "" when the
// caller is not an agent, or the repository is not its owner's.
func (d Deps) signOffOwner(ctx context.Context, repo string) string {
	if d.SignOffs == nil || d.Owners == nil {
		return ""
	}
	who := ops.User(ctx)
	acct, ok := d.accountNamed(who)
	if !ok || acct.Operator == "" || acct.Disabled != "" {
		return ""
	}
	answers := acct.Operator
	if answers == forgeOperator {
		answers = acct.Answerable
	}
	owner, err := d.Owners.OwnerOf(repo)
	if err != nil || owner == "" || owner != answers {
		return ""
	}
	return owner
}

// askOwner turns an agent's deed act on its owner's repository into a
// request for their sign-off. asked is false for every other caller,
// who goes on to the act's ordinary gate.
func (d Deps) askOwner(ctx context.Context, op, repo string, in any, summary string) (req *SignOffRequest, asked bool, err error) {
	owner := d.signOffOwner(ctx, repo)
	if owner == "" {
		return nil, false, nil
	}
	r, err := d.repo(repo)
	if err != nil {
		return nil, true, err
	}
	raw, err := json.Marshal(in)
	if err != nil {
		return nil, true, err
	}
	agent := ops.User(ctx)
	s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: owner, Op: op, Repo: repo,
		Code: r.Code, Input: string(raw), Summary: summary})
	if err != nil {
		return nil, true, err
	}
	d.attend(owner, AttentionEvent{Kind: "signoff", Repo: repo, From: agent, Summary: summary, Link: "/me#signoffs"})
	return &s, true, nil
}

type signOffListIn struct{}

type signOffListOut struct {
	Requests []SignOffRequest `json:"requests"`
}

type signOffDecideIn struct {
	ID      string `json:"id" validate:"required,max=64"`
	Approve bool   `json:"approve"`
}

type signOffDecideOut struct {
	Request SignOffRequest `json:"request"`
}

// registerSignOff is the owner's half: the requests waiting for them,
// and the decision.
func registerSignOff(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[signOffListIn, signOffListOut]{
		Name:  "signoff.list",
		Doc:   "The requests your agents have made for your sign-off on a deed act (deleting, un-serving or handing on a repository you own): waiting ones first, then the most recently decided.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/signoffs",
		Run: func(ctx context.Context, in signOffListIn) (signOffListOut, error) {
			if d.SignOffs == nil {
				return signOffListOut{Requests: []SignOffRequest{}}, nil
			}
			reqs, err := d.SignOffs.SignOffsFor(ops.User(ctx), 50)
			if err != nil {
				return signOffListOut{}, err
			}
			if reqs == nil {
				reqs = []SignOffRequest{}
			}
			return signOffListOut{Requests: reqs}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[signOffDecideIn, signOffDecideOut]{
		Name: "signoff.decide",
		Doc: "Approve or deny a request waiting for your sign-off. Approving runs exactly what the agent asked, as your own act, " +
			"after checking that the repository, its owner and the agent are what they were when it asked. " +
			"An agent cannot decide, and a token limited to other repositories cannot either.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/signoffs/decide",
		Run: func(ctx context.Context, in signOffDecideIn) (signOffDecideOut, error) {
			if d.SignOffs == nil {
				return signOffDecideOut{}, fmt.Errorf("this forge keeps no sign-offs: %w", ops.ErrUnavailable)
			}
			who := ops.User(ctx)
			if op := d.operatorOf(who); op != "" {
				return signOffDecideOut{}, fmt.Errorf("%s is an agent; a sign-off is its owner's: %w", who, ops.ErrForbidden)
			}
			s, found, err := d.SignOffs.SignOffByID(in.ID)
			if err != nil {
				return signOffDecideOut{}, err
			}
			if !found || s.Owner != who {
				return signOffDecideOut{}, fmt.Errorf("no sign-off request %q: %w", in.ID, ops.ErrNotFound)
			}
			if tok, byToken := ops.TokenOf(ctx); byToken && (tok.Limited || !tok.MayAt(s.Repo, ops.Admin)) {
				return signOffDecideOut{}, fmt.Errorf("this token does not reach %s; decide from a session or a token that does: %w", s.Repo, ops.ErrForbidden)
			}
			if in.Approve {
				// Checked before the request is closed, so a request that
				// can no longer run says why and stays answerable.
				if err := d.signOffStillHolds(s); err != nil {
					return signOffDecideOut{}, err
				}
			}
			s, err = d.SignOffs.DecideSignOff(in.ID, who, in.Approve)
			if err != nil {
				return signOffDecideOut{}, fmt.Errorf("%v: %w", err, ops.ErrInvalid)
			}
			if !in.Approve {
				return signOffDecideOut{Request: s}, nil
			}
			if err := d.runSignedOff(ctx, r, s); err != nil {
				if ferr := d.SignOffs.FailSignOff(s.ID, err.Error()); ferr != nil {
					log.Printf("signoff %s: recording failure: %v", s.ID, ferr)
				}
				s.State, s.Err = "failed", err.Error()
				return signOffDecideOut{Request: s}, err
			}
			if d.Acts != nil {
				if err := d.Acts.RecordForgeAct(s.Agent+" (signed off by "+s.Owner+")", s.Op, s.Repo, s.Owner); err != nil {
					logForgeActFailure(s.Op, s.Repo, err)
				}
			}
			return signOffDecideOut{Request: s}, nil
		},
	})
}

// signOffStillHolds checks, at approval, what the request was made
// about: the same repository (by project code, so a name deleted and
// made again is not it), still the approver's, and an agent that still
// exists, is open, and still answers to them.
func (d Deps) signOffStillHolds(s SignOffRequest) error {
	stale := func(why string) error {
		return fmt.Errorf("this request can no longer run: %s: %w", why, ops.ErrInvalid)
	}
	if !signOffOps[s.Op] {
		return stale("it asks for something that needs no sign-off")
	}
	r, err := d.repo(s.Repo)
	if err != nil || r.Code != s.Code {
		return stale(s.Repo + " is not the repository it was asked about")
	}
	if owner, err := d.Owners.OwnerOf(s.Repo); err != nil || owner != s.Owner {
		return stale(s.Repo + " is no longer yours")
	}
	acct, ok := d.accountNamed(s.Agent)
	if !ok || acct.Disabled != "" {
		return stale(s.Agent + " is gone or shut")
	}
	answers := acct.Operator
	if answers == forgeOperator {
		answers = acct.Answerable
	}
	if answers != s.Owner {
		return stale(s.Agent + " no longer answers to you")
	}
	return nil
}

// runSignedOff runs the recorded request through the op itself, as the
// owner: the agent's token may be long revoked, and under the owner
// every deed gate the op has passes or refuses honestly.
func (d Deps) runSignedOff(ctx context.Context, r *ops.Registry, s SignOffRequest) error {
	desc, ok := r.Lookup(s.Op)
	if !ok {
		return fmt.Errorf("no operation %q: %w", s.Op, ops.ErrInvalid)
	}
	in := desc.NewIn()
	dec := json.NewDecoder(strings.NewReader(s.Input))
	dec.DisallowUnknownFields()
	if err := dec.Decode(in); err != nil {
		return fmt.Errorf("the request's input no longer reads: %v: %w", err, ops.ErrInvalid)
	}
	if repoOf(in) != s.Repo {
		return fmt.Errorf("the request names another repository: %w", ops.ErrInvalid)
	}
	_, err := r.Invoke(ctx, s.Op, reflect.ValueOf(in).Elem().Interface())
	return err
}