Skip to content
quarry Artifact [f0186f5ffb]
Login

Artifact [f0186f5ffb]

Artifact f0186f5ffb38d54d9feef0619edf6a7130f222c801243d3defd9da67b4ec763d:


// accountcaps.go raises the events RFC 0005 declared for a person's
// standing on a repository, and adds the one write path that could not
// be reached from outside the browser.
//
// Three kinds — account.added, account.caps, account.removed — were
// declared and nothing in the tree raised any of them. A webhook
// subscribed to `account` received nothing, ever: the endpoint looked
// configured and was silently dead. The settings page's own "recent
// changes" trail covered repository changes only, so adding a person,
// changing their role and removing them left no trace on the very page
// where they were done. The chat robot and the attention plane were
// equally deaf.
//
// The ticket that asked for this said the CLI and the API could not
// grant a role either, and that is not so: account.grant and
// account.revoke have existed all along. What they could not do is set
// an arbitrary capability STRING — only one of the four preset roles —
// which is the thing the settings editor does, and the reason it wrote
// through no op at all. So there are two changes here and they are
// different in kind: account.caps is a missing door, and the raises are
// a missing signal on three doors that were already open.
//
// All three raise through one helper. Three copies would drift, and the
// drift would be a role change that reaches a webhook when it is made
// one way and vanishes when it is made another.
package api

