package calls import ( "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/hex" "errors" "regexp" "strconv" "strings" "amadan.net/rastrillo/rastrillo/scope" "gorm.io/gorm" ) var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) var errAgentName = errors.New("calls: an agent name is 1 to 64 letters, digits, - or _") // errAgentOperator guards against a caller that forgot to load a real // Person before minting: a zero Person is never a legitimate operator. var errAgentOperator = errors.New("calls: an agent needs a real operator") // maxLiveAgents is the operator-level ceiling (fix round 1, item 4): // an operator who can mint agents without limit can mint without // limit, and twenty is generous for the handful-of-automations-per- // person shape this slice is aimed at. Revoked agents do not count // against it — only live ones do. const maxLiveAgents = 20 var errAgentLimit = errors.New("calls: an operator may hold at most 20 live agents") // errAgentDuplicate is a Go-side check (fix round 1, item 5), not a // unique index: a name is free to reuse the moment its old row is // revoked, which a partial unique index would not express as cleanly // as a plain query does. var errAgentDuplicate = errors.New("calls: an operator may not have two live agents with the same name") // newAgentToken mints "mt_" + 32 random bytes, base64url without // padding, and its SHA-256 hex. The token exists in plaintext only on // its way to the one page that shows it (§8.1). func newAgentToken() (token, hash string, err error) { var b [32]byte if _, err := rand.Read(b[:]); err != nil { return "", "", err } token = "mt_" + base64.RawURLEncoding.EncodeToString(b[:]) return token, hashAgentToken(token), nil } func hashAgentToken(token string) string { sum := sha256.Sum256([]byte(token)) return hex.EncodeToString(sum[:]) } // principal is the identity a meeting's creator string is rendered as // on the list (§8.1): the agent's own name, then the operator whose // reach it borrows. func principal(ag Agent, operator Person) string { return ag.Name + "~" + operator.Email } func actorForAgent(ag Agent) string { return "agent:" + strconv.FormatInt(ag.ID, 10) } // createAgent validates the name, refuses a made-up operator, and — // inside one transaction, so a concurrent create from the same // operator cannot slip past either check — refuses past the live cap // or a live duplicate name before minting and storing the token's // hash. func (s *store) createAgent(ctx context.Context, name string, operator Person) (Agent, string, error) { name = strings.TrimSpace(name) if !agentNameRe.MatchString(name) { return Agent{}, "", errAgentName } if operator.ID == 0 { return Agent{}, "", errAgentOperator } token, hash, err := newAgentToken() if err != nil { return Agent{}, "", err } var ag Agent err = s.g.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var live int64 if err := agentsOwnedBy(tx.Model(&Agent{}), operator). Where("revoked_at IS NULL"). Count(&live).Error; err != nil { return err } if live >= maxLiveAgents { return errAgentLimit } var dup int64 if err := agentsOwnedBy(tx.Model(&Agent{}), operator). Where("name = ? AND revoked_at IS NULL", name). Count(&dup).Error; err != nil { return err } if dup > 0 { return errAgentDuplicate } ag = Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()} return tx.Create(&ag).Error }) if err != nil { return Agent{}, "", err } return ag, token, nil } // agentsOwnedBy is the one ownership filter every operator-side agent // query runs through (review S5-5, AGENTS.md's scoped-query rule): an // agent is owned by the person who minted it, and its owner column is // operator_person_id rather than the convention's user_id, so this is // scope.OwnedBy rather than scope.Owned. Written once so no caller // hand-writes the predicate — including the ones inside createAgent's // transaction, which take tx rather than s.g. func agentsOwnedBy(g *gorm.DB, operator Person) *gorm.DB { return scope.OwnedBy(g, "operator_person_id", operator.ID) } func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) { var out []Agent err := agentsOwnedBy(s.g.WithContext(ctx), operator).Order("created_at DESC, id DESC").Find(&out).Error return out, err } // revokeAgent revokes one of the operator's own agents. False when the // row is not theirs or is already revoked: a 404 shape, never a 403. func (s *store) revokeAgent(ctx context.Context, id int64, operator Person) (bool, error) { res := agentsOwnedBy(s.g.WithContext(ctx).Model(&Agent{}), operator). Where("id = ? AND revoked_at IS NULL", id). Update("revoked_at", s.now().UTC()) return res.RowsAffected == 1, res.Error } // agentByToken resolves a bearer to its agent and operator. Unknown, // revoked, and an agent whose operator row no longer exists all // answer false with no error — the caller must not tell them apart // (fix round 1, item 1: fail closed, not a bare-ID Person). Whether // the operator currently holds member standing is a separate question // the caller re-derives (spec §8.1, §13). // // Not an ownership query, and so not agentsOwnedBy's (review S5-5): // there is no operator on this path to scope to. The bearer is what // establishes who the caller is, and the row it resolves to is what // names the operator — scoping the lookup by an operator would mean // already knowing the answer it exists to find. The token hash is the // filter, and it is a secret the caller had to present. func (s *store) agentByToken(ctx context.Context, token string) (Agent, Person, bool, error) { var ag Agent err := s.g.WithContext(ctx).Where("token_hash = ? AND revoked_at IS NULL", hashAgentToken(token)).Take(&ag).Error return s.withOperator(ctx, ag, err) } // agentByID resolves an agent scoped to its operator (fix round 1, // item 2): a foreign or unknown id both answer false, the same 404 // shape scope.Owned gives every other owned row — through // agentsOwnedBy, so the predicate is the framework's and not this // file's (review S5-5). func (s *store) agentByID(ctx context.Context, id int64, operator Person) (Agent, Person, bool, error) { var ag Agent err := agentsOwnedBy(s.g.WithContext(ctx), operator).Where("id = ?", id).Take(&ag).Error return s.withOperator(ctx, ag, err) } // agentAnyByID resolves an agent by id alone, with no operator scope, // and exists for exactly one purpose: rendering the principal of a // write an agent made (§8.1, "robot badge and operator name on every // write"). It is not an ownership check and must never stand in for // one — agentByID is that — but every member already sees every // meeting on the list, so naming which agent created one tells them // nothing the list did not. // // Deliberately outside agentsOwnedBy (review S5-5): the reader of a // list is not the operator of the agents on it, and scoping this // lookup to the reader would blank the principal on every meeting made // by somebody else's agent. Nothing is decided on what it returns. func (s *store) agentAnyByID(ctx context.Context, id int64) (Agent, Person, bool, error) { var ag Agent err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error return s.withOperator(ctx, ag, err) } // withOperator finishes every agent lookup the same way (fix round 1, // item 1): a missing agent row, a driver error, and an agent whose // operator row is gone are all one fail-closed answer, so no caller can // tell them apart and none is ever handed a bare-ID Person. func (s *store) withOperator(ctx context.Context, ag Agent, err error) (Agent, Person, bool, error) { if errors.Is(err, gorm.ErrRecordNotFound) { return Agent{}, Person{}, false, nil } if err != nil { return Agent{}, Person{}, false, err } op, found, err := s.personByID(ctx, ag.OperatorPersonID) if err != nil { return Agent{}, Person{}, false, err } if !found { return Agent{}, Person{}, false, nil } return ag, op, true, nil } // creatorLabel renders a stored actor (§7.2) as the identity a reader // is shown: an agent's principal, ~, or a // person's display name. It is a label and not an authority — nothing // is decided on it, which is also why neither lookup it makes is a // scoped one (review S5-5): the row was already shown to this reader // by the list, and all this adds is the name of who made it — so an // actor that no longer resolves comes back as // the raw actor string rather than failing the whole list, logged the // way store.title logs an unreadable title. func (s *store) creatorLabel(ctx context.Context, actor string) string { kind, rest, found := strings.Cut(actor, ":") if !found { return actor } id, err := strconv.ParseInt(rest, 10, 64) if err != nil { return actor } switch kind { case "agent": ag, op, ok, err := s.agentAnyByID(ctx, id) if err != nil { s.logger.Error("calls: resolve creator agent", "agent_id", id, "err", err) return actor } if !ok { return actor } return principal(ag, op) case "person": p, ok, err := s.personByID(ctx, id) if err != nil { s.logger.Error("calls: resolve creator person", "person_id", id, "err", err) return actor } if !ok { return actor } return p.Name } return actor }