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
5 files changed,
+235
−0
internal/meet/agents.go+127 −0internal/meet/agents_test.go+82 −0internal/meet/migrations/0002_agents.sql+6 −0internal/meet/migrations/schema.sql+6 −0internal/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 |
| +} |