import (
	"context"
	"fmt"
	"log"

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

// CapsWriter is the optional seam for the raw capability string: read
// what an account holds on a repository, write a new one.
//
// Its own dep rather than a widening of AccountStore: every fake in
// the tree implements that interface, and adding two methods would make
// each of them carry what none of them needs. A forge with no roster
// seam raises no account events — the honest answer, because without
// the before-string there is no way to tell an arrival from a role
// change.
type CapsWriter interface {
	CapsOf(account, repo string) (string, error)
	SetCaps(account, repo, caps string) error
}

// accountEvent picks which of the three kinds a change is.
//
// The roster IS the per-repository capability string, so "joined this
// repository" and "left it" are the transitions in and out of empty.
// Distinct kinds rather than one account.caps carrying a before and an
// after, because a subscriber who only wants to hear about arrivals and
// departures should not have to parse a diff to find them.
func accountEvent(before, after string) evt.Kind {
	switch {
	case before == "" && after != "":
		return evt.AccountAdded
	case after == "":
		return evt.AccountRemoved
	default:
		return evt.AccountCaps
	}
}

// raiseAccountChange is the single raise point for all three kinds.
//
// Called AFTER the write, and never allowed to fail it: the role is
// already changed by the time this runs, so returning an error here
// would tell a caller their save did not happen when it did.
//
// A change that changed nothing raises nothing. These are operational
// events (RFC 0005 §derived: if the delivery is missed, the fact is
// gone), so they must name real transitions — and a page that re-posts
// the same role must not put a line in somebody's inbox saying so.
func (d Deps) raiseAccountChange(ctx context.Context, repo, account, before, after string) {
	if d.Events == nil {
		return
	}
	// The same sentence the settings page has always shown, from the
	// same function, so the event and the page cannot describe one
	// change two ways.
	changed := oltp.Change(before, after)
	if changed == "" {
		return
	}
	kind := accountEvent(before, after)
	// The summary is what every surface renders: the settings trail, a
	// webhook body, the chat robot's line. An event carrying only a
	// kind makes all of them say the kind.
	if err := d.Events.Raise(kind, repo, map[string]any{
		"repo": repo, "account": account, "caps": after,
		"actor":   ops.User(ctx),
		"summary": account + " " + changed,
	}); err != nil {
		log.Printf("events: raise %s: %v", kind, err)
	}
}

// capsNow reads the string an account holds, or "" when the forge
// cannot say. Used to capture the BEFORE of a change, which is what
// tells an arrival from a role change.
func (d Deps) capsNow(account, repo string) string {
	if d.Roster == nil {
		return ""
	}
	caps, err := d.Roster.CapsOf(account, repo)
	if err != nil {
		return ""
	}
	return caps
}

type accountCapsIn struct {
	Repo    string `json:"repo" validate:"required,max=64"`
	Account string `json:"account" validate:"required,max=64"`
	// Caps is the whole new string, not a delta. A role IS its
	// capability string (RFC 0004), so a partial update would need a
	// second grammar for "and also" and "but not" — and the two
	// grammars would eventually disagree about what a role is.
	//
	// EMPTY is meaningful: it removes the person's explicit string on
	// this repository, which is the only way to do it, and the reason
	// this field is not `required`.
	Caps string `json:"caps" validate:"max=64"`
}

type accountCapsOut struct {
	Account string `json:"account"`
	Repo    string `json:"repo"`
	Caps    string `json:"caps"`
	// Changed is the sentence a reader gets: "added as maintainer",
	// "reader → maintainer", "removed from the repository". Empty when
	// the save was a no-op, which a caller needs to be able to tell
	// apart from a refusal.
	Changed string `json:"changed,omitempty"`
	// Warning is the guard's remark on a save it allowed.
	Warning string `json:"warning,omitempty"`
}

// registerAccountCaps adds the arbitrary-capability-string door.
func (d Deps) registerAccountCaps(r *ops.Registry) error {
	return ops.Register(r, ops.Op[accountCapsIn, accountCapsOut]{
		Name: "account.caps",
		Caps: "a",
		Doc: "Set an account's capability string on a repository — the whole string, not a delta, because a role IS its capability string (RFC 0004). " +
			"Where account.grant takes one of four preset roles, this takes any letters the forge understands, which is what the settings editor needs. " +
			"An EMPTY `caps` removes the account's explicit string; the repository's categories still apply. " +
			"Refused by RFC 0008's guard where it applies. Raises account.added, account.caps or account.removed (RFC 0005); `changed` is the sentence those carry. " +
			"Gated on `a`, not site admin: who holds what on a repository is that repository's own business.",
		Kind:  ops.Command,
		Auth:  ops.Token,
		Route: "POST /api/v1/r/{repo}/people/{account}",
		Run: func(ctx context.Context, in accountCapsIn) (accountCapsOut, error) {
			if d.Roster == nil {
				return accountCapsOut{}, fmt.Errorf("this forge cannot set a capability string directly: %w", ops.ErrUnavailable)
			}
			if err := d.refuseUnlessWritable(in.Repo); err != nil {
				return accountCapsOut{}, err
			}
			// The repository must be one quarry serves. A string against
			// a name that resolves to nothing is a row that looks like
			// access and grants none.
			if _, err := d.repoFor(ctx, in.Repo); err != nil {
				return accountCapsOut{}, err
			}
			before, err := d.Roster.CapsOf(in.Account, in.Repo)
			if err != nil {
				return accountCapsOut{}, err
			}
			out := accountCapsOut{Account: in.Account, Repo: in.Repo, Caps: in.Caps}
			// The guard first, and with the ACTOR, because what it
			// refuses depends on who is asking — an admin may not strip
			// the last admin off a repository, themselves included.
			if g, ok := d.Roster.(CapsGuard); ok {
				out.Warning, err = g.GuardCaps(author(ctx), in.Account, in.Repo, in.Caps)
				if err != nil {
					return accountCapsOut{}, err
				}
			}
			if err := d.Roster.SetCaps(in.Account, in.Repo, in.Caps); err != nil {
				return accountCapsOut{}, err
			}
			out.Changed = oltp.Change(before, in.Caps)
			d.raiseAccountChange(ctx, in.Repo, in.Account, before, in.Caps)
			return out, nil
		},
	})
}

// CapsGuard is RFC 0008's refusal seam for the raw string, asked before
// every save with the ACTOR: it answers a warning worth showing, or a
// refusal that stops the save. Optional, like GrantGuard, and for the
// same reason.
type CapsGuard interface {
	GuardCaps(actor, account, repo, caps string) (warning string, err error)
}