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
12 files changed,
+618
−55
docs/superpowers/copy/2026-09-10-slice-2-strings.md+29 −0internal/meet/app.go+4 −0internal/meet/auth.go+5 −5internal/meet/handlers_join.go+6 −6internal/meet/handlers_meetings.go+21 −13internal/meet/handlers_room.go+23 −23internal/meet/handlers_settings.go+196 −0internal/meet/render.go+17 −8internal/meet/templates/agent_once.html+8 −0internal/meet/templates/layout.html+1 −0internal/meet/templates/settings.html+32 −0internal/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 *<name>*~*<your address>*. | `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 *<name>*? Anything using its token stops working at once. | `handlers_settings.go`'s `confirmRevokeAgent`, split as `confirm.html`'s `Title` ("Revoke *<name>*?") and `Body` ("Anything using its token stops working at once.") | Same two-part shape as "End the meeting?" / "'*<title>*' 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()) |
| + } |
| +} |