// 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)
}