oficina / meet Public

Clone
git clone https://amadan.net/oficina/meet

Plain git — no account needed to clone.

Download

Download this file

Meet slice 5: the drive surface — Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: A member mints an agent token from Settings; an agent with that token can list, create, link and end meetings over a JSON API, exactly as far as its operator's current standing reaches and never any further; every write it makes is marked as the agent's on the meeting list.

Architecture: One new table, agents, holds a name, the operator, the SHA-256 of a token shown once, and a revocation time. A bearer middleware on /api/v1/* finds the agent by token hash, refuses a revoked one, and re-derives the operator's member standing on every request (§8.1: an operator who leaves takes the agent's reach with them, no revocation step). The five operations of §8.2 reuse the store's existing meeting functions with actor agent:<id>. The list shows a robot badge and the principal <name>~<operator email> on meetings an agent created. Cookies are never honoured on the API and bearers never on HTML routes.

Tech Stack: Go 1.25 (rastrillo v0.26.0, chi, gorm), rastrillo migration generate, the vendored ui partials.

Spec: docs/superpowers/specs/2026-09-07-meet-design.md revision 9: §2.5 (the /api/v1/* row), §7.2 (actors), §8 (the drive surface: §8.1 principals and reach, §8.2 operations, §8.3 MCP deferred, §8.4 native clients), §12 item 5, §13 (the slice-5 gate: the API reach check after an operator's removal), §15. Suite discussion 4 (agents as their own principal claude~paul style, bound to the operator's reach).

Global Constraints

  • CGO_ENABLED=0; make ci on every task; make race where named; the browser gate is untouched by this slice and must stay 31/31 on the final head (run once with the sandbox off).
  • Schema change: the agents table only, added with rastrillo migration generate after editing models.go; read the SQL it writes; never edit a shipped migration.
  • Every query touching user-owned rows goes through scope.Owned where the framework applies it; a row that isn't yours 404s, never 403s. Never bind a request body onto a GORM model: explicit map[string]any + .Select allowlist, or a typed request struct copied field by field.
  • The token is shown exactly once (the Settings page after minting) and stored only as a SHA-256 hex; it never reaches a log line, a response other than the mint page, or an error message. Actor strings are person:<id> and agent:<id> (§7.2).
  • On the API: Authorization: Bearer <token> only; a cookie is ignored; a missing or malformed bearer is 401 {"error":"unauthorized"}; an unknown, revoked, or reach-less token is 404 {"error":"not-found"} byte-identical to an unknown meeting's 404, so nothing about token existence or operator standing is observable. On HTML routes a bearer is ignored.
  • Reach is re-derived on every API request: the agent unrevoked and the operator currently a member (isMember); a change in the allowlist takes effect on the next request (§13's gate).
  • JSON bodies are capped at 16 KB (http.MaxBytesReader, 413 {"error":"too-large"}); malformed or unknown fields are 400 {"error":"bad-request"} (DisallowUnknownFields). Titles 1..200 characters, starts_at RFC 3339 or absent.
  • The only two responses that ever carry a link token are creation and replacement (§8.2). GET /api/v1/meetings/{sqid} returns the count of admitted peers while the meeting runs, never names or chat.
  • A per-agent token bucket on the API: 60 requests per minute, burst 60, 429 with Retry-After (reuse rateLimiter with key agent:<id>).
  • Copy review is waived this round per suite discussion 5; every new string is catalogued in docs/superpowers/copy/2026-09-10-slice-2-strings.md under "## Slice 5".
  • Screens work without JavaScript; the Settings page and its confirm pages are plain forms.
  • Commits: git -c user.name="Paul Campbell" -c user.email="paul@rslw.com" commit, files by name, never .claude/, node_modules or package-lock.json; trailers Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> and Claude-Session: https://claude.ai/code/session_01CzvnuKhLzudvq7jZ7SvVt2.

---

File structure

  • internal/calls/models.go — Agent; internal/calls/migrations/0002_agents.sql (generated).
  • internal/calls/agents.go — the token, store methods: createAgent, agentsFor, revokeAgent, agentByToken, agentByID; principal.
  • internal/calls/handlers_settings.go, templates/settings.html, templates/agent_once.html — the Settings page, the mint result, the revoke confirm page.
  • internal/calls/api.go — the bearer middleware, apiJSON, the five operations; internal/calls/hub/count.go — AdmittedCount(sqid).
  • internal/calls/store.go (meetingView gains the creator's principal), templates/index.html (the badge).
  • internal/callstest/agents_test.go, api_test.go.
  • docs/superpowers/reviews/2026-09-11-slice-5-record.md, spec §15.17.

---

Task 1: The agents table and store

Files:

  • Modify: internal/calls/models.go (add Agent, register in Models), internal/calls/migrations/ (generate 0002_agents.sql)
  • Create: internal/calls/agents.go, internal/calls/agents_test.go

Interfaces:

  • Produces: ``go 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 } func newAgentToken() (token, hash string, err error) // token "mt_" + base64url(32 random bytes); hash = hex(SHA-256(token)) func hashAgentToken(token string) string func (s *store) createAgent(ctx, name string, operator Person) (Agent, string /* token, once */, error) func (s *store) agentsFor(ctx, operator Person) ([]Agent, error) // newest first, revoked included func (s *store) revokeAgent(ctx, id int64, operator Person) (bool, error) // false when not the operator's or already revoked func (s *store) agentByToken(ctx, token string) (Agent, Person /* operator */, bool, error) // false for unknown or revoked func (s *store) agentByID(ctx, id int64) (Agent, Person, bool, error) func principal(ag Agent, operator Person) string // "<name>~<operator email>" func actorForAgent(ag Agent) string // "agent:<id>" ``
  • Name rules: 1..64 characters after trimming, letters, digits, - and _ only (^[A-Za-z0-9_-]{1,64}$), because it appears in the principal before ~.
  • [ ] Step 1: Write the failing tests (internal/calls/agents_test.go; use the package's existing test store helper, see store_test.go)
package meet

import (
	"context"
	"strings"
	"testing"
)

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

(If the package has no testStore helper or itoa, write minimal ones in the test file, opening an in-memory database through the same db.Open path the harness uses and applying Schema.)

  • [ ] Step 2: Run to see them fail: compile error.
  • [ ] Step 3: Implement. Add Agent to models.go and to the Models list, then run rastrillo migration generate (through go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration generate from the module root, as the Makefile's migration-check does) and read the SQL it writes into migrations/0002_agents.sql: it must be a CREATE TABLE agents with the unique index on token_hash and the index on operator_person_id, nothing else. Then agents.go:
package meet

import (
	"context"
	"crypto/rand"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"regexp"
	"strconv"
	"strings"
	"time"

	"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 _")

// 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[:])
}

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.
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
	}
	var op Person
	if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
		if errors.Is(err, gorm.ErrRecordNotFound) {
			return Agent{}, Person{}, false, 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
	}
	var op Person
	if err := s.g.WithContext(ctx).Where("id = ?", ag.OperatorPersonID).Take(&op).Error; err != nil {
		return Agent{}, Person{}, false, err
	}
	return ag, op, true, nil
}

