Skip to content
quarry Artifact [b01ede441e]
Login

Artifact [b01ede441e]

Artifact b01ede441e3349c8506320ee405fc78f908127683c69b19b9b26426214b31d95:


// 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, or a site admin
//	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)
	TeamListed(team string, accounts []string) (map[string]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
	RemoveTeamRepo(repo string) 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), "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)
}

// mayManageTeam reports whether the caller may change a team: one of
// its owners, or a site admin.
func (d Deps) mayManageTeam(ctx context.Context, team string) bool {
	user := author(ctx)
	if user == "" || d.Teams == nil {
		return false
	}
	if d.Teams.IsTeamOwner(team, user) {
		return true
	}
	return d.isSiteAdmin(user)
}

// isSiteAdmin answers from the accounts plane, or false without one.
func (d Deps) isSiteAdmin(user string) bool {
	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
}

// 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
	}
	if d.isSiteAdmin(user) {
		return true
	}
	return d.CapsFor != nil && d.CapsFor(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 always stands: their ability to act is not a
// grant on the repository and cannot lapse with one.
func (d Deps) mayOfferStillStands(offer oltp.TeamOffer) bool {
	if offer.OfferedBy == "" {
		return false
	}
	if d.isSiteAdmin(offer.OfferedBy) {
		return true
	}
	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"`
	// 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 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"`
	// 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"`
}

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 means listed;
	// send false to opt out.
	//
	// 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 {
				who = author(ctx)
			}
			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
			}
			// 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
			}
			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
				} 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.
			for i := range teams {
				teams[i].Unlisted = 0
			}
			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, found := d.Teams.TeamByName(in.Name)
			if !found {
				return teamGetOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
			}
			out := teamGetOut{Team: t, CanManage: d.mayManageTeam(ctx, in.Name)}
			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)
			}
			if out.Repos, err = d.Teams.TeamReposPage(in.Name, overviewRows, 0); err != nil {
				return teamGetOut{}, err
			}
			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, found := d.Teams.TeamByName(in.Name)
			if !found {
				return teamPeopleOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
			}
			out := teamPeopleOut{Team: t, Repos: t.Repos,
				CanManage: d.mayManageTeam(ctx, in.Name),
				InRoom:    d.inTeamRoom(ctx, in.Name)}
			if !out.CanManage {
				out.Team.Unlisted = 0
			}
			total, err := d.Teams.TeamPeopleCount(in.Name)
			if err != nil {
				return teamPeopleOut{}, err
			}
			out.Total = total
			if out.Owners, err = d.Teams.TeamOwnerCount(in.Name); err != nil {
				return teamPeopleOut{}, err
			}
			people, err := d.Teams.TeamPeoplePage(in.Name, in.Limit, in.Offset)
			if err != nil {
				return teamPeopleOut{}, err
			}
			// The same withholding team.get does, for the same reason:
			// a member who opted out is not shown to the public, and IS
			// shown to whoever has to manage them.
			// Who opted out, asked one page at a time rather than by
			// reading every member row: the page is bounded now, so
			// this lookup has to be too or the screen still does work
			// proportional to the team.
			// Who opted out, for THIS PAGE, in one query. Asking per
			// row would put the work back in proportion to the page
			// the window exists to bound.
			listed := map[string]bool{}
			if !out.CanManage {
				names := make([]string, 0, len(people))
				for _, p := range people {
					names = append(names, p.Account)
				}
				listed, err = d.Teams.TeamListed(in.Name, names)
				if err != nil {
					return teamPeopleOut{}, err
				}
			}
			out.People = []oltp.TeamPerson{}
			for _, p := range people {
				// Only a MEMBERSHIP is hidden by the opt-out. Somebody
				// with a direct grant on one of the repositories is
				// visible on that repository's own page regardless, so
				// hiding them here would be a privacy promise this
				// screen cannot keep.
				if !out.CanManage && p.Member && !listed[p.Account] {
					if len(p.Direct) == 0 {
						continue
					}
					p.Member, p.Owner, p.Caps = false, false, ""
				}
				out.People = append(out.People, p)
			}
			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.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/teams",
		Run: func(ctx context.Context, in teamCreateIn) (teamOkOut, error) {
			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)
			}
			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[teamGetIn, teamOkOut]{
		Name:  "team.delete",
		Doc:   "Delete a team, its membership and its repository bindings. 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 teamGetIn) (teamOkOut, error) {
			if err := d.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			// 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.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 := true, 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)
			}
			return teamOkOut{Name: in.Name, OK: true, Warning: warn}, 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.requireTeamManager(ctx, in.Name); err != nil {
				return teamOkOut{}, err
			}
			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")
			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 d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if _, found := d.Teams.TeamByName(in.Name); !found {
				return teamOkOut{}, fmt.Errorf("no team named %q: %w", in.Name, ops.ErrNotFound)
			}
			if _, err := d.repoFor(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,
					in.Repo+" was offered to this team")
				d.tellTeamOwners(ctx, in.Name,
					author(ctx)+" offered "+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.handsWarning(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, 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 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)
			}
			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.handsWarning(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, in.Repo+" joined this team")
			d.tellOfferer(ctx, offer.OfferedBy,
				author(ctx)+" accepted "+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 d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			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,
				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 "+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 d.Teams == nil {
				return teamOkOut{}, errNoTeams()
			}
			if !d.maySetRepoTeam(ctx, in.Repo) && !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)
			d.raiseTeam(ctx, evt.TeamRepo, in.Name, in.Repo+" left this team")
			if err := d.Teams.RemoveTeamRepo(in.Repo); err != nil {
				return teamOkOut{}, err
			}
			d.settled(ctx, watch, author(ctx))
			return out, nil
		},
	})
}

// 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 _, found := d.Teams.TeamByName(team); !found {
		return fmt.Errorf("no team named %q: %w", team, ops.ErrNotFound)
	}
	if !d.mayManageTeam(ctx, team) {
		return fmt.Errorf("only an owner of %s or a site admin 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
	}
	if oltp.IsRefusal(err) {
		return fmt.Errorf("%w: %v", ops.ErrInvalid, err)
	}
	return err
}