Skip to content
quarry Artifact [a69af3c7ca]
Login

Artifact [a69af3c7ca]

Artifact a69af3c7cae139e18688fc970f696585982e273096d4e50d03ae5674d5a14810:


// meagents.go is the web side of RFC 0021's agents: the "Your agents"
// section on /me and an agent's own page. Every act is an agent.* op
// called through the same invoke path the other /me sections use; the
// rules (the operator's letters, the caps, the one-time value) live in
// the ops, and this file only draws them and posts to them.
package web

import (
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"net/url"
	"sort"
	"strings"

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

// meAgentsView is the "Your agents" section. Show is false on a forge
// whose account store has no agents, and for a person who has none and
// may make none: a section with nothing to list and nothing to do is
// not drawn.
type meAgentsView struct {
	Show      bool
	CanCreate bool
	// SiteAdmin offers the opt-in that a new site-wide agent inherits
	// the caller's site-admin flag (RFC 0026, decision 4).
	SiteAdmin bool
	// Teams and Repos are the scopes the create form offers: the
	// caller's teams and the repositories they reach (RFC 0026).
	Teams []string
	Repos []string
	Rows  []agentView
	// Live says some agent holds a token that still works, which is
	// when "revoke every agent token" has something to do.
	Live bool
}

// agentView is one agent as agent.list reports it, plus what the pages
// derive from it.
type agentView struct {
	Name, Operator, Created string
	Disabled, Suspended     string
	// Admin is the site-admin flag, which only a forge agent holds.
	Admin bool
	// Answerable is who answers for a forge, repository or team agent.
	Answerable string
	// Inherit: a person's agent holding its operator's letters wherever
	// Repos has no row (RFC 0026).
	Inherit bool
	// Asks: it may ask its person for what it may not do (RFC 0027).
	Asks bool
	// Scope is where it reaches: "" the site, team:NAME, repo:NAME, or
	// gone (RFC 0026).
	Scope  string
	Tokens []meTokenView
	Repos  []agentRepoView
}

// Dedicated says the agent belongs to a repository or a team rather
// than a person (RFC 0026).
func (a agentView) Dedicated() bool {
	return strings.HasPrefix(a.Operator, "repo:") || strings.HasPrefix(a.Operator, "team:")
}

// Personal says the agent is a person's: not the forge's, not a
// repository's or a team's.
func (a agentView) Personal() bool { return a.Operator != "forge" && !a.Dedicated() }

// OwnerRepo and OwnerTeam name what a dedicated agent belongs to.
func (a agentView) OwnerRepo() string { return strings.TrimPrefix(a.Operator, "repo:") }
func (a agentView) OwnerTeam() string { return strings.TrimPrefix(a.Operator, "team:") }

// ScopeName is a team or repository scope in words: "the team crew",
// "the repository quarry".
func (a agentView) ScopeName() string {
	switch {
	case strings.HasPrefix(a.Scope, "team:"):
		return "the team " + strings.TrimPrefix(a.Scope, "team:")
	case strings.HasPrefix(a.Scope, "repo:"):
		return "the repository " + strings.TrimPrefix(a.Scope, "repo:")
	}
	return a.Scope
}

// Newest is the agent's most recently made token that still works, or
// nil when it holds none.
func (a agentView) Newest() *meTokenView {
	var best *meTokenView
	for i := range a.Tokens {
		t := &a.Tokens[i]
		if t.Ended() {
			continue
		}
		if best == nil || t.Created > best.Created {
			best = t
		}
	}
	return best
}

// agentRepoView is one repository an agent holds a string on. Caps is
// what is stored, Effective what it resolves to under the operator's
// letters and Role the nearest preset to that.
type agentRepoView struct {
	Repo      string `json:"repo"`
	Caps      string `json:"caps"`
	Effective string `json:"effective"`
	// Role is the nearest preset to Caps, which is what the role
	// dropdown shows; EffRole the same for Effective.
	Role     string `json:"role"`
	EffRole  string `json:"-"`
	Sentence string `json:"-"`
}

// within is the stored string's letters that survive the operator's:
// the string read as the role it still amounts to. Effective itself
// carries category letters nobody stored, which no preset would name.
func (r agentRepoView) within() string {
	var kept strings.Builder
	for _, l := range oltp.Closure(r.Caps) {
		if strings.ContainsRune(r.Effective, l) {
			kept.WriteRune(l)
		}
	}
	return kept.String()
}

// Narrowed says the operator's letters took something from the stored
// string.
func (r agentRepoView) Narrowed() bool {
	for _, l := range oltp.Closure(r.Caps) {
		if !strings.ContainsRune(r.Effective, l) {
			return true
		}
	}
	return false
}

// agentPageView is an agent's own page: the agent, the dropdown
// choices its forms offer, and whatever the response that drew it has to
// say once (a new token's value, a clipped grant).
type agentPageView struct {
	agentView
	// Grantable are the repositories the operator reaches that this
	// agent holds nothing on yet.
	Grantable []string
	// Ceilings are the roles a token may be limited to: those whose
	// letters the agent resolves to somewhere.
	Ceilings []oltp.Preset
	Roles    []oltp.Preset
	// NewValue is a token's value, present for the one response that
	// minted it. NewName is that token's name.
	NewValue string
	NewName  string
	Warning  string
	Error    string
	// Site offers the site scope: a forge agent holding the flag, on the
	// page a site admin opened (RFC 0021, Forge agents).
	Site bool
	// TokenRepos are the repositories a token may be narrowed to: the
	// agent's grants, and for an inheriting agent every repository its
	// operator reaches (RFC 0026).
	TokenRepos []string
}

// agentsOf reads agent.list for the caller. A forge whose account store
// has no agents answers an error, which is no section.
func agentsOf(r *ops.Registry, req *http.Request, user string) (meAgentsView, bool) {
	return agentsFor(r, req, user, url.Values{})
}

// agentsFor is agentsOf for the agent.list form given: operator=forge
// is the forge's own agents, which only a site admin is shown.
func agentsFor(r *ops.Registry, req *http.Request, user string, form url.Values) (meAgentsView, bool) {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.list", form)
	if err != nil {
		// A forge with no agents, and a caller who may not manage them
		// (a token), are no section; anything else is worth a trace.
		if !errors.Is(err, ops.ErrUnavailable) && !errors.Is(err, ops.ErrForbidden) {
			log.Printf("web: agent.list for %s: %v", user, err)
		}
		return meAgentsView{}, false
	}
	var l struct {
		Agents []struct {
			Name       string          `json:"name"`
			Operator   string          `json:"operator"`
			Admin      bool            `json:"admin"`
			Created    string          `json:"created"`
			Disabled   string          `json:"disabled"`
			Suspended  string          `json:"suspended"`
			Inherit    bool            `json:"inherit"`
			Asks       bool            `json:"asks"`
			Answerable string          `json:"answerable"`
			Scope      string          `json:"scope"`
			Tokens     []meTokenView   `json:"tokens"`
			Repos      []agentRepoView `json:"repos"`
		} `json:"agents"`
		CanCreate bool `json:"can_create"`
		SiteAdmin bool `json:"site_admin"`
	}
	remarshal(out, &l)
	v := meAgentsView{CanCreate: l.CanCreate, SiteAdmin: l.SiteAdmin}
	for _, a := range l.Agents {
		row := agentView{Name: a.Name, Operator: a.Operator, Admin: a.Admin, Created: a.Created,
			Disabled: a.Disabled, Suspended: a.Suspended, Inherit: a.Inherit, Asks: a.Asks, Scope: a.Scope, Answerable: a.Answerable, Tokens: a.Tokens, Repos: a.Repos}
		for i := range row.Repos {
			g := &row.Repos[i]
			g.EffRole = oltp.Infer(g.within()).Role
			g.Sentence = oltp.Explain(row.Name, oltp.CapSet(g.Effective))
		}
		for _, t := range row.Tokens {
			if !t.Ended() {
				v.Live = true
			}
		}
		v.Rows = append(v.Rows, row)
	}
	v.Show = len(v.Rows) > 0 || v.CanCreate
	return v, true
}

// agentPageOf is one agent's page, or false when the caller has no
// such agent. A site admin's are their own agents and the forge's.
func agentPageOf(r *ops.Registry, req *http.Request, user, name string) (*agentPageView, bool) {
	all, ok := agentsOf(r, req, user)
	if !ok {
		return nil, false
	}
	if forge, ok := agentsFor(r, req, user, url.Values{"operator": {"forge"}}); ok {
		all.Rows = append(all.Rows, forge.Rows...)
	}
	// A repository's or a team's own agent, for its admins or owners.
	if owned, ok := agentsFor(r, req, user, url.Values{"agent": {name}}); ok {
		all.Rows = append(all.Rows, owned.Rows...)
	}
	for _, a := range all.Rows {
		if a.Name != name {
			continue
		}
		p := &agentPageView{agentView: a, Roles: oltp.Presets, Site: a.Operator == "forge" && a.Admin}
		held := map[string]bool{}
		have := ""
		for _, g := range a.Repos {
			held[g.Repo] = true
			have += g.Effective
		}
		have = oltp.Closure(have)
		for _, pr := range oltp.Presets {
			if strings.Trim(oltp.Closure(pr.Caps), have) == "" {
				p.Ceilings = append(p.Ceilings, pr)
			}
		}
		reach := reachableRepos(r, req, user)
		for _, n := range reach {
			// A repository-scoped agent is offered its one repository; a
			// grant anywhere else would do nothing (jva, ^2636).
			if s := strings.TrimPrefix(a.Scope, "repo:"); s != a.Scope && s != n {
				continue
			}
			if !held[n] {
				p.Grantable = append(p.Grantable, n)
			}
		}
		for _, g := range a.Repos {
			if g.Effective != "" {
				p.TokenRepos = append(p.TokenRepos, g.Repo)
			}
		}
		if a.Inherit {
			// It reaches what its operator reaches, less what it is
			// narrowed out of; a ceiling may be any role.
			p.TokenRepos = p.TokenRepos[:0]
			for _, n := range reach {
				if s := strings.TrimPrefix(a.Scope, "repo:"); s != a.Scope && s != n {
					continue
				}
				if !closedRow(a.Repos, n) {
					p.TokenRepos = append(p.TokenRepos, n)
				}
			}
			p.Ceilings = oltp.Presets
		}
		return p, true
	}
	return nil, false
}

// closedRow says the agent's grant on repo is No access.
func closedRow(rows []agentRepoView, repo string) bool {
	for _, g := range rows {
		if g.Repo == repo {
			return g.Role == "No access"
		}
	}
	return false
}

// repoAgentsFor is a repository's own agents for its settings page: nil
// for a caller who may not manage them or a forge without agents, which
// draws no section.
func repoAgentsFor(r *ops.Registry, req *http.Request, repo string) *meAgentsView {
	user := ops.User(req.Context())
	if user == "" {
		return nil
	}
	v, ok := agentsFor(r, req, user, url.Values{"operator": {"repo:" + repo}})
	if !ok {
		return nil
	}
	return &v
}

// myTeams are the caller's teams, the team scopes an agent may take. A
// failure offers none.
func myTeams(r *ops.Registry, req *http.Request, user string) []string {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "team.list", url.Values{"mine": {"true"}})
	if err != nil {
		return nil
	}
	var tl struct {
		Teams []struct {
			Name string `json:"name"`
		} `json:"teams"`
	}
	remarshal(out, &tl)
	var names []string
	for _, t := range tl.Teams {
		names = append(names, t.Name)
	}
	return names
}

