Skip to content
quarry Artifact [01e5203167]
Login

Artifact [01e5203167]

Artifact 01e5203167ed9474e9b6c5130a092223e78f52aafc287a5fa67587dbea5c85e5:


// agentops.go is RFC 0021's operator surface: a person creates agents
// for themselves, mints and revokes their tokens, and puts them to work
// on repositories they reach. The rules an agent is held to (the
// operator intersection, the deed, the two `a` guards) live in oltp,
// where every path meets them; this file is the doors.
package api

import (
	"context"
	"fmt"
	"log"
	"sort"
	"strings"
	"time"

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

// AgentStore is the optional half of the account seam behind the
// agent.* ops (oltp/agent.go, oltp/token.go). A store without it has
// no agents.
type AgentStore interface {
	// NewAgent counts the operator's agents against perPerson under the
	// lock it inserts with; negative is no cap.
	NewAgent(name, operator string, perPerson int) error
	// NewScopedAgent is NewAgent reaching one team ("team:NAME") or one
	// repository ("repo:NAME") instead of the site (RFC 0026).
	NewScopedAgent(name, operator, scope string, perPerson int) error
	// NewDedicatedAgent makes a repository's ("repo:NAME") or a team's
	// ("team:NAME") own agent, answerable naming its creator.
	NewDedicatedAgent(name, scope, answerable string) error
	// SetSiteOpt records a site admin's opt-in that their personal site
	// agent inherits the site-admin flag (RFC 0026, decision 4).
	SetSiteOpt(operator, agent string) error
	Agents(operator string) ([]oltp.Account, error)
	AgentCount(operator string) (int, error)
	Operator(name string) string
	MintToken(account string, spec oltp.TokenSpec) (value string, tok oltp.Token, err error)
	Tokens(account string) ([]oltp.Token, error)
	RevokeToken(account, id string) error
	RevokeAgentTokens(operator string) (int, error)
	AgentGrant(operator, agent, repo, caps string) (stored string, clipped bool, err error)
	// The inherit switch and dropping a narrowing (RFC 0026).
	SetInherit(operator, agent string, on bool) error
	AgentInheritHere(operator, agent, repo string) error
	// ReissueTokens revokes every token of an account and mints one
	// like the most recently used (RFC 0026, Tokens).
	ReissueTokens(account string) (value string, tok oltp.Token, revoked int, err error)
	ClosedTo(agent string) ([]string, error)
	// The forge's own agents (RFC 0021, Forge agents): made or
	// re-marked with a site admin answerable, and who answers now.
	NewForgeAgent(name, answerable string) error
	SetForgeAgent(name, answerable string) error
	SetAnswerable(name, admin string) error
	Answerable(name string) string
}

// forgeOperator is the reserved operator of a forge agent (RFC 0021,
// Forge agents), which is nobody's agent in the sense these ops mean.
const forgeOperator = "forge"

func (d Deps) agents() (AgentStore, error) {
	if d.Accounts == nil {
		return nil, errNoAccounts()
	}
	as, ok := d.Accounts.(AgentStore)
	if !ok {
		return nil, fmt.Errorf("this forge's account store has no agents: %w", ops.ErrUnavailable)
	}
	return as, nil
}

// agentsAnd is user and every agent acting for them: the ones they
// operate, and the forge agents they answer for. A store without agents
// gives the person alone.
func (d Deps) agentsAnd(user string) []string {
	out := []string{user}
	as, err := d.agents()
	if err != nil {
		return out
	}
	if mine, err := as.Agents(user); err == nil {
		for _, a := range mine {
			out = append(out, a.Name)
		}
	}
	if forge, err := as.Agents(forgeOperator); err == nil {
		for _, a := range forge {
			if as.Answerable(a.Name) == user {
				out = append(out, a.Name)
			}
		}
	}
	return out
}

// operatorOf names who answers for name: "" for a person or an
// unknown name. Read through the account list the other identity
// questions here use (isSiteAdmin), so a fake that lists no operator
// has no agents.
func (d Deps) operatorOf(name string) string {
	if d.Accounts == nil || name == "" {
		return ""
	}
	if as, ok := d.Accounts.(AgentStore); ok {
		return as.Operator(name)
	}
	accts, err := d.Accounts.Accounts()
	if err != nil {
		return ""
	}
	for _, a := range accts {
		if a.Name == name {
			return a.Operator
		}
	}
	return ""
}

// refuseAgent is the refusal in front of every deed act (RFC 0021,
// What an agent may hold): an agent is refused BEFORE the deed is
// consulted, held or unheld, so an agent with `a` on an unclaimed
// repository can neither retire it nor claim it. A forge agent is the
// exception, under its own section's rules.
func (d Deps) refuseAgent(ctx context.Context, verb string) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op != "" && op != forgeOperator {
		return fmt.Errorf("%s is an agent, operated by %s, and an agent never holds a repository's deed, so it cannot %s one: %w",
			who, op, verb, ops.ErrForbidden)
	}
	return nil
}

// refuseForgeAgent is the edge a forge agent holding s stops at (RFC
// 0021, Forge agents: Guards): it may do what a site admin may, except
// make or alter another forge agent or change any account's s flag.
// Those stay with a person, so that a machine cannot multiply machines
// or grant itself a successor. verb is what it was trying to do.
func (d Deps) refuseForgeAgent(ctx context.Context, verb string) error {
	who := ops.User(ctx)
	if d.flaggedAgent(who) {
		return fmt.Errorf("%s is an agent holding the site-admin flag, and such an agent never %s: %w", who, verb, ops.ErrForbidden)
	}
	return nil
}