var _ = time.Now
  • [ ] Step 4: Gate: make ci (its migration check must agree), make race.
  • [ ] Step 5: Commit agents: the table, the once-shown token, the store with the trailers, files by name.

---

Task 2: Settings: mint, show once, revoke

Files:

  • Create: internal/calls/handlers_settings.go, internal/calls/templates/settings.html, internal/calls/templates/agent_once.html
  • Modify: internal/calls/app.go (routes inside the requireMember group: GET /settings, POST /settings/agents, GET /settings/agents/{id}/revoke, POST /settings/agents/{id}/revoke), internal/calls/templates/layout.html or the topbar (a "Settings" link for members), internal/calls/templates/index.html (nothing yet; Task 4)
  • Test: internal/callstest/agents_test.go

Interfaces:

  • GET /settings lists the member's agents (name, created, revoked or the principal) with a create form (name) and a Revoke link per live agent; POST /settings/agents mints and renders agent_once.html with the token in a readonly input (the link_once.html pattern) and the principal; GET /settings/agents/{id}/revoke is the confirm page (ui/confirm-form); POST revokes and 303s to /settings; a foreign or unknown id 404s.
  • Strings: "Settings", "Agents", "An agent acts on your behalf with exactly your reach, as <name>~<your address>.", "Name", "Create an agent", "This is the only time this token is shown. Copy it now.", "Revoke", "Revoke <name>? Anything using its token stops working at once.", "Revoked", "No agents yet."
  • [ ] Step 1: Write the failing tests (callstest, the harness's memberWithMeeting/browser/signIn/get/post helpers): a member sees an empty Settings page; creating an agent renders the token once (the page contains mt_ and the principal; a second GET of Settings does not contain the token); the Revoke confirm page and POST work for the operator and 404 for another member (bea@rslw.com) and for a guest; the token never appears in the captured log (newHarnessWithLogger).
  • [ ] Step 2: Implement the handlers in the file's existing style (page(r, data), ErrorPage, actorFor(p)), the templates with the vendored partials (field for the name, confirm-form for revoke, back-nav, page-header, empty-state), and the Settings link in the shell for members.
  • [ ] Step 3: Gate, catalogue the strings, commit settings: mint an agent token once, revoke it.

---

Task 3: The API: bearer, reach, five operations

Files:

  • Create: internal/calls/api.go, internal/calls/hub/count.go (+ test), internal/callstest/api_test.go
  • Modify: internal/calls/app.go (a route group /api/v1 outside the session group, with csrf.Protect NOT applied? Keep the router's global csrf.Protect: it is origin-based and exempts requests without an Origin; confirm an agent's POST with no Origin header passes; if it does not, mount the API before csrf.Protect and say so), internal/calls/ratelimit.go only if a constructor is needed.

Interfaces:

  • func (h *Hub) AdmittedCount(sqid string) int — under h.mu, the number of admitted participations in the room, 0 for no room.
  • Middleware a.requireAgent: reads Authorization, requires Bearer mt_… (else 401 {"error":"unauthorized"}), agentByToken (false → 404 not-found body), a.isMember(operator) (false → the same 404), then a.apiLimiter.allow("agent:"+id, now) (false → 429 with Retry-After), puts agent and operator on the context. Cookies are never read here. Logs one line per refusal with agent_id (only when resolved) and outcome; never the token or its hash; nothing on success.
  • JSON shapes: - GET /api/v1/meetings → 200 {"meetings":[{"sqid","title","starts_at":RFC3339|null,"ended_at":RFC3339|null,"link_present":bool,"created_by":"<principal or person name>"}]} - POST /api/v1/meetings body {"title":"…","starts_at":"RFC3339"?} → 201 {"meeting":{…},"link":"https://…/j/<token>"} (the link URL through a.linkURL(token)), actor agent:<id> - POST /api/v1/meetings/{sqid}/links → 200 {"meeting":{…},"link":"…"}; ended meeting → 409 {"error":"ended"} - POST /api/v1/meetings/{sqid}/end → 200 {"meeting":{…}}; already ended → 409 {"error":"ended"} - GET /api/v1/meetings/{sqid} → 200 {"meeting":{…},"admitted":N} (admitted present only while ended_at is null; it is hub.AdmittedCount) - unknown sqid → 404 {"error":"not-found"}; malformed body/unknown field → 400 {"error":"bad-request"}; > 16 KB → 413 {"error":"too-large"}; title outside 1..200 → 400. - Every response Content-Type: application/json, Cache-Control: no-store.
  • [ ] Step 1: Write the failing tests (api_test.go, with a helper mintAgent(t, x, c) (token string) that drives Settings as the member):
func TestAPIRequiresABearerAndIgnoresCookies(t *testing.T)   // no header → 401; a signed-in cookie alone → 401; "Bearer nope" → 404 with the not-found body; Basic → 401
func TestAPIReachIsTheOperatorsStandingOnEveryRequest(t *testing.T) // list → 200; x.h.SetAllowlist("bea@rslw.com") (operator removed) → next list 404 with the not-found body; SetAllowlist back → 200 again; revoke via Settings → 404
func TestAPIListsCreatesLinksAndEnds(t *testing.T)           // create with title and starts_at → 201, link contains "/j/mt"?? no: the LINK token (not the agent token); GET the meeting → admitted 0; a member joins through the browser harness and is admitted → admitted 1; replace link → a different link; the old link 404s at /j/; end → 200, ended_at set, admitted absent; end again → 409; links on ended → 409; list shows created_by "claude~paul@example.com"
func TestAPIBodyRules(t *testing.T)                          // unknown field 400; title 201 chars 400; empty title 400; bad starts_at 400; 17 KB body 413; a title with "<script>" is stored and listed escaped-as-JSON (a plain string) 
func TestAPILogsCarryNoToken(t *testing.T)                   // newHarnessWithLogger: a success, a 404 refusal, a 401; the buffer never contains the token, its hash, the link token, or the title
func TestAPIRateLimitsPerAgent(t *testing.T)                 // 61 GETs → the 61st is 429 with Retry-After; a second agent is unaffected
func TestAPIWritesAreMarkedAsTheAgents(t *testing.T)        // after a create, the member's HTML list shows the robot badge and the principal for that meeting and not for a member-created one (Task 4's assertion, write it here and let it fail until Task 4)

Also hub/count_test.go: AdmittedCount is 0 for no room, counts admitted only, and never a waiting guest.

  • [ ] Step 2: Implement api.go with a small apiJSON(w, status, body) writer, the middleware, and five handlers that call the store (listMeetings, createMeeting, meetingBySqid, replaceLink, endMeeting) with actorForAgent(ag); hub/count.go. Refusal bodies through one apiNotFound() constant map. No handler reads a cookie. The per-agent limiter: newRateLimiter(1, 60, 4096) (or the existing constructor's shape) stored on app as apiLimiter.
  • [ ] Step 3: Gate: make ci, make race. Commit api: /api/v1 meetings for agents, reach re-derived per request.

---

Task 4: The badge on the list, the record, the spec note

Files:

  • Modify: internal/calls/store.go (meetingView gains CreatedByPrincipal string and ByAgent bool, filled by resolving created_by prefixes agent:<id> through agentByID; person:<id> stays as today), internal/calls/templates/index.html (a robot badge through ui/badge with the lucide bot icon from internal/calls/icons, and the principal after the title: "by claude~paul@example.com"), internal/calls/icons/ (add bot if absent, following the file's pattern), docs/superpowers/copy/2026-09-10-slice-2-strings.md ("## Slice 5"), docs/superpowers/specs/2026-09-07-meet-design.md (§15.17: the API wire, the 401/404 rule, the per-agent limit, the token shape, the actor and principal), docs/superpowers/reviews/2026-09-11-slice-5-record.md
  • Test: internal/callstest/api_test.go's badge test now passes; internal/callstest/room_test.go untouched.
  • [ ] Step 1: Implement the view and the badge; make Task 3's badge test pass.
  • [ ] Step 2: Write §15.17 and the record (rulings from the ledger, per-task dispositions, carried items, the gate note that the browser gate is unchanged and must still pass on the final head, the deploy note: one migration, applied at boot, agents table).
  • [ ] Step 3: Gate: make ci, make race, and make browser once (sandbox off) on the head. Commit list: the robot badge and the principal on an agent's meetings; docs.

---

Self-review

Spec coverage (§12 item 5): agents (1); the five operations (3); the per-request reach check (3, and §13's gate in TestAPIReachIsTheOperatorsStandingOnEveryRequest); the badge (4). §8.1's Settings mint and revoke (2). §8.3 deferred by the spec. §8.4: the API is plain JSON with a bearer, usable by a native client.

Placeholders: none; Task 2's handlers follow the named files' existing style with the partials named.

Type consistency: Agent, createAgent, agentsFor, revokeAgent, agentByToken, agentByID, principal, actorForAgent (1) used in 2, 3, 4; hub.AdmittedCount (3); meetingView.CreatedByPrincipal/ByAgent (4) read by 3's badge test.

Rulings for the controller: token shape mt_ + 32 random bytes base64url (Slides has no implementation to copy; §8.1 says instance-minted); unknown, revoked and reach-less tokens all answer the not-found body, a missing bearer 401; end and links on an ended meeting are 409 ended; the per-agent limit is 60/min; created_by on the API is the principal for agents and the person's display name for members.