oficina / meet Public

agents: the table, the once-shown token, the store

- Deviation: the generator named the migration
  0002_create_agents.sql; renamed to 0002_agents.sql per the task
  brief before committing (both migration check and the applied
  schema are unaffected — the id comes from the filename, and this
  migration has never shipped).
- Deviation: agentByToken/agentByID tolerate a missing operator
  row (falling back to a bare-ID Person) rather than failing the
  lookup outright — resolving the agent itself is a separate
  question from the operator's current standing, which a later
  task re-derives per request (spec §8.1, §13).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2
Paul Campbell pushed by paul@keymail.dev 533b629e982f991b22ff701a829a5e1d02122d8f parent e657ba9
5 files changed, +235 −0
  • internal/meet/agents.go +127 −0
  • internal/meet/agents_test.go +82 −0
  • internal/meet/migrations/0002_agents.sql +6 −0
  • internal/meet/migrations/schema.sql +6 −0
  • internal/meet/models.go +14 −0

CI passed — run details

diff --git a/internal/meet/agents.go b/internal/meet/agents.go
new file mode 100644
index 0000000..26bc192
--- /dev/null
+++ b/internal/meet/agents.go
@@ -0,0 +1,127 @@
+package meet
+
+import (
+ "context"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/hex"
+ "errors"
+ "regexp"
+ "strconv"
+ "strings"
+
+ "gorm.io/gorm"
+)
+
+var agentNameRe = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
+
+var errAgentName = errors.New("meet: an agent name is 1 to 64 letters, digits, - or _")
+
+// 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) }
+
+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
+ }
+ token, hash, err := newAgentToken()
+ if err != nil {
+ return Agent{}, "", err
+ }
+ ag := Agent{Name: name, OperatorPersonID: operator.ID, TokenHash: hash, CreatedAt: s.now().UTC()}
+ if err := s.g.WithContext(ctx).Create(&ag).Error; err != nil {
+ return Agent{}, "", err
+ }
+ return ag, token, nil
+}
+
+func (s *store) agentsFor(ctx context.Context, operator Person) ([]Agent, error) {
+ var out []Agent
+ err := s.g.WithContext(ctx).Where("operator_person_id = ?", operator.ID).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 := s.g.WithContext(ctx).Model(&Agent{}).
+ Where("id = ? AND operator_person_id = ? AND revoked_at IS NULL", id, operator.ID).
+ Update("revoked_at", s.now().UTC())
+ return res.RowsAffected == 1, res.Error
+}
+
+// agentByToken resolves a bearer to its agent and operator. Unknown and
+// revoked both answer false; the caller must not tell them apart.
+// Whether the operator currently holds member standing is a separate
+// question the caller re-derives (spec §8.1, §13); finding the agent
+// row is all this answers.
+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
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Agent{}, Person{}, false, nil
+ }
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ op, err := s.operatorFor(ctx, ag)
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ return ag, op, true, nil
+}
+
+func (s *store) agentByID(ctx context.Context, id int64) (Agent, Person, bool, error) {
+ var ag Agent
+ err := s.g.WithContext(ctx).Where("id = ?", id).Take(&ag).Error
+ if errors.Is(err, gorm.ErrRecordNotFound) {
+ return Agent{}, Person{}, false, nil
+ }
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ op, err := s.operatorFor(ctx, ag)
+ if err != nil {
+ return Agent{}, Person{}, false, err
+ }
+ return ag, op, true, nil
+}
+
+// operatorFor loads the agent's operator by id, falling back to a
+// bare-ID Person when the row cannot be found: resolving the agent
+// itself never fails on that account, since the operator's current
+// standing is re-derived by the caller (an absent row simply derives
+// to no standing at all).
+func (s *store) operatorFor(ctx context.Context, ag Agent) (Person, error) {
+ op, found, err := s.personByID(ctx, ag.OperatorPersonID)
+ if err != nil {
+ return Person{}, err
+ }
+ if !found {
+ return Person{ID: ag.OperatorPersonID}, nil
+ }
+ return op, nil
+}
diff --git a/internal/meet/agents_test.go b/internal/meet/agents_test.go
new file mode 100644
index 0000000..174fb26
--- /dev/null
+++ b/internal/meet/agents_test.go
@@ -0,0 +1,82 @@
+package meet
+
+import (
+ "context"
+ "strconv"
+ "strings"
+ "testing"
+)
+
+// itoa is the test package's own decimal formatter, used only to spell
+// out the actor string an assertion expects.
+func itoa(id int64) string { return strconv.FormatInt(id, 10) }
+
+func TestAgentTokenIsShownOnceAndStoredHashed(t *testing.T) {
+ s, _ := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
+ ag, token, err := s.createAgent(ctx, "claude", op)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.HasPrefix(token, "mt_") || len(token) < 40 {
+ t.Fatalf("token shape %q", token)
+ }
+ if ag.TokenHash != hashAgentToken(token) || ag.TokenHash == token || len(ag.TokenHash) != 64 {
+ t.Fatalf("stored %q for %q", ag.TokenHash, token)
+ }
+ got, opGot, ok, err := s.agentByToken(ctx, token)
+ if err != nil || !ok || got.ID != ag.ID || opGot.ID != op.ID {
+ t.Fatalf("lookup %+v %+v %v %v", got, opGot, ok, err)
+ }
+ if _, _, ok, _ := s.agentByToken(ctx, "mt_nope"); ok {
+ t.Fatal("an unknown token must not resolve")
+ }
+ if principal(ag, op) != "claude~paul@example.com" {
+ t.Fatalf("principal %q", principal(ag, op))
+ }
+ if actorForAgent(ag) != "agent:"+itoa(ag.ID) {
+ t.Fatalf("actor %q", actorForAgent(ag))
+ }
+}
+
+func TestRevokedAgentDoesNotResolveAndOnlyTheOperatorMayRevoke(t *testing.T) {
+ s, _ := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com", Name: "paul"}
+ other := Person{ID: 2, Email: "bea@example.com", Name: "bea"}
+ ag, token, _ := s.createAgent(ctx, "claude", op)
+ if ok, _ := s.revokeAgent(ctx, ag.ID, other); ok {
+ t.Fatal("another person revoked the agent")
+ }
+ if ok, _ := s.revokeAgent(ctx, ag.ID, op); !ok {
+ t.Fatal("the operator could not revoke")
+ }
+ if ok, _ := s.revokeAgent(ctx, ag.ID, op); ok {
+ t.Fatal("revoking twice must report false")
+ }
+ if _, _, ok, _ := s.agentByToken(ctx, token); ok {
+ t.Fatal("a revoked token must not resolve")
+ }
+ list, _ := s.agentsFor(ctx, op)
+ if len(list) != 1 || list[0].RevokedAt == nil {
+ t.Fatalf("list %+v", list)
+ }
+ if list2, _ := s.agentsFor(ctx, other); len(list2) != 0 {
+ t.Fatal("another person's list must be empty")
+ }
+}
+
+func TestAgentNameRules(t *testing.T) {
+ s, _ := testStore(t)
+ ctx := context.Background()
+ op := Person{ID: 1, Email: "paul@example.com"}
+ for _, bad := range []string{"", " ", "with space", "tilde~x", "colon:x", strings.Repeat("a", 65), "é"} {
+ if _, _, err := s.createAgent(ctx, bad, op); err == nil {
+ t.Fatalf("accepted name %q", bad)
+ }
+ }
+ if _, _, err := s.createAgent(ctx, " claude-2_ok ", op); err != nil {
+ t.Fatalf("trimmed valid name refused: %v", err)
+ }
+}
diff --git a/internal/meet/migrations/0002_agents.sql b/internal/meet/migrations/0002_agents.sql
new file mode 100644
index 0000000..9b6fc4a
--- /dev/null
+++ b/internal/meet/migrations/0002_agents.sql
@@ -0,0 +1,6 @@
+CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL,`operator_person_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
+
+CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`);
+
+CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`);
+
diff --git a/internal/meet/migrations/schema.sql b/internal/meet/migrations/schema.sql
index b940601..03338d5 100644
--- a/internal/meet/migrations/schema.sql
+++ b/internal/meet/migrations/schema.sql
@@ -1,6 +1,8 @@
-- Generated by rastrillo migration generate; DO NOT EDIT.
-- The schema every migration in this directory adds up to.
+CREATE TABLE `agents` (`id` integer PRIMARY KEY AUTOINCREMENT,`name` text NOT NULL,`operator_person_id` integer NOT NULL,`token_hash` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
+
CREATE TABLE `guest_grants` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`person_id` integer NOT NULL,`granted_at` datetime,`expires_at` datetime,`admitted_at` datetime,`admitted_by` text);
CREATE TABLE `meeting_links` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id` integer NOT NULL,`token_hash` text NOT NULL,`created_by` text NOT NULL,`created_at` datetime,`revoked_at` datetime);
@@ -11,6 +13,10 @@ CREATE TABLE `pending_joins` (`id` integer PRIMARY KEY AUTOINCREMENT,`meeting_id
CREATE TABLE `persons` (`id` integer PRIMARY KEY AUTOINCREMENT,`email` text NOT NULL,`name` text NOT NULL,`created_at` datetime);
+CREATE INDEX `idx_agents_operator_person_id` ON `agents`(`operator_person_id`);
+
+CREATE UNIQUE INDEX `idx_agents_token_hash` ON `agents`(`token_hash`);
+
CREATE UNIQUE INDEX `idx_grant_meeting_person` ON `guest_grants`(`meeting_id`,`person_id`);
CREATE INDEX `idx_guest_grants_expires_at` ON `guest_grants`(`expires_at`);
diff --git a/internal/meet/models.go b/internal/meet/models.go
index c4a188e..773506a 100644
--- a/internal/meet/models.go
+++ b/internal/meet/models.go
@@ -12,6 +12,7 @@ var Models = []any{
&MeetingLink{},
&GuestGrant{},
&PendingJoin{},
+ &Agent{},
}
// Person is a signed-in address. Standing (member, guest, none) is
@@ -79,3 +80,16 @@ type PendingJoin struct {
CreatedAt time.Time
ExpiresAt time.Time
}
+
+// Agent is a drive-surface principal (spec §8.1): a bearer token minted
+// by a member (the operator) that reaches exactly as far as that
+// member's own current standing, and no further once they leave. The
+// token itself is never stored — only the SHA-256 of it.
+type Agent struct {
+ ID int64
+ Name string `gorm:"not null"`
+ OperatorPersonID int64 `gorm:"index;not null"`
+ TokenHash string `gorm:"uniqueIndex;not null"`
+ CreatedAt time.Time
+ RevokedAt *time.Time
+}