// flaggedAgent says name is an agent that holds the site-admin flag or
// may: a forge agent, or a person's agent that is a site admin by
// inheritance (RFC 0026, decision 4). Every forge-agent edge applies to
// both: a flag reached by inheritance is no licence a forge agent lacks.
func (d Deps) flaggedAgent(name string) bool {
	if name == "" {
		return false
	}
	if d.operatorOf(name) == forgeOperator {
		return true
	}
	a, ok := d.accountNamed(name)
	return ok && a.AdminByInheritance
}

// refuseForgeAgentOn is refuseForgeAgent for the account-level ops a
// site admin may run against another account (mark, clear, disable,
// delete, reset, email, sessions, grants): a forge agent caller is
// refused when the target is another forge agent or holds s. Marking
// strips the flag and the password, disabling ends the sessions, a
// reset or a new address takes the account: each is a way for a
// machine to lock a person out or alter a peer, which is what the
// edge exists to stop.
func (d Deps) refuseForgeAgentOn(ctx context.Context, target string) error {
	who := ops.User(ctx)
	if !d.flaggedAgent(who) || target == who {
		return nil
	}
	if d.flaggedAgent(target) {
		return d.refuseForgeAgent(ctx, "alters another forge agent")
	}
	if a, ok := d.accountNamed(target); ok && a.Admin {
		return d.refuseForgeAgent(ctx, "alters a site admin's account")
	}
	return nil
}

// refuseForgeAgentPeer is refuseForgeAgentOn for a grant write
// (account.grant, account.revoke, account.caps): only another forge
// agent is refused here. Raising a site admin's letters cannot lock
// them out, so a forge agent may (zeitgeist grants admins their
// mirrors); that a machine never LOWERS a site admin's reach is judged
// on effective sets by the store's guard, which every grant write
// passes through (oltp/guard.go agentGuard), so a grant that replaces
// a with reader is refused there whatever door it came in by.
func (d Deps) refuseForgeAgentPeer(ctx context.Context, target string) error {
	who := ops.User(ctx)
	if d.flaggedAgent(who) && target != who && d.flaggedAgent(target) {
		return d.refuseForgeAgent(ctx, "alters another forge agent")
	}
	return nil
}

// refuseForgeAgentFlag is refuseForgeAgent at the two ops that write
// an account's s flag on the way in (account.add, account.invite):
// a forge agent may invite a person, never a site admin, and never
// re-invite one in a way that moves the flag either way.
func (d Deps) refuseForgeAgentFlag(ctx context.Context, name string, admin bool) error {
	if !d.flaggedAgent(ops.User(ctx)) {
		return nil
	}
	now, exists := d.accountNamed(name)
	if admin || (exists && now.Admin) {
		return d.refuseForgeAgent(ctx, "changes an account's site-admin flag")
	}
	return nil
}

// refuseAboveCeiling keeps a token from writing a letter it does not
// itself resolve to on repo: a token ceilinged to developer that
// somehow reached a grant writes developer at most. The literal letters
// of the string are judged, so an umbrella is judged as itself.
func (d Deps) refuseAboveCeiling(ctx context.Context, repo, caps string) error {
	tok, ok := ops.TokenOf(ctx)
	if !ok {
		return nil
	}
	for _, l := range caps {
		if !tok.Caps(repo).HasAny(string(l)) {
			return fmt.Errorf("this token does not hold %c on %s and cannot grant it: %w", l, repo, ops.ErrForbidden)
		}
	}
	return nil
}

// dedicatedScope reports whether operator names a repository or a team
// rather than a person: a dedicated agent's (RFC 0026).
func dedicatedScope(operator string) bool {
	return strings.HasPrefix(operator, "repo:") || strings.HasPrefix(operator, "team:")
}

// mayManageScope reports whether the caller may make and manage the
// agents a repository or a team owns: an admin (a) of the repository,
// an owner of the team. A site admin is neither by the flag (#318).
func (d Deps) mayManageScope(ctx context.Context, scope string) bool {
	who := ops.User(ctx)
	if who == "" {
		return false
	}
	if repo, ok := strings.CutPrefix(scope, "repo:"); ok {
		return d.CapsFor != nil && d.CapsFor(who, repo).HasAny("a")
	}
	if team, ok := strings.CutPrefix(scope, "team:"); ok {
		return d.mayManageTeam(ctx, team)
	}
	return false
}

