Skip to content
quarry Artifact [ce1bcff7da]
Login

Artifact [ce1bcff7da]

Artifact ce1bcff7da8106cec020d7bcb16ace5184d4ae2d87a0c848e66557fa001b449b:


// team.go is RFC 0018's ops surface.
//
// Three authorities, deliberately apart, and the last row of the table
// is the security argument for the whole file:
//
//	create a team ......... any signed-in account
//	manage a team ......... its owners
//	put a repo under it ... somebody holding `a` on THAT repository
//
// Creation is an ordinary user action, like registering a repository —
// `repo.add` sets the precedent in its own doc string: "Any signed-in
// account may call this — the creator becomes the repository's admin in
// the same operation, which is where ownership starts."
//
// The last one is what stops team creation being an escalation
// primitive. Without it: make a team, put somebody else's repository
// under it, grant yourself admin. The check is the capability that
// already exists, asked at the moment the repository joins.
package api

import (
	"context"
	"fmt"
	"log"
	"sort"

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

// TeamStore is RFC 0018's roster, in this package's own vocabulary the
// way AccountStore and CIRuns are.
type TeamStore interface {
	Teams() ([]oltp.Team, error)
	TeamsFor(account string) ([]oltp.Team, error)
	TeamByName(name string) (oltp.Team, bool)
	AddTeam(name, title, descr, by string) error
	SetTeamText(name, title, descr string) error
	DeleteTeam(name string) error

	TeamMembers(team string) ([]oltp.TeamMember, error)
	TeamMemberOf(team, account string) (oltp.TeamMember, bool)
	IsTeamOwner(team, account string) bool
	// The last-owner guard and the write as one decision, the only
	// membership writes here: a guard asked in one call and a write made
	// in another let two owners removing each other at once leave a team
	// with none.
	SetTeamMemberGuarded(actor, team, account, caps string, owner, listed bool) (string, error)
	RemoveTeamMemberGuarded(actor, team, account string) (string, error)

	TeamRepos(team string) ([]oltp.TeamRepo, error)
	TeamPeople(team string) ([]oltp.TeamPerson, error)
	TeamPeoplePage(team string, limit, offset int) ([]oltp.TeamPerson, error)
	TeamPeopleCount(team string) (int, error)
	TeamShownPeoplePage(team string, limit, offset int) ([]oltp.TeamPerson, error)
	TeamShownPeopleCount(team string) (int, error)
	SetTeamMemberListed(team, account string, listed bool) (bool, error)
	SetTeamPrivate(name string, private bool) (bool, error)
	TeamOwnerCount(team string) (int, error)
	TeamCount() (int, error)
	TeamReposPage(team string, limit, offset int) ([]oltp.TeamRepo, error)
	TeamMembersPage(team string, limit, offset int) ([]oltp.TeamMember, error)
	TeamOfRepo(repo string) string
	SetTeamRepo(team, repo, by string) error
	RemoveTeamRepoFrom(team, repo string) (bool, error)

	// Offers: a repository waiting on a team's consent, holding no
	// access of its own (RFC 0018 §"The receiving half is an offer").
	OfferTeamRepo(team, repo, by string) error
	TeamOfferFor(repo string) (oltp.TeamOffer, bool, error)
	TeamOffers(team string) ([]oltp.TeamOffer, error)
	DropTeamOffer(repo string) error
}

// raiseTeam announces a change to a team.
//
// Called AFTER the write and never allowed to fail it: the change has
// already happened by the time this runs, so an error here would tell
// a caller their save did not when it did. raiseAccountChange's rule,
// for the same reason.
//
// The team's name goes in the repository slot (RFC 0005 §team): a
// subject is quarry.evt.<repo>.<noun>.<action>, and a team borrows
// that slot so one subject shape serves both.
func (d Deps) raiseTeam(ctx context.Context, kind evt.Kind, team, summary string) {
	if d.Events == nil {
		return
	}
	if err := d.Events.Raise(kind, team, map[string]any{
		"team": team, "actor": author(ctx), "via": ops.Via(ctx), "summary": summary,
	}); err != nil {
		log.Printf("events: raise %s: %v", kind, err)
	}
}

// offerTold is the inbox half of a repository offer (ticket 6d176a8c).
//
// The offer machinery was complete except for this: the row was
// recorded, both sides could read it through team.get with offeredBy
// and an expiry, and neither side was ever TOLD. The only way to learn
// an offer was waiting was to open the team's settings page, which is
// the one place somebody with nothing pending has no reason to visit.
//
// Attention rather than notify, for AccessChanged's reason: an offer
// changes who may reach a repository, and that is account mail. It
// does not ride the preference matrix and cannot be switched off,
// because a decision waiting on you is not content you subscribed to.
//
// Deliberately silent to the actor. Whoever pressed the button already
// knows; a row telling you what you just did is noise in the one place
// that must stay worth reading.
func (d Deps) tellTeamOwners(ctx context.Context, team, summary, link string) {
	if d.Teams == nil {
		return
	}
	members, err := d.Teams.TeamMembers(team)
	if err != nil {
		return
	}
	actor := author(ctx)
	now := time.Now().UTC().Format(time.RFC3339Nano)
	for _, m := range members {
		if !m.Owner || m.Account == actor {
			continue
		}
		d.attend(m.Account, AttentionEvent{
			Kind: "team", From: actor, Summary: summary, Link: link, At: now,
		})
	}
}

// tellOfferer answers the person who asked. An offer they made is a
// question, and accept and decline are both its answer.
func (d Deps) tellOfferer(ctx context.Context, who, summary, link string) {
	if who == "" || who == author(ctx) {
		return
	}
	d.attend(who, AttentionEvent{
		Kind: "team", From: author(ctx), Summary: summary, Link: link,
		At: time.Now().UTC().Format(time.RFC3339Nano),
	})
}

func errNoTeams() error {
	return fmt.Errorf("this forge keeps no teams: %w", ops.ErrUnavailable)
}

// mayCreateIn is nil when the caller may create a repository in
// namespace: their own (empty, or their deed holder's name), or a team
// they manage. An agent creates in its person's namespace only, as it
// takes no team actions (refuseAgentTeams).
func (d Deps) mayCreateIn(ctx context.Context, namespace string) error {
	if namespace == "" {
		return nil
	}
	if d.Registry != nil {
		if owner, err := d.Registry.Owner(ops.User(ctx)); err == nil && owner == namespace {
			return nil
		}
	}
	if err := d.refuseAgentTeams(ctx); err != nil {
		return err
	}
	if d.Teams == nil {
		return errNoTeams()
	}
	if _, err := d.teamFor(ctx, namespace); err != nil {
		return err
	}
	if !d.mayManageTeam(ctx, namespace) {
		return fmt.Errorf("only an owner of %s may create repositories in it: %w", namespace, ops.ErrForbidden)
	}
	return nil
}

// mayManageTeam reports whether the caller may change a team: one of
// its owners. A site admin is not one by the flag (#318): a team's
// members reach every repository under it, so managing its members is
// a way into those repositories.
func (d Deps) mayManageTeam(ctx context.Context, team string) bool {
	user := author(ctx)
	if user == "" || d.Teams == nil {
		return false
	}
	return d.Teams.IsTeamOwner(team, user)
}

// mayDeleteTeam reports whether the caller may delete a team: an owner,
// or a site admin. Deleting is the one change an admin may make to a
// team they do not own: it hands nobody any access, so it reopens none
// of what #318 closed, and without it a team whose owners are gone
// could never be removed.
func (d Deps) mayDeleteTeam(ctx context.Context, team string) bool {
	return d.mayManageTeam(ctx, team) || d.isSiteAdmin(ctx, author(ctx))
}

// teamVisible says whether this caller may know the team exists. A
// public team, anyone; a private one, its members, its owners and site
// admins. Everyone else is answered as though it did not exist, the
// rule a private repository follows.
func (d Deps) teamVisible(ctx context.Context, t oltp.Team) bool {
	return d.teamSeer(ctx)(t)
}

// teamSeer is teamVisible for one caller over many teams: a listing
// asks whether they are a site admin once, not once per private team.
func (d Deps) teamSeer(ctx context.Context) func(oltp.Team) bool {
	who := ops.User(ctx)
	admin := -1
	return func(t oltp.Team) bool {
		if !t.Private {
			return true
		}
		if who == "" || d.Teams == nil {
			return false
		}
		if _, ok := d.Teams.TeamMemberOf(t.Name, who); ok {
			return true
		}
		if admin < 0 {
			admin = 0
			if d.isSiteAdmin(ctx, who) {
				admin = 1
			}
		}
		return admin == 1
	}
}

// teamFor reads a team for this caller, or not found: the one lookup
// every team op shares, so "no such team" and "a private team you
// cannot see" are one answer everywhere.
func (d Deps) teamFor(ctx context.Context, name string) (oltp.Team, error) {
	if d.Teams == nil {
		return oltp.Team{}, errNoTeams()
	}
	t, found := d.Teams.TeamByName(name)
	if !found || !d.teamVisible(ctx, t) {
		return oltp.Team{}, fmt.Errorf("no team named %q: %w", name, ops.ErrNotFound)
	}
	return t, nil
}

// isSiteAdmin answers from the accounts plane, or false without one.
//
// Through a token only when it is a site token asking about its own
// account (RFC 0021, Forge agents): the flag is not a letter and
// reaches no other token, so a site admin's token reaches no operator
// path, whatever it is ceilinged to. Even then the operator path stops
// where the token's reads do (siteReaches).
func (d Deps) isSiteAdmin(ctx context.Context, user string) bool {
	if tok, byToken := ops.TokenOf(ctx); byToken && (user != ops.User(ctx) || !tok.SiteAdmin()) {
		return false
	}
	if d.Accounts == nil || user == "" {
		return false
	}
	accts, err := d.Accounts.Accounts()
	if err != nil {
		return false
	}
	for _, a := range accts {
		if a.Name == user {
			return a.Admin
		}
	}
	return false
}

// siteReaches says whether a site admin's operator path reaches repo
// for this caller. A person's does, readable or not (#318). A site
// token's stops where its reads do: a private repository its agent
// holds no grant on is no more there for it than for an anonymous
// visitor, to act on or to list (RFC 0021, Forge agents).
func (d Deps) siteReaches(ctx context.Context, repo string) bool {
	if _, byToken := ops.TokenOf(ctx); !byToken || repo == "" || d.CanRead == nil {
		return true
	}
	return d.CanRead(ctx, repo)
}

// siteReachesAll is siteReaches for every repository a row names.
func (d Deps) siteReachesAll(ctx context.Context, repos []string) bool {
	for _, repo := range repos {
		if !d.siteReaches(ctx, repo) {
			return false
		}
	}
	return true
}

// maySetRepoTeam reports whether the caller may move a repository into
// or out of a team: they must hold `a` on the REPOSITORY.
//
// Never the team's authority. A team that already holds admin
// somewhere must not become a way to reach another repository without
// that repository's admin agreeing.
func (d Deps) maySetRepoTeam(ctx context.Context, repo string) bool {
	user := author(ctx)
	if user == "" {
		return false
	}
	return d.CapsFor != nil && d.capsFor(ctx, user, repo).HasAny("a")
}

// mayOfferStillStands asks whether the person who made an offer could
// still make it today.
//
// An offer is consent from the TEAM's side; it is not a standing grant
// from the repository's. Somebody who held `a` when they offered may
// have lost it in the seven days since — handed the repository on,
// been removed as an admin, left. Accepting on the strength of a
// capability that has expired would move a repository on the authority
// of somebody who no longer has any, which is exactly the escalation
// §Authority exists to prevent, arriving a week late.
//
// A site admin's offer stands on the same `a` as anybody's (#318).
func (d Deps) mayOfferStillStands(offer oltp.TeamOffer) bool {
	if offer.OfferedBy == "" {
		return false
	}
	return d.CapsFor != nil && d.CapsFor(offer.OfferedBy, offer.Repo).HasAny("a")
}

type teamListIn struct {
	// Mine limits the listing to the caller's own teams. Absent, the
	// listing is every team, which is public (RFC 0018 §public
	// presentation).
	Mine bool `json:"mine"`
	// Manages narrows the caller's own teams to those they may manage:
	// where they may create a repository (RFC 0025). Implies mine.
	Manages bool `json:"manages"`
	// Account limits it to one person's teams. A membership somebody
	// opted out of is withheld unless the caller is that person or can
	// manage the team — the opt-out is the member's choice, so it has
	// to hold on every surface that could reveal it, not only the
	// team's own page.
	Account string `json:"account" validate:"max=64"`
	// Limit and Offset window the listing. A forge-wide directory is
	// unbounded by nature, and the badge reads Total rather than this.
	Limit  int `json:"limit" validate:"min=0,max=200,default=50"`
	Offset int `json:"offset" validate:"min=0,max=1000000"`
}

type teamListOut struct {
	Teams []oltp.Team `json:"teams"`
	// Total is every team on the forge, whatever this window holds. The
	// explore strip's badge reads this: len(Teams) would silently
	// become the page size the moment the listing was paged, which is
	// the count-disagrees-with-its-list bug this area has produced
	// twice already.
	Total int `json:"total"`
}

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

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

type teamYou struct {
	Listed bool `json:"listed"`
}

type teamPrivateIn struct {
	Name    string `json:"name" validate:"required,max=64"`
	Private bool   `json:"private"`
}

type teamListedIn struct {
	Name   string `json:"name" validate:"required,max=64"`
	Listed bool   `json:"listed"`
}

type teamGetOut struct {
	Team    oltp.Team         `json:"team"`
	Members []oltp.TeamMember `json:"members"`
	Repos   []oltp.TeamRepo   `json:"repos"`
	// CanManage says whether the caller may change any of this, so a
	// page does not have to work it out a second way.
	CanManage bool `json:"canManage"`
	// CanDelete says whether the caller may delete the team: an owner,
	// or a site admin who may not change it otherwise.
	CanDelete bool `json:"canDelete"`
	// InRoom draws the team's Room tab: whether the caller may read the
	// team's chat. Every team page takes its bar from this op, so
	// without it no page but People ever showed the room.
	InRoom bool `json:"inRoom"`
	// You is the caller's own membership, when they have one: whether
	// they are on the team and whether they chose to be shown, which
	// the member list withholds from them when they are not.
	You *teamYou `json:"you,omitempty"`
	// Donatable is what THIS CALLER could put under the team: the
	// repositories they administer that no team holds yet. The page
	// cannot work it out — it would need the caller's letters on every
	// repository, which is a query per row — and the gate is not team
	// management but `a` on each repository, so the only honest source
	// is the op that already answers authority. CanManage's precedent.
	//
	// A repository already under ANOTHER team is left out on purpose.
	// team.repo.add would move it, and a control that silently takes a
	// repository out of somebody else's team is not an "add".
	// MembersShown and ReposShown say how much of each count is in
	// these arrays. The overview is a SUMMARY: it shows a screenful of
	// each and points at the roster for the rest, rather than
	// rendering a thousand rows nobody scrolls.
	MembersShown int      `json:"membersShown"`
	ReposShown   int      `json:"reposShown"`
	Donatable    []string `json:"donatable,omitempty"`
	// Offered is what is WAITING on this team's answer (RFC 0018
	// §"The receiving half is an offer"). Shown to anybody who can see
	// the team: a pending offer is not private — it names a repository
	// whose admin has already chosen to make the connection public by
	// asking — and hiding it from members would mean only owners knew
	// what the team was about to take on. Only an owner gets the
	// controls.
	Offered []oltp.TeamOffer `json:"offered,omitempty"`
	// Losing names, per repository, who would lose access if it left
	// the team — the people whose only route to it is the membership
	// floor. RFC 0017 requires this BEFORE the act, not after: "a
	// confirmation that lists them is the difference between a decision
	// and an accident". The op's Warning says the same thing afterwards,
	// which is the wrong moment for a control on a page.
	//
	// Empty for a public repository, and correctly so: the categories
	// already grant what the floor does, so nobody's access moves.
	Losing map[string][]string `json:"losing,omitempty"`
	// MayRemove lists which of Repos this caller may take out — an
	// admin of that repository, or an owner of this team, which is why
	// it is not the same answer as CanManage for every row.
	MayRemove []string `json:"mayRemove,omitempty"`
}

// teamPageIn is teamGetIn plus a window. A separate type because
// team.get and team.people page different things.
type teamPageIn struct {
	Name string `json:"name" validate:"required,max=64"`
	// Limit and Offset bound the WORK, not only what is rendered: the
	// order and the window are the database's (oltp.TeamPeoplePage).
	Limit  int `json:"limit" validate:"min=0,max=200,default=50"`
	Offset int `json:"offset" validate:"min=0,max=1000000"`
}

type teamPeopleOut struct {
	Team   oltp.Team         `json:"team"`
	People []oltp.TeamPerson `json:"people"`
	// Total is the whole population this window is cut from, so a pager
	// knows there is a next page and a heading can say how many people
	// reach these repositories without listing them.
	Total int `json:"total"`
	// Owners is the team's owner count, from the store and not from
	// this page: the screen hides the last owner's Remove, and that
	// control must not depend on which rows happen to be in view.
	Owners int `json:"owners"`
	// Repos is how many repositories the team holds, so a page can say
	// what "reaches these repositories" is counting.
	Repos     int  `json:"repos"`
	CanManage bool `json:"canManage"`
	// InRoom, as on team.get, so the nav is the same on both pages.
	InRoom bool `json:"inRoom"`
}

type teamCreateIn struct {
	Name  string `json:"name" validate:"required,max=64"`
	Title string `json:"title" validate:"max=120"`
	Descr string `json:"descr" validate:"max=300"`
	// Private makes the team private before anybody else can see it,
	// rather than public for the moment between create and team.private.
	Private bool `json:"private"`
}

type teamTextIn struct {
	Name  string `json:"name" validate:"required,max=64"`
	Title string `json:"title" validate:"max=120"`
	Descr string `json:"descr" validate:"max=300"`
}

type teamMemberIn struct {
	Name    string `json:"name" validate:"required,max=64"`
	Account string `json:"account" validate:"required,max=64"`
	// Caps is what they get on every repository under the team, ON TOP
	// of the membership floor. Empty is a real state: the floor alone.
	Caps string `json:"caps" validate:"max=64"`
	// Owner makes them a manager OF THE TEAM — its members, its
	// repositories, its text — and nothing about any repository under
	// it. ABSENT leaves an existing member's ownership alone, and makes
	// a new member an ordinary one.
	//
	// A pointer for the same reason as Listed, and here the zero value
	// was worse than confusing: this op is an upsert, so a caller
	// changing an owner's caps without mentioning `owner` DEMOTED
	// them. GuardTeamMember refuses the change that would leave a team
	// with no owner at all, so the last owner was safe — but a team
	// with two lost one silently, and the response said ok.
	Owner *bool `json:"owner"`
	// Listed shows them on the team's public page. ABSENT keeps what an
	// existing member has, and leaves a new member unlisted: being shown
	// is the member's own choice (team.listed), not the team's.
	//
	// A pointer, not a bool with `default=1`, because that default was
	// unreachable in the path that matters: defaults are applied by the
	// form and query codec only, a JSON body goes straight through
	// json.Unmarshal, and applyDefault had no bool case in either
	// event. Two members added over the API without the field landed
	// silently unlisted — withheld from the team's page and absent from
	// its count, which made the opt-out the default for everybody not
	// using the web form. nil is the only way a body can say "I did not
	// mention this", and the opt-out has to stay the member's own
	// choice rather than an artefact of how they were added.
	Listed *bool `json:"listed"`
}

// donatableLimit bounds the add control's options. It is a menu on a
// page, not a listing: past a screenful nobody scrolls it, and a person
// with more repositories than this needs a search rather than a longer
// select. Stated as a constant so the page and the op agree on what
// "the options" means.
const donatableLimit = 200

// overviewRows is how much of each list the team's overview carries.
// The page is a summary and the People screen is the roster, so this is
// "a screenful" and not a page size: past it the overview says how many
// more there are and links to the list that pages properly.
const overviewRows = 12

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

type teamOkOut struct {
	Name string `json:"name"`
	OK   bool   `json:"ok"`
	// Warning is the guard's remark on a change it allowed — a site
	// admin is told a team now has no owner rather than refused, the
	// way GuardCaps treats a repository.
	Warning string `json:"warning,omitempty"`
	// Offered says the call recorded a QUESTION rather than a change:
	// the repository is where it was until one of the team's owners
	// accepts (RFC 0018). OK is still true — the call did what it
	// could — so a caller that ignores this field must not be able to
	// read the result as "it moved".
	Offered bool `json:"offered,omitempty"`
}

func (d Deps) registerTeams(r *ops.Registry) error {
	if err := ops.Register(r, ops.Op[teamListIn, teamListOut]{
		Name: "team.list",
		Doc: "Every team on the forge, with its member and repository counts. Public: a quarry team sits at the level other forges call an organisation, and that level is publicly listable everywhere (RFC 0018). " +
			"`mine` narrows it to the caller's own.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/teams",
		Run: func(ctx context.Context, in teamListIn) (teamListOut, error) {
			if d.Teams == nil {
				return teamListOut{}, errNoTeams()
			}
			var teams []oltp.Team
			var err error
			var total int
			who := in.Account
			if in.Mine || in.Manages {
				who = author(ctx)
			}
			if in.Manages && who == "" {
				return teamListOut{Teams: []oltp.Team{}}, nil
			}
			switch {
			case in.Mine && who == "":
				return teamListOut{Teams: []oltp.Team{}}, nil
			case who != "":
				teams, err = d.Teams.TeamsFor(who)
			default:
				teams, err = d.Teams.Teams()
			}
			if err != nil {
				return teamListOut{}, err
			}
			if in.Manages {
				kept := teams[:0]
				for _, t := range teams {
					if d.mayManageTeam(ctx, t.Name) {
						kept = append(kept, t)
					}
				}
				teams = kept
			}
			// Somebody else's listing honours their opt-out. Their own,
			// and a manager's view of them, does not: the person knows
			// what they chose, and a manager is who has to act on it.
			if who != "" && who != author(ctx) {
				kept := teams[:0]
				for _, t := range teams {
					if m, ok := d.Teams.TeamMemberOf(t.Name, who); ok && !m.Listed &&
						!d.mayManageTeam(ctx, t.Name) {
						continue
					}
					kept = append(kept, t)
				}
				teams = kept
			}
			// A private team is absent for whoever may not see it, and
			// so is its place in the count below.
			hidden := 0
			kept := teams[:0]
			sees := d.teamSeer(ctx)
			for _, t := range teams {
				if !sees(t) {
					hidden++
					continue
				}
				kept = append(kept, t)
			}
			teams = kept
			if teams == nil {
				teams = []oltp.Team{}
			}
			// Total describes the population THIS listing is a window
			// on, not the forge. A filtered listing (mine, or one
			// account's) has already been narrowed and had opt-outs
			// withheld, so its total is what survived that; only the
			// unfiltered directory can take the cheap count.
			if who == "" {
				if n, cerr := d.Teams.TeamCount(); cerr == nil {
					total = n - hidden
				} else {
					total = len(teams)
				}
			} else {
				total = len(teams)
			}
			// The window is applied after the opt-out filter, so a
			// withheld membership cannot leave a short page behind and
			// tell a stranger that something was removed.
			if in.Offset < len(teams) {
				teams = teams[in.Offset:]
			} else {
				teams = nil
			}
			if in.Limit > 0 && len(teams) > in.Limit {
				teams = teams[:in.Limit]
			}
			// The directory shows a count with no list beside it, and
			// answering "may this caller manage it" per row is the
			// query-per-row the folded counts exist to avoid. So the
			// listing is uniformly public: nobody learns an opt-out
			// from it, and a manager reads the true figure on the
			// team's own page. The repository count is the reader's,
			// though: a private repository is not numbered to somebody
			// who cannot read it. That is one query per team holding
			// anything, for the window only.
			for i := range teams {
				teams[i].Unlisted = 0
				teams[i].Repos = d.readableRepoCount(ctx, teams[i])
			}
			return teamListOut{Teams: teams, Total: total}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamGetIn, teamGetOut]{
		Name:  "team.get",
		Doc:   "One team: its text, its repositories, and its members. Members who opted out of being listed are withheld from callers who cannot manage the team; a manager sees everyone, because they are the person who has to.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/teams/{name}",
		Run: func(ctx context.Context, in teamGetIn) (teamGetOut, error) {
			if d.Teams == nil {
				return teamGetOut{}, errNoTeams()
			}
			t, err := d.teamFor(ctx, in.Name)
			if err != nil {
				return teamGetOut{}, err
			}
			out := teamGetOut{Team: t, CanManage: d.mayManageTeam(ctx, in.Name),
				CanDelete: d.mayDeleteTeam(ctx, in.Name), InRoom: d.inTeamRoom(ctx, in.Name)}
			if who := ops.User(ctx); who != "" {
				if m, ok := d.Teams.TeamMemberOf(in.Name, who); ok {
					out.You = &teamYou{Listed: m.Listed}
				}
			}
			if !out.CanManage {
				out.Team.Unlisted = 0
			}
			members, err := d.Teams.TeamMembersPage(in.Name, overviewRows, 0)
			if err != nil {
				return teamGetOut{}, err
			}
			out.Members = []oltp.TeamMember{}
			for _, m := range members {
				// An opt-out is withheld from everyone who cannot
				// manage the team, including the member themselves —
				// a page that showed you your own hidden row would
				// read as though the opt-out had not taken.
				if !m.Listed && !out.CanManage {
					continue
				}
				out.Members = append(out.Members, m)
			}
			// Only the repositories this caller may read, and the count
			// with them: a private repository's name is not on its
			// team's page, nor its place in "N more". Filtered before
			// the window, so a withheld row leaves no short page.
			all, err := d.Teams.TeamRepos(in.Name)
			if err != nil {
				return teamGetOut{}, err
			}
			for _, tr := range all {
				if d.CanRead != nil && !d.CanRead(ctx, tr.Repo) {
					continue
				}
				out.Repos = append(out.Repos, tr)
			}
			out.Team.Repos = len(out.Repos)
			if len(out.Repos) > overviewRows {
				out.Repos = out.Repos[:overviewRows]
			}
			out.MembersShown, out.ReposShown = len(out.Members), len(out.Repos)
			// Only for somebody who can act on it: this is a list of
			// accounts, and a page that cannot offer the control has no
			// reason to carry one.
			if out.CanManage || len(out.MayRemove) > 0 {
				for _, tr := range out.Repos {
					if who := d.wouldLose(in.Name, tr.Repo); len(who) > 0 {
						if out.Losing == nil {
							out.Losing = map[string][]string{}
						}
						out.Losing[tr.Repo] = who
					}
				}
			}
			if out.Repos == nil {
				out.Repos = []oltp.TeamRepo{}
			}
			// Who may take each of these out. Not the same answer for
			// every row: an owner of the team may remove any of them,
			// but a repository admin who is not an owner may remove
			// only their own — and they must be able to, or they have
			// lost control of their own access list (team.repo.remove).
			for _, tr := range out.Repos {
				if out.CanManage || d.maySetRepoTeam(ctx, tr.Repo) {
					out.MayRemove = append(out.MayRemove, tr.Repo)
				}
			}
			// And what they could add — from the caller's OWN GRANTS,
			// which is one query, and never by walking the forge.
			//
			// The first version of this asked maySetRepoTeam for every
			// repository quarry serves. It was correct and it was
			// linear in the size of the forge: measured at 3.2ms for 20
			// repositories, 19ms for 200 and 174ms for 2,000, on a page
			// render. Ticket fbe8dbfcc230 is about exactly that shape.
			// A person administers few repositories however large the
			// forge grows, so the honest scale is the caller's grants.
			//
			// myRepos also carries the site-admin case (an admin with
			// nothing granted falls back to the fleet) and a limit, so
			// this inherits both rather than restating them.
			if offers, err := d.Teams.TeamOffers(in.Name); err == nil {
				// A repository the caller may not see is withheld: an
				// offer must not become a way to learn a private
				// repository exists (RFC 0017's rule that visibility
				// never relaxes).
				for _, o := range offers {
					if _, err := d.repoFor(ctx, o.Repo); err != nil {
						continue
					}
					out.Offered = append(out.Offered, o)
				}
			}
			if author(ctx) != "" && d.Repos != nil && d.Accounts != nil {
				_, mine, err := d.myRepos(ctx, donatableLimit)
				if err == nil {
					for _, name := range mine {
						// Already under a team is left out: add would
						// MOVE it, and a control that quietly takes a
						// repository out of somebody else's team is not
						// an add.
						if d.Teams.TeamOfRepo(name) != "" {
							continue
						}
						// A repository already waiting on an answer is
						// not something to offer again: the control
						// would invite the same question a second
						// time, and the page says elsewhere that it is
						// pending.
						if _, pending, err := d.Teams.TeamOfferFor(name); err == nil && pending {
							continue
						}
						if !d.maySetRepoTeam(ctx, name) {
							continue
						}
						out.Donatable = append(out.Donatable, name)
					}
					sort.Strings(out.Donatable)
				}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamPageIn, teamPeopleOut]{
		Name: "team.people",
		Doc: "Everyone who reaches any repository under this team, and the route each came by: a team role, the membership floor alone, or a string of their own on one of the repositories. " +
			"A non-member holding a grant on one of them is included — the question is who reaches these repositories, and an answer that hid them would be answering a different one. " +
			"Unlisted members are withheld from callers who cannot manage the team.",
		Kind:  ops.Query,
		Auth:  ops.Anonymous,
		Route: "GET /api/v1/teams/{name}/people",
		Run: func(ctx context.Context, in teamPageIn) (teamPeopleOut, error) {
			if d.Teams == nil {
				return teamPeopleOut{}, errNoTeams()
			}
			t, err := d.teamFor(ctx, in.Name)
			if err != nil {
				return teamPeopleOut{}, err
			}
			unread, err := d.unreadableTeamRepos(ctx, t)
			if err != nil {
				return teamPeopleOut{}, err
			}
			out := teamPeopleOut{Team: t, Repos: t.Repos - len(unread),
				CanManage: d.mayManageTeam(ctx, in.Name),
				InRoom:    d.inTeamRoom(ctx, in.Name)}
			out.Team.Repos = out.Repos
			if !out.CanManage {
				out.Team.Unlisted = 0
			}
			// A member who opted out is not shown to the public, and IS
			// shown to whoever has to manage them. Withheld in the query,
			// so the total and the window count only who is shown: a
			// short page or a larger total would say somebody is hiding.
			// Only a MEMBERSHIP is hidden: somebody with a grant of their
			// own on a repository here is on that repository's page
			// regardless, so they stay, as a non-member.
			count, page := d.Teams.TeamPeopleCount, d.Teams.TeamPeoplePage
			if !out.CanManage {
				count, page = d.Teams.TeamShownPeopleCount, d.Teams.TeamShownPeoplePage
			}
			total, err := count(in.Name)
			if err != nil {
				return teamPeopleOut{}, err
			}
			out.Total = total
			// The owner count guards the last owner's Remove, a manager's
			// control; to anyone else it would number owners who chose
			// not to be shown.
			if out.CanManage {
				if out.Owners, err = d.Teams.TeamOwnerCount(in.Name); err != nil {
					return teamPeopleOut{}, err
				}
			}
			var people []oltp.TeamPerson
			if len(unread) == 0 {
				if people, err = page(in.Name, in.Limit, in.Offset); err != nil {
					return teamPeopleOut{}, err
				}
			} else {
				// A grant on a repository this caller cannot read is not
				// shown, and somebody whose only place here is such a
				// grant is not here for them, nor in the total. That
				// needs every row, so the window is applied after; only
				// a team holding a repository the caller cannot read
				// pays for it.
				all, err := page(in.Name, total, 0)
				if err != nil {
					return teamPeopleOut{}, err
				}
				kept := all[:0]
				for _, p := range all {
					direct := p.Direct[:0:0]
					for _, g := range p.Direct {
						if !unread[g.Repo] {
							direct = append(direct, g)
						}
					}
					if !p.Member && len(direct) == 0 {
						continue
					}
					p.Direct = direct
					kept = append(kept, p)
				}
				out.Total = len(kept)
				if in.Offset < len(kept) {
					kept = kept[in.Offset:]
				} else {
					kept = nil
				}
				if in.Limit > 0 && len(kept) > in.Limit {
					kept = kept[:in.Limit]
				}
				people = kept
			}
			out.People = people
			if out.People == nil {
				out.People = []oltp.TeamPerson{}
			}
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamCreateIn, teamOkOut]{
		Name: "team.create",
		Doc: "Make a team. Any signed-in account may call this — the creator becomes its first owner in the same operation, which is where ownership starts, and is what stops a team existing that nobody can manage. " +
			"The name comes from the SAME namespace as accounts and is refused if an account holds it: a grant row names a string, and the forge must never have to guess which kind it is. " +
			"`private` makes it private from the start (see team.private).",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams",
		Run: func(ctx context.Context, in teamCreateIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if err := d.Teams.AddTeam(in.Name, in.Title, in.Descr, author(ctx)); err != nil {
				return teamOkOut{}, refusalAsInvalid(err)
			}
			if in.Private {
				if _, err := d.Teams.SetTeamPrivate(in.Name, true); err != nil {
					return teamOkOut{}, err
				}
			}
			d.raiseTeam(ctx, evt.TeamCreated, in.Name, author(ctx)+" made this team")
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamTextIn, teamOkOut]{
		Name:  "team.text",
		Doc:   "Set a team's display name and its public sentence. The handle never changes: it is what every grant row names.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/text",
		Run: func(ctx context.Context, in teamTextIn) (teamOkOut, error) {
			if err := d.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			if err := d.Teams.SetTeamText(in.Name, in.Title, in.Descr); err != nil {
				return teamOkOut{}, err
			}
			d.raiseTeam(ctx, evt.TeamSettings, in.Name, author(ctx)+" changed the name or description")
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamDeleteIn, teamOkOut]{
		Name:  "team.delete",
		Doc:   "Delete a team, its membership and its repository bindings. `confirm` must equal `name`. An owner may, and so may a site admin, who manages no team but may remove one. The repositories themselves are untouched: a team manages access to them and does not own them, so deleting one must never be able to delete anybody's code.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/delete",
		Run: func(ctx context.Context, in teamDeleteIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if _, err := d.teamFor(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			if !d.mayDeleteTeam(ctx, in.Name) {
				return teamOkOut{}, fmt.Errorf("only an owner of %s or a site admin may delete it: %w",
					in.Name, ops.ErrForbidden)
			}
			if in.Confirm != in.Name {
				return teamOkOut{}, fmt.Errorf("type the team's name to confirm deletion: %w", ops.ErrInvalid)
			}
			// Dissolving a team takes every repository it held out of
			// it, so the same two obligations apply: name who loses
			// access, and tell them afterwards.
			dissolving, _ := d.Teams.TeamRepos(in.Name)
			watches := make([]handsWatch, 0, len(dissolving))
			for _, tr := range dissolving {
				watches = append(watches, d.watchHands(tr.Repo, in.Name))
			}
			d.raiseTeam(ctx, evt.TeamRemoved, in.Name, author(ctx)+" deleted this team")
			if err := d.Teams.DeleteTeam(in.Name); err != nil {
				return teamOkOut{}, err
			}
			for _, w := range watches {
				d.settled(ctx, w, author(ctx))
			}
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamMemberIn, teamOkOut]{
		Name: "team.member",
		Doc: "Put an account in a team, or change what it holds there. `caps` is a capability string granted on every repository under the team, ON TOP of the membership floor every member carries; empty means the floor alone. " +
			"`owner` manages the TEAM and nothing about any repository under it. A change that would leave the team with no owner is refused; a site admin is told instead. " +
			"Omitting `owner` or `listed` keeps what the member already had — it is not the same as sending false — so a change to one field cannot quietly undo another.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/member",
		Run: func(ctx context.Context, in teamMemberIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if err := d.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			// An absent field means "I did not mention this", so it
			// keeps whatever the member already had, and takes the
			// documented default only when there is nothing to keep.
			// Anything else makes this upsert destructive: omitting
			// `listed` would undo somebody's opt-out, and omitting
			// `owner` would take their ownership away.
			cur, existed := d.Teams.TeamMemberOf(in.Name, in.Account)
			listed, owner := false, false
			if existed {
				listed, owner = cur.Listed, cur.Owner
			}
			if in.Listed != nil {
				listed = *in.Listed
			}
			if in.Owner != nil {
				owner = *in.Owner
			}
			warn, err := d.Teams.SetTeamMemberGuarded(author(ctx), in.Name, in.Account, in.Caps, owner, listed)
			if err != nil {
				return teamOkOut{}, refusalAsInvalid(err)
			}
			d.raiseTeam(ctx, evt.TeamMember, in.Name, in.Account+" joined or changed role")
			// Only on arrival: `existed` was read before the write, so
			// this is the one call that turned a non-member into a
			// member. An owner editing caps or listing on somebody
			// already here changes nothing they need telling about.
			if !existed {
				d.TeamMembership(ctx, author(ctx), in.Account, in.Name)
			} else {
				d.TeamRoleChanged(ctx, author(ctx), in.Account, in.Name, cur, oltp.TeamMember{Account: in.Account, Caps: in.Caps, Owner: owner, Listed: listed})
			}
			return teamOkOut{Name: in.Name, OK: true, Warning: warn}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamPrivateIn, teamOkOut]{
		Name: "team.private",
		Doc: "Make a team private, or public again. A private team exists only for its members, its owners and site admins: to anyone else its page, " +
			"its API, its mark and its place in every listing answer as though there were no such team, the rule a private repository follows. " +
			"Each repository under it keeps its own visibility. Only an owner of the team may change this.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/private",
		Run: func(ctx context.Context, in teamPrivateIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if err := d.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			if _, err := d.Teams.SetTeamPrivate(in.Name, in.Private); err != nil {
				return teamOkOut{}, err
			}
			what := "made this team public"
			if in.Private {
				what = "made this team private"
			}
			d.raiseTeam(ctx, evt.TeamSettings, in.Name, author(ctx)+" "+what)
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamListedIn, teamOkOut]{
		Name: "team.listed",
		Doc: "Show yourself on a team's public page, or stop. A member starts unlisted: being on a team is the member's to make public, so " +
			"this is the one setting about a member that only that member changes. Managers see every member either way.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/listed",
		Run: func(ctx context.Context, in teamListedIn) (teamOkOut, error) {
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			who := ops.User(ctx)
			ok, err := d.Teams.SetTeamMemberListed(in.Name, who, in.Listed)
			if err != nil {
				return teamOkOut{}, err
			}
			if !ok {
				return teamOkOut{}, fmt.Errorf("you are not a member of %q: %w", in.Name, ops.ErrNotFound)
			}
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamMemberIn, teamOkOut]{
		Name:  "team.unmember",
		Doc:   "Take an account out of a team. Removing somebody who is not in it is not an error. NOTE that on a private repository this removes ALL of their access: there is nothing beneath the membership floor.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/unmember",
		Run: func(ctx context.Context, in teamMemberIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if err := d.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			_, was := d.Teams.TeamMemberOf(in.Name, in.Account)
			warn, err := d.Teams.RemoveTeamMemberGuarded(author(ctx), in.Name, in.Account)
			if err != nil {
				return teamOkOut{}, refusalAsInvalid(err)
			}
			d.raiseTeam(ctx, evt.TeamMember, in.Name, in.Account+" left")
			if was {
				d.TeamRemoval(ctx, author(ctx), in.Account, in.Name)
			}
			return teamOkOut{Name: in.Name, OK: true, Warning: warn}, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
		Name: "team.repo.add",
		Doc: "Put a repository under a team, moving it if another team held it — a repository is under exactly one. " +
			"Gated on `a` on THAT REPOSITORY, never on the team: otherwise making a team would be a way to take somebody else's repository and grant yourself admin of it.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/repo",
		Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if _, err := d.teamFor(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return teamOkOut{}, err
			}
			if err := d.refuseHeldRepo(ctx, in.Repo); err != nil {
				return teamOkOut{}, err
			}
			if !d.maySetRepoTeam(ctx, in.Repo) {
				return teamOkOut{}, fmt.Errorf("only an admin of %s may put it under a team: %w",
					in.Repo, ops.ErrForbidden)
			}
			// A repository is under exactly one team, so adding it to a
			// second MOVES it — which revokes for one population and
			// grants to another in a single operation. Both halves are
			// watched, and the losing team's members are named in the
			// answer rather than left to discover it.
			//
			// Not refused: whoever may add already holds `a` on the
			// repository, and team.repo.remove accepts that too, so the
			// move grants no authority a remove-then-add would not.
			// What it must not do is happen quietly.
			from := d.Teams.TeamOfRepo(in.Repo)
			// The receiving half (RFC 0018). Holding `a` on the
			// repository says nobody can take it from you; it does not
			// say you may hand a team work it never asked for. Where
			// the actor does not own the team, this is a question, and
			// the answer is an owner's. Where they do, there is nobody
			// to consult.
			if !d.mayManageTeam(ctx, in.Name) {
				if err := d.Teams.OfferTeamRepo(in.Name, in.Repo, author(ctx)); err != nil {
					return teamOkOut{}, err
				}
				// An offer is a team.repo and not a noun of its own.
				// Nothing has moved — that is what an offer MEANS —
				// but it is the team's repository relationship that
				// changed, and the vocabulary is deliberately small
				// (RFC 0005). The summary says which half it is.
				d.raiseTeam(ctx, evt.TeamRepo, in.Name,
					d.repoLabel(in.Repo)+" was offered to this team")
				d.tellTeamOwners(ctx, in.Name,
					author(ctx)+" offered "+d.repoLabel(in.Repo)+" to "+in.Name,
					d.link("/t/"+in.Name+"/settings"))
				return teamOkOut{Name: in.Name, OK: true, Offered: true,
					Warning: fmt.Sprintf("offered to %s; nothing moves until one of its owners accepts", in.Name)}, nil
			}
			out := teamOkOut{Name: in.Name, OK: true}
			if from != "" && from != in.Name {
				out.Warning = d.leavingWarning(ctx, from, in.Repo)
			}
			watch := d.watchHands(in.Repo, from, in.Name)
			if err := d.Teams.SetTeamRepo(in.Name, in.Repo, author(ctx)); err != nil {
				return teamOkOut{}, refusalAsInvalid(err)
			}
			// An offer this supersedes is answered by the act.
			_ = d.Teams.DropTeamOffer(in.Repo)
			d.raiseTeam(ctx, evt.TeamRepo, in.Name, d.repoLabel(in.Repo)+" joined this team")
			d.settled(ctx, watch, author(ctx))
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
		Name:  "team.repo.offer.accept",
		Doc:   "Accept a repository offered to this team, moving it under the team (RFC 0018). An owner's decision: the offer carries no access until this call, and a repository that has moved elsewhere since is refused rather than taken back.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/repo/accept",
		Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if !d.mayManageTeam(ctx, in.Name) {
				return teamOkOut{}, fmt.Errorf("only an owner of %s may accept what is offered to it: %w",
					in.Name, ops.ErrForbidden)
			}
			if err := d.refuseHeldRepo(ctx, in.Repo); err != nil {
				return teamOkOut{}, err
			}
			offer, ok, err := d.Teams.TeamOfferFor(in.Repo)
			if err != nil {
				return teamOkOut{}, err
			}
			// Expired reads as absent, which is what TeamOfferFor
			// already answers: an offer nobody answered in seven days
			// is a question withdrawn.
			if !ok || offer.Team != in.Name {
				return teamOkOut{}, fmt.Errorf("%s is not offered to %s: %w",
					in.Repo, in.Name, ops.ErrNotFound)
			}
			// The offer is consent from the team's side. It is NOT a
			// standing grant from the repository's: the person who
			// offered held `a` when they offered, and may not now. Ask
			// again, at the moment it takes effect.
			if !d.mayOfferStillStands(offer) {
				_ = d.Teams.DropTeamOffer(in.Repo)
				return teamOkOut{}, fmt.Errorf("%s no longer administers %s, so the offer has lapsed: %w",
					offer.OfferedBy, in.Repo, ops.ErrInvalid)
			}
			from := d.Teams.TeamOfRepo(in.Repo)
			out := teamOkOut{Name: in.Name, OK: true}
			if from != "" && from != in.Name {
				out.Warning = d.leavingWarning(ctx, from, in.Repo)
			}
			// The change of hands is reported HERE and not at the
			// offer, because this is when anybody actually gains or
			// loses access.
			watch := d.watchHands(in.Repo, from, in.Name)
			if err := d.Teams.SetTeamRepo(in.Name, in.Repo, offer.OfferedBy); err != nil {
				return teamOkOut{}, refusalAsInvalid(err)
			}
			if err := d.Teams.DropTeamOffer(in.Repo); err != nil {
				return teamOkOut{}, err
			}
			d.raiseTeam(ctx, evt.TeamRepo, in.Name, d.repoLabel(in.Repo)+" joined this team")
			d.tellOfferer(ctx, offer.OfferedBy,
				author(ctx)+" accepted "+d.repoLabel(in.Repo)+" into "+in.Name,
				d.link("/t/"+in.Name))
			d.settled(ctx, watch, author(ctx))
			return out, nil
		},
	}); err != nil {
		return err
	}
	if err := ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
		Name:  "team.repo.offer.decline",
		Doc:   "Decline a repository offered to this team, or withdraw an offer you made. Leaves no state: RFC 0018 keeps no register of who declined whom, and the offer may be made again.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/repo/decline",
		Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			// The team before the offer: for a private team an outsider
			// is answered as for no team, not told an offer exists.
			if !d.maySetRepoTeam(ctx, in.Repo) {
				if _, err := d.teamFor(ctx, in.Name); err != nil {
					return teamOkOut{}, err
				}
			}
			offer, ok, err := d.Teams.TeamOfferFor(in.Repo)
			if err != nil {
				return teamOkOut{}, err
			}
			if !ok || offer.Team != in.Name {
				return teamOkOut{}, fmt.Errorf("%s is not offered to %s: %w",
					in.Repo, in.Name, ops.ErrNotFound)
			}
			// Two people may end an offer: the team it was made to, and
			// whoever made it. A repository admin who changed their
			// mind must not have to ask the team to decline for them.
			if !d.mayManageTeam(ctx, in.Name) && !d.maySetRepoTeam(ctx, in.Repo) {
				return teamOkOut{}, fmt.Errorf("only an owner of %s or an admin of %s may end this offer: %w",
					in.Name, in.Repo, ops.ErrForbidden)
			}
			if err := d.Teams.DropTeamOffer(in.Repo); err != nil {
				return teamOkOut{}, err
			}
			d.raiseTeam(ctx, evt.TeamRepo, in.Name,
				d.repoLabel(in.Repo)+"'s offer was ended")
			// "ended" rather than "declined": either side may end an
			// offer, so an offerer withdrawing their own reaches this
			// path too — and tellOfferer stays silent when the actor IS
			// the offerer, so nobody hears about their own withdrawal.
			d.tellOfferer(ctx, offer.OfferedBy,
				author(ctx)+" declined "+d.repoLabel(in.Repo)+" for "+in.Name,
				d.link("/r/"+in.Repo))
			return teamOkOut{Name: in.Name, OK: true}, nil
		},
	}); err != nil {
		return err
	}
	return ops.Register(r, ops.Op[teamRepoIn, teamOkOut]{
		Name:  "team.repo.remove",
		Doc:   "Take a repository out of its team. EITHER side may do this — an admin of the repository or an owner of the team — because whoever can grant must be able to revoke, or a repository admin loses control of their own access list.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams/{name}/repo/remove",
		Run: func(ctx context.Context, in teamRepoIn) (teamOkOut, error) {
			if err := d.refuseAgentTeams(ctx); err != nil {
				return teamOkOut{}, err
			}
			if d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if err := d.refuseHeldRepo(ctx, in.Repo); err != nil {
				return teamOkOut{}, err
			}
			// The repository must be in the team named, and a private
			// team's name is not confirmed to someone who guessed it.
			// Its admin may know the team, which their settings show.
			repoAdmin := d.maySetRepoTeam(ctx, in.Repo)
			if d.Teams.TeamOfRepo(in.Repo) != in.Name {
				if _, err := d.teamFor(ctx, in.Name); err != nil && !repoAdmin {
					return teamOkOut{}, err
				}
				return teamOkOut{}, fmt.Errorf("%s is not under %s: %w", in.Repo, in.Name, ops.ErrNotFound)
			}
			if !repoAdmin {
				if _, err := d.teamFor(ctx, in.Name); err != nil {
					return teamOkOut{}, err
				}
			}
			if !repoAdmin && !d.mayManageTeam(ctx, in.Name) {
				return teamOkOut{}, fmt.Errorf("only an admin of %s or an owner of %s may take it out: %w",
					in.Repo, in.Name, ops.ErrForbidden)
			}
			out := teamOkOut{Name: in.Name, OK: true,
				Warning: d.handsWarning(in.Name, in.Repo)}
			watch := d.watchHands(in.Repo, in.Name)
			// Checked again where it is removed: a move since the check
			// above leaves it under a team nobody named, and that is
			// not taken out.
			removed, err := d.Teams.RemoveTeamRepoFrom(in.Name, in.Repo)
			if err != nil {
				return teamOkOut{}, err
			}
			if !removed {
				return teamOkOut{}, fmt.Errorf("%s is not under %s: %w", in.Repo, in.Name, ops.ErrNotFound)
			}
			d.raiseTeam(ctx, evt.TeamRepo, in.Name, d.repoLabel(in.Repo)+" left this team")
			d.settled(ctx, watch, author(ctx))
			return out, nil
		},
	})
}

// leavingWarning is handsWarning for a repository leaving team from, to
// a caller who may know that team: one who can see it, or the
// repository's admin. A private team is not named, nor its people, to
// an owner of the receiving team who is neither.
func (d Deps) leavingWarning(ctx context.Context, from, repo string) string {
	if _, err := d.teamFor(ctx, from); err != nil && !d.maySetRepoTeam(ctx, repo) {
		return ""
	}
	return d.handsWarning(from, repo)
}

// unreadableTeamRepos names the team's repositories this caller may
// not read; nil for a team that holds nothing or a forge with no read
// gate.
func (d Deps) unreadableTeamRepos(ctx context.Context, t oltp.Team) (map[string]bool, error) {
	if t.Repos == 0 || d.CanRead == nil {
		return nil, nil
	}
	rows, err := d.Teams.TeamRepos(t.Name)
	if err != nil {
		return nil, err
	}
	var out map[string]bool
	for _, tr := range rows {
		if !d.CanRead(ctx, tr.Repo) {
			if out == nil {
				out = map[string]bool{}
			}
			out[tr.Repo] = true
		}
	}
	return out, nil
}

// readableRepoCount is how many of a team's repositories this caller
// may read: the count a page shows, which must not number a private
// repository the reader cannot see. One query per team, for the window
// a listing returns, and none for a team that holds nothing.
func (d Deps) readableRepoCount(ctx context.Context, t oltp.Team) int {
	unread, err := d.unreadableTeamRepos(ctx, t)
	if err != nil {
		// The store's own count, not a zero that reads as "nothing
		// yet": a failed read is not an empty team.
		return t.Repos
	}
	return t.Repos - len(unread)
}

// requireTeamManager is the gate every management op shares.
func (d Deps) requireTeamManager(ctx context.Context, team string) error {
	if d.Teams == nil {
		return errNoTeams()
	}
	if _, err := d.teamFor(ctx, team); err != nil {
		return err
	}
	if !d.mayManageTeam(ctx, team) {
		return fmt.Errorf("only an owner of %s may change it: %w",
			team, ops.ErrForbidden)
	}
	return nil
}

// refusalAsInvalid carries the guard's own words across the ops
// boundary as a client error rather than a server one.
func refusalAsInvalid(err error) error {
	if err == nil {
		return nil
	}
	// An agent's edge is forbidden, not invalid: no rewording of the
	// request makes it pass (oltp.Forbidden).
	if oltp.IsForbidden(err) {
		return fmt.Errorf("%w: %v", ops.ErrForbidden, err)
	}
	if oltp.IsRefusal(err) {
		return fmt.Errorf("%w: %v", ops.ErrInvalid, err)
	}
	return err
}