// reachableRepos are the repositories the caller can see, which is the
// set agent.grant accepts. A failure offers none rather than failing
// the page.
func reachableRepos(r *ops.Registry, req *http.Request, user string) []string {
	out, err := invoke(ops.WithUser(req.Context(), user), r, "repo.list", nil)
	if err != nil {
		log.Printf("web: repo.list for %s: %v", user, err)
		return nil
	}
	var rl struct {
		Repos []struct {
			Name string `json:"name"`
		} `json:"repos"`
	}
	remarshal(out, &rl)
	var names []string
	for _, repo := range rl.Repos {
		names = append(names, repo.Name)
	}
	sort.Strings(names)
	return names
}

// registerAgentPages wires the agent routes. They live beside the /me
// routes, on the same session cookie.
func registerAgentPages(mux *http.ServeMux, r *ops.Registry, sessions Sessions) {
	var signOffPageOf func(w http.ResponseWriter, req *http.Request, user, problem string)
	signedIn := func(w http.ResponseWriter, req *http.Request) (string, bool) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next=%2Fme", http.StatusSeeOther)
		}
		return user, ok
	}
	// agentPage draws one agent's page. The ops decide what is allowed;
	// a refusal comes back as the page's own sentence.
	agentPage := func(w http.ResponseWriter, req *http.Request, user string, p *agentPageView, code int) {
		renderStatus(r, w, req, agentPageTmpl, pageData{Title: "Agent", Agent: p,
			SettingsJS: true, Appear: appearanceOf(req)}, code)
	}
	// refuse draws the agent's page again with the op's sentence beside
	// the form that caused it.
	refuse := func(w http.ResponseWriter, req *http.Request, user string, err error) {
		name := req.PathValue("name")
		if p, ok := agentPageOf(r, req, user, name); ok {
			p.Error = err.Error()
			agentPage(w, req, user, p, http.StatusBadRequest)
			return
		}
		fail(r, w, req, err)
	}
	// act runs one op for the agent in the path.
	act := func(w http.ResponseWriter, req *http.Request, user, op string, form url.Values) bool {
		form.Set("agent", req.PathValue("name"))
		if _, err := invoke(ops.WithUser(req.Context(), user), r, op, form); err != nil {
			refuse(w, req, user, err)
			return false
		}
		return true
	}
	here := func(req *http.Request) string { return "/me/agents/" + url.PathEscape(req.PathValue("name")) }

	// One request for sign-off on a page of its own (RFC 0027): what the
	// email links to. A session, never a token: a forwarded email or a
	// stolen link approves nothing. The GET only shows; the buttons post.
	signOffPageOf = func(w http.ResponseWriter, req *http.Request, user, problem string) {
		out, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.get", url.Values{"id": {req.PathValue("id")}})
		if err != nil {
			fail(r, w, req, err)
			return
		}
		var got struct {
			Request meSignOff `json:"request"`
		}
		remarshal(out, &got)
		code := http.StatusOK
		if problem != "" {
			code = http.StatusBadRequest
		}
		renderStatus(r, w, req, signOffPageTmpl, pageData{Title: "Approve?", SignOff: &got.Request, Error: problem, OwnH1: true}, code)
	}
	mux.HandleFunc("GET /me/signoffs/{id}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := sessions.Resolve(cookieToken(req))
		if !ok {
			http.Redirect(w, req, "/login?next="+url.QueryEscape(req.URL.RequestURI()), http.StatusSeeOther)
			return
		}
		signOffPageOf(w, req, user, "")
	})
	// A decision on a request for sign-off. Posted, never a link: a
	// request that deletes a repository must not run from a GET.
	mux.HandleFunc("POST /me/signoffs/{id}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		form := url.Values{"id": {req.PathValue("id")}, "approve": {req.FormValue("approve")}}
		// From the request's own page, the answer is that page again,
		// now saying what became of it.
		if req.FormValue("back") == "page" {
			if _, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.decide", form); err != nil {
				signOffPageOf(w, req, user, err.Error())
				return
			}
			http.Redirect(w, req, "/me/signoffs/"+url.PathEscape(req.PathValue("id")), http.StatusSeeOther)
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "signoff.decide", form); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "signoffs", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me#signoffs", http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/agents", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		name := strings.TrimSpace(req.FormValue("name"))
		form := url.Values{"name": {name}, "scope": {req.FormValue("scope")}}
		if req.FormValue("site_admin") != "" {
			form.Set("site_admin", "true")
			if req.FormValue("confirm") != "" {
				form.Set("confirm", "true")
			}
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.new", form); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me/agents/"+url.PathEscape(name), http.StatusSeeOther)
	})
	mux.HandleFunc("POST /me/agents/revoke-all", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if _, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.revoke_all", url.Values{}); err != nil {
			v, verr := meAccountPageOf(r, req, user)
			if verr != nil {
				fail(r, w, req, verr)
				return
			}
			renderMe(r, w, req, user, v, "", "agents", err.Error(), http.StatusBadRequest)
			return
		}
		http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
	})
	mux.HandleFunc("GET /me/agents/{name}", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		p, ok := agentPageOf(r, req, user, req.PathValue("name"))
		if !ok {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/delete", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.delete", url.Values{"name": {req.PathValue("name")}}) {
			http.Redirect(w, req, "/me#agents", http.StatusSeeOther)
		}
	})
	// Minting renders rather than redirects, for the reason the push
	// credential does: the value is readable once, and a redirect would
	// either lose it or carry it in a URL.
	mux.HandleFunc("POST /me/agents/{name}/token", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		form := url.Values{
			"name":    {strings.TrimSpace(req.FormValue("name"))},
			"scopes":  {strings.Join(req.PostForm["scope"], ",")},
			"repos":   {strings.Join(req.PostForm["repo"], ",")},
			"ceiling": {req.FormValue("ceiling")},
		}
		if d := req.FormValue("days"); d != "" {
			form.Set("days", d)
		}
		form.Set("agent", req.PathValue("name"))
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.new", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		var t struct {
			Value string `json:"value"`
			Token struct {
				Name string `json:"name"`
			} `json:"token"`
		}
		remarshal(out, &t)
		p.NewValue, p.NewName = t.Value, t.Token.Name
		// The value is in this body and must not outlive it: no cache,
		// bfcache or intermediary keeps the page, and nothing is leaked
		// through a Referer from it.
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Referrer-Policy", "no-referrer")
		agentPage(w, req, user, p, http.StatusOK)
	})
	// Revoke tokens renders like a mint: the replacement's value is in
	// this body and nowhere else.
	mux.HandleFunc("POST /me/agents/{name}/token/reissue", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.token.reissue", url.Values{"name": {req.PathValue("name")}})
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		var t struct {
			Name  string `json:"name"`
			Value string `json:"value"`
		}
		remarshal(out, &t)
		p.NewValue, p.NewName = t.Value, t.Name
		w.Header().Set("Cache-Control", "no-store")
		w.Header().Set("Referrer-Policy", "no-referrer")
		agentPage(w, req, user, p, http.StatusOK)
	})
	mux.HandleFunc("POST /me/agents/{name}/token/revoke", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.token.revoke", url.Values{"id": {req.FormValue("id")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	mux.HandleFunc("POST /me/agents/{name}/ask", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.ask", url.Values{"agent": {req.PathValue("name")}, "on": {req.FormValue("on")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	mux.HandleFunc("POST /me/agents/{name}/inherit", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if act(w, req, user, "agent.inherit", url.Values{"agent": {req.PathValue("name")}, "on": {req.FormValue("on")}}) {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
		}
	})
	// One door for a role change, a new grant and a removal: an empty
	// role is the op's own revoke.
	mux.HandleFunc("POST /me/agents/{name}/grant", func(w http.ResponseWriter, req *http.Request) {
		user, ok := signedIn(w, req)
		if !ok {
			return
		}
		if err := req.ParseForm(); err != nil {
			fail(r, w, req, err)
			return
		}
		role := req.FormValue("role")
		// An empty role is the op's revoke, so only the Remove button
		// and an inheriting agent's explicit "No access" may send one: a
		// dropdown left on its blank "Custom" must not take an agent's
		// access away.
		if req.FormValue("revoke") != "" || role == "none" {
			role = ""
		} else if role == "" && req.FormValue("inherit") == "" {
			refuse(w, req, user, fmt.Errorf("choose a role: %w", ops.ErrInvalid))
			return
		}
		form := url.Values{"repo": {req.FormValue("repo")}, "role": {role}, "agent": {req.PathValue("name")}}
		if req.FormValue("inherit") != "" {
			form.Set("inherit", "true")
		}
		out, err := invoke(ops.WithUser(req.Context(), user), r, "agent.grant", form)
		if err != nil {
			refuse(w, req, user, err)
			return
		}
		var g struct {
			Warning string `json:"warning"`
		}
		remarshal(out, &g)
		if g.Warning == "" {
			http.Redirect(w, req, here(req), http.StatusSeeOther)
			return
		}
		p, found := agentPageOf(r, req, user, req.PathValue("name"))
		if !found {
			fail(r, w, req, notYourAgent(req.PathValue("name")))
			return
		}
		p.Warning = g.Warning
		agentPage(w, req, user, p, http.StatusOK)
	})
}

func notYourAgent(name string) error {
	return fmt.Errorf("no agent of yours is called %s: %w", name, ops.ErrNotFound)
}

// Limit names what a token is limited to: the role its ceiling is, or
// the letters when it is none.
func (t meTokenView) Limit() string {
	if n := oltp.PresetOf(t.Ceiling); n != "" {
		return n
	}
	return t.Ceiling
}

// meAgentsTmpl is the "Your agents" section of /me.
const meAgentsTmpl = `{{define "me-agents"}}{{with .V}}{{if .Show}}<section id="agents" data-hook="account-agents">
<h2>Your agents</h2>
{{if $.Error}}<p class="status err" role="alert"><strong>{{$.Error}}</strong></p>{{end}}
{{if .Rows}}<div class="dt-wrap" data-hook="agent-list"><table class="dt dt-stack"><thead><tr>
<th>Agent</th><th>Repositories</th><th>Newest token</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Rows}}<tr data-hook="agent-row" data-agent="{{.Name}}">
<td><span class="who">{{userIcon .Name}} <a href="/me/agents/{{pathesc .Name}}" data-hook="agent-link">{{.Name}}</a></span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</td>
<td data-label="Repositories" data-hook="agent-repos">{{range $i, $g := .Repos}}{{if $i}}, {{end}}<a href="{{repoURL $g.Repo}}">{{$g.Repo}}</a>{{else}}&mdash;{{end}}</td>
<td data-label="Newest token" data-hook="agent-newest">{{with .Newest}}{{.Name}} <span class="sub">{{ago .Created}}</span>{{else}}&mdash;{{end}}</td>
<td class="dt-act"><form method="post" action="/me/agents/{{pathesc .Name}}/delete" class="inline"><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete</button></form></td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .CanCreate}}<form method="post" action="/me/agents" class="acct-email-form" data-hook="agent-new">
<label class="sr-only" for="agent-new-name">Agent name</label>
<input id="agent-new-name" name="name" maxlength="64" required placeholder="name">
<label class="sr-only" for="agent-new-scope">Reaches</label>
<select id="agent-new-scope" name="scope" data-hook="agent-new-scope"><option value="">everywhere you reach</option>{{with .Teams}}<optgroup label="one team">{{range .}}<option value="team:{{.}}">{{.}}</option>{{end}}</optgroup>{{end}}{{with .Repos}}<optgroup label="one repository">{{range .}}<option value="repo:{{.}}">{{.}}</option>{{end}}</optgroup>{{end}}</select>
{{if .SiteAdmin}}<p class="sub" data-hook="agent-new-site"><label><input type="checkbox" name="site_admin" value="true"> also a site admin while you are one (only with &ldquo;everywhere you reach&rdquo;)</label><br>
<label><input type="checkbox" name="confirm" value="true"> I understand anyone holding its token can then manage the whole forge</label></p>{{end}}
<button type="submit" class="btn-primary">New agent</button>
</form>{{end}}
{{if .Live}}<form method="post" action="/me/agents/revoke-all"><p><button type="submit" class="btn-warn" data-hook="agents-revoke-all" data-confirm="Every token of every agent of yours stops working now.">Revoke every agent token</button></p></form>{{end}}
</section>{{end}}{{end}}{{end}}`

// agentPageTmpl is an agent's own page.
var agentPageTmpl = page(template.Must(notifyBase.clone().Parse(`{{define "content"}}
{{template "account-styles"}}
{{with .Agent}}{{$name := .Name}}{{$agent := .}}
<p><a href="/me#agents" data-hook="agent-back">Your agents</a></p>
<p data-hook="agent-head">{{userIcon .Name}} <strong>{{.Name}}</strong> <span class="agent-tag" data-hook="agent-mark">agent</span>{{if or .Disabled .Suspended}} <span class="b-pill" data-hook="agent-off">off</span>{{end}}</p>
{{if eq .Operator "forge"}}<p data-hook="agent-operator">forge agent</p>{{else if .Dedicated}}<p data-hook="agent-operator" data-owner="{{.Operator}}">belongs to {{if eq .Operator (print "repo:" .OwnerRepo)}}the repository <a href="{{repoURL .OwnerRepo}}">{{.OwnerRepo}}</a>{{else}}the team <a href="/t/{{pathesc .OwnerTeam}}">{{.OwnerTeam}}</a>{{end}}{{with .Answerable}} &middot; answerable: <a href="/people/{{pathesc .}}">{{.}}</a>{{end}}</p>{{else}}<p data-hook="agent-operator">operated by <a href="/people/{{pathesc .Operator}}">{{.Operator}}</a></p>{{end}}
{{if .Error}}<p class="status err" role="alert" data-hook="agent-error"><strong>{{.Error}}</strong></p>{{end}}
{{if .Warning}}<p class="status" role="status" data-hook="agent-warning">{{.Warning}}</p>{{end}}
{{if .NewValue}}<div class="agent-once" data-hook="agent-token-once">
<p><strong>{{.NewName}}</strong> <span class="chip">shown once</span></p>
<pre class="push-token" data-hook="agent-token-value">{{.NewValue}}</pre>
<p data-signals:_copied="false"><button type="button" class="btn" data-hook="agent-token-copy" data-on:click="navigator.clipboard.writeText(evt.currentTarget.parentElement.previousElementSibling.textContent).then(() => { $_copied = true; setTimeout(() => { $_copied = false }, 4000) })" data-text="$_copied ? 'Copied' : 'Copy'">Copy</button> <span class="sr-only" role="status" aria-live="polite" data-hook="agent-token-copied" data-text="$_copied ? 'token copied' : ''"></span></p>
</div>{{end}}
<section id="asking" data-hook="agent-asking">
<h2>Asking</h2>
<form method="post" action="/me/agents/{{pathesc .Name}}/ask" class="inline" data-hook="agent-ask" data-asks="{{.Asks}}">
{{if .Asks}}<p>{{.Name}} <strong>may ask you</strong> for what it cannot do itself. Each request reaches you by inbox, push and email, and runs only if you approve it, as you.</p>
<p><input type="hidden" name="on" value="false"><button type="submit" class="btn" data-hook="agent-ask-off">Stop it asking</button></p>
{{else}}<p>{{.Name}} <strong>does not ask you</strong>: what it cannot do itself is refused.</p>
<p><input type="hidden" name="on" value="true"><button type="submit" class="btn" data-hook="agent-ask-on">Let it ask</button></p>
{{end}}</form>
</section>
<section id="access" data-hook="agent-access">
<h2>Access</h2>
{{if .Personal}}<p data-hook="agent-scope" data-scope="{{.Scope}}">Reaches {{if eq .Scope ""}}every repository you reach{{else if eq .Scope "gone"}}nothing: the team or repository it was made for was deleted{{else}}{{.ScopeName}} only{{end}}.</p>{{end}}
{{if .Personal}}<form method="post" action="/me/agents/{{pathesc .Name}}/inherit" class="inline" data-hook="agent-inherit" data-inherit="{{.Inherit}}">
{{if .Inherit}}<p>{{.Name}} <strong>inherits your access</strong>: on {{if .Scope}}{{.ScopeName}}{{else}}every repository you reach{{end}} it holds what you hold, except where you narrow it below. Never your site-admin flag.</p>
<p><input type="hidden" name="on" value="false"><button type="submit" class="btn" data-hook="agent-inherit-off">Only where set</button></p>
{{else}}<p>{{.Name}} holds <strong>only the access set below</strong>; anywhere else, only what every signed-in account holds.</p>
<p><input type="hidden" name="on" value="true"><button type="submit" class="btn" data-hook="agent-inherit-on">Inherit my access</button></p>
{{end}}</form>{{end}}
{{if .Dedicated}}<p data-hook="agent-dedicated-access">It holds what {{if eq .Operator (print "repo:" .OwnerRepo)}}the repository&rsquo;s admins grant it on <a href="{{repoURL .OwnerRepo}}/settings#people">its People page</a>, on {{.OwnerRepo}} only{{else}}the team&rsquo;s owners give it as a member on <a href="/t/{{pathesc .OwnerTeam}}">the team&rsquo;s page</a>, on the team&rsquo;s repositories only{{end}}. Never the site-admin flag.</p>
{{range .Repos}}{{if .Effective}}<p data-hook="agent-grant" data-repo="{{.Repo}}" data-effective="{{.Effective}}">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{repoName .Repo}}</a> <span class="chip role">{{.EffRole}}</span> <span class="sub">{{.Sentence}}</span></p>{{end}}{{end}}
{{else}}{{if .Repos}}<div class="dt-wrap"><table class="dt dt-stack"><thead><tr>
<th>Repository</th><th>Role</th><th>Resolves to</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Repos}}{{$g := .}}<tr data-hook="agent-grant" data-repo="{{.Repo}}" data-caps="{{.Caps}}" data-effective="{{.Effective}}">
<td><span class="who">{{repoIcon .Repo}} <a href="{{repoURL .Repo}}">{{repoName .Repo}}</a></span></td>
<td data-label="Role"><form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline">
<input type="hidden" name="repo" value="{{.Repo}}">
<select name="role" data-hook="agent-role" aria-label="role on {{repoName .Repo}}">{{if not (eq .Role "Reader" "Publisher" "Developer" "Maintainer" "Admin")}}<option value="{{if eq .Role "No access"}}none{{end}}" selected>{{.Role}}</option>{{end}}{{range $agent.Roles}}<option value="{{.Name}}"{{if eq .Name $g.Role}} selected{{end}}>{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn" data-hook="agent-role-change">Change</button></form></td>
<td class="agent-resolves" data-label="Resolves to" data-hook="agent-resolves">{{if .Effective}}<span class="chip role" data-hook="agent-effective-role">{{.EffRole}}</span> <code data-hook="agent-effective-caps">{{.Effective}}</code>{{if .Narrowed}} <span class="chip minus" data-hook="agent-narrowed">narrowed</span>{{end}}<span class="sub" data-hook="agent-sentence">{{.Sentence}}</span>{{else}}<span class="d-sub" data-hook="agent-no-access">nothing</span>{{end}}</td>
<td class="dt-act">{{if $agent.Inherit}}<form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="inherit" value="1"><button type="submit" class="btn" data-hook="agent-grant-inherit">Inherit here</button></form>{{else}}<form method="post" action="/me/agents/{{pathesc $name}}/grant" class="inline"><input type="hidden" name="repo" value="{{.Repo}}"><input type="hidden" name="revoke" value="1"><button type="submit" class="btn-warn" data-hook="agent-grant-remove" data-confirm="{{$name}} loses its access to {{.Repo}}.">Remove</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>{{end}}
{{if .Grantable}}<form method="post" action="/me/agents/{{pathesc .Name}}/grant" class="acct-email-form" data-hook="agent-grant-new">
<label class="sr-only" for="agent-grant-repo">Repository</label>
<select id="agent-grant-repo" name="repo">{{range .Grantable}}<option value="{{.}}">{{.}}</option>{{end}}</select>
<label class="sr-only" for="agent-grant-role">Role</label>
<select id="agent-grant-role" name="role">{{if .Inherit}}<option value="none">No access</option>{{end}}{{range .Roles}}<option value="{{.Name}}">{{.Name}}</option>{{end}}</select>
<button type="submit" class="btn-primary">{{if .Inherit}}Narrow{{else}}Give access{{end}}</button>
</form>{{end}}{{end}}
</section>
<section id="tokens" data-hook="agent-tokens">
<h2>Tokens</h2>
{{if .Tokens}}<div class="dt-wrap" data-hook="agent-token-list"><table class="dt dt-stack"><thead><tr>
<th>Name</th><th>Can use</th><th>Repositories</th><th>Limited to</th><th>Ends</th><th>Last used</th><th class="dt-act"></th></tr></thead><tbody>
{{range .Tokens}}<tr data-hook="agent-token" data-token="{{.ID}}" data-state="{{.State}}"{{if .Ended}} class="tok-ended"{{end}}>
<td data-hook="agent-token-name" title="{{.ID}}">{{.Name}}{{if eq .State "revoked"}} <span class="b-pill">revoked</span>{{else if eq .State "expired"}} <span class="b-pill">expired</span>{{else if eq .State "expiring"}} <span class="b-pill b-warn">expires soon</span>{{end}}</td>
<td data-label="Can use" data-hook="agent-token-scope">{{range $i, $s := .Scopes}}{{if $i}}, {{end}}{{$s}}{{end}}</td>
<td data-label="Repositories" data-hook="agent-token-repos">{{range $i, $r := .Repos}}{{if $i}}, {{end}}{{$r}}{{else}}all{{end}}</td>
<td data-label="Limited to" data-hook="agent-token-limit">{{if .Ceiling}}{{.Limit}}{{else}}its role{{end}}</td>
<td data-label="Ends" data-hook="agent-token-expires" data-idle="{{.Idle}}">{{if eq .State "revoked"}}&mdash;{{else}}{{if .Idle}}<span class="sub">if unused until</span> {{end}}<time datetime="{{.Lapses}}">{{if ge (len .Lapses) 10}}{{slice .Lapses 0 10}}{{else}}{{.Lapses}}{{end}}</time>{{end}}</td>
<td data-label="Last used" data-hook="agent-token-used">{{if .LastUsed}}{{ago .LastUsed}}{{else}}&mdash;{{end}}</td>
<td class="dt-act">{{if not .Ended}}<form method="post" action="/me/agents/{{pathesc $name}}/token/revoke" class="inline"><input type="hidden" name="id" value="{{.ID}}"><button type="submit" class="btn-warn" data-hook="agent-token-revoke">Revoke</button></form>{{end}}</td>
</tr>{{end}}
</tbody></table></div>
{{if .Newest}}<form method="post" action="/me/agents/{{pathesc .Name}}/token/reissue" class="inline"><p><button type="submit" class="btn-warn" data-hook="agent-token-reissue" data-confirm="Every token of {{.Name}} stops working now, and one new token like the last one used is shown once.">Revoke tokens</button> <span class="sub">revokes every token and issues one fresh one in its place</span></p></form>{{end}}{{end}}
<p class="sub" data-hook="agent-token-life">A token has no expiry date: it works while it is used, and lapses after 90 days unused.</p>
<form method="post" action="/me/agents/{{pathesc .Name}}/token" data-hook="agent-token-new">
<p><label>Name<br><input name="name" maxlength="80" required></label></p>
<p data-hook="agent-token-scopes">Can use<br><label><input type="checkbox" name="scope" value="sync" checked> sync</label> <label><input type="checkbox" name="scope" value="api" checked> api</label> <label><input type="checkbox" name="scope" value="web"> web</label>{{if .Site}} <label title="carries {{.Name}}'s site-admin flag to the api and web doors; never past a private repository it holds no grant on"><input type="checkbox" name="scope" value="site" data-hook="agent-token-site"> site</label>{{end}}</p>
{{if .TokenRepos}}<p data-hook="agent-token-repos-pick">Repositories<br>{{range .TokenRepos}}<label><input type="checkbox" name="repo" value="{{.}}"> {{repoName .}}</label> {{end}}</p>{{end}}
{{if .Ceilings}}<p><label>Limited to<br><select name="ceiling" data-hook="agent-token-ceiling"><option value="">its role</option>{{range .Ceilings}}<option value="{{.Caps}}">{{.Name}}</option>{{end}}</select></label></p>{{end}}
<p><button type="submit" class="btn-primary">New token</button></p>
</form>
</section>
<form method="post" action="/me/agents/{{pathesc .Name}}/delete"><p><button type="submit" class="btn-warn" data-hook="agent-delete" data-confirm="{{.Name}} is deleted with its tokens and sessions; what it wrote stays under its name.">Delete {{.Name}}</button></p></form>
{{end}}
{{end}}`)))

// signOffPageTmpl is one request for sign-off: who asks, for what and
// why, and the two buttons (RFC 0027, Answering).
var signOffPageTmpl = page(template.Must(template.Must(base.Clone()).Parse(`{{define "content"}}{{with .SignOff}}
<section class="approve" data-hook="signoff-page" data-state="{{.State}}">
<h1>{{if eq .State "pending"}}Approve?{{else}}Request {{if eq .State "approved"}}approved{{else if eq .State "failed"}}approved, did not run{{else}}closed{{end}}{{end}}</h1>
<p class="approve-title"><strong data-hook="signoff-agent">{{.Agent}}</strong> asks you to run <code data-hook="signoff-summary">{{.Summary}}</code></p>
{{with .Reason}}<p data-hook="signoff-reason">“{{.}}”</p>{{end}}
<p class="tl-meta-inline">asked {{ago .Requested}}{{with .Decided}} · decided {{ago .}}{{end}}</p>
{{with $.Error}}<p class="status err" role="alert"><strong>{{.}}</strong></p>{{end}}
{{if eq .State "pending"}}<p>Approving runs exactly this, as you, with your own permissions; nothing happens until you choose.</p>
<div class="signoff-acts"><form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="false"><input type="hidden" name="back" value="page"><button type="submit" class="btn" data-hook="signoff-deny">Deny</button></form>
<form method="post" action="/me/signoffs/{{.ID}}"><input type="hidden" name="approve" value="true"><input type="hidden" name="back" value="page"><button type="submit" class="btn btn-primary" data-hook="signoff-approve">Approve</button></form></div>
{{else}}{{with .Err}}<p class="tl-meta-inline" data-hook="signoff-err">{{.}}</p>{{end}}{{end}}
<p><a href="/me#signoffs">All your sign-offs</a></p>
</section>{{end}}{{end}}`)))