// myAgent answers the agent the caller may manage: their own, or
// anybody's for a site admin. Never a person, whatever the caller: the
// agent ops delete and re-credential accounts, and a person's account
// is not one of them. A forge agent is any site admin's to manage
// (RFC 0021, Forge agents: any site admin controls it), and its own:
// another forge agent's never.
func (d Deps) myAgent(ctx context.Context, as AgentStore, name string) (operator string, err error) {
	who := ops.User(ctx)
	if who == "" {
		return "", fmt.Errorf("managing agents needs a signed-in account: %w", ops.ErrInvalid)
	}
	operator = as.Operator(name)
	if operator == "" {
		return "", fmt.Errorf("%s is not an agent: %w", name, ops.ErrNotFound)
	}
	if dedicatedScope(operator) {
		// A repository's or a team's own agent: its admins or owners
		// manage it, and a site admin may (decision 8).
		if !d.mayManageScope(ctx, operator) && !d.isSiteAdmin(ctx, who) {
			// Not found, not forbidden: the scope may be a private
			// repository, which a refusal must not name.
			return "", fmt.Errorf("%s is not an agent you manage: %w", name, ops.ErrNotFound)
		}
		return operator, nil
	}
	if operator == forgeOperator {
		if name != who {
			if err := d.refuseForgeAgent(ctx, "alters another forge agent"); err != nil {
				return "", err
			}
		}
		if !d.isSiteAdmin(ctx, who) {
			return "", fmt.Errorf("%s is a forge agent, which only a site admin manages: %w", name, ops.ErrForbidden)
		}
		return operator, nil
	}
	if operator != who && !d.isSiteAdmin(ctx, who) {
		return "", fmt.Errorf("%s is %s's agent, not yours: %w", name, operator, ops.ErrForbidden)
	}
	return operator, nil
}

type agentNewIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Scope is where a person's agent reaches: empty for the whole site,
	// team:NAME for one team of yours, repo:NAME for one repository you
	// reach (RFC 0026).
	Scope string `json:"scope" validate:"max=140"`
	// SiteAdmin, from a site admin creating their own site-wide agent,
	// makes it a site admin by inheritance while they are one. It needs
	// Confirm: a leaked token of such an agent can manage the forge.
	SiteAdmin bool `json:"site_admin"`
	Confirm   bool `json:"confirm"`
	// Operator is who answers for the agent: the caller, unless a site
	// admin names somebody else. The reserved name forge makes a forge
	// agent, a site admin's act, which needs Answerable.
	Operator string `json:"operator" validate:"max=140"`
	// Answerable is the site admin who answers for a forge agent;
	// required with operator forge and meaningless otherwise.
	Answerable string `json:"answerable" validate:"max=64"`
}

type accountAnswerableIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Answerable is the site admin who answers for the forge agent
	// from now on.
	Answerable string `json:"answerable" validate:"required,max=64"`
}

type accountAnswerableOut struct {
	Name       string `json:"name"`
	Answerable string `json:"answerable"`
}

type agentOut struct {
	Name     string `json:"name"`
	Operator string `json:"operator"`
}

type agentListIn struct {
	// Operator limits the list to one person's agents; a site admin
	// may name anyone, everyone else gets their own regardless.
	Operator string `json:"operator" validate:"max=140"`
	// Agent lists the agents of whoever owns this one, if the caller may
	// manage it: how a page finds a repository's or a team's agent by
	// name alone.
	Agent string `json:"agent" validate:"max=64"`
}

// agentRepo is one repository an agent holds a string on, with what
// it resolves to there under its operator.
type agentRepo struct {
	Repo string `json:"repo"`
	// Caps is the stored string; Effective is what it resolves to
	// under the operator's letters, which may be less.
	Caps      string `json:"caps"`
	Effective string `json:"effective"`
	Role      string `json:"role"`
}

type agentRow struct {
	Name     string `json:"name"`
	Operator string `json:"operator"`
	// Answerable is the site admin who answers for a forge agent now,
	// "" when nobody does (RFC 0021, Forge agents); "" for a person's
	// agent, whose operator answers.
	Answerable string `json:"answerable,omitempty"`
	// Admin is the site-admin flag, which only a forge agent may hold.
	Admin     bool   `json:"admin,omitempty"`
	Created   string `json:"created"`
	Disabled  string `json:"disabled,omitempty"`
	Suspended string `json:"suspended,omitempty"`
	// Inherit says a person's agent holds its operator's letters
	// wherever Repos has no row for it (RFC 0026); false holds Repos
	// alone.
	Inherit bool `json:"inherit"`
	// Asks says the agent may ask its person for what it may not do
	// (RFC 0027); on unless the person turned it off.
	Asks bool `json:"asks"`
	// Scope is where it reaches: "" the site, team:NAME, repo:NAME, or
	// gone once that team or repository was deleted.
	Scope  string       `json:"scope,omitempty"`
	Tokens []meTokenRow `json:"tokens"`
	Repos  []agentRepo  `json:"repos"`
}

type agentListOut struct {
	Agents []agentRow `json:"agents"`
	Count  int        `json:"count"`
	// CanCreate says whether the caller may make another agent now: a
	// site admin always, anybody else while the forge allows agents and
	// they are under its cap. A page draws its create control from this
	// rather than offering one agent.new would refuse.
	CanCreate bool `json:"can_create"`
	// SiteAdmin says the caller is a site admin, who may make an agent
	// that inherits the flag.
	SiteAdmin bool `json:"site_admin"`
}

type agentNameIn struct {
	Name string `json:"name" validate:"required,max=64"`
}

type agentTokenNewIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	Name  string `json:"name" validate:"required,max=80"`
	// Scopes is a comma-separated list of sync, api, web and site.
	Scopes string `json:"scopes" validate:"required,max=20"`
	// Repos is a comma-separated list of repositories the token is
	// narrowed to; empty means every repository the agent reaches.
	Repos string `json:"repos" validate:"max=2000"`
	// Ceiling is a cap string the token's letters are cut to; empty
	// means the agent's own.
	Ceiling string `json:"ceiling" validate:"max=64"`
	// Days until a fixed expiry, a year at most. Zero, the default, is
	// no date: the token lapses after 90 days unused (RFC 0026).
	Days int `json:"days" validate:"min=0,max=366"`
}

