package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"craftdesign.group/quarry/internal/ops"
)
// RFC 0027: an agent refused for want of authority its person holds may
// ask them instead. The transports find this through the registry
// (ops.Registry.AskerOf) and call it only when the caller sent
// Quarry-Ask; it decides whether this caller may ask for this call,
// files the request and tells the person.
// personFor is who answers for agent: a personal agent's operator, a
// forge agent's answerable admin. "" for a person, for an agent whose
// person is gone or shut, or for an agent that answers to another
// agent.
func (d Deps) personFor(agent string) string {
acct, ok := d.accountNamed(agent)
if !ok || acct.Operator == "" || acct.Disabled != "" {
return ""
}
person := acct.Operator
if person == forgeOperator {
person = acct.Answerable
}
p, ok := d.accountNamed(person)
if !ok || p.Disabled != "" || p.Operator != "" {
return ""
}
return person
}
// askPerson is the forge's ops.Asker.
func (d Deps) askPerson(ctx context.Context, desc *ops.Descriptor, repo string, in any, reason string) (any, bool, error) {
if d.SignOffs == nil {
return nil, false, nil
}
agent := ops.User(ctx)
person := d.personFor(agent)
if person == "" || !askableFor(desc.Name, person, in) {
return nil, false, nil
}
if !d.agentAsks(agent) {
return nil, true, fmt.Errorf("%s takes no requests from %s: %w", person, agent, ops.ErrForbidden)
}
// The agent's own scope bounds what it may ask about: a token
// limited to repositories asks only about those, and nothing
// forge-wide.
if tok, byToken := ops.TokenOf(ctx); byToken {
if repo == "" && tok.Limited {
return nil, false, nil
}
if repo != "" && tok.Reaches != nil && !tok.Reaches(repo) {
return nil, false, nil
}
}
// And the person must hold what the call needs. Judged without the
// agent's token: these are the person's own letters, or their site
// flag for an operator op. An op that names no letters is judged
// when it runs, as them.
if desc.Caps != "" {
holds := d.CapsFor != nil && d.CapsFor(person, repo).HasAny(desc.Caps)
if !holds && (desc.Operator || desc.Scope == ops.ScopeFleet) {
holds = d.isSiteAdmin(context.Background(), person)
}
if !holds {
return nil, false, nil
}
}
// A repository the agent cannot read is answered as one that does
// not exist, by asking as by refusing.
if repo != "" && d.CanRead != nil && !d.CanRead(ctx, repo) {
return nil, false, nil
}
code := ""
if repo != "" {
r, err := d.repo(repo)
if err != nil {
return nil, false, nil
}
code = r.Code
}
raw, err := json.Marshal(in)
if err != nil {
return nil, true, err
}
reason = strings.TrimSpace(reason)
if len(reason) > 200 {
reason = reason[:200]
}
summary := describeCall(desc.Name, repo, in)
s, err := d.SignOffs.RequestSignOff(SignOffRequest{Agent: agent, Owner: person, Op: desc.Name, Repo: repo,
Code: code, Input: string(raw), Summary: summary, Reason: reason})
if err != nil {
if errors.Is(err, ErrTooManySignOffs) {
return nil, true, fmt.Errorf("%s already has as many requests waiting for %s as it may: %w", agent, person, ops.ErrInvalid)
}
return nil, true, err
}
d.tellPerson(s)
return askedOut{SignOff: s}, true, nil
}
// ErrTooManySignOffs is a SignOffStore's refusal of a request from an
// agent that already has as many waiting as it may (RFC 0027, Limits).
var ErrTooManySignOffs = errors.New("too many sign-off requests waiting")
// askedOut is what an agent that asked is answered with.
type askedOut struct {
SignOff SignOffRequest `json:"signoff"`
}
// describeCall names a call for the person in words that carry no
// secrets: the op, and the repository and account it names. Never the
// input itself, which may hold a password.
func describeCall(op, repo string, in any) string {
s := op
if name := nameOf(in); name != "" && name != repo {
s += " for " + name
}
if repo != "" {
s += " on " + repo
}
return s
}
// tellPerson reaches the person wherever they are: the page's attention
// stream, push and email, as every sign-off does.
func (d Deps) tellPerson(s SignOffRequest) {
summary := s.Summary
if s.Reason != "" {
summary += ` ("` + s.Reason + `")`
}
d.attend(s.Owner, AttentionEvent{Kind: "signoff", Repo: s.Repo, From: s.Agent, Summary: summary, Link: "/me/signoffs/" + s.ID})
d.fireNotify(NotifyEvent{
Workflow: "quarry-signoff", To: s.Owner,
Payload: map[string]any{
"repo": s.Repo, "from": s.Agent, "summary": summary, "surface": "signoff",
"link": d.link("/me/signoffs/" + s.ID),
},
TxnID: "signoff:" + s.ID,
})
}
// prefAsk is the agent account's setting that turns asking off: "off",
// or unset for on (RFC 0027, Limits).
const prefAsk = "ask"
// agentAsks reports whether agent may ask its person.
func (d Deps) agentAsks(agent string) bool {
return d.Prefs == nil || d.Prefs.AccountPref(agent, prefAsk) != "off"
}