Skip to content
quarry Artifact [a2dcad2d8f]
Login

Artifact [a2dcad2d8f]

Artifact a2dcad2d8f9f2a273bd42b0388a04cd57d5de628f0bae1519c00c826ddd5dba4:


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.

// deedOps are the deed acts: a request for one goes to the repository's
// deed holder, and approval checks the deed is still theirs.
var deedOps = map[string]bool{"repo.delete": true, "repo.remove": true, "repo.claim": true}

// notAskable is what an agent may never ask its person for (RFC 0027,
// What may be asked): deciding requests and changing who answers for
// an agent. The person's own credentials, sessions and account are
// askableFor's other half.
var notAskable = map[string]bool{
	"signoff.decide": true, "signoff.list": true, "signoff.get": true,
	"account.agent": true, "account.answerable": true,
}

// askableFor reports whether an agent may ask person for op with this
// input: anything the person may do (checked when it runs, as them),
// except the exclusions. Every me.* op is the person's own account,
// credentials, sessions or devices; an account op aimed at the person
// themselves is too.
func askableFor(op, person string, in any) bool {
	if notAskable[op] || strings.HasPrefix(op, "me.") {
		return false
	}
	switch op {
	case "account.delete", "account.disable", "account.reset", "account.revoke", "account.email.set":
		return nameOf(in) != person
	}
	return true
}

// nameOf is an input's Name field, the account an account op names.
func nameOf(in any) string {
	v := reflect.ValueOf(in)
	if v.Kind() == reflect.Ptr {
		v = v.Elem()
	}
	if v.Kind() != reflect.Struct {
		return ""
	}
	if f := v.FieldByName("Name"); f.IsValid() && f.Kind() == reflect.String {
		return f.String()
	}
	return ""
}

// 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"`
	// Reason is the agent's own line on why it asks (RFC 0027).
	Reason string `json:"reason,omitempty"`
	// Result is what the approved call answered, as JSON, without the
	// fields the op marks secret; Secret is those fields, for the
	// asking agent once. Neither is part of the request as listed:
	// signoff.get hands them out.
	Result string `json:"-"`
	Secret string `json:"-"`
}

// 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
	FinishSignOff(id, result, secret string) error
	TakeSignOffSecret(id, agent string) (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/" + s.ID})
	// And to the owner wherever they are (dmestas: "I probably need an
	// email or sms for sign offs"): the request waits on them, and the
	// attention stream only reaches an open tab.
	d.fireNotify(NotifyEvent{
		Workflow: "quarry-signoff", To: owner,
		Payload: map[string]any{
			"repo": repo, "from": agent, "summary": summary, "surface": "signoff",
			"link": d.link("/me/signoffs/" + s.ID),
		},
		TxnID: "signoff:" + s.ID,
	})
	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"`
}

type signOffGetIn struct {
	ID string `json:"id" validate:"required,max=64"`
}

type signOffGetOut struct {
	Request SignOffRequest `json:"request"`
	// Result is what the approved call answered, without its secret
	// fields; Secret is those fields, given to the asking agent on its
	// first read after approval and never again.
	Result json.RawMessage `json:"result,omitempty"`
	Secret json.RawMessage `json:"secret,omitempty"`
}

// registerSignOff is the owner's half: the requests waiting for them,
// and the decision; and the agent's: reading what became of one.
func registerSignOff(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[signOffGetIn, signOffGetOut]{
		Name: "signoff.get",
		Doc: "One request for sign-off, as the agent that asked or the person it asked sees it: its state and, once approved, " +
			"what the call answered. Fields the call marks secret (a minted token) go to the asking agent alone, on its first read, and are then forgotten.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/signoffs/{id}",
		Run: func(ctx context.Context, in signOffGetIn) (signOffGetOut, error) {
			if d.SignOffs == nil {
				return signOffGetOut{}, fmt.Errorf("this forge keeps no sign-offs: %w", ops.ErrUnavailable)
			}
			who := ops.User(ctx)
			s, found, err := d.SignOffs.SignOffByID(in.ID)
			if err != nil {
				return signOffGetOut{}, err
			}
			if !found || (who != s.Agent && who != s.Owner) {
				return signOffGetOut{}, fmt.Errorf("no sign-off request %q: %w", in.ID, ops.ErrNotFound)
			}
			out := signOffGetOut{Request: s}
			if s.Result != "" {
				out.Result = json.RawMessage(s.Result)
			}
			if who == s.Agent && s.Secret != "" {
				sec, err := d.SignOffs.TakeSignOffSecret(s.ID, who)
				if err != nil {
					return signOffGetOut{}, err
				}
				if sec != "" {
					out.Secret = json.RawMessage(sec)
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[signOffListIn, signOffListOut]{
		Name:  "signoff.list",
		Doc:   "The requests your agents have made for your sign-off: anything you may do that they may not (RFC 0027), deed acts on your repositories among them. Waiting ones first, then the most recently decided; a request waits seven days.",
		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 s.Repo != "" {
		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 deedOps[s.Op] {
		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 s.Repo != "" && repoOf(in) != s.Repo {
		return fmt.Errorf("the request names another repository: %w", ops.ErrInvalid)
	}
	arg := reflect.ValueOf(in).Elem().Interface()
	if !askableFor(s.Op, s.Owner, arg) {
		return fmt.Errorf("%s is not something an agent may ask for: %w", s.Op, ops.ErrInvalid)
	}
	out, err := r.Invoke(ctx, s.Op, arg)
	if err != nil {
		return err
	}
	result, secret, err := splitSecret(out)
	if err != nil {
		return err
	}
	return d.SignOffs.FinishSignOff(s.ID, result, secret)
}

// splitSecret renders an op's answer as two JSON objects: the answer
// without the fields tagged secret:"once", and those fields alone (""
// when there are none). The secret half goes to the asking agent once
// and never to a page, an email or the journal (RFC 0027, Results).
func splitSecret(out any) (result, secret string, err error) {
	raw, err := json.Marshal(out)
	if err != nil {
		return "", "", err
	}
	v := reflect.ValueOf(out)
	for v.Kind() == reflect.Ptr {
		v = v.Elem()
	}
	if v.Kind() != reflect.Struct {
		return string(raw), "", nil
	}
	var fields map[string]json.RawMessage
	if err := json.Unmarshal(raw, &fields); err != nil {
		return string(raw), "", nil
	}
	hidden := map[string]json.RawMessage{}
	t := v.Type()
	for i := 0; i < t.NumField(); i++ {
		f := t.Field(i)
		if f.Tag.Get("secret") != "once" {
			continue
		}
		key, _, _ := strings.Cut(f.Tag.Get("json"), ",")
		if key == "" {
			key = f.Name
		}
		if val, ok := fields[key]; ok {
			hidden[key] = val
			delete(fields, key)
		}
	}
	if len(hidden) == 0 {
		return string(raw), "", nil
	}
	pub, err := json.Marshal(fields)
	if err != nil {
		return "", "", err
	}
	sec, err := json.Marshal(hidden)
	if err != nil {
		return "", "", err
	}
	return string(pub), string(sec), nil
}