type agentTokenReissueOut struct {
	Agent string `json:"agent"`
	// Revoked counts the tokens revoked; Name and Value are the one
	// minted in their place, "" when the agent never held a token. The
	// value is in this response and nowhere else.
	Revoked int    `json:"revoked"`
	Name    string `json:"name,omitempty"`
	Value   string `json:"value,omitempty" secret:"once"`
}

type agentTokenNewOut struct {
	// Value is the token, readable this once. Asked for through a
	// sign-off (RFC 0027) it goes to the asking agent alone.
	Value string     `json:"value" secret:"once"`
	Token meTokenRow `json:"token"`
}

type agentTokenListIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
}

type agentTokenListOut struct {
	Agent  string       `json:"agent"`
	Tokens []meTokenRow `json:"tokens"`
}

type agentTokenRevokeIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	ID    string `json:"id" validate:"required,max=32"`
}

type agentRevokeAllOut struct {
	Revoked int `json:"revoked"`
}

type agentAskIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	On    bool   `json:"on"`
}

type agentAskOut struct {
	Agent string `json:"agent"`
	Asks  bool   `json:"asks"`
}

type agentGrantIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	Repo  string `json:"repo" validate:"required,max=64"`
	// Role is a preset (reader, developer, maintainer, admin); Caps is
	// any string instead. One of the two; an empty pair revokes.
	Role string `json:"role" validate:"max=20"`
	Caps string `json:"caps" validate:"max=64"`
	// Inherit drops the agent's string here instead, so an inheriting
	// agent holds your letters on the repository again (RFC 0026).
	Inherit bool `json:"inherit"`
}

type agentInheritIn struct {
	Agent string `json:"agent" validate:"required,max=64"`
	On    bool   `json:"on"`
}

type agentInheritOut struct {
	Agent   string `json:"agent"`
	Inherit bool   `json:"inherit"`
}

type agentGrantOut struct {
	Agent string `json:"agent"`
	Repo  string `json:"repo"`
	// Caps is the string stored, clipped to the operator's letters
	// there; Clipped says the clip took something, and Warning says so
	// in words.
	Caps    string `json:"caps"`
	Clipped bool   `json:"clipped"`
	Warning string `json:"warning,omitempty"`
}

