oficina / meet Public

settings: mint an agent token once, revoke it

- page() and ErrorPage() became methods on *app (mechanical, all
  callers already had a in scope) so Viewer can carry Member and the
  shell can show Settings to members only.
- Handler-side cap of 20 live agents per operator, plus scaffolding
  (errAgentLimit, errAgentDuplicate) for a store-side fix round the
  Task 1 review flagged mid-task; see task-2-report.md Deviations.
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 d9f3b9e85d6e4d89aeb1c8b4a861dadd79833855 parent 533b629
12 files changed, +618 −55
  • docs/superpowers/copy/2026-09-10-slice-2-strings.md +29 −0
  • internal/meet/app.go +4 −0
  • internal/meet/auth.go +5 −5
  • internal/meet/handlers_join.go +6 −6
  • internal/meet/handlers_meetings.go +21 −13
  • internal/meet/handlers_room.go +23 −23
  • internal/meet/handlers_settings.go +196 −0
  • internal/meet/render.go +17 −8
  • internal/meet/templates/agent_once.html +8 −0
  • internal/meet/templates/layout.html +1 −0
  • internal/meet/templates/settings.html +32 −0
  • internal/meettest/agents_test.go +276 −0

CI passed — run details

diff --git a/docs/superpowers/copy/2026-09-10-slice-2-strings.md b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
index d02c819..ceb4596 100644
--- a/docs/superpowers/copy/2026-09-10-slice-2-strings.md
+++ b/docs/superpowers/copy/2026-09-10-slice-2-strings.md
@@ -270,3 +270,32 @@ Developer-facing, in a `console.error` prefix rather than on a screen:
| --- | --- | --- |
| meet: share | `room.js`, the share button's rejection handler | Same shape as `meet: start`, `meet: roster`, `meet: renew`. Never rendered, and it prints an error object — never a capture, a track id or a peer's name. |
| meet: chat | `room.js`, `runBroadcast()` — the broadcast lane's rejection handler | As above. Nothing awaits the pump, so a rejection escaping it would go where neither a person nor a gate could see it. It prints an error object and never a message: §4.4 says nothing of an accepted chat reaches a log line, and that includes this one. |
+
+## Slice 5
+
+The drive surface's Task 2, Settings: mint an agent's token once, show
+it, and revoke it
+(`.superpowers/sdd/2026-09-11-meet-slice-5-drive/task-2-brief.md`).
+Every string below is new; none replaces or renames a string already
+in this file.
+
+| String | Where | Note |
+| --- | --- | --- |
+| Settings | `templates/layout.html`, the account corner beside sign-out; `templates/settings.html`'s `<title>` and `page-header` | The screen's name, in the nav link and again as the page's own title — the same doubling `link_once.html`'s "Your meeting link" and index's "Meetings" already use between a page's `<title>` and its heading. Shown in the shell only when `Viewer.Member` is true (`render.go`'s `page`, now a method so it can ask `a.isMember`): a guest signed into a room they were let into never sees it, because they have no agents to manage. |
+| Agents | `settings.html`, the list section's `<h2>` | The section's name, plural because the screen is the list of them. Not repeated as a page title — "Settings" already owns that. |
+| An agent acts on your behalf with exactly your reach, as *&lt;name&gt;*~*&lt;your address&gt;*. | `agent_once.html`, the sentence under the minted agent's name | Spec §8.1's principal, spelled out in one sentence rather than left as a bare `name~email` string: says what an agent *is* (a delegate with the operator's own reach, no more and no less) at the one moment a person is looking straight at the credential that makes it true. `~` between the two halves is deliberate and unexplained here — it is the same separator `principal()` (`agents.go`) writes into a meeting's creator field, so a person who later sees `scheduler~paul@rslw.com` on a meetings list recognises the shape from this sentence. |
+| Name | `settings.html`, the create form's field label (`ui/field`) | The one field the create form has. Not "Agent name": the field envelope is already inside a "Create an agent" section: heading, then label, would be typed as "Create an agent" / "Agent name" / an agent name — this is `title.html`'s "Title" pattern (a form under its own heading names its field plainly, not by repeating the heading's noun). |
+| Create an agent | `settings.html`, the creation section's `<h2>` and its submit button | One string in two places on purpose, the way `chat.html`'s "Chat" names both a heading and an `aria-label` for the same reason: a button whose action reads differently from the section it sits in is a small tax on every reader, paid for nothing. Says what happens, not "New" or "Add" (`page-header.html`'s own rule for `ActionLabel`, applied here to a plain form button instead). |
+| This is the only time this token is shown. Copy it now. | `agent_once.html`, the paragraph above the readonly token field | `link_once.html`'s "This is the only time this link is shown. Copy it now and send it to the people you are meeting." shortened to fit a token instead of a link: nobody is sent a token the way a meeting link is sent to attendees, so the sentence stops after "Copy it now." rather than inventing an audience for it. |
+| Revoke | `settings.html`, the link on each live agent's row; `handlers_settings.go`'s confirm page, the destructive button (`ui/confirm-form`'s `Label`) | The verb, matching "Replace the link" / "End" on the meetings list: an action link named for what it does, and the same word again as the confirm page's own submit — one control, described consistently on the way to it and on it. |
+| Revoke *&lt;name&gt;*? Anything using its token stops working at once. | `handlers_settings.go`'s `confirmRevokeAgent`, split as `confirm.html`'s `Title` ("Revoke *&lt;name&gt;*?") and `Body` ("Anything using its token stops working at once.") | Same two-part shape as "End the meeting?" / "'*&lt;title&gt;*' will close for everyone and its link will stop working." (`handlers_meetings.go`'s `confirmEnd`): the question names the thing by its own identity (the agent's name, not "this agent"), the body says the one consequence that matters — every request the token would have authorized now fails, immediately, not at some later sweep. "At once" carries that timing; nothing here promises what already-open work using the token will see, because nothing in this slice tracks that. |
+| Revoked | `settings.html`, a revoked row (`ui/status-pill`, default neutral tone) | The one-word state for a row that used to show its principal. A pill, not a link, because there is no action left to take from here — spec's "state is never colour alone" (AGENTS.md): the tone is neutral and the word is what actually carries the meaning. |
+| No agents yet. | `settings.html`, the empty list (`ui/empty-state`) | Deliberately terser than "No meetings yet. Make one above and share its link." (`index.html`'s own empty state): an agent is a less familiar idea than a meeting, and this screen's create form sits directly under the heading with no other section between — the empty state does not need to teach what the form immediately beneath it already explains at a glance. |
+| Give the agent a name of 1 to 64 letters, digits, - or _. | `handlers_settings.go`'s `createAgentHandler`, beside the Name field on `errAgentName` | Spells out the rule `agentNameRe` (`agents.go`) enforces, the same register as "Keep the title under 200 characters." — the limit stated as a fact about the field, not a complaint about what was typed. The submitted name is never echoed back inside this sentence (only into the field's own `Value`, `ui/field`'s ordinary redisplay), so a name containing something worth not repeating verbatim in a message never has to be. |
+| You already have 20 live agents. Revoke one before creating another. | `handlers_settings.go`'s `createAgentHandler`, beside the Name field when the operator is at the cap | The refusal for the twenty-live-agent ceiling the Task 1 review asked for. States the number and the way out, the same shape as the sign-in form's "Too many sign-in attempts just now. Wait a few minutes and try again." Enforced today by the handler's own count (`liveAgentCount`, `maxLiveAgents`) rather than by the store: `store.createAgent` does not yet return a sentinel for this, so the `errAgentLimit` branch this sentence also serves is unreachable until a later fix round wires the check into `createAgent` itself — see `handlers_settings.go`'s comment on `maxLiveAgents` and the task's report. |
+| You already have a live agent with that name. | `handlers_settings.go`'s `createAgentHandler`, beside the Name field on `errAgentDuplicate` | Scaffolding only, per the same Task 1 addendum: `store.createAgent` does not check for a live duplicate name yet, so nothing in this slice can actually produce this refusal — the sentence and its `errors.Is` branch exist now so a later fix round's store-side check renders correctly the moment it starts returning `errAgentDuplicate`, with no copy change needed then. |
+
+Two sentinel errors, `errAgentLimit` and `errAgentDuplicate`
+(`handlers_settings.go`), are declared outside `agents.go` for the
+duration of this task only, and are not user-facing strings themselves
+— see the report's Deviations for why, and what should move where.
diff --git a/internal/meet/app.go b/internal/meet/app.go
index 8b761e2..9f2ad1b 100644
--- a/internal/meet/app.go
+++ b/internal/meet/app.go
@@ -418,6 +418,10 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Post("/meetings/{sqid}/links", a.replaceLink)
r.Get("/meetings/{sqid}/end", a.confirmEnd)
r.Post("/meetings/{sqid}/end", a.endMeeting)
+ r.Get("/settings", a.settings)
+ r.Post("/settings/agents", a.createAgentHandler)
+ r.Get("/settings/agents/{id}/revoke", a.confirmRevokeAgent)
+ r.Post("/settings/agents/{id}/revoke", a.revokeAgentHandler)
})
r.Get("/m/{sqid}/stream", a.stream)
r.Post("/m/{sqid}/signal", a.signal)
diff --git a/internal/meet/auth.go b/internal/meet/auth.go
index 67fba9b..db82a0b 100644
--- a/internal/meet/auth.go
+++ b/internal/meet/auth.go
@@ -149,13 +149,13 @@ func (a *app) requirePerson(next http.Handler) http.Handler {
s, ok := sessions.Current(r)
if !ok || s.Subject == "" {
a.logger.Error("meet: session with no subject reached requirePerson", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
p, err := a.store.ensurePerson(r.Context(), s.Subject)
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p)))
@@ -172,7 +172,7 @@ func (a *app) requireMember(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := currentPerson(r)
if !ok || !a.isMember(p) {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
next.ServeHTTP(w, r)
@@ -201,7 +201,7 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
p, ok := currentPerson(r)
if !ok {
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if dest, handled := a.continuePending(w, r, p); handled {
@@ -238,5 +238,5 @@ func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person)
func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
- render(w, "welcome", page(r, map[string]any{"Email": p.Email}))
+ render(w, "welcome", a.page(r, map[string]any{"Email": p.Email}))
}
diff --git a/internal/meet/handlers_join.go b/internal/meet/handlers_join.go
index fd11c97..5a2b68f 100644
--- a/internal/meet/handlers_join.go
+++ b/internal/meet/handlers_join.go
@@ -13,11 +13,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
link, m, live, err := a.store.liveLink(r.Context(), chi.URLParam(r, "token"))
if err != nil {
a.logger.Error("meet: follow link", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if !live {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
id, signedIn := a.auth.SessionFrom(r)
@@ -29,7 +29,7 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
p, err := a.store.ensurePerson(r.Context(), id.Address)
if err != nil {
a.logger.Error("meet: ensure person", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if !a.isMember(p) {
@@ -41,11 +41,11 @@ func (a *app) followLink(w http.ResponseWriter, r *http.Request) {
_, live, err := a.store.mintGrantViaLink(r.Context(), link.ID, p.ID)
if err != nil {
a.logger.Error("meet: mint grant", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if !live {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
}
@@ -67,7 +67,7 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) {
if session, ok := a.sessionKey(r); ok {
_, openElsewhere = a.hub.LiveForSession(m.Sqid, session)
}
- render(w, "join", page(r, map[string]any{
+ render(w, "join", a.page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
"Floor": !a.relayConfigured(), "OpenElsewhere": openElsewhere,
diff --git a/internal/meet/handlers_meetings.go b/internal/meet/handlers_meetings.go
index c55d544..742b4a6 100644
--- a/internal/meet/handlers_meetings.go
+++ b/internal/meet/handlers_meetings.go
@@ -31,11 +31,19 @@ func (a *app) parseStart(form url.Values) (*time.Time, string) {
return &u, ""
}
+// fmtTime is the instance zone's one date+time rendering, shared by
+// every screen that shows a stored moment (a meeting's start, an
+// agent's minted-at) rather than each keeping its own copy of the
+// layout string.
+func (a *app) fmtTime(t time.Time) string {
+ return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone
+}
+
func (a *app) fmtStart(t *time.Time) string {
if t == nil {
return ""
}
- return t.In(a.cfg.zone).Format("Mon 2 Jan 2006, 15:04") + " " + a.cfg.Zone
+ return a.fmtTime(*t)
}
func (a *app) index(w http.ResponseWriter, r *http.Request) {
@@ -46,7 +54,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
views, err := a.store.listMeetings(r.Context())
if err != nil {
a.logger.Error("meet: list meetings", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
type row struct {
@@ -62,7 +70,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
upcoming = append(upcoming, rw)
}
}
- render(w, "index", page(r, map[string]any{
+ render(w, "index", a.page(r, map[string]any{
"Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
"TitleError": titleErr, "StartError": startErr,
}))
@@ -88,7 +96,7 @@ func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
m, token, err := a.store.createMeeting(r.Context(), title, start, actorFor(p))
if err != nil {
a.logger.Error("meet: create meeting", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
a.renderLinkOnce(w, r, m, token, false)
@@ -111,7 +119,7 @@ func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
m, token, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
if err != nil {
a.logger.Error("meet: start call", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
a.renderLinkOnce(w, r, m, token, false)
@@ -121,7 +129,7 @@ func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
// query string lands in the edge's access logs (Sheets' ruling), and
// the page is the one time the token exists in plaintext.
func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) {
- render(w, "link_once", page(r, map[string]any{
+ render(w, "link_once", a.page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Link": a.linkURL(token), "Replaced": replaced,
}))
@@ -132,11 +140,11 @@ func (a *app) meetingForMember(w http.ResponseWriter, r *http.Request) (Meeting,
m, ok, err := a.store.meetingBySqid(r.Context(), chi.URLParam(r, "sqid"))
if err != nil {
a.logger.Error("meet: load meeting", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return Meeting{}, false
}
if !ok {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return Meeting{}, false
}
return m, true
@@ -147,7 +155,7 @@ func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", page(r, map[string]any{
+ render(w, "confirm", a.page(r, map[string]any{
"Title": "Replace the link?",
"Body": "Everyone holding the current link for \"" + a.store.title(m) + "\" will get a not-found page. You will see the new link once.",
"Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
@@ -161,14 +169,14 @@ func (a *app) replaceLink(w http.ResponseWriter, r *http.Request) {
return
}
if m.EndedAt != nil {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
p, _ := currentPerson(r)
token, err := a.store.replaceLink(r.Context(), m.ID, actorFor(p))
if err != nil {
a.logger.Error("meet: replace link", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
a.renderLinkOnce(w, r, m, token, true)
@@ -179,7 +187,7 @@ func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", page(r, map[string]any{
+ render(w, "confirm", a.page(r, map[string]any{
"Title": "End the meeting?",
"Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
"Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting",
@@ -195,7 +203,7 @@ func (a *app) endMeeting(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
if err := a.store.endMeeting(r.Context(), m.ID, actorFor(p)); err != nil {
a.logger.Error("meet: end meeting", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
// The row is the authority; the hub is told so every open stream
diff --git a/internal/meet/handlers_room.go b/internal/meet/handlers_room.go
index b003161..c6304ef 100644
--- a/internal/meet/handlers_room.go
+++ b/internal/meet/handlers_room.go
@@ -16,25 +16,25 @@ func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Perso
m, ok, err := a.store.meetingBySqid(r.Context(), sqid)
if err != nil {
a.logger.Error("meet: load meeting", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return Meeting{}, Person{}, StandingNone, false
}
if !ok || m.EndedAt != nil {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return Meeting{}, Person{}, StandingNone, false
}
p, _ := currentPerson(r)
st, err := a.standingFor(r.Context(), p, m)
if err != nil {
a.logger.Error("meet: standing", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return Meeting{}, Person{}, StandingNone, false
}
if st == StandingNone {
session, ok := a.sessionKey(r)
if !ok {
a.logger.Error("meet: room access with no session subject", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return Meeting{}, Person{}, StandingNone, false
}
// The fallback is ruling 14's and nothing wider: an admitted
@@ -47,7 +47,7 @@ func (a *app) roomAccess(w http.ResponseWriter, r *http.Request) (Meeting, Perso
// hold their participation, which is the opposite of the rule.
_, kind, live := a.hub.LiveKindForSession(sqid, session)
if !live || kind == hub.KindMember {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return Meeting{}, Person{}, StandingNone, false
}
st = StandingGuest
@@ -64,7 +64,7 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "room", page(r, map[string]any{
+ render(w, "room", a.page(r, map[string]any{
"Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
// Floor and Relay are the same fact read twice, and both
@@ -104,26 +104,26 @@ func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) {
// a page whose POST hub.Remove would then refuse. The two checks
// answer the same 404, so nothing distinguishes them from outside.
if st != StandingMember {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
target := chi.URLParam(r, "peer")
if !validPeerID(hub.PeerID(target)) {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
session, ok := a.sessionKey(r)
if !ok {
a.logger.Error("meet: confirm remove with no session subject", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
name, ok := a.hub.PeerNameFor(m.Sqid, session, hub.PeerID(target))
if !ok {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
- render(w, "confirm", page(r, map[string]any{
+ render(w, "confirm", a.page(r, map[string]any{
"Title": "Remove " + name + " from the call?",
// True for a guest, who has only the link, and for a member, who
// can also come back from the meeting list — one sentence rather
@@ -149,27 +149,27 @@ func (a *app) remove(w http.ResponseWriter, r *http.Request) {
}
target := chi.URLParam(r, "peer")
if !validPeerID(hub.PeerID(target)) {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
session, ok := a.sessionKey(r)
if !ok {
a.logger.Error("meet: remove with no session subject", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
actor, live := a.hub.LiveForSession(m.Sqid, session)
if !live {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
if err := a.hub.Remove(r.Context(), m.Sqid, session, actor, hub.PeerID(target)); err != nil {
if !hubRefusal(err) {
a.logger.Error("meet: remove", "path", r.URL.Path, "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
// Not back to the room: that would open a fresh stream. The holding
@@ -191,14 +191,14 @@ func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) {
session, ok := a.sessionKey(r)
if !ok {
a.logger.Error("meet: confirm takeover with no session subject", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if _, live := a.hub.LiveForSession(m.Sqid, session); !live {
http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
return
}
- render(w, "confirm", page(r, map[string]any{
+ render(w, "confirm", a.page(r, map[string]any{
"Title": "This meeting is open in another tab",
"Body": "Use this tab instead? The other tab will leave the call.",
"Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab",
@@ -221,26 +221,26 @@ func (a *app) takeover(w http.ResponseWriter, r *http.Request) {
st, err := a.standingFor(r.Context(), p, m)
if err != nil {
a.logger.Error("meet: takeover: standing", "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if st == StandingNone {
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
session, ok := a.sessionKey(r)
if !ok {
a.logger.Error("meet: takeover with no session subject", "path", r.URL.Path)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
if _, err := a.hub.Takeover(m.Sqid, session); err != nil {
if !hubRefusal(err) {
a.logger.Error("meet: takeover", "path", r.URL.Path, "err", err)
- ErrorPage(w, r, http.StatusInternalServerError, "")
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
return
}
- ErrorPage(w, r, http.StatusNotFound, "")
+ a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
http.Redirect(w, r, "/m/"+m.Sqid, http.StatusSeeOther)
diff --git a/internal/meet/handlers_settings.go b/internal/meet/handlers_settings.go
new file mode 100644
index 0000000..c4af922
--- /dev/null
+++ b/internal/meet/handlers_settings.go
@@ -0,0 +1,196 @@
+package meet
+
+import (
+ "errors"
+ "net/http"
+ "strconv"
+
+ "github.com/go-chi/chi/v5"
+)
+
+// maxLiveAgents is the operator-level ceiling the reviewer asked for
+// ahead of a written spec number: 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.
+//
+// Enforced here, in the handler, rather than in store.createAgent:
+// the Task 1 review flagged this cap (and a duplicate-live-name
+// refusal) for a fix round that moves both into createAgent as
+// sentinel errors on the store side. errAgentLimit and errAgentDuplicate
+// below are that fix round's sentinels, declared here rather than in
+// agents.go because this task does not write that file. Today
+// store.createAgent never returns either one, so the switch in
+// createAgentHandler that matches them is inert scaffolding and this
+// constant plus liveAgentCount below is what actually enforces the
+// cap. When the fix round lands createAgent's own check, delete
+// maxLiveAgents, liveAgentCount and the handler's own count (keeping
+// only the switch's existing cases), and delete these two var
+// declarations in favour of agents.go's real ones.
+const maxLiveAgents = 20
+
+var (
+ errAgentLimit = errors.New("meet: an operator may hold at most 20 live agents")
+ errAgentDuplicate = errors.New("meet: an operator may not have two live agents with the same name")
+)
+
+// settingsRow is one line of the agent list: never the token, which
+// exists in plaintext only on the page that minted it (spec §8.1).
+// A revoked row shows that and nothing else; a live one shows the
+// principal the meetings list would attribute to it.
+type settingsRow struct {
+ ID int64
+ Name string
+ Created string
+ Revoked bool
+ Principal string
+}
+
+func (a *app) settings(w http.ResponseWriter, r *http.Request) {
+ a.renderSettings(w, r, "", "")
+}
+
+// renderSettings is GET /settings and POST /settings/agents' shared
+// render: name and nameErr repopulate the create form on a validation
+// failure, exactly as renderIndex repopulates the meeting form.
+func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, name, nameErr string) {
+ p, _ := currentPerson(r)
+ agents, err := a.store.agentsFor(r.Context(), p)
+ if err != nil {
+ a.logger.Error("meet: list agents", "err", err)
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ rows := make([]settingsRow, 0, len(agents))
+ for _, ag := range agents {
+ rows = append(rows, settingsRow{
+ ID: ag.ID, Name: ag.Name, Created: a.fmtTime(ag.CreatedAt),
+ Revoked: ag.RevokedAt != nil, Principal: principal(ag, p),
+ })
+ }
+ render(w, "settings", a.page(r, map[string]any{
+ "Agents": rows, "Name": name, "NameError": nameErr,
+ }))
+}
+
+// liveAgentCount is agentsFor filtered to the rows still worth
+// something: this task's cap is a handler-level rule, not a store
+// method, so it counts here rather than growing the store's interface
+// for one caller.
+func (a *app) liveAgentCount(agents []Agent) int {
+ live := 0
+ for _, ag := range agents {
+ if ag.RevokedAt == nil {
+ live++
+ }
+ }
+ return live
+}
+
+// createAgentHandler is POST /settings/agents: mints a token and shows
+// it exactly once, on agent_once.html, alongside the principal string
+// the meetings list will show for anything this agent creates.
+func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) {
+ _ = r.ParseForm()
+ name := r.FormValue("name")
+ p, _ := currentPerson(r)
+ agents, err := a.store.agentsFor(r.Context(), p)
+ if err != nil {
+ a.logger.Error("meet: list agents", "err", err)
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ if a.liveAgentCount(agents) >= maxLiveAgents {
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ a.renderSettings(w, r, name, "You already have 20 live agents. Revoke one before creating another.")
+ return
+ }
+ ag, token, err := a.store.createAgent(r.Context(), name, p)
+ if err != nil {
+ switch {
+ case errors.Is(err, errAgentName):
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ a.renderSettings(w, r, name, "Give the agent a name of 1 to 64 letters, digits, - or _.")
+ case errors.Is(err, errAgentLimit):
+ // Not reachable today (see maxLiveAgents' comment); kept so
+ // the fix round's store-side cap renders correctly the
+ // moment createAgent starts returning it.
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ a.renderSettings(w, r, name, "You already have 20 live agents. Revoke one before creating another.")
+ case errors.Is(err, errAgentDuplicate):
+ w.WriteHeader(http.StatusUnprocessableEntity)
+ a.renderSettings(w, r, name, "You already have a live agent with that name.")
+ default:
+ a.logger.Error("meet: create agent", "err", err)
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
+ }
+ return
+ }
+ render(w, "agent_once", a.page(r, map[string]any{
+ "Name": ag.Name, "Principal": principal(ag, p), "Token": token,
+ }))
+}
+
+// agentForOperator loads the agent named by the URL's id, refusing with
+// the same 404 shape meetingForMember uses (never a 403) whether the id
+// is unknown or belongs to another operator: the two must be
+// indistinguishable from outside.
+//
+// store.agentByID is not itself ownership-scoped (Task 1 review): it
+// answers whether the id exists at all, for anyone. The comparison
+// below is what actually keeps a foreign agent out — done before this
+// function returns true, so nothing downstream ever sees a row it
+// should not. When agentByID's signature grows an operator parameter,
+// call it with p here and this comparison becomes redundant defense in
+// depth rather than the only check.
+func (a *app) agentForOperator(w http.ResponseWriter, r *http.Request) (Agent, bool) {
+ id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
+ if err != nil {
+ a.ErrorPage(w, r, http.StatusNotFound, "")
+ return Agent{}, false
+ }
+ ag, _, ok, err := a.store.agentByID(r.Context(), id)
+ if err != nil {
+ a.logger.Error("meet: load agent", "err", err)
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
+ return Agent{}, false
+ }
+ p, _ := currentPerson(r)
+ if !ok || ag.OperatorPersonID != p.ID {
+ a.ErrorPage(w, r, http.StatusNotFound, "")
+ return Agent{}, false
+ }
+ return ag, true
+}
+
+func (a *app) confirmRevokeAgent(w http.ResponseWriter, r *http.Request) {
+ ag, ok := a.agentForOperator(w, r)
+ if !ok {
+ return
+ }
+ render(w, "confirm", a.page(r, map[string]any{
+ "Title": "Revoke " + ag.Name + "?",
+ "Body": "Anything using its token stops working at once.",
+ "Action": "/settings/agents/" + strconv.FormatInt(ag.ID, 10) + "/revoke",
+ "Label": "Revoke",
+ "CancelHref": "/settings", "BackLabel": "Settings",
+ }))
+}
+
+func (a *app) revokeAgentHandler(w http.ResponseWriter, r *http.Request) {
+ ag, ok := a.agentForOperator(w, r)
+ if !ok {
+ return
+ }
+ p, _ := currentPerson(r)
+ // revokeAgent is idempotent (agents.go): a second revoke of an
+ // already-revoked row of the caller's own answers false with no
+ // error, and that is not this handler's business to distinguish
+ // from the first — ownership was already settled above.
+ if _, err := a.store.revokeAgent(r.Context(), ag.ID, p); err != nil {
+ a.logger.Error("meet: revoke agent", "err", err)
+ a.ErrorPage(w, r, http.StatusInternalServerError, "")
+ return
+ }
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+}
diff --git a/internal/meet/render.go b/internal/meet/render.go
index bb393db..2d2d4fb 100644
--- a/internal/meet/render.go
+++ b/internal/meet/render.go
@@ -17,7 +17,7 @@ var appFS embed.FS
var assets = rastrillo.NewAssets(appFS)
-var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room"}
+var pageNames = []string{"index", "signin", "errors", "welcome", "link_once", "confirm", "join", "room", "settings", "agent_once"}
var pages = map[string]*template.Template{}
@@ -56,9 +56,14 @@ func render(w http.ResponseWriter, name string, data any) {
buf.WriteTo(w)
}
-// Viewer is the signed-in person the shell's account corner names —
-// nothing more, since that corner has nothing else to show yet.
-type Viewer struct{ Email string }
+// Viewer is the signed-in person the shell's account corner names, plus
+// whether they currently hold member standing — the layout's only use
+// for it is deciding whether the Settings link belongs beside sign-out
+// (spec's team-only surface: a guest has no agents to manage).
+type Viewer struct {
+ Email string
+ Member bool
+}
// page builds render's data map for a request that may be signed in:
// it adds "Viewer" when the request carries a signed-in person
@@ -68,16 +73,20 @@ type Viewer struct{ Email string }
// the layout's {{if .Viewer}} renders nothing. A page reached only
// while signed out (templates/signin.html) has no need to call this
// and renders with the plain render(w, name, data) instead.
-func page(r *http.Request, data map[string]any) map[string]any {
+//
+// A method on *app, not a free function, because Viewer.Member needs
+// a.isMember — the team as it stands right now (standing.go) — which
+// a bare *http.Request cannot answer on its own.
+func (a *app) page(r *http.Request, data map[string]any) map[string]any {
if p, ok := currentPerson(r); ok {
- data["Viewer"] = Viewer{Email: p.Email}
+ data["Viewer"] = Viewer{Email: p.Email, Member: a.isMember(p)}
}
return data
}
// ErrorPage renders the one error template with the status and the
// reference rastrillo minted for the log line.
-func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
w.WriteHeader(status)
- render(w, "errors", page(r, map[string]any{"Status": status, "Ref": ref}))
+ render(w, "errors", a.page(r, map[string]any{"Status": status, "Ref": ref}))
}
diff --git a/internal/meet/templates/agent_once.html b/internal/meet/templates/agent_once.html
new file mode 100644
index 0000000..3425450
--- /dev/null
+++ b/internal/meet/templates/agent_once.html
@@ -0,0 +1,8 @@
+{{define "title"}}Your agent's token{{end}}
+{{define "content"}}
+{{template "back-nav" dict "Href" "/settings" "Label" "Settings"}}
+{{template "page-header" dict "Title" .Name}}
+<p>An agent acts on your behalf with exactly your reach, as {{.Principal}}.</p>
+<p>This is the only time this token is shown. Copy it now.</p>
+<p><input rst-input readonly value="{{.Token}}" size="80" aria-label="Agent token"></p>
+{{end}}
diff --git a/internal/meet/templates/layout.html b/internal/meet/templates/layout.html
index 333d81e..b3f019f 100644
--- a/internal/meet/templates/layout.html
+++ b/internal/meet/templates/layout.html
@@ -27,6 +27,7 @@
<div rst-shell-tail>
<div rst-shell-account>
<span>{{.Viewer.Email}}</span>
+{{if .Viewer.Member}}<a href="/settings">Settings</a>{{end}}
<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
</div>
</div>
diff --git a/internal/meet/templates/settings.html b/internal/meet/templates/settings.html
new file mode 100644
index 0000000..885b417
--- /dev/null
+++ b/internal/meet/templates/settings.html
@@ -0,0 +1,32 @@
+{{define "title"}}Settings{{end}}
+{{define "content"}}
+{{template "page-header" dict "Title" "Settings"}}
+<section>
+<h2>Agents</h2>
+{{if .Agents}}
+<div rst-list>
+{{range .Agents}}
+<article>
+ <h3>{{.Name}}</h3>
+ <p>Created {{.Created}}</p>
+ {{if .Revoked}}
+ <p>{{template "status-pill" dict "Label" "Revoked"}}</p>
+ {{else}}
+ <p>{{.Principal}}</p>
+ <p><a href="/settings/agents/{{.ID}}/revoke">Revoke</a></p>
+ {{end}}
+</article>
+{{end}}
+</div>
+{{else}}
+{{template "empty-state" dict "Body" "No agents yet."}}
+{{end}}
+</section>
+<section>
+<h2>Create an agent</h2>
+<form rst-form method="post" action="/settings/agents">
+ {{template "field" dict "ID" "name" "Name" "name" "Label" "Name" "Required" true "Maxlength" "64" "Error" .NameError "Value" .Name "Autofocus" true}}
+ <div rst-form-actions><button type="submit" rst-btn="primary lg">Create an agent</button></div>
+</form>
+</section>
+{{end}}
diff --git a/internal/meettest/agents_test.go b/internal/meettest/agents_test.go
new file mode 100644
index 0000000..732bb0e
--- /dev/null
+++ b/internal/meettest/agents_test.go
@@ -0,0 +1,276 @@
+package meettest
+
+import (
+ "bytes"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ meet "amadan.net/oficina/meet/internal/meet"
+)
+
+var (
+ agentTokenRe = regexp.MustCompile(`mt_[A-Za-z0-9_-]+`)
+ revokeHrefRe = regexp.MustCompile(`/settings/agents/(\d+)/revoke`)
+)
+
+func extractToken(t *testing.T, body string) string {
+ t.Helper()
+ m := agentTokenRe.FindString(body)
+ if m == "" {
+ t.Fatalf("no mt_ token on the page:\n%s", body)
+ }
+ return m
+}
+
+// agentRevokeID reads the id off the first Revoke link the settings
+// page renders — the same way a browser has no other route to it,
+// since the id is never shown as a bare number anywhere on the page.
+func agentRevokeID(t *testing.T, body string) string {
+ t.Helper()
+ m := revokeHrefRe.FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("no revoke link on the settings page:\n%s", body)
+ }
+ return m[1]
+}
+
+func TestSettingsPageIsEmptyForAMemberWithNoAgents(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.get("/settings")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /settings: %d\n%s", rec.Code, rec.Body.String())
+ }
+ body := rec.Body.String()
+ if !strings.Contains(body, "No agents yet.") {
+ t.Fatalf("empty settings page lacks its empty-state sentence:\n%s", body)
+ }
+ if strings.Contains(body, "mt_") {
+ t.Fatalf("empty settings page contains a token-shaped string:\n%s", body)
+ }
+}
+
+// Spec §8.1: the token exists in plaintext only on the page that
+// mints it. A second GET of Settings must show the same agent by its
+// principal — the identity a meeting's creator field would attribute
+// to it — and never the token.
+func TestCreatingAnAgentShowsTheTokenOnceWithThePrincipal(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+
+ rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST /settings/agents: %d\n%s", rec.Code, rec.Body.String())
+ }
+ mintBody := rec.Body.String()
+ extractToken(t, mintBody)
+ if !strings.Contains(mintBody, "scheduler~paul@example.com") {
+ t.Fatalf("mint page lacks the principal:\n%s", mintBody)
+ }
+
+ token := extractToken(t, mintBody)
+ list := c.get("/settings")
+ if list.Code != http.StatusOK {
+ t.Fatalf("GET /settings: %d", list.Code)
+ }
+ listBody := list.Body.String()
+ if strings.Contains(listBody, token) {
+ t.Fatalf("the settings list shows the token:\n%s", listBody)
+ }
+ if !strings.Contains(listBody, "scheduler~paul@example.com") {
+ t.Fatalf("the settings list lacks the principal for a live agent:\n%s", listBody)
+ }
+ if !strings.Contains(listBody, "Revoke") {
+ t.Fatalf("a live agent has no Revoke link:\n%s", listBody)
+ }
+}
+
+func TestAnInvalidAgentNameIsRefusedWithASentenceAndMintsNothing(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+
+ rec := c.post("/settings/agents", url.Values{"name": {"has a space"}})
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("invalid name: %d\n%s", rec.Code, rec.Body.String())
+ }
+ if strings.Contains(rec.Body.String(), "mt_") {
+ t.Fatalf("a refused create still minted a token:\n%s", rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "letters, digits, - or _") {
+ t.Fatalf("no name-rule sentence on the refusal:\n%s", rec.Body.String())
+ }
+ if strings.Contains(c.get("/settings").Body.String(), "Revoke") {
+ t.Fatal("a refused create nonetheless left an agent behind")
+ }
+}
+
+// The confirm page and the POST both work for the operator: the same
+// shape as replacing a link or ending a meeting (handlers_meetings.go).
+func TestRevokeConfirmsAndRevokes(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ c.post("/settings/agents", url.Values{"name": {"scheduler"}})
+ id := agentRevokeID(t, c.get("/settings").Body.String())
+
+ confirm := c.get("/settings/agents/" + id + "/revoke")
+ if confirm.Code != http.StatusOK {
+ t.Fatalf("GET confirm: %d\n%s", confirm.Code, confirm.Body.String())
+ }
+ confirmBody := confirm.Body.String()
+ if !strings.Contains(confirmBody, "Revoke scheduler?") {
+ t.Fatalf("confirm page lacks its question:\n%s", confirmBody)
+ }
+ if !strings.Contains(confirmBody, "Anything using its token stops working at once.") {
+ t.Fatalf("confirm page lacks its consequence sentence:\n%s", confirmBody)
+ }
+
+ post := c.post("/settings/agents/"+id+"/revoke", nil)
+ if post.Code != http.StatusSeeOther || post.Header().Get("Location") != "/settings" {
+ t.Fatalf("POST revoke: %d %s", post.Code, post.Header().Get("Location"))
+ }
+
+ after := c.get("/settings").Body.String()
+ if !strings.Contains(after, "Revoked") {
+ t.Fatalf("settings does not show the agent as revoked:\n%s", after)
+ }
+ if strings.Contains(after, "/settings/agents/"+id+"/revoke") {
+ t.Fatalf("a revoked agent still carries a Revoke link:\n%s", after)
+ }
+}
+
+// A foreign or unknown agent id 404s — never a 403 — for both the
+// confirm page and the POST.
+func TestForeignOrUnknownAgentIDIs404(t *testing.T) {
+ x := newHarness(t, "paul@example.com", "bea@rslw.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ c.post("/settings/agents", url.Values{"name": {"scheduler"}})
+ id := agentRevokeID(t, c.get("/settings").Body.String())
+
+ bea := x.browser(t)
+ bea.signIn("bea@rslw.com")
+ if rec := bea.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound {
+ t.Fatalf("another member's confirm GET: %d", rec.Code)
+ }
+ if rec := bea.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound {
+ t.Fatalf("another member's revoke POST: %d", rec.Code)
+ }
+ // The agent the operator actually owns must be unaffected by the
+ // foreign attempt above.
+ if strings.Contains(bea.get("/settings").Body.String(), "scheduler") {
+ t.Fatal("bea's own settings page somehow shows paul's agent")
+ }
+
+ if rec := c.get("/settings/agents/999999/revoke"); rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown id confirm GET: %d", rec.Code)
+ }
+ if rec := c.post("/settings/agents/999999/revoke", nil); rec.Code != http.StatusNotFound {
+ t.Fatalf("unknown id revoke POST: %d", rec.Code)
+ }
+}
+
+// A guest — signed in through a live meeting link, never on the team —
+// gets the same 404 from every settings route: requireMember's own
+// refusal (spec §2.5).
+func TestGuestCannotReachSettingsOrRevoke(t *testing.T) {
+ x, _, tok := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ c.post("/settings/agents", url.Values{"name": {"scheduler"}})
+ id := agentRevokeID(t, c.get("/settings").Body.String())
+
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("guest@example.com")
+
+ if rec := g.get("/settings"); rec.Code != http.StatusNotFound {
+ t.Fatalf("guest GET /settings: %d", rec.Code)
+ }
+ if rec := g.post("/settings/agents", url.Values{"name": {"x"}}); rec.Code != http.StatusNotFound {
+ t.Fatalf("guest POST /settings/agents: %d", rec.Code)
+ }
+ if rec := g.get("/settings/agents/" + id + "/revoke"); rec.Code != http.StatusNotFound {
+ t.Fatalf("guest confirm GET: %d", rec.Code)
+ }
+ if rec := g.post("/settings/agents/"+id+"/revoke", nil); rec.Code != http.StatusNotFound {
+ t.Fatalf("guest revoke POST: %d", rec.Code)
+ }
+}
+
+// A member never sees the Settings link before signing in as one, and a
+// guest's shell (a live meeting they were let into) does not carry it
+// either — the link belongs where sign-out lives, for members only.
+func TestSettingsLinkIsShownOnlyToAMember(t *testing.T) {
+ x, sqid, tok := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ if rec := c.get("/"); !strings.Contains(rec.Body.String(), `href="/settings"`) {
+ t.Fatalf("a member's shell lacks the Settings link:\n%s", rec.Body.String())
+ }
+
+ g := x.browser(t)
+ g.get("/j/" + tok)
+ g.signIn("guest@example.com")
+ if rec := g.get("/m/" + sqid); strings.Contains(rec.Body.String(), `href="/settings"`) {
+ t.Fatalf("a guest's shell carries the Settings link:\n%s", rec.Body.String())
+ }
+}
+
+// The reviewer's cap (Task 1 addendum): at most 20 live agents per
+// operator. The 21st create is refused with a sentence and mints
+// nothing; the 20 already made are untouched.
+func TestCreateIsRefusedAtTwentyLiveAgents(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ for i := 0; i < 20; i++ {
+ name := fmt.Sprintf("agent%d", i)
+ rec := c.post("/settings/agents", url.Values{"name": {name}})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("create agent %d: %d\n%s", i, rec.Code, rec.Body.String())
+ }
+ }
+ rec := c.post("/settings/agents", url.Values{"name": {"one-too-many"}})
+ if rec.Code != http.StatusUnprocessableEntity {
+ t.Fatalf("the 21st create: %d\n%s", rec.Code, rec.Body.String())
+ }
+ if !strings.Contains(rec.Body.String(), "20 live agents") {
+ t.Fatalf("no cap sentence on the refusal:\n%s", rec.Body.String())
+ }
+ list := c.get("/settings").Body.String()
+ if strings.Contains(list, "one-too-many") {
+ t.Fatal("the refused 21st agent was created anyway")
+ }
+ if got := strings.Count(list, "Revoke"); got != 20 {
+ t.Fatalf("expected exactly 20 live agents, found %d Revoke links:\n%s", got, list)
+ }
+}
+
+// spec §0.1/§4.4's rule, applied to the mint token: nothing an app logs
+// may ever carry it.
+func TestMintedAgentTokenNeverAppearsInTheLog(t *testing.T) {
+ var buf bytes.Buffer
+ x := newHarnessWithLogger(t, meet.EnvDevelopment,
+ slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})),
+ "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+
+ rec := c.post("/settings/agents", url.Values{"name": {"scheduler"}})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("create agent: %d\n%s", rec.Code, rec.Body.String())
+ }
+ token := extractToken(t, rec.Body.String())
+ if strings.Contains(buf.String(), token) {
+ t.Fatalf("the log contains the minted token:\n%s", buf.String())
+ }
+}