// registerAgents adds the operator's doors. Every one refuses a token
// (refuseToken): they manage accounts and credentials, which is what
// a token must not reach.
func registerAgents(r *ops.Registry, d Deps) error {
	if err := ops.Register(r, ops.Op[agentNewIn, agentOut]{
		Name: "agent.new",
		Doc: "Create an agent of your own: an account with no password that holds tokens instead, and never more than you hold (RFC 0021). " +
			"You become its operator; a site admin may name another operator. Refused past the forge's agents-per-person cap, and for everyone but site admins when the cap is zero. " +
			"A site admin may create a forge agent with operator=forge and answerable= naming the site admin who answers for it: no cap, no operator ceiling, listed on /fleet.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents",
		Run: func(ctx context.Context, in agentNewIn) (agentOut, error) {
			if err := refuseToken(ctx, "agent.new"); err != nil {
				return agentOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentOut{}, fmt.Errorf("creating an agent needs a signed-in account: %w", ops.ErrInvalid)
			}
			admin := d.isSiteAdmin(ctx, who)
			operator := strings.TrimSpace(in.Operator)
			if operator == "" {
				operator = who
			}
			if dedicatedScope(operator) {
				// A repository's or a team's own agent (RFC 0026): its
				// admins or owners make it, and the maker answers for it.
				if strings.TrimSpace(in.Scope) != "" {
					return agentOut{}, fmt.Errorf("a repository's or a team's agent reaches its own scope; scope is for a person's agent: %w", ops.ErrInvalid)
				}
				if !d.mayManageScope(ctx, operator) {
					return agentOut{}, fmt.Errorf("only an admin of the repository, or an owner of the team, may give %s an agent: %w", operator, ops.ErrForbidden)
				}
				if err := as.NewDedicatedAgent(in.Name, operator, who); err != nil {
					return agentOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
				}
				name := in.Name
				d.provision(func(ctx context.Context) error {
					return d.Provision.EnsureSubscriber(ctx, name)
				})
				return agentOut{Name: in.Name, Operator: operator}, nil
			}
			if operator == forgeOperator {
				if strings.TrimSpace(in.Scope) != "" {
					return agentOut{}, fmt.Errorf("a forge agent reaches the site; scope is for a person's agent: %w", ops.ErrInvalid)
				}
				// The forge's own agent: a site admin's act, and a
				// person's, with a site admin named answerable.
				if err := d.refuseForgeAgent(ctx, "creates another forge agent"); err != nil {
					return agentOut{}, err
				}
				if !admin {
					return agentOut{}, fmt.Errorf("only a site admin may create a forge agent: %w", ops.ErrForbidden)
				}
				if strings.TrimSpace(in.Answerable) == "" {
					return agentOut{}, fmt.Errorf("a forge agent needs answerable=, the site admin who answers for it: %w", ops.ErrInvalid)
				}
				if err := as.NewForgeAgent(in.Name, strings.TrimSpace(in.Answerable)); err != nil {
					return agentOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
				}
				name := in.Name
				d.provision(func(ctx context.Context) error {
					return d.Provision.EnsureSubscriber(ctx, name)
				})
				return agentOut{Name: in.Name, Operator: forgeOperator}, nil
			}
			if operator != who && !admin {
				return agentOut{}, fmt.Errorf("only a site admin may create an agent for somebody else: %w", ops.ErrForbidden)
			}
			// The cap is counted in the store, under the lock the row is
			// written with; a site admin is never capped.
			perPerson := -1
			if !admin {
				if d.AgentsPerPerson <= 0 {
					return agentOut{}, fmt.Errorf("this forge does not let people create agents: %w", ops.ErrForbidden)
				}
				perPerson = d.AgentsPerPerson
			}
			if in.SiteAdmin {
				switch {
				case operator != who || !admin:
					return agentOut{}, fmt.Errorf("only a site admin may make their own agent a site admin by inheritance: %w", ops.ErrForbidden)
				case strings.TrimSpace(in.Scope) != "":
					return agentOut{}, fmt.Errorf("an agent that inherits the site-admin flag reaches the whole site, so it takes no scope: %w", ops.ErrInvalid)
				case !in.Confirm:
					return agentOut{}, fmt.Errorf("confirm=true: anyone holding this agent's token can manage the whole forge while you are a site admin: %w", ops.ErrInvalid)
				}
			}
			if err := as.NewScopedAgent(in.Name, operator, strings.TrimSpace(in.Scope), perPerson); err != nil {
				return agentOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
			}
			if in.SiteAdmin {
				if err := as.SetSiteOpt(who, in.Name); err != nil {
					return agentOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
				}
			}
			name := in.Name
			d.provision(func(ctx context.Context) error {
				return d.Provision.EnsureSubscriber(ctx, name)
			})
			return agentOut{Name: in.Name, Operator: operator}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentListIn, agentListOut]{
		Name: "agent.list",
		Doc: "Your agents, each with its tokens (names, scopes, dates, never the values) and the repositories it holds a string on, " +
			"with what each resolves to under your letters. A site admin may ask for anybody's with operator=.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/agents",
		Run: func(ctx context.Context, in agentListIn) (agentListOut, error) {
			if err := refuseToken(ctx, "agent.list"); err != nil {
				return agentListOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentListOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentListOut{}, fmt.Errorf("listing agents needs a signed-in account: %w", ops.ErrInvalid)
			}
			operator := who
			if in.Agent != "" {
				op, err := d.myAgent(ctx, as, in.Agent)
				if err != nil {
					return agentListOut{}, err
				}
				in.Operator = op
			}
			if in.Agent != "" && !dedicatedScope(in.Operator) {
				operator = in.Operator
			} else if dedicatedScope(in.Operator) {
				if !d.mayManageScope(ctx, in.Operator) && !d.isSiteAdmin(ctx, who) {
					return agentListOut{}, fmt.Errorf("only an admin of the repository, or an owner of the team, may list %s's agents: %w", in.Operator, ops.ErrForbidden)
				}
				operator = in.Operator
			} else if in.Operator != "" && in.Operator != who {
				if !d.isSiteAdmin(ctx, who) {
					return agentListOut{}, fmt.Errorf("only a site admin may list somebody else's agents: %w", ops.ErrForbidden)
				}
				operator = in.Operator
			}
			agents, err := as.Agents(operator)
			if err != nil {
				return agentListOut{}, err
			}
			ex, canExplain := d.Accounts.(CapsExplainer)
			out := agentListOut{Agents: []agentRow{}}
			now := time.Now()
			for _, a := range agents {
				row := agentRow{Name: a.Name, Operator: a.Operator, Admin: a.Admin, Created: a.Created, Disabled: a.Disabled,
					Suspended: a.Suspended, Inherit: a.Inherit, Scope: a.Scope, Tokens: []meTokenRow{}, Repos: []agentRepo{},
					Asks: d.agentAsks(a.Name)}
				if a.Operator == forgeOperator || dedicatedScope(a.Operator) {
					row.Answerable = as.Answerable(a.Name)
				}
				if toks, err := as.Tokens(a.Name); err == nil {
					row.Tokens = tokenRows(toks, now)
				}
				grants, err := d.Accounts.GrantsFor(a.Name)
				if err != nil {
					return agentListOut{}, err
				}
				repos := make([]string, 0, len(grants))
				for repo := range grants {
					// "" is a fleet-scope row, not a repository.
					if repo != "" {
						repos = append(repos, repo)
					}
				}
				sort.Strings(repos)
				// An inheriting agent narrowed to nothing holds an empty
				// string, which GrantsFor does not list.
				closed := map[string]bool{}
				if a.Inherit {
					names, err := as.ClosedTo(a.Name)
					if err != nil {
						return agentListOut{}, err
					}
					for _, n := range names {
						closed[n] = true
						repos = append(repos, n)
					}
					sort.Strings(repos)
				}
				for _, repo := range repos {
					if closed[repo] {
						// What it still holds is the repository's own
						// categories, which every signed-in account has.
						ar := agentRepo{Repo: repo, Role: "No access"}
						if canExplain {
							if _, eff, err := ex.CapsAt(a.Name, repo); err == nil {
								ar.Effective = eff
							}
						}
						row.Repos = append(row.Repos, ar)
						continue
					}
					ar := agentRepo{Repo: repo, Role: grants[repo]}
					if canExplain {
						if stored, eff, err := ex.CapsAt(a.Name, repo); err == nil {
							ar.Caps, ar.Effective = stored, eff
							ar.Role = oltp.Infer(stored).Role
						}
					}
					row.Repos = append(row.Repos, ar)
				}
				out.Agents = append(out.Agents, row)
			}
			out.Count = len(out.Agents)
			out.SiteAdmin = d.isSiteAdmin(ctx, who)
			switch {
			case d.isSiteAdmin(ctx, who):
				out.CanCreate = true
			case d.AgentsPerPerson > 0:
				n, err := as.AgentCount(who)
				if err != nil {
					// Not offering the form is the safe answer; the trace is
					// how a failed read is told from a full quota.
					log.Printf("agent.list: counting %s's agents: %v", who, err)
				}
				out.CanCreate = err == nil && n < d.AgentsPerPerson
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[accountAnswerableIn, accountAnswerableOut]{
		Name: "account.answerable",
		Doc: "Name the site admin who answers for a forge agent, replacing whoever did (RFC 0021, Forge agents). Any site admin may; " +
			"a forge agent itself may not. Disabling or deleting the answerable admin never touches the agent: it answers to nobody until one is named.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/accounts/answerable",
		Run: func(ctx context.Context, in accountAnswerableIn) (accountAnswerableOut, error) {
			if err := refuseToken(ctx, "account.answerable"); err != nil {
				return accountAnswerableOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return accountAnswerableOut{}, err
			}
			if err := d.refuseForgeAgent(ctx, "alters a forge agent"); err != nil {
				return accountAnswerableOut{}, err
			}
			if !d.isSiteAdmin(ctx, ops.User(ctx)) {
				return accountAnswerableOut{}, fmt.Errorf("only a site admin names who answers for a forge agent: %w", ops.ErrForbidden)
			}
			if err := as.SetAnswerable(in.Name, strings.TrimSpace(in.Answerable)); err != nil {
				return accountAnswerableOut{}, refusalAsInvalid(fmt.Errorf("%w: %v", ops.ErrInvalid, err))
			}
			return accountAnswerableOut{Name: in.Name, Answerable: strings.TrimSpace(in.Answerable)}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentNameIn, agentOut]{
		Name: "agent.delete",
		Doc: "Delete one of your agents: every session ended, every token revoked, the row removed and the name retired, the cascade a deleted person gets. " +
			"Its check-ins and tickets stay in the record under its name. A site admin may delete anybody's.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/delete",
		Run: func(ctx context.Context, in agentNameIn) (agentOut, error) {
			if err := refuseToken(ctx, "agent.delete"); err != nil {
				return agentOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentOut{}, err
			}
			operator, err := d.myAgent(ctx, as, in.Name)
			if err != nil {
				return agentOut{}, err
			}
			if err := d.Accounts.DeleteAccount(in.Name); err != nil {
				return agentOut{}, refusalAsInvalid(err)
			}
			name := in.Name
			d.provision(func(ctx context.Context) error {
				return d.Provision.RemoveSubscriber(ctx, name)
			})
			return agentOut{Name: in.Name, Operator: operator}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenNewIn, agentTokenNewOut]{
		Name: "agent.token.new",
		Doc: "Mint a token for one of your agents and return it the ONE time it is readable. `scopes` is any of sync, api and web, comma-separated; " +
			"site as well, beside api or web, for a forge agent holding the site-admin flag: its token then acts as a site admin, except in a private repository it holds no grant on. " +
			"`repos` narrows it to named repositories; `ceiling` cuts its letters; `days` gives it a fixed life of up to a year; without it the token has no date and lapses after 90 days unused. " +
			"Minting adds; the agent's other tokens keep working.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens",
		Run: func(ctx context.Context, in agentTokenNewIn) (agentTokenNewOut, error) {
			if err := refuseToken(ctx, "agent.token.new"); err != nil {
				return agentTokenNewOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentTokenNewOut{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return agentTokenNewOut{}, err
			}
			spec := oltp.TokenSpec{Name: in.Name, Scopes: splitList(in.Scopes), Repos: splitList(in.Repos), Ceiling: strings.TrimSpace(in.Ceiling)}
			if in.Days > 0 {
				spec.Expires = time.Now().Add(time.Duration(in.Days) * 24 * time.Hour)
			}
			value, tok, err := as.MintToken(in.Agent, spec)
			if err != nil {
				return agentTokenNewOut{}, refusalAsInvalid(err)
			}
			return agentTokenNewOut{Value: value, Token: tokenRows([]oltp.Token{tok}, time.Now())[0]}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenListIn, agentTokenListOut]{
		Name:  "agent.token.list",
		Doc:   "One of your agents' tokens: names, scopes, repositories, ceilings and dates, expiring ones first — never a value.",
		Kind:  ops.Query,
		Auth:  ops.Token,
		Route: "GET /api/v1/agents/tokens",
		Run: func(ctx context.Context, in agentTokenListIn) (agentTokenListOut, error) {
			if err := refuseToken(ctx, "agent.token.list"); err != nil {
				return agentTokenListOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentTokenListOut{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return agentTokenListOut{}, err
			}
			toks, err := as.Tokens(in.Agent)
			if err != nil {
				return agentTokenListOut{}, err
			}
			return agentTokenListOut{Agent: in.Agent, Tokens: tokenRows(toks, time.Now())}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentTokenRevokeIn, struct{}]{
		Name:  "agent.token.revoke",
		Doc:   "Revoke one token of one of your agents. The row stays, marked, for the list; every door refuses it from the next request.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens/revoke",
		Run: func(ctx context.Context, in agentTokenRevokeIn) (struct{}, error) {
			if err := refuseToken(ctx, "agent.token.revoke"); err != nil {
				return struct{}{}, err
			}
			as, err := d.agents()
			if err != nil {
				return struct{}{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Agent); err != nil {
				return struct{}{}, err
			}
			if err := as.RevokeToken(in.Agent, in.ID); err != nil {
				return struct{}{}, fmt.Errorf("%s holds no such live token: %w", in.Agent, ops.ErrNotFound)
			}
			return struct{}{}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentNameIn, agentTokenReissueOut]{
		Name: "agent.token.reissue",
		Doc: "Revoke every token of one of your agents and mint one replacement in the same step (RFC 0026, Tokens): same name, scopes, repositories and ceiling " +
			"as its most recently used token, and no expiry date. The value is in this response only. A site admin may do this to any agent.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens/reissue",
		Run: func(ctx context.Context, in agentNameIn) (agentTokenReissueOut, error) {
			if err := refuseToken(ctx, "agent.token.reissue"); err != nil {
				return agentTokenReissueOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentTokenReissueOut{}, err
			}
			if _, err := d.myAgent(ctx, as, in.Name); err != nil {
				return agentTokenReissueOut{}, err
			}
			value, tok, n, err := as.ReissueTokens(in.Name)
			if err != nil {
				return agentTokenReissueOut{}, err
			}
			return agentTokenReissueOut{Agent: in.Name, Revoked: n, Name: tok.Name, Value: value}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[struct{}, agentRevokeAllOut]{
		Name: "agent.token.revoke_all",
		Doc: "Revoke every token of every agent of yours and end their sessions, in one transaction: the control for a laptop that went missing, " +
			"one button because there is no safe answer to which of your tokens was on it. Your own tokens are untouched.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/tokens/revoke-all",
		Run: func(ctx context.Context, _ struct{}) (agentRevokeAllOut, error) {
			if err := refuseToken(ctx, "agent.token.revoke_all"); err != nil {
				return agentRevokeAllOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentRevokeAllOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentRevokeAllOut{}, fmt.Errorf("revoking your agents' tokens needs a signed-in account: %w", ops.ErrInvalid)
			}
			n, err := as.RevokeAgentTokens(who)
			if err != nil {
				return agentRevokeAllOut{}, err
			}
			return agentRevokeAllOut{Revoked: n}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentInheritIn, agentInheritOut]{
		Name: "agent.inherit",
		Doc: "Switch one of your agents between inherit and only-where-set (RFC 0026). Inheriting, it holds your letters on every repository where you have not narrowed it; " +
			"only-where-set, it holds its strings alone. Either way it never holds more than you, and never your site-admin flag.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/inherit",
		Run: func(ctx context.Context, in agentInheritIn) (agentInheritOut, error) {
			if err := refuseToken(ctx, "agent.inherit"); err != nil {
				return agentInheritOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentInheritOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentInheritOut{}, fmt.Errorf("switching an agent needs a signed-in account: %w", ops.ErrInvalid)
			}
			if as.Operator(in.Agent) != who {
				return agentInheritOut{}, fmt.Errorf("%s is not your agent: %w", in.Agent, ops.ErrForbidden)
			}
			if err := as.SetInherit(who, in.Agent, in.On); err != nil {
				return agentInheritOut{}, refusalAsInvalid(err)
			}
			return agentInheritOut{Agent: in.Agent, Inherit: in.On}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[agentAskIn, agentAskOut]{
		Name: "agent.ask",
		Doc: "Let one of your agents ask you for what it may not do, or stop it asking (RFC 0027). On for every agent unless you turn it off; " +
			"while off, a call it sends with Quarry-Ask is refused with a sentence saying you take no requests from it.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/ask",
		Run: func(ctx context.Context, in agentAskIn) (agentAskOut, error) {
			if err := refuseToken(ctx, "agent.ask"); err != nil {
				return agentAskOut{}, err
			}
			who := ops.User(ctx)
			if who == "" || d.personFor(in.Agent) != who {
				return agentAskOut{}, fmt.Errorf("%s is not your agent: %w", in.Agent, ops.ErrForbidden)
			}
			if d.Prefs == nil {
				return agentAskOut{}, fmt.Errorf("this forge keeps no settings for agents: %w", ops.ErrUnavailable)
			}
			v := ""
			if !in.On {
				v = "off"
			}
			if err := d.Prefs.SetAccountPref(in.Agent, prefAsk, v); err != nil {
				return agentAskOut{}, err
			}
			return agentAskOut{Agent: in.Agent, Asks: in.On}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[agentGrantIn, agentGrantOut]{
		Name: "agent.grant",
		Doc: "Put one of your agents to work on a repository you reach, administered or not: `role` is a preset, `caps` any string, both empty revokes. " +
			"For an inheriting agent (RFC 0026) a string narrows what it inherits there, an empty one leaves it nothing, and inherit=true drops the string so it inherits again. " +
			"The string is clipped to your own letters there before it is stored, and resolves under them on every read after, so the agent never holds more than you. " +
			"Refused on a repository you cannot reach.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/agents/grant",
		Run: func(ctx context.Context, in agentGrantIn) (agentGrantOut, error) {
			if err := refuseToken(ctx, "agent.grant"); err != nil {
				return agentGrantOut{}, err
			}
			as, err := d.agents()
			if err != nil {
				return agentGrantOut{}, err
			}
			who := ops.User(ctx)
			if who == "" {
				return agentGrantOut{}, fmt.Errorf("granting needs a signed-in account: %w", ops.ErrInvalid)
			}
			// The operator's own agent only: a site admin uses
			// account.grant, which clips nothing and lets the
			// intersection keep the result honest.
			if as.Operator(in.Agent) != who {
				return agentGrantOut{}, fmt.Errorf("%s is not your agent: %w", in.Agent, ops.ErrForbidden)
			}
			// A repository the operator cannot read is one that does
			// not exist for them, so neither does the grant.
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return agentGrantOut{}, err
			}
			if err := d.refuseHeldRepo(ctx, in.Repo); err != nil {
				return agentGrantOut{}, err
			}
			if in.Inherit {
				before := d.agentHolds(in.Agent, in.Repo)
				if err := as.AgentInheritHere(who, in.Agent, in.Repo); err != nil {
					return agentGrantOut{}, refusalAsInvalid(err)
				}
				d.raiseAccountChange(ctx, in.Repo, in.Agent, before, d.agentHolds(in.Agent, in.Repo))
				return agentGrantOut{Agent: in.Agent, Repo: in.Repo}, nil
			}
			caps := strings.TrimSpace(in.Caps)
			if in.Role != "" {
				role, err := oltp.ParseRole(in.Role)
				if err != nil {
					return agentGrantOut{}, fmt.Errorf("%w: %v", ops.ErrInvalid, err)
				}
				caps = role.Caps()
			}
			before := d.agentHolds(in.Agent, in.Repo)
			stored, clipped, err := as.AgentGrant(who, in.Agent, in.Repo, caps)
			if err != nil {
				return agentGrantOut{}, refusalAsInvalid(err)
			}
			out := agentGrantOut{Agent: in.Agent, Repo: in.Repo, Caps: stored, Clipped: clipped}
			if clipped {
				out.Warning = "clipped to " + who + "'s own letters on " + in.Repo + "; an agent never holds more than its operator"
			}
			d.raiseAccountChange(ctx, in.Repo, in.Agent, before, d.agentHolds(in.Agent, in.Repo))
			agent, repoName := in.Agent, in.Repo
			d.provision(func(ctx context.Context) error {
				return d.Provision.SetTopicMember(ctx, repoName, agent, stored != "")
			})
			return out, nil
		},
	})
}

// agentHolds is what an agent holds on repo for an event's before and
// after: its effective letters, since an inheriting agent's stored
// string says nothing of what it gains or loses (RFC 0026). Without a
// resolver, its stored string.
func (d Deps) agentHolds(agent, repo string) string {
	if d.CapsFor != nil {
		return fmt.Sprint(d.CapsFor(agent, repo))
	}
	return d.capsNow(agent, repo)
}

// splitList is a comma-separated field as a list, blanks dropped.
func splitList(s string) []string {
	var out []string
	for _, p := range strings.Split(s, ",") {
		if p = strings.TrimSpace(p); p != "" {
			out = append(out, p)
		}
	}
	return out
}

// refuseAgentTeams keeps an agent out of the team ops that change
// grants, membership or where a repository lives (team.create,
// team.delete, team.member, team.unmember, team.repo.*). A team is a
// route to letters beside the one the operator intersection governs,
// and an agent holding a could otherwise make one, grant a third
// account through it, move a repository under it or unmember its
// operator. Team-owned agents are RFC 0021's rung 8; until then an
// agent does not manage teams.
func (d Deps) refuseAgentTeams(ctx context.Context) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op != "" && op != forgeOperator {
		return fmt.Errorf("%s is an agent, operated by %s, and an agent does not manage teams: %w", who, op, ops.ErrForbidden)
	}
	return nil
}

// refuseAgentAbove is agentGuard's letter rule (oltp/guard.go) at the
// one write the guard does not see: a category string. A category is
// not a grant to an account, so guardCaps never judges it, but it hands
// letters to everyone in the category, and the nobody category sits
// outside the operator intersection, so an agent writing a letter it
// does not resolve to into one would hand it to others and, through
// nobody, to itself. An agent caller may write only letters it resolves
// to on that repository (its token's, when it came in on one); a person
// is held to nothing here but the `a` the op is gated on.
func (d Deps) refuseAgentAbove(ctx context.Context, repo, caps string) error {
	who := ops.User(ctx)
	if op := d.operatorOf(who); op == "" || op == forgeOperator || d.CapsFor == nil {
		return nil
	}
	mine := d.capsFor(ctx, who, repo)
	for _, l := range caps {
		if !mine.HasAny(string(l)) {
			return fmt.Errorf("%s does not hold %c on %s itself; an agent cannot grant a letter it does not resolve to: %w", who, l, repo, ops.ErrForbidden)
		}
	}
	return nil
}